diff --git a/.gitea/workflows/manual-db-backup.yml b/.gitea/workflows/manual-db-backup.yml index 96fcfe7..1434c3e 100644 --- a/.gitea/workflows/manual-db-backup.yml +++ b/.gitea/workflows/manual-db-backup.yml @@ -60,4 +60,4 @@ jobs: uses: actions/upload-artifact@v3 with: name: database-backup - path: ${{ env.backup_filename }} \ No newline at end of file + path: ${{ env.backup_filename }} diff --git a/.gitea/workflows/manual-db-reset-prod.yml b/.gitea/workflows/manual-db-reset-prod.yml index 679e465..6939d43 100644 --- a/.gitea/workflows/manual-db-reset-prod.yml +++ b/.gitea/workflows/manual-db-reset-prod.yml @@ -144,4 +144,4 @@ jobs: find "$APP_PATH/flyer-images" -type f -name '*-test-flyer-image.*' -delete find "$APP_PATH/flyer-images/icons" -type f -name '*-test-flyer-image.*' -delete find "$APP_PATH/flyer-images/archive" -mindepth 1 -maxdepth 1 -type f -delete || echo "Archive directory not found, skipping." - echo "✅ Flyer asset directories cleared." \ No newline at end of file + echo "✅ Flyer asset directories cleared." diff --git a/.gitea/workflows/manual-db-reset-test.yml b/.gitea/workflows/manual-db-reset-test.yml index 12f0f35..c114cf9 100644 --- a/.gitea/workflows/manual-db-reset-test.yml +++ b/.gitea/workflows/manual-db-reset-test.yml @@ -130,4 +130,4 @@ jobs: find "$APP_PATH/flyer-images" -mindepth 1 -type f -delete find "$APP_PATH/flyer-images/icons" -mindepth 1 -type f -delete find "$APP_PATH/flyer-images/archive" -mindepth 1 -type f -delete || echo "Archive directory not found, skipping." - echo "✅ Test flyer asset directories cleared." \ No newline at end of file + echo "✅ Test flyer asset directories cleared." diff --git a/.gitea/workflows/manual-db-restore.yml b/.gitea/workflows/manual-db-restore.yml index 06974e9..6956e3e 100644 --- a/.gitea/workflows/manual-db-restore.yml +++ b/.gitea/workflows/manual-db-restore.yml @@ -25,7 +25,7 @@ jobs: DB_USER: ${{ secrets.DB_USER }} DB_PASSWORD: ${{ secrets.DB_PASSWORD }} DB_NAME: ${{ secrets.DB_DATABASE_PROD }} - BACKUP_DIR: "/var/www/backups" # Define a dedicated directory for backups + BACKUP_DIR: '/var/www/backups' # Define a dedicated directory for backups steps: - name: Validate Secrets and Inputs @@ -92,4 +92,4 @@ jobs: echo "Restarting application server..." cd /var/www/flyer-crawler.projectium.com pm2 startOrReload ecosystem.config.cjs --env production && pm2 save - echo "✅ Application server restarted." \ No newline at end of file + echo "✅ Application server restarted." diff --git a/docs/adr/0001-standardized-error-handling.md b/docs/adr/0001-standardized-error-handling.md index a7b4b83..017b136 100644 --- a/docs/adr/0001-standardized-error-handling.md +++ b/docs/adr/0001-standardized-error-handling.md @@ -34,7 +34,7 @@ We will adopt a strict, consistent error-handling contract for the service and r **Robustness**: Eliminates an entire class of bugs where `undefined` is passed to `res.json()`, preventing incorrect `500` errors. **Consistency & Predictability**: All data-fetching methods now have a predictable contract. They either return the expected data or throw a specific, typed error. **Developer Experience**: Route handlers become simpler, cleaner, and easier to write correctly. The cognitive load on developers is reduced as they no longer need to remember to check for `undefined`. -**Improved Testability**: Tests become more reliable and realistic. Mocks can now throw the *exact* error type (`new NotFoundError()`) that the real implementation would, ensuring tests accurately reflect the application's behavior. +**Improved Testability**: Tests become more reliable and realistic. Mocks can now throw the _exact_ error type (`new NotFoundError()`) that the real implementation would, ensuring tests accurately reflect the application's behavior. **Centralized Control**: Error-to-HTTP-status logic is centralized in the `errorHandler` middleware, making it easy to manage and modify error responses globally. ### Negative diff --git a/docs/adr/0002-standardized-transaction-management.md b/docs/adr/0002-standardized-transaction-management.md index e27200f..5a898ef 100644 --- a/docs/adr/0002-standardized-transaction-management.md +++ b/docs/adr/0002-standardized-transaction-management.md @@ -10,21 +10,19 @@ Following the standardization of error handling in ADR-001, the next most common This manual approach has several drawbacks: **Repetitive Boilerplate**: The `try/catch/finally` block for transaction management is duplicated across multiple files. -**Error-Prone**: It is easy to forget to `client.release()` in all code paths, which can lead to connection pool exhaustion and bring down the application. -3. **Poor Composability**: It is difficult to compose multiple repository methods into a single, atomic "Unit of Work". For example, a service function that needs to update a user's points and create a budget in a single transaction cannot easily do so if both underlying repository methods create their own transactions. +**Error-Prone**: It is easy to forget to `client.release()` in all code paths, which can lead to connection pool exhaustion and bring down the application. 3. **Poor Composability**: It is difficult to compose multiple repository methods into a single, atomic "Unit of Work". For example, a service function that needs to update a user's points and create a budget in a single transaction cannot easily do so if both underlying repository methods create their own transactions. ## Decision We will implement a standardized "Unit of Work" pattern through a high-level `withTransaction` helper function. This function will abstract away the complexity of transaction management. 1. **`withTransaction` Helper**: A new helper function, `withTransaction(callback: (client: PoolClient) => Promise): Promise`, will be created. This function will be responsible for: - -* Acquiring a client from the database pool. -* Starting a transaction (`BEGIN`). -* Executing the `callback` function, passing the transactional client to it. -* If the callback succeeds, it will `COMMIT` the transaction. -* If the callback throws an error, it will `ROLLBACK` the transaction and re-throw the error. -* In all cases, it will `RELEASE` the client back to the pool. + - Acquiring a client from the database pool. + - Starting a transaction (`BEGIN`). + - Executing the `callback` function, passing the transactional client to it. + - If the callback succeeds, it will `COMMIT` the transaction. + - If the callback throws an error, it will `ROLLBACK` the transaction and re-throw the error. + - In all cases, it will `RELEASE` the client back to the pool. 2. **Repository Method Signature**: Repository methods that need to be part of a transaction will be updated to optionally accept a `PoolClient` in their constructor or as a method parameter. By default, they will use the global pool. When called from within a `withTransaction` block, they will be passed the transactional client. 3. **Service Layer Orchestration**: Service-layer functions that orchestrate multi-step operations will use `withTransaction` to ensure atomicity. They will instantiate or call repository methods, providing them with the transactional client from the callback. @@ -40,7 +38,7 @@ async function registerUserAndCreateDefaultList(userData) { const shoppingRepo = new ShoppingRepository(client); const newUser = await userRepo.createUser(userData); - await shoppingRepo.createShoppingList(newUser.user_id, "My First List"); + await shoppingRepo.createShoppingList(newUser.user_id, 'My First List'); return newUser; }); diff --git a/docs/adr/0003-standardized-input-validation-using-middleware.md b/docs/adr/0003-standardized-input-validation-using-middleware.md index 7da09d1..e9af1bc 100644 --- a/docs/adr/0003-standardized-input-validation-using-middleware.md +++ b/docs/adr/0003-standardized-input-validation-using-middleware.md @@ -20,8 +20,8 @@ We will adopt a schema-based approach for input validation using the `zod` libra 1. **Adopt `zod` for Schema Definition**: We will use `zod` to define clear, type-safe schemas for the `params`, `query`, and `body` of each API request. `zod` provides powerful and declarative validation rules and automatically infers TypeScript types. 2. **Create a Reusable Validation Middleware**: A generic `validateRequest(schema)` middleware will be created. This middleware will take a `zod` schema, parse the incoming request against it, and handle success and error cases. - * On successful validation, the parsed and typed data will be attached to the `req` object (e.g., `req.body` will be replaced with the parsed body), and `next()` will be called. - * On validation failure, the middleware will call `next()` with a custom `ValidationError` containing a structured list of issues, which `ADR-001`'s `errorHandler` can then format into a user-friendly `400 Bad Request` response. + - On successful validation, the parsed and typed data will be attached to the `req` object (e.g., `req.body` will be replaced with the parsed body), and `next()` will be called. + - On validation failure, the middleware will call `next()` with a custom `ValidationError` containing a structured list of issues, which `ADR-001`'s `errorHandler` can then format into a user-friendly `400 Bad Request` response. 3. **Refactor Routes**: All route handlers will be refactored to use this new middleware, removing all manual validation logic. @@ -46,18 +46,18 @@ const getFlyerSchema = z.object({ type GetFlyerRequest = z.infer; // 3. Apply the middleware and use an inline cast for the request -router.get('/:id', validateRequest(getFlyerSchema), (async (req, res, next) => { - // Cast 'req' to the inferred type. - // This provides full type safety for params, query, and body. - const { params } = req as unknown as GetFlyerRequest; +router.get('/:id', validateRequest(getFlyerSchema), async (req, res, next) => { + // Cast 'req' to the inferred type. + // This provides full type safety for params, query, and body. + const { params } = req as unknown as GetFlyerRequest; - try { - const flyer = await db.flyerRepo.getFlyerById(params.id); // params.id is 'number' - res.json(flyer); - } catch (error) { - next(error); - } -})); + try { + const flyer = await db.flyerRepo.getFlyerById(params.id); // params.id is 'number' + res.json(flyer); + } catch (error) { + next(error); + } +}); ``` ## Consequences diff --git a/docs/adr/0004-standardized-application-wide-structured-logging.md b/docs/adr/0004-standardized-application-wide-structured-logging.md index c7b8889..75a2e30 100644 --- a/docs/adr/0004-standardized-application-wide-structured-logging.md +++ b/docs/adr/0004-standardized-application-wide-structured-logging.md @@ -20,9 +20,9 @@ We will adopt a standardized, application-wide structured logging policy. All lo **Request-Scoped Logger with Context**: We will create a middleware that runs at the beginning of the request lifecycle. This middleware will: -* Generate a unique `request_id` for each incoming request. -* Create a request-scoped logger instance (a "child logger") that automatically includes the `request_id`, `user_id` (if authenticated), and `ip_address` in every log message it generates. -* Attach this child logger to the `req` object (e.g., `req.log`). +- Generate a unique `request_id` for each incoming request. +- Create a request-scoped logger instance (a "child logger") that automatically includes the `request_id`, `user_id` (if authenticated), and `ip_address` in every log message it generates. +- Attach this child logger to the `req` object (e.g., `req.log`). **Mandatory Use of Request-Scoped Logger**: All route handlers and any service functions called by them **MUST** use the request-scoped logger (`req.log`) instead of the global logger instance. This ensures all logs for a given request are automatically correlated. @@ -32,9 +32,9 @@ We will adopt a standardized, application-wide structured logging policy. All lo **Standardized Logging Practices**: **INFO**: Log key business events, such as `User logged in` or `Flyer processed`. - **WARN**: Log recoverable errors or unusual situations that do not break the request, such as `Client Error: 404 on GET /api/non-existent-route` or `Retrying failed database connection`. - **ERROR**: Log only unhandled or server-side errors that cause a request to fail (typically handled by the `errorHandler`). Avoid logging expected client errors (like 4xx) at this level. - **DEBUG**: Log detailed diagnostic information useful during development, such as function entry/exit points or variable states. +**WARN**: Log recoverable errors or unusual situations that do not break the request, such as `Client Error: 404 on GET /api/non-existent-route` or `Retrying failed database connection`. +**ERROR**: Log only unhandled or server-side errors that cause a request to fail (typically handled by the `errorHandler`). Avoid logging expected client errors (like 4xx) at this level. +**DEBUG**: Log detailed diagnostic information useful during development, such as function entry/exit points or variable states. ### Example Usage @@ -59,15 +59,15 @@ export const requestLogger = (req, res, next) => { // In a route handler: router.get('/:id', async (req, res, next) => { - // Use the request-scoped logger - req.log.info({ flyerId: req.params.id }, 'Fetching flyer by ID'); - try { - // ... business logic ... - res.json(flyer); - } catch (error) { - // The error itself will be logged with full context by the errorHandler - next(error); - } + // Use the request-scoped logger + req.log.info({ flyerId: req.params.id }, 'Fetching flyer by ID'); + try { + // ... business logic ... + res.json(flyer); + } catch (error) { + // The error itself will be logged with full context by the errorHandler + next(error); + } }); ``` diff --git a/docs/adr/0011-advanced-authorization-and-access-control-strategy.md b/docs/adr/0011-advanced-authorization-and-access-control-strategy.md index 190a2b6..15274be 100644 --- a/docs/adr/0011-advanced-authorization-and-access-control-strategy.md +++ b/docs/adr/0011-advanced-authorization-and-access-control-strategy.md @@ -14,5 +14,5 @@ We will formalize a centralized Role-Based Access Control (RBAC) or Attribute-Ba ## Consequences -* **Positive**: Ensures authorization logic is consistent, easy to audit, and decoupled from business logic. Improves security by centralizing access control. -* **Negative**: Requires a significant refactoring effort to integrate the new authorization system across all protected routes and features. Introduces a new dependency if an external library is chosen. +- **Positive**: Ensures authorization logic is consistent, easy to audit, and decoupled from business logic. Improves security by centralizing access control. +- **Negative**: Requires a significant refactoring effort to integrate the new authorization system across all protected routes and features. Introduces a new dependency if an external library is chosen. diff --git a/docs/adr/0012-frontend-component-library-and-design-system.md b/docs/adr/0012-frontend-component-library-and-design-system.md index 9262205..2d7080f 100644 --- a/docs/adr/0012-frontend-component-library-and-design-system.md +++ b/docs/adr/0012-frontend-component-library-and-design-system.md @@ -14,5 +14,5 @@ We will establish a formal Design System and Component Library. This will involv ## Consequences -* **Positive**: Ensures a consistent and high-quality user interface. Accelerates frontend development by providing reusable, well-documented components. Improves maintainability and reduces technical debt. -* **Negative**: Requires an initial investment in setting up Storybook and migrating existing components. Adds a new dependency and a new workflow for frontend development. +- **Positive**: Ensures a consistent and high-quality user interface. Accelerates frontend development by providing reusable, well-documented components. Improves maintainability and reduces technical debt. +- **Negative**: Requires an initial investment in setting up Storybook and migrating existing components. Adds a new dependency and a new workflow for frontend development. diff --git a/docs/adr/0013-database-schema-migration-strategy.md b/docs/adr/0013-database-schema-migration-strategy.md index 618295d..fb29d1d 100644 --- a/docs/adr/0013-database-schema-migration-strategy.md +++ b/docs/adr/0013-database-schema-migration-strategy.md @@ -14,5 +14,5 @@ We will adopt a dedicated database migration tool, such as **`node-pg-migrate`** ## Consequences -* **Positive**: Provides a safe, repeatable, and reversible way to evolve the database schema. Improves team collaboration on database changes. Reduces the risk of data loss or downtime during deployments. -* **Negative**: Requires an initial setup and learning curve for the chosen migration tool. All future schema changes must adhere to the migration workflow. +- **Positive**: Provides a safe, repeatable, and reversible way to evolve the database schema. Improves team collaboration on database changes. Reduces the risk of data loss or downtime during deployments. +- **Negative**: Requires an initial setup and learning curve for the chosen migration tool. All future schema changes must adhere to the migration workflow. diff --git a/docs/adr/0014-containerization-and-deployment-strategy.md b/docs/adr/0014-containerization-and-deployment-strategy.md index c48849a..7e0ea1a 100644 --- a/docs/adr/0014-containerization-and-deployment-strategy.md +++ b/docs/adr/0014-containerization-and-deployment-strategy.md @@ -14,5 +14,5 @@ We will standardize the deployment process by containerizing the application usi ## Consequences -* **Positive**: Ensures consistency between development and production environments. Simplifies the setup for new developers. Improves portability and scalability of the application. -* **Negative**: Requires learning Docker and containerization concepts. Adds `Dockerfile` and `docker-compose.yml` to the project's configuration. +- **Positive**: Ensures consistency between development and production environments. Simplifies the setup for new developers. Improves portability and scalability of the application. +- **Negative**: Requires learning Docker and containerization concepts. Adds `Dockerfile` and `docker-compose.yml` to the project's configuration. diff --git a/docs/adr/0016-api-security-hardening.md b/docs/adr/0016-api-security-hardening.md index 7a6eb5a..a83bf5d 100644 --- a/docs/adr/0016-api-security-hardening.md +++ b/docs/adr/0016-api-security-hardening.md @@ -18,5 +18,5 @@ We will implement a multi-layered security approach for the API: ## Consequences -* **Positive**: Significantly improves the application's security posture against common web vulnerabilities like XSS, clickjacking, and brute-force attacks. -* **Negative**: Requires careful configuration of CORS and rate limits to avoid blocking legitimate traffic. Content-Security-Policy can be complex to configure correctly. +- **Positive**: Significantly improves the application's security posture against common web vulnerabilities like XSS, clickjacking, and brute-force attacks. +- **Negative**: Requires careful configuration of CORS and rate limits to avoid blocking legitimate traffic. Content-Security-Policy can be complex to configure correctly. diff --git a/docs/adr/0017-ci-cd-and-branching-strategy.md b/docs/adr/0017-ci-cd-and-branching-strategy.md index 68ca6fb..071eb69 100644 --- a/docs/adr/0017-ci-cd-and-branching-strategy.md +++ b/docs/adr/0017-ci-cd-and-branching-strategy.md @@ -14,5 +14,5 @@ We will formalize the end-to-end CI/CD process. This ADR will define the project ## Consequences -* **Positive**: Automates quality control and creates a safe, repeatable path to production. Increases development velocity and reduces deployment-related errors. -* **Negative**: Initial setup effort for the CI/CD pipeline. May slightly increase the time to merge code due to mandatory checks. +- **Positive**: Automates quality control and creates a safe, repeatable path to production. Increases development velocity and reduces deployment-related errors. +- **Negative**: Initial setup effort for the CI/CD pipeline. May slightly increase the time to merge code due to mandatory checks. diff --git a/docs/adr/0018-api-documentation-strategy.md b/docs/adr/0018-api-documentation-strategy.md index 3227d08..912af43 100644 --- a/docs/adr/0018-api-documentation-strategy.md +++ b/docs/adr/0018-api-documentation-strategy.md @@ -14,5 +14,5 @@ We will adopt **OpenAPI (Swagger)** for API documentation. We will use tools (e. ## Consequences -* **Positive**: Creates a single source of truth for API documentation that stays in sync with the code. Enables auto-generation of client SDKs and simplifies testing. -* **Negative**: Requires developers to maintain JSDoc annotations on all routes. Adds a build step and new dependencies to the project. +- **Positive**: Creates a single source of truth for API documentation that stays in sync with the code. Enables auto-generation of client SDKs and simplifies testing. +- **Negative**: Requires developers to maintain JSDoc annotations on all routes. Adds a build step and new dependencies to the project. diff --git a/docs/adr/0019-data-backup-and-recovery-strategy.md b/docs/adr/0019-data-backup-and-recovery-strategy.md index 3d668c3..fdf647d 100644 --- a/docs/adr/0019-data-backup-and-recovery-strategy.md +++ b/docs/adr/0019-data-backup-and-recovery-strategy.md @@ -14,5 +14,5 @@ We will implement a formal data backup and recovery strategy. This will involve ## Consequences -* **Positive**: Protects against catastrophic data loss, ensuring business continuity. Provides a clear, tested plan for disaster recovery. -* **Negative**: Requires setup and maintenance of backup scripts and secure storage. Incurs storage costs for backup files. +- **Positive**: Protects against catastrophic data loss, ensuring business continuity. Provides a clear, tested plan for disaster recovery. +- **Negative**: Requires setup and maintenance of backup scripts and secure storage. Incurs storage costs for backup files. diff --git a/docs/adr/0020-health-checks-and-liveness-readiness-probes.md b/docs/adr/0020-health-checks-and-liveness-readiness-probes.md index fc5dbee..d835d34 100644 --- a/docs/adr/0020-health-checks-and-liveness-readiness-probes.md +++ b/docs/adr/0020-health-checks-and-liveness-readiness-probes.md @@ -12,11 +12,11 @@ When the application is containerized (`ADR-014`), the container orchestrator (e We will implement dedicated health check endpoints in the Express application. -* A **Liveness Probe** (`/api/health/live`) will return a `200 OK` to indicate the server is running. If it fails, the orchestrator should restart the container. +- A **Liveness Probe** (`/api/health/live`) will return a `200 OK` to indicate the server is running. If it fails, the orchestrator should restart the container. -* A **Readiness Probe** (`/api/health/ready`) will return a `200 OK` only if the application is ready to accept traffic (e.g., database connection is established). If it fails, the orchestrator will temporarily remove the container from the load balancer. +- A **Readiness Probe** (`/api/health/ready`) will return a `200 OK` only if the application is ready to accept traffic (e.g., database connection is established). If it fails, the orchestrator will temporarily remove the container from the load balancer. ## Consequences -* **Positive**: Enables robust, automated application lifecycle management in a containerized environment. Prevents traffic from being sent to unhealthy or uninitialized application instances. -* **Negative**: Adds a small amount of code for the health check endpoints. Requires configuration in the container orchestration layer. +- **Positive**: Enables robust, automated application lifecycle management in a containerized environment. Prevents traffic from being sent to unhealthy or uninitialized application instances. +- **Negative**: Adds a small amount of code for the health check endpoints. Requires configuration in the container orchestration layer. diff --git a/docs/adr/0026-standardized-client-side-structured-logging.md b/docs/adr/0026-standardized-client-side-structured-logging.md index 250c654..380adb7 100644 --- a/docs/adr/0026-standardized-client-side-structured-logging.md +++ b/docs/adr/0026-standardized-client-side-structured-logging.md @@ -24,8 +24,8 @@ We will adopt a standardized, application-wide structured logging policy for all **2. Pino-like API for Structured Logging**: The client logger mimics the `pino` API, which is the standard on the backend. It supports two primary call signatures: -* `logger.info('A simple message');` -* `logger.info({ key: 'value' }, 'A message with a structured data payload');` +- `logger.info('A simple message');` +- `logger.info({ key: 'value' }, 'A message with a structured data payload');` The second signature, which includes a data object as the first argument, is **strongly preferred**, especially for logging errors or complex state. @@ -79,7 +79,7 @@ describe('MyComponent', () => { // Assert that the logger was called with the expected structure expect(logger.error).toHaveBeenCalledWith( expect.objectContaining({ err: expect.any(Error) }), // Check for the error object - 'Failed to fetch component data' // Check for the message + 'Failed to fetch component data', // Check for the message ); }); }); diff --git a/sql/initial_schema.sql b/sql/initial_schema.sql index abe789c..d783c37 100644 --- a/sql/initial_schema.sql +++ b/sql/initial_schema.sql @@ -110,8 +110,8 @@ CREATE TABLE IF NOT EXISTS public.flyers ( file_name TEXT NOT NULL, image_url TEXT NOT NULL, icon_url TEXT, - checksum TEXT UNIQUE, - store_id BIGINT REFERENCES public.stores(store_id), + checksum TEXT UNIQUE, + store_id BIGINT REFERENCES public.stores(store_id) ON DELETE CASCADE, valid_from DATE, valid_to DATE, store_address TEXT, @@ -139,7 +139,7 @@ CREATE INDEX IF NOT EXISTS idx_flyers_valid_to_file_name ON public.flyers (valid CREATE TABLE IF NOT EXISTS public.master_grocery_items ( master_grocery_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, name TEXT NOT NULL UNIQUE, - category_id BIGINT REFERENCES public.categories(category_id), + category_id BIGINT REFERENCES public.categories(category_id) ON DELETE SET NULL, is_allergen BOOLEAN DEFAULT false, allergy_info JSONB, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, @@ -171,13 +171,13 @@ CREATE TABLE IF NOT EXISTS public.flyer_items ( price_in_cents INTEGER, quantity_num NUMERIC, quantity TEXT NOT NULL, - category_id BIGINT REFERENCES public.categories(category_id), + category_id BIGINT REFERENCES public.categories(category_id) ON DELETE SET NULL, category_name TEXT, unit_price JSONB, view_count INTEGER DEFAULT 0 NOT NULL, click_count INTEGER DEFAULT 0 NOT NULL, - master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id), - product_id BIGINT, + master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE SET NULL, + product_id BIGINT REFERENCES public.products(product_id) ON DELETE SET NULL, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL ); @@ -294,7 +294,7 @@ CREATE INDEX IF NOT EXISTS idx_shopping_lists_user_id ON public.shopping_lists(u CREATE TABLE IF NOT EXISTS public.shopping_list_items ( shopping_list_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, shopping_list_id BIGINT NOT NULL REFERENCES public.shopping_lists(shopping_list_id) ON DELETE CASCADE, - master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id), + master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE, custom_item_name TEXT, quantity NUMERIC DEFAULT 1 NOT NULL, is_purchased BOOLEAN DEFAULT false NOT NULL, @@ -359,7 +359,7 @@ CREATE INDEX IF NOT EXISTS idx_shared_menu_plans_shared_with_user_id ON public.s CREATE TABLE IF NOT EXISTS public.suggested_corrections ( suggested_correction_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, flyer_item_id BIGINT NOT NULL REFERENCES public.flyer_items(flyer_item_id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES public.users(user_id), + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, correction_type TEXT NOT NULL, suggested_value TEXT NOT NULL, status TEXT DEFAULT 'pending' NOT NULL, @@ -379,9 +379,9 @@ CREATE INDEX IF NOT EXISTS idx_suggested_corrections_pending ON public.suggested -- 21. For prices submitted directly by users from in-store. CREATE TABLE IF NOT EXISTS public.user_submitted_prices ( user_submitted_price_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(user_id), - master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id), - store_id BIGINT NOT NULL REFERENCES public.stores(store_id), + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE, + store_id BIGINT NOT NULL REFERENCES public.stores(store_id) ON DELETE CASCADE, price_in_cents INTEGER NOT NULL, photo_url TEXT, upvotes INTEGER DEFAULT 0 NOT NULL, @@ -424,8 +424,8 @@ COMMENT ON COLUMN public.brands.store_id IS 'If this is a store-specific brand ( -- 24. For specific products, linking a master item with a brand and size. CREATE TABLE IF NOT EXISTS public.products ( product_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id), - brand_id BIGINT REFERENCES public.brands(brand_id), + master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE, + brand_id BIGINT REFERENCES public.brands(brand_id) ON DELETE SET NULL, name TEXT NOT NULL, description TEXT, size TEXT, @@ -496,7 +496,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_recipes_unique_system_recipe_name ON publi CREATE TABLE IF NOT EXISTS public.recipe_ingredients ( recipe_ingredient_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, recipe_id BIGINT NOT NULL REFERENCES public.recipes(recipe_id) ON DELETE CASCADE, - master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id), + master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE, quantity NUMERIC NOT NULL, unit TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, @@ -780,7 +780,7 @@ CREATE INDEX IF NOT EXISTS idx_shopping_trips_shopping_list_id ON public.shoppin CREATE TABLE IF NOT EXISTS public.shopping_trip_items ( shopping_trip_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, shopping_trip_id BIGINT NOT NULL REFERENCES public.shopping_trips(shopping_trip_id) ON DELETE CASCADE, - master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id), + master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE SET NULL, custom_item_name TEXT, quantity NUMERIC NOT NULL, price_paid_cents INTEGER, @@ -844,7 +844,7 @@ CREATE INDEX IF NOT EXISTS idx_user_follows_following_id ON public.user_follows( CREATE TABLE IF NOT EXISTS public.receipts ( receipt_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, - store_id BIGINT REFERENCES public.stores(store_id), + store_id BIGINT REFERENCES public.stores(store_id) ON DELETE CASCADE, receipt_image_url TEXT NOT NULL, transaction_date TIMESTAMPTZ, total_amount_cents INTEGER, @@ -865,8 +865,8 @@ CREATE TABLE IF NOT EXISTS public.receipt_items ( raw_item_description TEXT NOT NULL, quantity NUMERIC DEFAULT 1 NOT NULL, price_paid_cents INTEGER NOT NULL, - master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id), - product_id BIGINT REFERENCES public.products(product_id), + master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE SET NULL, + product_id BIGINT REFERENCES public.products(product_id) ON DELETE SET NULL, status TEXT DEFAULT 'unmatched' NOT NULL CHECK (status IN ('unmatched', 'matched', 'needs_review', 'ignored')), created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL diff --git a/sql/master_schema_rollup.sql b/sql/master_schema_rollup.sql index 2d083aa..c29330b 100644 --- a/sql/master_schema_rollup.sql +++ b/sql/master_schema_rollup.sql @@ -126,8 +126,8 @@ CREATE TABLE IF NOT EXISTS public.flyers ( file_name TEXT NOT NULL, image_url TEXT NOT NULL, icon_url TEXT, - checksum TEXT UNIQUE, - store_id BIGINT REFERENCES public.stores(store_id), + checksum TEXT UNIQUE, + store_id BIGINT REFERENCES public.stores(store_id) ON DELETE CASCADE, valid_from DATE, valid_to DATE, store_address TEXT, @@ -155,7 +155,7 @@ CREATE INDEX IF NOT EXISTS idx_flyers_valid_to_file_name ON public.flyers (valid CREATE TABLE IF NOT EXISTS public.master_grocery_items ( master_grocery_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, name TEXT NOT NULL UNIQUE, - category_id BIGINT REFERENCES public.categories(category_id), + category_id BIGINT REFERENCES public.categories(category_id) ON DELETE SET NULL, is_allergen BOOLEAN DEFAULT false, allergy_info JSONB, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, @@ -187,13 +187,13 @@ CREATE TABLE IF NOT EXISTS public.flyer_items ( price_in_cents INTEGER, quantity_num NUMERIC, quantity TEXT NOT NULL, - category_id BIGINT REFERENCES public.categories(category_id), + category_id BIGINT REFERENCES public.categories(category_id) ON DELETE SET NULL, category_name TEXT, unit_price JSONB, view_count INTEGER DEFAULT 0 NOT NULL, click_count INTEGER DEFAULT 0 NOT NULL, - master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id), - product_id BIGINT, + master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE SET NULL, + product_id BIGINT REFERENCES public.products(product_id) ON DELETE SET NULL, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL ); @@ -310,7 +310,7 @@ CREATE INDEX IF NOT EXISTS idx_shopping_lists_user_id ON public.shopping_lists(u CREATE TABLE IF NOT EXISTS public.shopping_list_items ( shopping_list_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, shopping_list_id BIGINT NOT NULL REFERENCES public.shopping_lists(shopping_list_id) ON DELETE CASCADE, - master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id), + master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE, custom_item_name TEXT, quantity NUMERIC DEFAULT 1 NOT NULL, is_purchased BOOLEAN DEFAULT false NOT NULL, @@ -375,7 +375,7 @@ CREATE INDEX IF NOT EXISTS idx_shared_menu_plans_shared_with_user_id ON public.s CREATE TABLE IF NOT EXISTS public.suggested_corrections ( suggested_correction_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, flyer_item_id BIGINT NOT NULL REFERENCES public.flyer_items(flyer_item_id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES public.users(user_id), + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, correction_type TEXT NOT NULL, suggested_value TEXT NOT NULL, status TEXT DEFAULT 'pending' NOT NULL, @@ -395,9 +395,9 @@ CREATE INDEX IF NOT EXISTS idx_suggested_corrections_pending ON public.suggested -- 21. For prices submitted directly by users from in-store. CREATE TABLE IF NOT EXISTS public.user_submitted_prices ( user_submitted_price_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - user_id UUID NOT NULL REFERENCES public.users(user_id), - master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id), - store_id BIGINT NOT NULL REFERENCES public.stores(store_id), + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE, + store_id BIGINT NOT NULL REFERENCES public.stores(store_id) ON DELETE CASCADE, price_in_cents INTEGER NOT NULL, photo_url TEXT, upvotes INTEGER DEFAULT 0 NOT NULL, @@ -439,8 +439,8 @@ COMMENT ON COLUMN public.brands.store_id IS 'If this is a store-specific brand ( -- 24. For specific products, linking a master item with a brand and size. CREATE TABLE IF NOT EXISTS public.products ( product_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id), - brand_id BIGINT REFERENCES public.brands(brand_id), + master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE, + brand_id BIGINT REFERENCES public.brands(brand_id) ON DELETE SET NULL, name TEXT NOT NULL, description TEXT, size TEXT, @@ -510,7 +510,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_recipes_unique_system_recipe_name ON publi CREATE TABLE IF NOT EXISTS public.recipe_ingredients ( recipe_ingredient_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, recipe_id BIGINT NOT NULL REFERENCES public.recipes(recipe_id) ON DELETE CASCADE, - master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id), + master_item_id BIGINT NOT NULL REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE, quantity NUMERIC NOT NULL, unit TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT now() NOT NULL, @@ -796,7 +796,7 @@ CREATE INDEX IF NOT EXISTS idx_shopping_trips_shopping_list_id ON public.shoppin CREATE TABLE IF NOT EXISTS public.shopping_trip_items ( shopping_trip_item_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, shopping_trip_id BIGINT NOT NULL REFERENCES public.shopping_trips(shopping_trip_id) ON DELETE CASCADE, - master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id), + master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE SET NULL, custom_item_name TEXT, quantity NUMERIC NOT NULL, price_paid_cents INTEGER, @@ -862,7 +862,7 @@ CREATE INDEX IF NOT EXISTS idx_user_follows_following_id ON public.user_follows( CREATE TABLE IF NOT EXISTS public.receipts ( receipt_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, - store_id BIGINT REFERENCES public.stores(store_id), + store_id BIGINT REFERENCES public.stores(store_id) ON DELETE CASCADE, receipt_image_url TEXT NOT NULL, transaction_date TIMESTAMPTZ, total_amount_cents INTEGER, @@ -883,8 +883,8 @@ CREATE TABLE IF NOT EXISTS public.receipt_items ( raw_item_description TEXT NOT NULL, quantity NUMERIC DEFAULT 1 NOT NULL, price_paid_cents INTEGER NOT NULL, - master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id), - product_id BIGINT REFERENCES public.products(product_id), + master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE SET NULL, + product_id BIGINT REFERENCES public.products(product_id) ON DELETE SET NULL, status TEXT DEFAULT 'unmatched' NOT NULL CHECK (status IN ('unmatched', 'matched', 'needs_review', 'ignored')), created_at TIMESTAMPTZ DEFAULT now() NOT NULL, updated_at TIMESTAMPTZ DEFAULT now() NOT NULL diff --git a/src/routes/ai.routes.test.ts b/src/routes/ai.routes.test.ts index 065bc48..eb1d22d 100644 --- a/src/routes/ai.routes.test.ts +++ b/src/routes/ai.routes.test.ts @@ -620,6 +620,14 @@ describe('AI Routes (/api/ai)', () => { expect(response.body.text).toContain('server-generated quick insight'); }); + it('POST /quick-insights should accept items with "item" property instead of "name"', async () => { + const response = await supertest(app) + .post('/api/ai/quick-insights') + .send({ items: [{ item: 'test item' }] }); + + expect(response.status).toBe(200); + }); + it('POST /quick-insights should return 500 on a generic error', async () => { // To hit the catch block, we can simulate an error by making the logger throw. vi.mocked(mockLogger.info).mockImplementationOnce(() => { diff --git a/src/routes/ai.routes.ts b/src/routes/ai.routes.ts index 692b435..74dc747 100644 --- a/src/routes/ai.routes.ts +++ b/src/routes/ai.routes.ts @@ -88,10 +88,17 @@ const rescanAreaSchema = z.object({ const flyerItemForAnalysisSchema = z .object({ - name: requiredString('Item name is required.'), - // Allow other properties to pass through without validation + item: z.string().nullish(), + name: z.string().nullish(), }) - .passthrough(); + .passthrough() + .refine( + (data) => + (data.item && data.item.trim().length > 0) || (data.name && data.name.trim().length > 0), + { + message: "Item identifier is required (either 'item' or 'name').", + }, + ); const insightsSchema = z.object({ body: z.object({ diff --git a/src/routes/auth.routes.test.ts b/src/routes/auth.routes.test.ts index f0b20ac..7848384 100644 --- a/src/routes/auth.routes.test.ts +++ b/src/routes/auth.routes.test.ts @@ -617,7 +617,9 @@ describe('Auth Routes (/api/auth)', () => { const setCookieHeader = response.headers['set-cookie']; expect(setCookieHeader).toBeDefined(); expect(setCookieHeader[0]).toContain('refreshToken=;'); - expect(setCookieHeader[0]).toContain('Expires=Thu, 01 Jan 1970'); + // Check for Max-Age=0, which is the modern way to expire a cookie. + // The 'Expires' attribute is a fallback and its exact value can be inconsistent. + expect(setCookieHeader[0]).toContain('Max-Age=0'); }); it('should still return 200 OK even if deleting the refresh token from DB fails', async () => { diff --git a/src/tests/integration/ai.integration.test.ts b/src/tests/integration/ai.integration.test.ts index 5bf75c8..5c4b792 100644 --- a/src/tests/integration/ai.integration.test.ts +++ b/src/tests/integration/ai.integration.test.ts @@ -98,11 +98,27 @@ describe('AI API Routes Integration Tests', () => { altitudeAccuracy: null, heading: null, speed: null, - toJSON: () => ({}), + toJSON: function () { + return { + latitude: this.latitude, + longitude: this.longitude, + accuracy: this.accuracy, + altitude: this.altitude, + altitudeAccuracy: this.altitudeAccuracy, + heading: this.heading, + speed: this.speed, + }; + }, + }; + const mockStore = { + name: 'Test Store for Trip', + store_id: 1, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), }; const response = await aiApiClient.planTripWithMaps( [], - undefined, + mockStore, mockLocation, undefined, authToken, diff --git a/src/tests/integration/flyer-processing.integration.test.ts b/src/tests/integration/flyer-processing.integration.test.ts index e5d1de2..a145197 100644 --- a/src/tests/integration/flyer-processing.integration.test.ts +++ b/src/tests/integration/flyer-processing.integration.test.ts @@ -60,7 +60,11 @@ describe('Flyer Processing Background Job Integration Test', () => { // Arrange: Load a mock flyer PDF. const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg'); const imageBuffer = await fs.readFile(imagePath); - const mockImageFile = new File([imageBuffer], 'test-flyer-image.jpg', { type: 'image/jpeg' }); + // Create a unique buffer and filename for each test run to ensure a unique checksum. + // This prevents a 409 Conflict error when the second test runs. + const uniqueContent = Buffer.concat([imageBuffer, Buffer.from(Date.now().toString())]); + const uniqueFileName = `test-flyer-image-${Date.now()}.jpg`; + const mockImageFile = new File([uniqueContent], uniqueFileName, { type: 'image/jpeg' }); const checksum = await generateFileChecksum(mockImageFile); // Act 1: Upload the file to start the background job. diff --git a/src/tests/integration/public.routes.integration.test.ts b/src/tests/integration/public.routes.integration.test.ts index 7513640..7d17845 100644 --- a/src/tests/integration/public.routes.integration.test.ts +++ b/src/tests/integration/public.routes.integration.test.ts @@ -54,9 +54,10 @@ describe('Public API Routes Integration Tests', () => { testFlyer = flyerRes.rows[0]; // Add an item to the flyer - await pool.query(`INSERT INTO public.flyer_items (flyer_id, item) VALUES ($1, 'Test Item')`, [ - testFlyer.flyer_id, - ]); + await pool.query( + `INSERT INTO public.flyer_items (flyer_id, item, price_display, quantity) VALUES ($1, 'Test Item', '$0.00', 'each')`, + [testFlyer.flyer_id], + ); }); afterAll(async () => {