finish migrating from Supabase to local Postgress, and passport.js for auth because CORS

This commit is contained in:
2025-11-19 19:51:18 -08:00
parent b330ce4cd1
commit a62af10ce6
62 changed files with 1461 additions and 7157 deletions

View File

@@ -66,6 +66,13 @@ jobs:
- name: Run Unit Tests
run: npm test # Run the test suite to ensure code correctness.
- name: Archive Code Coverage Report
# This action saves the generated HTML coverage report as a downloadable artifact.
uses: actions/upload-artifact@v3
with:
name: code-coverage-report
path: coverage/
# --- Backend Deployment ---
- name: Deploy Supabase Edge Functions
# Pass the access token as an environment variable directly to this step

28
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"bcrypt": "^6.0.0",
"cookie-parser": "^1.4.7",
"express": "^5.1.0",
"express-rate-limit": "^8.2.1",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2",
"passport": "^0.7.0",
@@ -5325,6 +5326,24 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
"license": "MIT",
"dependencies": {
"ip-address": "10.0.1"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/express/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
@@ -6214,6 +6233,15 @@
"node": ">=12"
}
},
"node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",

View File

@@ -17,6 +17,7 @@
"bcrypt": "^6.0.0",
"cookie-parser": "^1.4.7",
"express": "^5.1.0",
"express-rate-limit": "^8.2.1",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2",
"passport": "^0.7.0",

231
server.ts
View File

@@ -5,6 +5,7 @@ import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt';
import bcrypt from 'bcrypt';
import zxcvbn from 'zxcvbn';
import jwt from 'jsonwebtoken';
import rateLimit from 'express-rate-limit';
import dotenv from 'dotenv';
import cookieParser from 'cookie-parser';
import crypto from 'crypto';
@@ -21,6 +22,37 @@ const app = express();
app.use(express.json()); // Middleware to parse JSON request bodies
app.use(cookieParser()); // Middleware to parse cookies
app.use(passport.initialize()); // Initialize Passport
// --- Logging Middleware ---
const getDurationInMilliseconds = (start: [number, number]): number => {
const NS_PER_SEC = 1e9;
const NS_TO_MS = 1e6;
const diff = process.hrtime(start);
return (diff[0] * NS_PER_SEC + diff[1]) / NS_TO_MS;
};
const requestLogger = (req: Request, res: Response, next: NextFunction) => {
const start = process.hrtime();
const { method, originalUrl } = req;
res.on('finish', () => {
const user = req.user as { id?: string } | undefined;
const durationInMilliseconds = getDurationInMilliseconds(start);
const { statusCode } = res;
const userIdentifier = user?.id ? ` (User: ${user.id})` : '';
const logMessage = `${method} ${originalUrl} ${statusCode} ${durationInMilliseconds.toFixed(2)}ms${userIdentifier}`;
if (statusCode >= 500) logger.error(logMessage);
else if (statusCode >= 400) logger.warn(logMessage);
else logger.info(logMessage);
});
next();
};
app.use(requestLogger); // Use the logging middleware for all requests
// --- Configuration ---
// IMPORTANT: Use a strong, randomly generated secret key and store it securely
@@ -44,6 +76,26 @@ const storage = multer.diskStorage({
});
const upload = multer({ storage: storage });
// --- Rate Limiting Configuration ---
// Rate limiter for forgot-password requests to prevent email spam.
// This limits a single IP address to 5 requests every 15 minutes.
const forgotPasswordLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5,
message: 'Too many password reset requests from this IP, please try again after 15 minutes.',
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
});
// Rate limiter for reset-password attempts to prevent token brute-forcing.
const resetPasswordLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // A slightly higher limit as it requires a token
message: 'Too many password reset attempts from this IP, please try again after 15 minutes.',
standardHeaders: true,
legacyHeaders: false,
});
// --- Passport Local Strategy (for email/password login) ---
passport.use(new LocalStrategy(
{ usernameField: 'email' }, // Tell Passport to expect 'email' instead of 'username'
@@ -231,6 +283,72 @@ app.post('/api/flyer-items/batch-count', async (req: Request, res: Response, nex
next(error);
}
});
app.get('/api/recipes/by-sale-percentage', async (req: Request, res: Response, next: NextFunction) => {
const minPercentageStr = req.query.minPercentage as string || '50.0';
const minPercentage = parseFloat(minPercentageStr);
if (isNaN(minPercentage) || minPercentage < 0 || minPercentage > 100) {
return res.status(400).json({ message: 'Query parameter "minPercentage" must be a number between 0 and 100.' });
}
try {
const recipes = await db.getRecipesBySalePercentage(minPercentage);
res.json(recipes);
} catch (error) {
next(error);
}
});
app.get('/api/recipes/by-sale-ingredients', async (req: Request, res: Response, next: NextFunction) => {
const minIngredientsStr = req.query.minIngredients as string || '3';
const minIngredients = parseInt(minIngredientsStr, 10);
if (isNaN(minIngredients) || minIngredients < 1) {
return res.status(400).json({ message: 'Query parameter "minIngredients" must be a positive integer.' });
}
try {
const recipes = await db.getRecipesByMinSaleIngredients(minIngredients);
res.json(recipes);
} catch (error) {
next(error);
}
});
app.get('/api/recipes/by-ingredient-and-tag', async (req: Request, res: Response, next: NextFunction) => {
const { ingredient, tag } = req.query;
if (!ingredient || !tag) {
return res.status(400).json({ message: 'Both "ingredient" and "tag" query parameters are required.' });
}
try {
const recipes = await db.findRecipesByIngredientAndTag(ingredient as string, tag as string);
res.json(recipes);
} catch (error) {
next(error);
}
});
app.get('/api/stats/most-frequent-sales', async (req: Request, res: Response, next: NextFunction) => {
const daysStr = req.query.days as string || '30';
const limitStr = req.query.limit as string || '10';
const days = parseInt(daysStr, 10);
const limit = parseInt(limitStr, 10);
if (isNaN(days) || days < 1 || days > 365) {
return res.status(400).json({ message: 'Query parameter "days" must be an integer between 1 and 365.' });
}
if (isNaN(limit) || limit < 1 || limit > 50) {
return res.status(400).json({ message: 'Query parameter "limit" must be an integer between 1 and 50.' });
}
try {
const items = await db.getMostFrequentSaleItems(days, limit);
res.json(items);
} catch (error) {
next(error);
}
});
// --- Flyer Processing Route ---
app.post('/api/flyers/process', upload.single('flyerImage'), async (req: Request, res: Response, next: NextFunction) => {
@@ -323,6 +441,83 @@ app.delete('/api/watched-items/:masterItemId', passport.authenticate('jwt', { se
}
});
// --- Authenticated User Routes ---
app.get('/api/users/pantry-recipes', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => {
const user = req.user as { id: string };
try {
const recipes = await db.findRecipesFromPantry(user.id);
res.json(recipes);
} catch (error) {
next(error);
}
});
app.get('/api/users/recommended-recipes', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => {
const user = req.user as { id: string };
const limitStr = req.query.limit as string || '10';
const limit = parseInt(limitStr, 10);
if (isNaN(limit) || limit < 1 || limit > 50) {
return res.status(400).json({ message: 'Query parameter "limit" must be an integer between 1 and 50.' });
}
try {
const recipes = await db.recommendRecipesForUser(user.id, limit);
res.json(recipes);
} catch (error) {
next(error);
}
});
app.get('/api/users/best-sale-prices', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => {
const user = req.user as { id: string };
try {
const deals = await db.getBestSalePricesForUser(user.id);
res.json(deals);
} catch (error) {
next(error);
}
});
app.get('/api/pantry-items/:id/conversions', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => {
const pantryItemId = parseInt(req.params.id, 10);
// TODO: Add an ownership check to ensure the user owns this pantry item.
try {
const conversions = await db.suggestPantryItemConversions(pantryItemId);
res.json(conversions);
} catch (error) {
next(error);
}
});
app.get('/api/menu-plans/:id/shopping-list', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => {
const user = req.user as { id: string };
const menuPlanId = parseInt(req.params.id, 10);
try {
const shoppingListItems = await db.generateShoppingListForMenuPlan(menuPlanId, user.id);
res.json(shoppingListItems);
} catch (error) {
next(error);
}
});
app.post('/api/shopping-lists/:listId/add-from-menu-plan', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => {
const user = req.user as { id: string };
const shoppingListId = parseInt(req.params.listId, 10);
const { menuPlanId } = req.body;
if (!menuPlanId) {
return res.status(400).json({ message: 'menuPlanId is required in the request body.' });
}
try {
const addedItems = await db.addMenuPlanToShoppingList(menuPlanId, shoppingListId, user.id);
res.status(201).json(addedItems);
} catch (error) {
next(error);
}
});
// --- Price History Route (Protected) ---
app.post('/api/price-history', passport.authenticate('jwt', { session: false }), async (req: Request, res: Response, next: NextFunction) => {
@@ -409,7 +604,7 @@ app.delete('/api/shopping-lists/items/:itemId', passport.authenticate('jwt', { s
// Registration Route
app.post('/api/auth/register', async (req: Request, res: Response, next: NextFunction) => {
const { email, password } = req.body;
const { email, password, full_name, avatar_url } = req.body;
if (!email || !password) {
return res.status(400).json({ message: 'Email and password are required.' });
@@ -437,7 +632,8 @@ app.post('/api/auth/register', async (req: Request, res: Response, next: NextFun
const hashedPassword = await bcrypt.hash(password, saltRounds);
logger.info(`Hashing password for new user: ${email}`);
const newUser = await db.createUser(email, hashedPassword);
// Pass the extra profile data to the createUser function
const newUser = await db.createUser(email, hashedPassword, { full_name, avatar_url });
logger.info(`Successfully created new user in DB: ${newUser.email} (ID: ${newUser.id})`);
// Immediately log in the user by issuing a JWT
@@ -466,6 +662,7 @@ app.post('/api/auth/login', (req: Request, res: Response, next: NextFunction) =>
// Use passport.authenticate with the 'local' strategy
// { session: false } because we're using JWTs, not server-side sessions
passport.authenticate('local', { session: false }, (err: Error, user: Express.User | false, info: { message: string }) => {
const { rememberMe } = req.body; // Get the 'rememberMe' flag from the request
if (err) {
logger.error('Login authentication error in /login route:', { error: err });
return next(err); // Pass server errors to the error handler
@@ -486,12 +683,15 @@ app.post('/api/auth/login', (req: Request, res: Response, next: NextFunction) =>
db.saveRefreshToken(typedUser.id, refreshToken).then(() => {
logger.info(`JWT and refresh token issued for user: ${typedUser.email}`);
// Send the refresh token in a secure, HttpOnly cookie
res.cookie('refreshToken', refreshToken, {
// Set cookie options based on the "Remember Me" flag
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
maxAge: rememberMe ? 30 * 24 * 60 * 60 * 1000 : undefined // 30 days if remembered, otherwise session cookie
};
// Send the refresh token in a secure, HttpOnly cookie
res.cookie('refreshToken', refreshToken, cookieOptions);
return res.json({ user: payload, token: accessToken });
}).catch(tokenErr => {
@@ -502,7 +702,7 @@ app.post('/api/auth/login', (req: Request, res: Response, next: NextFunction) =>
});
// Route to request a password reset
app.post('/api/auth/forgot-password', async (req: Request, res: Response, next: NextFunction) => {
app.post('/api/auth/forgot-password', forgotPasswordLimiter, async (req: Request, res: Response, next: NextFunction) => {
const { email } = req.body;
if (!email) {
return res.status(400).json({ message: 'Email is required.' });
@@ -546,7 +746,7 @@ app.post('/api/auth/forgot-password', async (req: Request, res: Response, next:
});
// Route to reset the password using a token
app.post('/api/auth/reset-password', async (req: Request, res: Response, next: NextFunction) => {
app.post('/api/auth/reset-password', resetPasswordLimiter, async (req: Request, res: Response, next: NextFunction) => {
const { token, newPassword } = req.body;
if (!token || !newPassword) {
return res.status(400).json({ message: 'Token and new password are required.' });
@@ -568,8 +768,21 @@ app.post('/api/auth/reset-password', async (req: Request, res: Response, next: N
return res.status(400).json({ message: 'Invalid or expired password reset token.' });
}
// --- Password Strength Check ---
const MIN_PASSWORD_SCORE = 3;
const strength = zxcvbn(newPassword);
if (strength.score < MIN_PASSWORD_SCORE) {
logger.warn(`Weak password rejected during password reset for user ID: ${tokenRecord.user_id}. Score: ${strength.score}`);
const feedback = strength.feedback.warning || (strength.feedback.suggestions && strength.feedback.suggestions[0]);
return res.status(400).json({ message: `New password is too weak. ${feedback || 'Please choose a stronger password.'}`.trim() });
}
// Hash the new password before storing it
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(newPassword, saltRounds);
// If token is valid, proceed with password update and strength check
await db.updateUserPassword(tokenRecord.user_id, newPassword); // The password will be hashed inside this function
await db.updateUserPassword(tokenRecord.user_id, hashedPassword);
await db.deleteResetToken(tokenRecord.token_hash); // Invalidate the token after use
res.status(200).json({ message: 'Password has been reset successfully.' });

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,39 +0,0 @@
/*
-- This script is used to completely reset the public schema by deleting all tables.
-- It should be run before re-running the schema.sql.txt script to ensure a clean state.
-- The CASCADE option is used to automatically handle dependent objects like foreign keys.
-- The order is roughly the reverse of creation to minimize dependency issues.
*/
DROP TABLE IF EXISTS public.pantry_items CASCADE;
DROP TABLE IF EXISTS public.planned_meals CASCADE;
DROP TABLE IF EXISTS public.menu_plans CASCADE;
DROP TABLE IF EXISTS public.recipe_ratings CASCADE;
DROP TABLE IF EXISTS public.recipe_tags CASCADE;
DROP TABLE IF EXISTS public.tags CASCADE;
DROP TABLE IF EXISTS public.recipe_ingredients CASCADE;
DROP TABLE IF EXISTS public.recipes CASCADE;
DROP TABLE IF EXISTS public.flyer_locations CASCADE;
DROP TABLE IF EXISTS public.store_locations CASCADE;
DROP TABLE IF EXISTS public.user_submitted_prices CASCADE;
DROP TABLE IF EXISTS public.suggested_corrections CASCADE;
DROP TABLE IF EXISTS public.shopping_list_items CASCADE;
DROP TABLE IF EXISTS public.shopping_lists CASCADE;
DROP TABLE IF EXISTS public.notifications CASCADE;
DROP TABLE IF EXISTS public.user_alerts CASCADE;
DROP TABLE IF EXISTS public.user_watched_items CASCADE;
DROP TABLE IF EXISTS public.master_item_aliases CASCADE;
DROP TABLE IF EXISTS public.item_price_history CASCADE;
DROP TABLE IF EXISTS public.flyer_items CASCADE;
DROP TABLE IF EXISTS public.products CASCADE;
DROP TABLE IF EXISTS public.brands CASCADE;
DROP TABLE IF EXISTS public.flyers CASCADE;
DROP TABLE IF EXISTS public.master_grocery_items CASCADE;
DROP TABLE IF EXISTS public.stores CASCADE;
DROP TABLE IF EXISTS public.categories CASCADE;
DROP TABLE IF EXISTS public.profiles CASCADE;
DROP TABLE IF EXISTS public.users CASCADE;
/*
-- The delete_all_tables.sql.txt script does not and cannot remove the auth.users table - Go to your Supabase Project Dashboard -> Authentication -> Users.
*/

View File

@@ -1,15 +1,36 @@
/*
-- This script is used to completely reset the public schema by deleting all tables.
-- It should be run before re-running the schema.sql.txt script to ensure a clean state.
-- The CASCADE option is used to automatically handle dependent objects like foreign keys.
-- The order is roughly the reverse of creation to minimize dependency issues.
*/
DROP TABLE IF EXISTS public.pantry_items CASCADE;
DROP TABLE IF EXISTS public.planned_meals CASCADE;
DROP TABLE IF EXISTS public.menu_plans CASCADE;
DROP TABLE IF EXISTS public.recipe_ratings CASCADE;
DROP TABLE IF EXISTS public.recipe_tags CASCADE;
DROP TABLE IF EXISTS public.tags CASCADE;
DROP TABLE IF EXISTS public.recipe_ingredients CASCADE;
DROP TABLE IF EXISTS public.recipes CASCADE;
DROP TABLE IF EXISTS public.flyer_locations CASCADE;
DROP TABLE IF EXISTS public.store_locations CASCADE;
DROP TABLE IF EXISTS public.user_submitted_prices CASCADE;
DROP TABLE IF EXISTS public.suggested_corrections CASCADE;
DROP TABLE IF EXISTS public.shopping_list_items CASCADE;
DROP TABLE IF EXISTS public.shopping_lists CASCADE;
DROP TABLE IF EXISTS public.master_item_aliases CASCADE;
DROP TABLE IF EXISTS public.item_price_history CASCADE;
DROP TABLE IF EXISTS public.notifications CASCADE;
DROP TABLE IF EXISTS public.user_alerts CASCADE;
DROP TABLE IF EXISTS public.flyer_items CASCADE;
DROP TABLE IF EXISTS public.user_watched_items CASCADE;
DROP TABLE IF EXISTS public.master_item_aliases CASCADE;
DROP TABLE IF EXISTS public.item_price_history CASCADE;
DROP TABLE IF EXISTS public.flyer_items CASCADE;
DROP TABLE IF EXISTS public.products CASCADE;
DROP TABLE IF EXISTS public.brands CASCADE;
DROP TABLE IF EXISTS public.flyers CASCADE;
DROP TABLE IF EXISTS public.master_grocery_items CASCADE;
DROP TABLE IF EXISTS public.stores CASCADE;
DROP TABLE IF EXISTS public.categories CASCADE;
DROP TABLE IF EXISTS public.profiles CASCADE;
DROP TABLE IF EXISTS public.profiles CASCADE;
DROP TABLE IF EXISTS public.password_reset_tokens CASCADE;
DROP TABLE IF EXISTS public.users CASCADE;

View File

@@ -1,79 +0,0 @@
-- ============================================================================
-- PERMISSION FIX & VERIFICATION SCRIPT
-- ============================================================================
-- Purpose:
-- This script first resets the default privileges on the public schema to fix
-- "permission denied" errors. It then runs a series of tests to VERIFY that
-- the permissions have been correctly applied, providing clear feedback.
--
-- Usage:
-- RUN THIS SCRIPT FIRST. If it completes successfully, you can then run
-- the main schema.sql.txt script.
-- ============================================================================
-- STEP 1: APPLY PERMISSION FIXES
-- ============================================================================
-- Grant usage on the schema to the key roles.
GRANT USAGE ON SCHEMA public TO postgres, anon, authenticated, service_role;
-- Grant ALL privileges on EXISTING tables, sequences, and functions to the key roles.
-- This is a "catch-all" for any existing objects that may have wrong permissions.
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO postgres, anon, authenticated, service_role;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO postgres, anon, authenticated, service_role;
GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO postgres, anon, authenticated, service_role;
-- Grant ALL privileges on FUTURE tables, sequences, and functions to the key roles.
-- This is the most important part: it ensures new objects get the right permissions.
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO postgres, anon, authenticated, service_role;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres, anon, authenticated, service_role;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres, anon, authenticated, service_role;
-- ============================================================================
-- STEP 2: VERIFY PERMISSIONS HAVE BEEN APPLIED
-- ============================================================================
-- This block performs live tests to confirm the fixes worked. If any test
-- fails, it will raise an error and stop the script.
DO $$
BEGIN
RAISE NOTICE '--- Starting Permission Verification Tests ---';
-- TEST 1: Check if the 'postgres' user (you) can CREATE in the public schema.
-- This directly tests the original "permission denied" error.
IF NOT has_schema_privilege('postgres', 'public', 'CREATE') THEN
RAISE EXCEPTION 'TEST FAILED: The "postgres" role still does not have CREATE permission on the public schema. The script cannot proceed.';
END IF;
RAISE NOTICE '✅ TEST PASSED: "postgres" role has CREATE permission on public schema.';
-- TEST 2: Create a temporary table to check default privileges.
-- This confirms that the ALTER DEFAULT PRIVILEGES command worked.
CREATE TABLE public.permission_test_table (id int);
RAISE NOTICE ' -> Created temporary table "permission_test_table".';
-- TEST 3: Check if the 'authenticated' role has full rights on the NEW table.
-- This is crucial for your application's logged-in users.
IF NOT has_table_privilege('authenticated', 'public.permission_test_table', 'SELECT, INSERT, UPDATE, DELETE') THEN
DROP TABLE public.permission_test_table;
RAISE EXCEPTION 'TEST FAILED: The "authenticated" role did not automatically get full permissions on a newly created table. Default privileges are incorrect.';
END IF;
RAISE NOTICE '✅ TEST PASSED: "authenticated" role has full CRUD permissions on the new test table.';
-- TEST 4: Check if the 'anon' role also has full rights on the NEW table.
-- This is important for RLS policies that might allow anonymous access for specific queries.
IF NOT has_table_privilege('anon', 'public.permission_test_table', 'SELECT, INSERT, UPDATE, DELETE') THEN
DROP TABLE public.permission_test_table;
RAISE EXCEPTION 'TEST FAILED: The "anon" role did not automatically get full permissions on a newly created table. Default privileges are incorrect.';
END IF;
RAISE NOTICE '✅ TEST PASSED: "anon" role has full CRUD permissions on the new test table.';
-- Cleanup: Drop the temporary table.
DROP TABLE public.permission_test_table;
RAISE NOTICE ' -> Cleaned up temporary table.';
RAISE NOTICE '--- ALL PERMISSION TESTS PASSED SUCCESSFULLY ---';
RAISE NOTICE 'You should now be able to run the main schema.sql.txt script without permission errors.';
END;
$$;

View File

@@ -1,24 +1,7 @@
-- 19. Policies for the 'flyers' storage bucket
DROP POLICY IF EXISTS "Allow public access to flyers storage" ON storage.objects;
CREATE POLICY "Allow public access to flyers storage" ON storage.objects
-- Allow public read access to flyers
FOR SELECT TO public USING (bucket_id = 'flyers');
-- Allow authenticated users to upload/manage their own flyers (if applicable, needs user_id association)
-- For now, restricting write access to authenticated users. If specific roles are needed, adjust TO authenticated.
CREATE POLICY "Allow authenticated users to manage flyers" ON storage.objects
FOR INSERT, UPDATE, DELETE TO authenticated USING (bucket_id = 'flyers');
-- 20. Set up the trigger to automatically create a profile when a new user signs up.
-- NOTE: Trigger definitions have been moved to `triggers.sql`.
-- 21. Create a reusable function to automatically update 'updated_at' columns.
-- NOTE: Trigger definitions have been moved to `triggers.sql`.
-- 22. Function to find the best current sale price for a user's watched items.
-- Function to find the best current sale price for a user's watched items.
-- This function queries all currently active flyers to find the lowest price
-- for each item on a specific user's watchlist.
CREATE OR REPLACE FUNCTION public.get_best_sale_prices_for_user(p_user_id UUID)
RETURNS TABLE (
master_item_id BIGINT,
@@ -31,11 +14,12 @@ RETURNS TABLE (
flyer_valid_to DATE
)
LANGUAGE plpgsql
SECURITY INVOKER -- Runs with the privileges of the calling user. RLS policies will apply.
SECURITY INVOKER -- Runs with the privileges of the calling user.
AS $$
BEGIN
RETURN QUERY
WITH UserWatchedSales AS (
-- This CTE gathers all sales from active flyers that match the user's watched items.
SELECT
uwi.master_item_id,
mgi.name AS item_name,
@@ -45,6 +29,7 @@ BEGIN
f.image_url AS flyer_image_url,
f.valid_from AS flyer_valid_from,
f.valid_to AS flyer_valid_to,
-- We use ROW_NUMBER to rank sales for the same item, prioritizing the lowest price.
ROW_NUMBER() OVER (PARTITION BY uwi.master_item_id ORDER BY fi.price_in_cents ASC, f.valid_to DESC, s.name ASC) as rn
FROM
public.user_watched_items uwi
@@ -57,19 +42,17 @@ BEGIN
AND f.valid_to >= CURRENT_DATE
AND fi.price_in_cents IS NOT NULL
)
-- The final select returns only the top-ranked sale (rn = 1) for each item.
SELECT uws.master_item_id, uws.item_name, uws.price_in_cents, uws.store_name, uws.flyer_id, uws.flyer_image_url, uws.flyer_valid_from, uws.flyer_valid_to
FROM UserWatchedSales uws
WHERE uws.rn = 1;
END;
$$;
-- 23. Create a trigger function to populate the item_price_history table.
-- NOTE: Trigger definitions have been moved to `triggers.sql`.
-- 24. Create a trigger function to recalculate price history when a flyer item is deleted.
-- NOTE: Trigger definitions have been moved to `triggers.sql`.
-- 25. Function to generate a smart shopping list from a menu plan, subtracting pantry items.
-- Function to generate a smart shopping list from a menu plan, subtracting pantry items.
-- This function calculates the total ingredients needed for a user's menu plan,
-- scales them by desired servings, and then subtracts what the user already has
-- in their pantry to determine what needs to be bought.
CREATE OR REPLACE FUNCTION public.generate_shopping_list_for_menu_plan(p_menu_plan_id BIGINT, p_user_id UUID)
RETURNS TABLE (
master_item_id BIGINT,
@@ -80,18 +63,18 @@ RETURNS TABLE (
unit TEXT
)
LANGUAGE plpgsql
SECURITY INVOKER -- Runs with the privileges of the calling user. RLS policies will apply.
SECURITY INVOKER -- Runs with the privileges of the calling user.
AS $$
BEGIN
RETURN QUERY
WITH RequiredIngredients AS (
-- First, calculate the total quantity of each ingredient needed for the menu plan.
-- This now accounts for scaling the recipe based on desired servings.
-- This CTE calculates the total quantity of each ingredient needed for the menu plan.
-- It accounts for scaling the recipe based on the number of servings the user plans to cook.
SELECT
ri.master_item_id,
ri.unit,
SUM(
ri.quantity * -- The base ingredient quantity
ri.quantity * -- The base ingredient quantity from the recipe
-- Calculate the scaling factor. Default to 1 if servings_to_cook is not set.
(COALESCE(pm.servings_to_cook, r.servings)::NUMERIC / NULLIF(r.servings, 0)::NUMERIC)
) AS total_required
@@ -102,36 +85,37 @@ BEGIN
WHERE mp.id = p_menu_plan_id AND mp.user_id = p_user_id
GROUP BY ri.master_item_id, ri.unit
)
-- Now, compare the required ingredients with the user's pantry.
-- This final select compares the required ingredients with the user's pantry.
SELECT
req.master_item_id,
mgi.name AS item_name,
req.total_required AS required_quantity,
COALESCE(pi.quantity, 0) AS pantry_quantity,
-- Calculate the amount to buy. If pantry has enough, this will be 0.
-- Calculate the amount to buy. If pantry has enough, this will be 0 or less, so GREATEST(0, ...) ensures we don't get negative values.
GREATEST(0, req.total_required - COALESCE(pi.quantity, 0)) AS shopping_list_quantity,
req.unit
FROM RequiredIngredients req
JOIN public.master_grocery_items mgi ON req.master_item_id = mgi.id
LEFT JOIN public.pantry_items pi
ON req.master_item_id = pi.master_item_id
AND req.unit = pi.unit -- Critical: only subtract if units match
AND req.unit = pi.unit -- Critical: only subtract if units match to avoid errors (e.g., subtracting 2 "items" from 500 "grams").
AND pi.user_id = p_user_id
WHERE
-- Only include items that need to be purchased.
-- Only include items that actually need to be purchased.
GREATEST(0, req.total_required - COALESCE(pi.quantity, 0)) > 0;
END;
$$;
-- 26. Function to find all recipes that can be made entirely from items currently on sale.
-- Function to find recipes based on the percentage of their ingredients that are currently on sale.
-- For example, you can ask for recipes where at least 50% of the ingredients are on sale.
CREATE OR REPLACE FUNCTION public.get_recipes_by_sale_percentage(p_min_sale_percentage NUMERIC DEFAULT 100.0)
RETURNS TABLE (recipe_details JSONB) -- The return type remains the same (JSONB object per recipe)
RETURNS TABLE (recipe_details JSONB)
LANGUAGE sql
STABLE -- Indicates the function cannot modify the database and is safe for read-only queries.
SECURITY INVOKER
AS $$
WITH BestCurrentPrices AS (
-- 1. For every item on sale, find its single best price and the store offering it.
-- CTE 1: For every distinct item on sale, find its single best price and the store offering it.
SELECT
bcp.master_item_id,
bcp.price_in_cents,
@@ -152,17 +136,18 @@ AS $$
WHERE bcp.rn = 1
),
RecipeIngredientStats AS (
-- 2. For each recipe, count its total ingredients and how many of them are on sale.
-- CTE 2: For each recipe, count its total ingredients and how many of them are on sale.
SELECT
ri.recipe_id,
COUNT(ri.master_item_id) AS total_ingredients,
COUNT(bcp.master_item_id) AS sale_ingredients
COUNT(bcp.master_item_id) AS sale_ingredients -- COUNT(column) only counts non-NULL values.
FROM public.recipe_ingredients ri
LEFT JOIN BestCurrentPrices bcp ON ri.master_item_id = bcp.master_item_id -- Join to count how many ingredients are on sale
LEFT JOIN BestCurrentPrices bcp ON ri.master_item_id = bcp.master_item_id
GROUP BY ri.recipe_id
),
EligibleRecipes AS (
-- 3. Filter recipes based on the minimum sale percentage.
-- CTE 3: Filter recipes based on the minimum sale percentage provided as an argument.
SELECT
ris.recipe_id,
ris.total_ingredients,
@@ -172,7 +157,7 @@ AS $$
AND (ris.sale_ingredients * 100.0 / ris.total_ingredients) >= p_min_sale_percentage
),
RecipeSaleDetails AS (
-- 4. Gather details for the eligible recipes and ALL their ingredients, noting which are on sale.
-- CTE 4: Gather details for the eligible recipes and ALL their ingredients, noting which are on sale.
SELECT
r.id AS recipe_id,
r.name AS recipe_name,
@@ -183,29 +168,29 @@ AS $$
JOIN EligibleRecipes er ON r.id = er.recipe_id -- Join with the filtered eligible recipes
JOIN public.recipe_ingredients ri ON r.id = ri.recipe_id
JOIN public.master_grocery_items mgi ON ri.master_item_id = mgi.id
LEFT JOIN BestCurrentPrices bcp ON ri.master_item_id = bcp.master_item_id -- LEFT JOIN to include all ingredients
LEFT JOIN BestCurrentPrices bcp ON ri.master_item_id = bcp.master_item_id -- LEFT JOIN to include all ingredients, not just sale ones.
)
-- 5. Aggregate the details into a JSON object for each recipe.
-- Final Step: Aggregate the details into a single JSON object for each recipe.
SELECT
jsonb_build_object(
'id', rsd.recipe_id,
'name', rsd.recipe_name,
-- Aggregate all ingredients for the recipe into a JSON array.
'ingredients', jsonb_agg(
jsonb_build_object(
'item_name', rsd.item_name,
'on_sale', (rsd.best_price_in_cents IS NOT NULL), -- Mark if the item is on sale
'on_sale', (rsd.best_price_in_cents IS NOT NULL),
'best_price_in_cents', rsd.best_price_in_cents,
'store_name', rsd.store_name
)
ORDER BY (rsd.best_price_in_cents IS NOT NULL) DESC, rsd.item_name ASC -- Show sale items first
ORDER BY (rsd.best_price_in_cents IS NOT NULL) DESC, rsd.item_name ASC -- Show sale items first in the list.
)
)
FROM RecipeSaleDetails rsd
GROUP BY rsd.recipe_id, rsd.recipe_name;
$$;
-- 27. Function to add items from a menu plan to a user's shopping list.
-- Function to add items generated from a menu plan directly to a user's shopping list.
-- This acts as a utility function to chain `generate_shopping_list_for_menu_plan` with an INSERT action.
CREATE OR REPLACE FUNCTION public.add_menu_plan_to_shopping_list(
p_menu_plan_id BIGINT,
p_shopping_list_id BIGINT,
@@ -217,15 +202,15 @@ RETURNS TABLE (
quantity_added NUMERIC
)
LANGUAGE plpgsql
-- SECURITY DEFINER is used here to chain functions and perform checks before modification.
-- The function internally ensures the calling user has the correct permissions.
-- SECURITY DEFINER is used here to perform actions with elevated privileges,
-- but it's safe because we first perform a strict ownership check inside the function.
SECURITY DEFINER
AS $$
DECLARE
list_owner_id UUID;
item_to_add RECORD;
BEGIN
-- Security Check: Ensure the user owns the target shopping list.
-- Security Check: Ensure the user calling this function owns the target shopping list.
SELECT user_id INTO list_owner_id
FROM public.shopping_lists
WHERE id = p_shopping_list_id;
@@ -238,7 +223,7 @@ BEGIN
FOR item_to_add IN
SELECT * FROM public.generate_shopping_list_for_menu_plan(p_menu_plan_id, p_user_id)
LOOP
-- Insert the item into the shopping list. If it already exists, update the quantity.
-- Insert the item into the shopping list. If it already exists, add to the quantity.
INSERT INTO public.shopping_list_items (shopping_list_id, master_item_id, quantity)
VALUES (p_shopping_list_id, item_to_add.master_item_id, item_to_add.shopping_list_quantity)
ON CONFLICT (shopping_list_id, master_item_id)
@@ -251,10 +236,7 @@ BEGIN
END;
$$;
-- 28. Trigger function to update the average rating on the recipes table.
-- NOTE: Trigger definitions have been moved to `triggers.sql`.
-- 29. Function to find recipes that have at least 'x' ingredients currently on sale.
-- Function to find recipes that have at least a specified number of ingredients currently on sale.
CREATE OR REPLACE FUNCTION public.get_recipes_by_min_sale_ingredients(p_min_sale_ingredients INTEGER)
RETURNS TABLE (
recipe_id BIGINT,
@@ -267,7 +249,7 @@ STABLE
SECURITY INVOKER
AS $$
WITH CurrentSaleItems AS (
-- 1. Get a distinct list of all master item IDs that are currently on sale.
-- CTE 1: Get a distinct list of all master item IDs that are currently on sale.
SELECT DISTINCT fi.master_item_id
FROM public.flyer_items fi
JOIN public.flyers f ON fi.flyer_id = f.id
@@ -276,7 +258,7 @@ AS $$
AND CURRENT_DATE BETWEEN f.valid_from AND f.valid_to
),
RecipeIngredientStats AS (
-- 2. For each recipe, count how many of its ingredients are on sale.
-- CTE 2: For each recipe, count how many of its ingredients are on the sale list.
SELECT
ri.recipe_id,
COUNT(csi.master_item_id) AS sale_ingredients_count
@@ -284,7 +266,7 @@ AS $$
LEFT JOIN CurrentSaleItems csi ON ri.master_item_id = csi.master_item_id
GROUP BY ri.recipe_id
)
-- 3. Select recipes that meet the minimum sale ingredient count.
-- Final Step: Select recipes that meet the minimum sale ingredient count and order them.
SELECT
r.id,
r.name,
@@ -298,7 +280,8 @@ AS $$
r.avg_rating DESC;
$$;
-- 30. Function to find the most frequently advertised items in a given period.
-- Function to find the most frequently advertised items in a given period.
-- This helps identify which items go on sale most often.
CREATE OR REPLACE FUNCTION public.get_most_frequent_sale_items(days_interval INTEGER, result_limit INTEGER)
RETURNS TABLE (
item_name TEXT,
@@ -310,7 +293,7 @@ SECURITY INVOKER
AS $$
SELECT
mgi.name AS item_name,
COUNT(DISTINCT fi.flyer_id) AS sale_occurrence_count
COUNT(DISTINCT fi.flyer_id) AS sale_occurrence_count -- Count distinct flyers the item appeared in
FROM
public.flyer_items fi
JOIN
@@ -330,7 +313,8 @@ AS $$
LIMIT result_limit;
$$;
-- 31. Function to find recipes by a specific ingredient and tag.
-- Function to find recipes by a specific ingredient AND a specific tag.
-- This allows for more refined recipe searching, e.g., "Find me a quick & easy recipe with chicken breast".
CREATE OR REPLACE FUNCTION public.find_recipes_by_ingredient_and_tag(p_ingredient_name TEXT, p_tag_name TEXT)
RETURNS TABLE (
id BIGINT,
@@ -349,14 +333,14 @@ AS $$
FROM
public.recipes r
WHERE
-- Check that the recipe has the required ingredient
-- Check that the recipe has the required ingredient using an EXISTS subquery.
EXISTS (
SELECT 1 FROM public.recipe_ingredients ri
JOIN public.master_grocery_items mgi ON ri.master_item_id = mgi.id
WHERE ri.recipe_id = r.id AND mgi.name = p_ingredient_name
)
AND
-- Check that the recipe has the required tag
-- Check that the recipe has the required tag using another EXISTS subquery.
EXISTS (
SELECT 1 FROM public.recipe_tags rt
JOIN public.tags t ON rt.tag_id = t.id
@@ -366,7 +350,140 @@ AS $$
r.avg_rating DESC, r.name ASC;
$$;
-- 32. Function to approve a suggested correction and apply it.
-- Function to suggest a master_item_id for a given flyer item name.
-- This function uses trigram similarity to find the best match from both the
-- master_grocery_items table and the master_item_aliases table.
CREATE OR REPLACE FUNCTION public.suggest_master_item_for_flyer_item(p_flyer_item_name TEXT)
RETURNS BIGINT
LANGUAGE plpgsql
STABLE -- This function does not modify the database.
AS $$
DECLARE
suggested_id BIGINT;
-- A similarity score between 0 and 1. A higher value means a better match.
-- This threshold can be adjusted based on observed performance. 0.4 is a reasonable starting point.
similarity_threshold REAL := 0.4;
BEGIN
WITH candidates AS (
-- Search for matches in the primary master_grocery_items table
SELECT
id AS master_item_id,
similarity(name, p_flyer_item_name) AS score
FROM public.master_grocery_items
WHERE name % p_flyer_item_name -- The '%' operator uses the trigram index for pre-filtering, making the search much faster.
UNION ALL
-- Search for matches in the master_item_aliases table
SELECT
master_item_id,
similarity(alias, p_flyer_item_name) AS score
FROM public.master_item_aliases
WHERE alias % p_flyer_item_name
)
-- Select the master_item_id with the highest similarity score, provided it's above our threshold.
SELECT master_item_id INTO suggested_id FROM candidates WHERE score >= similarity_threshold ORDER BY score DESC, master_item_id LIMIT 1;
RETURN suggested_id;
END;
$$;
-- Function to recommend recipes to a user based on their watched items and highly-rated recipes.
-- It calculates a score based on ingredient matches from the user's watchlist and similarity
-- to other recipes the user has liked.
CREATE OR REPLACE FUNCTION public.recommend_recipes_for_user(p_user_id UUID, p_limit INTEGER DEFAULT 10)
RETURNS TABLE (
recipe_id BIGINT,
recipe_name TEXT,
recipe_description TEXT,
avg_rating NUMERIC,
recommendation_score NUMERIC,
recommendation_reason TEXT
)
LANGUAGE sql
STABLE
SECURITY INVOKER
AS $$
WITH UserHighRatedRecipes AS (
-- CTE 1: Get recipes the user has rated 4 stars or higher.
SELECT rr.recipe_id, rr.rating
FROM public.recipe_ratings rr
WHERE rr.user_id = p_user_id AND rr.rating >= 4
),
UserWatchedItems AS (
-- CTE 2: Get the user's watchlist of grocery items.
SELECT uwi.master_item_id
FROM public.user_watched_items uwi
WHERE uwi.user_id = p_user_id
),
RecipeScores AS (
-- CTE 3: Calculate a score for each recipe based on two factors.
SELECT
r.id AS recipe_id,
-- Score from watched items: +5 points for each watched ingredient in the recipe.
(
SELECT 5 * COUNT(*)
FROM public.recipe_ingredients ri
WHERE ri.recipe_id = r.id AND ri.master_item_id IN (SELECT master_item_id FROM UserWatchedItems)
) AS watched_item_score,
-- Score from similarity to highly-rated recipes.
(
SELECT COALESCE(SUM(
-- +2 points for each shared ingredient with a highly-rated recipe.
(
SELECT 2 * COUNT(*)
FROM public.recipe_ingredients ri1
JOIN public.recipe_ingredients ri2 ON ri1.master_item_id = ri2.master_item_id
WHERE ri1.recipe_id = r.id AND ri2.recipe_id = uhr.recipe_id
) +
-- +3 points for each shared tag with a highly-rated recipe.
(
SELECT 3 * COUNT(*)
FROM public.recipe_tags rt1
JOIN public.recipe_tags rt2 ON rt1.tag_id = rt2.tag_id
WHERE rt1.recipe_id = r.id AND rt2.recipe_id = uhr.recipe_id
)
), 0)
FROM UserHighRatedRecipes uhr
WHERE uhr.recipe_id <> r.id -- Don't compare a recipe to itself.
) AS similarity_score
FROM public.recipes r
),
RankedRecommendations AS (
-- CTE 4: Combine scores and generate a human-readable reason for the recommendation.
SELECT
rs.recipe_id,
rs.watched_item_score + rs.similarity_score AS total_score,
-- Create a reason string based on which score is higher.
CASE
WHEN rs.watched_item_score > rs.similarity_score THEN 'Contains items from your watchlist'
WHEN rs.similarity_score > 0 THEN 'Similar to recipes you''ve liked'
ELSE 'A popular recipe you might like'
END AS reason
FROM RecipeScores rs
WHERE rs.watched_item_score + rs.similarity_score > 0
-- Exclude recipes the user has already rated to avoid recommending things they've already seen.
AND rs.recipe_id NOT IN (SELECT recipe_id FROM public.recipe_ratings WHERE user_id = p_user_id)
)
-- Final Selection: Join back to the recipes table to get full details and order by the final score.
SELECT
r.id,
r.name,
r.description,
r.avg_rating,
rr.total_score,
rr.reason
FROM RankedRecommendations rr
JOIN public.recipes r ON rr.recipe_id = r.id
ORDER BY
rr.total_score DESC,
r.avg_rating DESC, -- As a tie-breaker, prefer higher-rated recipes.
r.rating_count DESC,
r.name ASC
LIMIT p_limit;
$$;
-- Function to approve a suggested correction and apply it.
-- This is a SECURITY DEFINER function to allow an admin to update tables
-- they might not have direct RLS access to.
CREATE OR REPLACE FUNCTION public.approve_correction(p_correction_id BIGINT)
@@ -376,10 +493,8 @@ SECURITY DEFINER
AS $$
DECLARE
correction_record RECORD;
target_flyer_item RECORD;
new_master_item_id BIGINT;
BEGIN
-- 1. Fetch the correction details
-- 1. Fetch the correction details, ensuring it's still pending.
SELECT * INTO correction_record
FROM public.suggested_corrections
WHERE id = p_correction_id AND status = 'pending';
@@ -388,31 +503,105 @@ BEGIN
RAISE EXCEPTION 'Correction with ID % not found or already processed.', p_correction_id;
END IF;
-- 2. Fetch the target flyer item
SELECT * INTO target_flyer_item
FROM public.flyer_items
WHERE id = correction_record.flyer_item_id;
-- 3. Apply the correction based on its type
-- 2. Apply the correction based on its type.
IF correction_record.correction_type = 'INCORRECT_ITEM_LINK' THEN
-- The suggested_value is expected to be the ID of the correct master_grocery_item
new_master_item_id := correction_record.suggested_value::BIGINT;
UPDATE public.flyer_items
SET master_item_id = new_master_item_id
SET master_item_id = correction_record.suggested_value::BIGINT
WHERE id = correction_record.flyer_item_id;
ELSIF correction_record.correction_type = 'WRONG_PRICE' THEN
-- The suggested_value is expected to be the new price in cents
UPDATE public.flyer_items
SET price_in_cents = correction_record.suggested_value::INTEGER
WHERE id = correction_record.flyer_item_id;
END IF;
-- 4. Update the correction status to 'approved'
-- 3. Update the correction status to 'approved'.
UPDATE public.suggested_corrections
SET status = 'approved', reviewed_at = now()
WHERE id = p_correction_id;
END;
$$;
-- Function to find recipes that can be made entirely from items in a user's pantry.
-- This function checks each recipe and returns it only if every ingredient is present
-- in the specified user's pantry.
CREATE OR REPLACE FUNCTION public.find_recipes_from_pantry(p_user_id UUID)
RETURNS TABLE(
id BIGINT,
name TEXT,
description TEXT,
prep_time_minutes INTEGER,
cook_time_minutes INTEGER,
avg_rating NUMERIC,
missing_ingredients_count BIGINT
)
LANGUAGE sql
STABLE
SECURITY INVOKER
AS $$
WITH UserPantryItems AS (
-- CTE 1: Get a distinct set of master item IDs from the user's pantry.
SELECT master_item_id, quantity, unit
FROM public.pantry_items
WHERE user_id = p_user_id AND quantity > 0
),
RecipeIngredientStats AS (
-- CTE 2: For each recipe, count its total ingredients and how many of those are in the user's pantry.
SELECT
ri.recipe_id,
-- Count how many ingredients DO NOT meet the pantry requirements.
-- An ingredient is missing if it's not in the pantry OR if the quantity is insufficient.
-- The filter condition handles this logic.
COUNT(*) FILTER (
WHERE upi.master_item_id IS NULL -- The item is not in the pantry at all
OR upi.quantity < ri.quantity -- The user has the item, but not enough of it
) AS missing_ingredients_count
FROM public.recipe_ingredients ri
-- LEFT JOIN to the user's pantry on both item and unit.
-- We only compare quantities if the units match (e.g., 'g' vs 'g').
LEFT JOIN UserPantryItems upi
ON ri.master_item_id = upi.master_item_id
AND ri.unit = upi.unit
GROUP BY ri.recipe_id
)
-- Final Step: Select recipes where the total ingredient count matches the pantry ingredient count.
SELECT
r.id,
r.name,
r.description,
r.prep_time_minutes,
r.cook_time_minutes,
r.avg_rating,
ris.missing_ingredients_count
FROM public.recipes r
JOIN RecipeIngredientStats ris ON r.id = ris.recipe_id
-- Order by recipes with the fewest missing ingredients first, then by rating.
-- Recipes with 0 missing ingredients are the ones that can be made.
ORDER BY ris.missing_ingredients_count ASC, r.avg_rating DESC, r.name ASC;
$$;
-- Function to suggest alternative units for a given pantry item.
-- For example, if a user has 500g of flour, this function might suggest "4.1 cups".
CREATE OR REPLACE FUNCTION public.suggest_pantry_item_conversions(p_pantry_item_id BIGINT)
RETURNS TABLE (
suggested_quantity NUMERIC,
suggested_unit TEXT
)
LANGUAGE sql
STABLE
SECURITY INVOKER
AS $$
SELECT
-- Calculate the converted quantity by multiplying the original quantity by the conversion factor.
-- Round to 2 decimal places for readability.
ROUND(pi.quantity * uc.factor, 2) AS suggested_quantity,
uc.to_unit AS suggested_unit
FROM public.pantry_items pi
-- Join with the unit_conversions table to find available conversion rules.
JOIN public.unit_conversions uc
ON pi.master_item_id = uc.master_item_id
AND pi.unit = uc.from_unit
WHERE
pi.id = p_pantry_item_id
-- Exclude suggesting a conversion back to the same unit.
AND pi.unit <> uc.to_unit;
$$;

View File

@@ -1,16 +1,32 @@
-- DONE
-- 0. Create a simple users table for future expansion.
-- ============================================================================
-- PART 0.5: USER AUTHENTICATION TABLE
-- ============================================================================
-- This replaces the Supabase `auth.users` table.
CREATE TABLE IF NOT EXISTS public.users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
refresh_token TEXT, -- Stores the long-lived refresh token for re-authentication.
created_at TIMESTAMPTZ DEFAULT now() NOT NULL
);
COMMENT ON TABLE public.users IS 'Stores user authentication information, replacing Supabase auth.';
-- Add an index on the refresh_token for faster lookups when refreshing tokens.
CREATE INDEX IF NOT EXISTS idx_users_refresh_token ON public.users(refresh_token);
-- 0. Create a table for public user profiles.
-- This table is linked to the auth.users table and stores non-sensitive user data.
CREATE TABLE IF NOT EXISTS public.profiles (
id UUID PRIMARY KEY REFERENCES auth.users ON DELETE CASCADE,
id UUID PRIMARY KEY REFERENCES public.users(id) ON DELETE CASCADE,
updated_at TIMESTAMPTZ,
username TEXT UNIQUE,
full_name TEXT,
avatar_url TEXT,
CONSTRAINT username_length CHECK (char_length(username) >= 3)
preferences JSONB,
role TEXT CHECK (role IN ('admin', 'user'))
);
COMMENT ON TABLE public.profiles IS 'Stores public-facing user data, linked to the private auth.users table.';
-- DONE
-- 1. Create the 'stores' table for normalized store data.
@@ -20,27 +36,30 @@ CREATE TABLE IF NOT EXISTS public.stores (
name TEXT NOT NULL UNIQUE,
logo_url TEXT
);
COMMENT ON TABLE public.stores IS 'Stores metadata for grocery store chains (e.g., Safeway, Kroger).';
-- DONE
-- 2. Create the 'categories' table for normalized category data.
CREATE TABLE IF NOT EXISTS public.categories (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
name TEXT NOT NULL UNIQUE
);
COMMENT ON TABLE public.categories IS 'Stores a predefined list of grocery item categories (e.g., ''Fruits & Vegetables'', ''Dairy & Eggs'').';
-- DONE
-- 3. Create the 'flyers' table with its full, final schema.
CREATE TABLE IF NOT EXISTS public.flyers (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
file_name TEXT,
image_url TEXT,
file_name TEXT NOT NULL,
image_url TEXT NOT NULL,
checksum TEXT UNIQUE,
store_id BIGINT REFERENCES public.stores(id),
valid_from DATE,
valid_to DATE
valid_to DATE,
store_address TEXT
);
COMMENT ON TABLE public.flyers IS 'Stores metadata for each processed flyer, linking it to a store and its validity period.';
-- DONE
-- 4. Create the 'master_grocery_items' table. This is the master dictionary.
@@ -50,44 +69,49 @@ CREATE TABLE IF NOT EXISTS public.master_grocery_items (
name TEXT NOT NULL UNIQUE,
category_id BIGINT REFERENCES public.categories(id)
);
COMMENT ON TABLE public.master_grocery_items IS 'The master dictionary of canonical grocery items. Each item has a unique name and is linked to a category.';
-- DONE
-- 5. Create the 'user_watched_items' table. This links to the master list.
CREATE TABLE IF NOT EXISTS public.user_watched_items (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
UNIQUE(user_id, master_item_id)
);
COMMENT ON TABLE public.user_watched_items IS 'A linking table that represents a user''s personal watchlist of grocery items.';
-- DONE
-- 6. Create the 'flyer_items' table with its full, final schema.
CREATE TABLE IF NOT EXISTS public.flyer_items (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
item TEXT,
price_display TEXT,
price_in_cents INTEGER,
quantity TEXT,
quantity_num NUMERIC,
unit_price JSONB,
flyer_id BIGINT REFERENCES public.flyers(id) ON DELETE CASCADE,
master_item_id BIGINT REFERENCES public.master_grocery_items(id)
item TEXT NOT NULL,
price_display TEXT NOT NULL,
price_in_cents INTEGER,
quantity_num NUMERIC,
quantity TEXT NOT NULL,
category_id BIGINT REFERENCES public.categories(id),
category_name TEXT, -- Denormalized for easier display
unit_price JSONB,
master_item_id BIGINT REFERENCES public.master_grocery_items(id),
product_id BIGINT -- Future use for specific product linking
);
COMMENT ON TABLE public.flyer_items IS 'Stores individual items extracted from a specific flyer.';
-- DONE
-- 7. Create a table for user-defined alerts on watched items.
CREATE TABLE IF NOT EXISTS public.user_alerts (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
user_watched_item_id BIGINT NOT NULL REFERENCES public.user_watched_items(id) ON DELETE CASCADE,
alert_type TEXT NOT NULL, -- e.g., 'PRICE_BELOW', 'PERCENT_OFF_AVERAGE'
threshold_value NUMERIC NOT NULL, -- The value for the alert condition (e.g., 299 for a price of $2.99, or 20 for 20%)
alert_type TEXT NOT NULL CHECK (alert_type IN ('PRICE_BELOW', 'PERCENT_OFF_AVERAGE')),
threshold_value NUMERIC NOT NULL,
is_active BOOLEAN DEFAULT true NOT NULL,
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
-- A user should only have one type of alert per watched item
UNIQUE(user_watched_item_id, alert_type)
created_at TIMESTAMPTZ DEFAULT now() NOT NULL
);
COMMENT ON TABLE public.user_alerts IS 'Stores user-configured alert rules for their watched items.';
COMMENT ON COLUMN public.user_alerts.alert_type IS 'The condition that triggers the alert, e.g., ''PRICE_BELOW''.';
COMMENT ON COLUMN public.user_alerts.threshold_value IS 'The numeric threshold for the alert condition (e.g., price in cents, or percentage).';
@@ -95,12 +119,13 @@ COMMENT ON COLUMN public.user_alerts.threshold_value IS 'The numeric threshold f
-- 8. Create a table to store notifications for users.
CREATE TABLE IF NOT EXISTS public.notifications (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
content TEXT NOT NULL, -- The message for the user, e.g., "Chicken Thighs are on sale at Superstore!"
link_url TEXT, -- A deep link to the relevant flyer or item in the app
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
link_url TEXT,
is_read BOOLEAN DEFAULT false NOT NULL,
created_at TIMESTAMPTZ DEFAULT now() NOT NULL
);
COMMENT ON TABLE public.notifications IS 'A central log of notifications generated for users, such as price alerts.';
COMMENT ON COLUMN public.notifications.content IS 'The notification message displayed to the user.';
COMMENT ON COLUMN public.notifications.link_url IS 'A URL to navigate to when the notification is clicked.';
@@ -109,15 +134,20 @@ COMMENT ON COLUMN public.notifications.link_url IS 'A URL to navigate to when th
CREATE TABLE IF NOT EXISTS public.item_price_history (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id) ON DELETE CASCADE,
summary_date DATE NOT NULL, -- The date this summary applies to
min_price_in_cents INTEGER, -- The lowest price found for this item on this day, in cents
max_price_in_cents INTEGER, -- The highest price found for this item on this day, in cents
avg_price_in_cents INTEGER, -- The average price found for this item on this day, in cents
data_points_count INTEGER NOT NULL, -- How many data points were used for this summary
summary_date DATE NOT NULL,
min_price_in_cents INTEGER,
max_price_in_cents INTEGER,
avg_price_in_cents INTEGER,
data_points_count INTEGER DEFAULT 0 NOT NULL,
UNIQUE(master_item_id, summary_date)
);
COMMENT ON TABLE public.item_price_history IS 'Serves as a summary table to speed up charting and analytics.';
COMMENT ON COLUMN public.item_price_history.summary_date IS 'The date for which the price data is summarized.';
COMMENT ON COLUMN public.item_price_history.min_price_in_cents IS 'The lowest price found for this item on this day, in cents,';
COMMENT ON COLUMN public.item_price_history.max_price_in_cents IS 'The highest price found for this item on this day, in cents.';
COMMENT ON COLUMN public.item_price_history.avg_price_in_cents IS 'The average price found for this item on this day, in cents.';
COMMENT ON COLUMN public.item_price_history.data_points_count IS 'How many data points were used for this summary.';
-- DONE
-- 10. Create a table to map various names to a single master grocery item.
@@ -133,7 +163,7 @@ COMMENT ON COLUMN public.master_item_aliases.alias IS 'An alternative name, e.g.
-- 11. Create tables for user shopping lists.
CREATE TABLE IF NOT EXISTS public.shopping_lists (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now() NOT NULL
);
@@ -144,12 +174,10 @@ CREATE TABLE IF NOT EXISTS public.shopping_list_items (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
shopping_list_id BIGINT NOT NULL REFERENCES public.shopping_lists(id) ON DELETE CASCADE,
master_item_id BIGINT REFERENCES public.master_grocery_items(id),
custom_item_name TEXT, -- For items not in the master list, e.g., "Grandma's special spice mix"
quantity INTEGER DEFAULT 1 NOT NULL,
custom_item_name TEXT,
quantity NUMERIC DEFAULT 1 NOT NULL,
is_purchased BOOLEAN DEFAULT false NOT NULL,
added_at TIMESTAMPTZ DEFAULT now() NOT NULL,
-- Ensure a master item is not added twice to the same list
UNIQUE(shopping_list_id, master_item_id),
-- Ensure one of the item identifiers is present
CONSTRAINT must_have_item_identifier CHECK (master_item_id IS NOT NULL OR custom_item_name IS NOT NULL)
);
@@ -163,12 +191,12 @@ COMMENT ON COLUMN public.shopping_list_items.is_purchased IS 'Lets users check i
CREATE TABLE IF NOT EXISTS public.suggested_corrections (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
flyer_item_id BIGINT NOT NULL REFERENCES public.flyer_items(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
correction_type TEXT NOT NULL, -- e.g., 'WRONG_PRICE', 'INCORRECT_ITEM_LINK', 'INVALID_QUANTITY'
suggested_value TEXT NOT NULL, -- The proposed new value, stored as text for flexibility.
status TEXT DEFAULT 'pending' NOT NULL, -- e.g., 'pending', 'approved', 'rejected'
user_id UUID NOT NULL REFERENCES public.users(id),
correction_type TEXT NOT NULL,
suggested_value TEXT NOT NULL,
status TEXT DEFAULT 'pending' NOT NULL,
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
reviewed_notes TEXT, -- An admin can leave a note about why a suggestion was approved/rejected.
reviewed_notes TEXT,
reviewed_at TIMESTAMPTZ
);
COMMENT ON TABLE public.suggested_corrections IS 'A queue for user-submitted data corrections, enabling crowdsourced data quality improvements.';
@@ -180,16 +208,14 @@ COMMENT ON COLUMN public.suggested_corrections.status IS 'The moderation status
-- 13. Create a table for prices submitted directly by users from in-store.
CREATE TABLE IF NOT EXISTS public.user_submitted_prices (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id) ON DELETE CASCADE,
store_id BIGINT NOT NULL REFERENCES public.stores(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES public.users(id),
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id),
store_id BIGINT NOT NULL REFERENCES public.stores(id),
price_in_cents INTEGER NOT NULL,
photo_url TEXT, -- Optional: URL to a photo of the price tag for verification.
upvotes INTEGER DEFAULT 1 NOT NULL, -- Community validation mechanism.
photo_url TEXT,
upvotes INTEGER DEFAULT 0 NOT NULL,
downvotes INTEGER DEFAULT 0 NOT NULL,
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
-- A user can only submit one price for an item at a store per day.
UNIQUE(user_id, master_item_id, store_id, created_at::date)
created_at TIMESTAMPTZ DEFAULT now() NOT NULL
);
COMMENT ON TABLE public.user_submitted_prices IS 'Stores item prices submitted by users directly from physical stores.';
COMMENT ON COLUMN public.user_submitted_prices.photo_url IS 'URL to user-submitted photo evidence of the price.';
@@ -217,23 +243,29 @@ COMMENT ON TABLE public.brands IS 'Stores brand names like "Coca-Cola", "Maple L
CREATE TABLE IF NOT EXISTS public.products (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id),
brand_id BIGINT REFERENCES public.brands(id), -- Can be null for generic/store-brand items
name TEXT NOT NULL, -- e.g., "Prime Raised without Antibiotics Chicken Breast"
brand_id BIGINT REFERENCES public.brands(id),
name TEXT NOT NULL,
description TEXT,
size TEXT, -- e.g., "4L", "500g"
upc_code TEXT UNIQUE, -- Universal Product Code for precise identification
UNIQUE(master_item_id, brand_id, name, size)
size TEXT,
upc_code TEXT UNIQUE
);
COMMENT ON TABLE public.products IS 'Represents a specific, sellable product, combining a generic item with a brand and size.';
COMMENT ON COLUMN public.products.upc_code IS 'Universal Product Code, if available, for exact product matching.';
COMMENT ON COLUMN public.products.brand_id IS 'Can be null for generic/store-brand items.';
COMMENT ON COLUMN public.products.name IS 'Prime Raised without Antibiotics Chicken Breast.';
COMMENT ON COLUMN public.products.size IS 'e.g., "4L", "500g".';
-- Then, you would update 'flyer_items' to link to this new table.
ALTER TABLE public.flyer_items
ADD COLUMN IF NOT EXISTS product_id BIGINT REFERENCES public.products(id);
ADD CONSTRAINT flyer_items_product_id_fkey
FOREIGN KEY (product_id) REFERENCES public.products(id);
-- Enable trigram support for fuzzy string matching
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- Add a GIN index to the 'item' column for fast fuzzy text searching.
-- This requires the pg_trgm extension.
CREATE INDEX IF NOT EXISTS flyer_items_item_trgm_idx ON public.flyer_items USING GIN (item gin_trgm_ops);
-- First, enable the PostGIS extension if you haven't already.
@@ -255,6 +287,9 @@ CREATE TABLE IF NOT EXISTS public.store_locations (
CREATE INDEX IF NOT EXISTS store_locations_geo_idx ON public.store_locations USING GIST (location);
COMMENT ON TABLE public.store_locations IS 'Stores physical locations of stores with geographic data for proximity searches.';
COMMENT ON COLUMN public.store_locations.location IS 'Geographic coordinates (longitude, latitude) of the store.';
-- Add a GIST index for efficient geographic queries.
-- This requires the postgis extension.
CREATE INDEX IF NOT EXISTS store_locations_geo_idx ON public.store_locations USING GIST (location);
-- DONE
-- You might also need a linking table if one flyer is valid for multiple locations.
@@ -263,27 +298,28 @@ CREATE TABLE IF NOT EXISTS public.flyer_locations (
store_location_id BIGINT NOT NULL REFERENCES public.store_locations(id) ON DELETE CASCADE,
PRIMARY KEY (flyer_id, store_location_id)
);
COMMENT ON TABLE public.flyer_locations IS 'A linking table associating a single flyer with multiple store locations where its deals are valid.';
-- done
-- A table to store recipes, which can be user-created or pre-populated.
CREATE TABLE IF NOT EXISTS public.recipes (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
user_id UUID REFERENCES public.profiles(id) ON DELETE SET NULL, -- Can be a system recipe (user_id is NULL) or user-submitted
user_id UUID REFERENCES public.users(id) ON DELETE SET NULL, -- Can be a system recipe (user_id is NULL) or user-submitted
name TEXT NOT NULL,
description TEXT,
instructions TEXT,
prep_time_minutes INTEGER,
cook_time_minutes INTEGER,
servings INTEGER, -- How many people the recipe is intended for.
servings INTEGER,
photo_url TEXT,
-- Optional nutritional information
calories_per_serving INTEGER,
protein_grams INTEGER,
fat_grams INTEGER,
carb_grams INTEGER,
protein_grams NUMERIC,
fat_grams NUMERIC,
carb_grams NUMERIC,
-- Aggregated rating data for fast sorting/display
avg_rating NUMERIC(3, 2) DEFAULT 0.00,
rating_count INTEGER DEFAULT 0,
avg_rating NUMERIC(2,1) DEFAULT 0.0 NOT NULL,
rating_count INTEGER DEFAULT 0 NOT NULL,
created_at TIMESTAMPTZ DEFAULT now() NOT NULL
);
COMMENT ON TABLE public.recipes IS 'Stores recipes that can be used to generate shopping lists.';
@@ -296,9 +332,10 @@ CREATE TABLE IF NOT EXISTS public.recipe_ingredients (
recipe_id BIGINT NOT NULL REFERENCES public.recipes(id) ON DELETE CASCADE,
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id),
quantity NUMERIC NOT NULL,
unit TEXT NOT NULL -- e.g., 'cups', 'tbsp', 'g', 'each'
unit TEXT NOT NULL
);
COMMENT ON TABLE public.recipe_ingredients IS 'Defines the ingredients and quantities needed for a recipe.';
COMMENT ON COLUMN public.recipe_ingredients.unit IS 'e.g., "cups", "tbsp", "g", "each".';
-- done
-- A table to store a predefined list of tags for recipes.
@@ -315,6 +352,7 @@ CREATE TABLE IF NOT EXISTS public.recipe_tags (
tag_id BIGINT NOT NULL REFERENCES public.tags(id) ON DELETE CASCADE,
PRIMARY KEY (recipe_id, tag_id)
);
COMMENT ON TABLE public.recipe_tags IS 'A linking table to associate multiple tags with a single recipe.';
-- done
-- A table to store individual user ratings for recipes.
@@ -327,6 +365,7 @@ CREATE TABLE IF NOT EXISTS public.recipe_ratings (
created_at TIMESTAMPTZ DEFAULT now() NOT NULL,
UNIQUE(recipe_id, user_id) -- A user can only rate a recipe once.
);
COMMENT ON TABLE public.recipe_ratings IS 'Stores individual user ratings for recipes, ensuring a user can only rate a recipe once.';
-- DONE
@@ -349,9 +388,8 @@ CREATE TABLE IF NOT EXISTS public.planned_meals (
menu_plan_id BIGINT NOT NULL REFERENCES public.menu_plans(id) ON DELETE CASCADE,
recipe_id BIGINT NOT NULL REFERENCES public.recipes(id) ON DELETE CASCADE,
plan_date DATE NOT NULL,
-- e.g., 'Breakfast', 'Lunch', 'Dinner', 'Snack'
meal_type TEXT NOT NULL,
-- A user can plan the same recipe for multiple meal types on the same day (e.g., leftovers for lunch)
servings_to_cook INTEGER
);
COMMENT ON TABLE public.planned_meals IS 'Assigns a recipe to a specific day and meal type within a user''s menu plan.';
COMMENT ON COLUMN public.planned_meals.meal_type IS 'The designated meal for the recipe, e.g., ''Breakfast'', ''Lunch'', ''Dinner''.';
@@ -363,10 +401,9 @@ CREATE TABLE IF NOT EXISTS public.pantry_items (
user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id) ON DELETE CASCADE,
quantity NUMERIC NOT NULL,
unit TEXT, -- e.g., 'g', 'ml', 'items'. Should align with recipe_ingredients.unit
unit TEXT,
best_before_date DATE,
updated_at TIMESTAMPTZ DEFAULT now() NOT NULL,
-- A user should only have one entry per master item in their pantry.
UNIQUE(user_id, master_item_id)
);
COMMENT ON TABLE public.pantry_items IS 'Tracks a user''s personal inventory of grocery items to enable smart shopping lists.';
@@ -375,271 +412,27 @@ COMMENT ON COLUMN public.pantry_items.unit IS 'e.g., ''g'', ''ml'', ''items''. S
-- 15. Pre-populate the master grocery items dictionary.
DO $$
DECLARE
fv_cat_id BIGINT;
ms_cat_id BIGINT;
de_cat_id BIGINT;
bb_cat_id BIGINT;
pdg_cat_id BIGINT;
bev_cat_id BIGINT;
ff_cat_id BIGINT;
snk_cat_id BIGINT;
hc_cat_id BIGINT;
pch_cat_id BIGINT;
bc_cat_id BIGINT;
ps_cat_id BIGINT;
dpf_cat_id BIGINT;
cg_cat_id BIGINT;
cs_cat_id BIGINT;
bkc_cat_id BIGINT;
BEGIN
SELECT id INTO fv_cat_id FROM public.categories WHERE name = 'Fruits & Vegetables';
SELECT id INTO ms_cat_id FROM public.categories WHERE name = 'Meat & Seafood';
SELECT id INTO de_cat_id FROM public.categories WHERE name = 'Dairy & Eggs';
SELECT id INTO bb_cat_id FROM public.categories WHERE name = 'Bakery & Bread';
SELECT id INTO pdg_cat_id FROM public.categories WHERE name = 'Pantry & Dry Goods';
SELECT id INTO bev_cat_id FROM public.categories WHERE name = 'Beverages';
SELECT id INTO ff_cat_id FROM public.categories WHERE name = 'Frozen Foods';
SELECT id INTO snk_cat_id FROM public.categories WHERE name = 'Snacks';
SELECT id INTO hc_cat_id FROM public.categories WHERE name = 'Household & Cleaning';
SELECT id INTO pch_cat_id FROM public.categories WHERE name = 'Personal Care & Health';
SELECT id INTO bc_cat_id FROM public.categories WHERE name = 'Baby & Child';
SELECT id INTO ps_cat_id FROM public.categories WHERE name = 'Pet Supplies';
SELECT id INTO dpf_cat_id FROM public.categories WHERE name = 'Deli & Prepared Foods';
SELECT id INTO cg_cat_id FROM public.categories WHERE name = 'Canned Goods';
SELECT id INTO cs_cat_id FROM public.categories WHERE name = 'Condiments & Spices';
SELECT id INTO bkc_cat_id FROM public.categories WHERE name = 'Breakfast & Cereal';
INSERT INTO public.master_grocery_items (name, category_id) VALUES
('apples', fv_cat_id), ('bananas', fv_cat_id), ('oranges', fv_cat_id), ('grapes', fv_cat_id), ('strawberries', fv_cat_id), ('blueberries', fv_cat_id), ('raspberries', fv_cat_id), ('avocados', fv_cat_id), ('tomatoes', fv_cat_id), ('potatoes', fv_cat_id), ('onions', fv_cat_id), ('garlic', fv_cat_id), ('carrots', fv_cat_id), ('broccoli', fv_cat_id), ('spinach', fv_cat_id), ('lettuce', fv_cat_id), ('bell peppers', fv_cat_id), ('cucumbers', fv_cat_id), ('mushrooms', fv_cat_id), ('lemons', fv_cat_id), ('limes', fv_cat_id), ('celery', fv_cat_id), ('corn', fv_cat_id), ('sweet potatoes', fv_cat_id), ('zucchini', fv_cat_id), ('cauliflower', fv_cat_id), ('green beans', fv_cat_id), ('peas', fv_cat_id), ('asparagus', fv_cat_id),
('chicken breast', ms_cat_id), ('chicken thighs', ms_cat_id), ('ground beef', ms_cat_id), ('steak', ms_cat_id), ('pork chops', ms_cat_id), ('bacon', ms_cat_id), ('sausage', ms_cat_id), ('salmon', ms_cat_id), ('shrimp', ms_cat_id), ('tilapia', ms_cat_id), ('cod', ms_cat_id), ('tuna', ms_cat_id), ('ham', ms_cat_id), ('turkey', ms_cat_id),
('milk', de_cat_id), ('cheese', de_cat_id), ('yogurt', de_cat_id), ('butter', de_cat_id), ('eggs', de_cat_id), ('cream cheese', de_cat_id), ('sour cream', de_cat_id), ('cottage cheese', de_cat_id),
('bread', bb_cat_id), ('bagels', bb_cat_id), ('tortillas', bb_cat_id), ('croissants', bb_cat_id), ('muffins', bb_cat_id), ('baguette', bb_cat_id), ('pita bread', bb_cat_id),
('rice', pdg_cat_id), ('pasta', pdg_cat_id), ('flour', pdg_cat_id), ('sugar', pdg_cat_id), ('salt', pdg_cat_id), ('pepper', pdg_cat_id), ('olive oil', pdg_cat_id), ('vegetable oil', pdg_cat_id), ('canned tomatoes', pdg_cat_id), ('canned beans', pdg_cat_id), ('peanut butter', pdg_cat_id), ('jam', pdg_cat_id), ('honey', pdg_cat_id), ('syrup', pdg_cat_id), ('nuts', pdg_cat_id), ('dried fruit', pdg_cat_id), ('crackers', pdg_cat_id), ('quinoa', pdg_cat_id), ('lentils', pdg_cat_id),
('water', bev_cat_id), ('juice', bev_cat_id), ('soda', bev_cat_id), ('coffee', bev_cat_id), ('tea', bev_cat_id), ('almond milk', bev_cat_id), ('soy milk', bev_cat_id), ('coconut water', bev_cat_id),
('frozen pizza', ff_cat_id), ('frozen vegetables', ff_cat_id), ('frozen fruit', ff_cat_id), ('ice cream', ff_cat_id), ('frozen dinners', ff_cat_id), ('french fries', ff_cat_id), ('frozen fish', ff_cat_id),
('chips', snk_cat_id), ('pretzels', snk_cat_id), ('popcorn', snk_cat_id), ('granola bars', snk_cat_id), ('cookies', snk_cat_id), ('chocolate', snk_cat_id), ('candy', snk_cat_id),
('paper towels', hc_cat_id), ('toilet paper', hc_cat_id), ('trash bags', hc_cat_id), ('dish soap', hc_cat_id), ('laundry detergent', hc_cat_id), ('all-purpose cleaner', hc_cat_id), ('sponges', hc_cat_id),
('soap', pch_cat_id), ('shampoo', pch_cat_id), ('conditioner', pch_cat_id), ('toothpaste', pch_cat_id), ('deodorant', pch_cat_id), ('vitamins', pch_cat_id), ('pain reliever', pch_cat_id),
('diapers', bc_cat_id), ('baby wipes', bc_cat_id), ('baby food', bc_cat_id), ('formula', bc_cat_id),
('dog food', ps_cat_id), ('cat food', ps_cat_id), ('cat litter', ps_cat_id),
('deli meat', dpf_cat_id), ('deli cheese', dpf_cat_id), ('rotisserie chicken', dpf_cat_id), ('prepared salads', dpf_cat_id),
('canned soup', cg_cat_id), ('canned corn', cg_cat_id), ('canned tuna', cg_cat_id), ('canned chicken', cg_cat_id),
('ketchup', cs_cat_id), ('mustard', cs_cat_id), ('mayonnaise', cs_cat_id), ('soy sauce', cs_cat_id), ('hot sauce', cs_cat_id), ('bbq sauce', cs_cat_id), ('salad dressing', cs_cat_id), ('cinnamon', cs_cat_id), ('oregano', cs_cat_id), ('paprika', cs_cat_id), ('garlic powder', cs_cat_id),
('cereal', bkc_cat_id), ('oatmeal', bkc_cat_id), ('granola', bkc_cat_id), ('pancake mix', bkc_cat_id)
ON CONFLICT (name) DO NOTHING;
END $$;
-- 16. Pre-populate initial watched items after master list exists.
DO $$
DECLARE
tp_id BIGINT;
ct_id BIGINT;
-- Note: Seeding user-specific data in a generic script is tricky.
-- This block is for demonstration. In a real app, users would add their own watched items.
-- We'll comment it out to avoid errors if no users exist.
-- sample_user_id UUID;
BEGIN
-- -- Find a user to assign watched items to.
-- SELECT id INTO sample_user_id FROM auth.users LIMIT 1;
-- -- If a user exists, add some default watched items for them.
-- IF sample_user_id IS NOT NULL THEN
-- SELECT id INTO tp_id FROM public.master_grocery_items WHERE name = 'toilet paper';
-- SELECT id INTO ct_id FROM public.master_grocery_items WHERE name = 'chicken thighs';
-- IF tp_id IS NOT NULL THEN
-- INSERT INTO public.user_watched_items (user_id, master_item_id)
-- VALUES (sample_user_id, tp_id)
-- ON CONFLICT (user_id, master_item_id) DO NOTHING;
-- END IF;
-- IF ct_id IS NOT NULL THEN
-- INSERT INTO public.user_watched_items (user_id, master_item_id)
-- VALUES (sample_user_id, ct_id)
-- ON CONFLICT (user_id, master_item_id) DO NOTHING;
-- END IF;
-- END IF;
NULL; -- PL/pgSQL block cannot be empty
END $$;
-- 17. Enable Row Level Security (RLS) on all tables. This is safe to run multiple times.
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.stores ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.categories ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.flyers ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.flyer_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.master_grocery_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.user_watched_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.user_alerts ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.notifications ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.item_price_history ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.master_item_aliases ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.shopping_lists ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.shopping_list_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.suggested_corrections ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.user_submitted_prices ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.brands ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.products ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.store_locations ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.flyer_locations ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.recipes ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.recipe_ingredients ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.menu_plans ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.planned_meals ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.tags ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.recipe_tags ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.recipe_ratings ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.pantry_items ENABLE ROW LEVEL SECURITY;
-- 18. Create policies. Using DROP/CREATE makes this script idempotent and safe to re-run.
-- Policies for 'profiles' table
-- Profiles are publicly readable
DROP POLICY IF EXISTS "Public profiles are viewable by everyone." ON public.profiles;
CREATE POLICY "Public profiles are viewable by everyone." ON public.profiles FOR SELECT USING (true);
-- Users can insert and update their own profile
DROP POLICY IF EXISTS "Users can update their own profile." ON public.profiles;
CREATE POLICY "Users can update their own profile." ON public.profiles FOR UPDATE USING (auth.uid() = id) WITH CHECK (auth.uid() = id);
-- Policies for 'stores' table
DROP POLICY IF EXISTS "Public read access" ON public.stores;
CREATE POLICY "Public read access" ON public.stores FOR SELECT USING (true);
-- Policies for 'categories' table
DROP POLICY IF EXISTS "Public read access" ON public.categories;
CREATE POLICY "Public read access" ON public.categories FOR SELECT USING (true);
-- Policies for 'flyers' table
DROP POLICY IF EXISTS "Allow public read access to flyers" ON public.flyers;
CREATE POLICY "Allow public read access to flyers" ON public.flyers
FOR SELECT USING (true);
-- Policies for 'flyer_items' table
DROP POLICY IF EXISTS "Public read access" ON public.flyer_items;
CREATE POLICY "Public read access" ON public.flyer_items FOR SELECT USING (true);
-- Policies for 'master_grocery_items' table
DROP POLICY IF EXISTS "Public read access" ON public.master_grocery_items;
CREATE POLICY "Public read access" ON public.master_grocery_items FOR SELECT USING (true);
-- Policies for 'user_watched_items' table
-- Users can view their own watched items.
DROP POLICY IF EXISTS "Users can view their own watched items." ON public.user_watched_items;
CREATE POLICY "Users can view their own watched items." ON public.user_watched_items FOR SELECT USING (auth.uid() = user_id);
-- Users can insert, update, and delete their own watched items.
DROP POLICY IF EXISTS "Users can manage their own watched items." ON public.user_watched_items;
CREATE POLICY "Users can manage their own watched items." ON public.user_watched_items FOR ALL USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);
-- Policies for 'user_alerts' table
DROP POLICY IF EXISTS "Users can manage their own alerts" ON public.user_alerts;
CREATE POLICY "Users can manage their own alerts" ON public.user_alerts
FOR ALL USING (
-- Check that the user owns the parent 'user_watched_item'
auth.uid() = (SELECT user_id FROM public.user_watched_items WHERE id = user_watched_item_id)
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token_hash ON public.password_reset_tokens(token_hash);
-- A table to store password reset tokens.
CREATE TABLE IF NOT EXISTS public.password_reset_tokens (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT now() NOT NULL
);
COMMENT ON TABLE public.password_reset_tokens IS 'Stores secure, single-use tokens for password reset requests.';
COMMENT ON COLUMN public.password_reset_tokens.token_hash IS 'A bcrypt hash of the reset token sent to the user.';
COMMENT ON COLUMN public.password_reset_tokens.expires_at IS 'The timestamp when this token is no longer valid.';
-- Policies for 'notifications' table
DROP POLICY IF EXISTS "Users can manage their own notifications" ON public.notifications;
CREATE POLICY "Users can manage their own notifications" ON public.notifications
FOR ALL USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);
-- Policies for 'item_price_history' table
DROP POLICY IF EXISTS "Public read access for price history" ON public.item_price_history;
CREATE POLICY "Public read access for price history" ON public.item_price_history
FOR SELECT USING (true);
-- Policies for 'master_item_aliases' table
DROP POLICY IF EXISTS "Aliases are publicly viewable." ON public.master_item_aliases;
CREATE POLICY "Aliases are publicly viewable." ON public.master_item_aliases FOR SELECT USING (true);
-- Note: Write access to aliases should be restricted to an admin or trusted role.
-- Policies for 'shopping_lists' table
DROP POLICY IF EXISTS "Users can manage their own shopping lists." ON public.shopping_lists;
CREATE POLICY "Users can manage their own shopping lists." ON public.shopping_lists
FOR ALL USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);
-- Policies for 'shopping_list_items' table
DROP POLICY IF EXISTS "Users can manage items in their own shopping lists." ON public.shopping_list_items;
CREATE POLICY "Users can manage items in their own shopping lists." ON public.shopping_list_items
FOR ALL USING (
-- Check that the user owns the parent 'shopping_list'
auth.uid() = (SELECT user_id FROM public.shopping_lists WHERE id = shopping_list_id)
-- A table to store unit conversion factors for specific master grocery items.
CREATE TABLE IF NOT EXISTS public.unit_conversions (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(id) ON DELETE CASCADE,
from_unit TEXT NOT NULL,
to_unit TEXT NOT NULL,
factor NUMERIC NOT NULL,
UNIQUE(master_item_id, from_unit, to_unit)
);
-- Policies for 'suggested_corrections' table
DROP POLICY IF EXISTS "Users can manage their own suggestions." ON public.suggested_corrections;
CREATE POLICY "Users can manage their own suggestions." ON public.suggested_corrections
FOR ALL USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);
-- Note: An admin role would need a separate policy to view/update all suggestions.
-- Policies for 'user_submitted_prices' table
DROP POLICY IF EXISTS "Submitted prices are publicly viewable." ON public.user_submitted_prices;
CREATE POLICY "Submitted prices are publicly viewable." ON public.user_submitted_prices
FOR SELECT USING (true);
DROP POLICY IF EXISTS "Users can manage their own submitted prices." ON public.user_submitted_prices;
CREATE POLICY "Users can manage their own submitted prices." ON public.user_submitted_prices
FOR INSERT, UPDATE, DELETE USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);
-- Policies for new product/location tables
DROP POLICY IF EXISTS "Public read access" ON public.brands;
CREATE POLICY "Public read access" ON public.brands FOR SELECT USING (true);
DROP POLICY IF EXISTS "Public read access" ON public.products;
CREATE POLICY "Public read access" ON public.products FOR SELECT USING (true);
DROP POLICY IF EXISTS "Public read access" ON public.store_locations;
CREATE POLICY "Public read access" ON public.store_locations FOR SELECT USING (true);
DROP POLICY IF EXISTS "Public read access" ON public.flyer_locations;
CREATE POLICY "Public read access" ON public.flyer_locations FOR SELECT USING (true);
-- Policies for recipe/menu plan tables
DROP POLICY IF EXISTS "Recipes are publicly viewable." ON public.recipes;
CREATE POLICY "Recipes are publicly viewable." ON public.recipes FOR SELECT USING (true);
DROP POLICY IF EXISTS "Users can manage their own recipes." ON public.recipes;
CREATE POLICY "Users can manage their own recipes." ON public.recipes FOR INSERT, UPDATE, DELETE USING (auth.uid() = user_id);
DROP POLICY IF EXISTS "Recipe ingredients are publicly viewable." ON public.recipe_ingredients;
CREATE POLICY "Recipe ingredients are publicly viewable." ON public.recipe_ingredients FOR SELECT USING (true);
-- Note: Write access to recipe_ingredients should be controlled by who owns the parent recipe.
-- A more complex policy or SECURITY DEFINER function would be needed for users to edit ingredients.
-- For now, we assume inserts/updates happen via a trusted process or admin role.
DROP POLICY IF EXISTS "Users can manage their own menu plans." ON public.menu_plans;
CREATE POLICY "Users can manage their own menu plans." ON public.menu_plans FOR ALL USING (auth.uid() = user_id);
DROP POLICY IF EXISTS "Users can manage meals in their own menu plans." ON public.planned_meals;
CREATE POLICY "Users can manage meals in their own menu plans." ON public.planned_meals
FOR ALL USING (
auth.uid() = (SELECT user_id FROM public.menu_plans WHERE id = menu_plan_id)
);
-- Policies for pantry_items table
DROP POLICY IF EXISTS "Users can manage their own pantry items." ON public.pantry_items;
CREATE POLICY "Users can manage their own pantry items." ON public.pantry_items FOR ALL USING (auth.uid() = user_id);
-- Policies for new recipe-related tables
DROP POLICY IF EXISTS "Tags are publicly viewable." ON public.tags;
CREATE POLICY "Tags are publicly viewable." ON public.tags FOR SELECT USING (true);
DROP POLICY IF EXISTS "Recipe-tag links are publicly viewable." ON public.recipe_tags;
CREATE POLICY "Recipe-tag links are publicly viewable." ON public.recipe_tags FOR SELECT USING (true);
DROP POLICY IF EXISTS "Recipe ratings are publicly viewable." ON public.recipe_ratings;
CREATE POLICY "Recipe ratings are publicly viewable." ON public.recipe_ratings FOR SELECT USING (true);
DROP POLICY IF EXISTS "Users can manage their own recipe ratings." ON public.recipe_ratings;
CREATE POLICY "Users can manage their own recipe ratings." ON public.recipe_ratings FOR ALL USING (auth.uid() = user_id);
COMMENT ON TABLE public.unit_conversions IS 'Stores item-specific unit conversion factors (e.g., grams of flour to cups).';
COMMENT ON COLUMN public.unit_conversions.factor IS 'The multiplication factor to convert from_unit to to_unit.';

220
sql/initial_data.sql Normal file
View File

@@ -0,0 +1,220 @@
-- ============================================================================
-- INITIAL DATA SEEDING SCRIPT
-- ============================================================================
-- Purpose:
-- This script populates the database with essential starting data.
-- It should be run AFTER the schema has been created (e.g., after initial.sql).
-- It is idempotent, meaning it can be run multiple times without causing errors.
-- 1. Pre-populate the master grocery items dictionary.
-- This block links generic items to their respective categories.
DO $$
DECLARE
fv_cat_id BIGINT; ms_cat_id BIGINT; de_cat_id BIGINT; bb_cat_id BIGINT; pdg_cat_id BIGINT;
bev_cat_id BIGINT; ff_cat_id BIGINT; snk_cat_id BIGINT; hc_cat_id BIGINT; pch_cat_id BIGINT;
bc_cat_id BIGINT; ps_cat_id BIGINT; dpf_cat_id BIGINT; cg_cat_id BIGINT; cs_cat_id BIGINT;
bkc_cat_id BIGINT;
BEGIN
SELECT id INTO fv_cat_id FROM public.categories WHERE name = 'Fruits & Vegetables';
SELECT id INTO ms_cat_id FROM public.categories WHERE name = 'Meat & Seafood';
SELECT id INTO de_cat_id FROM public.categories WHERE name = 'Dairy & Eggs';
SELECT id INTO bb_cat_id FROM public.categories WHERE name = 'Bakery & Bread';
SELECT id INTO pdg_cat_id FROM public.categories WHERE name = 'Pantry & Dry Goods';
SELECT id INTO bev_cat_id FROM public.categories WHERE name = 'Beverages';
SELECT id INTO ff_cat_id FROM public.categories WHERE name = 'Frozen Foods';
SELECT id INTO snk_cat_id FROM public.categories WHERE name = 'Snacks';
SELECT id INTO hc_cat_id FROM public.categories WHERE name = 'Household & Cleaning';
SELECT id INTO pch_cat_id FROM public.categories WHERE name = 'Personal Care & Health';
SELECT id INTO bc_cat_id FROM public.categories WHERE name = 'Baby & Child';
SELECT id INTO ps_cat_id FROM public.categories WHERE name = 'Pet Supplies';
SELECT id INTO dpf_cat_id FROM public.categories WHERE name = 'Deli & Prepared Foods';
SELECT id INTO cg_cat_id FROM public.categories WHERE name = 'Canned Goods';
SELECT id INTO cs_cat_id FROM public.categories WHERE name = 'Condiments & Spices';
SELECT id INTO bkc_cat_id FROM public.categories WHERE name = 'Breakfast & Cereal';
INSERT INTO public.master_grocery_items (name, category_id) VALUES
('apples', fv_cat_id), ('bananas', fv_cat_id), ('oranges', fv_cat_id), ('grapes', fv_cat_id), ('strawberries', fv_cat_id), ('blueberries', fv_cat_id), ('raspberries', fv_cat_id), ('avocados', fv_cat_id), ('tomatoes', fv_cat_id), ('potatoes', fv_cat_id), ('onions', fv_cat_id), ('garlic', fv_cat_id), ('carrots', fv_cat_id), ('broccoli', fv_cat_id), ('spinach', fv_cat_id), ('lettuce', fv_cat_id), ('bell peppers', fv_cat_id), ('cucumbers', fv_cat_id), ('mushrooms', fv_cat_id), ('lemons', fv_cat_id), ('limes', fv_cat_id), ('celery', fv_cat_id), ('corn', fv_cat_id), ('sweet potatoes', fv_cat_id), ('zucchini', fv_cat_id), ('cauliflower', fv_cat_id), ('green beans', fv_cat_id), ('peas', fv_cat_id), ('asparagus', fv_cat_id),
('chicken breast', ms_cat_id), ('chicken thighs', ms_cat_id), ('ground beef', ms_cat_id), ('steak', ms_cat_id), ('pork chops', ms_cat_id), ('bacon', ms_cat_id), ('sausage', ms_cat_id), ('salmon', ms_cat_id), ('shrimp', ms_cat_id), ('tilapia', ms_cat_id), ('cod', ms_cat_id), ('tuna', ms_cat_id), ('ham', ms_cat_id), ('turkey', ms_cat_id),
('milk', de_cat_id), ('cheese', de_cat_id), ('yogurt', de_cat_id), ('butter', de_cat_id), ('eggs', de_cat_id), ('cream cheese', de_cat_id), ('sour cream', de_cat_id), ('cottage cheese', de_cat_id),
('bread', bb_cat_id), ('bagels', bb_cat_id), ('tortillas', bb_cat_id), ('croissants', bb_cat_id), ('muffins', bb_cat_id), ('baguette', bb_cat_id), ('pita bread', bb_cat_id),
('rice', pdg_cat_id), ('pasta', pdg_cat_id), ('flour', pdg_cat_id), ('sugar', pdg_cat_id), ('salt', pdg_cat_id), ('pepper', pdg_cat_id), ('olive oil', pdg_cat_id), ('vegetable oil', pdg_cat_id), ('canned tomatoes', pdg_cat_id), ('canned beans', pdg_cat_id), ('peanut butter', pdg_cat_id), ('jam', pdg_cat_id), ('honey', pdg_cat_id), ('syrup', pdg_cat_id), ('nuts', pdg_cat_id), ('dried fruit', pdg_cat_id), ('crackers', pdg_cat_id), ('quinoa', pdg_cat_id), ('lentils', pdg_cat_id),
('water', bev_cat_id), ('juice', bev_cat_id), ('soda', bev_cat_id), ('coffee', bev_cat_id), ('tea', bev_cat_id), ('almond milk', bev_cat_id), ('soy milk', bev_cat_id), ('coconut water', bev_cat_id),
('frozen pizza', ff_cat_id), ('frozen vegetables', ff_cat_id), ('frozen fruit', ff_cat_id), ('ice cream', ff_cat_id), ('frozen dinners', ff_cat_id), ('french fries', ff_cat_id), ('frozen fish', ff_cat_id),
('chips', snk_cat_id), ('pretzels', snk_cat_id), ('popcorn', snk_cat_id), ('granola bars', snk_cat_id), ('cookies', snk_cat_id), ('chocolate', snk_cat_id), ('candy', snk_cat_id),
('paper towels', hc_cat_id), ('toilet paper', hc_cat_id), ('trash bags', hc_cat_id), ('dish soap', hc_cat_id), ('laundry detergent', hc_cat_id), ('all-purpose cleaner', hc_cat_id), ('sponges', hc_cat_id),
('soap', pch_cat_id), ('shampoo', pch_cat_id), ('conditioner', pch_cat_id), ('toothpaste', pch_cat_id), ('deodorant', pch_cat_id), ('vitamins', pch_cat_id), ('pain reliever', pch_cat_id),
('diapers', bc_cat_id), ('baby wipes', bc_cat_id), ('baby food', bc_cat_id), ('formula', bc_cat_id),
('dog food', ps_cat_id), ('cat food', ps_cat_id), ('cat litter', ps_cat_id),
('deli meat', dpf_cat_id), ('deli cheese', dpf_cat_id), ('rotisserie chicken', dpf_cat_id), ('prepared salads', dpf_cat_id),
('canned soup', cg_cat_id), ('canned corn', cg_cat_id), ('canned tuna', cg_cat_id), ('canned chicken', cg_cat_id),
('ketchup', cs_cat_id), ('mustard', cs_cat_id), ('mayonnaise', cs_cat_id), ('soy sauce', cs_cat_id), ('hot sauce', cs_cat_id), ('bbq sauce', cs_cat_id), ('salad dressing', cs_cat_id), ('cinnamon', cs_cat_id), ('oregano', cs_cat_id), ('paprika', cs_cat_id), ('garlic powder', cs_cat_id),
('cereal', bkc_cat_id), ('oatmeal', bkc_cat_id), ('granola', bkc_cat_id), ('pancake mix', bkc_cat_id)
ON CONFLICT (name) DO NOTHING;
END $$;
-- 2. Pre-populate the brands and products tables.
-- This block adds common brands and links them to specific products.
DO $$
DECLARE
-- Brand IDs
coke_id BIGINT; kraft_id BIGINT; maple_leaf_id BIGINT; dempsters_id BIGINT; no_name_id BIGINT; pc_id BIGINT;
-- Master Item IDs
soda_item_id BIGINT; pasta_item_id BIGINT; turkey_item_id BIGINT; bread_item_id BIGINT; cheese_item_id BIGINT;
BEGIN
-- Insert brands and get their IDs
INSERT INTO public.brands (name) VALUES ('Coca-Cola'), ('Kraft'), ('Maple Leaf'), ('Dempster''s'), ('No Name'), ('President''s Choice')
ON CONFLICT (name) DO NOTHING;
SELECT id INTO coke_id FROM public.brands WHERE name = 'Coca-Cola';
SELECT id INTO kraft_id FROM public.brands WHERE name = 'Kraft';
SELECT id INTO maple_leaf_id FROM public.brands WHERE name = 'Maple Leaf';
SELECT id INTO dempsters_id FROM public.brands WHERE name = 'Dempster''s';
SELECT id INTO no_name_id FROM public.brands WHERE name = 'No Name';
SELECT id INTO pc_id FROM public.brands WHERE name = 'President''s Choice';
-- Get master item IDs
SELECT id INTO soda_item_id FROM public.master_grocery_items WHERE name = 'soda';
SELECT id INTO pasta_item_id FROM public.master_grocery_items WHERE name = 'pasta';
SELECT id INTO turkey_item_id FROM public.master_grocery_items WHERE name = 'turkey';
SELECT id INTO bread_item_id FROM public.master_grocery_items WHERE name = 'bread';
SELECT id INTO cheese_item_id FROM public.master_grocery_items WHERE name = 'cheese';
-- Insert specific products, linking master items and brands
INSERT INTO public.products (master_item_id, brand_id, name, size, upc_code) VALUES
(soda_item_id, coke_id, 'Coca-Cola Classic', '2L Bottle', '067000004114'),
(pasta_item_id, kraft_id, 'Kraft Dinner Original Macaroni & Cheese', '225g Box', '068100058918'),
(turkey_item_id, maple_leaf_id, 'Maple Leaf Natural Selections Sliced Turkey Breast', '175g', '063100123456'),
(bread_item_id, dempsters_id, 'Dempster''s 100% Whole Wheat Bread', '675g Loaf', '068721001005'),
(cheese_item_id, no_name_id, 'No Name Cheddar Cheese Block', '400g', '060383037575'),
(cheese_item_id, pc_id, 'PC Old Cheddar Cheese', '400g', '060383000005')
ON CONFLICT (upc_code) DO NOTHING;
END $$;
-- 3. Pre-populate the master_item_aliases table.
-- This block adds common alternative names for master items to improve fuzzy matching.
DO $$
DECLARE
ground_beef_id BIGINT;
chicken_breast_id BIGINT;
chicken_thighs_id BIGINT;
bell_peppers_id BIGINT;
soda_id BIGINT;
paper_towels_id BIGINT;
toilet_paper_id BIGINT;
BEGIN
-- Get master item IDs
SELECT id INTO ground_beef_id FROM public.master_grocery_items WHERE name = 'ground beef';
SELECT id INTO chicken_breast_id FROM public.master_grocery_items WHERE name = 'chicken breast';
SELECT id INTO chicken_thighs_id FROM public.master_grocery_items WHERE name = 'chicken thighs';
SELECT id INTO bell_peppers_id FROM public.master_grocery_items WHERE name = 'bell peppers';
SELECT id INTO soda_id FROM public.master_grocery_items WHERE name = 'soda';
SELECT id INTO paper_towels_id FROM public.master_grocery_items WHERE name = 'paper towels';
SELECT id INTO toilet_paper_id FROM public.master_grocery_items WHERE name = 'toilet paper';
-- Insert aliases, ignoring any that might already exist
INSERT INTO public.master_item_aliases (master_item_id, alias) VALUES
(ground_beef_id, 'ground chuck'), (ground_beef_id, 'lean ground beef'), (ground_beef_id, 'extra lean ground beef'), (ground_beef_id, 'hamburger meat'),
(chicken_breast_id, 'boneless skinless chicken breast'), (chicken_breast_id, 'chicken cutlets'),
(chicken_thighs_id, 'boneless skinless chicken thighs'), (chicken_thighs_id, 'bone-in chicken thighs'),
(bell_peppers_id, 'red pepper'), (bell_peppers_id, 'green pepper'), (bell_peppers_id, 'yellow pepper'), (bell_peppers_id, 'orange pepper'),
(soda_id, 'pop'), (soda_id, 'soft drink'), (soda_id, 'coke'), (soda_id, 'pepsi'),
(paper_towels_id, 'paper towel'),
(toilet_paper_id, 'bathroom tissue'), (toilet_paper_id, 'toilet tissue')
ON CONFLICT (alias) DO NOTHING;
END $$;
-- 4. Pre-populate recipes, ingredients, and tags to make the recommendation engine effective.
DO $$
DECLARE
-- Recipe IDs
chicken_recipe_id BIGINT;
bolognese_recipe_id BIGINT;
stir_fry_recipe_id BIGINT;
-- Ingredient (Master Item) IDs
chicken_breast_id BIGINT; rice_id BIGINT; broccoli_id BIGINT;
ground_beef_id BIGINT; pasta_id BIGINT; tomatoes_id BIGINT; onions_id BIGINT; garlic_id BIGINT;
bell_peppers_id BIGINT; carrots_id BIGINT; soy_sauce_id BIGINT;
-- Tag IDs
quick_easy_tag BIGINT; healthy_tag BIGINT; chicken_tag BIGINT;
family_tag BIGINT; beef_tag BIGINT; weeknight_tag BIGINT; vegetarian_tag BIGINT;
BEGIN
-- Insert sample recipes and get their IDs
INSERT INTO public.recipes (name, description, instructions, prep_time_minutes, cook_time_minutes, servings) VALUES
('Simple Chicken and Rice', 'A quick and healthy weeknight meal with chicken, rice, and broccoli.', '1. Cook rice according to package directions. 2. Steam broccoli. 3. Pan-sear chicken breast until cooked through. 4. Combine and serve.', 10, 20, 4),
('Classic Spaghetti Bolognese', 'A rich and hearty meat sauce served over spaghetti, perfect for the whole family.', '1. Brown ground beef with onions and garlic. 2. Add tomatoes and simmer for 30 minutes. 3. Cook pasta. 4. Serve sauce over pasta.', 15, 45, 6),
('Vegetable Stir-fry', 'A fast, flavorful, and vegetarian stir-fry loaded with fresh vegetables.', '1. Chop all vegetables. 2. Heat oil in a wok or large pan. 3. Stir-fry vegetables for 5-7 minutes until tender-crisp. 4. Add soy sauce and serve immediately.', 10, 10, 3)
ON CONFLICT (name) DO NOTHING;
SELECT id INTO chicken_recipe_id FROM public.recipes WHERE name = 'Simple Chicken and Rice';
SELECT id INTO bolognese_recipe_id FROM public.recipes WHERE name = 'Classic Spaghetti Bolognese';
SELECT id INTO stir_fry_recipe_id FROM public.recipes WHERE name = 'Vegetable Stir-fry';
-- Get ingredient IDs from master_grocery_items
SELECT id INTO chicken_breast_id FROM public.master_grocery_items WHERE name = 'chicken breast';
SELECT id INTO rice_id FROM public.master_grocery_items WHERE name = 'rice';
SELECT id INTO broccoli_id FROM public.master_grocery_items WHERE name = 'broccoli';
SELECT id INTO ground_beef_id FROM public.master_grocery_items WHERE name = 'ground beef';
SELECT id INTO pasta_id FROM public.master_grocery_items WHERE name = 'pasta';
SELECT id INTO tomatoes_id FROM public.master_grocery_items WHERE name = 'tomatoes';
SELECT id INTO onions_id FROM public.master_grocery_items WHERE name = 'onions';
SELECT id INTO garlic_id FROM public.master_grocery_items WHERE name = 'garlic';
SELECT id INTO bell_peppers_id FROM public.master_grocery_items WHERE name = 'bell peppers';
SELECT id INTO carrots_id FROM public.master_grocery_items WHERE name = 'carrots';
SELECT id INTO soy_sauce_id FROM public.master_grocery_items WHERE name = 'soy sauce';
-- Insert ingredients for each recipe
INSERT INTO public.recipe_ingredients (recipe_id, master_item_id, quantity, unit) VALUES
(chicken_recipe_id, chicken_breast_id, 2, 'items'), (chicken_recipe_id, rice_id, 200, 'g'), (chicken_recipe_id, broccoli_id, 300, 'g'),
(bolognese_recipe_id, ground_beef_id, 500, 'g'), (bolognese_recipe_id, pasta_id, 400, 'g'), (bolognese_recipe_id, tomatoes_id, 800, 'g'), (bolognese_recipe_id, onions_id, 1, 'items'), (bolognese_recipe_id, garlic_id, 2, 'cloves'),
(stir_fry_recipe_id, broccoli_id, 200, 'g'), (stir_fry_recipe_id, bell_peppers_id, 1, 'items'), (stir_fry_recipe_id, carrots_id, 2, 'items'), (stir_fry_recipe_id, onions_id, 1, 'items'), (stir_fry_recipe_id, soy_sauce_id, 50, 'ml')
ON CONFLICT (id) DO NOTHING;
-- Insert tags and get their IDs
INSERT INTO public.tags (name) VALUES ('Quick & Easy'), ('Healthy'), ('Chicken'), ('Family Friendly'), ('Beef'), ('Weeknight Dinner'), ('Vegetarian')
ON CONFLICT (name) DO NOTHING;
SELECT id INTO quick_easy_tag FROM public.tags WHERE name = 'Quick & Easy';
SELECT id INTO healthy_tag FROM public.tags WHERE name = 'Healthy';
SELECT id INTO chicken_tag FROM public.tags WHERE name = 'Chicken';
SELECT id INTO family_tag FROM public.tags WHERE name = 'Family Friendly';
SELECT id INTO beef_tag FROM public.tags WHERE name = 'Beef';
SELECT id INTO weeknight_tag FROM public.tags WHERE name = 'Weeknight Dinner';
SELECT id INTO vegetarian_tag FROM public.tags WHERE name = 'Vegetarian';
-- Link tags to recipes
INSERT INTO public.recipe_tags (recipe_id, tag_id) VALUES
(chicken_recipe_id, quick_easy_tag), (chicken_recipe_id, healthy_tag), (chicken_recipe_id, chicken_tag), (chicken_recipe_id, weeknight_tag),
(bolognese_recipe_id, family_tag), (bolognese_recipe_id, beef_tag), (bolognese_recipe_id, weeknight_tag),
(stir_fry_recipe_id, quick_easy_tag), (stir_fry_recipe_id, healthy_tag), (stir_fry_recipe_id, vegetarian_tag)
ON CONFLICT (recipe_id, tag_id) DO NOTHING;
END $$;
-- 5. Pre-populate the unit_conversions table with common cooking conversions.
-- Factors are for converting 1 unit of `from_unit` to the `to_unit`.
DO $$
DECLARE
flour_id BIGINT; sugar_id BIGINT; butter_id BIGINT; milk_id BIGINT; water_id BIGINT; rice_id BIGINT;
BEGIN
-- Get master item IDs
SELECT id INTO flour_id FROM public.master_grocery_items WHERE name = 'flour';
SELECT id INTO sugar_id FROM public.master_grocery_items WHERE name = 'sugar';
SELECT id INTO butter_id FROM public.master_grocery_items WHERE name = 'butter';
SELECT id INTO milk_id FROM public.master_grocery_items WHERE name = 'milk';
SELECT id INTO water_id FROM public.master_grocery_items WHERE name = 'water';
SELECT id INTO rice_id FROM public.master_grocery_items WHERE name = 'rice';
-- Insert conversion factors
INSERT INTO public.unit_conversions (master_item_id, from_unit, to_unit, factor) VALUES
-- 1 gram of flour is approx 0.00833 cups
(flour_id, 'g', 'cup', 0.00833), (flour_id, 'cup', 'g', 120),
-- 1 gram of sugar is approx 0.005 cups
(sugar_id, 'g', 'cup', 0.005), (sugar_id, 'cup', 'g', 200),
-- 1 gram of butter is approx 0.0044 cups
(butter_id, 'g', 'cup', 0.0044), (butter_id, 'cup', 'g', 227),
-- 1 ml of water/milk is approx 0.0042 cups
(water_id, 'ml', 'cup', 0.0042), (water_id, 'cup', 'ml', 240),
(milk_id, 'ml', 'cup', 0.0042), (milk_id, 'cup', 'ml', 240)
ON CONFLICT (master_item_id, from_unit, to_unit) DO NOTHING;
END $$;

View File

@@ -3,13 +3,11 @@
-- ============================================================================
-- Purpose:
-- This file contains the master SQL schema for the entire Supabase database.
-- It is designed to be a "one-click" script that can be run in the Supabase
-- SQL Editor to set up the entire backend from scratch, including:
-- It is designed to be a "one-click" script that can be run in a PostgreSQL
-- database to set up the entire backend from scratch, including:
-- 1. Enabling required Postgres extensions.
-- 2. Creating all tables with relationships and constraints.
-- 3. Seeding essential initial data (categories, master items).
-- 4. Creating the necessary storage buckets.
-- 5. Applying comprehensive Row Level Security (RLS) policies.
-- 6. Defining database functions for business logic.
-- 7. Setting up triggers for automation (e.g., creating user profiles).
--
@@ -492,6 +490,74 @@ BEGIN
ON CONFLICT (name) DO NOTHING;
END $$;
-- Pre-populate recipes, ingredients, and tags to make the recommendation engine effective.
DO $$
DECLARE
-- Recipe IDs
chicken_recipe_id BIGINT;
bolognese_recipe_id BIGINT;
stir_fry_recipe_id BIGINT;
-- Ingredient (Master Item) IDs
chicken_breast_id BIGINT; rice_id BIGINT; broccoli_id BIGINT;
ground_beef_id BIGINT; pasta_id BIGINT; tomatoes_id BIGINT; onions_id BIGINT; garlic_id BIGINT;
bell_peppers_id BIGINT; carrots_id BIGINT; soy_sauce_id BIGINT;
-- Tag IDs
quick_easy_tag BIGINT; healthy_tag BIGINT; chicken_tag BIGINT;
family_tag BIGINT; beef_tag BIGINT; weeknight_tag BIGINT; vegetarian_tag BIGINT;
BEGIN
-- Insert sample recipes and get their IDs
INSERT INTO public.recipes (name, description, instructions, prep_time_minutes, cook_time_minutes, servings) VALUES
('Simple Chicken and Rice', 'A quick and healthy weeknight meal with chicken, rice, and broccoli.', '1. Cook rice according to package directions. 2. Steam broccoli. 3. Pan-sear chicken breast until cooked through. 4. Combine and serve.', 10, 20, 4),
('Classic Spaghetti Bolognese', 'A rich and hearty meat sauce served over spaghetti, perfect for the whole family.', '1. Brown ground beef with onions and garlic. 2. Add tomatoes and simmer for 30 minutes. 3. Cook pasta. 4. Serve sauce over pasta.', 15, 45, 6),
('Vegetable Stir-fry', 'A fast, flavorful, and vegetarian stir-fry loaded with fresh vegetables.', '1. Chop all vegetables. 2. Heat oil in a wok or large pan. 3. Stir-fry vegetables for 5-7 minutes until tender-crisp. 4. Add soy sauce and serve immediately.', 10, 10, 3)
ON CONFLICT (name) DO NOTHING;
SELECT id INTO chicken_recipe_id FROM public.recipes WHERE name = 'Simple Chicken and Rice';
SELECT id INTO bolognese_recipe_id FROM public.recipes WHERE name = 'Classic Spaghetti Bolognese';
SELECT id INTO stir_fry_recipe_id FROM public.recipes WHERE name = 'Vegetable Stir-fry';
-- Get ingredient IDs from master_grocery_items
SELECT id INTO chicken_breast_id FROM public.master_grocery_items WHERE name = 'chicken breast';
SELECT id INTO rice_id FROM public.master_grocery_items WHERE name = 'rice';
SELECT id INTO broccoli_id FROM public.master_grocery_items WHERE name = 'broccoli';
SELECT id INTO ground_beef_id FROM public.master_grocery_items WHERE name = 'ground beef';
SELECT id INTO pasta_id FROM public.master_grocery_items WHERE name = 'pasta';
SELECT id INTO tomatoes_id FROM public.master_grocery_items WHERE name = 'tomatoes';
SELECT id INTO onions_id FROM public.master_grocery_items WHERE name = 'onions';
SELECT id INTO garlic_id FROM public.master_grocery_items WHERE name = 'garlic';
SELECT id INTO bell_peppers_id FROM public.master_grocery_items WHERE name = 'bell peppers';
SELECT id INTO carrots_id FROM public.master_grocery_items WHERE name = 'carrots';
SELECT id INTO soy_sauce_id FROM public.master_grocery_items WHERE name = 'soy sauce';
-- Insert ingredients for each recipe
INSERT INTO public.recipe_ingredients (recipe_id, master_item_id, quantity, unit) VALUES
(chicken_recipe_id, chicken_breast_id, 2, 'items'), (chicken_recipe_id, rice_id, 200, 'g'), (chicken_recipe_id, broccoli_id, 300, 'g'),
(bolognese_recipe_id, ground_beef_id, 500, 'g'), (bolognese_recipe_id, pasta_id, 400, 'g'), (bolognese_recipe_id, tomatoes_id, 800, 'g'), (bolognese_recipe_id, onions_id, 1, 'items'), (bolognese_recipe_id, garlic_id, 2, 'cloves'),
(stir_fry_recipe_id, broccoli_id, 200, 'g'), (stir_fry_recipe_id, bell_peppers_id, 1, 'items'), (stir_fry_recipe_id, carrots_id, 2, 'items'), (stir_fry_recipe_id, onions_id, 1, 'items'), (stir_fry_recipe_id, soy_sauce_id, 50, 'ml')
ON CONFLICT (id) DO NOTHING;
-- Insert tags and get their IDs
INSERT INTO public.tags (name) VALUES ('Quick & Easy'), ('Healthy'), ('Chicken'), ('Family Friendly'), ('Beef'), ('Weeknight Dinner'), ('Vegetarian')
ON CONFLICT (name) DO NOTHING;
SELECT id INTO quick_easy_tag FROM public.tags WHERE name = 'Quick & Easy';
SELECT id INTO healthy_tag FROM public.tags WHERE name = 'Healthy';
SELECT id INTO chicken_tag FROM public.tags WHERE name = 'Chicken';
SELECT id INTO family_tag FROM public.tags WHERE name = 'Family Friendly';
SELECT id INTO beef_tag FROM public.tags WHERE name = 'Beef';
SELECT id INTO weeknight_tag FROM public.tags WHERE name = 'Weeknight Dinner';
SELECT id INTO vegetarian_tag FROM public.tags WHERE name = 'Vegetarian';
-- Link tags to recipes
INSERT INTO public.recipe_tags (recipe_id, tag_id) VALUES
(chicken_recipe_id, quick_easy_tag), (chicken_recipe_id, healthy_tag), (chicken_recipe_id, chicken_tag), (chicken_recipe_id, weeknight_tag),
(bolognese_recipe_id, family_tag), (bolognese_recipe_id, beef_tag), (bolognese_recipe_id, weeknight_tag),
(stir_fry_recipe_id, quick_easy_tag), (stir_fry_recipe_id, healthy_tag), (stir_fry_recipe_id, vegetarian_tag)
ON CONFLICT (recipe_id, tag_id) DO NOTHING;
END $$;
-- ============================================================================
-- PART 3: STORAGE
-- ============================================================================
@@ -858,6 +924,56 @@ AS $$
r.avg_rating DESC, r.name ASC;
$$;
-- Function to approve a suggested correction and apply it.
-- This is a SECURITY DEFINER function to allow an admin to update tables
-- they might not have direct RLS access to.
CREATE OR REPLACE FUNCTION public.approve_correction(p_correction_id BIGINT)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
correction_record RECORD;
target_flyer_item RECORD;
new_master_item_id BIGINT;
BEGIN
-- 1. Fetch the correction details
SELECT * INTO correction_record
FROM public.suggested_corrections
WHERE id = p_correction_id AND status = 'pending';
IF NOT FOUND THEN
RAISE EXCEPTION 'Correction with ID % not found or already processed.', p_correction_id;
END IF;
-- 2. Fetch the target flyer item
SELECT * INTO target_flyer_item
FROM public.flyer_items
WHERE id = correction_record.flyer_item_id;
-- 3. Apply the correction based on its type
IF correction_record.correction_type = 'INCORRECT_ITEM_LINK' THEN
-- The suggested_value is expected to be the ID of the correct master_grocery_item
new_master_item_id := correction_record.suggested_value::BIGINT;
UPDATE public.flyer_items
SET master_item_id = new_master_item_id
WHERE id = correction_record.flyer_item_id;
ELSIF correction_record.correction_type = 'WRONG_PRICE' THEN
-- The suggested_value is expected to be the new price in cents
UPDATE public.flyer_items
SET price_in_cents = correction_record.suggested_value::INTEGER
WHERE id = correction_record.flyer_item_id;
END IF;
-- 4. Update the correction status to 'approved'
UPDATE public.suggested_corrections
SET status = 'approved', reviewed_at = now()
WHERE id = p_correction_id;
END;
$$;
-- ============================================================================
-- PART 6: SYSTEM CHECK HELPER FUNCTIONS

View File

@@ -1,26 +1,33 @@
-- This file contains all trigger functions and trigger definitions for the database.
-- 1. Set up the trigger to automatically create a profile when a new user signs up.
-- This function will be called by the trigger.
-- This function is called by a trigger on the `public.users` table.
-- It creates a corresponding profile and a default shopping list for the new user.
-- It now accepts full_name and avatar_url from the user's metadata.
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
DECLARE
new_profile_id UUID;
user_meta_data JSONB;
BEGIN
INSERT INTO public.profiles (id, full_name, avatar_url)
VALUES (new.id, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url')
-- The user's metadata (full_name, avatar_url) is passed via a temporary session variable.
user_meta_data := current_setting('my_app.user_metadata', true)::JSONB;
INSERT INTO public.profiles (id, role, full_name, avatar_url)
VALUES (new.id, 'user', user_meta_data->>'full_name', user_meta_data->>'avatar_url')
RETURNING id INTO new_profile_id;
-- Also create a default shopping list for the new user.
INSERT INTO public.shopping_lists (user_id, name)
VALUES (new_profile_id, 'Main Shopping List');
RETURN new;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
$$ LANGUAGE plpgsql;
-- This trigger calls the function after a new user is created.
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
DROP TRIGGER IF EXISTS on_auth_user_created ON public.users;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
AFTER INSERT ON public.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
-- 2. Create a reusable function to automatically update 'updated_at' columns.
@@ -30,7 +37,7 @@ BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
$$ LANGUAGE plpgsql;
-- Apply the trigger to the 'profiles' table.
DROP TRIGGER IF EXISTS on_profile_updated ON public.profiles;
@@ -82,7 +89,7 @@ BEGIN
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
$$ LANGUAGE plpgsql;
-- Create the trigger on the flyer_items table for insert.
DROP TRIGGER IF EXISTS trigger_update_price_history ON public.flyer_items;
@@ -146,7 +153,7 @@ BEGIN
RETURN OLD;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
$$ LANGUAGE plpgsql;
-- Create the trigger on the flyer_items table for DELETE operations.
DROP TRIGGER IF EXISTS trigger_recalculate_price_history_on_delete ON public.flyer_items;
@@ -174,7 +181,7 @@ BEGIN
RETURN NULL; -- The result is ignored since this is an AFTER trigger.
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
$$ LANGUAGE plpgsql;
-- Trigger to call the function after any change to recipe_ratings.
DROP TRIGGER IF EXISTS on_recipe_rating_change ON public.recipe_ratings;

View File

@@ -13,7 +13,6 @@ import { isImageAFlyer, extractCoreDataFromImage, extractAddressFromImage, extra
import type { FlyerItem, Flyer, MasterGroceryItem, DealItem, ProcessingStage, StageStatus, Profile, ShoppingList, ShoppingListItem, User, UserProfile } from './types';
import { BulkImporter } from './components/BulkImporter';
import { PriceHistoryChart } from './components/PriceHistoryChart'; // This import seems to have a supabase dependency, but the component is not provided. Assuming it will be updated separately.
import { supabase } from './services/supabaseClient'; // This will be removed shortly.
import { getAuthenticatedUserProfile, fetchFlyers as apiFetchFlyers, fetchMasterItems as apiFetchMasterItems, fetchWatchedItems as apiFetchWatchedItems, addWatchedItem as apiAddWatchedItem, removeWatchedItem as apiRemoveWatchedItem, fetchShoppingLists as apiFetchShoppingLists, createShoppingList as apiCreateShoppingList, deleteShoppingList as apiDeleteShoppingList, addShoppingListItem as apiAddShoppingListItem, updateShoppingListItem as apiUpdateShoppingListItem, removeShoppingListItem as apiRemoveShoppingListItem, processFlyerFile, fetchFlyerItems as apiFetchFlyerItems, fetchFlyerItemsForFlyers as apiFetchFlyerItemsForFlyers, countFlyerItemsForFlyers as apiCountFlyerItemsForFlyers, uploadLogoAndUpdateStore } from './services/apiClient'; // updateUserPreferences is no longer called directly from App.tsx
import { FlyerList } from './components/FlyerList';
import { recordProcessingTime, getAverageProcessingTime } from './utils/processingTimer';
@@ -117,19 +116,35 @@ function App() {
// This is the login handler that will be passed to the ProfileManager component.
const handleLoginSuccess = async (loggedInUser: User, token: string) => {
setError(null); // Clear previous errors on a new attempt
setError(null);
// Immediately store the token so subsequent API calls in this function are authenticated.
localStorage.setItem('authToken', token);
try {
localStorage.setItem('authToken', token); // Store token
setUser(loggedInUser);
setAuthStatus('AUTHENTICATED');
// After successful login, fetch the detailed profile from our new backend endpoint.
const userProfile = await getAuthenticatedUserProfile();
setProfile(userProfile);
logger.info('Login successful', { user: loggedInUser });
// Fetch all essential user data *before* setting the final authenticated state.
// This ensures the app doesn't enter an inconsistent state if one of these calls fails.
const [userProfile, watchedData, listsData] = await Promise.all([
getAuthenticatedUserProfile(),
apiFetchWatchedItems(),
apiFetchShoppingLists(),
]);
// Now that all data is successfully fetched, update the application state.
setProfile(userProfile);
setUser(loggedInUser); // Or userProfile.user, which should be identical
setAuthStatus('AUTHENTICATED');
setWatchedItems(watchedData);
setShoppingLists(listsData);
if (listsData.length > 0) {
setActiveListId(listsData[0].id);
}
logger.info('Login and data fetch successful', { user: loggedInUser });
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
logger.error('Failed to fetch profile after login', { error: errorMessage });
setError(errorMessage);
const errorMessage = e instanceof Error ? e.message : String(e);
logger.error('Failed to fetch user data after login. Rolling back.', { error: errorMessage });
setError(`Login succeeded, but failed to fetch your data: ${errorMessage}`);
handleSignOut(); // Log the user out to prevent an inconsistent state.
}
};
@@ -219,14 +234,6 @@ function App() {
checkAuthToken();
}, []);
// Effect to fetch user-specific data once authenticated.
useEffect(() => {
if (authStatus === 'AUTHENTICATED' && user) {
fetchWatchedItems();
fetchShoppingLists();
}
}, [authStatus, user, fetchWatchedItems, fetchShoppingLists]);
useEffect(() => {
if (isReady) {

View File

@@ -1,8 +1,8 @@
import React, { useMemo, useState } from 'react';
import type { FlyerItem, MasterGroceryItem, ShoppingList } from '../types';
import { formatUnitPrice } from '../utils/unitConverter';
import { Session } from '@supabase/supabase-js';
import { PlusCircleIcon } from './icons/PlusCircleIcon';
import { User } from '../types';
interface ExtractedDataTableProps {
items: FlyerItem[];
@@ -10,14 +10,14 @@ interface ExtractedDataTableProps {
watchedItems?: MasterGroceryItem[];
masterItems: MasterGroceryItem[];
unitSystem: 'metric' | 'imperial';
session: Session | null;
user: User | null;
onAddItem: (itemName: string, category: string) => Promise<void>;
shoppingLists: ShoppingList[];
activeListId: number | null;
onAddItemToList: (masterItemId: number) => void;
}
export const ExtractedDataTable: React.FC<ExtractedDataTableProps> = ({ items, totalActiveItems, watchedItems = [], masterItems, unitSystem, session, onAddItem, shoppingLists, activeListId, onAddItemToList }) => {
export const ExtractedDataTable: React.FC<ExtractedDataTableProps> = ({ items, totalActiveItems, watchedItems = [], masterItems, unitSystem, user, onAddItem, shoppingLists, activeListId, onAddItemToList }) => {
const [categoryFilter, setCategoryFilter] = useState('all');
const watchedItemIds = useMemo(() => new Set(watchedItems.map(item => item.id)), [watchedItems]);
@@ -120,7 +120,7 @@ export const ExtractedDataTable: React.FC<ExtractedDataTableProps> = ({ items, t
<div className="flex justify-between items-center mb-2">
<div className={itemNameClass}>{item.item}</div>
<div className="flex items-center space-x-2 shrink-0 ml-4">
{session && canonicalName && !isInList && (
{user && canonicalName && !isInList && (
<button
onClick={() => onAddItemToList(item.master_item_id!)}
disabled={!activeListId}
@@ -130,7 +130,7 @@ export const ExtractedDataTable: React.FC<ExtractedDataTableProps> = ({ items, t
<PlusCircleIcon className="w-5 h-5" />
</button>
)}
{session && !isWatched && canonicalName && (
{user && !isWatched && canonicalName && (
<button
onClick={() => onAddItem(canonicalName, item.category_name || 'Other/Miscellaneous')}
className="text-xs bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-brand-primary dark:text-brand-light font-semibold py-1 px-2.5 rounded-md transition-colors duration-200"

View File

@@ -1,21 +1,20 @@
import React from 'react';
import type { DealItem } from '../types';
import type { DealItem, User } from '../types';
import { TagIcon } from './icons/TagIcon';
import { LoadingSpinner } from './LoadingSpinner';
import { formatUnitPrice } from '../utils/unitConverter';
import { Session } from '@supabase/supabase-js';
import { UserIcon } from './icons/UserIcon';
interface PriceChartProps {
deals: DealItem[];
isLoading: boolean;
unitSystem: 'metric' | 'imperial';
session: Session | null;
user: User | null;
}
export const PriceChart: React.FC<PriceChartProps> = ({ deals, isLoading, unitSystem, session }) => {
export const PriceChart: React.FC<PriceChartProps> = ({ deals, isLoading, unitSystem, user }) => {
const renderContent = () => {
if (!session) {
if (!user) {
return (
<div className="flex flex-col items-center justify-center h-full min-h-[150px] text-center">
<UserIcon className="w-10 h-10 text-gray-400 mb-3" />

View File

@@ -174,6 +174,31 @@ describe('ProfileManager Authentication Flows', () => {
});
});
it('should call registerUser with all fields on successful registration', async () => {
render(<ProfileManager {...defaultProps} />);
// 1. Switch to the registration form
fireEvent.click(screen.getByRole('button', { name: /register/i }));
// 2. Fill out all fields in the form
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: 'New Test User' } });
fireEvent.change(screen.getByLabelText(/avatar url/i), { target: { value: 'http://example.com/new.png' } });
fireEvent.change(screen.getByLabelText(/email address/i), { target: { value: 'newuser@test.com' } });
fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'newsecurepassword' } });
// 3. Submit the registration form
fireEvent.click(screen.getByRole('button', { name: /register/i }));
// 4. Assert that the correct functions were called with the correct data
await waitFor(() => {
expect(apiClient.registerUser).toHaveBeenCalledWith('newuser@test.com', 'newsecurepassword', 'New Test User', 'http://example.com/new.png');
expect(mockOnLoginSuccess).toHaveBeenCalledWith(
{ id: '123', email: 'test@example.com' },
'mock-token'
);
expect(mockOnClose).toHaveBeenCalled();
});
});
it('should display an error message on failed registration', async () => {
(apiClient.registerUser as Mock).mockRejectedValueOnce(new Error('Email already in use'));
render(<ProfileManager {...defaultProps} />);
@@ -339,6 +364,25 @@ describe('ProfileManager Authenticated User Features', () => {
});
});
it('should show an error if updating the profile fails', async () => {
// Mock the API to reject the promise
(apiClient.updateUserProfile as Mock).mockRejectedValueOnce(new Error('Server is down'));
render(<ProfileManager {...authenticatedProps} />);
fireEvent.click(screen.getByRole('tab', { name: /profile/i }));
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: 'A Name That Will Fail' } });
fireEvent.click(screen.getByRole('button', { name: /save profile/i }));
await waitFor(() => {
// Check that the error toast is displayed
expect(screen.getByText('Server is down')).toBeInTheDocument();
});
// Ensure the success callback was NOT called
expect(mockOnProfileUpdate).not.toHaveBeenCalled();
});
// --- Security Tab ---
it('should allow updating the password', async () => {
render(<ProfileManager {...authenticatedProps} />);

View File

@@ -51,9 +51,12 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
const [isRegistering, setIsRegistering] = useState(false);
const [authEmail, setAuthEmail] = useState('');
const [authPassword, setAuthPassword] = useState('');
const [authFullName, setAuthFullName] = useState(''); // State for full name
const [authAvatarUrl, setAuthAvatarUrl] = useState(''); // State for avatar URL
const [authLoading, setAuthLoading] = useState(false);
const [authError, setAuthError] = useState('');
const [isForgotPassword, setIsForgotPassword] = useState(false);
const [rememberMe, setRememberMe] = useState(false);
useEffect(() => {
@@ -69,9 +72,12 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
setShowPassword(false);
setAuthEmail('');
setAuthPassword('');
setAuthFullName('');
setAuthAvatarUrl('');
setAuthError('');
setIsRegistering(false);
setIsForgotPassword(false);
setRememberMe(false); // Reset on open
}
}, [isOpen, profile]); // Depend on isOpen and profile
@@ -235,10 +241,10 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
try {
let response;
if (isRegistering) {
response = await registerUser(authEmail, authPassword);
response = await registerUser(authEmail, authPassword, authFullName, authAvatarUrl);
logger.info('New user registration successful.', { email: authEmail });
} else {
response = await loginUser(authEmail, authPassword);
response = await loginUser(authEmail, authPassword, rememberMe);
}
onLoginSuccess(response.user, response.token);
onClose(); // Close modal on success
@@ -352,8 +358,20 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
{showPassword ? <EyeSlashIcon className="h-5 w-5" /> : <EyeIcon className="h-5 w-5" />}
</button>
</div>
{isRegistering && <PasswordStrengthIndicator password={authPassword} />}
</div>
{isRegistering && (
<div>
<label htmlFor="authFullName" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Full Name (Optional)</label>
<input id="authFullName" type="text" value={authFullName} onChange={e => setAuthFullName(e.target.value)} className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm" />
</div>
)}
{isRegistering && (
<div>
<label htmlFor="authAvatarUrl" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Avatar URL (Optional)</label>
<input id="authAvatarUrl" type="url" value={authAvatarUrl} onChange={e => setAuthAvatarUrl(e.target.value)} placeholder="https://..." className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm" />
</div>
)}
{isRegistering && <PasswordStrengthIndicator password={authPassword} />}
{!isRegistering && (
<div className="flex items-center justify-end text-sm">
<button type="button" onClick={() => setIsForgotPassword(true)} className="font-medium text-brand-primary hover:underline">

View File

@@ -1,6 +1,5 @@
import React, { useState, useMemo, useCallback } from 'react';
import { Session } from '@supabase/supabase-js';
import type { ShoppingList, ShoppingListItem } from '../types';
import type { ShoppingList, ShoppingListItem, User } from '../types';
import { UserIcon } from './icons/UserIcon';
import { ListBulletIcon } from './icons/ListBulletIcon';
import { LoadingSpinner } from './LoadingSpinner';
@@ -10,7 +9,7 @@ import { generateSpeechFromText } from '../services/geminiService';
import { decode, decodeAudioData } from '../utils/audioUtils';
interface ShoppingListComponentProps {
session: Session | null;
user: User | null;
lists: ShoppingList[];
activeListId: number | null;
onSelectList: (listId: number) => void;
@@ -21,7 +20,7 @@ interface ShoppingListComponentProps {
onRemoveItem: (itemId: number) => Promise<void>;
}
export const ShoppingListComponent: React.FC<ShoppingListComponentProps> = ({ session, lists, activeListId, onSelectList, onCreateList, onDeleteList, onAddItem, onUpdateItem, onRemoveItem }) => {
export const ShoppingListComponent: React.FC<ShoppingListComponentProps> = ({ user, lists, activeListId, onSelectList, onCreateList, onDeleteList, onAddItem, onUpdateItem, onRemoveItem }) => {
const [isCreatingList, setIsCreatingList] = useState(false);
const [customItemName, setCustomItemName] = useState('');
const [isAddingCustom, setIsAddingCustom] = useState(false);
@@ -92,7 +91,7 @@ export const ShoppingListComponent: React.FC<ShoppingListComponentProps> = ({ se
}
}, [activeList, neededItems]);
if (!session) {
if (!user) {
return (
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-4 text-center">
<div className="flex flex-col items-center justify-center h-full min-h-[150px]">

View File

@@ -3,7 +3,7 @@ import { startVoiceSession } from '../services/geminiService';
import { XMarkIcon } from './icons/XMarkIcon';
import { MicrophoneIcon } from './icons/MicrophoneIcon';
// FIX: Corrected the import path. Types should be imported from the top-level '@google/genai' package.
import { LiveServerMessage, Blob } from '@google/genai';
import type { LiveServerMessage, Blob } from '@google/genai';
import { encode } from '../utils/audioUtils';
interface VoiceAssistantProps {

View File

@@ -1,11 +1,10 @@
import React, { useState, useMemo } from 'react';
import type { MasterGroceryItem } from '../types';
import type { MasterGroceryItem, User } from '../types';
import { EyeIcon } from './icons/EyeIcon';
import { LoadingSpinner } from './LoadingSpinner';
import { SortAscIcon } from './icons/SortAscIcon';
import { SortDescIcon } from './icons/SortDescIcon';
import { CATEGORIES } from '../types';
import { Session } from '@supabase/supabase-js';
import { TrashIcon } from './icons/TrashIcon';
import { UserIcon } from './icons/UserIcon';
import { PlusCircleIcon } from './icons/PlusCircleIcon';
@@ -14,12 +13,12 @@ interface WatchedItemsListProps {
items: MasterGroceryItem[];
onAddItem: (itemName: string, category: string) => Promise<void>;
onRemoveItem: (masterItemId: number) => Promise<void>;
session: Session | null;
user: User | null;
activeListId: number | null;
onAddItemToList: (masterItemId: number) => void;
}
export const WatchedItemsList: React.FC<WatchedItemsListProps> = ({ items, onAddItem, onRemoveItem, session, activeListId, onAddItemToList }) => {
export const WatchedItemsList: React.FC<WatchedItemsListProps> = ({ items, onAddItem, onRemoveItem, user, activeListId, onAddItemToList }) => {
const [newItemName, setNewItemName] = useState('');
const [newCategory, setNewCategory] = useState('');
const [isAdding, setIsAdding] = useState(false);
@@ -66,7 +65,7 @@ export const WatchedItemsList: React.FC<WatchedItemsListProps> = ({ items, onAdd
});
}, [items, sortOrder, categoryFilter]);
if (!session) {
if (!user) {
return (
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-4 text-center">
<div className="flex flex-col items-center justify-center h-full min-h-[150px]">

View File

@@ -0,0 +1,112 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { ResetPasswordPage } from './ResetPasswordPage';
import * as apiClient from '../services/apiClient';
// Mock the apiClient module
vi.mock('../services/apiClient', async (importOriginal) => {
const actual = await importOriginal<typeof apiClient>();
return {
...actual,
resetPassword: vi.fn(),
};
});
// Mock the logger to prevent console output during tests
vi.mock('../services/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
// Helper function to render the component within a router context
const renderWithRouter = (token: string) => {
return render(
<MemoryRouter initialEntries={[`/reset-password/${token}`]}>
<Routes>
<Route path="/reset-password/:token" element={<ResetPasswordPage />} />
<Route path="/" element={<div>Home Page</div>} />
</Routes>
</MemoryRouter>
);
};
describe('ResetPasswordPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should render the form with password fields and a submit button', () => {
renderWithRouter('test-token-123');
expect(screen.getByRole('heading', { name: /set a new password/i })).toBeInTheDocument();
expect(screen.getByPlaceholderText('New Password')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Confirm New Password')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /reset password/i })).toBeInTheDocument();
});
it('should call resetPassword and show success message on valid submission', async () => {
(apiClient.resetPassword as Mock).mockResolvedValue({ message: 'Password reset was successful!' });
const token = 'valid-token';
renderWithRouter(token);
fireEvent.change(screen.getByPlaceholderText('New Password'), { target: { value: 'newSecurePassword123' } });
fireEvent.change(screen.getByPlaceholderText('Confirm New Password'), { target: { value: 'newSecurePassword123' } });
fireEvent.click(screen.getByRole('button', { name: /reset password/i }));
await waitFor(() => {
expect(apiClient.resetPassword).toHaveBeenCalledWith(token, 'newSecurePassword123');
expect(screen.getByText(/password reset was successful!/i)).toBeInTheDocument();
expect(screen.getByText(/return to home/i)).toBeInTheDocument();
});
// Check that form is cleared
expect(screen.queryByPlaceholderText('New Password')).not.toBeInTheDocument();
});
it('should show an error message if passwords do not match', async () => {
renderWithRouter('test-token');
fireEvent.change(screen.getByPlaceholderText('New Password'), { target: { value: 'passwordA' } });
fireEvent.change(screen.getByPlaceholderText('Confirm New Password'), { target: { value: 'passwordB' } });
fireEvent.click(screen.getByRole('button', { name: /reset password/i }));
await waitFor(() => {
expect(screen.getByText('Passwords do not match.')).toBeInTheDocument();
});
expect(apiClient.resetPassword).not.toHaveBeenCalled();
});
it('should show an error message if the API call fails', async () => {
(apiClient.resetPassword as Mock).mockRejectedValueOnce(new Error('Invalid or expired token.'));
renderWithRouter('invalid-token');
fireEvent.change(screen.getByPlaceholderText('New Password'), { target: { value: 'newSecurePassword123' } });
fireEvent.change(screen.getByPlaceholderText('Confirm New Password'), { target: { value: 'newSecurePassword123' } });
fireEvent.click(screen.getByRole('button', { name: /reset password/i }));
await waitFor(() => {
expect(screen.getByText('Invalid or expired token.')).toBeInTheDocument();
});
});
it('should show a loading spinner while submitting', async () => {
// Mock a promise that never resolves to keep the component in a loading state
(apiClient.resetPassword as Mock).mockReturnValueOnce(new Promise(() => {}));
renderWithRouter('test-token');
fireEvent.change(screen.getByPlaceholderText('New Password'), { target: { value: 'newSecurePassword123' } });
fireEvent.change(screen.getByPlaceholderText('Confirm New Password'), { target: { value: 'newSecurePassword123' } });
fireEvent.click(screen.getByRole('button', { name: /reset password/i }));
await waitFor(() => {
const button = screen.getByRole('button', { name: /reset password/i });
// Check for the SVG spinner within the button
expect(button.querySelector('svg')).toBeInTheDocument();
});
});
});

View File

@@ -11,19 +11,11 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3001
// --- API Fetch Wrapper with Token Refresh Logic ---
let isRefreshing = false;
let failedQueue: Array<{ resolve: (value?: unknown) => void; reject: (reason?: any) => void; }> = [];
const processQueue = (error: Error | null, token: string | null = null) => {
failedQueue.forEach(prom => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
/**
* A promise that holds the in-progress token refresh operation.
* This prevents multiple parallel refresh requests.
*/
let refreshTokenPromise: Promise<string> | null = null;
/**
* Attempts to refresh the access token using the HttpOnly refresh token cookie.
@@ -33,19 +25,22 @@ const refreshToken = async (): Promise<string> => {
try {
const response = await fetch(`${API_BASE_URL}/auth/refresh-token`, {
method: 'POST',
// This endpoint relies on the HttpOnly cookie, so no body is needed.
headers: { 'Content-Type': 'application/json' },
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Failed to refresh token.');
}
// On successful refresh, store the new access token.
localStorage.setItem('authToken', data.token);
return data.token;
} catch (error) {
// If refresh fails, the user must log in again.
// If refresh fails, the user is logged out.
// This could be because the refresh token expired or was revoked.
localStorage.removeItem('authToken');
// Force a reload to reset the app state to logged-out.
// A more advanced implementation might use a global state management event.
// A hard redirect is a simple way to reset the app state to logged-out.
// A more advanced implementation could use a global state manager to show a "Session Expired" modal.
window.location.href = '/';
throw error;
}
@@ -59,38 +54,47 @@ const refreshToken = async (): Promise<string> => {
* @returns A promise that resolves to the fetch Response.
*/
const apiFetch = async (url: string, options: RequestInit = {}): Promise<Response> => {
let token = localStorage.getItem('authToken');
// Create a new headers object to avoid mutating the original options.
const headers = new Headers(options.headers || {});
const token = localStorage.getItem('authToken');
if (token) {
options.headers = {
...options.headers,
'Authorization': `Bearer ${token}`,
};
headers.set('Authorization', `Bearer ${token}`);
}
const newOptions = { ...options, headers };
let response = await fetch(url, options);
let response = await fetch(url, newOptions);
if (response.status === 401) {
if (isRefreshing) {
// If a refresh is already in progress, wait for it to complete.
await new Promise((resolve, reject) => failedQueue.push({ resolve, reject }));
options.headers!['Authorization'] = `Bearer ${localStorage.getItem('authToken')}`;
return fetch(url, options); // Retry with the new token
// Prevent an infinite loop if the refresh token endpoint itself returns 401.
if (url.includes('/auth/refresh-token')) {
return response;
}
isRefreshing = true;
try {
await refreshToken();
processQueue(null, localStorage.getItem('authToken'));
options.headers!['Authorization'] = `Bearer ${localStorage.getItem('authToken')}`;
return fetch(url, options); // Retry the original request
// If no refresh is in progress, start one.
if (!refreshTokenPromise) {
refreshTokenPromise = refreshToken();
}
// Wait for the existing or new refresh operation to complete.
const newToken = await refreshTokenPromise;
// Retry the original request with the new token.
headers.set('Authorization', `Bearer ${newToken}`);
response = await fetch(url, { ...options, headers });
} catch (refreshError) {
// If refreshToken() fails, it will redirect, but we re-throw just in case.
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
// Clear the promise so the next 401 will trigger a new refresh.
refreshTokenPromise = null;
}
}
return response;
};
/**
* Pings the backend server to check if it's running and reachable.
* @returns A promise that resolves to true if the server responds with 'pong'.
@@ -443,13 +447,13 @@ export const getAuthenticatedUserProfile = async (): Promise<UserProfile> => {
return profile;
};
export async function loginUser(email: string, password: string): Promise<AuthResponse> {
export async function loginUser(email: string, password: string, rememberMe: boolean): Promise<AuthResponse> {
const response = await fetch(`${API_BASE_URL}/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
body: JSON.stringify({ email, password, rememberMe }),
});
const data = await response.json();
@@ -565,13 +569,23 @@ export const updateSuggestedCorrection = async (correctionId: number, newSuggest
return response.json();
};
export async function registerUser(email: string, password: string): Promise<AuthResponse> {
export async function registerUser(
email: string,
password: string,
fullName?: string,
avatarUrl?: string
): Promise<AuthResponse> {
const response = await fetch(`${API_BASE_URL}/auth/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
body: JSON.stringify({
email,
password,
full_name: fullName,
avatar_url: avatarUrl
}),
});
const data = await response.json();

View File

@@ -46,18 +46,42 @@ export async function findUserByEmail(email: string): Promise<DbUser | undefined
* Creates a new user in the public.users table.
* @param email The user's email.
* @param passwordHash The bcrypt hashed password.
* @param profileData An object containing optional full_name and avatar_url for the profile.
* @returns A promise that resolves to the newly created user object (id, email).
*/
export async function createUser(email: string, passwordHash: string): Promise<{ id: string; email: string }> {
export async function createUser(
email: string,
passwordHash: string,
profileData: { full_name?: string; avatar_url?: string }
): Promise<{ id: string; email: string }> {
// Use a client from the pool to run multiple queries in a transaction
const client = await pool.connect();
try {
const res = await pool.query<{ id: string; email: string }>(
// Start the transaction
await client.query('BEGIN');
// Set a temporary session variable with the user metadata.
// The 'handle_new_user' trigger will read this variable.
// We stringify the object to pass it as a single JSONB value.
await client.query('SET LOCAL my_app.user_metadata = $1', [JSON.stringify(profileData)]);
// Insert the new user into the 'users' table. This will fire the trigger.
const res = await client.query<{ id: string; email: string }>(
'INSERT INTO public.users (email, password_hash) VALUES ($1, $2) RETURNING id, email',
[email, passwordHash]
);
// Commit the transaction
await client.query('COMMIT');
return res.rows[0];
} catch (error) {
logger.error('Database error in createUser:', { error });
// If any query fails, roll back the entire transaction
await client.query('ROLLBACK');
logger.error('Database transaction error in createUser:', { error });
throw new Error('Failed to create user in database.');
} finally {
// Release the client back to the pool
client.release();
}
}
@@ -647,17 +671,19 @@ export async function createFlyerAndItems(
// Prepare and insert all flyer items
if (items.length > 0) {
// This approach builds a single INSERT statement with multiple VALUES clauses,
// which is highly efficient for bulk inserts.
const itemInsertQuery = `
INSERT INTO public.flyer_items (flyer_id, item, price_display, price_in_cents, quantity, master_item_id, category_name, unit_price)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
INSERT INTO public.flyer_items (
flyer_id, item, price_display, price_in_cents, quantity,
master_item_id, -- This will be populated by our suggestion function
category_name, unit_price
)
VALUES ($1, $2, $3, $4, $5, public.suggest_master_item_for_flyer_item($2), $6, $7)
`;
// We can execute multiple queries within the transaction.
// Looping and executing one query per item is safe and clear.
// For very high performance needs, a more complex single-query builder could be used,
// but this is a robust and secure starting point.
// Loop through each item and execute the insert query.
// The query now directly calls the `suggest_master_item_for_flyer_item` function
// on the database side, passing the item name (`item.item`) as the argument.
// This is more efficient than making a separate DB call for each item to get the suggestion.
for (const item of items) {
const itemValues = [
newFlyer.id,
@@ -665,7 +691,6 @@ export async function createFlyerAndItems(
item.price_display,
item.price_in_cents,
item.quantity,
item.master_item_id,
item.category_name,
item.unit_price ? JSON.stringify(item.unit_price) : null // Ensure JSONB is correctly stringified
];
@@ -1038,4 +1063,105 @@ export async function getDailyStatsForLast30Days(): Promise<{ date: string; new_
logger.error('Database error in getDailyStatsForLast30Days:', { error });
throw new Error('Failed to retrieve daily statistics.');
}
}
export async function getMostFrequentSaleItems(days: number, limit: number): Promise<any[]> {
try {
const res = await pool.query('SELECT * FROM public.get_most_frequent_sale_items($1, $2)', [days, limit]);
return res.rows;
} catch (error) {
logger.error('Database error in getMostFrequentSaleItems:', { error });
throw new Error('Failed to get most frequent sale items.');
}
}
export async function findRecipesFromPantry(userId: string): Promise<any[]> {
try {
const res = await pool.query('SELECT * FROM public.find_recipes_from_pantry($1)', [userId]);
return res.rows;
} catch (error) {
logger.error('Database error in findRecipesFromPantry:', { error, userId });
throw new Error('Failed to find recipes from pantry.');
}
}
export async function recommendRecipesForUser(userId: string, limit: number): Promise<any[]> {
try {
const res = await pool.query('SELECT * FROM public.recommend_recipes_for_user($1, $2)', [userId, limit]);
return res.rows;
} catch (error) {
logger.error('Database error in recommendRecipesForUser:', { error, userId });
throw new Error('Failed to recommend recipes.');
}
}
export async function getBestSalePricesForUser(userId: string): Promise<any[]> {
try {
const res = await pool.query('SELECT * FROM public.get_best_sale_prices_for_user($1)', [userId]);
return res.rows;
} catch (error) {
logger.error('Database error in getBestSalePricesForUser:', { error, userId });
throw new Error('Failed to get best sale prices.');
}
}
export async function suggestPantryItemConversions(pantryItemId: number): Promise<any[]> {
try {
const res = await pool.query('SELECT * FROM public.suggest_pantry_item_conversions($1)', [pantryItemId]);
return res.rows;
} catch (error) {
logger.error('Database error in suggestPantryItemConversions:', { error, pantryItemId });
throw new Error('Failed to suggest pantry item conversions.');
}
}
export async function generateShoppingListForMenuPlan(menuPlanId: number, userId: string): Promise<any[]> {
try {
const res = await pool.query('SELECT * FROM public.generate_shopping_list_for_menu_plan($1, $2)', [menuPlanId, userId]);
return res.rows;
} catch (error) {
logger.error('Database error in generateShoppingListForMenuPlan:', { error, menuPlanId });
throw new Error('Failed to generate shopping list for menu plan.');
}
}
export async function addMenuPlanToShoppingList(menuPlanId: number, shoppingListId: number, userId: string): Promise<any[]> {
try {
const res = await pool.query('SELECT * FROM public.add_menu_plan_to_shopping_list($1, $2, $3)', [menuPlanId, shoppingListId, userId]);
return res.rows;
} catch (error) {
logger.error('Database error in addMenuPlanToShoppingList:', { error, menuPlanId });
throw new Error('Failed to add menu plan to shopping list.');
}
}
export async function getRecipesBySalePercentage(minPercentage: number): Promise<any[]> {
try {
const res = await pool.query('SELECT * FROM public.get_recipes_by_sale_percentage($1)', [minPercentage]);
// The function returns JSONB, so we need to parse it.
return res.rows.map(row => row.recipe_details);
} catch (error) {
logger.error('Database error in getRecipesBySalePercentage:', { error });
throw new Error('Failed to get recipes by sale percentage.');
}
}
export async function getRecipesByMinSaleIngredients(minIngredients: number): Promise<any[]> {
try {
const res = await pool.query('SELECT * FROM public.get_recipes_by_min_sale_ingredients($1)', [minIngredients]);
return res.rows;
} catch (error) {
logger.error('Database error in getRecipesByMinSaleIngredients:', { error });
throw new Error('Failed to get recipes by minimum sale ingredients.');
}
}
export async function findRecipesByIngredientAndTag(ingredient: string, tag: string): Promise<any[]> {
try {
const res = await pool.query('SELECT * FROM public.find_recipes_by_ingredient_and_tag($1, $2)', [ingredient, tag]);
return res.rows;
} catch (error) {
logger.error('Database error in findRecipesByIngredientAndTag:', { error });
throw new Error('Failed to find recipes by ingredient and tag.');
}
}

View File

@@ -1,974 +0,0 @@
import { createClient, SupabaseClient, PostgrestError } from '@supabase/supabase-js';
import type { Flyer, FlyerItem, MasterGroceryItem, Profile, ShoppingList, ShoppingListItem, Store, SuggestedCorrection, UnitPrice } from '../types';
import { logger } from './logger';
import { Database, Json } from '../types/supabase';
// In a Vite project, environment variables are exposed on the `import.meta.env` object.
// For security, only variables prefixed with `VITE_` are exposed to the client-side code.
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
console.warn("Supabase environment variables not set. Running in no-database mode.");
}
// Create and export the Supabase client.
// If the keys are missing, this will be null, and features requiring it will be disabled.
export let supabase = (supabaseUrl && supabaseAnonKey)
? createClient<Database>(supabaseUrl, supabaseAnonKey)
: null;
// =============================================
// INTERNAL HELPERS
// =============================================
/**
* Initializes or re-initializes the Supabase client with new credentials.
* This is used for manual connection from the UI.
* @param url The Supabase project URL.
* @param anonKey The Supabase anon key.
* @returns The newly created Supabase client instance.
*/
export const initializeSupabase = (url: string, anonKey: string): SupabaseClient<Database> => {
if (!url || !anonKey) {
throw new Error("Supabase URL and Anon Key are required.");
}
supabase = createClient<Database>(url, anonKey);
return supabase;
};
/**
* A centralized check to ensure the Supabase client is initialized before use.
* @throws {Error} If the client is not initialized.
*/
const ensureSupabase = (): SupabaseClient<Database> => {
if (!supabase) {
throw new Error("Supabase client not initialized. Please check your environment variables and configuration.");
}
return supabase;
};
/**
* Generic helper to handle Supabase query responses, centralizing error handling and data casting.
* @param query A Supabase query promise.
* @returns The data from the query, or throws an error.
*/
const handleResponse = async <T>(query: PromiseLike<{
data: T | null;
error: PostgrestError | null;
}>): Promise<T | null> => {
const { data, error } = await query;
if (error) throw new Error(error.message);
return data;
};
/**
* A type guard to check if a given JSON object conforms to the UnitPrice interface.
* This provides runtime validation to safely cast the generic `Json` type from
* Supabase to our specific application `UnitPrice` type.
* @param obj The object to check.
* @returns True if the object is a valid UnitPrice, false otherwise.
*/
const isUnitPrice = (obj: unknown): obj is UnitPrice => {
return (
typeof obj === 'object' && obj !== null && 'value' in obj && 'unit' in obj && typeof (obj as UnitPrice).value === 'number' && typeof (obj as UnitPrice).unit === 'string'
);
};
/**
* Disconnects the Supabase client by setting the instance to null.
*/
export const disconnectSupabase = (): void => {
supabase = null;
// Clear stored credentials on explicit disconnect
localStorage.removeItem('supabaseUrl');
localStorage.removeItem('supabaseAnonKey');
};
/**
* Tests basic read access to the database.
* @returns An object indicating success and any error message.
*/
export const testDatabaseConnection = async (): Promise<{ success: boolean; error: string | null }> => {
try {
const { error } = await ensureSupabase().from('stores').select('id').limit(1);
if (error) throw error;
return { success: true, error: null };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return { success: false, error: `Database connection test failed: ${errorMessage}. Check RLS policies and client initialization.` };
}
};
/**
* Performs a full CRUD (Create, Read, Update, Delete) test on a table.
* @returns An object indicating success and any error message.
*/
export const runDatabaseSelfTest = async (): Promise<{ success: boolean; error: string | null }> => {
const supabaseClient = ensureSupabase();
const testItem = {
item: `DB_SELF_TEST_ITEM_${Date.now()}`,
price_display: '$0.00',
quantity: 'test',
};
try {
// 1. Insert
const { data: insertData, error: insertError } = await supabaseClient
.from('flyer_items')
.insert(testItem)
.select()
.single();
if (insertError) throw new Error(`Insert failed: ${insertError.message}`);
if (!insertData) throw new Error('Insert did not return data.');
// 2. Select (implicit in insert's .select())
const testItemId = insertData.id;
// 3. Update
const { error: updateError } = await supabaseClient
.from('flyer_items')
.update({ item: 'DB_SELF_TEST_ITEM_UPDATED' })
.eq('id', testItemId);
if (updateError) throw new Error(`Update failed: ${updateError.message}`);
// 4. Delete
const { error: deleteError } = await supabaseClient
.from('flyer_items')
.delete()
.eq('id', testItemId);
if (deleteError) throw new Error(`Delete failed: ${deleteError.message}`);
return { success: true, error: null };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return { success: false, error: `Database self-test failed: ${errorMessage}. Check table permissions (select, insert, update, delete) and RLS policies for 'flyer_items'.` };
}
};
/**
* Tests storage by uploading and deleting a file.
* @returns An object indicating success and any error message.
*/
export const testStorageConnection = async (): Promise<{ success: boolean; error: string | null }> => {
const supabaseClient = ensureSupabase();
const bucketName = 'flyers';
const testFileName = `storage-self-test-${Date.now()}.txt`;
const testFileContent = 'test';
try {
// 1. Upload
const { error: uploadError } = await supabaseClient.storage
.from(bucketName)
.upload(testFileName, testFileContent);
if (uploadError) throw new Error(`Upload to storage failed: ${uploadError.message}`);
// 2. Delete
const { error: deleteError } = await supabaseClient.storage
.from(bucketName)
.remove([testFileName]);
if (deleteError) throw new Error(`Deleting from storage failed: ${deleteError.message}`);
return { success: true, error: null };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return { success: false, error: `Storage connection test failed: ${errorMessage}. Check bucket permissions (select, insert, delete) and RLS policies for bucket '${bucketName}'.` };
}
};
/**
* Uploads a flyer image to Supabase storage.
* @param file The image file to upload.
* @returns The public URL of the uploaded image.
*/
export const uploadFlyerImage = async (file: File): Promise<string> => {
const supabaseClient = ensureSupabase();
const fileName = `${Date.now()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, '_')}`;
const { data: uploadData, error } = await supabaseClient.storage
.from('flyers')
.upload(fileName, file);
if (error) throw new Error(`Failed to upload flyer image: ${error.message}`);
if (!uploadData) throw new Error("Upload did not return a path.");
const { data: urlData } = supabaseClient.storage.from('flyers').getPublicUrl(uploadData.path);
const publicUrl = urlData?.publicUrl;
if (!publicUrl) throw new Error("Could not get public URL for uploaded flyer image.");
return publicUrl;
};
/**
* Creates a record for a new flyer in the database, handling store creation if needed.
* @returns The newly created flyer object, joined with its store.
*/
export const createFlyerRecord = async (
fileName: string,
imageUrl: string,
checksum: string,
storeName: string,
validFrom: string | null,
validTo: string | null,
storeAddress: string | null
): Promise<Flyer> => {
const supabaseClient = ensureSupabase();
let store = await handleResponse<Store>(
supabaseClient
.from('stores')
.select('*')
.ilike('name', storeName)
.maybeSingle()
);
if (!store) {
store = await handleResponse(supabaseClient
.from('stores')
.insert({ name: storeName })
.select()
.single());
}
const { data: newFlyer, error: flyerError } = await supabase
.from('flyers')
.insert({
file_name: fileName,
image_url: imageUrl,
checksum: checksum,
store_id: store.id,
valid_from: validFrom,
valid_to: validTo,
store_address: storeAddress,
})
.select('*, store:stores(*)')
.single();
if (flyerError) throw new Error(`Failed to create flyer record: ${flyerError.message}`);
if (!newFlyer) throw new Error("Flyer record creation did not return data.");
return newFlyer as Flyer;
};
/**
* Saves a list of extracted items to the database.
* @param items The items to save.
* @param flyerId The ID of the flyer these items belong to.
* @returns The array of saved items with their new IDs.
*/
export const saveFlyerItems = async (items: Omit<FlyerItem, 'id' | 'created_at' | 'flyer_id'>[], flyerId: number): Promise<FlyerItem[]> => {
const supabaseClient = ensureSupabase();
if (items.length === 0) return [];
// Define the type for the items we are inserting, which matches the DB schema.
type FlyerItemInsert = Database['public']['Tables']['flyer_items']['Insert'];
const itemsToInsert: FlyerItemInsert[] = items.map(item => {
// Create a new object without the properties that don't exist in the DB table
return { ...item, flyer_id: flyerId, unit_price: item.unit_price as unknown as Json | null };
});
const { data: savedItems, error } = await supabaseClient
.from('flyer_items')
// The `itemsToInsert` array is now correctly typed for the insert operation.
.insert(itemsToInsert)
.select();
if (error) throw new Error(`Failed to save flyer items: ${error.message}`);
if (!savedItems) return [];
// Safely map the returned data to the application type, handling unit_price
return savedItems.map(item => ({
...item,
unit_price: isUnitPrice(item.unit_price) ? item.unit_price : null,
}));
};
/**
* Retrieves all flyers from the database, ordered by most recent.
* @returns An array of flyer objects.
*/
export const getFlyers = async (): Promise<Flyer[]> => {
const supabaseClient = ensureSupabase();
const data = await handleResponse<Flyer[]>(supabaseClient
.from('flyers')
.select('*, store:stores(*)')
.order('created_at', { ascending: false }));
return data ?? [];
};
/**
* Retrieves all items for a specific flyer.
* @param flyerId The ID of the flyer.
* @returns An array of flyer item objects.
*/
export const getFlyerItems = async (flyerId: number): Promise<FlyerItem[]> => {
const supabaseClient = ensureSupabase();
// Fetch the data using the raw, auto-generated type from Supabase.
// This avoids the initial type conflict with `unit_price`.
const rawData = await handleResponse<Database['public']['Tables']['flyer_items']['Row'][]>(supabaseClient
.from('flyer_items')
.select('*')
.eq('flyer_id', flyerId)
.order('item', { ascending: true }));
if (!rawData) {
return [];
}
// Explicitly map the raw data to our application's `FlyerItem` type.
// We use the `isUnitPrice` type guard to safely handle the conversion
// from the generic `Json` type to our specific `UnitPrice` type.
return rawData.map(item => ({
...item,
// The type guard validates the shape at runtime. If it's not a valid
// UnitPrice, we treat it as null to maintain type safety.
unit_price: isUnitPrice(item.unit_price) ? item.unit_price : null,
}));
};
/**
* Looks for an existing flyer with a matching checksum to prevent duplicates.
* @param checksum The SHA-256 checksum of the file.
* @returns The found flyer object or null.
*/
export const findFlyerByChecksum = async (checksum: string): Promise<Flyer | null> => {
const supabaseClient = ensureSupabase();
const { data, error } = await supabaseClient
.from('flyers')
.select('*')
.eq('checksum', checksum)
.single();
if (error && error.code !== 'PGRST116') throw new Error(`Error finding flyer by checksum: ${error.message}`);
return data;
};
/**
* Uploads a store logo and updates the store record.
* This is designed to be a non-critical step. It won't throw an error but will log warnings.
* It will only update the store if no logo_url is currently set.
* @param storeId The ID of the store to update.
* @param logoBase64 The base64 encoded logo string.
* @returns The public URL of the logo if successful, otherwise null.
*/
export const uploadLogoAndUpdateStore = async (storeId: number, logoBase64: string): Promise<string | null> => {
const supabaseClient = ensureSupabase();
try {
// Helper function to convert base64 to a Blob for uploading.
const base64ToBlob = (base64: string, mimeType: string): Blob => {
const byteCharacters = atob(base64);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
return new Blob([byteArray], { type: mimeType });
};
const logoBlob = base64ToBlob(logoBase64, 'image/png');
const filePath = `logos/store_logo_${storeId}.png`;
const { data, error: uploadError } = await supabaseClient.storage
.from('flyers')
.upload(filePath, logoBlob, {
cacheControl: '3600',
upsert: true, // Overwrite if it exists, simplifies logic
});
if (uploadError) {
console.warn(`Failed to upload logo image: ${uploadError.message}`);
return null;
}
const { data: urlData } = supabaseClient.storage.from('flyers').getPublicUrl(data.path);
const publicUrl = urlData?.publicUrl;
if (!publicUrl) {
console.warn("Could not get public URL for uploaded logo.");
return null;
}
// Update the store record with the new URL, but only if it's currently null.
const { error: updateError } = await supabaseClient
.from('stores')
.update({ logo_url: publicUrl })
.eq('id', storeId)
.is('logo_url', null);
if (updateError) {
console.warn(`Failed to update store with new logo URL: ${updateError.message}`);
}
return publicUrl;
} catch (e) { // This catch block is now valid because of the preceding `try`.
const errorMessage = e instanceof Error ? e.message : String(e);
console.warn(`An error occurred during logo processing: ${errorMessage}`);
return null;
}
};
/**
* Retrieves all items a specific user is watching.
* @param userId The UUID of the user.
* @returns An array of master grocery item objects.
*/
export const getWatchedItems = async (userId: string): Promise<MasterGroceryItem[]> => {
const supabaseClient = ensureSupabase();
const data = await handleResponse<{ master_item: MasterGroceryItem & { category: { name: string } | null } }[] | null>(supabaseClient
.from('user_watched_items')
.select(`
master_item:master_grocery_items (
*,
category:categories (name)
)
`)
.eq('user_id', userId)
.order('name', { referencedTable: 'master_grocery_items', ascending: true }));
return (data?.map(item => ({ ...item.master_item, category_name: item.master_item.category?.name })) ?? []) as MasterGroceryItem[];
};
/**
* Retrieves all master grocery items. This is used for matching during extraction.
* @returns An array of master grocery item objects.
*/
export const getAllMasterItems = async (): Promise<MasterGroceryItem[]> => {
const supabaseClient = ensureSupabase();
// Define the expected raw type from the query, including the nested category object.
type MasterItemWithCategory = Database['public']['Tables']['master_grocery_items']['Row'] & {
category_name: { name: string } | null;
};
// Fetch the data using the raw, auto-generated type from Supabase.
const rawData = await handleResponse<MasterItemWithCategory[]>(supabaseClient
.from('master_grocery_items')
.select('*, category_name:categories(name)')
.order('name', { ascending: true }));
// Explicitly map the raw data to our application's `MasterGroceryItem` type,
// flattening the nested category name in the process.
return (rawData?.map(item => ({
...item,
category_name: item.category_name?.name,
})) ?? []) as MasterGroceryItem[]; // The final cast is safe as we've constructed the correct shape.
};
/**
* Adds a new item to a user's watchlist.
* It first ensures the master item exists, then creates the user-item link.
* @param userId The UUID of the user.
* @param itemName The name of the item to add.
* @param category The category of the item.
* @returns The master item object that was added to the watchlist.
*/
export const addWatchedItem = async (userId: string, itemName: string, category: string): Promise<MasterGroceryItem> => {
const supabaseClient = ensureSupabase();
try {
// 1. Find or create the category
let categoryData = await handleResponse<{ id: number } | null>(
supabaseClient
.from('categories')
.select('id')
.eq('name', category)
.maybeSingle()
);
if (!categoryData) {
categoryData = await handleResponse<{ id: number } | null>(
supabaseClient
.from('categories')
.insert({ name: category })
.select('id')
.single()
);
}
// 2. Upsert the master item to ensure it exists and get its ID
const { data: masterItem, error: masterItemError } = await supabaseClient
.from('master_grocery_items')
.upsert({ name: itemName.trim(), category_id: categoryData.id }, { onConflict: 'name' })
.select('*, category_name:categories(name)')
.single();
if (masterItemError) throw new Error(`Failed to upsert master item: ${masterItemError.message}`);
if (!masterItem) throw new Error("Master item operation did not return data.");
// 3. Create the link in user_watched_items
const { error: watchLinkError } = await supabaseClient
.from('user_watched_items')
.insert({ user_id: userId, master_item_id: masterItem.id });
// Ignore duplicate errors (user already watching), throw others
if (watchLinkError && watchLinkError.code !== '23505') { // 23505 is unique_violation
throw new Error(`Failed to add item to watchlist: ${watchLinkError.message}`);
} else if (watchLinkError) { // This block is now valid because of the preceding `if`.
// Log the ignored duplicate error for debugging purposes if needed.
logger.debug(`Ignored duplicate watch item error for user ${userId} and item ${masterItem.id}`);
}
// 4. Return the full master item object for UI update.
// We explicitly construct the object to match the `MasterGroceryItem` type
// by flattening the nested `category_name` object into a string.
return {
id: masterItem.id,
created_at: masterItem.created_at,
name: masterItem.name,
category_id: masterItem.category_id,
category_name: masterItem.category_name?.name ?? null,
};
} catch (error) {
// This catch block is now valid because of the preceding `try`.
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred in addWatchedItem.';
throw new Error(`Failed to add watched item: ${errorMessage}`);
}
};
/**
* Removes an item from a user's watchlist.
* @param userId The UUID of the user.
* @param masterItemId The ID of the master item to remove.
*/
export const removeWatchedItem = async (userId: string, masterItemId: number): Promise<void> => {
const supabaseClient = ensureSupabase();
const { error } = await supabaseClient
.from('user_watched_items')
.delete()
.eq('user_id', userId)
.eq('master_item_id', masterItemId);
if (error) {
throw new Error(`Failed to remove watched item: ${error.message}`);
}
};
/**
* Fetches all flyer items for a given list of flyer IDs.
* @param flyerIds An array of flyer IDs.
* @returns An array of flyer item objects.
*/
export const getFlyerItemsForFlyers = async (flyerIds: number[]): Promise<FlyerItem[]> => {
const supabaseClient = ensureSupabase();
if (flyerIds.length === 0) return [];
// Fetch the data using the raw, auto-generated type from Supabase.
const rawData = await handleResponse<Database['public']['Tables']['flyer_items']['Row'][]>(supabaseClient
.from('flyer_items')
.select('*')
.in('flyer_id', flyerIds));
if (!rawData) {
return [];
}
// Explicitly map the raw data to our application's `FlyerItem` type,
// using the type guard to safely handle the `unit_price` conversion.
return rawData.map(item => ({
...item,
unit_price: isUnitPrice(item.unit_price) ? item.unit_price : null,
}));
};
/**
* Counts the total number of items across a list of flyers.
* @param flyerIds An array of flyer IDs.
* @returns The total count of items.
*/
export const countFlyerItemsForFlyers = async (flyerIds: number[]): Promise<number> => {
const supabaseClient = ensureSupabase();
if (flyerIds.length === 0) return 0;
const { count, error } = await supabaseClient
.from('flyer_items')
.select('*', { count: 'exact', head: true })
.in('flyer_id', flyerIds);
if (error) throw new Error(`Error counting items for flyers: ${error.message}`);
return count || 0;
};
/**
* Fetches all flyer items corresponding to a user's watched items to build a price history.
* @param watchedItems An array of master grocery items.
* @returns An array of historical price data points.
*/
export const getHistoricalWatchedItems = async (watchedItems: MasterGroceryItem[]): Promise<Pick<FlyerItem, 'master_item_id' | 'price_in_cents' | 'created_at'>[]> => {
const supabaseClient = ensureSupabase();
if (watchedItems.length === 0) return [];
const watchedItemIds = watchedItems.map(item => item.id);
const data = await handleResponse<Pick<FlyerItem, 'master_item_id' | 'price_in_cents' | 'created_at'>[] | null>(supabaseClient
.from('flyer_items')
.select('master_item_id, price_in_cents, created_at')
.in('master_item_id', watchedItemIds)
.not('price_in_cents', 'is', null)
.order('created_at', { ascending: true }));
return data ?? [];
};
/**
* Fetches a user's profile from the database.
* @param userId The UUID of the user.
* @returns The user's profile object.
*/
export const getUserProfile = async (userId: string): Promise<Profile | null> => {
const supabaseClient = ensureSupabase();
const data = await handleResponse<Profile | null>(supabaseClient
.from('profiles')
.select('*, role')
.eq('id', userId)
.single());
if (!data) { // handleResponse returns null if no data
console.error("Error fetching user profile: No data returned.");
return null;
}
// Cast the result back to the expected application type
return data as Profile | null;
};
/**
* Updates a user's profile information.
* @param userId The UUID of the user.
* @param updates The profile fields to update.
* @returns The updated profile object.
*/
export const updateUserProfile = async (userId: string, updates: { full_name?: string; avatar_url?: string }): Promise<Profile> => {
const supabaseClient = ensureSupabase();
const data = await handleResponse<Profile | null>(supabaseClient
.from('profiles')
// Cast to `any` to handle potential JSONB type mismatches if more fields are added
.update(updates)
.eq('id', userId)
.select()
.single());
return data as Profile; // Cast to application's Profile type
};
/**
* Updates a user's preferences.
* @param userId The UUID of the user.
* @param preferences The preferences object to save.
* @returns The updated profile object.
*/
export const updateUserPreferences = async (userId: string, preferences: Profile['preferences']): Promise<Profile> => {
const supabaseClient = ensureSupabase();
const data = await handleResponse<Profile | null>(supabaseClient
.from('profiles')
// Cast to `any` to handle the JSONB type mismatch for `preferences`
.update({ preferences: preferences as Json })
.eq('id', userId)
.select().single()
);
return data as Profile; // Cast is safe here because we expect a single result
};
/**
* Gathers all data for a specific user for export.
* @param userId The UUID of the user.
* @returns An object containing all of the user's data.
*/
type WatchedItemExport = { created_at: string; item: { name: string; category: { name: string } | null } | null };
type ShoppingListExport = { name: string; created_at: string; items: { custom_item_name: string | null; quantity: number; is_purchased: boolean; master_item: { name: string } | null }[] };
interface UserDataExport {
profile: Profile | null;
watchedItems: WatchedItemExport[] | null;
shoppingLists: ShoppingListExport[] | null;
}
export const exportUserData = async (userId: string): Promise<UserDataExport> => {
const supabaseClient = ensureSupabase();
const profile = await handleResponse<Profile | null>(supabaseClient
.from('profiles')
.select('*')
.eq('id', userId)
.single());
const watchedItems = await handleResponse<WatchedItemExport[] | null>(supabaseClient
.from('user_watched_items')
.select('created_at, item:master_grocery_items(name, category:categories(name))')
.eq('user_id', userId));
const shoppingLists = await handleResponse<ShoppingListExport[] | null>(supabaseClient
.from('shopping_lists')
.select('name, created_at, items:shopping_list_items(custom_item_name, quantity, is_purchased, master_item:master_grocery_items(name))')
.eq('user_id', userId));
return {
profile,
watchedItems,
shoppingLists,
};
};
/**
* Calls the `system-check` Edge Function to verify the backend setup.
* @returns The results of the system checks.
*/
export const invokeSystemCheckFunction = async (): Promise<unknown> => {
const supabaseClient = ensureSupabase();
const { data, error } = await supabaseClient.functions.invoke('system-check');
if (error) {
let errorDetails = `System check function failed: ${error.message}.`;
if (error.message.includes("Not found")) {
errorDetails = "The 'system-check' Edge Function is not deployed. Please follow the instructions in the README to deploy it.";
} else if (error.context) {
try {
const errorBody = await error.context.json();
errorDetails += `\nDetails: ${errorBody.error || 'Unknown error'}`;
} catch { /* ignore parsing error */ }
}
throw new Error(errorDetails);
}
if (data?.error) throw new Error(data.error);
return data.results;
};
/**
* Creates the initial development users by invoking a secure Edge Function.
*/
export const invokeSeedDatabaseFunction = async (): Promise<{ message: string; }> => {
const supabaseClient = ensureSupabase();
const { data, error } = await supabaseClient.functions.invoke('seed-database');
if (error) {
let errorDetails = `Edge Function returned a non-2xx status code: ${error.message}.`;
if (error.context) {
try {
const errorBody = await error.context.json();
const message = errorBody.error || 'No error message in body.';
const stack = errorBody.stack || 'No stack trace in body.';
errorDetails = `Error: ${message}\n\nStack Trace:\n${stack}`;
} catch {
errorDetails += `\nCould not parse error response body. Raw response might be in browser network tab.`;
}
}
throw new Error(errorDetails);
}
if (data?.error) throw new Error(data.error);
return data;
};
/**
* Fetches all pending suggested corrections from the database.
* This is an admin-only function.
* @returns An array of suggested correction objects with related user and item data.
*/
export const getSuggestedCorrections = async (): Promise<SuggestedCorrection[]> => {
const supabaseClient = ensureSupabase();
// Define a more specific type for the query result to avoid `any`.
type CorrectionWithRelations = SuggestedCorrection & {
user: { email: string | null } | null;
item: { item: string; price_display: string } | null;
};
// Re-writing this query explicitly to avoid a complex type-inference issue
// that was causing the TypeScript language server to report a phantom syntax error.
const query = supabaseClient
.from('suggested_corrections')
.select(`
*,
user:profiles(email),
item:flyer_items(item, price_display)
`)
.eq('status', 'pending')
.order('created_at', { ascending: true });
const { data, error } = await query;
if (error) {
throw new Error(`Failed to get suggested corrections: ${error.message}`);
}
// The data can be null if the query returns no rows, so we handle that case.
if (!data) {
return [];
}
return (data as unknown as CorrectionWithRelations[]).map((c) => ({
...c,
user_email: c.user?.email ?? 'Unknown',
flyer_item_name: c.item?.item ?? 'Unknown Item',
flyer_item_price_display: c.item?.price_display ?? 'N/A'
})) as SuggestedCorrection[];
};
/**
* Approves a suggested correction by calling the `approve_correction` RPC.
* This is an admin-only function.
* @param correctionId The ID of the correction to approve.
*/
export const approveCorrection = async (correctionId: number): Promise<void> => {
const supabaseClient = ensureSupabase();
const { error } = await supabaseClient.rpc('approve_correction', { p_correction_id: correctionId });
if (error) {
throw new Error(`Failed to approve correction: ${error.message}`);
}
};
/**
* Rejects a suggested correction.
* This is an admin-only function.
* @param correctionId The ID of the correction to reject.
*/
export const rejectCorrection = async (correctionId: number): Promise<void> => {
const supabaseClient = ensureSupabase();
const { error } = await supabaseClient.from('suggested_corrections').update({ status: 'rejected', reviewed_at: new Date().toISOString() }).eq('id', correctionId);
if (error) throw new Error(`Failed to reject correction: ${error.message}`);
};
// =============================================
// SHOPPING LIST FUNCTIONS
// =============================================
/**
* Fetches all shopping lists for a user, including their items.
* @param userId The UUID of the user.
* @returns An array of shopping list objects.
*/
export const getShoppingLists = async (userId: string): Promise<ShoppingList[]> => {
const supabaseClient = ensureSupabase();
const data = await handleResponse<ShoppingList[] | null>(
supabaseClient
.from('shopping_lists')
.select(`
*,
items:shopping_list_items (
*,
master_item:master_grocery_items (name)
)
`)
.eq('user_id', userId)
.order('created_at', { ascending: true })
.order('added_at', { ascending: true, referencedTable: 'shopping_list_items' })
);
return data ?? [];
};
/**
* Creates a new shopping list for a user.
* @param userId The UUID of the user.
* @param name The name of the new list.
* @returns The newly created shopping list object.
*/
export const createShoppingList = async (userId: string, name: string): Promise<ShoppingList> => {
const supabaseClient = ensureSupabase();
const data = await handleResponse<ShoppingList | null>(supabaseClient
.from('shopping_lists')
.insert({ user_id: userId, name })
.select()
.single());
return { ...data, items: [] }; // Return with empty items array
};
/**
* Deletes a shopping list.
* @param listId The ID of the list to delete.
*/
export const deleteShoppingList = async (listId: number): Promise<void> => {
const supabaseClient = ensureSupabase();
const { error } = await supabaseClient
.from('shopping_lists')
.delete()
.eq('id', listId);
if (error) throw new Error(`Error deleting shopping list: ${error.message}`);
};
/**
* Adds an item to a shopping list.
* @param listId The ID of the list.
* @param masterItemId Optional ID of the master grocery item.
* @param customItemName Optional name for a custom item.
* @returns The newly created shopping list item.
*/
export const addShoppingListItem = async (listId: number, { masterItemId, customItemName }: { masterItemId?: number; customItemName?: string }): Promise<ShoppingListItem> => {
const supabaseClient = ensureSupabase();
if (!masterItemId && !customItemName) {
throw new Error("Either masterItemId or customItemName must be provided.");
}
let query;
if (masterItemId) {
const itemToUpsert = {
shopping_list_id: listId,
master_item_id: masterItemId,
custom_item_name: null, // Ensure the object shape is complete for the type checker
quantity: 1,
};
query = supabaseClient.from('shopping_list_items').upsert(itemToUpsert, { onConflict: 'shopping_list_id, master_item_id' });
} else {
const itemToInsert = {
shopping_list_id: listId,
master_item_id: null, // Ensure the object shape is complete
custom_item_name: customItemName,
quantity: 1,
};
query = supabaseClient.from('shopping_list_items').insert(itemToInsert);
}
// Bypassing the generic `handleResponse` helper to resolve the complex type inference issue.
const { data, error } = await query.select('*, master_item:master_grocery_items(name)').single();
if (error) throw new Error(`Failed to add shopping list item: ${error.message}`);
if (!data) throw new Error("Adding shopping list item did not return data.");
return data as ShoppingListItem;
};
/**
* Updates a shopping list item.
* @param itemId The ID of the item to update.
* @param updates The fields to update (e.g., is_purchased, quantity).
* @returns The updated shopping list item.
*/
export const updateShoppingListItem = async (itemId: number, updates: Partial<Omit<ShoppingListItem, 'id' | 'master_item'>>): Promise<ShoppingListItem> => {
const supabaseClient = ensureSupabase();
// The 'id' and 'master_item' cannot be part of the update payload.
const { ...updateData } = updates;
const data = await handleResponse<ShoppingListItem>(supabaseClient
.from('shopping_list_items')
.update(updateData)
.eq('id', itemId)
.select('*, master_item:master_grocery_items(name)')
.single());
return data as ShoppingListItem;
};
/**
* Removes an item from a shopping list.
* @param itemId The ID of the item to remove.
*/
export const removeShoppingListItem = async (itemId: number): Promise<void> => {
const supabaseClient = ensureSupabase();
const { error } = await supabaseClient
.from('shopping_list_items')
.delete()
.eq('id', itemId);
if (error) throw new Error(`Error removing shopping list item: ${error.message}`);
};

View File

@@ -89,7 +89,7 @@ export interface Profile {
updated_at?: string;
full_name?: string | null;
avatar_url?: string | null;
role?: 'admin' | 'user' | null;
role: 'admin' | 'user';
preferences?: {
darkMode?: boolean;
unitSystem?: 'metric' | 'imperial';
@@ -170,19 +170,6 @@ export interface ShoppingListItem {
} | null;
}
export interface SuggestedCorrection {
id: number;
flyer_item_id: number;
user_id: string; // UUID
correction_type: string; // e.g., 'WRONG_PRICE', 'INCORRECT_ITEM_LINK'
suggested_value: string;
status: 'pending' | 'approved' | 'rejected';
created_at: string;
reviewed_notes?: string | null;
reviewed_at?: string | null;
}
export interface UserSubmittedPrice {
id: number;
user_id: string; // UUID

File diff suppressed because it is too large Load Diff

View File

@@ -1 +0,0 @@
v2.58.5

View File

@@ -1 +0,0 @@
v2.182.1

View File

@@ -1 +0,0 @@
postgresql://postgres.azmmnxkvjryracrnmhvj@aws-1-us-east-2.pooler.supabase.com:5432/postgres

View File

@@ -1 +0,0 @@
17.6.1.036

View File

@@ -1 +0,0 @@
azmmnxkvjryracrnmhvj

View File

@@ -1 +0,0 @@
v13.0.5

View File

@@ -1 +0,0 @@
fix-object-level

View File

@@ -1 +0,0 @@
v1.28.4

View File

@@ -1,5 +0,0 @@
{
"deno.enable": true,
"deno.lint": true,
"deno.unstable": true
}

View File

@@ -1,24 +0,0 @@
project_id = "azmmnxkvjryracrnmhvj"
[functions.delete-user]
# Point to the single import map in the supabase/ directory
import_map = "import_map.json"
entrypoint = "./functions/delete-user/index.ts"
[functions.seed-database]
import_map = "import_map.json"
entrypoint = "./functions/seed-database/index.ts"
[functions.system-check]
import_map = "import_map.json"
entrypoint = "./functions/system-check/index.ts"
[functions.process-flyer]
enabled = true
verify_jwt = true
import_map = "import_map.json"
# Uncomment to specify a custom file path to the entrypoint.
# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx
entrypoint = "./functions/process-flyer/index.ts"
# Specifies static files to be bundled with the function. Supports glob patterns.
# For example, if you want to serve static HTML pages in your function:
# static_files = [ "./functions/process-flyer/*.html" ]

View File

@@ -1,11 +0,0 @@
{
"importMap": "./import_map.json",
"compilerOptions": {
"lib": ["deno.ns", "dom"]
},
"lint": {
"rules": {
"exclude": ["no-explicit-any"]
}
}
}

30
supabase/deno.lock generated
View File

@@ -1,30 +0,0 @@
{
"version": "5",
"specifiers": {
"npm:@types/node@*": "24.2.0"
},
"npm": {
"@types/node@24.2.0": {
"integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==",
"dependencies": [
"undici-types"
]
},
"undici-types@7.10.0": {
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="
}
},
"redirects": {
"https://esm.sh/@supabase/supabase-js@2": "https://esm.sh/@supabase/supabase-js@2.81.0"
},
"remote": {
"https://esm.sh/@supabase/auth-js@2.81.0/denonext/auth-js.mjs": "b5fd37fcb23c29d8aced42fa5860351b2c3cdf6c93f682f5ad18eae9a5ec975f",
"https://esm.sh/@supabase/functions-js@2.81.0/denonext/functions-js.mjs": "9d372092ef779df5229d5dfdb99418b18a3ca13aa2c75bdfc2bb800152e63209",
"https://esm.sh/@supabase/postgrest-js@2.81.0/denonext/postgrest-js.mjs": "06f7dfb7d7a12928ca6b032ff85c08da7e615bde3d6ce8296a15f6e5275b9c67",
"https://esm.sh/@supabase/realtime-js@2.81.0/denonext/realtime-js.mjs": "9dd5143d77cf76c29eeaba47d275ba175b1757d95de746c6ac47dcc9cc20dc35",
"https://esm.sh/@supabase/storage-js@2.81.0/denonext/storage-js.mjs": "a1fd4ba5177e3929f8ef2d0e3a67925bc2a8fe3a44842c86ed272fe8ab4d32ac",
"https://esm.sh/@supabase/supabase-js@2.81.0": "3770fbb84be9073d4e9961daf72e0b750d9af22be7798386a487e653ab8ce0b5",
"https://esm.sh/@supabase/supabase-js@2.81.0/denonext/supabase-js.mjs": "14d334ff59edbb57f86e22c7b63ffe9b0ba16987884b7cbcd028d460714a57fc",
"https://esm.sh/tslib@2.8.1/denonext/tslib.mjs": "ebce3cd5facb654623020337f867b426ba95f71596ba87acc9e6c6f4e55905ca"
}
}

View File

@@ -1 +0,0 @@
v2.54.11

View File

@@ -1,6 +0,0 @@
// This file provides shared CORS headers for Supabase Edge Functions.
// It allows the web application to securely call these backend functions.
export const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};

View File

@@ -1 +0,0 @@
v2.181.0

View File

@@ -1 +0,0 @@
postgresql://postgres.azmmnxkvjryracrnmhvj@aws-1-us-east-2.pooler.supabase.com:5432/postgres

View File

@@ -1 +0,0 @@
17.6.1.036

View File

@@ -1 +0,0 @@
azmmnxkvjryracrnmhvj

View File

@@ -1 +0,0 @@
v13.0.5

View File

@@ -1 +0,0 @@
fix-object-level

View File

@@ -1 +0,0 @@
v1.28.4

View File

@@ -1,10 +0,0 @@
// This file provides shared CORS headers for Supabase Edge Functions.
// It allows the web application to securely call these backend functions.
export const corsHeaders = {
// Allow requests from your specific frontend domain.
// Using a wildcard '*' is not secure and doesn't work with authenticated requests.
'Access-Control-Allow-Origin': 'https://flyer-crawler.projectium.com',
// Specify which methods are allowed for CORS requests.
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};

File diff suppressed because one or more lines are too long

View File

@@ -1,137 +0,0 @@
//console.log("Hello from Functions!")
//Deno.serve(async (req) => {
// const { name } = await req.json()
// const data = {
// message: `Hello ${name}!`,
// }
// return new Response(
// JSON.stringify(data),
// { headers: { "Content-Type": "application/json" } },
// )
//})
/* To invoke locally:
1. Run `supabase start` (see: https://supabase.com/docs/reference/cli/supabase-start)
2. Make an HTTP request:
curl -i --location --request POST 'http://127.0.0.1:54321/functions/v1/delete-user' \
--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0' \
--header 'Content-Type: application/json' \
--data '{"name":"Functions"}'
*/
import { createClient } from '@supabase/supabase-js'
import { corsHeaders } from '../_shared/cors.ts';
// Define a type for the expected request body for better type safety.
interface DeleteUserPayload {
password?: string;
}
const supabaseUrl = Deno.env.get('SUPABASE_URL');
const supabaseAnonKey = Deno.env.get('SUPABASE_ANON_KEY');
const supabaseServiceRoleKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY');
Deno.serve(async (req: Request) => {
// Handle preflight OPTIONS request for CORS
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
console.log("delete-user function invoked.");
if (!supabaseUrl || !supabaseAnonKey || !supabaseServiceRoleKey) {
throw new Error("Missing required environment variables (SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY).");
}
// Gracefully handle cases where there is no request body.
const body: DeleteUserPayload | null = await req.json().catch(() => {
console.error("Function called without a valid JSON body.");
return null;
});
// Single, robust check for the password.
if (!body || !body.password) {
console.error("Function called without a password in the body.");
return new Response(JSON.stringify({ error: 'Password is required.' }), {
status: 400, // Bad Request
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const { password } = body;
// Create a Supabase client with the user's authentication token
console.log("Checking for Authorization header...");
const authHeader = req.headers.get('Authorization');
if (!authHeader) {
throw new Error('Missing authorization header.');
}
const userSupabaseClient = createClient(
supabaseUrl,
supabaseAnonKey,
{ global: { headers: { Authorization: authHeader } } }
);
// Get the user from the token
console.log("Attempting to get user from provided token...");
const { data: { user }, error: userError } = await userSupabaseClient.auth.getUser();
if (userError || !user) {
console.error("Could not get user from token.", { error: userError?.message });
return new Response(JSON.stringify({ error: userError?.message || 'User not authenticated.' }), {
status: 401,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
console.log(`User identified: ${user.id}. Verifying password...`);
// Verify the user's password by attempting to sign in
const { error: signInError } = await userSupabaseClient.auth.signInWithPassword({
email: user.email!,
password: password,
});
if (signInError) {
console.error(`Password verification failed for user ${user.id}.`, { error: signInError.message });
return new Response(JSON.stringify({ error: 'Invalid password.' }), {
status: 403,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
console.log(`Password verified for user ${user.id}. Proceeding with account deletion.`);
// If password is correct, create an admin client with the service_role key
const adminSupabaseClient = createClient(
supabaseUrl,
supabaseServiceRoleKey,
{ auth: { autoRefreshToken: false, persistSession: false } }
);
// Delete the user
const { error: deleteError } = await adminSupabaseClient.auth.admin.deleteUser(user.id);
if (deleteError) {
console.error(`Admin client failed to delete user ${user.id}.`, { error: deleteError.message });
throw deleteError;
}
console.log(`Successfully deleted user ${user.id}.`);
return new Response(JSON.stringify({ message: 'User deleted successfully.' }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 200,
});
} catch (e) {
// This is the most important part: log the full error and stack trace.
const error = e instanceof Error ? e : new Error(String(e));
console.error("An unexpected error occurred in delete-user function:", { error: error.message, stack: error.stack });
// Return a detailed error with a stack trace for better debugging.
return new Response(JSON.stringify({ error: error.message, stack: error.stack }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 500,
});
}
});

View File

View File

@@ -1,246 +0,0 @@
import { createClient } from '@supabase/supabase-js';
// Import GoogleGenAI from the local vendor file as mapped in import_map.json
import GoogleGenAI from "@google/genai";
import { corsHeaders } from '../_shared/cors.ts';
import { Database } from '../_shared/supabase.ts';
// Define the schema type enum locally to avoid Deno type resolution issues with the vendored package.
// This mirrors the 'Type' or 'FunctionDeclarationSchemaType' enum from @google/genai.
const enum SchemaType {
STRING = "STRING",
NUMBER = "NUMBER",
INTEGER = "INTEGER",
BOOLEAN = "BOOLEAN",
ARRAY = "ARRAY",
OBJECT = "OBJECT",
}
type FlyerItemInsert = Database['public']['Tables']['flyer_items']['Insert'];
// Helper to parse JSON robustly
function parseGeminiJson<T>(responseText: string): T {
let cleanedText = responseText.trim();
const jsonRegex = /```json\s*([\s\S]*?)\s*```/;
const match = cleanedText.match(jsonRegex);
if (match && match[1]) {
cleanedText = match[1];
}
try {
return JSON.parse(cleanedText) as T;
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
console.error("Failed to parse JSON response from AI.", {
originalResponse: responseText,
cleanedJSON: cleanedText,
error: errorMessage,
});
throw new Error(`Failed to parse JSON response from AI. Error: ${errorMessage}.`);
}
}
// Helper function to parse a price string (e.g., "$3.99") into an integer of cents.
function parsePriceToCents(price: string): number | null {
if (!price || typeof price !== 'string') return null;
const cleanedPrice = price.trim();
if (cleanedPrice.match(/\d+\s+for/i)) return null;
const centsMatch = cleanedPrice.match(/(\d+\.?\d*)\s?¢/);
if (centsMatch && centsMatch[1]) return Math.round(parseFloat(centsMatch[1]));
const dollarsMatch = cleanedPrice.match(/\$?(\d+\.?\d*)/);
if (dollarsMatch && dollarsMatch[1]) {
const numericValue = parseFloat(dollarsMatch[1]);
if (!cleanedPrice.includes('.') && numericValue > 50 && numericValue % 1 === 0) return numericValue;
return Math.round(numericValue * 100);
}
return null;
}
// The main server function
Deno.serve(async (req: Request) => {
// Handle preflight OPTIONS request for CORS
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
// In an Edge Function, we get secrets from Deno's environment variables.
// These are set in your Supabase project's dashboard.
const apiKey = Deno.env.get("GOOGLE_AI_API_KEY");
if (!apiKey) {
throw new Error("GOOGLE_AI_API_KEY environment variable not set");
}
// In newer versions of the SDK, the constructor takes the API key directly.
const ai = new GoogleGenAI(apiKey);
// Create an admin client to bypass RLS
const adminSupabaseClient = createClient<Database>(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
{ auth: { autoRefreshToken: false, persistSession: false } }
);
// Get image data and master items from the request body
const { imageParts, masterItems, fileChecksum, fileName, imageUrl } = await req.json();
// --- This is the core AI logic, now running securely on the server ---
const UNMATCHED_ITEM_ID = 0;
const unmatchedMasterItem = { id: UNMATCHED_ITEM_ID, name: '_UNMATCHED_' };
const masterItemsForPrompt = [
...masterItems.map((item: {id: number, name: string}) => ({ id: item.id, name: item.name })),
unmatchedMasterItem
];
const CATEGORIES = [
'Fruits & Vegetables', 'Meat & Seafood', 'Dairy & Eggs', 'Bakery & Bread',
'Pantry & Dry Goods', 'Beverages', 'Frozen Foods', 'Snacks', 'Household & Cleaning',
'Personal Care & Health', 'Baby & Child', 'Pet Supplies', 'Deli & Prepared Foods',
'Canned Goods', 'Condiments & Spices', 'Breakfast & Cereal', 'Organic',
'International Foods', 'Other/Miscellaneous'
];
// Get the generative model instance.
const model = ai.getGenerativeModel({
model: 'gemini-1.5-flash', // Note: 'gemini-2.5-flash' is not a valid model name, using 'gemini-1.5-flash' as a likely intended model.
generationConfig: {
responseMimeType: "application/json",
},
});
const prompt = `You are an expert data extraction and matching system for grocery store flyers. Analyze the provided flyer images.
1. Identify the name of the grocery store/company.
2. Identify the date range for which the flyer's deals are valid. Return dates in 'YYYY-MM-DD' format. If no date range is visible, return 'null' for both date fields.
3. Extract all distinct sale items. For each item, extract its name, price, and quantity/deal description.
4. **Categorization**: For each item, determine its category from the provided list.
5. **Extract Numeric Quantity**: From the quantity string, extract the primary numeric value if one is present (e.g., for "500g bag", extract 500). If no clear number is present, return 'null'.
6. **CRITICAL ITEM MATCHING**: For each extracted item, you MUST match it to its corresponding canonical item from the 'Master Items List'. If you are not 100% certain of a perfect match, you MUST assign the master_item_id of the special _UNMATCHED_ item (ID: ${UNMATCHED_ITEM_ID}).
7. **Unit Price Calculation**: For each item, calculate and provide a 'unit_price' as a JSON object: { "value": <number>, "unit": "<string>" }. If not applicable, return null.
Return the result as a single JSON object, strictly following the provided schema. The schema is defined in the tool configuration.
Category List: ${JSON.stringify(CATEGORIES)}
Master Items List: ${JSON.stringify(masterItemsForPrompt)}
`;
const response = await model.generateContent({
contents: [{ parts: [...imageParts, { text: prompt }] }],
tools: [{
functionDeclarations: [{
name: "flyer_data_extraction",
description: "Extracts structured data from a grocery flyer.",
parameters: {
type: SchemaType.OBJECT,
properties: {
store_name: { type: SchemaType.STRING },
valid_from: { type: SchemaType.STRING, description: "YYYY-MM-DD format or null" },
valid_to: { type: SchemaType.STRING, description: "YYYY-MM-DD format or null" },
items: {
type: SchemaType.ARRAY,
items: {
type: SchemaType.OBJECT,
properties: {
item: { type: SchemaType.STRING },
price: { type: SchemaType.STRING },
quantity: { type: SchemaType.STRING },
category: { type: SchemaType.STRING },
quantity_num: { type: SchemaType.NUMBER, nullable: true },
master_item_id: { type: SchemaType.INTEGER },
unit_price: {
type: SchemaType.OBJECT,
nullable: true,
properties: { value: { type: SchemaType.NUMBER }, unit: { type: SchemaType.STRING } },
required: ["value", "unit"]
}
},
required: ['item', 'price', 'quantity', 'category', 'quantity_num', 'master_item_id', 'unit_price']
}
}
},
required: ['store_name', 'valid_from', 'valid_to', 'items']
},
}]
}]
});
const parsedJson = parseGeminiJson<{
store_name: string;
valid_from: string | null;
valid_to: string | null;
items: {
item: string;
price: string;
quantity: string;
category: string;
quantity_num: number | null;
master_item_id: number | null;
unit_price: { value: number, unit: string } | null;
}[];
// The response text is now accessed via response.response.text()
// and the actual function call arguments are in the first tool call.
}>(JSON.stringify(response.response.toolCalls[0].functionCall.args));
// --- End of AI logic ---
// --- Database Insertion Logic ---
// 1. Find or create the store.
const { data: store, error: storeError } = await adminSupabaseClient
.from('stores')
.upsert({ name: parsedJson.store_name }, { onConflict: 'name' })
.select()
.single();
if (storeError) throw storeError;
if (!store) throw new Error("Failed to find or create store.");
// 2. Create the flyer record.
const today = new Date().toISOString().split('T')[0];
const { data: flyer, error: flyerError } = await adminSupabaseClient
.from('flyers')
.insert({
file_name: fileName,
image_url: imageUrl,
checksum: fileChecksum,
store_id: store.id,
valid_from: parsedJson.valid_from || today,
valid_to: parsedJson.valid_to || today,
})
.select()
.single();
if (flyerError) throw flyerError;
if (!flyer) throw new Error("Failed to create flyer record.");
// 3. Prepare and insert the flyer items.
const itemsToInsert: FlyerItemInsert[] = parsedJson.items.map(item => ({
flyer_id: flyer.id,
item: item.item,
price_display: item.price,
price_in_cents: parsePriceToCents(item.price),
quantity: item.quantity,
quantity_num: item.quantity_num,
category_name: item.category,
master_item_id: item.master_item_id === UNMATCHED_ITEM_ID ? null : item.master_item_id,
unit_price: item.unit_price,
}));
if (itemsToInsert.length > 0) {
const { error: itemsError } = await adminSupabaseClient
.from('flyer_items')
.insert(itemsToInsert);
if (itemsError) throw itemsError;
}
// Return the newly created flyer ID and item count to the client.
const responsePayload = { flyerId: flyer.id, itemCount: itemsToInsert.length };
return new Response(JSON.stringify(responsePayload), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 200,
});
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e));
console.error("An unexpected error occurred in process-flyer function:", { error: error.message, stack: error.stack });
return new Response(JSON.stringify({ error: error.message, stack: error.stack }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 500,
});
}
});

View File

@@ -1,121 +0,0 @@
//console.log("Hello from Functions!")
//Deno.serve(async (req) => {
// const { name } = await req.json()
// const data = {
// message: `Hello ${name}!`,
// }
//
// return new Response(
// JSON.stringify(data),
// { headers: { "Content-Type": "application/json" } },
// )
//})
/* To invoke locally:
1. Run `supabase start` (see: https://supabase.com/docs/reference/cli/supabase-start)
2. Make an HTTP request:
curl -i --location --request POST 'http://127.0.0.1:54321/functions/v1/seed-database' \
--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0' \
--header 'Content-Type: application/json' \
--data '{"name":"Functions"}'
*/
import { createClient, type User } from '@supabase/supabase-js'
import { corsHeaders } from '../_shared/cors.ts';
const supabaseUrl = Deno.env.get('SUPABASE_URL');
const supabaseServiceRoleKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY');
Deno.serve(async (req: Request) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
// IMPORTANT: Add a guard to prevent this from running in production.
// Set DENO_ENV to 'development' in your local .env file.
if (Deno.env.get('DENO_ENV') !== 'development') {
return new Response(JSON.stringify({ error: 'This function is for development use only.' }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 403, // Forbidden
});
}
if (!supabaseUrl || !supabaseServiceRoleKey) {
throw new Error("Missing required environment variables (SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY).");
}
// We create an admin client using the service_role key to perform elevated actions.
const adminSupabaseClient = createClient(
supabaseUrl,
supabaseServiceRoleKey,
{ auth: { autoRefreshToken: false, persistSession: false } }
);
const usersToSeed = [
{
email: 'admin@example.com',
password: 'password123',
user_metadata: { full_name: 'Admin User' }
},
{
email: 'user@example.com',
password: 'password123',
user_metadata: { full_name: 'Normal User' }
}
];
const createdUsers = [];
const existingUsers = [];
const { data: { users: existingUserList }, error: listError } = await adminSupabaseClient.auth.admin.listUsers();
if (listError) throw listError;
const existingEmails = new Set(existingUserList.map((u: User) => u.email));
for (const user of usersToSeed) {
if (!existingEmails.has(user.email)) {
const { error } = await adminSupabaseClient.auth.admin.createUser({
email: user.email,
password: user.password,
user_metadata: user.user_metadata,
email_confirm: true, // Auto-confirm for dev environment
});
if (error) {
throw new Error(`Failed to create user ${user.email}: ${error.message}`);
}
createdUsers.push(user.email);
} else {
existingUsers.push(user.email);
}
}
let message = '';
if (createdUsers.length > 0) {
message += `Successfully created users: ${createdUsers.join(', ')}. `;
}
if (existingUsers.length > 0) {
message += `Users already existed: ${existingUsers.join(', ')}.`;
}
if (message === '') {
message = 'All development users already exist.'
}
return new Response(JSON.stringify({ message: message.trim() }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 200,
});
} catch (e) {
// Return a detailed error with a stack trace for better debugging.
const error = e instanceof Error ? e : new Error(String(e));
return new Response(JSON.stringify({ error: error.message, stack: error.stack }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 500,
});
}
});

View File

@@ -1,140 +0,0 @@
// console.log("Hello from Functions!")
// Deno.serve(async (req) => {
// const { name } = await req.json()
// const data = {
// message: `Hello ${name}!`,
// }
// return new Response(
// JSON.stringify(data),
// { headers: { "Content-Type": "application/json" } },
// )
// })
/* To invoke locally:
1. Run `supabase start` (see: https://supabase.com/docs/reference/cli/supabase-start)
2. Make an HTTP request:
curl -i --location --request POST 'http://127.0.0.1:54321/functions/v1/system-check' \
--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0' \
--header 'Content-Type: application/json' \
--data '{"name":"Functions"}'
*/
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
import { corsHeaders } from '../_shared/cors.ts';
const supabaseUrl = Deno.env.get('SUPABASE_URL');
const supabaseServiceRoleKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY');
// Helper function to create a Supabase admin client
const createAdminClient = () => createClient(
supabaseUrl!,
supabaseServiceRoleKey!,
{ auth: { autoRefreshToken: false, persistSession: false } }
);
interface SchemaCheckResult {
tables: string[];
}
const checkDatabaseSchema = async (client: SupabaseClient) => {
const { data, error } = await client.rpc('check_schema');
if (error) throw new Error(`Schema check failed: ${error.message}`);
if (!data) throw new Error('Schema check returned no data.');
const requiredTables = ['flyers', 'flyer_items', 'profiles', 'shopping_lists'];
const missingTables = requiredTables.filter(t => !data.tables.includes(t));
if (missingTables.length > 0) {
return { pass: false, message: `Missing tables: ${missingTables.join(', ')}. Please run the full schema.sql.txt script.` };
}
return { pass: true, message: 'All required tables exist.' };
};
interface RlsCheckResult {
table_name: string;
policy_name: string;
}
const checkRlsPolicies = async (client: SupabaseClient) => {
const { data, error } = await client.rpc('check_rls');
if (error) throw new Error(`RLS check failed: ${error.message}`);
const requiredPolicies: { [key: string]: string } = {
// Check a user-specific policy to ensure authenticated user permissions are set.
'profiles': 'Users can update their own profile.',
'shopping_lists': 'Users can manage their own shopping lists.',
// Check a public policy to ensure anonymous users can read data.
'flyer_items': 'Public read access'
};
for (const table in requiredPolicies) {
const policyName = requiredPolicies[table];
if (!data.some((p: RlsCheckResult) => p.table_name === table && p.policy_name === policyName)) {
return { pass: false, message: `Missing RLS policy "${policyName}" on table "${table}". Please run the schema.sql.txt script.` };
}
}
return { pass: true, message: 'Key RLS policies are in place.' };
};
const checkUserCreationTrigger = async (client: SupabaseClient) => {
const { data, error } = await client.rpc('check_trigger_security');
if (error) throw new Error(`Trigger check failed: ${error.message}`);
if (!data || data.length === 0) {
return { pass: false, message: `Function 'handle_new_user' not found. Please run schema.sql.txt.` };
}
const handleNewUser = data[0];
if (!handleNewUser.is_security_definer) {
return { pass: false, message: `Trigger function 'handle_new_user' must be 'SECURITY DEFINER'. Please run the schema.sql.txt script to fix it.` };
}
if (handleNewUser.owner_role !== 'postgres') {
return { pass: false, message: `Trigger function 'handle_new_user' must be owned by 'postgres'. Yours is owned by '${handleNewUser.owner_role}'. Please run schema.sql.txt to fix it.` };
}
return { pass: true, message: 'User creation trigger is correctly configured.' };
};
const checkStorageBucket = async (client: SupabaseClient) => {
const { data, error } = await client.storage.getBucket('flyers');
if (error) {
return { pass: false, message: `Failed to access 'flyers' bucket: ${error.message}. Ensure it exists and permissions are set.` };
}
if (!data.public) {
return { pass: false, message: `Storage bucket 'flyers' is not public. Please enable public access in your Supabase dashboard.` };
}
return { pass: true, message: "'flyers' bucket exists and is public." };
};
Deno.serve(async (req: Request) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
if (!supabaseUrl || !supabaseServiceRoleKey) {
throw new Error("Missing required environment variables (SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY).");
}
const adminClient = createAdminClient();
const results: { [key: string]: { pass: boolean; message: string } } = {};
results['schema'] = await checkDatabaseSchema(adminClient);
results['rls'] = await checkRlsPolicies(adminClient);
results['trigger'] = await checkUserCreationTrigger(adminClient);
results['storage'] = await checkStorageBucket(adminClient);
return new Response(JSON.stringify({ results }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 200,
});
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e));
return new Response(JSON.stringify({ error: error.message, stack: error.stack }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 500,
});
}
});

View File

@@ -1,7 +0,0 @@
{
"imports": {
"@supabase/supabase-js": "https://esm.sh/@supabase/supabase-js@2",
"std/": "https://deno.land/std@0.224.0/",
"@google/genai": "./functions/_vendor/google-genai.js"
}
}

View File

@@ -1,36 +0,0 @@
-- ============================================================================
-- MIGRATION: Add approve_correction function
-- ============================================================================
-- This migration adds the `approve_correction` function, which allows an admin
-- to approve a user's suggested correction and apply it to the database.
CREATE OR REPLACE FUNCTION public.approve_correction(p_correction_id BIGINT)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
correction_record RECORD;
target_flyer_item RECORD;
new_master_item_id BIGINT;
BEGIN
-- 1. Fetch the correction details
SELECT * INTO correction_record
FROM public.suggested_corrections
WHERE id = p_correction_id AND status = 'pending';
IF NOT FOUND THEN
RAISE EXCEPTION 'Correction with ID % not found or already processed.', p_correction_id;
END IF;
-- 2. Apply the correction based on its type
IF correction_record.correction_type = 'INCORRECT_ITEM_LINK' THEN
UPDATE public.flyer_items SET master_item_id = correction_record.suggested_value::BIGINT WHERE id = correction_record.flyer_item_id;
ELSIF correction_record.correction_type = 'WRONG_PRICE' THEN
UPDATE public.flyer_items SET price_in_cents = correction_record.suggested_value::INTEGER WHERE id = correction_record.flyer_item_id;
END IF;
-- 3. Update the correction status to 'approved'
UPDATE public.suggested_corrections SET status = 'approved', reviewed_at = now() WHERE id = p_correction_id;
END;
$$;

View File

@@ -1,36 +0,0 @@
-- ============================================================================
-- MIGRATION: Secure Public Read Policies
-- ============================================================================
-- This migration replaces overly permissive "Allow full public access" RLS policies
-- with more secure, read-only policies for public data. This ensures that
-- anonymous users can view data like flyers and items, but cannot modify or delete it.
-- This fixes the issue where non-logged-in users could not see processed flyers.
-- Secure 'stores' table
DROP POLICY IF EXISTS "Allow full public access" ON public.stores;
CREATE POLICY "Public read access" ON public.stores FOR SELECT USING (true);
-- Secure 'categories' table
DROP POLICY IF EXISTS "Allow full public access" ON public.categories;
CREATE POLICY "Public read access" ON public.categories FOR SELECT USING (true);
-- Secure 'flyers' table
DROP POLICY IF EXISTS "Allow full public access" ON public.flyers;
CREATE POLICY "Public read access" ON public.flyers FOR SELECT USING (true);
-- Secure 'flyer_items' table
DROP POLICY IF EXISTS "Allow full public access" ON public.flyer_items;
CREATE POLICY "Public read access" ON public.flyer_items FOR SELECT USING (true);
-- Secure 'master_grocery_items' table
DROP POLICY IF EXISTS "Allow full public access" ON public.master_grocery_items;
CREATE POLICY "Public read access" ON public.master_grocery_items FOR SELECT USING (true);
-- Secure 'brands' table
DROP POLICY IF EXISTS "Allow full public access" ON public.brands;
CREATE POLICY "Public read access" ON public.brands FOR SELECT USING (true);
-- Secure 'products' table
DROP POLICY IF EXISTS "Allow full public access" ON public.products;
CREATE POLICY "Public read access" ON public.products FOR SELECT USING (true);

View File

@@ -1,18 +0,0 @@
{
"compilerOptions": {
/* Base Options */
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"noEmit": true, // This fixes the error with 'allowImportingTsExtensions'
/* Deno-specific settings */
"module": "ESNext",
"moduleResolution": "bundler",
// The Deno Language Server will provide "deno.ns" automatically from deno.json.
// We can keep a basic lib for other tools.
"lib": ["ESNext", "DOM", "DOM.Iterable"]
}
}

View File

@@ -12,6 +12,19 @@ const vitestConfig = defineVitestConfig({
test: {
environment: 'jsdom',
setupFiles: ['./src/vitest.setup.ts'],
coverage: {
provider: 'v8', // or 'istanbul'
// Reporters to use. 'text' will show a summary in the console.
// 'html' will generate a full report in the directory specified below.
reporter: ['text', 'html'],
reportsDirectory: './coverage',
include: ['src/**/*.{ts,tsx}'], // This now correctly includes all src files for coverage analysis.
// Exclude files that are not relevant for coverage.
exclude: [
'src/main.tsx', 'src/vite-env.d.ts', 'src/types.ts', 'src/vitest.setup.ts',
'src/**/*.test.{ts,tsx}', 'src/components/icons', 'src/services/logger.ts', 'src/services/notificationService.ts'
],
}
},
});