From 11aeac5edd7537b735b207c7dccb665b24ebb14a Mon Sep 17 00:00:00 2001 From: Torben Sorensen Date: Sun, 11 Jan 2026 19:05:43 -0800 Subject: [PATCH] whoa - so much - new features (UPC,etc) - Sentry for app logging! so much more ! --- .env.example | 19 + .nycrc.json | 5 + CLAUDE.md | 69 +- DEPLOYMENT.md | 60 + Dockerfile.dev | 88 +- INSTALL.md | 1 + compose.dev.yml | 9 + docs/BARE-METAL-SETUP.md | 637 +++++++++ ...rformance-monitoring-and-error-tracking.md | 1 + .../0051-asynchronous-context-propagation.md | 54 + .../0052-granular-debug-logging-strategy.md | 42 + docs/adr/0053-worker-health-checks.md | 62 + package-lock.json | 850 +++++++++++- package.json | 5 +- scripts/test-bugsink.ts | 164 +++ server.ts | 43 +- sql/initial_schema.sql | 229 +++ sql/master_schema_rollup.sql | 229 +++ sql/migrations/001_upc_scanning.sql | 90 ++ sql/migrations/002_expiry_tracking.sql | 189 +++ .../003_receipt_scanning_enhancements.sql | 169 +++ src/components/ErrorBoundary.tsx | 152 ++ src/config.ts | 10 + src/config/env.ts | 47 + .../passport.test.ts} | 4 +- .../passport.routes.ts => config/passport.ts} | 2 +- src/config/workerOptions.ts | 18 + src/index.tsx | 8 + src/middleware/multer.middleware.ts | 18 +- src/providers/AppProviders.tsx | 30 +- src/routes/admin.routes.ts | 3 +- src/routes/ai.routes.ts | 473 ++++++- src/routes/auth.routes.ts | 2 +- src/routes/budget.routes.ts | 2 +- src/routes/deals.routes.ts | 2 +- src/routes/gamification.routes.ts | 2 +- src/routes/inventory.routes.test.ts | 664 +++++++++ src/routes/inventory.routes.ts | 839 +++++++++++ src/routes/price.routes.ts | 46 +- src/routes/reactions.routes.ts | 103 +- src/routes/receipt.routes.test.ts | 767 +++++++++++ src/routes/receipt.routes.ts | 814 +++++++++++ src/routes/recipe.routes.ts | 2 +- src/routes/upc.routes.test.ts | 525 +++++++ src/routes/upc.routes.ts | 493 +++++++ src/routes/user.routes.ts | 2 +- src/services/aiService.server.ts | 12 +- src/services/apiClient.test.ts | 48 +- src/services/apiClient.ts | 23 +- src/services/barcodeService.server.test.ts | 404 ++++++ src/services/barcodeService.server.ts | 335 +++++ src/services/db/expiry.db.test.ts | 1079 +++++++++++++++ src/services/db/expiry.db.ts | 1111 +++++++++++++++ src/services/db/index.db.ts | 9 + src/services/db/receipt.db.test.ts | 1226 +++++++++++++++++ src/services/db/receipt.db.ts | 1074 +++++++++++++++ src/services/db/upc.db.test.ts | 518 +++++++ src/services/db/upc.db.ts | 556 ++++++++ src/services/expiryService.server.test.ts | 933 +++++++++++++ src/services/expiryService.server.ts | 955 +++++++++++++ src/services/flyerProcessingService.server.ts | 18 +- src/services/logger.server.ts | 12 + src/services/monitoringService.server.ts | 17 +- src/services/queueService.server.ts | 6 + src/services/queues.server.ts | 63 +- src/services/receiptService.server.test.ts | 791 +++++++++++ src/services/receiptService.server.ts | 843 ++++++++++++ src/services/sentry.client.ts | 124 ++ src/services/sentry.server.ts | 161 +++ src/services/upcService.server.test.ts | 674 +++++++++ src/services/upcService.server.ts | 614 +++++++++ src/services/workers.server.ts | 64 +- src/tests/e2e/error-reporting.e2e.test.ts | 252 ++++ src/tests/e2e/inventory-journey.e2e.test.ts | 406 ++++++ src/tests/e2e/receipt-journey.e2e.test.ts | 364 +++++ src/tests/e2e/upc-journey.e2e.test.ts | 247 ++++ .../integration/inventory.integration.test.ts | 650 +++++++++ .../integration/receipt.integration.test.ts | 591 ++++++++ src/tests/integration/upc.integration.test.ts | 450 ++++++ src/types/expiry.ts | 585 ++++++++ src/types/job-data.ts | 73 +- src/types/upc.ts | 282 ++++ 82 files changed, 23503 insertions(+), 110 deletions(-) create mode 100644 .nycrc.json create mode 100644 docs/BARE-METAL-SETUP.md create mode 100644 docs/adr/0051-asynchronous-context-propagation.md create mode 100644 docs/adr/0052-granular-debug-logging-strategy.md create mode 100644 docs/adr/0053-worker-health-checks.md create mode 100644 scripts/test-bugsink.ts create mode 100644 sql/migrations/001_upc_scanning.sql create mode 100644 sql/migrations/002_expiry_tracking.sql create mode 100644 sql/migrations/003_receipt_scanning_enhancements.sql create mode 100644 src/components/ErrorBoundary.tsx rename src/{routes/passport.routes.test.ts => config/passport.test.ts} (99%) rename src/{routes/passport.routes.ts => config/passport.ts} (99%) create mode 100644 src/config/workerOptions.ts create mode 100644 src/routes/inventory.routes.test.ts create mode 100644 src/routes/inventory.routes.ts create mode 100644 src/routes/receipt.routes.test.ts create mode 100644 src/routes/receipt.routes.ts create mode 100644 src/routes/upc.routes.test.ts create mode 100644 src/routes/upc.routes.ts create mode 100644 src/services/barcodeService.server.test.ts create mode 100644 src/services/barcodeService.server.ts create mode 100644 src/services/db/expiry.db.test.ts create mode 100644 src/services/db/expiry.db.ts create mode 100644 src/services/db/receipt.db.test.ts create mode 100644 src/services/db/receipt.db.ts create mode 100644 src/services/db/upc.db.test.ts create mode 100644 src/services/db/upc.db.ts create mode 100644 src/services/expiryService.server.test.ts create mode 100644 src/services/expiryService.server.ts create mode 100644 src/services/receiptService.server.test.ts create mode 100644 src/services/receiptService.server.ts create mode 100644 src/services/sentry.client.ts create mode 100644 src/services/sentry.server.ts create mode 100644 src/services/upcService.server.test.ts create mode 100644 src/services/upcService.server.ts create mode 100644 src/tests/e2e/error-reporting.e2e.test.ts create mode 100644 src/tests/e2e/inventory-journey.e2e.test.ts create mode 100644 src/tests/e2e/receipt-journey.e2e.test.ts create mode 100644 src/tests/e2e/upc-journey.e2e.test.ts create mode 100644 src/tests/integration/inventory.integration.test.ts create mode 100644 src/tests/integration/receipt.integration.test.ts create mode 100644 src/tests/integration/upc.integration.test.ts create mode 100644 src/types/expiry.ts create mode 100644 src/types/upc.ts diff --git a/.env.example b/.env.example index 6e2135d..9f65848 100644 --- a/.env.example +++ b/.env.example @@ -83,3 +83,22 @@ CLEANUP_WORKER_CONCURRENCY=10 # Worker lock duration in milliseconds (default: 2 minutes) WORKER_LOCK_DURATION=120000 + +# =================== +# Error Tracking (ADR-015) +# =================== +# Sentry-compatible error tracking via Bugsink (self-hosted) +# DSNs are created in Bugsink UI at http://localhost:8000 (dev) or your production URL +# Backend DSN - for Express/Node.js errors +SENTRY_DSN= +# Frontend DSN - for React/browser errors (uses VITE_ prefix) +VITE_SENTRY_DSN= +# Environment name for error grouping (defaults to NODE_ENV) +SENTRY_ENVIRONMENT=development +VITE_SENTRY_ENVIRONMENT=development +# Enable/disable error tracking (default: true) +SENTRY_ENABLED=true +VITE_SENTRY_ENABLED=true +# Enable debug mode for SDK troubleshooting (default: false) +SENTRY_DEBUG=false +VITE_SENTRY_DEBUG=false diff --git a/.nycrc.json b/.nycrc.json new file mode 100644 index 0000000..d983d6a --- /dev/null +++ b/.nycrc.json @@ -0,0 +1,5 @@ +{ + "text": { + "maxCols": 200 + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 5d361ea..87ed863 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -194,18 +194,61 @@ cb(null, `${file.fieldname}-${uniqueSuffix}-${sanitizedOriginalName}`); The following MCP servers are configured for this project: -| Server | Purpose | -| ------------------- | ---------------------------------------- | -| gitea-projectium | Gitea API for gitea.projectium.com | -| gitea-torbonium | Gitea API for gitea.torbonium.com | -| podman | Container management | -| filesystem | File system access | -| fetch | Web fetching | -| markitdown | Convert documents to markdown | -| sequential-thinking | Step-by-step reasoning | -| memory | Knowledge graph persistence | -| postgres | Direct database queries (localhost:5432) | -| playwright | Browser automation and testing | -| redis | Redis cache inspection (localhost:6379) | +| Server | Purpose | +| --------------------- | ------------------------------------------- | +| gitea-projectium | Gitea API for gitea.projectium.com | +| gitea-torbonium | Gitea API for gitea.torbonium.com | +| podman | Container management | +| filesystem | File system access | +| fetch | Web fetching | +| markitdown | Convert documents to markdown | +| sequential-thinking | Step-by-step reasoning | +| memory | Knowledge graph persistence | +| postgres | Direct database queries (localhost:5432) | +| playwright | Browser automation and testing | +| redis | Redis cache inspection (localhost:6379) | +| sentry-selfhosted-mcp | Error tracking via Bugsink (localhost:8000) | **Note:** MCP servers are currently only available in **Claude CLI**. Due to a bug in Claude VS Code extension, MCP servers do not work there yet. + +### Sentry/Bugsink MCP Server Setup (ADR-015) + +To enable Claude Code to query and analyze application errors from Bugsink: + +1. **Install the MCP server**: + + ```bash + # Clone the sentry-selfhosted-mcp repository + git clone https://github.com/ddfourtwo/sentry-selfhosted-mcp.git + cd sentry-selfhosted-mcp + npm install + ``` + +2. **Configure Claude Code** (add to `.claude/mcp.json`): + + ```json + { + "sentry-selfhosted-mcp": { + "command": "node", + "args": ["/path/to/sentry-selfhosted-mcp/dist/index.js"], + "env": { + "SENTRY_URL": "http://localhost:8000", + "SENTRY_AUTH_TOKEN": "", + "SENTRY_ORG_SLUG": "flyer-crawler" + } + } + } + ``` + +3. **Get the auth token**: + - Navigate to Bugsink UI at `http://localhost:8000` + - Log in with admin credentials + - Go to Settings > API Keys + - Create a new API key with read access + +4. **Available capabilities**: + - List projects and issues + - View detailed error events + - Search by error message or stack trace + - Update issue status (resolve, ignore) + - Add comments to issues diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 67d4fb8..b31bf7a 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -204,8 +204,68 @@ pm2 restart flyer-crawler-api --- +## Error Tracking with Bugsink (ADR-015) + +Bugsink is a self-hosted Sentry-compatible error tracking system. See [docs/adr/0015-application-performance-monitoring-and-error-tracking.md](docs/adr/0015-application-performance-monitoring-and-error-tracking.md) for the full architecture decision. + +### Creating Bugsink Projects and DSNs + +After Bugsink is installed and running, you need to create projects and obtain DSNs: + +1. **Access Bugsink UI**: Navigate to `http://localhost:8000` + +2. **Log in** with your admin credentials + +3. **Create Backend Project**: + - Click "Create Project" + - Name: `flyer-crawler-backend` + - Platform: Node.js + - Copy the generated DSN (format: `http://@localhost:8000/`) + +4. **Create Frontend Project**: + - Click "Create Project" + - Name: `flyer-crawler-frontend` + - Platform: React + - Copy the generated DSN + +5. **Configure Environment Variables**: + + ```bash + # Backend (server-side) + export SENTRY_DSN=http://@localhost:8000/ + + # Frontend (client-side, exposed to browser) + export VITE_SENTRY_DSN=http://@localhost:8000/ + + # Shared settings + export SENTRY_ENVIRONMENT=production + export VITE_SENTRY_ENVIRONMENT=production + export SENTRY_ENABLED=true + export VITE_SENTRY_ENABLED=true + ``` + +### Testing Error Tracking + +Verify Bugsink is receiving events: + +```bash +npx tsx scripts/test-bugsink.ts +``` + +This sends test error and info events. Check the Bugsink UI for: + +- `BugsinkTestError` in the backend project +- Info message "Test info message from test-bugsink.ts" + +### Sentry SDK v10+ HTTP DSN Limitation + +The Sentry SDK v10+ enforces HTTPS-only DSNs by default. Since Bugsink runs locally over HTTP, our implementation uses the Sentry Store API directly instead of the SDK's built-in transport. This is handled transparently by the `sentry.server.ts` and `sentry.client.ts` modules. + +--- + ## Related Documentation - [Database Setup](DATABASE.md) - PostgreSQL and PostGIS configuration - [Authentication Setup](AUTHENTICATION.md) - OAuth provider configuration - [Installation Guide](INSTALL.md) - Local development setup +- [Bare-Metal Server Setup](docs/BARE-METAL-SETUP.md) - Manual server installation guide diff --git a/Dockerfile.dev b/Dockerfile.dev index e638d9e..902a54b 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -65,8 +65,67 @@ RUN python3 -m venv /opt/bugsink \ && /opt/bugsink/bin/pip install --upgrade pip \ && /opt/bugsink/bin/pip install bugsink gunicorn psycopg2-binary -# Create Bugsink directories -RUN mkdir -p /var/log/bugsink /var/lib/bugsink +# Create Bugsink directories and configuration +RUN mkdir -p /var/log/bugsink /var/lib/bugsink /opt/bugsink/conf + +# Create Bugsink configuration file (Django settings module) +# This file is imported by bugsink-manage via DJANGO_SETTINGS_MODULE +# Based on bugsink/conf_templates/docker.py.template but customized for our setup +RUN echo 'import os\n\ +from urllib.parse import urlparse\n\ +\n\ +from bugsink.settings.default import *\n\ +from bugsink.settings.default import DATABASES, SILENCED_SYSTEM_CHECKS\n\ +from bugsink.conf_utils import deduce_allowed_hosts, deduce_script_name\n\ +\n\ +IS_DOCKER = True\n\ +\n\ +# Security settings\n\ +SECRET_KEY = os.getenv("SECRET_KEY")\n\ +DEBUG = os.getenv("DEBUG", "False").lower() in ("true", "1", "yes")\n\ +\n\ +# Silence cookie security warnings for dev (no HTTPS)\n\ +SILENCED_SYSTEM_CHECKS += ["security.W012", "security.W016"]\n\ +\n\ +# Database configuration from DATABASE_URL environment variable\n\ +if os.getenv("DATABASE_URL"):\n\ + DATABASE_URL = os.getenv("DATABASE_URL")\n\ + parsed = urlparse(DATABASE_URL)\n\ + \n\ + if parsed.scheme in ["postgres", "postgresql"]:\n\ + DATABASES["default"] = {\n\ + "ENGINE": "django.db.backends.postgresql",\n\ + "NAME": parsed.path.lstrip("/"),\n\ + "USER": parsed.username,\n\ + "PASSWORD": parsed.password,\n\ + "HOST": parsed.hostname,\n\ + "PORT": parsed.port or "5432",\n\ + }\n\ +\n\ +# Snappea (background task runner) settings\n\ +SNAPPEA = {\n\ + "TASK_ALWAYS_EAGER": False,\n\ + "WORKAHOLIC": True,\n\ + "NUM_WORKERS": 2,\n\ + "PID_FILE": None,\n\ +}\n\ +DATABASES["snappea"]["NAME"] = "/tmp/snappea.sqlite3"\n\ +\n\ +# Site settings\n\ +_PORT = os.getenv("PORT", "8000")\n\ +BUGSINK = {\n\ + "BASE_URL": os.getenv("BASE_URL", f"http://localhost:{_PORT}"),\n\ + "SITE_TITLE": os.getenv("SITE_TITLE", "Flyer Crawler Error Tracking"),\n\ + "SINGLE_USER": os.getenv("SINGLE_USER", "True").lower() in ("true", "1", "yes"),\n\ + "SINGLE_TEAM": os.getenv("SINGLE_TEAM", "True").lower() in ("true", "1", "yes"),\n\ + "PHONEHOME": False,\n\ +}\n\ +\n\ +ALLOWED_HOSTS = deduce_allowed_hosts(BUGSINK["BASE_URL"])\n\ +\n\ +# Console email backend for dev\n\ +EMAIL_BACKEND = "bugsink.email_backends.QuietConsoleEmailBackend"\n\ +' > /opt/bugsink/conf/bugsink_conf.py # Create Bugsink startup script # Uses DATABASE_URL environment variable (standard Docker approach per docs) @@ -78,6 +137,11 @@ export DATABASE_URL="postgresql://${BUGSINK_DB_USER:-bugsink}:${BUGSINK_DB_PASSW # SECRET_KEY is required by Bugsink/Django\n\ export SECRET_KEY="${BUGSINK_SECRET_KEY:-dev-bugsink-secret-key-minimum-50-characters-for-security}"\n\ \n\ +# Create superuser if not exists (for dev convenience)\n\ +if [ -n "$BUGSINK_ADMIN_EMAIL" ] && [ -n "$BUGSINK_ADMIN_PASSWORD" ]; then\n\ + export CREATE_SUPERUSER="${BUGSINK_ADMIN_EMAIL}:${BUGSINK_ADMIN_PASSWORD}"\n\ +fi\n\ +\n\ # Wait for PostgreSQL to be ready\n\ until pg_isready -h ${BUGSINK_DB_HOST:-postgres} -p ${BUGSINK_DB_PORT:-5432} -U ${BUGSINK_DB_USER:-bugsink}; do\n\ echo "Waiting for PostgreSQL..."\n\ @@ -87,13 +151,25 @@ done\n\ echo "PostgreSQL is ready. Starting Bugsink..."\n\ echo "DATABASE_URL: postgresql://${BUGSINK_DB_USER}:***@${BUGSINK_DB_HOST}:${BUGSINK_DB_PORT}/${BUGSINK_DB_NAME}"\n\ \n\ +# Change to config directory so bugsink_conf.py can be found\n\ +cd /opt/bugsink/conf\n\ +\n\ # Run migrations\n\ +echo "Running database migrations..."\n\ /opt/bugsink/bin/bugsink-manage migrate --noinput\n\ \n\ -# Create superuser if not exists (for dev convenience)\n\ -if [ -n "$BUGSINK_ADMIN_EMAIL" ] && [ -n "$BUGSINK_ADMIN_PASSWORD" ]; then\n\ - export CREATE_SUPERUSER="${BUGSINK_ADMIN_EMAIL}:${BUGSINK_ADMIN_PASSWORD}"\n\ - echo "Superuser configured: ${BUGSINK_ADMIN_EMAIL}"\n\ +# Create superuser if CREATE_SUPERUSER is set (format: email:password)\n\ +if [ -n "$CREATE_SUPERUSER" ]; then\n\ + IFS=":" read -r ADMIN_EMAIL ADMIN_PASS <<< "$CREATE_SUPERUSER"\n\ + /opt/bugsink/bin/bugsink-manage shell -c "\n\ +from django.contrib.auth import get_user_model\n\ +User = get_user_model()\n\ +if not User.objects.filter(email='"'"'$ADMIN_EMAIL'"'"').exists():\n\ + User.objects.create_superuser('"'"'$ADMIN_EMAIL'"'"', '"'"'$ADMIN_PASS'"'"')\n\ + print('"'"'Superuser created'"'"')\n\ +else:\n\ + print('"'"'Superuser already exists'"'"')\n\ +" || true\n\ fi\n\ \n\ # Start Bugsink with Gunicorn\n\ diff --git a/INSTALL.md b/INSTALL.md index 7b20ca1..1d17ace 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -103,6 +103,7 @@ You are now inside the Ubuntu container's shell. ``` 4. **Install Project Dependencies**: + ```bash npm install ``` diff --git a/compose.dev.yml b/compose.dev.yml index f088c80..b9aea86 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -78,6 +78,15 @@ services: - BUGSINK_ADMIN_EMAIL=admin@localhost - BUGSINK_ADMIN_PASSWORD=admin - BUGSINK_SECRET_KEY=dev-bugsink-secret-key-minimum-50-characters-for-security + # Sentry SDK configuration (points to local Bugsink) + - SENTRY_DSN=http://59a58583-e869-7697-f94a-cfa0337676a8@localhost:8000/1 + - VITE_SENTRY_DSN=http://d5fc5221-4266-ff2f-9af8-5689696072f3@localhost:8000/2 + - SENTRY_ENVIRONMENT=development + - VITE_SENTRY_ENVIRONMENT=development + - SENTRY_ENABLED=true + - VITE_SENTRY_ENABLED=true + - SENTRY_DEBUG=true + - VITE_SENTRY_DEBUG=true depends_on: postgres: condition: service_healthy diff --git a/docs/BARE-METAL-SETUP.md b/docs/BARE-METAL-SETUP.md new file mode 100644 index 0000000..a72dfd0 --- /dev/null +++ b/docs/BARE-METAL-SETUP.md @@ -0,0 +1,637 @@ +# Bare-Metal Server Setup Guide + +This guide covers the manual installation of Flyer Crawler and its dependencies on a bare-metal Ubuntu server (e.g., a colocation server). This is the definitive reference for setting up a production environment without containers. + +**Target Environment**: Ubuntu 22.04 LTS (or newer) + +--- + +## Table of Contents + +1. [System Prerequisites](#system-prerequisites) +2. [PostgreSQL Setup](#postgresql-setup) +3. [Redis Setup](#redis-setup) +4. [Node.js and Application Setup](#nodejs-and-application-setup) +5. [PM2 Process Manager](#pm2-process-manager) +6. [NGINX Reverse Proxy](#nginx-reverse-proxy) +7. [Bugsink Error Tracking](#bugsink-error-tracking) +8. [Logstash Log Aggregation](#logstash-log-aggregation) +9. [SSL/TLS with Let's Encrypt](#ssltls-with-lets-encrypt) +10. [Firewall Configuration](#firewall-configuration) +11. [Maintenance Commands](#maintenance-commands) + +--- + +## System Prerequisites + +Update the system and install essential packages: + +```bash +sudo apt update && sudo apt upgrade -y +sudo apt install -y curl git build-essential python3 python3-pip python3-venv +``` + +--- + +## PostgreSQL Setup + +### Install PostgreSQL 14+ with PostGIS + +```bash +# Add PostgreSQL APT repository +sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' +wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add - +sudo apt update + +# Install PostgreSQL and PostGIS +sudo apt install -y postgresql-14 postgresql-14-postgis-3 +``` + +### Create Application Database and User + +```bash +sudo -u postgres psql +``` + +```sql +-- Create application user and database +CREATE USER flyer_crawler WITH PASSWORD 'YOUR_SECURE_PASSWORD'; +CREATE DATABASE flyer_crawler OWNER flyer_crawler; + +-- Connect to the database and enable extensions +\c flyer_crawler + +CREATE EXTENSION IF NOT EXISTS postgis; +CREATE EXTENSION IF NOT EXISTS pg_trgm; +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +-- Grant privileges +GRANT ALL PRIVILEGES ON DATABASE flyer_crawler TO flyer_crawler; + +\q +``` + +### Create Bugsink Database (for error tracking) + +```bash +sudo -u postgres psql +``` + +```sql +-- Create dedicated Bugsink user and database +CREATE USER bugsink WITH PASSWORD 'BUGSINK_SECURE_PASSWORD'; +CREATE DATABASE bugsink OWNER bugsink; +GRANT ALL PRIVILEGES ON DATABASE bugsink TO bugsink; + +\q +``` + +### Configure PostgreSQL for Remote Access (if needed) + +Edit `/etc/postgresql/14/main/postgresql.conf`: + +```conf +listen_addresses = 'localhost' # Change to '*' for remote access +``` + +Edit `/etc/postgresql/14/main/pg_hba.conf` to add allowed hosts: + +```conf +# Local connections +local all all peer +host all all 127.0.0.1/32 scram-sha-256 +``` + +Restart PostgreSQL: + +```bash +sudo systemctl restart postgresql +``` + +--- + +## Redis Setup + +### Install Redis + +```bash +sudo apt install -y redis-server +``` + +### Configure Redis Password + +Edit `/etc/redis/redis.conf`: + +```conf +requirepass YOUR_REDIS_PASSWORD +``` + +Restart Redis: + +```bash +sudo systemctl restart redis-server +sudo systemctl enable redis-server +``` + +### Test Redis Connection + +```bash +redis-cli -a YOUR_REDIS_PASSWORD ping +# Should output: PONG +``` + +--- + +## Node.js and Application Setup + +### Install Node.js 20.x + +```bash +curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - +sudo apt install -y nodejs +``` + +Verify installation: + +```bash +node --version # Should output v20.x.x +npm --version +``` + +### Install System Dependencies for PDF Processing + +```bash +sudo apt install -y poppler-utils # For pdftocairo +``` + +### Clone and Install Application + +```bash +# Create application directory +sudo mkdir -p /opt/flyer-crawler +sudo chown $USER:$USER /opt/flyer-crawler + +# Clone repository +cd /opt/flyer-crawler +git clone https://gitea.projectium.com/flyer-crawler/flyer-crawler.projectium.com.git . + +# Install dependencies +npm install + +# Build for production +npm run build +``` + +### Configure Environment Variables + +Create a systemd environment file at `/etc/flyer-crawler/environment`: + +```bash +sudo mkdir -p /etc/flyer-crawler +sudo nano /etc/flyer-crawler/environment +``` + +Add the following (replace with actual values): + +```bash +# Database +DB_HOST=localhost +DB_USER=flyer_crawler +DB_PASSWORD=YOUR_SECURE_PASSWORD +DB_DATABASE_PROD=flyer_crawler + +# Redis +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD_PROD=YOUR_REDIS_PASSWORD + +# Authentication +JWT_SECRET=YOUR_LONG_RANDOM_JWT_SECRET + +# Google APIs +VITE_GOOGLE_GENAI_API_KEY=YOUR_GEMINI_API_KEY +GOOGLE_MAPS_API_KEY=YOUR_MAPS_API_KEY + +# Sentry/Bugsink Error Tracking (ADR-015) +SENTRY_DSN=http://BACKEND_KEY@localhost:8000/1 +VITE_SENTRY_DSN=http://FRONTEND_KEY@localhost:8000/2 +SENTRY_ENVIRONMENT=production +VITE_SENTRY_ENVIRONMENT=production +SENTRY_ENABLED=true +VITE_SENTRY_ENABLED=true +SENTRY_DEBUG=false +VITE_SENTRY_DEBUG=false + +# Application +NODE_ENV=production +PORT=3001 +``` + +Secure the file: + +```bash +sudo chmod 600 /etc/flyer-crawler/environment +``` + +--- + +## PM2 Process Manager + +### Install PM2 Globally + +```bash +sudo npm install -g pm2 +``` + +### Start Application with PM2 + +```bash +cd /opt/flyer-crawler +npm run start:prod +``` + +This starts three processes: + +- `flyer-crawler-api` - Main API server (port 3001) +- `flyer-crawler-worker` - Background job worker +- `flyer-crawler-analytics-worker` - Analytics processing worker + +### Configure PM2 Startup + +```bash +pm2 startup systemd +# Follow the command output to enable PM2 on boot + +pm2 save +``` + +### PM2 Log Rotation + +```bash +pm2 install pm2-logrotate +pm2 set pm2-logrotate:max_size 10M +pm2 set pm2-logrotate:retain 14 +pm2 set pm2-logrotate:compress true +``` + +--- + +## NGINX Reverse Proxy + +### Install NGINX + +```bash +sudo apt install -y nginx +``` + +### Create Site Configuration + +Create `/etc/nginx/sites-available/flyer-crawler.projectium.com`: + +```nginx +server { + listen 80; + server_name flyer-crawler.projectium.com; + + # Redirect HTTP to HTTPS (uncomment after SSL setup) + # return 301 https://$server_name$request_uri; + + location / { + proxy_pass http://localhost:5173; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + + location /api { + proxy_pass http://localhost:3001; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + + # File upload size limit + client_max_body_size 50M; + } + + # MIME type fix for .mjs files + types { + application/javascript js mjs; + } +} +``` + +### Enable the Site + +```bash +sudo ln -s /etc/nginx/sites-available/flyer-crawler.projectium.com /etc/nginx/sites-enabled/ +sudo nginx -t +sudo systemctl reload nginx +sudo systemctl enable nginx +``` + +--- + +## Bugsink Error Tracking + +Bugsink is a lightweight, self-hosted Sentry-compatible error tracking system. See [ADR-015](adr/0015-application-performance-monitoring-and-error-tracking.md) for architecture details. + +### Install Bugsink + +```bash +# Create virtual environment +sudo mkdir -p /opt/bugsink +sudo python3 -m venv /opt/bugsink/venv + +# Activate and install +source /opt/bugsink/venv/bin/activate +pip install bugsink + +# Create wrapper scripts +sudo tee /opt/bugsink/bin/bugsink-manage << 'EOF' +#!/bin/bash +source /opt/bugsink/venv/bin/activate +exec python -m bugsink.manage "$@" +EOF + +sudo tee /opt/bugsink/bin/bugsink-runserver << 'EOF' +#!/bin/bash +source /opt/bugsink/venv/bin/activate +exec python -m bugsink.runserver "$@" +EOF + +sudo chmod +x /opt/bugsink/bin/bugsink-manage /opt/bugsink/bin/bugsink-runserver +``` + +### Configure Bugsink + +Create `/etc/bugsink/environment`: + +```bash +sudo mkdir -p /etc/bugsink +sudo nano /etc/bugsink/environment +``` + +```bash +SECRET_KEY=YOUR_RANDOM_50_CHAR_SECRET_KEY +DATABASE_URL=postgresql://bugsink:BUGSINK_SECURE_PASSWORD@localhost:5432/bugsink +BASE_URL=http://localhost:8000 +PORT=8000 +``` + +```bash +sudo chmod 600 /etc/bugsink/environment +``` + +### Initialize Bugsink Database + +```bash +source /etc/bugsink/environment +/opt/bugsink/bin/bugsink-manage migrate +/opt/bugsink/bin/bugsink-manage migrate --database=snappea +``` + +### Create Bugsink Admin User + +```bash +/opt/bugsink/bin/bugsink-manage createsuperuser +``` + +### Create Systemd Service + +Create `/etc/systemd/system/bugsink.service`: + +```ini +[Unit] +Description=Bugsink Error Tracking +After=network.target postgresql.service + +[Service] +Type=simple +User=www-data +Group=www-data +EnvironmentFile=/etc/bugsink/environment +ExecStart=/opt/bugsink/bin/bugsink-runserver 0.0.0.0:8000 +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl daemon-reload +sudo systemctl enable bugsink +sudo systemctl start bugsink +``` + +### Create Bugsink Projects and Get DSNs + +1. Access Bugsink UI at `http://localhost:8000` +2. Log in with admin credentials +3. Create projects: + - **flyer-crawler-backend** (Platform: Node.js) + - **flyer-crawler-frontend** (Platform: React) +4. Copy the DSNs from each project's settings +5. Update `/etc/flyer-crawler/environment` with the DSNs + +### Test Error Tracking + +```bash +cd /opt/flyer-crawler +npx tsx scripts/test-bugsink.ts +``` + +Check Bugsink UI for test events. + +--- + +## Logstash Log Aggregation + +Logstash aggregates logs from the application and infrastructure, forwarding errors to Bugsink. + +### Install Logstash + +```bash +# Add Elastic APT repository +wget -qO - https://artifacts.elastic.co/GPG-KEY-elasticsearch | sudo gpg --dearmor -o /usr/share/keyrings/elastic-keyring.gpg +echo "deb [signed-by=/usr/share/keyrings/elastic-keyring.gpg] https://artifacts.elastic.co/packages/8.x/apt stable main" | sudo tee /etc/apt/sources.list.d/elastic-8.x.list + +sudo apt update +sudo apt install -y logstash +``` + +### Configure Logstash Pipeline + +Create `/etc/logstash/conf.d/bugsink.conf`: + +```conf +input { + # Pino application logs + file { + path => "/opt/flyer-crawler/logs/*.log" + codec => json + type => "pino" + tags => ["app"] + } + + # Redis logs + file { + path => "/var/log/redis/*.log" + type => "redis" + tags => ["redis"] + } +} + +filter { + # Pino error detection (level 50 = error, 60 = fatal) + if [type] == "pino" and [level] >= 50 { + mutate { add_tag => ["error"] } + } + + # Redis error detection + if [type] == "redis" { + grok { + match => { "message" => "%{POSINT:pid}:%{WORD:role} %{MONTHDAY} %{MONTH} %{TIME} %{WORD:loglevel} %{GREEDYDATA:redis_message}" } + } + if [loglevel] in ["WARNING", "ERROR"] { + mutate { add_tag => ["error"] } + } + } +} + +output { + if "error" in [tags] { + http { + url => "http://localhost:8000/api/1/store/" + http_method => "post" + format => "json" + headers => { + "X-Sentry-Auth" => "Sentry sentry_version=7, sentry_client=logstash/1.0, sentry_key=YOUR_BACKEND_DSN_KEY" + } + } + } +} +``` + +Replace `YOUR_BACKEND_DSN_KEY` with the key from your backend project DSN. + +### Start Logstash + +```bash +sudo systemctl enable logstash +sudo systemctl start logstash +``` + +--- + +## SSL/TLS with Let's Encrypt + +### Install Certbot + +```bash +sudo apt install -y certbot python3-certbot-nginx +``` + +### Obtain Certificate + +```bash +sudo certbot --nginx -d flyer-crawler.projectium.com +``` + +Certbot will automatically configure NGINX for HTTPS. + +### Auto-Renewal + +Certbot installs a systemd timer for automatic renewal. Verify: + +```bash +sudo systemctl status certbot.timer +``` + +--- + +## Firewall Configuration + +### Configure UFW + +```bash +sudo ufw default deny incoming +sudo ufw default allow outgoing + +# Allow SSH +sudo ufw allow ssh + +# Allow HTTP and HTTPS +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp + +# Enable firewall +sudo ufw enable +``` + +**Important**: Bugsink (port 8000) should NOT be exposed externally. It listens on localhost only. + +--- + +## Maintenance Commands + +### Application Management + +| Task | Command | +| --------------------- | -------------------------------------------------------------------------------------- | +| View PM2 status | `pm2 status` | +| View application logs | `pm2 logs` | +| Restart all processes | `pm2 restart all` | +| Restart specific app | `pm2 restart flyer-crawler-api` | +| Update application | `cd /opt/flyer-crawler && git pull && npm install && npm run build && pm2 restart all` | + +### Service Management + +| Service | Start | Stop | Status | +| ---------- | ----------------------------------- | ---------------------------------- | ------------------------------------ | +| PostgreSQL | `sudo systemctl start postgresql` | `sudo systemctl stop postgresql` | `sudo systemctl status postgresql` | +| Redis | `sudo systemctl start redis-server` | `sudo systemctl stop redis-server` | `sudo systemctl status redis-server` | +| NGINX | `sudo systemctl start nginx` | `sudo systemctl stop nginx` | `sudo systemctl status nginx` | +| Bugsink | `sudo systemctl start bugsink` | `sudo systemctl stop bugsink` | `sudo systemctl status bugsink` | +| Logstash | `sudo systemctl start logstash` | `sudo systemctl stop logstash` | `sudo systemctl status logstash` | + +### Database Backup + +```bash +# Backup application database +pg_dump -U flyer_crawler -h localhost flyer_crawler > backup_$(date +%Y%m%d).sql + +# Backup Bugsink database +pg_dump -U bugsink -h localhost bugsink > bugsink_backup_$(date +%Y%m%d).sql +``` + +### Log Locations + +| Log | Location | +| ----------------- | --------------------------- | +| Application (PM2) | `~/.pm2/logs/` | +| NGINX access | `/var/log/nginx/access.log` | +| NGINX error | `/var/log/nginx/error.log` | +| PostgreSQL | `/var/log/postgresql/` | +| Redis | `/var/log/redis/` | +| Bugsink | `journalctl -u bugsink` | +| Logstash | `/var/log/logstash/` | + +--- + +## Related Documentation + +- [DEPLOYMENT.md](../DEPLOYMENT.md) - Container-based deployment +- [DATABASE.md](../DATABASE.md) - Database schema and extensions +- [AUTHENTICATION.md](../AUTHENTICATION.md) - OAuth provider setup +- [ADR-015](adr/0015-application-performance-monitoring-and-error-tracking.md) - Error tracking architecture diff --git a/docs/adr/0015-application-performance-monitoring-and-error-tracking.md b/docs/adr/0015-application-performance-monitoring-and-error-tracking.md index ce8b6d0..2c6b19b 100644 --- a/docs/adr/0015-application-performance-monitoring-and-error-tracking.md +++ b/docs/adr/0015-application-performance-monitoring-and-error-tracking.md @@ -54,6 +54,7 @@ The React frontend will integrate `@sentry/react` SDK to: - Capture unhandled JavaScript errors - Report errors with component stack traces - Track user session context +- **Frontend Error Correlation**: The global API client (Axios/Fetch wrapper) MUST intercept 4xx/5xx responses. It MUST extract the `x-request-id` header (if present) and attach it to the Sentry scope as a tag `api_request_id` before re-throwing the error. This allows developers to copy the ID from Sentry and search for it in backend logs. ### 4. Log Aggregation: Logstash diff --git a/docs/adr/0051-asynchronous-context-propagation.md b/docs/adr/0051-asynchronous-context-propagation.md new file mode 100644 index 0000000..a6090bb --- /dev/null +++ b/docs/adr/0051-asynchronous-context-propagation.md @@ -0,0 +1,54 @@ +# ADR-051: Asynchronous Context Propagation + +**Date**: 2026-01-11 + +**Status**: Accepted (Implemented) + +## Context + +Debugging asynchronous workflows is difficult because the `request_id` generated at the API layer is lost when a task is handed off to a background queue (BullMQ). Logs from the worker appear disconnected from the user action that triggered them. + +## Decision + +We will implement a context propagation pattern for all background jobs: + +1. **Job Data Payload**: All job data interfaces MUST include a `meta` object containing `requestId`, `userId`, and `origin`. +2. **Worker Logger Initialization**: All BullMQ workers MUST initialize a child logger immediately upon processing a job, using the metadata passed in the payload. +3. **Correlation**: The worker's logger must use the _same_ `request_id` as the initiating API request. + +## Implementation + +```typescript +// 1. Enqueueing (API Layer) +await queue.add('process-flyer', { + ...data, + meta: { + requestId: req.log.bindings().request_id, // Propagate ID + userId: req.user.id, + }, +}); + +// 2. Processing (Worker Layer) +const worker = new Worker('queue', async (job) => { + const { requestId, userId } = job.data.meta || {}; + + // Create context-aware logger for this specific job execution + const jobLogger = logger.child({ + request_id: requestId || uuidv4(), // Use propagated ID or generate new + user_id: userId, + job_id: job.id, + service: 'worker', + }); + + try { + await processJob(job.data, jobLogger); // Pass logger down + } catch (err) { + jobLogger.error({ err }, 'Job failed'); + throw err; + } +}); +``` + +## Consequences + +**Positive**: Complete traceability from API request -> Queue -> Worker execution. Drastically reduces time to find "what happened" to a specific user request. diff --git a/docs/adr/0052-granular-debug-logging-strategy.md b/docs/adr/0052-granular-debug-logging-strategy.md new file mode 100644 index 0000000..8699e94 --- /dev/null +++ b/docs/adr/0052-granular-debug-logging-strategy.md @@ -0,0 +1,42 @@ +# ADR-052: Granular Debug Logging Strategy + +**Date**: 2026-01-11 + +**Status**: Proposed + +## Context + +Global log levels (INFO vs DEBUG) are too coarse. Developers need to inspect detailed debug information for specific subsystems (e.g., `ai-service`, `db-pool`) without being flooded by logs from the entire application. + +## Decision + +We will adopt a namespace-based debug filter pattern, similar to the `debug` npm package, but integrated into our Pino logger. + +1. **Logger Namespaces**: Every service/module logger must be initialized with a `module` property (e.g., `logger.child({ module: 'ai-service' })`). +2. **Environment Filter**: We will support a `DEBUG_MODULES` environment variable that overrides the log level for matching modules. + +## Implementation + +In `src/services/logger.server.ts`: + +```typescript +const debugModules = (process.env.DEBUG_MODULES || '').split(',').map((s) => s.trim()); + +export const createScopedLogger = (moduleName: string) => { + // If DEBUG_MODULES contains "ai-service" or "*", force level to 'debug' + const isDebugEnabled = debugModules.includes('*') || debugModules.includes(moduleName); + + return logger.child({ + module: moduleName, + level: isDebugEnabled ? 'debug' : logger.level, + }); +}; +``` + +## Usage + +To debug only AI and Database interactions: + +```bash +DEBUG_MODULES=ai-service,db-repo npm run dev +``` diff --git a/docs/adr/0053-worker-health-checks.md b/docs/adr/0053-worker-health-checks.md new file mode 100644 index 0000000..13034e6 --- /dev/null +++ b/docs/adr/0053-worker-health-checks.md @@ -0,0 +1,62 @@ +# ADR-053: Worker Health Checks and Stalled Job Monitoring + +**Date**: 2026-01-11 + +**Status**: Proposed + +## Context + +Our application relies heavily on background workers (BullMQ) for flyer processing, analytics, and emails. If a worker process crashes (e.g., Out of Memory) or hangs, jobs may remain in the 'active' state indefinitely ("stalled") until BullMQ's fail-safe triggers. + +Currently, we lack: + +1. Visibility into queue depths and worker status via HTTP endpoints (for uptime monitors). +2. A mechanism to detect if the worker process itself is alive, beyond just queue statistics. +3. Explicit configuration to ensure stalled jobs are recovered quickly. + +## Decision + +We will implement a multi-layered health check strategy for background workers: + +1. **Queue Metrics Endpoint**: Expose a protected endpoint `GET /health/queues` that returns the counts (waiting, active, failed) for all critical queues. +2. **Stalled Job Configuration**: Explicitly configure BullMQ workers with aggressive stall detection settings to recover quickly from crashes. +3. **Worker Heartbeats**: Workers will periodically update a "heartbeat" key in Redis. The health endpoint will check if this timestamp is recent. + +## Implementation + +### 1. BullMQ Worker Settings + +Workers must be initialized with specific options to handle stalls: + +```typescript +const workerOptions = { + // Check for stalled jobs every 30 seconds + stalledInterval: 30000, + // Fail job after 3 stalls (prevents infinite loops causing infinite retries) + maxStalledCount: 3, + // Duration of the lock for the job in milliseconds. + // If the worker doesn't renew this (e.g. crash), the job stalls. + lockDuration: 30000, +}; +``` + +### 2. Health Endpoint Logic + +The `/health/queues` endpoint will: + +1. Iterate through all defined queues (`flyerQueue`, `emailQueue`, etc.). +2. Fetch job counts (`waiting`, `active`, `failed`, `delayed`). +3. Return a 200 OK if queues are accessible, or 503 if Redis is unreachable. +4. (Future) Return 500 if the `waiting` count exceeds a critical threshold for too long. + +## Consequences + +**Positive**: + +- Early detection of stuck processing pipelines. +- Automatic recovery of stalled jobs via BullMQ configuration. +- Metrics available for external monitoring tools (e.g., UptimeRobot, Datadog). + +**Negative**: + +- Requires configuring external monitoring to poll the new endpoint. diff --git a/package-lock.json b/package-lock.json index 2503110..83d8979 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,8 @@ "@bull-board/api": "^6.14.2", "@bull-board/express": "^6.14.2", "@google/genai": "^1.30.0", + "@sentry/node": "^10.32.1", + "@sentry/react": "^10.32.1", "@tanstack/react-query": "^5.90.12", "@types/connect-timeout": "^1.9.0", "bcrypt": "^5.1.1", @@ -49,7 +51,8 @@ "swagger-ui-express": "^5.0.1", "tsx": "^4.20.6", "zod": "^4.2.1", - "zxcvbn": "^4.4.2" + "zxcvbn": "^4.4.2", + "zxing-wasm": "^2.2.4" }, "devDependencies": { "@tailwindcss/postcss": "4.1.17", @@ -187,6 +190,23 @@ "openapi-types": ">=7" } }, + "node_modules/@apm-js-collab/code-transformer": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.8.2.tgz", + "integrity": "sha512-YRjJjNq5KFSjDUoqu5pFUWrrsvGOxl6c3bu+uMFc9HNNptZ2rNU/TI2nLw4jnhQNtka972Ee2m3uqbvDQtPeCA==", + "license": "Apache-2.0" + }, + "node_modules/@apm-js-collab/tracing-hooks": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.3.1.tgz", + "integrity": "sha512-Vu1CbmPURlN5fTboVuKMoJjbO5qcq9fA5YXpskx3dXe/zTBvjODFoerw+69rVBlRLrJpwPqSDqEuJDEKIrTldw==", + "license": "Apache-2.0", + "dependencies": { + "@apm-js-collab/code-transformer": "^0.8.0", + "debug": "^4.4.1", + "module-details-from-path": "^1.0.4" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", @@ -3569,6 +3589,524 @@ "dev": true, "license": "MIT" }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.208.0.tgz", + "integrity": "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/context-async-hooks": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.3.0.tgz", + "integrity": "sha512-hGcsT0qDP7Il1L+qT3JFpiGl1dCjF794Bb4yCRCYdr7XC0NwHtOF3ngF86Gk6TUnsakbyQsDQ0E/S4CU0F4d4g==", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.3.0.tgz", + "integrity": "sha512-PcmxJQzs31cfD0R2dE91YGFcLxOSN4Bxz7gez5UwSUjCai8BwH/GI5HchfVshHkWdTkUs0qcaPJgVHKXUp7I3A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.208.0.tgz", + "integrity": "sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.208.0", + "import-in-the-middle": "^2.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-amqplib": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.55.0.tgz", + "integrity": "sha512-5ULoU8p+tWcQw5PDYZn8rySptGSLZHNX/7srqo2TioPnAAcvTy6sQFQXsNPrAnyRRtYGMetXVyZUy5OaX1+IfA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.208.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-connect": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.52.0.tgz", + "integrity": "sha512-GXPxfNB5szMbV3I9b7kNWSmQBoBzw7MT0ui6iU/p+NIzVx3a06Ri2cdQO7tG9EKb4aKSLmfX9Cw5cKxXqX6Ohg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "@types/connect": "3.4.38" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-dataloader": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.26.0.tgz", + "integrity": "sha512-P2BgnFfTOarZ5OKPmYfbXfDFjQ4P9WkQ1Jji7yH5/WwB6Wm/knynAoA1rxbjWcDlYupFkyT0M1j6XLzDzy0aCA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.208.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-express": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.57.0.tgz", + "integrity": "sha512-HAdx/o58+8tSR5iW+ru4PHnEejyKrAy9fYFhlEI81o10nYxrGahnMAHWiSjhDC7UQSY3I4gjcPgSKQz4rm/asg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-fs": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.28.0.tgz", + "integrity": "sha512-FFvg8fq53RRXVBRHZViP+EMxMR03tqzEGpuq55lHNbVPyFklSVfQBN50syPhK5UYYwaStx0eyCtHtbRreusc5g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.208.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-generic-pool": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.52.0.tgz", + "integrity": "sha512-ISkNcv5CM2IwvsMVL31Tl61/p2Zm2I2NAsYq5SSBgOsOndT0TjnptjufYVScCnD5ZLD1tpl4T3GEYULLYOdIdQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.208.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-graphql": { + "version": "0.56.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.56.0.tgz", + "integrity": "sha512-IPvNk8AFoVzTAM0Z399t34VDmGDgwT6rIqCUug8P9oAGerl2/PEIYMPOl/rerPGu+q8gSWdmbFSjgg7PDVRd3Q==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.208.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-hapi": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.55.0.tgz", + "integrity": "sha512-prqAkRf9e4eEpy4G3UcR32prKE8NLNlA90TdEU1UsghOTg0jUvs40Jz8LQWFEs5NbLbXHYGzB4CYVkCI8eWEVQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-http": { + "version": "0.208.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.208.0.tgz", + "integrity": "sha512-rhmK46DRWEbQQB77RxmVXGyjs6783crXCnFjYQj+4tDH/Kpv9Rbg3h2kaNyp5Vz2emF1f9HOQQvZoHzwMWOFZQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/instrumentation": "0.208.0", + "@opentelemetry/semantic-conventions": "^1.29.0", + "forwarded-parse": "2.1.2" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-http/node_modules/@opentelemetry/core": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", + "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation-ioredis": { + "version": "0.56.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.56.0.tgz", + "integrity": "sha512-XSWeqsd3rKSsT3WBz/JKJDcZD4QYElZEa0xVdX8f9dh4h4QgXhKRLorVsVkK3uXFbC2sZKAS2Ds+YolGwD83Dg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/redis-common": "^0.38.2" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-kafkajs": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.18.0.tgz", + "integrity": "sha512-KCL/1HnZN5zkUMgPyOxfGjLjbXjpd4odDToy+7c+UsthIzVLFf99LnfIBE8YSSrYE4+uS7OwJMhvhg3tWjqMBg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/semantic-conventions": "^1.30.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-knex": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.53.0.tgz", + "integrity": "sha512-xngn5cH2mVXFmiT1XfQ1aHqq1m4xb5wvU6j9lSgLlihJ1bXzsO543cpDwjrZm2nMrlpddBf55w8+bfS4qDh60g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/semantic-conventions": "^1.33.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-koa": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.57.0.tgz", + "integrity": "sha512-3JS8PU/D5E3q295mwloU2v7c7/m+DyCqdu62BIzWt+3u9utjxC9QS7v6WmUNuoDN3RM+Q+D1Gpj13ERo+m7CGg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/semantic-conventions": "^1.36.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + } + }, + "node_modules/@opentelemetry/instrumentation-lru-memoizer": { + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.53.0.tgz", + "integrity": "sha512-LDwWz5cPkWWr0HBIuZUjslyvijljTwmwiItpMTHujaULZCxcYE9eU44Qf/pbVC8TulT0IhZi+RoGvHKXvNhysw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.208.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mongodb": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.61.0.tgz", + "integrity": "sha512-OV3i2DSoY5M/pmLk+68xr5RvkHU8DRB3DKMzYJdwDdcxeLs62tLbkmRyqJZsYf3Ht7j11rq35pHOWLuLzXL7pQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.208.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mongoose": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.55.0.tgz", + "integrity": "sha512-5afj0HfF6aM6Nlqgu6/PPHFk8QBfIe3+zF9FGpX76jWPS0/dujoEYn82/XcLSaW5LPUDW8sni+YeK0vTBNri+w==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.208.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mysql": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.54.0.tgz", + "integrity": "sha512-bqC1YhnwAeWmRzy1/Xf9cDqxNG2d/JDkaxnqF5N6iJKN1eVWI+vg7NfDkf52/Nggp3tl1jcC++ptC61BD6738A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.208.0", + "@types/mysql": "2.15.27" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mysql2": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.55.0.tgz", + "integrity": "sha512-0cs8whQG55aIi20gnK8B7cco6OK6N+enNhW0p5284MvqJ5EPi+I1YlWsWXgzv/V2HFirEejkvKiI4Iw21OqDWg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/semantic-conventions": "^1.33.0", + "@opentelemetry/sql-common": "^0.41.2" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-pg": { + "version": "0.61.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.61.0.tgz", + "integrity": "sha512-UeV7KeTnRSM7ECHa3YscoklhUtTQPs6V6qYpG283AB7xpnPGCUCUfECFT9jFg6/iZOQTt3FHkB1wGTJCNZEvPw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/semantic-conventions": "^1.34.0", + "@opentelemetry/sql-common": "^0.41.2", + "@types/pg": "8.15.6", + "@types/pg-pool": "2.0.6" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-pg/node_modules/@types/pg": { + "version": "8.15.6", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", + "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@opentelemetry/instrumentation-redis": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.57.0.tgz", + "integrity": "sha512-bCxTHQFXzrU3eU1LZnOZQ3s5LURxQPDlU3/upBzlWY77qOI1GZuGofazj3jtzjctMJeBEJhNwIFEgRPBX1kp/Q==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/redis-common": "^0.38.2", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-tedious": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.27.0.tgz", + "integrity": "sha512-jRtyUJNZppPBjPae4ZjIQ2eqJbcRaRfJkr0lQLHFmOU/no5A6e9s1OHLd5XZyZoBJ/ymngZitanyRRA5cniseA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.208.0", + "@types/tedious": "^4.0.14" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-undici": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.19.0.tgz", + "integrity": "sha512-Pst/RhR61A2OoZQZkn6OLpdVpXp6qn3Y92wXa6umfJe9rV640r4bc6SWvw4pPN6DiQqPu2c8gnSSZPDtC6JlpQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/semantic-conventions": "^1.24.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.7.0" + } + }, + "node_modules/@opentelemetry/redis-common": { + "version": "0.38.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.38.2.tgz", + "integrity": "sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.3.0.tgz", + "integrity": "sha512-shlr2l5g+87J8wqYlsLyaUsgKVRO7RtX70Ckd5CtDOWtImZgaUDmf4Z2ozuSKQLM2wPDR0TE/3bPVBNJtRm/cQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.3.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.3.0.tgz", + "integrity": "sha512-B0TQ2e9h0ETjpI+eGmCz8Ojb+lnYms0SE3jFwEKrN/PK4aSVHU28AAmnOoBmfub+I3jfgPwvDJgomBA5a7QehQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.3.0", + "@opentelemetry/resources": "2.3.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.38.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.38.0.tgz", + "integrity": "sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/sql-common": { + "version": "0.41.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/sql-common/-/sql-common-0.41.2.tgz", + "integrity": "sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", @@ -3602,6 +4140,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@prisma/instrumentation": { + "version": "6.19.0", + "resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-6.19.0.tgz", + "integrity": "sha512-QcuYy25pkXM8BJ37wVFBO7Zh34nyRV1GOb2n3lPkkbRYfl4hWl3PTcImP41P0KrzVXfa/45p6eVCos27x3exIg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": ">=0.52.0 <1" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.8" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -4034,6 +4584,187 @@ "hasInstallScript": true, "license": "Apache-2.0" }, + "node_modules/@sentry-internal/browser-utils": { + "version": "10.32.1", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.32.1.tgz", + "integrity": "sha512-sjLLep1es3rTkbtAdTtdpc/a6g7v7bK5YJiZJsUigoJ4NTiFeMI5uIDCxbH/tjJ1q23YE1LzVn7T96I+qBRjHA==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.32.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/feedback": { + "version": "10.32.1", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.32.1.tgz", + "integrity": "sha512-O24G8jxbfBY1RE/v2qFikPJISVMOrd/zk8FKyl+oUVYdOxU2Ucjk2cR3EQruBFlc7irnL6rT3GPfRZ/kBgLkmQ==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.32.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay": { + "version": "10.32.1", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.32.1.tgz", + "integrity": "sha512-KKmLUgIaLRM0VjrMA1ByQTawZyRDYSkG2evvEOVpEtR9F0sumidAQdi7UY71QEKE1RYe/Jcp/3WoaqsMh8tbnQ==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.32.1", + "@sentry/core": "10.32.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay-canvas": { + "version": "10.32.1", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.32.1.tgz", + "integrity": "sha512-/XGTzWNWVc+B691fIVekV2KeoHFEDA5KftrLFAhEAW7uWOwk/xy3aQX4TYM0LcPm2PBKvoumlAD+Sd/aXk63oA==", + "license": "MIT", + "dependencies": { + "@sentry-internal/replay": "10.32.1", + "@sentry/core": "10.32.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/browser": { + "version": "10.32.1", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.32.1.tgz", + "integrity": "sha512-NPNCXTZ05ZGTFyJdKNqjykpFm+urem0ebosILQiw3C4BxNVNGH4vfYZexyl6prRhmg91oB6GjVNiVDuJiap1gg==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.32.1", + "@sentry-internal/feedback": "10.32.1", + "@sentry-internal/replay": "10.32.1", + "@sentry-internal/replay-canvas": "10.32.1", + "@sentry/core": "10.32.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/core": { + "version": "10.32.1", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.32.1.tgz", + "integrity": "sha512-PH2ldpSJlhqsMj2vCTyU0BI2Fx1oIDhm7Izo5xFALvjVCS0gmlqHt1udu6YlKn8BtpGH6bGzssvv5APrk+OdPQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node": { + "version": "10.32.1", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.32.1.tgz", + "integrity": "sha512-oxlybzt8QW0lx/QaEj1DcvZDRXkgouewFelu/10dyUwv5So3YvipfvWInda+yMLmn25OggbloDQ0gyScA2jU3g==", + "license": "MIT", + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^2.2.0", + "@opentelemetry/core": "^2.2.0", + "@opentelemetry/instrumentation": "^0.208.0", + "@opentelemetry/instrumentation-amqplib": "0.55.0", + "@opentelemetry/instrumentation-connect": "0.52.0", + "@opentelemetry/instrumentation-dataloader": "0.26.0", + "@opentelemetry/instrumentation-express": "0.57.0", + "@opentelemetry/instrumentation-fs": "0.28.0", + "@opentelemetry/instrumentation-generic-pool": "0.52.0", + "@opentelemetry/instrumentation-graphql": "0.56.0", + "@opentelemetry/instrumentation-hapi": "0.55.0", + "@opentelemetry/instrumentation-http": "0.208.0", + "@opentelemetry/instrumentation-ioredis": "0.56.0", + "@opentelemetry/instrumentation-kafkajs": "0.18.0", + "@opentelemetry/instrumentation-knex": "0.53.0", + "@opentelemetry/instrumentation-koa": "0.57.0", + "@opentelemetry/instrumentation-lru-memoizer": "0.53.0", + "@opentelemetry/instrumentation-mongodb": "0.61.0", + "@opentelemetry/instrumentation-mongoose": "0.55.0", + "@opentelemetry/instrumentation-mysql": "0.54.0", + "@opentelemetry/instrumentation-mysql2": "0.55.0", + "@opentelemetry/instrumentation-pg": "0.61.0", + "@opentelemetry/instrumentation-redis": "0.57.0", + "@opentelemetry/instrumentation-tedious": "0.27.0", + "@opentelemetry/instrumentation-undici": "0.19.0", + "@opentelemetry/resources": "^2.2.0", + "@opentelemetry/sdk-trace-base": "^2.2.0", + "@opentelemetry/semantic-conventions": "^1.37.0", + "@prisma/instrumentation": "6.19.0", + "@sentry/core": "10.32.1", + "@sentry/node-core": "10.32.1", + "@sentry/opentelemetry": "10.32.1", + "import-in-the-middle": "^2", + "minimatch": "^9.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node-core": { + "version": "10.32.1", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.32.1.tgz", + "integrity": "sha512-w56rxdBanBKc832zuwnE+zNzUQ19fPxfHEtOhK8JGPu3aSwQYcIxwz9z52lOx3HN7k/8Fj5694qlT3x/PokhRw==", + "license": "MIT", + "dependencies": { + "@apm-js-collab/tracing-hooks": "^0.3.1", + "@sentry/core": "10.32.1", + "@sentry/opentelemetry": "10.32.1", + "import-in-the-middle": "^2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0 || ^2.2.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0 || ^2.2.0", + "@opentelemetry/instrumentation": ">=0.57.1 <1", + "@opentelemetry/resources": "^1.30.1 || ^2.1.0 || ^2.2.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0 || ^2.2.0", + "@opentelemetry/semantic-conventions": "^1.37.0" + } + }, + "node_modules/@sentry/opentelemetry": { + "version": "10.32.1", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.32.1.tgz", + "integrity": "sha512-YLssSz5Y+qPvufrh2cDaTXDoXU8aceOhB+YTjT8/DLF6SOj7Tzen52aAcjNaifawaxEsLCC8O+B+A2iA+BllvA==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.32.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.1.0 || ^2.2.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0 || ^2.2.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0 || ^2.2.0", + "@opentelemetry/semantic-conventions": "^1.37.0" + } + }, + "node_modules/@sentry/react": { + "version": "10.32.1", + "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.32.1.tgz", + "integrity": "sha512-/tX0HeACbAmVP57x8txTrGk/U3fa9pDBaoAtlOrnPv5VS/aC5SGkehXWeTGSAa+ahlOWwp3IF8ILVXRiOoG/Vg==", + "license": "MIT", + "dependencies": { + "@sentry/browser": "10.32.1", + "@sentry/core": "10.32.1", + "hoist-non-react-statics": "^3.3.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.14.0 || 17.x || 18.x || 19.x" + } + }, "node_modules/@smithy/abort-controller": { "version": "4.2.7", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.7.tgz", @@ -5318,6 +6049,12 @@ "@types/ssh2": "*" } }, + "node_modules/@types/emscripten": { + "version": "1.41.5", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", + "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -5402,6 +6139,15 @@ "@types/express": "*" } }, + "node_modules/@types/mysql": { + "version": "2.15.27", + "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.27.tgz", + "integrity": "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "24.10.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz", @@ -5523,7 +6269,6 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -5531,6 +6276,15 @@ "pg-types": "^2.2.0" } }, + "node_modules/@types/pg-pool": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/pg-pool/-/pg-pool-2.0.6.tgz", + "integrity": "sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ==", + "license": "MIT", + "dependencies": { + "@types/pg": "*" + } + }, "node_modules/@types/piexifjs": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/piexifjs/-/piexifjs-1.0.0.tgz", @@ -5695,6 +6449,15 @@ "@types/serve-static": "*" } }, + "node_modules/@types/tedious": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz", + "integrity": "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -6163,7 +6926,6 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -6172,6 +6934,15 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8" + } + }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -7391,6 +8162,12 @@ "node": ">=10" } }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "license": "MIT" + }, "node_modules/clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -9721,6 +10498,12 @@ "node": ">= 0.6" } }, + "node_modules/forwarded-parse": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz", + "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==", + "license": "MIT" + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -10468,6 +11251,21 @@ "hermes-estree": "0.25.1" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -10648,6 +11446,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-in-the-middle": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-2.0.1.tgz", + "integrity": "sha512-bruMpJ7xz+9jwGzrwEhWgvRrlKRYCRDBrfU+ur3FcasYXLJDxTruJ//8g2Noj+QFyRBeqbpj8Bhn4Fbw6HjvhA==", + "license": "Apache-2.0", + "dependencies": { + "acorn": "^8.14.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^1.2.2", + "module-details-from-path": "^1.0.3" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -12704,6 +13514,12 @@ "dev": true, "license": "MIT" }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -14926,6 +15742,19 @@ "node": ">=0.10.0" } }, + "node_modules/require-in-the-middle": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" + }, + "engines": { + "node": ">=9.3.0 || >=8.10.0 <9.0.0" + } + }, "node_modules/require-main-filename": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", @@ -16333,7 +17162,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", - "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -16747,7 +17575,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.3.1.tgz", "integrity": "sha512-VCn+LMHbd4t6sF3wfU/+HKT63C9OoyrSIf4b+vtWHpt2U7/4InZG467YDNMFMR70DdHjAdpPWmw2lzRdg0Xqqg==", - "dev": true, "license": "(MIT OR CC0-1.0)", "dependencies": { "tagged-tag": "^1.0.0" @@ -17763,6 +18590,19 @@ "resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz", "integrity": "sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ==", "license": "MIT" + }, + "node_modules/zxing-wasm": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-2.2.4.tgz", + "integrity": "sha512-1gq5zs4wuNTs5umWLypzNNeuJoluFvwmvjiiT3L9z/TMlVveeJRWy7h90xyUqCe+Qq0zL0w7o5zkdDMWDr9aZA==", + "license": "MIT", + "dependencies": { + "@types/emscripten": "^1.41.5", + "type-fest": "^5.2.0" + }, + "peerDependencies": { + "@types/emscripten": ">=1.39.6" + } } } } diff --git a/package.json b/package.json index af01238..28355af 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,8 @@ "@bull-board/api": "^6.14.2", "@bull-board/express": "^6.14.2", "@google/genai": "^1.30.0", + "@sentry/node": "^10.32.1", + "@sentry/react": "^10.32.1", "@tanstack/react-query": "^5.90.12", "@types/connect-timeout": "^1.9.0", "bcrypt": "^5.1.1", @@ -69,7 +71,8 @@ "swagger-ui-express": "^5.0.1", "tsx": "^4.20.6", "zod": "^4.2.1", - "zxcvbn": "^4.4.2" + "zxcvbn": "^4.4.2", + "zxing-wasm": "^2.2.4" }, "devDependencies": { "@tailwindcss/postcss": "4.1.17", diff --git a/scripts/test-bugsink.ts b/scripts/test-bugsink.ts new file mode 100644 index 0000000..adc157b --- /dev/null +++ b/scripts/test-bugsink.ts @@ -0,0 +1,164 @@ +#!/usr/bin/env npx tsx +/** + * Test script to verify Bugsink error tracking is working. + * + * This script sends test events directly to Bugsink using the Sentry store API. + * We use curl/fetch instead of the Sentry SDK because SDK v8+ has strict DSN + * validation that rejects HTTP URLs (Bugsink uses HTTP locally). + * + * Usage: + * npx tsx scripts/test-bugsink.ts + * + * Or with environment override: + * SENTRY_DSN=http://...@localhost:8000/1 npx tsx scripts/test-bugsink.ts + */ + +// Configuration - parse DSN to extract components +const DSN = + process.env.SENTRY_DSN || 'http://59a58583-e869-7697-f94a-cfa0337676a8@localhost:8000/1'; +const ENVIRONMENT = process.env.SENTRY_ENVIRONMENT || 'test'; + +// Parse DSN: http://@/ +function parseDsn(dsn: string) { + const match = dsn.match(/^(https?):\/\/([^@]+)@([^/]+)\/(.+)$/); + if (!match) { + throw new Error(`Invalid DSN format: ${dsn}`); + } + return { + protocol: match[1], + publicKey: match[2], + host: match[3], + projectId: match[4], + }; +} + +const dsnParts = parseDsn(DSN); +const STORE_URL = `${dsnParts.protocol}://${dsnParts.host}/api/${dsnParts.projectId}/store/`; + +console.log('='.repeat(60)); +console.log('Bugsink/Sentry Test Script'); +console.log('='.repeat(60)); +console.log(`DSN: ${DSN}`); +console.log(`Store URL: ${STORE_URL}`); +console.log(`Public Key: ${dsnParts.publicKey}`); +console.log(`Environment: ${ENVIRONMENT}`); +console.log(''); + +// Generate a UUID for event_id +function generateEventId(): string { + return 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'.replace(/x/g, () => + Math.floor(Math.random() * 16).toString(16), + ); +} + +// Send an event to Bugsink via the Sentry store API +async function sendEvent( + event: Record, +): Promise<{ success: boolean; status: number }> { + const response = await fetch(STORE_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Sentry-Auth': `Sentry sentry_version=7, sentry_client=test-bugsink/1.0, sentry_key=${dsnParts.publicKey}`, + }, + body: JSON.stringify(event), + }); + + return { + success: response.ok, + status: response.status, + }; +} + +async function main() { + console.log('[Test] Sending test events to Bugsink...\n'); + + try { + // Test 1: Send an error event + const errorEventId = generateEventId(); + console.log(`[Test 1] Sending error event (ID: ${errorEventId})...`); + const errorEvent = { + event_id: errorEventId, + timestamp: new Date().toISOString(), + platform: 'node', + level: 'error', + logger: 'test-bugsink.ts', + environment: ENVIRONMENT, + server_name: 'flyer-crawler-dev', + message: 'BugsinkTestError: This is a test error from test-bugsink.ts script', + exception: { + values: [ + { + type: 'BugsinkTestError', + value: 'This is a test error from test-bugsink.ts script', + stacktrace: { + frames: [ + { + filename: 'scripts/test-bugsink.ts', + function: 'main', + lineno: 42, + colno: 10, + in_app: true, + }, + ], + }, + }, + ], + }, + tags: { + test: 'true', + source: 'test-bugsink.ts', + }, + }; + + const errorResult = await sendEvent(errorEvent); + console.log( + ` Result: ${errorResult.success ? 'SUCCESS' : 'FAILED'} (HTTP ${errorResult.status})`, + ); + + // Test 2: Send an info message + const messageEventId = generateEventId(); + console.log(`[Test 2] Sending info message (ID: ${messageEventId})...`); + const messageEvent = { + event_id: messageEventId, + timestamp: new Date().toISOString(), + platform: 'node', + level: 'info', + logger: 'test-bugsink.ts', + environment: ENVIRONMENT, + server_name: 'flyer-crawler-dev', + message: 'Test info message from test-bugsink.ts - Bugsink is working!', + tags: { + test: 'true', + source: 'test-bugsink.ts', + }, + }; + + const messageResult = await sendEvent(messageEvent); + console.log( + ` Result: ${messageResult.success ? 'SUCCESS' : 'FAILED'} (HTTP ${messageResult.status})`, + ); + + // Summary + console.log(''); + console.log('='.repeat(60)); + if (errorResult.success && messageResult.success) { + console.log('SUCCESS! Both test events were accepted by Bugsink.'); + console.log(''); + console.log('Check Bugsink UI at http://localhost:8000'); + console.log('Look for:'); + console.log(' - BugsinkTestError: "This is a test error..."'); + console.log(' - Info message: "Test info message from test-bugsink.ts"'); + } else { + console.log('WARNING: Some events may not have been accepted.'); + console.log('Check that Bugsink is running and the DSN is correct.'); + process.exit(1); + } + console.log('='.repeat(60)); + } catch (error) { + console.error('[Test] Failed to send events:', error); + process.exit(1); + } +} + +main(); diff --git a/server.ts b/server.ts index aa2b806..66a307c 100644 --- a/server.ts +++ b/server.ts @@ -1,4 +1,12 @@ // server.ts +/** + * IMPORTANT: Sentry initialization MUST happen before any other imports + * to ensure all errors are captured, including those in imported modules. + * See ADR-015: Application Performance Monitoring and Error Tracking. + */ +import { initSentry, getSentryMiddleware } from './src/services/sentry.server'; +initSentry(); + import express, { Request, Response, NextFunction } from 'express'; import { randomUUID } from 'crypto'; import helmet from 'helmet'; @@ -7,7 +15,7 @@ import cookieParser from 'cookie-parser'; import listEndpoints from 'express-list-endpoints'; import { getPool } from './src/services/db/connection.db'; -import passport from './src/routes/passport.routes'; +import passport from './src/config/passport'; import { logger } from './src/services/logger.server'; // Import routers @@ -24,6 +32,9 @@ import statsRouter from './src/routes/stats.routes'; import gamificationRouter from './src/routes/gamification.routes'; import systemRouter from './src/routes/system.routes'; import healthRouter from './src/routes/health.routes'; +import upcRouter from './src/routes/upc.routes'; +import inventoryRouter from './src/routes/inventory.routes'; +import receiptRouter from './src/routes/receipt.routes'; import { errorHandler } from './src/middleware/errorHandler'; import { backgroundJobService, startBackgroundJobs } from './src/services/backgroundJobService'; import type { UserProfile } from './src/types'; @@ -37,6 +48,7 @@ import { gracefulShutdown, tokenCleanupQueue, } from './src/services/queueService.server'; +import { monitoringService } from './src/services/monitoringService.server'; // --- START DEBUG LOGGING --- // Log the database connection details as seen by the SERVER PROCESS. @@ -108,9 +120,14 @@ app.use(express.urlencoded({ limit: '100mb', extended: true })); app.use(cookieParser()); // Middleware to parse cookies app.use(passport.initialize()); // Initialize Passport +// --- Sentry Request Handler (ADR-015) --- +// Must be the first middleware after body parsers to capture request data for errors. +const sentryMiddleware = getSentryMiddleware(); +app.use(sentryMiddleware.requestHandler); + // --- MOCK AUTH FOR TESTING --- // This MUST come after passport.initialize() and BEFORE any of the API routes. -import { mockAuth } from './src/routes/passport.routes'; +import { mockAuth } from './src/config/passport'; app.use(mockAuth); // Add a request timeout middleware. This will help prevent requests from hanging indefinitely. @@ -215,6 +232,18 @@ if (process.env.NODE_ENV !== 'production') { // --- API Routes --- +// ADR-053: Worker Health Checks +// Expose queue metrics for monitoring. +app.get('/api/health/queues', async (req, res) => { + try { + const statuses = await monitoringService.getQueueStatuses(); + res.json(statuses); + } catch (error) { + logger.error({ err: error }, 'Failed to fetch queue statuses'); + res.status(503).json({ error: 'Failed to fetch queue statuses' }); + } +}); + // The order of route registration is critical. // More specific routes should be registered before more general ones. // 1. Authentication routes for login, registration, etc. @@ -243,9 +272,19 @@ app.use('/api/personalization', personalizationRouter); app.use('/api/price-history', priceRouter); // 10. Public statistics routes. app.use('/api/stats', statsRouter); +// 11. UPC barcode scanning routes. +app.use('/api/upc', upcRouter); +// 12. Inventory and expiry tracking routes. +app.use('/api/inventory', inventoryRouter); +// 13. Receipt scanning routes. +app.use('/api/receipts', receiptRouter); // --- Error Handling and Server Startup --- +// Sentry Error Handler (ADR-015) - captures errors and sends to Bugsink. +// Must come BEFORE the custom error handler but AFTER all routes. +app.use(sentryMiddleware.errorHandler); + // Global error handling middleware. This must be the last `app.use()` call. app.use(errorHandler); diff --git a/sql/initial_schema.sql b/sql/initial_schema.sql index 8bf2599..bb41787 100644 --- a/sql/initial_schema.sql +++ b/sql/initial_schema.sql @@ -1012,3 +1012,232 @@ CREATE INDEX IF NOT EXISTS idx_user_achievements_user_id ON public.user_achievem CREATE INDEX IF NOT EXISTS idx_user_achievements_achievement_id ON public.user_achievements(achievement_id); +-- ============================================================================ +-- UPC SCANNING FEATURE TABLES (59-60) +-- ============================================================================ + +-- 59. UPC Scan History - tracks all UPC scans performed by users +-- This table provides an audit trail and allows users to see their scan history +CREATE TABLE IF NOT EXISTS public.upc_scan_history ( + scan_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + upc_code TEXT NOT NULL, + product_id BIGINT REFERENCES public.products(product_id) ON DELETE SET NULL, + scan_source TEXT NOT NULL, + scan_confidence NUMERIC(5,4), + raw_image_path TEXT, + lookup_successful BOOLEAN DEFAULT FALSE NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT upc_scan_history_upc_code_check CHECK (upc_code ~ '^[0-9]{8,14}$'), + CONSTRAINT upc_scan_history_scan_source_check CHECK (scan_source IN ('image_upload', 'manual_entry', 'phone_app', 'camera_scan')), + CONSTRAINT upc_scan_history_scan_confidence_check CHECK (scan_confidence IS NULL OR (scan_confidence >= 0 AND scan_confidence <= 1)) +); +COMMENT ON TABLE public.upc_scan_history IS 'Audit trail of all UPC barcode scans performed by users, tracking scan source and results.'; +COMMENT ON COLUMN public.upc_scan_history.upc_code IS 'The scanned UPC/EAN barcode (8-14 digits).'; +COMMENT ON COLUMN public.upc_scan_history.product_id IS 'Reference to the matched product, if found in our database.'; +COMMENT ON COLUMN public.upc_scan_history.scan_source IS 'How the scan was performed: image_upload, manual_entry, phone_app, or camera_scan.'; +COMMENT ON COLUMN public.upc_scan_history.scan_confidence IS 'Confidence score from barcode detection (0.0-1.0), null for manual entry.'; +COMMENT ON COLUMN public.upc_scan_history.raw_image_path IS 'Path to the uploaded barcode image, if applicable.'; +COMMENT ON COLUMN public.upc_scan_history.lookup_successful IS 'Whether the UPC was successfully matched to a product (internal or external).'; +CREATE INDEX IF NOT EXISTS idx_upc_scan_history_user_id ON public.upc_scan_history(user_id); +CREATE INDEX IF NOT EXISTS idx_upc_scan_history_upc_code ON public.upc_scan_history(upc_code); +CREATE INDEX IF NOT EXISTS idx_upc_scan_history_created_at ON public.upc_scan_history(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_upc_scan_history_product_id ON public.upc_scan_history(product_id) WHERE product_id IS NOT NULL; + +-- 60. UPC External Lookups - cache for external UPC database API responses +CREATE TABLE IF NOT EXISTS public.upc_external_lookups ( + lookup_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + upc_code TEXT NOT NULL UNIQUE, + product_name TEXT, + brand_name TEXT, + category TEXT, + description TEXT, + image_url TEXT, + external_source TEXT NOT NULL, + lookup_data JSONB, + lookup_successful BOOLEAN DEFAULT FALSE NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT upc_external_lookups_upc_code_check CHECK (upc_code ~ '^[0-9]{8,14}$'), + CONSTRAINT upc_external_lookups_external_source_check CHECK (external_source IN ('openfoodfacts', 'upcitemdb', 'manual', 'unknown')), + CONSTRAINT upc_external_lookups_name_check CHECK (NOT lookup_successful OR product_name IS NOT NULL) +); +COMMENT ON TABLE public.upc_external_lookups IS 'Cache for external UPC database API responses to reduce API calls and improve lookup speed.'; +COMMENT ON COLUMN public.upc_external_lookups.upc_code IS 'The UPC/EAN barcode that was looked up.'; +COMMENT ON COLUMN public.upc_external_lookups.product_name IS 'Product name returned from external API.'; +COMMENT ON COLUMN public.upc_external_lookups.brand_name IS 'Brand name returned from external API.'; +COMMENT ON COLUMN public.upc_external_lookups.category IS 'Product category returned from external API.'; +COMMENT ON COLUMN public.upc_external_lookups.description IS 'Product description returned from external API.'; +COMMENT ON COLUMN public.upc_external_lookups.image_url IS 'Product image URL returned from external API.'; +COMMENT ON COLUMN public.upc_external_lookups.external_source IS 'Which external API provided this data: openfoodfacts, upcitemdb, manual, unknown.'; +COMMENT ON COLUMN public.upc_external_lookups.lookup_data IS 'Full raw JSON response from the external API for reference.'; +COMMENT ON COLUMN public.upc_external_lookups.lookup_successful IS 'Whether the external lookup found product information.'; +CREATE INDEX IF NOT EXISTS idx_upc_external_lookups_upc_code ON public.upc_external_lookups(upc_code); +CREATE INDEX IF NOT EXISTS idx_upc_external_lookups_external_source ON public.upc_external_lookups(external_source); + +-- Add index to existing products.upc_code for faster lookups +CREATE INDEX IF NOT EXISTS idx_products_upc_code ON public.products(upc_code) WHERE upc_code IS NOT NULL; + + +-- ============================================================================ +-- EXPIRY DATE TRACKING FEATURE TABLES (61-63) +-- ============================================================================ + +-- 61. Expiry Date Ranges - reference table for typical shelf life +CREATE TABLE IF NOT EXISTS public.expiry_date_ranges ( + expiry_range_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE, + category_id BIGINT REFERENCES public.categories(category_id) ON DELETE CASCADE, + item_pattern TEXT, + storage_location TEXT NOT NULL, + min_days INTEGER NOT NULL, + max_days INTEGER NOT NULL, + typical_days INTEGER NOT NULL, + notes TEXT, + source TEXT, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT expiry_date_ranges_storage_location_check CHECK (storage_location IN ('fridge', 'freezer', 'pantry', 'room_temp')), + CONSTRAINT expiry_date_ranges_min_days_check CHECK (min_days >= 0), + CONSTRAINT expiry_date_ranges_max_days_check CHECK (max_days >= min_days), + CONSTRAINT expiry_date_ranges_typical_days_check CHECK (typical_days >= min_days AND typical_days <= max_days), + CONSTRAINT expiry_date_ranges_identifier_check CHECK ( + master_item_id IS NOT NULL OR category_id IS NOT NULL OR item_pattern IS NOT NULL + ), + CONSTRAINT expiry_date_ranges_source_check CHECK (source IS NULL OR source IN ('usda', 'fda', 'manual', 'community')) +); +COMMENT ON TABLE public.expiry_date_ranges IS 'Reference table storing typical shelf life for grocery items based on storage location.'; +COMMENT ON COLUMN public.expiry_date_ranges.master_item_id IS 'Specific item this range applies to (most specific).'; +COMMENT ON COLUMN public.expiry_date_ranges.category_id IS 'Category this range applies to (fallback if no item match).'; +COMMENT ON COLUMN public.expiry_date_ranges.item_pattern IS 'Regex pattern to match item names (fallback if no item/category match).'; +COMMENT ON COLUMN public.expiry_date_ranges.storage_location IS 'Where the item is stored: fridge, freezer, pantry, or room_temp.'; +COMMENT ON COLUMN public.expiry_date_ranges.min_days IS 'Minimum shelf life in days under proper storage.'; +COMMENT ON COLUMN public.expiry_date_ranges.max_days IS 'Maximum shelf life in days under proper storage.'; +COMMENT ON COLUMN public.expiry_date_ranges.typical_days IS 'Most common/recommended shelf life in days.'; +COMMENT ON COLUMN public.expiry_date_ranges.notes IS 'Additional storage tips or warnings.'; +COMMENT ON COLUMN public.expiry_date_ranges.source IS 'Data source: usda, fda, manual, or community.'; +CREATE INDEX IF NOT EXISTS idx_expiry_date_ranges_master_item_id ON public.expiry_date_ranges(master_item_id) WHERE master_item_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_expiry_date_ranges_category_id ON public.expiry_date_ranges(category_id) WHERE category_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_expiry_date_ranges_storage_location ON public.expiry_date_ranges(storage_location); +CREATE UNIQUE INDEX IF NOT EXISTS idx_expiry_date_ranges_unique_item_location + ON public.expiry_date_ranges(master_item_id, storage_location) + WHERE master_item_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_expiry_date_ranges_unique_category_location + ON public.expiry_date_ranges(category_id, storage_location) + WHERE category_id IS NOT NULL AND master_item_id IS NULL; + +-- 62. Expiry Alerts - user notification preferences for expiry warnings +CREATE TABLE IF NOT EXISTS public.expiry_alerts ( + expiry_alert_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + days_before_expiry INTEGER NOT NULL DEFAULT 3, + alert_method TEXT NOT NULL, + is_enabled BOOLEAN DEFAULT TRUE NOT NULL, + last_alert_sent_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT expiry_alerts_days_before_check CHECK (days_before_expiry >= 0 AND days_before_expiry <= 30), + CONSTRAINT expiry_alerts_method_check CHECK (alert_method IN ('email', 'push', 'in_app')), + UNIQUE(user_id, alert_method) +); +COMMENT ON TABLE public.expiry_alerts IS 'User preferences for expiry date notifications and alerts.'; +COMMENT ON COLUMN public.expiry_alerts.days_before_expiry IS 'How many days before expiry to send alert (0-30).'; +COMMENT ON COLUMN public.expiry_alerts.alert_method IS 'How to notify: email, push, or in_app.'; +COMMENT ON COLUMN public.expiry_alerts.is_enabled IS 'Whether this alert type is currently enabled.'; +COMMENT ON COLUMN public.expiry_alerts.last_alert_sent_at IS 'Timestamp of the last alert sent to prevent duplicate notifications.'; +CREATE INDEX IF NOT EXISTS idx_expiry_alerts_user_id ON public.expiry_alerts(user_id); +CREATE INDEX IF NOT EXISTS idx_expiry_alerts_enabled ON public.expiry_alerts(user_id, is_enabled) WHERE is_enabled = TRUE; + +-- 63. Expiry Alert Log - tracks sent notifications +CREATE TABLE IF NOT EXISTS public.expiry_alert_log ( + alert_log_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + pantry_item_id BIGINT REFERENCES public.pantry_items(pantry_item_id) ON DELETE SET NULL, + alert_type TEXT NOT NULL, + alert_method TEXT NOT NULL, + item_name TEXT NOT NULL, + expiry_date DATE, + days_until_expiry INTEGER, + sent_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT expiry_alert_log_type_check CHECK (alert_type IN ('expiring_soon', 'expired', 'expiry_reminder')), + CONSTRAINT expiry_alert_log_method_check CHECK (alert_method IN ('email', 'push', 'in_app')), + CONSTRAINT expiry_alert_log_item_name_check CHECK (TRIM(item_name) <> '') +); +COMMENT ON TABLE public.expiry_alert_log IS 'Log of all expiry notifications sent to users for auditing and duplicate prevention.'; +COMMENT ON COLUMN public.expiry_alert_log.pantry_item_id IS 'The pantry item that triggered the alert (may be null if item deleted).'; +COMMENT ON COLUMN public.expiry_alert_log.alert_type IS 'Type of alert: expiring_soon, expired, or expiry_reminder.'; +COMMENT ON COLUMN public.expiry_alert_log.alert_method IS 'How the alert was sent: email, push, or in_app.'; +COMMENT ON COLUMN public.expiry_alert_log.item_name IS 'Snapshot of item name at time of alert (in case item is deleted).'; +COMMENT ON COLUMN public.expiry_alert_log.expiry_date IS 'The expiry date that triggered the alert.'; +COMMENT ON COLUMN public.expiry_alert_log.days_until_expiry IS 'Days until expiry at time alert was sent (negative = expired).'; +CREATE INDEX IF NOT EXISTS idx_expiry_alert_log_user_id ON public.expiry_alert_log(user_id); +CREATE INDEX IF NOT EXISTS idx_expiry_alert_log_pantry_item_id ON public.expiry_alert_log(pantry_item_id) WHERE pantry_item_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_expiry_alert_log_sent_at ON public.expiry_alert_log(sent_at DESC); + + +-- ============================================================================ +-- RECEIPT SCANNING ENHANCEMENT TABLES (64-65) +-- ============================================================================ + +-- 64. Receipt Processing Log - track OCR/AI processing attempts +CREATE TABLE IF NOT EXISTS public.receipt_processing_log ( + log_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + receipt_id BIGINT NOT NULL REFERENCES public.receipts(receipt_id) ON DELETE CASCADE, + processing_step TEXT NOT NULL, + status TEXT NOT NULL, + provider TEXT, + duration_ms INTEGER, + tokens_used INTEGER, + cost_cents INTEGER, + input_data JSONB, + output_data JSONB, + error_message TEXT, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT receipt_processing_log_step_check CHECK (processing_step IN ( + 'upload', 'ocr_extraction', 'text_parsing', 'store_detection', + 'item_extraction', 'item_matching', 'price_parsing', 'finalization' + )), + CONSTRAINT receipt_processing_log_status_check CHECK (status IN ('started', 'completed', 'failed', 'skipped')), + CONSTRAINT receipt_processing_log_provider_check CHECK (provider IS NULL OR provider IN ( + 'tesseract', 'openai', 'anthropic', 'google_vision', 'aws_textract', 'internal' + )) +); +COMMENT ON TABLE public.receipt_processing_log IS 'Detailed log of each processing step for receipts, useful for debugging and cost tracking.'; +COMMENT ON COLUMN public.receipt_processing_log.processing_step IS 'Which processing step this log entry is for.'; +COMMENT ON COLUMN public.receipt_processing_log.status IS 'Status of this step: started, completed, failed, skipped.'; +COMMENT ON COLUMN public.receipt_processing_log.provider IS 'External service used: tesseract, openai, anthropic, etc.'; +COMMENT ON COLUMN public.receipt_processing_log.duration_ms IS 'How long this step took in milliseconds.'; +COMMENT ON COLUMN public.receipt_processing_log.tokens_used IS 'Number of API tokens used (for LLM providers).'; +COMMENT ON COLUMN public.receipt_processing_log.cost_cents IS 'Estimated cost in cents for this processing step.'; +COMMENT ON COLUMN public.receipt_processing_log.input_data IS 'Input data sent to the processing step (for debugging).'; +COMMENT ON COLUMN public.receipt_processing_log.output_data IS 'Output data received from the processing step.'; +CREATE INDEX IF NOT EXISTS idx_receipt_processing_log_receipt_id ON public.receipt_processing_log(receipt_id); +CREATE INDEX IF NOT EXISTS idx_receipt_processing_log_step_status ON public.receipt_processing_log(processing_step, status); +CREATE INDEX IF NOT EXISTS idx_receipt_processing_log_created_at ON public.receipt_processing_log(created_at DESC); + +-- 65. Store-specific receipt patterns - help identify stores from receipt text +CREATE TABLE IF NOT EXISTS public.store_receipt_patterns ( + pattern_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + store_id BIGINT NOT NULL REFERENCES public.stores(store_id) ON DELETE CASCADE, + pattern_type TEXT NOT NULL, + pattern_value TEXT NOT NULL, + priority INTEGER DEFAULT 0, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT store_receipt_patterns_type_check CHECK (pattern_type IN ( + 'header_regex', 'footer_regex', 'phone_number', 'address_fragment', 'store_number_format' + )), + CONSTRAINT store_receipt_patterns_value_check CHECK (TRIM(pattern_value) <> ''), + UNIQUE(store_id, pattern_type, pattern_value) +); +COMMENT ON TABLE public.store_receipt_patterns IS 'Patterns to help identify stores from receipt text and format.'; +COMMENT ON COLUMN public.store_receipt_patterns.pattern_type IS 'Type of pattern: header_regex, footer_regex, phone_number, etc.'; +COMMENT ON COLUMN public.store_receipt_patterns.pattern_value IS 'The actual pattern (regex or literal text).'; +COMMENT ON COLUMN public.store_receipt_patterns.priority IS 'Higher priority patterns are checked first.'; +COMMENT ON COLUMN public.store_receipt_patterns.is_active IS 'Whether this pattern is currently in use.'; +CREATE INDEX IF NOT EXISTS idx_store_receipt_patterns_store_id ON public.store_receipt_patterns(store_id); +CREATE INDEX IF NOT EXISTS idx_store_receipt_patterns_active ON public.store_receipt_patterns(pattern_type, is_active, priority DESC) + WHERE is_active = TRUE; + diff --git a/sql/master_schema_rollup.sql b/sql/master_schema_rollup.sql index 56ae7fa..e631f74 100644 --- a/sql/master_schema_rollup.sql +++ b/sql/master_schema_rollup.sql @@ -1033,6 +1033,235 @@ CREATE INDEX IF NOT EXISTS idx_user_achievements_user_id ON public.user_achievem CREATE INDEX IF NOT EXISTS idx_user_achievements_achievement_id ON public.user_achievements(achievement_id); +-- ============================================================================ +-- UPC SCANNING FEATURE TABLES (59-60) +-- ============================================================================ + +-- 59. UPC Scan History - tracks all UPC scans performed by users +-- This table provides an audit trail and allows users to see their scan history +CREATE TABLE IF NOT EXISTS public.upc_scan_history ( + scan_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + upc_code TEXT NOT NULL, + product_id BIGINT REFERENCES public.products(product_id) ON DELETE SET NULL, + scan_source TEXT NOT NULL, + scan_confidence NUMERIC(5,4), + raw_image_path TEXT, + lookup_successful BOOLEAN DEFAULT FALSE NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT upc_scan_history_upc_code_check CHECK (upc_code ~ '^[0-9]{8,14}$'), + CONSTRAINT upc_scan_history_scan_source_check CHECK (scan_source IN ('image_upload', 'manual_entry', 'phone_app', 'camera_scan')), + CONSTRAINT upc_scan_history_scan_confidence_check CHECK (scan_confidence IS NULL OR (scan_confidence >= 0 AND scan_confidence <= 1)) +); +COMMENT ON TABLE public.upc_scan_history IS 'Audit trail of all UPC barcode scans performed by users, tracking scan source and results.'; +COMMENT ON COLUMN public.upc_scan_history.upc_code IS 'The scanned UPC/EAN barcode (8-14 digits).'; +COMMENT ON COLUMN public.upc_scan_history.product_id IS 'Reference to the matched product, if found in our database.'; +COMMENT ON COLUMN public.upc_scan_history.scan_source IS 'How the scan was performed: image_upload, manual_entry, phone_app, or camera_scan.'; +COMMENT ON COLUMN public.upc_scan_history.scan_confidence IS 'Confidence score from barcode detection (0.0-1.0), null for manual entry.'; +COMMENT ON COLUMN public.upc_scan_history.raw_image_path IS 'Path to the uploaded barcode image, if applicable.'; +COMMENT ON COLUMN public.upc_scan_history.lookup_successful IS 'Whether the UPC was successfully matched to a product (internal or external).'; +CREATE INDEX IF NOT EXISTS idx_upc_scan_history_user_id ON public.upc_scan_history(user_id); +CREATE INDEX IF NOT EXISTS idx_upc_scan_history_upc_code ON public.upc_scan_history(upc_code); +CREATE INDEX IF NOT EXISTS idx_upc_scan_history_created_at ON public.upc_scan_history(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_upc_scan_history_product_id ON public.upc_scan_history(product_id) WHERE product_id IS NOT NULL; + +-- 60. UPC External Lookups - cache for external UPC database API responses +CREATE TABLE IF NOT EXISTS public.upc_external_lookups ( + lookup_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + upc_code TEXT NOT NULL UNIQUE, + product_name TEXT, + brand_name TEXT, + category TEXT, + description TEXT, + image_url TEXT, + external_source TEXT NOT NULL, + lookup_data JSONB, + lookup_successful BOOLEAN DEFAULT FALSE NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT upc_external_lookups_upc_code_check CHECK (upc_code ~ '^[0-9]{8,14}$'), + CONSTRAINT upc_external_lookups_external_source_check CHECK (external_source IN ('openfoodfacts', 'upcitemdb', 'manual', 'unknown')), + CONSTRAINT upc_external_lookups_name_check CHECK (NOT lookup_successful OR product_name IS NOT NULL) +); +COMMENT ON TABLE public.upc_external_lookups IS 'Cache for external UPC database API responses to reduce API calls and improve lookup speed.'; +COMMENT ON COLUMN public.upc_external_lookups.upc_code IS 'The UPC/EAN barcode that was looked up.'; +COMMENT ON COLUMN public.upc_external_lookups.product_name IS 'Product name returned from external API.'; +COMMENT ON COLUMN public.upc_external_lookups.brand_name IS 'Brand name returned from external API.'; +COMMENT ON COLUMN public.upc_external_lookups.category IS 'Product category returned from external API.'; +COMMENT ON COLUMN public.upc_external_lookups.description IS 'Product description returned from external API.'; +COMMENT ON COLUMN public.upc_external_lookups.image_url IS 'Product image URL returned from external API.'; +COMMENT ON COLUMN public.upc_external_lookups.external_source IS 'Which external API provided this data: openfoodfacts, upcitemdb, manual, unknown.'; +COMMENT ON COLUMN public.upc_external_lookups.lookup_data IS 'Full raw JSON response from the external API for reference.'; +COMMENT ON COLUMN public.upc_external_lookups.lookup_successful IS 'Whether the external lookup found product information.'; +CREATE INDEX IF NOT EXISTS idx_upc_external_lookups_upc_code ON public.upc_external_lookups(upc_code); +CREATE INDEX IF NOT EXISTS idx_upc_external_lookups_external_source ON public.upc_external_lookups(external_source); + +-- Add index to existing products.upc_code for faster lookups +CREATE INDEX IF NOT EXISTS idx_products_upc_code ON public.products(upc_code) WHERE upc_code IS NOT NULL; + + +-- ============================================================================ +-- EXPIRY DATE TRACKING FEATURE TABLES (61-63) +-- ============================================================================ + +-- 61. Expiry Date Ranges - reference table for typical shelf life +CREATE TABLE IF NOT EXISTS public.expiry_date_ranges ( + expiry_range_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE, + category_id BIGINT REFERENCES public.categories(category_id) ON DELETE CASCADE, + item_pattern TEXT, + storage_location TEXT NOT NULL, + min_days INTEGER NOT NULL, + max_days INTEGER NOT NULL, + typical_days INTEGER NOT NULL, + notes TEXT, + source TEXT, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT expiry_date_ranges_storage_location_check CHECK (storage_location IN ('fridge', 'freezer', 'pantry', 'room_temp')), + CONSTRAINT expiry_date_ranges_min_days_check CHECK (min_days >= 0), + CONSTRAINT expiry_date_ranges_max_days_check CHECK (max_days >= min_days), + CONSTRAINT expiry_date_ranges_typical_days_check CHECK (typical_days >= min_days AND typical_days <= max_days), + CONSTRAINT expiry_date_ranges_identifier_check CHECK ( + master_item_id IS NOT NULL OR category_id IS NOT NULL OR item_pattern IS NOT NULL + ), + CONSTRAINT expiry_date_ranges_source_check CHECK (source IS NULL OR source IN ('usda', 'fda', 'manual', 'community')) +); +COMMENT ON TABLE public.expiry_date_ranges IS 'Reference table storing typical shelf life for grocery items based on storage location.'; +COMMENT ON COLUMN public.expiry_date_ranges.master_item_id IS 'Specific item this range applies to (most specific).'; +COMMENT ON COLUMN public.expiry_date_ranges.category_id IS 'Category this range applies to (fallback if no item match).'; +COMMENT ON COLUMN public.expiry_date_ranges.item_pattern IS 'Regex pattern to match item names (fallback if no item/category match).'; +COMMENT ON COLUMN public.expiry_date_ranges.storage_location IS 'Where the item is stored: fridge, freezer, pantry, or room_temp.'; +COMMENT ON COLUMN public.expiry_date_ranges.min_days IS 'Minimum shelf life in days under proper storage.'; +COMMENT ON COLUMN public.expiry_date_ranges.max_days IS 'Maximum shelf life in days under proper storage.'; +COMMENT ON COLUMN public.expiry_date_ranges.typical_days IS 'Most common/recommended shelf life in days.'; +COMMENT ON COLUMN public.expiry_date_ranges.notes IS 'Additional storage tips or warnings.'; +COMMENT ON COLUMN public.expiry_date_ranges.source IS 'Data source: usda, fda, manual, or community.'; +CREATE INDEX IF NOT EXISTS idx_expiry_date_ranges_master_item_id ON public.expiry_date_ranges(master_item_id) WHERE master_item_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_expiry_date_ranges_category_id ON public.expiry_date_ranges(category_id) WHERE category_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_expiry_date_ranges_storage_location ON public.expiry_date_ranges(storage_location); +CREATE UNIQUE INDEX IF NOT EXISTS idx_expiry_date_ranges_unique_item_location + ON public.expiry_date_ranges(master_item_id, storage_location) + WHERE master_item_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_expiry_date_ranges_unique_category_location + ON public.expiry_date_ranges(category_id, storage_location) + WHERE category_id IS NOT NULL AND master_item_id IS NULL; + +-- 62. Expiry Alerts - user notification preferences for expiry warnings +CREATE TABLE IF NOT EXISTS public.expiry_alerts ( + expiry_alert_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + days_before_expiry INTEGER NOT NULL DEFAULT 3, + alert_method TEXT NOT NULL, + is_enabled BOOLEAN DEFAULT TRUE NOT NULL, + last_alert_sent_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT expiry_alerts_days_before_check CHECK (days_before_expiry >= 0 AND days_before_expiry <= 30), + CONSTRAINT expiry_alerts_method_check CHECK (alert_method IN ('email', 'push', 'in_app')), + UNIQUE(user_id, alert_method) +); +COMMENT ON TABLE public.expiry_alerts IS 'User preferences for expiry date notifications and alerts.'; +COMMENT ON COLUMN public.expiry_alerts.days_before_expiry IS 'How many days before expiry to send alert (0-30).'; +COMMENT ON COLUMN public.expiry_alerts.alert_method IS 'How to notify: email, push, or in_app.'; +COMMENT ON COLUMN public.expiry_alerts.is_enabled IS 'Whether this alert type is currently enabled.'; +COMMENT ON COLUMN public.expiry_alerts.last_alert_sent_at IS 'Timestamp of the last alert sent to prevent duplicate notifications.'; +CREATE INDEX IF NOT EXISTS idx_expiry_alerts_user_id ON public.expiry_alerts(user_id); +CREATE INDEX IF NOT EXISTS idx_expiry_alerts_enabled ON public.expiry_alerts(user_id, is_enabled) WHERE is_enabled = TRUE; + +-- 63. Expiry Alert Log - tracks sent notifications +CREATE TABLE IF NOT EXISTS public.expiry_alert_log ( + alert_log_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + pantry_item_id BIGINT REFERENCES public.pantry_items(pantry_item_id) ON DELETE SET NULL, + alert_type TEXT NOT NULL, + alert_method TEXT NOT NULL, + item_name TEXT NOT NULL, + expiry_date DATE, + days_until_expiry INTEGER, + sent_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT expiry_alert_log_type_check CHECK (alert_type IN ('expiring_soon', 'expired', 'expiry_reminder')), + CONSTRAINT expiry_alert_log_method_check CHECK (alert_method IN ('email', 'push', 'in_app')), + CONSTRAINT expiry_alert_log_item_name_check CHECK (TRIM(item_name) <> '') +); +COMMENT ON TABLE public.expiry_alert_log IS 'Log of all expiry notifications sent to users for auditing and duplicate prevention.'; +COMMENT ON COLUMN public.expiry_alert_log.pantry_item_id IS 'The pantry item that triggered the alert (may be null if item deleted).'; +COMMENT ON COLUMN public.expiry_alert_log.alert_type IS 'Type of alert: expiring_soon, expired, or expiry_reminder.'; +COMMENT ON COLUMN public.expiry_alert_log.alert_method IS 'How the alert was sent: email, push, or in_app.'; +COMMENT ON COLUMN public.expiry_alert_log.item_name IS 'Snapshot of item name at time of alert (in case item is deleted).'; +COMMENT ON COLUMN public.expiry_alert_log.expiry_date IS 'The expiry date that triggered the alert.'; +COMMENT ON COLUMN public.expiry_alert_log.days_until_expiry IS 'Days until expiry at time alert was sent (negative = expired).'; +CREATE INDEX IF NOT EXISTS idx_expiry_alert_log_user_id ON public.expiry_alert_log(user_id); +CREATE INDEX IF NOT EXISTS idx_expiry_alert_log_pantry_item_id ON public.expiry_alert_log(pantry_item_id) WHERE pantry_item_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_expiry_alert_log_sent_at ON public.expiry_alert_log(sent_at DESC); + + +-- ============================================================================ +-- RECEIPT SCANNING ENHANCEMENT TABLES (64-65) +-- ============================================================================ + +-- 64. Receipt Processing Log - track OCR/AI processing attempts +CREATE TABLE IF NOT EXISTS public.receipt_processing_log ( + log_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + receipt_id BIGINT NOT NULL REFERENCES public.receipts(receipt_id) ON DELETE CASCADE, + processing_step TEXT NOT NULL, + status TEXT NOT NULL, + provider TEXT, + duration_ms INTEGER, + tokens_used INTEGER, + cost_cents INTEGER, + input_data JSONB, + output_data JSONB, + error_message TEXT, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT receipt_processing_log_step_check CHECK (processing_step IN ( + 'upload', 'ocr_extraction', 'text_parsing', 'store_detection', + 'item_extraction', 'item_matching', 'price_parsing', 'finalization' + )), + CONSTRAINT receipt_processing_log_status_check CHECK (status IN ('started', 'completed', 'failed', 'skipped')), + CONSTRAINT receipt_processing_log_provider_check CHECK (provider IS NULL OR provider IN ( + 'tesseract', 'openai', 'anthropic', 'google_vision', 'aws_textract', 'internal' + )) +); +COMMENT ON TABLE public.receipt_processing_log IS 'Detailed log of each processing step for receipts, useful for debugging and cost tracking.'; +COMMENT ON COLUMN public.receipt_processing_log.processing_step IS 'Which processing step this log entry is for.'; +COMMENT ON COLUMN public.receipt_processing_log.status IS 'Status of this step: started, completed, failed, skipped.'; +COMMENT ON COLUMN public.receipt_processing_log.provider IS 'External service used: tesseract, openai, anthropic, etc.'; +COMMENT ON COLUMN public.receipt_processing_log.duration_ms IS 'How long this step took in milliseconds.'; +COMMENT ON COLUMN public.receipt_processing_log.tokens_used IS 'Number of API tokens used (for LLM providers).'; +COMMENT ON COLUMN public.receipt_processing_log.cost_cents IS 'Estimated cost in cents for this processing step.'; +COMMENT ON COLUMN public.receipt_processing_log.input_data IS 'Input data sent to the processing step (for debugging).'; +COMMENT ON COLUMN public.receipt_processing_log.output_data IS 'Output data received from the processing step.'; +CREATE INDEX IF NOT EXISTS idx_receipt_processing_log_receipt_id ON public.receipt_processing_log(receipt_id); +CREATE INDEX IF NOT EXISTS idx_receipt_processing_log_step_status ON public.receipt_processing_log(processing_step, status); +CREATE INDEX IF NOT EXISTS idx_receipt_processing_log_created_at ON public.receipt_processing_log(created_at DESC); + +-- 65. Store-specific receipt patterns - help identify stores from receipt text +CREATE TABLE IF NOT EXISTS public.store_receipt_patterns ( + pattern_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + store_id BIGINT NOT NULL REFERENCES public.stores(store_id) ON DELETE CASCADE, + pattern_type TEXT NOT NULL, + pattern_value TEXT NOT NULL, + priority INTEGER DEFAULT 0, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + CONSTRAINT store_receipt_patterns_type_check CHECK (pattern_type IN ( + 'header_regex', 'footer_regex', 'phone_number', 'address_fragment', 'store_number_format' + )), + CONSTRAINT store_receipt_patterns_value_check CHECK (TRIM(pattern_value) <> ''), + UNIQUE(store_id, pattern_type, pattern_value) +); +COMMENT ON TABLE public.store_receipt_patterns IS 'Patterns to help identify stores from receipt text and format.'; +COMMENT ON COLUMN public.store_receipt_patterns.pattern_type IS 'Type of pattern: header_regex, footer_regex, phone_number, etc.'; +COMMENT ON COLUMN public.store_receipt_patterns.pattern_value IS 'The actual pattern (regex or literal text).'; +COMMENT ON COLUMN public.store_receipt_patterns.priority IS 'Higher priority patterns are checked first.'; +COMMENT ON COLUMN public.store_receipt_patterns.is_active IS 'Whether this pattern is currently in use.'; +CREATE INDEX IF NOT EXISTS idx_store_receipt_patterns_store_id ON public.store_receipt_patterns(store_id); +CREATE INDEX IF NOT EXISTS idx_store_receipt_patterns_active ON public.store_receipt_patterns(pattern_type, is_active, priority DESC) + WHERE is_active = TRUE; + -- ============================================================================ -- PART 2: DATA SEEDING diff --git a/sql/migrations/001_upc_scanning.sql b/sql/migrations/001_upc_scanning.sql new file mode 100644 index 0000000..f69ec54 --- /dev/null +++ b/sql/migrations/001_upc_scanning.sql @@ -0,0 +1,90 @@ +-- sql/migrations/001_upc_scanning.sql +-- ============================================================================ +-- UPC SCANNING FEATURE MIGRATION +-- ============================================================================ +-- Purpose: +-- This migration adds tables to support UPC barcode scanning functionality: +-- 1. upc_scan_history - Audit trail of all UPC scans performed by users +-- 2. upc_external_lookups - Cache for external UPC database API responses +-- +-- The products.upc_code column already exists in the schema. +-- These tables extend the functionality to track scans and cache lookups. +-- ============================================================================ + +-- 1. UPC Scan History - tracks all UPC scans performed by users +-- This table provides an audit trail and allows users to see their scan history +CREATE TABLE IF NOT EXISTS public.upc_scan_history ( + scan_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + upc_code TEXT NOT NULL, + product_id BIGINT REFERENCES public.products(product_id) ON DELETE SET NULL, + scan_source TEXT NOT NULL, + scan_confidence NUMERIC(5,4), + raw_image_path TEXT, + lookup_successful BOOLEAN DEFAULT FALSE NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + -- Validate UPC code format (8-14 digits for UPC-A, UPC-E, EAN-8, EAN-13, etc.) + CONSTRAINT upc_scan_history_upc_code_check CHECK (upc_code ~ '^[0-9]{8,14}$'), + -- Validate scan source is one of the allowed values + CONSTRAINT upc_scan_history_scan_source_check CHECK (scan_source IN ('image_upload', 'manual_entry', 'phone_app', 'camera_scan')), + -- Confidence score must be between 0 and 1 if provided + CONSTRAINT upc_scan_history_scan_confidence_check CHECK (scan_confidence IS NULL OR (scan_confidence >= 0 AND scan_confidence <= 1)) +); +COMMENT ON TABLE public.upc_scan_history IS 'Audit trail of all UPC barcode scans performed by users, tracking scan source and results.'; +COMMENT ON COLUMN public.upc_scan_history.upc_code IS 'The scanned UPC/EAN barcode (8-14 digits).'; +COMMENT ON COLUMN public.upc_scan_history.product_id IS 'Reference to the matched product, if found in our database.'; +COMMENT ON COLUMN public.upc_scan_history.scan_source IS 'How the scan was performed: image_upload, manual_entry, phone_app, or camera_scan.'; +COMMENT ON COLUMN public.upc_scan_history.scan_confidence IS 'Confidence score from barcode detection (0.0-1.0), null for manual entry.'; +COMMENT ON COLUMN public.upc_scan_history.raw_image_path IS 'Path to the uploaded barcode image, if applicable.'; +COMMENT ON COLUMN public.upc_scan_history.lookup_successful IS 'Whether the UPC was successfully matched to a product (internal or external).'; + +-- Indexes for upc_scan_history +CREATE INDEX IF NOT EXISTS idx_upc_scan_history_user_id ON public.upc_scan_history(user_id); +CREATE INDEX IF NOT EXISTS idx_upc_scan_history_upc_code ON public.upc_scan_history(upc_code); +CREATE INDEX IF NOT EXISTS idx_upc_scan_history_created_at ON public.upc_scan_history(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_upc_scan_history_product_id ON public.upc_scan_history(product_id) WHERE product_id IS NOT NULL; + + +-- 2. UPC External Lookups - cache for external UPC database API responses +-- This table caches results from external UPC databases (OpenFoodFacts, UPC Item DB, etc.) +-- to reduce API calls and improve response times for repeated lookups +CREATE TABLE IF NOT EXISTS public.upc_external_lookups ( + lookup_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + upc_code TEXT NOT NULL UNIQUE, + product_name TEXT, + brand_name TEXT, + category TEXT, + description TEXT, + image_url TEXT, + external_source TEXT NOT NULL, + lookup_data JSONB, + lookup_successful BOOLEAN DEFAULT FALSE NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + -- Validate UPC code format + CONSTRAINT upc_external_lookups_upc_code_check CHECK (upc_code ~ '^[0-9]{8,14}$'), + -- Validate external source is one of the supported APIs + CONSTRAINT upc_external_lookups_external_source_check CHECK (external_source IN ('openfoodfacts', 'upcitemdb', 'manual', 'unknown')), + -- If lookup was successful, product_name should be present + CONSTRAINT upc_external_lookups_name_check CHECK (NOT lookup_successful OR product_name IS NOT NULL) +); +COMMENT ON TABLE public.upc_external_lookups IS 'Cache for external UPC database API responses to reduce API calls and improve lookup speed.'; +COMMENT ON COLUMN public.upc_external_lookups.upc_code IS 'The UPC/EAN barcode that was looked up.'; +COMMENT ON COLUMN public.upc_external_lookups.product_name IS 'Product name returned from external API.'; +COMMENT ON COLUMN public.upc_external_lookups.brand_name IS 'Brand name returned from external API.'; +COMMENT ON COLUMN public.upc_external_lookups.category IS 'Product category returned from external API.'; +COMMENT ON COLUMN public.upc_external_lookups.description IS 'Product description returned from external API.'; +COMMENT ON COLUMN public.upc_external_lookups.image_url IS 'Product image URL returned from external API.'; +COMMENT ON COLUMN public.upc_external_lookups.external_source IS 'Which external API provided this data: openfoodfacts, upcitemdb, manual, unknown.'; +COMMENT ON COLUMN public.upc_external_lookups.lookup_data IS 'Full raw JSON response from the external API for reference.'; +COMMENT ON COLUMN public.upc_external_lookups.lookup_successful IS 'Whether the external lookup found product information.'; + +-- Index for upc_external_lookups +CREATE INDEX IF NOT EXISTS idx_upc_external_lookups_upc_code ON public.upc_external_lookups(upc_code); +CREATE INDEX IF NOT EXISTS idx_upc_external_lookups_external_source ON public.upc_external_lookups(external_source); + + +-- 3. Add index to existing products.upc_code if not exists +-- This speeds up lookups when matching scanned UPCs to existing products +CREATE INDEX IF NOT EXISTS idx_products_upc_code ON public.products(upc_code) WHERE upc_code IS NOT NULL; diff --git a/sql/migrations/002_expiry_tracking.sql b/sql/migrations/002_expiry_tracking.sql new file mode 100644 index 0000000..c87a26e --- /dev/null +++ b/sql/migrations/002_expiry_tracking.sql @@ -0,0 +1,189 @@ +-- sql/migrations/002_expiry_tracking.sql +-- ============================================================================ +-- EXPIRY DATE TRACKING FEATURE MIGRATION +-- ============================================================================ +-- Purpose: +-- This migration adds tables and enhancements for expiry date tracking: +-- 1. expiry_date_ranges - Reference table for typical shelf life by item/category +-- 2. expiry_alerts - User notification preferences for expiry warnings +-- 3. Enhancements to pantry_items for better expiry tracking +-- +-- Existing tables used: +-- - pantry_items (already has best_before_date) +-- - pantry_locations (already exists for fridge/freezer/pantry) +-- - receipts and receipt_items (already exist for receipt scanning) +-- ============================================================================ + +-- 1. Expiry Date Ranges - reference table for typical shelf life +-- This table stores expected shelf life for items based on storage location +-- Used to auto-calculate expiry dates when users add items to inventory +CREATE TABLE IF NOT EXISTS public.expiry_date_ranges ( + expiry_range_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + master_item_id BIGINT REFERENCES public.master_grocery_items(master_grocery_item_id) ON DELETE CASCADE, + category_id BIGINT REFERENCES public.categories(category_id) ON DELETE CASCADE, + item_pattern TEXT, + storage_location TEXT NOT NULL, + min_days INTEGER NOT NULL, + max_days INTEGER NOT NULL, + typical_days INTEGER NOT NULL, + notes TEXT, + source TEXT, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + -- Validate storage location is one of the allowed values + CONSTRAINT expiry_date_ranges_storage_location_check CHECK (storage_location IN ('fridge', 'freezer', 'pantry', 'room_temp')), + -- Validate day ranges are logical + CONSTRAINT expiry_date_ranges_min_days_check CHECK (min_days >= 0), + CONSTRAINT expiry_date_ranges_max_days_check CHECK (max_days >= min_days), + CONSTRAINT expiry_date_ranges_typical_days_check CHECK (typical_days >= min_days AND typical_days <= max_days), + -- At least one identifier must be present + CONSTRAINT expiry_date_ranges_identifier_check CHECK ( + master_item_id IS NOT NULL OR category_id IS NOT NULL OR item_pattern IS NOT NULL + ), + -- Validate source is one of the known sources + CONSTRAINT expiry_date_ranges_source_check CHECK (source IS NULL OR source IN ('usda', 'fda', 'manual', 'community')) +); +COMMENT ON TABLE public.expiry_date_ranges IS 'Reference table storing typical shelf life for grocery items based on storage location.'; +COMMENT ON COLUMN public.expiry_date_ranges.master_item_id IS 'Specific item this range applies to (most specific).'; +COMMENT ON COLUMN public.expiry_date_ranges.category_id IS 'Category this range applies to (fallback if no item match).'; +COMMENT ON COLUMN public.expiry_date_ranges.item_pattern IS 'Regex pattern to match item names (fallback if no item/category match).'; +COMMENT ON COLUMN public.expiry_date_ranges.storage_location IS 'Where the item is stored: fridge, freezer, pantry, or room_temp.'; +COMMENT ON COLUMN public.expiry_date_ranges.min_days IS 'Minimum shelf life in days under proper storage.'; +COMMENT ON COLUMN public.expiry_date_ranges.max_days IS 'Maximum shelf life in days under proper storage.'; +COMMENT ON COLUMN public.expiry_date_ranges.typical_days IS 'Most common/recommended shelf life in days.'; +COMMENT ON COLUMN public.expiry_date_ranges.notes IS 'Additional storage tips or warnings.'; +COMMENT ON COLUMN public.expiry_date_ranges.source IS 'Data source: usda, fda, manual, or community.'; + +-- Indexes for expiry_date_ranges +CREATE INDEX IF NOT EXISTS idx_expiry_date_ranges_master_item_id ON public.expiry_date_ranges(master_item_id) WHERE master_item_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_expiry_date_ranges_category_id ON public.expiry_date_ranges(category_id) WHERE category_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_expiry_date_ranges_storage_location ON public.expiry_date_ranges(storage_location); + +-- Unique constraint to prevent duplicate entries for same item/location combo +CREATE UNIQUE INDEX IF NOT EXISTS idx_expiry_date_ranges_unique_item_location + ON public.expiry_date_ranges(master_item_id, storage_location) + WHERE master_item_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_expiry_date_ranges_unique_category_location + ON public.expiry_date_ranges(category_id, storage_location) + WHERE category_id IS NOT NULL AND master_item_id IS NULL; + + +-- 2. Expiry Alerts - user notification preferences for expiry warnings +-- This table stores user preferences for when and how to receive expiry notifications +CREATE TABLE IF NOT EXISTS public.expiry_alerts ( + expiry_alert_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + days_before_expiry INTEGER NOT NULL DEFAULT 3, + alert_method TEXT NOT NULL, + is_enabled BOOLEAN DEFAULT TRUE NOT NULL, + last_alert_sent_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + -- Validate days before expiry is reasonable + CONSTRAINT expiry_alerts_days_before_check CHECK (days_before_expiry >= 0 AND days_before_expiry <= 30), + -- Validate alert method is one of the allowed values + CONSTRAINT expiry_alerts_method_check CHECK (alert_method IN ('email', 'push', 'in_app')), + -- Each user can only have one setting per alert method + UNIQUE(user_id, alert_method) +); +COMMENT ON TABLE public.expiry_alerts IS 'User preferences for expiry date notifications and alerts.'; +COMMENT ON COLUMN public.expiry_alerts.days_before_expiry IS 'How many days before expiry to send alert (0-30).'; +COMMENT ON COLUMN public.expiry_alerts.alert_method IS 'How to notify: email, push, or in_app.'; +COMMENT ON COLUMN public.expiry_alerts.is_enabled IS 'Whether this alert type is currently enabled.'; +COMMENT ON COLUMN public.expiry_alerts.last_alert_sent_at IS 'Timestamp of the last alert sent to prevent duplicate notifications.'; + +-- Indexes for expiry_alerts +CREATE INDEX IF NOT EXISTS idx_expiry_alerts_user_id ON public.expiry_alerts(user_id); +CREATE INDEX IF NOT EXISTS idx_expiry_alerts_enabled ON public.expiry_alerts(user_id, is_enabled) WHERE is_enabled = TRUE; + + +-- 3. Expiry Alert Log - tracks sent notifications (for auditing and preventing duplicates) +CREATE TABLE IF NOT EXISTS public.expiry_alert_log ( + alert_log_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + user_id UUID NOT NULL REFERENCES public.users(user_id) ON DELETE CASCADE, + pantry_item_id BIGINT REFERENCES public.pantry_items(pantry_item_id) ON DELETE SET NULL, + alert_type TEXT NOT NULL, + alert_method TEXT NOT NULL, + item_name TEXT NOT NULL, + expiry_date DATE, + days_until_expiry INTEGER, + sent_at TIMESTAMPTZ DEFAULT now() NOT NULL, + -- Validate alert type + CONSTRAINT expiry_alert_log_type_check CHECK (alert_type IN ('expiring_soon', 'expired', 'expiry_reminder')), + -- Validate alert method + CONSTRAINT expiry_alert_log_method_check CHECK (alert_method IN ('email', 'push', 'in_app')), + -- Validate item_name is not empty + CONSTRAINT expiry_alert_log_item_name_check CHECK (TRIM(item_name) <> '') +); +COMMENT ON TABLE public.expiry_alert_log IS 'Log of all expiry notifications sent to users for auditing and duplicate prevention.'; +COMMENT ON COLUMN public.expiry_alert_log.pantry_item_id IS 'The pantry item that triggered the alert (may be null if item deleted).'; +COMMENT ON COLUMN public.expiry_alert_log.alert_type IS 'Type of alert: expiring_soon, expired, or expiry_reminder.'; +COMMENT ON COLUMN public.expiry_alert_log.alert_method IS 'How the alert was sent: email, push, or in_app.'; +COMMENT ON COLUMN public.expiry_alert_log.item_name IS 'Snapshot of item name at time of alert (in case item is deleted).'; +COMMENT ON COLUMN public.expiry_alert_log.expiry_date IS 'The expiry date that triggered the alert.'; +COMMENT ON COLUMN public.expiry_alert_log.days_until_expiry IS 'Days until expiry at time alert was sent (negative = expired).'; + +-- Indexes for expiry_alert_log +CREATE INDEX IF NOT EXISTS idx_expiry_alert_log_user_id ON public.expiry_alert_log(user_id); +CREATE INDEX IF NOT EXISTS idx_expiry_alert_log_pantry_item_id ON public.expiry_alert_log(pantry_item_id) WHERE pantry_item_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_expiry_alert_log_sent_at ON public.expiry_alert_log(sent_at DESC); + + +-- 4. Enhancements to pantry_items table +-- Add columns to better support expiry tracking from receipts and UPC scans + +-- Add purchase_date column to track when item was bought +ALTER TABLE public.pantry_items + ADD COLUMN IF NOT EXISTS purchase_date DATE; +COMMENT ON COLUMN public.pantry_items.purchase_date IS 'Date the item was purchased (from receipt or manual entry).'; + +-- Add source column to track how item was added +ALTER TABLE public.pantry_items + ADD COLUMN IF NOT EXISTS source TEXT DEFAULT 'manual'; +-- Note: Cannot add CHECK constraint via ALTER in PostgreSQL, will validate in application + +-- Add receipt_item_id to link back to receipt if added from receipt scan +ALTER TABLE public.pantry_items + ADD COLUMN IF NOT EXISTS receipt_item_id BIGINT REFERENCES public.receipt_items(receipt_item_id) ON DELETE SET NULL; +COMMENT ON COLUMN public.pantry_items.receipt_item_id IS 'Link to receipt_items if this pantry item was created from a receipt scan.'; + +-- Add product_id to link to specific product if known from UPC scan +ALTER TABLE public.pantry_items + ADD COLUMN IF NOT EXISTS product_id BIGINT REFERENCES public.products(product_id) ON DELETE SET NULL; +COMMENT ON COLUMN public.pantry_items.product_id IS 'Link to products if this pantry item was created from a UPC scan.'; + +-- Add expiry_source to track how expiry date was determined +ALTER TABLE public.pantry_items + ADD COLUMN IF NOT EXISTS expiry_source TEXT; +COMMENT ON COLUMN public.pantry_items.expiry_source IS 'How expiry was determined: manual, calculated, package, receipt.'; + +-- Add is_consumed column if not exists (check for existing) +ALTER TABLE public.pantry_items + ADD COLUMN IF NOT EXISTS is_consumed BOOLEAN DEFAULT FALSE; +COMMENT ON COLUMN public.pantry_items.is_consumed IS 'Whether the item has been fully consumed.'; + +-- Add consumed_at timestamp +ALTER TABLE public.pantry_items + ADD COLUMN IF NOT EXISTS consumed_at TIMESTAMPTZ; +COMMENT ON COLUMN public.pantry_items.consumed_at IS 'When the item was marked as consumed.'; + +-- New indexes for pantry_items expiry queries +CREATE INDEX IF NOT EXISTS idx_pantry_items_best_before_date ON public.pantry_items(best_before_date) + WHERE best_before_date IS NOT NULL AND (is_consumed IS NULL OR is_consumed = FALSE); +CREATE INDEX IF NOT EXISTS idx_pantry_items_expiring_soon ON public.pantry_items(user_id, best_before_date) + WHERE best_before_date IS NOT NULL AND (is_consumed IS NULL OR is_consumed = FALSE); +CREATE INDEX IF NOT EXISTS idx_pantry_items_receipt_item_id ON public.pantry_items(receipt_item_id) + WHERE receipt_item_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_pantry_items_product_id ON public.pantry_items(product_id) + WHERE product_id IS NOT NULL; + + +-- 5. Add UPC scan support to receipt_items table +-- When receipt items are matched via UPC, store the reference +ALTER TABLE public.receipt_items + ADD COLUMN IF NOT EXISTS upc_code TEXT; +COMMENT ON COLUMN public.receipt_items.upc_code IS 'UPC code if extracted from receipt or matched during processing.'; + +-- Add constraint for upc_code format (cannot add via ALTER, will validate in app) +CREATE INDEX IF NOT EXISTS idx_receipt_items_upc_code ON public.receipt_items(upc_code) + WHERE upc_code IS NOT NULL; diff --git a/sql/migrations/003_receipt_scanning_enhancements.sql b/sql/migrations/003_receipt_scanning_enhancements.sql new file mode 100644 index 0000000..52ce390 --- /dev/null +++ b/sql/migrations/003_receipt_scanning_enhancements.sql @@ -0,0 +1,169 @@ +-- sql/migrations/003_receipt_scanning_enhancements.sql +-- ============================================================================ +-- RECEIPT SCANNING ENHANCEMENTS MIGRATION +-- ============================================================================ +-- Purpose: +-- This migration adds enhancements to the existing receipt scanning tables: +-- 1. Enhancements to receipts table for better OCR processing +-- 2. Enhancements to receipt_items for better item matching +-- 3. receipt_processing_log for tracking OCR/AI processing attempts +-- +-- Existing tables: +-- - receipts (lines 932-948 in master_schema_rollup.sql) +-- - receipt_items (lines 951-966 in master_schema_rollup.sql) +-- ============================================================================ + +-- 1. Enhancements to receipts table + +-- Add store detection confidence +ALTER TABLE public.receipts + ADD COLUMN IF NOT EXISTS store_confidence NUMERIC(5,4); +COMMENT ON COLUMN public.receipts.store_confidence IS 'Confidence score for store detection (0.0-1.0).'; + +-- Add OCR provider used +ALTER TABLE public.receipts + ADD COLUMN IF NOT EXISTS ocr_provider TEXT; +COMMENT ON COLUMN public.receipts.ocr_provider IS 'Which OCR service processed this receipt: tesseract, openai, anthropic.'; + +-- Add error details for failed processing +ALTER TABLE public.receipts + ADD COLUMN IF NOT EXISTS error_details JSONB; +COMMENT ON COLUMN public.receipts.error_details IS 'Detailed error information if processing failed.'; + +-- Add retry count for failed processing +ALTER TABLE public.receipts + ADD COLUMN IF NOT EXISTS retry_count INTEGER DEFAULT 0; +COMMENT ON COLUMN public.receipts.retry_count IS 'Number of processing retry attempts.'; + +-- Add extracted text confidence +ALTER TABLE public.receipts + ADD COLUMN IF NOT EXISTS ocr_confidence NUMERIC(5,4); +COMMENT ON COLUMN public.receipts.ocr_confidence IS 'Overall OCR text extraction confidence score.'; + +-- Add currency detection +ALTER TABLE public.receipts + ADD COLUMN IF NOT EXISTS currency TEXT DEFAULT 'CAD'; +COMMENT ON COLUMN public.receipts.currency IS 'Detected currency: CAD, USD, etc.'; + +-- New indexes for receipt processing +CREATE INDEX IF NOT EXISTS idx_receipts_status_retry ON public.receipts(status, retry_count) + WHERE status IN ('pending', 'failed') AND retry_count < 3; + + +-- 2. Enhancements to receipt_items table + +-- Add line number from receipt for ordering +ALTER TABLE public.receipt_items + ADD COLUMN IF NOT EXISTS line_number INTEGER; +COMMENT ON COLUMN public.receipt_items.line_number IS 'Original line number on the receipt for display ordering.'; + +-- Add match confidence score +ALTER TABLE public.receipt_items + ADD COLUMN IF NOT EXISTS match_confidence NUMERIC(5,4); +COMMENT ON COLUMN public.receipt_items.match_confidence IS 'Confidence score for item matching (0.0-1.0).'; + +-- Add is_discount flag for discount/coupon lines +ALTER TABLE public.receipt_items + ADD COLUMN IF NOT EXISTS is_discount BOOLEAN DEFAULT FALSE; +COMMENT ON COLUMN public.receipt_items.is_discount IS 'Whether this line is a discount/coupon (negative price).'; + +-- Add unit_price if per-unit pricing detected +ALTER TABLE public.receipt_items + ADD COLUMN IF NOT EXISTS unit_price_cents INTEGER; +COMMENT ON COLUMN public.receipt_items.unit_price_cents IS 'Per-unit price if detected (e.g., price per kg).'; + +-- Add unit type if detected +ALTER TABLE public.receipt_items + ADD COLUMN IF NOT EXISTS unit_type TEXT; +COMMENT ON COLUMN public.receipt_items.unit_type IS 'Unit type if detected: kg, lb, each, etc.'; + +-- Add added_to_pantry flag +ALTER TABLE public.receipt_items + ADD COLUMN IF NOT EXISTS added_to_pantry BOOLEAN DEFAULT FALSE; +COMMENT ON COLUMN public.receipt_items.added_to_pantry IS 'Whether this item has been added to user pantry.'; + +-- Add pantry_item_id link +ALTER TABLE public.receipt_items + ADD COLUMN IF NOT EXISTS pantry_item_id BIGINT REFERENCES public.pantry_items(pantry_item_id) ON DELETE SET NULL; +COMMENT ON COLUMN public.receipt_items.pantry_item_id IS 'Link to pantry_items if this receipt item was added to pantry.'; + +-- New indexes for receipt_items +CREATE INDEX IF NOT EXISTS idx_receipt_items_status ON public.receipt_items(status); +CREATE INDEX IF NOT EXISTS idx_receipt_items_added_to_pantry ON public.receipt_items(receipt_id, added_to_pantry) + WHERE added_to_pantry = FALSE; +CREATE INDEX IF NOT EXISTS idx_receipt_items_pantry_item_id ON public.receipt_items(pantry_item_id) + WHERE pantry_item_id IS NOT NULL; + + +-- 3. Receipt Processing Log - track OCR/AI processing attempts +-- Useful for debugging, monitoring costs, and improving processing +CREATE TABLE IF NOT EXISTS public.receipt_processing_log ( + log_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + receipt_id BIGINT NOT NULL REFERENCES public.receipts(receipt_id) ON DELETE CASCADE, + processing_step TEXT NOT NULL, + status TEXT NOT NULL, + provider TEXT, + duration_ms INTEGER, + tokens_used INTEGER, + cost_cents INTEGER, + input_data JSONB, + output_data JSONB, + error_message TEXT, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + -- Validate processing step + CONSTRAINT receipt_processing_log_step_check CHECK (processing_step IN ( + 'upload', 'ocr_extraction', 'text_parsing', 'store_detection', + 'item_extraction', 'item_matching', 'price_parsing', 'finalization' + )), + -- Validate status + CONSTRAINT receipt_processing_log_status_check CHECK (status IN ('started', 'completed', 'failed', 'skipped')), + -- Validate provider if specified + CONSTRAINT receipt_processing_log_provider_check CHECK (provider IS NULL OR provider IN ( + 'tesseract', 'openai', 'anthropic', 'google_vision', 'aws_textract', 'internal' + )) +); +COMMENT ON TABLE public.receipt_processing_log IS 'Detailed log of each processing step for receipts, useful for debugging and cost tracking.'; +COMMENT ON COLUMN public.receipt_processing_log.processing_step IS 'Which processing step this log entry is for.'; +COMMENT ON COLUMN public.receipt_processing_log.status IS 'Status of this step: started, completed, failed, skipped.'; +COMMENT ON COLUMN public.receipt_processing_log.provider IS 'External service used: tesseract, openai, anthropic, etc.'; +COMMENT ON COLUMN public.receipt_processing_log.duration_ms IS 'How long this step took in milliseconds.'; +COMMENT ON COLUMN public.receipt_processing_log.tokens_used IS 'Number of API tokens used (for LLM providers).'; +COMMENT ON COLUMN public.receipt_processing_log.cost_cents IS 'Estimated cost in cents for this processing step.'; +COMMENT ON COLUMN public.receipt_processing_log.input_data IS 'Input data sent to the processing step (for debugging).'; +COMMENT ON COLUMN public.receipt_processing_log.output_data IS 'Output data received from the processing step.'; + +-- Indexes for receipt_processing_log +CREATE INDEX IF NOT EXISTS idx_receipt_processing_log_receipt_id ON public.receipt_processing_log(receipt_id); +CREATE INDEX IF NOT EXISTS idx_receipt_processing_log_step_status ON public.receipt_processing_log(processing_step, status); +CREATE INDEX IF NOT EXISTS idx_receipt_processing_log_created_at ON public.receipt_processing_log(created_at DESC); + + +-- 4. Store-specific receipt patterns - help identify stores from receipt text +CREATE TABLE IF NOT EXISTS public.store_receipt_patterns ( + pattern_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + store_id BIGINT NOT NULL REFERENCES public.stores(store_id) ON DELETE CASCADE, + pattern_type TEXT NOT NULL, + pattern_value TEXT NOT NULL, + priority INTEGER DEFAULT 0, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT now() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() NOT NULL, + -- Validate pattern type + CONSTRAINT store_receipt_patterns_type_check CHECK (pattern_type IN ( + 'header_regex', 'footer_regex', 'phone_number', 'address_fragment', 'store_number_format' + )), + -- Validate pattern is not empty + CONSTRAINT store_receipt_patterns_value_check CHECK (TRIM(pattern_value) <> ''), + -- Unique constraint per store/type/value + UNIQUE(store_id, pattern_type, pattern_value) +); +COMMENT ON TABLE public.store_receipt_patterns IS 'Patterns to help identify stores from receipt text and format.'; +COMMENT ON COLUMN public.store_receipt_patterns.pattern_type IS 'Type of pattern: header_regex, footer_regex, phone_number, etc.'; +COMMENT ON COLUMN public.store_receipt_patterns.pattern_value IS 'The actual pattern (regex or literal text).'; +COMMENT ON COLUMN public.store_receipt_patterns.priority IS 'Higher priority patterns are checked first.'; +COMMENT ON COLUMN public.store_receipt_patterns.is_active IS 'Whether this pattern is currently in use.'; + +-- Indexes for store_receipt_patterns +CREATE INDEX IF NOT EXISTS idx_store_receipt_patterns_store_id ON public.store_receipt_patterns(store_id); +CREATE INDEX IF NOT EXISTS idx_store_receipt_patterns_active ON public.store_receipt_patterns(pattern_type, is_active, priority DESC) + WHERE is_active = TRUE; diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..db9aa2d --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,152 @@ +// src/components/ErrorBoundary.tsx +/** + * React Error Boundary with Sentry integration. + * Implements ADR-015: Application Performance Monitoring and Error Tracking. + * + * This component catches JavaScript errors anywhere in the child component tree, + * logs them to Sentry/Bugsink, and displays a fallback UI instead of crashing. + */ +import { Component, ReactNode } from 'react'; +import { Sentry, captureException, isSentryConfigured } from '../services/sentry.client'; + +interface ErrorBoundaryProps { + /** Child components to render */ + children: ReactNode; + /** Optional custom fallback UI. If not provided, uses default error message. */ + fallback?: ReactNode; + /** Optional callback when an error is caught */ + onError?: (error: Error, errorInfo: React.ErrorInfo) => void; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; + eventId: string | null; +} + +/** + * Error Boundary component that catches React component errors + * and reports them to Sentry/Bugsink. + * + * @example + * ```tsx + * Something went wrong.

}> + * + *
+ * ``` + */ +export class ErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { + hasError: false, + error: null, + eventId: null, + }; + } + + static getDerivedStateFromError(error: Error): Partial { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { + // Log to console in development + console.error('ErrorBoundary caught an error:', error, errorInfo); + + // Report to Sentry with component stack + const eventId = captureException(error, { + componentStack: errorInfo.componentStack, + }); + + this.setState({ eventId: eventId ?? null }); + + // Call optional onError callback + this.props.onError?.(error, errorInfo); + } + + handleReload = (): void => { + window.location.reload(); + }; + + handleReportFeedback = (): void => { + if (isSentryConfigured && this.state.eventId) { + // Open Sentry feedback dialog if available + Sentry.showReportDialog({ eventId: this.state.eventId }); + } + }; + + render(): ReactNode { + if (this.state.hasError) { + // Custom fallback UI if provided + if (this.props.fallback) { + return this.props.fallback; + } + + // Default fallback UI + return ( +
+
+
+ +
+

+ Something went wrong +

+

+ We're sorry, but an unexpected error occurred. Our team has been notified. +

+
+ + {isSentryConfigured && this.state.eventId && ( + + )} +
+ {this.state.error && process.env.NODE_ENV === 'development' && ( +
+ + Error Details (Development Only) + +
+                  {this.state.error.message}
+                  {'\n\n'}
+                  {this.state.error.stack}
+                
+
+ )} +
+
+ ); + } + + return this.props.children; + } +} + +/** + * Pre-configured Sentry ErrorBoundary from @sentry/react. + * Use this for simpler integration when you don't need custom UI. + */ +export const SentryErrorBoundary = Sentry.ErrorBoundary; diff --git a/src/config.ts b/src/config.ts index d78cfaa..74126ec 100644 --- a/src/config.ts +++ b/src/config.ts @@ -14,6 +14,16 @@ const config = { google: { mapsEmbedApiKey: import.meta.env.VITE_GOOGLE_MAPS_EMBED_API_KEY, }, + /** + * Sentry/Bugsink error tracking configuration (ADR-015). + * Uses VITE_ prefix for client-side environment variables. + */ + sentry: { + dsn: import.meta.env.VITE_SENTRY_DSN, + environment: import.meta.env.VITE_SENTRY_ENVIRONMENT || import.meta.env.MODE, + debug: import.meta.env.VITE_SENTRY_DEBUG === 'true', + enabled: import.meta.env.VITE_SENTRY_ENABLED !== 'false', + }, }; export default config; diff --git a/src/config/env.ts b/src/config/env.ts index 0e65e04..b106c71 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -94,6 +94,15 @@ const aiSchema = z.object({ priceQualityThreshold: floatWithDefault(0.5), }); +/** + * UPC API configuration schema. + * External APIs for product lookup by barcode. + */ +const upcSchema = z.object({ + upcItemDbApiKey: z.string().optional(), // UPC Item DB API key (upcitemdb.com) + barcodeLookupApiKey: z.string().optional(), // Barcode Lookup API key (barcodelookup.com) +}); + /** * Google services configuration schema. */ @@ -126,6 +135,17 @@ const serverSchema = z.object({ storagePath: z.string().default('/var/www/flyer-crawler.projectium.com/flyer-images'), }); +/** + * Error tracking configuration schema (ADR-015). + * Uses Bugsink (Sentry-compatible self-hosted error tracking). + */ +const sentrySchema = z.object({ + dsn: z.string().optional(), // Sentry DSN for backend + enabled: booleanString(true), + environment: z.string().optional(), + debug: booleanString(false), +}); + /** * Complete environment configuration schema. */ @@ -135,9 +155,11 @@ const envSchema = z.object({ auth: authSchema, smtp: smtpSchema, ai: aiSchema, + upc: upcSchema, google: googleSchema, worker: workerSchema, server: serverSchema, + sentry: sentrySchema, }); export type EnvConfig = z.infer; @@ -178,6 +200,10 @@ function loadEnvVars(): unknown { geminiRpm: process.env.GEMINI_RPM, priceQualityThreshold: process.env.AI_PRICE_QUALITY_THRESHOLD, }, + upc: { + upcItemDbApiKey: process.env.UPC_ITEM_DB_API_KEY, + barcodeLookupApiKey: process.env.BARCODE_LOOKUP_API_KEY, + }, google: { mapsApiKey: process.env.GOOGLE_MAPS_API_KEY, clientId: process.env.GOOGLE_CLIENT_ID, @@ -198,6 +224,12 @@ function loadEnvVars(): unknown { baseUrl: process.env.BASE_URL, storagePath: process.env.STORAGE_PATH, }, + sentry: { + dsn: process.env.SENTRY_DSN, + enabled: process.env.SENTRY_ENABLED, + environment: process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV, + debug: process.env.SENTRY_DEBUG, + }, }; } @@ -301,3 +333,18 @@ export const isAiConfigured = !!config.ai.geminiApiKey; * Returns true if Google Maps is configured. */ export const isGoogleMapsConfigured = !!config.google.mapsApiKey; + +/** + * Returns true if Sentry/Bugsink error tracking is configured and enabled. + */ +export const isSentryConfigured = !!config.sentry.dsn && config.sentry.enabled; + +/** + * Returns true if UPC Item DB API is configured. + */ +export const isUpcItemDbConfigured = !!config.upc.upcItemDbApiKey; + +/** + * Returns true if Barcode Lookup API is configured. + */ +export const isBarcodeLookupConfigured = !!config.upc.barcodeLookupApiKey; diff --git a/src/routes/passport.routes.test.ts b/src/config/passport.test.ts similarity index 99% rename from src/routes/passport.routes.test.ts rename to src/config/passport.test.ts index a0f37de..d6a63f7 100644 --- a/src/routes/passport.routes.test.ts +++ b/src/config/passport.test.ts @@ -1,4 +1,4 @@ -// src/routes/passport.routes.test.ts +// src/config/passport.test.ts import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest'; import * as bcrypt from 'bcrypt'; import { Request, Response, NextFunction } from 'express'; @@ -101,7 +101,7 @@ vi.mock('passport', () => { }); // Now, import the passport configuration which will use our mocks -import passport, { isAdmin, optionalAuth, mockAuth } from './passport.routes'; +import passport, { isAdmin, optionalAuth, mockAuth } from './passport'; import { logger } from '../services/logger.server'; import { ForbiddenError } from '../services/db/errors.db'; diff --git a/src/routes/passport.routes.ts b/src/config/passport.ts similarity index 99% rename from src/routes/passport.routes.ts rename to src/config/passport.ts index 66fc788..866ff38 100644 --- a/src/routes/passport.routes.ts +++ b/src/config/passport.ts @@ -1,4 +1,4 @@ -// src/routes/passport.routes.ts +// src/config/passport.ts import passport from 'passport'; // All route handlers now use req.log (request-scoped logger) as per ADR-004 import { Strategy as LocalStrategy } from 'passport-local'; diff --git a/src/config/workerOptions.ts b/src/config/workerOptions.ts new file mode 100644 index 0000000..419dcf2 --- /dev/null +++ b/src/config/workerOptions.ts @@ -0,0 +1,18 @@ +import { WorkerOptions } from 'bullmq'; + +/** + * Standard worker options for stall detection and recovery. + * Defined in ADR-053. + * + * Note: This is a partial configuration that must be spread into a full + * WorkerOptions object along with a `connection` property when creating workers. + */ +export const defaultWorkerOptions: Omit = { + // Check for stalled jobs every 30 seconds + stalledInterval: 30000, + // Fail job after 3 stalls (prevents infinite loops causing infinite retries) + maxStalledCount: 3, + // Duration of the lock for the job in milliseconds. + // If the worker doesn't renew this (e.g. crash), the job stalls. + lockDuration: 30000, +}; diff --git a/src/index.tsx b/src/index.tsx index 832ed94..048ac98 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,12 @@ // src/index.tsx +/** + * IMPORTANT: Sentry initialization MUST happen before any other imports + * to ensure all errors are captured, including those in imported modules. + * See ADR-015: Application Performance Monitoring and Error Tracking. + */ +import { initSentry } from './services/sentry.client'; +initSentry(); + import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; diff --git a/src/middleware/multer.middleware.ts b/src/middleware/multer.middleware.ts index 4ff7d91..e64f46b 100644 --- a/src/middleware/multer.middleware.ts +++ b/src/middleware/multer.middleware.ts @@ -11,12 +11,17 @@ import { logger } from '../services/logger.server'; export const flyerStoragePath = process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images'; export const avatarStoragePath = path.join(process.cwd(), 'public', 'uploads', 'avatars'); +export const receiptStoragePath = path.join( + process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com', + 'receipts', +); // Ensure directories exist at startup (async () => { try { await fs.mkdir(flyerStoragePath, { recursive: true }); await fs.mkdir(avatarStoragePath, { recursive: true }); + await fs.mkdir(receiptStoragePath, { recursive: true }); logger.info('Ensured multer storage directories exist.'); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); @@ -24,7 +29,7 @@ export const avatarStoragePath = path.join(process.cwd(), 'public', 'uploads', ' } })(); -type StorageType = 'flyer' | 'avatar'; +type StorageType = 'flyer' | 'avatar' | 'receipt'; const getStorageConfig = (type: StorageType) => { switch (type) { @@ -47,6 +52,17 @@ const getStorageConfig = (type: StorageType) => { cb(null, uniqueSuffix); }, }); + case 'receipt': + return multer.diskStorage({ + destination: (req, file, cb) => cb(null, receiptStoragePath), + filename: (req, file, cb) => { + const user = req.user as UserProfile | undefined; + const userId = user?.user.user_id || 'anonymous'; + const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`; + const sanitizedOriginalName = sanitizeFilename(file.originalname); + cb(null, `receipt-${userId}-${uniqueSuffix}-${sanitizedOriginalName}`); + }, + }); case 'flyer': default: return multer.diskStorage({ diff --git a/src/providers/AppProviders.tsx b/src/providers/AppProviders.tsx index 561a299..e934558 100644 --- a/src/providers/AppProviders.tsx +++ b/src/providers/AppProviders.tsx @@ -8,6 +8,7 @@ import { FlyersProvider } from './FlyersProvider'; import { MasterItemsProvider } from './MasterItemsProvider'; import { ModalProvider } from './ModalProvider'; import { UserDataProvider } from './UserDataProvider'; +import { ErrorBoundary } from '../components/ErrorBoundary'; interface AppProvidersProps { children: ReactNode; @@ -18,6 +19,7 @@ interface AppProvidersProps { * This cleans up index.tsx and makes the provider hierarchy clear. * * Provider hierarchy (from outermost to innermost): + * 0. ErrorBoundary - Catches React errors and reports to Sentry (ADR-015) * 1. QueryClientProvider - TanStack Query for server state management (ADR-0005) * 2. ModalProvider - Modal state management * 3. AuthProvider - Authentication state @@ -27,18 +29,20 @@ interface AppProvidersProps { */ export const AppProviders: React.FC = ({ children }) => { return ( - - - - - - {children} - - - - - {/* React Query Devtools - only visible in development */} - {import.meta.env.DEV && } - + + + + + + + {children} + + + + + {/* React Query Devtools - only visible in development */} + {import.meta.env.DEV && } + + ); }; diff --git a/src/routes/admin.routes.ts b/src/routes/admin.routes.ts index 38f319f..a2873b4 100644 --- a/src/routes/admin.routes.ts +++ b/src/routes/admin.routes.ts @@ -1,7 +1,6 @@ // src/routes/admin.routes.ts import { Router, NextFunction, Request, Response } from 'express'; -import passport from './passport.routes'; -import { isAdmin } from './passport.routes'; // Correctly imported +import passport, { isAdmin } from '../config/passport'; import { z } from 'zod'; import * as db from '../services/db/index.db'; diff --git a/src/routes/ai.routes.ts b/src/routes/ai.routes.ts index 547f564..485432a 100644 --- a/src/routes/ai.routes.ts +++ b/src/routes/ai.routes.ts @@ -3,9 +3,7 @@ import { Router, Request, Response, NextFunction } from 'express'; // All route handlers now use req.log (request-scoped logger) as per ADR-004 import { z } from 'zod'; -import passport from './passport.routes'; -// All route handlers now use req.log (request-scoped logger) as per ADR-004 -import { optionalAuth } from './passport.routes'; +import passport, { optionalAuth } from '../config/passport'; // All route handlers now use req.log (request-scoped logger) as per ADR-004 import { aiService, DuplicateFlyerError } from '../services/aiService.server'; // All route handlers now use req.log (request-scoped logger) as per ADR-004 @@ -179,8 +177,41 @@ router.use((req: Request, res: Response, next: NextFunction) => { }); /** - * NEW ENDPOINT: Accepts a single flyer file (PDF or image), enqueues it for - * background processing, and immediately returns a job ID. + * @openapi + * /ai/upload-and-process: + * post: + * tags: [AI] + * summary: Upload and process flyer + * description: Accepts a single flyer file (PDF or image), enqueues it for background processing, and immediately returns a job ID. + * requestBody: + * required: true + * content: + * multipart/form-data: + * schema: + * type: object + * required: + * - flyerFile + * - checksum + * properties: + * flyerFile: + * type: string + * format: binary + * description: Flyer file (PDF or image) + * checksum: + * type: string + * pattern: ^[a-f0-9]{64}$ + * description: SHA-256 checksum of the file + * baseUrl: + * type: string + * format: uri + * description: Optional base URL + * responses: + * 202: + * description: Flyer accepted for processing + * 400: + * description: Missing file or invalid checksum + * 409: + * description: Duplicate flyer detected */ router.post( '/upload-and-process', @@ -245,12 +276,37 @@ router.post( ); /** - * POST /api/ai/upload-legacy - Process a flyer upload from a legacy client. - * This is an authenticated route that processes the flyer synchronously. - * This is used for integration testing the legacy upload flow. - * - * @deprecated Use POST /api/ai/upload-and-process instead for async queue-based processing (ADR-0006). - * This synchronous endpoint is retained only for integration testing purposes. + * @openapi + * /ai/upload-legacy: + * post: + * tags: [AI] + * summary: Legacy flyer upload (deprecated) + * description: Process a flyer upload synchronously. Deprecated - use /upload-and-process instead. + * deprecated: true + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * multipart/form-data: + * schema: + * type: object + * required: + * - flyerFile + * properties: + * flyerFile: + * type: string + * format: binary + * description: Flyer file (PDF or image) + * responses: + * 200: + * description: Flyer processed successfully + * 400: + * description: No flyer file uploaded + * 401: + * description: Unauthorized + * 409: + * description: Duplicate flyer detected */ router.post( '/upload-legacy', @@ -282,7 +338,24 @@ router.post( ); /** - * NEW ENDPOINT: Checks the status of a background job. + * @openapi + * /ai/jobs/{jobId}/status: + * get: + * tags: [AI] + * summary: Check job status + * description: Checks the status of a background flyer processing job. + * parameters: + * - in: path + * name: jobId + * required: true + * schema: + * type: string + * description: Job ID returned from upload-and-process + * responses: + * 200: + * description: Job status information + * 404: + * description: Job not found */ router.get( '/jobs/:jobId/status', @@ -304,12 +377,33 @@ router.get( ); /** - * POST /api/ai/flyers/process - Saves the processed flyer data to the database. - * This is the final step in the flyer upload workflow after the AI has extracted the data. - * It uses `optionalAuth` to handle submissions from both anonymous and authenticated users. - * - * @deprecated Use POST /api/ai/upload-and-process instead for async queue-based processing (ADR-0006). - * This synchronous endpoint processes flyers inline and should be migrated to the queue-based approach. + * @openapi + * /ai/flyers/process: + * post: + * tags: [AI] + * summary: Process flyer data (deprecated) + * description: Saves processed flyer data to the database. Deprecated - use /upload-and-process instead. + * deprecated: true + * requestBody: + * required: true + * content: + * multipart/form-data: + * schema: + * type: object + * required: + * - flyerImage + * properties: + * flyerImage: + * type: string + * format: binary + * description: Flyer image file + * responses: + * 201: + * description: Flyer processed and saved successfully + * 400: + * description: Flyer image file is required + * 409: + * description: Duplicate flyer detected */ router.post( '/flyers/process', @@ -348,8 +442,30 @@ router.post( ); /** - * This endpoint checks if an image is a flyer. It uses `optionalAuth` to allow - * both authenticated and anonymous users to perform this check. + * @openapi + * /ai/check-flyer: + * post: + * tags: [AI] + * summary: Check if image is a flyer + * description: Analyzes an image to determine if it's a grocery store flyer. + * requestBody: + * required: true + * content: + * multipart/form-data: + * schema: + * type: object + * required: + * - image + * properties: + * image: + * type: string + * format: binary + * description: Image file to check + * responses: + * 200: + * description: Flyer check result + * 400: + * description: Image file is required */ router.post( '/check-flyer', @@ -371,6 +487,32 @@ router.post( }, ); +/** + * @openapi + * /ai/extract-address: + * post: + * tags: [AI] + * summary: Extract address from image + * description: Extracts store address information from a flyer image. + * requestBody: + * required: true + * content: + * multipart/form-data: + * schema: + * type: object + * required: + * - image + * properties: + * image: + * type: string + * format: binary + * description: Image file to extract address from + * responses: + * 200: + * description: Extracted address information + * 400: + * description: Image file is required + */ router.post( '/extract-address', aiUploadLimiter, @@ -391,6 +533,34 @@ router.post( }, ); +/** + * @openapi + * /ai/extract-logo: + * post: + * tags: [AI] + * summary: Extract store logo + * description: Extracts store logo from flyer images. + * requestBody: + * required: true + * content: + * multipart/form-data: + * schema: + * type: object + * required: + * - images + * properties: + * images: + * type: array + * items: + * type: string + * format: binary + * description: Image files to extract logo from + * responses: + * 200: + * description: Extracted logo as base64 + * 400: + * description: Image files are required + */ router.post( '/extract-logo', aiUploadLimiter, @@ -411,6 +581,36 @@ router.post( }, ); +/** + * @openapi + * /ai/quick-insights: + * post: + * tags: [AI] + * summary: Get quick insights + * description: Get AI-generated quick insights about flyer items. + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - items + * properties: + * items: + * type: array + * items: + * type: object + * minItems: 1 + * description: List of flyer items to analyze + * responses: + * 200: + * description: AI-generated quick insights + * 401: + * description: Unauthorized + */ router.post( '/quick-insights', aiGenerationLimiter, @@ -426,6 +626,36 @@ router.post( }, ); +/** + * @openapi + * /ai/deep-dive: + * post: + * tags: [AI] + * summary: Get deep dive analysis + * description: Get detailed AI-generated analysis about flyer items. + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - items + * properties: + * items: + * type: array + * items: + * type: object + * minItems: 1 + * description: List of flyer items to analyze + * responses: + * 200: + * description: Detailed AI analysis + * 401: + * description: Unauthorized + */ router.post( '/deep-dive', aiGenerationLimiter, @@ -443,6 +673,33 @@ router.post( }, ); +/** + * @openapi + * /ai/search-web: + * post: + * tags: [AI] + * summary: Search web for information + * description: Search the web for product or deal information. + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - query + * properties: + * query: + * type: string + * description: Search query + * responses: + * 200: + * description: Search results with sources + * 401: + * description: Unauthorized + */ router.post( '/search-web', aiGenerationLimiter, @@ -458,6 +715,36 @@ router.post( }, ); +/** + * @openapi + * /ai/compare-prices: + * post: + * tags: [AI] + * summary: Compare prices across stores + * description: Compare prices for items across different stores. + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - items + * properties: + * items: + * type: array + * items: + * type: object + * minItems: 1 + * description: List of items to compare + * responses: + * 200: + * description: Price comparison results + * 401: + * description: Unauthorized + */ router.post( '/compare-prices', aiGenerationLimiter, @@ -477,6 +764,59 @@ router.post( }, ); +/** + * @openapi + * /ai/plan-trip: + * post: + * tags: [AI] + * summary: Plan shopping trip + * description: Plan an optimized shopping trip to a store based on items and location. + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - items + * - store + * - userLocation + * properties: + * items: + * type: array + * items: + * type: object + * description: List of items to buy + * store: + * type: object + * required: + * - name + * properties: + * name: + * type: string + * description: Store name + * userLocation: + * type: object + * required: + * - latitude + * - longitude + * properties: + * latitude: + * type: number + * minimum: -90 + * maximum: 90 + * longitude: + * type: number + * minimum: -180 + * maximum: 180 + * responses: + * 200: + * description: Trip plan with directions + * 401: + * description: Unauthorized + */ router.post( '/plan-trip', aiGenerationLimiter, @@ -497,6 +837,33 @@ router.post( // --- STUBBED AI Routes for Future Features --- +/** + * @openapi + * /ai/generate-image: + * post: + * tags: [AI] + * summary: Generate image (not implemented) + * description: Generate an image from a prompt. Currently not implemented. + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - prompt + * properties: + * prompt: + * type: string + * description: Image generation prompt + * responses: + * 501: + * description: Not implemented + * 401: + * description: Unauthorized + */ router.post( '/generate-image', aiGenerationLimiter, @@ -510,6 +877,33 @@ router.post( }, ); +/** + * @openapi + * /ai/generate-speech: + * post: + * tags: [AI] + * summary: Generate speech (not implemented) + * description: Generate speech from text. Currently not implemented. + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - text + * properties: + * text: + * type: string + * description: Text to convert to speech + * responses: + * 501: + * description: Not implemented + * 401: + * description: Unauthorized + */ router.post( '/generate-speech', aiGenerationLimiter, @@ -524,8 +918,43 @@ router.post( ); /** - * POST /api/ai/rescan-area - Performs a targeted AI scan on a specific area of an image. - * Requires authentication. + * @openapi + * /ai/rescan-area: + * post: + * tags: [AI] + * summary: Rescan area of image + * description: Performs a targeted AI scan on a specific area of an image. + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * multipart/form-data: + * schema: + * type: object + * required: + * - image + * - cropArea + * - extractionType + * properties: + * image: + * type: string + * format: binary + * description: Image file to scan + * cropArea: + * type: string + * description: JSON string with x, y, width, height + * extractionType: + * type: string + * enum: [store_name, dates, item_details] + * description: Type of data to extract + * responses: + * 200: + * description: Extracted data from image area + * 400: + * description: Image file is required + * 401: + * description: Unauthorized */ router.post( '/rescan-area', diff --git a/src/routes/auth.routes.ts b/src/routes/auth.routes.ts index a596d49..4d56aff 100644 --- a/src/routes/auth.routes.ts +++ b/src/routes/auth.routes.ts @@ -3,7 +3,7 @@ import { Router, Request, Response, NextFunction } from 'express'; // All route handlers now use req.log (request-scoped logger) as per ADR-004 import { z } from 'zod'; -import passport from './passport.routes'; +import passport from '../config/passport'; // All route handlers now use req.log (request-scoped logger) as per ADR-004 import { UniqueConstraintError } from '../services/db/errors.db'; // Import actual class for instanceof checks // Removed: import { logger } from '../services/logger.server'; diff --git a/src/routes/budget.routes.ts b/src/routes/budget.routes.ts index 21f6839..3d208d1 100644 --- a/src/routes/budget.routes.ts +++ b/src/routes/budget.routes.ts @@ -1,7 +1,7 @@ // src/routes/budget.ts import express, { Request, Response, NextFunction } from 'express'; import { z } from 'zod'; -import passport from './passport.routes'; +import passport from '../config/passport'; import { budgetRepo } from '../services/db/index.db'; import type { UserProfile } from '../types'; import { validateRequest } from '../middleware/validation.middleware'; diff --git a/src/routes/deals.routes.ts b/src/routes/deals.routes.ts index 2a2f998..07092d3 100644 --- a/src/routes/deals.routes.ts +++ b/src/routes/deals.routes.ts @@ -1,7 +1,7 @@ // src/routes/deals.routes.ts import express, { type Request, type Response, type NextFunction } from 'express'; import { z } from 'zod'; -import passport from './passport.routes'; +import passport from '../config/passport'; import { dealsRepo } from '../services/db/deals.db'; import type { UserProfile } from '../types'; import { validateRequest } from '../middleware/validation.middleware'; diff --git a/src/routes/gamification.routes.ts b/src/routes/gamification.routes.ts index 5e3caf8..995226f 100644 --- a/src/routes/gamification.routes.ts +++ b/src/routes/gamification.routes.ts @@ -2,7 +2,7 @@ import express, { NextFunction } from 'express'; // All route handlers now use req.log (request-scoped logger) as per ADR-004 import { z } from 'zod'; -import passport, { isAdmin } from './passport.routes'; // Correctly imported +import passport, { isAdmin } from '../config/passport'; // All route handlers now use req.log (request-scoped logger) as per ADR-004 import { gamificationService } from '../services/gamificationService'; // Removed: import { logger } from '../services/logger.server'; diff --git a/src/routes/inventory.routes.test.ts b/src/routes/inventory.routes.test.ts new file mode 100644 index 0000000..896361e --- /dev/null +++ b/src/routes/inventory.routes.test.ts @@ -0,0 +1,664 @@ +// src/routes/inventory.routes.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import supertest from 'supertest'; +import type { Request, Response, NextFunction } from 'express'; +import { createMockUserProfile } from '../tests/utils/mockFactories'; +import { createTestApp } from '../tests/utils/createTestApp'; +import { NotFoundError } from '../services/db/errors.db'; +import type { UserInventoryItem, ExpiringItemsResponse } from '../types/expiry'; + +// Mock the expiryService module +vi.mock('../services/expiryService.server', () => ({ + getInventory: vi.fn(), + addInventoryItem: vi.fn(), + getInventoryItemById: vi.fn(), + updateInventoryItem: vi.fn(), + deleteInventoryItem: vi.fn(), + markItemConsumed: vi.fn(), + getExpiringItemsGrouped: vi.fn(), + getExpiringItems: vi.fn(), + getExpiredItems: vi.fn(), + getAlertSettings: vi.fn(), + updateAlertSettings: vi.fn(), + getRecipeSuggestionsForExpiringItems: vi.fn(), +})); + +// Mock the logger to keep test output clean +vi.mock('../services/logger.server', async () => ({ + logger: (await import('../tests/utils/mockLogger')).mockLogger, +})); + +// Import the router and mocked service AFTER all mocks are defined. +import inventoryRouter from './inventory.routes'; +import * as expiryService from '../services/expiryService.server'; + +const mockUser = createMockUserProfile({ + user: { user_id: 'user-123', email: 'test@test.com' }, +}); + +// Standardized mock for passport +vi.mock('../config/passport', () => ({ + default: { + authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => { + req.user = mockUser; + next(); + }), + initialize: () => (req: Request, res: Response, next: NextFunction) => next(), + }, +})); + +// Define a reusable matcher for the logger object. +const expectLogger = expect.objectContaining({ + info: expect.any(Function), + error: expect.any(Function), +}); + +// Helper to create mock inventory item +function createMockInventoryItem(overrides: Partial = {}): UserInventoryItem { + return { + inventory_id: 1, + user_id: 'user-123', + product_id: null, + master_item_id: 100, + item_name: 'Milk', + quantity: 1, + unit: 'liters', + purchase_date: '2024-01-10', + expiry_date: '2024-02-10', + source: 'manual', + location: 'fridge', + notes: null, + is_consumed: false, + consumed_at: null, + expiry_source: 'manual', + receipt_item_id: null, + pantry_location_id: 1, + notification_sent_at: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + days_until_expiry: 10, + expiry_status: 'fresh', + ...overrides, + }; +} + +describe('Inventory Routes (/api/inventory)', () => { + const mockUserProfile = createMockUserProfile({ + user: { user_id: 'user-123', email: 'test@test.com' }, + }); + + beforeEach(() => { + vi.clearAllMocks(); + // Provide default mock implementations + vi.mocked(expiryService.getInventory).mockResolvedValue({ items: [], total: 0 }); + vi.mocked(expiryService.getExpiringItems).mockResolvedValue([]); + vi.mocked(expiryService.getExpiredItems).mockResolvedValue([]); + vi.mocked(expiryService.getAlertSettings).mockResolvedValue([]); + }); + + const app = createTestApp({ + router: inventoryRouter, + basePath: '/api/inventory', + authenticatedUser: mockUserProfile, + }); + + // ============================================================================ + // INVENTORY ITEM ENDPOINTS + // ============================================================================ + + describe('GET /', () => { + it('should return paginated inventory items', async () => { + const mockItems = [createMockInventoryItem()]; + vi.mocked(expiryService.getInventory).mockResolvedValue({ + items: mockItems, + total: 1, + }); + + const response = await supertest(app).get('/api/inventory'); + + expect(response.status).toBe(200); + expect(response.body.data.items).toHaveLength(1); + expect(response.body.data.total).toBe(1); + }); + + it('should support filtering by location', async () => { + vi.mocked(expiryService.getInventory).mockResolvedValue({ items: [], total: 0 }); + + const response = await supertest(app).get('/api/inventory?location=fridge'); + + expect(response.status).toBe(200); + expect(expiryService.getInventory).toHaveBeenCalledWith( + expect.objectContaining({ location: 'fridge' }), + expectLogger, + ); + }); + + it('should support filtering by expiring_within_days', async () => { + vi.mocked(expiryService.getInventory).mockResolvedValue({ items: [], total: 0 }); + + const response = await supertest(app).get('/api/inventory?expiring_within_days=7'); + + expect(response.status).toBe(200); + expect(expiryService.getInventory).toHaveBeenCalledWith( + expect.objectContaining({ expiring_within_days: 7 }), + expectLogger, + ); + }); + + it('should support search filter', async () => { + vi.mocked(expiryService.getInventory).mockResolvedValue({ items: [], total: 0 }); + + const response = await supertest(app).get('/api/inventory?search=milk'); + + expect(response.status).toBe(200); + expect(expiryService.getInventory).toHaveBeenCalledWith( + expect.objectContaining({ search: 'milk' }), + expectLogger, + ); + }); + + it('should support sorting', async () => { + vi.mocked(expiryService.getInventory).mockResolvedValue({ items: [], total: 0 }); + + const response = await supertest(app).get( + '/api/inventory?sort_by=expiry_date&sort_order=asc', + ); + + expect(response.status).toBe(200); + expect(expiryService.getInventory).toHaveBeenCalledWith( + expect.objectContaining({ + sort_by: 'expiry_date', + sort_order: 'asc', + }), + expectLogger, + ); + }); + + it('should return 400 for invalid location', async () => { + const response = await supertest(app).get('/api/inventory?location=invalid'); + + expect(response.status).toBe(400); + }); + + it('should return 500 if service fails', async () => { + vi.mocked(expiryService.getInventory).mockRejectedValue(new Error('DB Error')); + + const response = await supertest(app).get('/api/inventory'); + + expect(response.status).toBe(500); + }); + }); + + describe('POST /', () => { + it('should add a new inventory item', async () => { + const mockItem = createMockInventoryItem(); + vi.mocked(expiryService.addInventoryItem).mockResolvedValue(mockItem); + + const response = await supertest(app).post('/api/inventory').send({ + item_name: 'Milk', + source: 'manual', + quantity: 1, + location: 'fridge', + expiry_date: '2024-02-10', + }); + + expect(response.status).toBe(201); + expect(response.body.data.item_name).toBe('Milk'); + expect(expiryService.addInventoryItem).toHaveBeenCalledWith( + mockUserProfile.user.user_id, + expect.objectContaining({ + item_name: 'Milk', + source: 'manual', + }), + expectLogger, + ); + }); + + it('should return 400 if item_name is missing', async () => { + const response = await supertest(app).post('/api/inventory').send({ + source: 'manual', + }); + + expect(response.status).toBe(400); + expect(response.body.error.details[0].message).toMatch(/Item name/i); + }); + + it('should return 400 for invalid source', async () => { + const response = await supertest(app).post('/api/inventory').send({ + item_name: 'Milk', + source: 'invalid_source', + }); + + expect(response.status).toBe(400); + }); + + it('should return 400 for invalid expiry_date format', async () => { + const response = await supertest(app).post('/api/inventory').send({ + item_name: 'Milk', + source: 'manual', + expiry_date: '01-10-2024', + }); + + expect(response.status).toBe(400); + expect(response.body.error.details[0].message).toMatch(/YYYY-MM-DD/); + }); + + it('should return 500 if service fails', async () => { + vi.mocked(expiryService.addInventoryItem).mockRejectedValue(new Error('DB Error')); + + const response = await supertest(app).post('/api/inventory').send({ + item_name: 'Milk', + source: 'manual', + }); + + expect(response.status).toBe(500); + }); + }); + + describe('GET /:inventoryId', () => { + it('should return a specific inventory item', async () => { + const mockItem = createMockInventoryItem(); + vi.mocked(expiryService.getInventoryItemById).mockResolvedValue(mockItem); + + const response = await supertest(app).get('/api/inventory/1'); + + expect(response.status).toBe(200); + expect(response.body.data.inventory_id).toBe(1); + expect(expiryService.getInventoryItemById).toHaveBeenCalledWith( + 1, + mockUserProfile.user.user_id, + expectLogger, + ); + }); + + it('should return 404 when item not found', async () => { + vi.mocked(expiryService.getInventoryItemById).mockRejectedValue( + new NotFoundError('Item not found'), + ); + + const response = await supertest(app).get('/api/inventory/999'); + + expect(response.status).toBe(404); + }); + + it('should return 400 for invalid inventory ID', async () => { + const response = await supertest(app).get('/api/inventory/abc'); + + expect(response.status).toBe(400); + }); + }); + + describe('PUT /:inventoryId', () => { + it('should update an inventory item', async () => { + const mockItem = createMockInventoryItem({ quantity: 2 }); + vi.mocked(expiryService.updateInventoryItem).mockResolvedValue(mockItem); + + const response = await supertest(app).put('/api/inventory/1').send({ + quantity: 2, + }); + + expect(response.status).toBe(200); + expect(response.body.data.quantity).toBe(2); + }); + + it('should update expiry_date', async () => { + const mockItem = createMockInventoryItem({ expiry_date: '2024-03-01' }); + vi.mocked(expiryService.updateInventoryItem).mockResolvedValue(mockItem); + + const response = await supertest(app).put('/api/inventory/1').send({ + expiry_date: '2024-03-01', + }); + + expect(response.status).toBe(200); + expect(expiryService.updateInventoryItem).toHaveBeenCalledWith( + 1, + mockUserProfile.user.user_id, + expect.objectContaining({ expiry_date: '2024-03-01' }), + expectLogger, + ); + }); + + it('should return 400 if no update fields provided', async () => { + const response = await supertest(app).put('/api/inventory/1').send({}); + + expect(response.status).toBe(400); + expect(response.body.error.details[0].message).toMatch(/At least one field/); + }); + + it('should return 404 when item not found', async () => { + vi.mocked(expiryService.updateInventoryItem).mockRejectedValue( + new NotFoundError('Item not found'), + ); + + const response = await supertest(app).put('/api/inventory/999').send({ + quantity: 2, + }); + + expect(response.status).toBe(404); + }); + }); + + describe('DELETE /:inventoryId', () => { + it('should delete an inventory item', async () => { + vi.mocked(expiryService.deleteInventoryItem).mockResolvedValue(undefined); + + const response = await supertest(app).delete('/api/inventory/1'); + + expect(response.status).toBe(204); + expect(expiryService.deleteInventoryItem).toHaveBeenCalledWith( + 1, + mockUserProfile.user.user_id, + expectLogger, + ); + }); + + it('should return 404 when item not found', async () => { + vi.mocked(expiryService.deleteInventoryItem).mockRejectedValue( + new NotFoundError('Item not found'), + ); + + const response = await supertest(app).delete('/api/inventory/999'); + + expect(response.status).toBe(404); + }); + }); + + describe('POST /:inventoryId/consume', () => { + it('should mark item as consumed', async () => { + vi.mocked(expiryService.markItemConsumed).mockResolvedValue(undefined); + + const response = await supertest(app).post('/api/inventory/1/consume'); + + expect(response.status).toBe(204); + expect(expiryService.markItemConsumed).toHaveBeenCalledWith( + 1, + mockUserProfile.user.user_id, + expectLogger, + ); + }); + + it('should return 404 when item not found', async () => { + vi.mocked(expiryService.markItemConsumed).mockRejectedValue( + new NotFoundError('Item not found'), + ); + + const response = await supertest(app).post('/api/inventory/999/consume'); + + expect(response.status).toBe(404); + }); + }); + + // ============================================================================ + // EXPIRING ITEMS ENDPOINTS + // ============================================================================ + + describe('GET /expiring/summary', () => { + it('should return expiring items grouped by urgency', async () => { + const mockSummary: ExpiringItemsResponse = { + expiring_today: [createMockInventoryItem({ days_until_expiry: 0 })], + expiring_this_week: [createMockInventoryItem({ days_until_expiry: 3 })], + expiring_this_month: [createMockInventoryItem({ days_until_expiry: 20 })], + already_expired: [createMockInventoryItem({ days_until_expiry: -5 })], + counts: { + today: 1, + this_week: 1, + this_month: 1, + expired: 1, + total: 4, + }, + }; + + vi.mocked(expiryService.getExpiringItemsGrouped).mockResolvedValue(mockSummary); + + const response = await supertest(app).get('/api/inventory/expiring/summary'); + + expect(response.status).toBe(200); + expect(response.body.data.counts.total).toBe(4); + }); + + it('should return 500 if service fails', async () => { + vi.mocked(expiryService.getExpiringItemsGrouped).mockRejectedValue(new Error('DB Error')); + + const response = await supertest(app).get('/api/inventory/expiring/summary'); + + expect(response.status).toBe(500); + }); + }); + + describe('GET /expiring', () => { + it('should return items expiring within default 7 days', async () => { + const mockItems = [createMockInventoryItem({ days_until_expiry: 5 })]; + vi.mocked(expiryService.getExpiringItems).mockResolvedValue(mockItems); + + const response = await supertest(app).get('/api/inventory/expiring'); + + expect(response.status).toBe(200); + expect(response.body.data.items).toHaveLength(1); + expect(expiryService.getExpiringItems).toHaveBeenCalledWith( + mockUserProfile.user.user_id, + 7, + expectLogger, + ); + }); + + it('should accept custom days parameter', async () => { + vi.mocked(expiryService.getExpiringItems).mockResolvedValue([]); + + const response = await supertest(app).get('/api/inventory/expiring?days=14'); + + expect(response.status).toBe(200); + expect(expiryService.getExpiringItems).toHaveBeenCalledWith( + mockUserProfile.user.user_id, + 14, + expectLogger, + ); + }); + + it('should return 400 for invalid days parameter', async () => { + const response = await supertest(app).get('/api/inventory/expiring?days=100'); + + expect(response.status).toBe(400); + }); + }); + + describe('GET /expired', () => { + it('should return already expired items', async () => { + const mockItems = [ + createMockInventoryItem({ days_until_expiry: -3, expiry_status: 'expired' }), + ]; + vi.mocked(expiryService.getExpiredItems).mockResolvedValue(mockItems); + + const response = await supertest(app).get('/api/inventory/expired'); + + expect(response.status).toBe(200); + expect(response.body.data.items).toHaveLength(1); + expect(expiryService.getExpiredItems).toHaveBeenCalledWith( + mockUserProfile.user.user_id, + expectLogger, + ); + }); + + it('should return 500 if service fails', async () => { + vi.mocked(expiryService.getExpiredItems).mockRejectedValue(new Error('DB Error')); + + const response = await supertest(app).get('/api/inventory/expired'); + + expect(response.status).toBe(500); + }); + }); + + // ============================================================================ + // ALERT SETTINGS ENDPOINTS + // ============================================================================ + + describe('GET /alerts', () => { + it('should return user alert settings', async () => { + const mockSettings = [ + { + expiry_alert_id: 1, + user_id: 'user-123', + alert_method: 'email' as const, + days_before_expiry: 3, + is_enabled: true, + last_alert_sent_at: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ]; + + vi.mocked(expiryService.getAlertSettings).mockResolvedValue(mockSettings); + + const response = await supertest(app).get('/api/inventory/alerts'); + + expect(response.status).toBe(200); + expect(response.body.data).toHaveLength(1); + expect(response.body.data[0].alert_method).toBe('email'); + }); + + it('should return 500 if service fails', async () => { + vi.mocked(expiryService.getAlertSettings).mockRejectedValue(new Error('DB Error')); + + const response = await supertest(app).get('/api/inventory/alerts'); + + expect(response.status).toBe(500); + }); + }); + + describe('PUT /alerts/:alertMethod', () => { + it('should update alert settings for email', async () => { + const mockSettings = { + expiry_alert_id: 1, + user_id: 'user-123', + alert_method: 'email' as const, + days_before_expiry: 5, + is_enabled: true, + last_alert_sent_at: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + vi.mocked(expiryService.updateAlertSettings).mockResolvedValue(mockSettings); + + const response = await supertest(app).put('/api/inventory/alerts/email').send({ + days_before_expiry: 5, + is_enabled: true, + }); + + expect(response.status).toBe(200); + expect(response.body.data.days_before_expiry).toBe(5); + expect(expiryService.updateAlertSettings).toHaveBeenCalledWith( + mockUserProfile.user.user_id, + 'email', + { days_before_expiry: 5, is_enabled: true }, + expectLogger, + ); + }); + + it('should return 400 for invalid alert method', async () => { + const response = await supertest(app).put('/api/inventory/alerts/sms').send({ + is_enabled: true, + }); + + expect(response.status).toBe(400); + }); + + it('should return 400 for invalid days_before_expiry', async () => { + const response = await supertest(app).put('/api/inventory/alerts/email').send({ + days_before_expiry: 0, + }); + + expect(response.status).toBe(400); + }); + + it('should return 400 if days_before_expiry exceeds maximum', async () => { + const response = await supertest(app).put('/api/inventory/alerts/email').send({ + days_before_expiry: 31, + }); + + expect(response.status).toBe(400); + }); + + it('should return 500 if service fails', async () => { + vi.mocked(expiryService.updateAlertSettings).mockRejectedValue(new Error('DB Error')); + + const response = await supertest(app).put('/api/inventory/alerts/email').send({ + is_enabled: false, + }); + + expect(response.status).toBe(500); + }); + }); + + // ============================================================================ + // RECIPE SUGGESTIONS ENDPOINT + // ============================================================================ + + describe('GET /recipes/suggestions', () => { + it('should return recipe suggestions for expiring items', async () => { + const mockInventoryItem = createMockInventoryItem({ inventory_id: 1, item_name: 'Milk' }); + const mockResult = { + recipes: [ + { + recipe_id: 1, + recipe_name: 'Milk Smoothie', + description: 'A healthy smoothie', + prep_time_minutes: 5, + cook_time_minutes: 0, + servings: 2, + photo_url: null, + matching_items: [mockInventoryItem], + match_count: 1, + }, + ], + total: 1, + considered_items: [mockInventoryItem], + }; + + vi.mocked(expiryService.getRecipeSuggestionsForExpiringItems).mockResolvedValue( + mockResult as any, + ); + + const response = await supertest(app).get('/api/inventory/recipes/suggestions'); + + expect(response.status).toBe(200); + expect(response.body.data.recipes).toHaveLength(1); + expect(response.body.data.total).toBe(1); + }); + + it('should accept days, limit, and offset parameters', async () => { + vi.mocked(expiryService.getRecipeSuggestionsForExpiringItems).mockResolvedValue({ + recipes: [], + total: 0, + considered_items: [], + }); + + const response = await supertest(app).get( + '/api/inventory/recipes/suggestions?days=14&limit=5&offset=10', + ); + + expect(response.status).toBe(200); + expect(expiryService.getRecipeSuggestionsForExpiringItems).toHaveBeenCalledWith( + mockUserProfile.user.user_id, + 14, + expectLogger, + { limit: 5, offset: 10 }, + ); + }); + + it('should return 400 for invalid days parameter', async () => { + const response = await supertest(app).get('/api/inventory/recipes/suggestions?days=100'); + + expect(response.status).toBe(400); + }); + + it('should return 500 if service fails', async () => { + vi.mocked(expiryService.getRecipeSuggestionsForExpiringItems).mockRejectedValue( + new Error('DB Error'), + ); + + const response = await supertest(app).get('/api/inventory/recipes/suggestions'); + + expect(response.status).toBe(500); + }); + }); +}); diff --git a/src/routes/inventory.routes.ts b/src/routes/inventory.routes.ts new file mode 100644 index 0000000..d9c67fd --- /dev/null +++ b/src/routes/inventory.routes.ts @@ -0,0 +1,839 @@ +// src/routes/inventory.routes.ts +/** + * @file Inventory and Expiry Tracking API Routes + * Provides endpoints for managing pantry inventory, expiry tracking, and alerts. + */ +import express, { Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import passport from '../config/passport'; +import type { UserProfile } from '../types'; +import { validateRequest } from '../middleware/validation.middleware'; +import { numericIdParam, optionalNumeric } from '../utils/zodUtils'; +import { sendSuccess, sendNoContent } from '../utils/apiResponse'; +import * as expiryService from '../services/expiryService.server'; + +const router = express.Router(); + +// --- Zod Schemas for Inventory Routes --- + +/** + * Storage location validation + */ +const storageLocationSchema = z.enum(['fridge', 'freezer', 'pantry', 'room_temp']); + +/** + * Inventory source validation + */ +const inventorySourceSchema = z.enum(['manual', 'receipt_scan', 'upc_scan']); + +/** + * Alert method validation + */ +const alertMethodSchema = z.enum(['email', 'push', 'in_app']); + +/** + * Schema for inventory item ID parameter + */ +const inventoryIdParamSchema = numericIdParam( + 'inventoryId', + "Invalid ID for parameter 'inventoryId'. Must be a number.", +); + +/** + * Schema for adding an inventory item + */ +const addInventoryItemSchema = z.object({ + body: z.object({ + product_id: z.number().int().positive().optional(), + master_item_id: z.number().int().positive().optional(), + item_name: z.string().min(1, 'Item name is required.').max(255), + quantity: z.number().positive().default(1), + unit: z.string().max(50).optional(), + purchase_date: z.string().date('Purchase date must be in YYYY-MM-DD format.').optional(), + expiry_date: z.string().date('Expiry date must be in YYYY-MM-DD format.').optional(), + source: inventorySourceSchema, + location: storageLocationSchema.optional(), + notes: z.string().max(500).optional(), + }), +}); + +/** + * Schema for updating an inventory item + */ +const updateInventoryItemSchema = inventoryIdParamSchema.extend({ + body: z + .object({ + quantity: z.number().positive().optional(), + unit: z.string().max(50).optional(), + expiry_date: z.string().date('Expiry date must be in YYYY-MM-DD format.').optional(), + location: storageLocationSchema.optional(), + notes: z.string().max(500).optional(), + is_consumed: z.boolean().optional(), + }) + .refine((data) => Object.keys(data).length > 0, { + message: 'At least one field to update must be provided.', + }), +}); + +/** + * Schema for inventory query + */ +const inventoryQuerySchema = z.object({ + query: z.object({ + limit: optionalNumeric({ default: 50, min: 1, max: 100, integer: true }), + offset: optionalNumeric({ default: 0, min: 0, integer: true }), + location: storageLocationSchema.optional(), + is_consumed: z + .string() + .optional() + .transform((val) => (val === 'true' ? true : val === 'false' ? false : undefined)), + expiring_within_days: z + .string() + .optional() + .transform((val) => (val ? parseInt(val, 10) : undefined)) + .pipe(z.number().int().positive().optional()), + category_id: z + .string() + .optional() + .transform((val) => (val ? parseInt(val, 10) : undefined)) + .pipe(z.number().int().positive().optional()), + search: z.string().max(100).optional(), + sort_by: z.enum(['expiry_date', 'purchase_date', 'item_name', 'created_at']).optional(), + sort_order: z.enum(['asc', 'desc']).optional(), + }), +}); + +/** + * Schema for alert settings update + */ +const updateAlertSettingsSchema = z.object({ + params: z.object({ + alertMethod: alertMethodSchema, + }), + body: z.object({ + days_before_expiry: z.number().int().min(1).max(30).optional(), + is_enabled: z.boolean().optional(), + }), +}); + +/** + * Schema for days ahead parameter + */ +const daysAheadQuerySchema = z.object({ + query: z.object({ + days: z + .string() + .optional() + .default('7') + .transform((val) => parseInt(val, 10)) + .pipe(z.number().int().min(1).max(90)), + }), +}); + +// Middleware to ensure user is authenticated for all inventory routes +router.use(passport.authenticate('jwt', { session: false })); + +// ============================================================================ +// INVENTORY ITEM ENDPOINTS +// ============================================================================ + +/** + * @openapi + * /inventory: + * get: + * tags: [Inventory] + * summary: Get inventory items + * description: Retrieve the user's pantry inventory with optional filtering and pagination. + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: limit + * schema: + * type: integer + * minimum: 1 + * maximum: 100 + * default: 50 + * - in: query + * name: offset + * schema: + * type: integer + * minimum: 0 + * default: 0 + * - in: query + * name: location + * schema: + * type: string + * enum: [fridge, freezer, pantry, room_temp] + * - in: query + * name: is_consumed + * schema: + * type: boolean + * - in: query + * name: expiring_within_days + * schema: + * type: integer + * minimum: 1 + * - in: query + * name: category_id + * schema: + * type: integer + * - in: query + * name: search + * schema: + * type: string + * maxLength: 100 + * - in: query + * name: sort_by + * schema: + * type: string + * enum: [expiry_date, purchase_date, item_name, created_at] + * - in: query + * name: sort_order + * schema: + * type: string + * enum: [asc, desc] + * responses: + * 200: + * description: Inventory items retrieved + * 401: + * description: Unauthorized + */ +router.get( + '/', + validateRequest(inventoryQuerySchema), + async (req: Request, res: Response, next: NextFunction) => { + const userProfile = req.user as UserProfile; + type InventoryQueryRequest = z.infer; + const { query } = req as unknown as InventoryQueryRequest; + + try { + const result = await expiryService.getInventory( + { + user_id: userProfile.user.user_id, + location: query.location, + is_consumed: query.is_consumed, + expiring_within_days: query.expiring_within_days, + category_id: query.category_id, + search: query.search, + limit: query.limit, + offset: query.offset, + sort_by: query.sort_by, + sort_order: query.sort_order, + }, + req.log, + ); + sendSuccess(res, result); + } catch (error) { + req.log.error({ error, userId: userProfile.user.user_id }, 'Error fetching inventory'); + next(error); + } + }, +); + +/** + * @openapi + * /inventory: + * post: + * tags: [Inventory] + * summary: Add inventory item + * description: Add a new item to the user's pantry inventory. + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - item_name + * - source + * properties: + * product_id: + * type: integer + * master_item_id: + * type: integer + * item_name: + * type: string + * maxLength: 255 + * quantity: + * type: number + * minimum: 0 + * default: 1 + * unit: + * type: string + * maxLength: 50 + * purchase_date: + * type: string + * format: date + * expiry_date: + * type: string + * format: date + * source: + * type: string + * enum: [manual, receipt_scan, upc_scan] + * location: + * type: string + * enum: [fridge, freezer, pantry, room_temp] + * notes: + * type: string + * maxLength: 500 + * responses: + * 201: + * description: Item added to inventory + * 400: + * description: Validation error + * 401: + * description: Unauthorized + */ +router.post( + '/', + validateRequest(addInventoryItemSchema), + async (req: Request, res: Response, next: NextFunction) => { + const userProfile = req.user as UserProfile; + type AddItemRequest = z.infer; + const { body } = req as unknown as AddItemRequest; + + try { + req.log.info( + { userId: userProfile.user.user_id, itemName: body.item_name }, + 'Adding item to inventory', + ); + + const item = await expiryService.addInventoryItem(userProfile.user.user_id, body, req.log); + sendSuccess(res, item, 201); + } catch (error) { + req.log.error( + { error, userId: userProfile.user.user_id, body }, + 'Error adding inventory item', + ); + next(error); + } + }, +); + +/** + * @openapi + * /inventory/{inventoryId}: + * get: + * tags: [Inventory] + * summary: Get inventory item by ID + * description: Retrieve a specific inventory item. + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: inventoryId + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Inventory item retrieved + * 401: + * description: Unauthorized + * 404: + * description: Item not found + */ +router.get( + '/:inventoryId', + validateRequest(inventoryIdParamSchema), + async (req: Request, res: Response, next: NextFunction) => { + const userProfile = req.user as UserProfile; + type GetItemRequest = z.infer; + const { params } = req as unknown as GetItemRequest; + + try { + const item = await expiryService.getInventoryItemById( + params.inventoryId, + userProfile.user.user_id, + req.log, + ); + sendSuccess(res, item); + } catch (error) { + req.log.error( + { error, userId: userProfile.user.user_id, inventoryId: params.inventoryId }, + 'Error fetching inventory item', + ); + next(error); + } + }, +); + +/** + * @openapi + * /inventory/{inventoryId}: + * put: + * tags: [Inventory] + * summary: Update inventory item + * description: Update an existing inventory item. + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: inventoryId + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * quantity: + * type: number + * minimum: 0 + * unit: + * type: string + * maxLength: 50 + * expiry_date: + * type: string + * format: date + * location: + * type: string + * enum: [fridge, freezer, pantry, room_temp] + * notes: + * type: string + * maxLength: 500 + * is_consumed: + * type: boolean + * responses: + * 200: + * description: Item updated + * 400: + * description: Validation error + * 401: + * description: Unauthorized + * 404: + * description: Item not found + */ +router.put( + '/:inventoryId', + validateRequest(updateInventoryItemSchema), + async (req: Request, res: Response, next: NextFunction) => { + const userProfile = req.user as UserProfile; + type UpdateItemRequest = z.infer; + const { params, body } = req as unknown as UpdateItemRequest; + + try { + const item = await expiryService.updateInventoryItem( + params.inventoryId, + userProfile.user.user_id, + body, + req.log, + ); + sendSuccess(res, item); + } catch (error) { + req.log.error( + { error, userId: userProfile.user.user_id, inventoryId: params.inventoryId }, + 'Error updating inventory item', + ); + next(error); + } + }, +); + +/** + * @openapi + * /inventory/{inventoryId}: + * delete: + * tags: [Inventory] + * summary: Delete inventory item + * description: Remove an item from the user's inventory. + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: inventoryId + * required: true + * schema: + * type: integer + * responses: + * 204: + * description: Item deleted + * 401: + * description: Unauthorized + * 404: + * description: Item not found + */ +router.delete( + '/:inventoryId', + validateRequest(inventoryIdParamSchema), + async (req: Request, res: Response, next: NextFunction) => { + const userProfile = req.user as UserProfile; + type DeleteItemRequest = z.infer; + const { params } = req as unknown as DeleteItemRequest; + + try { + await expiryService.deleteInventoryItem( + params.inventoryId, + userProfile.user.user_id, + req.log, + ); + sendNoContent(res); + } catch (error) { + req.log.error( + { error, userId: userProfile.user.user_id, inventoryId: params.inventoryId }, + 'Error deleting inventory item', + ); + next(error); + } + }, +); + +/** + * @openapi + * /inventory/{inventoryId}/consume: + * post: + * tags: [Inventory] + * summary: Mark item as consumed + * description: Mark an inventory item as consumed. + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: inventoryId + * required: true + * schema: + * type: integer + * responses: + * 204: + * description: Item marked as consumed + * 401: + * description: Unauthorized + * 404: + * description: Item not found + */ +router.post( + '/:inventoryId/consume', + validateRequest(inventoryIdParamSchema), + async (req: Request, res: Response, next: NextFunction) => { + const userProfile = req.user as UserProfile; + type ConsumeItemRequest = z.infer; + const { params } = req as unknown as ConsumeItemRequest; + + try { + await expiryService.markItemConsumed(params.inventoryId, userProfile.user.user_id, req.log); + sendNoContent(res); + } catch (error) { + req.log.error( + { error, userId: userProfile.user.user_id, inventoryId: params.inventoryId }, + 'Error marking item as consumed', + ); + next(error); + } + }, +); + +// ============================================================================ +// EXPIRING ITEMS ENDPOINTS +// ============================================================================ + +/** + * @openapi + * /inventory/expiring/summary: + * get: + * tags: [Inventory] + * summary: Get expiring items summary + * description: Get items grouped by expiry urgency (today, this week, this month, expired). + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Expiring items grouped by urgency + * content: + * application/json: + * schema: + * type: object + * properties: + * expiring_today: + * type: array + * expiring_this_week: + * type: array + * expiring_this_month: + * type: array + * already_expired: + * type: array + * counts: + * type: object + * properties: + * today: + * type: integer + * this_week: + * type: integer + * this_month: + * type: integer + * expired: + * type: integer + * total: + * type: integer + * 401: + * description: Unauthorized + */ +router.get('/expiring/summary', async (req: Request, res: Response, next: NextFunction) => { + const userProfile = req.user as UserProfile; + + try { + const result = await expiryService.getExpiringItemsGrouped(userProfile.user.user_id, req.log); + sendSuccess(res, result); + } catch (error) { + req.log.error( + { error, userId: userProfile.user.user_id }, + 'Error fetching expiring items summary', + ); + next(error); + } +}); + +/** + * @openapi + * /inventory/expiring: + * get: + * tags: [Inventory] + * summary: Get expiring items + * description: Get items expiring within a specified number of days. + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: days + * schema: + * type: integer + * minimum: 1 + * maximum: 90 + * default: 7 + * description: Number of days to look ahead + * responses: + * 200: + * description: Expiring items retrieved + * 401: + * description: Unauthorized + */ +router.get( + '/expiring', + validateRequest(daysAheadQuerySchema), + async (req: Request, res: Response, next: NextFunction) => { + const userProfile = req.user as UserProfile; + type ExpiringItemsRequest = z.infer; + const { query } = req as unknown as ExpiringItemsRequest; + + try { + const items = await expiryService.getExpiringItems( + userProfile.user.user_id, + query.days, + req.log, + ); + sendSuccess(res, { items, total: items.length }); + } catch (error) { + req.log.error({ error, userId: userProfile.user.user_id }, 'Error fetching expiring items'); + next(error); + } + }, +); + +/** + * @openapi + * /inventory/expired: + * get: + * tags: [Inventory] + * summary: Get expired items + * description: Get all items that have already expired. + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Expired items retrieved + * 401: + * description: Unauthorized + */ +router.get('/expired', async (req: Request, res: Response, next: NextFunction) => { + const userProfile = req.user as UserProfile; + + try { + const items = await expiryService.getExpiredItems(userProfile.user.user_id, req.log); + sendSuccess(res, { items, total: items.length }); + } catch (error) { + req.log.error({ error, userId: userProfile.user.user_id }, 'Error fetching expired items'); + next(error); + } +}); + +// ============================================================================ +// ALERT SETTINGS ENDPOINTS +// ============================================================================ + +/** + * @openapi + * /inventory/alerts: + * get: + * tags: [Inventory] + * summary: Get alert settings + * description: Get the user's expiry alert settings. + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Alert settings retrieved + * 401: + * description: Unauthorized + */ +router.get('/alerts', async (req: Request, res: Response, next: NextFunction) => { + const userProfile = req.user as UserProfile; + + try { + const settings = await expiryService.getAlertSettings(userProfile.user.user_id, req.log); + sendSuccess(res, settings); + } catch (error) { + req.log.error({ error, userId: userProfile.user.user_id }, 'Error fetching alert settings'); + next(error); + } +}); + +/** + * @openapi + * /inventory/alerts/{alertMethod}: + * put: + * tags: [Inventory] + * summary: Update alert settings + * description: Update alert settings for a specific notification method. + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: alertMethod + * required: true + * schema: + * type: string + * enum: [email, push, in_app] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * days_before_expiry: + * type: integer + * minimum: 1 + * maximum: 30 + * is_enabled: + * type: boolean + * responses: + * 200: + * description: Alert settings updated + * 400: + * description: Validation error + * 401: + * description: Unauthorized + */ +router.put( + '/alerts/:alertMethod', + validateRequest(updateAlertSettingsSchema), + async (req: Request, res: Response, next: NextFunction) => { + const userProfile = req.user as UserProfile; + type UpdateAlertRequest = z.infer; + const { params, body } = req as unknown as UpdateAlertRequest; + + try { + const settings = await expiryService.updateAlertSettings( + userProfile.user.user_id, + params.alertMethod, + body, + req.log, + ); + sendSuccess(res, settings); + } catch (error) { + req.log.error( + { error, userId: userProfile.user.user_id, alertMethod: params.alertMethod }, + 'Error updating alert settings', + ); + next(error); + } + }, +); + +// ============================================================================ +// RECIPE SUGGESTIONS ENDPOINT +// ============================================================================ + +/** + * @openapi + * /inventory/recipes/suggestions: + * get: + * tags: [Inventory] + * summary: Get recipe suggestions for expiring items + * description: Get recipes that use items expiring soon to reduce food waste. + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: days + * schema: + * type: integer + * minimum: 1 + * maximum: 90 + * default: 7 + * description: Consider items expiring within this many days + * - in: query + * name: limit + * schema: + * type: integer + * minimum: 1 + * maximum: 50 + * default: 10 + * - in: query + * name: offset + * schema: + * type: integer + * minimum: 0 + * default: 0 + * responses: + * 200: + * description: Recipe suggestions retrieved + * 401: + * description: Unauthorized + */ +router.get( + '/recipes/suggestions', + validateRequest( + z.object({ + query: z.object({ + days: z + .string() + .optional() + .default('7') + .transform((val) => parseInt(val, 10)) + .pipe(z.number().int().min(1).max(90)), + limit: optionalNumeric({ default: 10, min: 1, max: 50, integer: true }), + offset: optionalNumeric({ default: 0, min: 0, integer: true }), + }), + }), + ), + async (req: Request, res: Response, next: NextFunction) => { + const userProfile = req.user as UserProfile; + const { query } = req as unknown as { + query: { days: number; limit?: number; offset?: number }; + }; + + try { + const result = await expiryService.getRecipeSuggestionsForExpiringItems( + userProfile.user.user_id, + query.days, + req.log, + { limit: query.limit, offset: query.offset }, + ); + sendSuccess(res, result); + } catch (error) { + req.log.error( + { error, userId: userProfile.user.user_id }, + 'Error fetching recipe suggestions', + ); + next(error); + } + }, +); + +export default router; diff --git a/src/routes/price.routes.ts b/src/routes/price.routes.ts index 7c4edb8..f9b54ae 100644 --- a/src/routes/price.routes.ts +++ b/src/routes/price.routes.ts @@ -1,7 +1,7 @@ // src/routes/price.routes.ts import { Router, Request, Response, NextFunction } from 'express'; import { z } from 'zod'; -import passport from './passport.routes'; +import passport from '../config/passport'; import { validateRequest } from '../middleware/validation.middleware'; import { priceRepo } from '../services/db/price.db'; import { optionalNumeric } from '../utils/zodUtils'; @@ -24,8 +24,48 @@ const priceHistorySchema = z.object({ type PriceHistoryRequest = z.infer; /** - * POST /api/price-history - Fetches historical price data for a given list of master item IDs. - * This endpoint retrieves price points over time for specified master grocery items. + * @openapi + * /price-history: + * post: + * tags: [Price] + * summary: Get price history + * description: Fetches historical price data for a given list of master item IDs. + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - masterItemIds + * properties: + * masterItemIds: + * type: array + * items: + * type: integer + * minItems: 1 + * description: Array of master item IDs to get price history for + * limit: + * type: integer + * default: 1000 + * description: Maximum number of price points to return + * offset: + * type: integer + * default: 0 + * description: Number of price points to skip + * responses: + * 200: + * description: Historical price data for specified items + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SuccessResponse' + * 400: + * description: Validation error - masterItemIds must be a non-empty array + * 401: + * description: Unauthorized - invalid or missing token */ router.post( '/', diff --git a/src/routes/reactions.routes.ts b/src/routes/reactions.routes.ts index f5739ce..4024826 100644 --- a/src/routes/reactions.routes.ts +++ b/src/routes/reactions.routes.ts @@ -2,7 +2,7 @@ import { Router, Request, Response, NextFunction } from 'express'; import { z } from 'zod'; import { reactionRepo } from '../services/db/index.db'; import { validateRequest } from '../middleware/validation.middleware'; -import passport from './passport.routes'; +import passport from '../config/passport'; import { requiredString } from '../utils/zodUtils'; import { UserProfile } from '../types'; import { publicReadLimiter, reactionToggleLimiter } from '../config/rateLimiters'; @@ -38,9 +38,36 @@ const getReactionSummarySchema = z.object({ // --- Routes --- /** - * GET /api/reactions - Fetches user reactions based on query filters. - * Supports filtering by userId, entityType, and entityId. - * This is a public endpoint. + * @openapi + * /reactions: + * get: + * tags: [Reactions] + * summary: Get reactions + * description: Fetches user reactions based on query filters. Supports filtering by userId, entityType, and entityId. + * parameters: + * - in: query + * name: userId + * schema: + * type: string + * format: uuid + * description: Filter by user ID + * - in: query + * name: entityType + * schema: + * type: string + * description: Filter by entity type (e.g., recipe, comment) + * - in: query + * name: entityId + * schema: + * type: string + * description: Filter by entity ID + * responses: + * 200: + * description: List of reactions matching filters + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SuccessResponse' */ router.get( '/', @@ -59,9 +86,34 @@ router.get( ); /** - * GET /api/reactions/summary - Fetches a summary of reactions for a specific entity. - * Example: /api/reactions/summary?entityType=recipe&entityId=123 - * This is a public endpoint. + * @openapi + * /reactions/summary: + * get: + * tags: [Reactions] + * summary: Get reaction summary + * description: Fetches a summary of reactions for a specific entity. + * parameters: + * - in: query + * name: entityType + * required: true + * schema: + * type: string + * description: Entity type (e.g., recipe, comment) + * - in: query + * name: entityId + * required: true + * schema: + * type: string + * description: Entity ID + * responses: + * 200: + * description: Reaction summary with counts by type + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SuccessResponse' + * 400: + * description: Missing required query parameters */ router.get( '/summary', @@ -84,8 +136,41 @@ router.get( ); /** - * POST /api/reactions/toggle - Toggles a user's reaction to an entity. - * This is a protected endpoint. + * @openapi + * /reactions/toggle: + * post: + * tags: [Reactions] + * summary: Toggle reaction + * description: Toggles a user's reaction to an entity. If the reaction exists, it's removed; otherwise, it's added. + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - entity_type + * - entity_id + * - reaction_type + * properties: + * entity_type: + * type: string + * description: Entity type (e.g., recipe, comment) + * entity_id: + * type: string + * description: Entity ID + * reaction_type: + * type: string + * description: Type of reaction (e.g., like, love) + * responses: + * 200: + * description: Reaction removed + * 201: + * description: Reaction added + * 401: + * description: Unauthorized - invalid or missing token */ router.post( '/toggle', diff --git a/src/routes/receipt.routes.test.ts b/src/routes/receipt.routes.test.ts new file mode 100644 index 0000000..e17c70c --- /dev/null +++ b/src/routes/receipt.routes.test.ts @@ -0,0 +1,767 @@ +// src/routes/receipt.routes.test.ts +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import request from 'supertest'; +import { createTestApp } from '../tests/utils/createTestApp'; +import { createMockUserProfile } from '../tests/utils/mockFactories'; +import receiptRouter from './receipt.routes'; +import type { ReceiptStatus, ReceiptItemStatus } from '../types/expiry'; + +// Mock passport +vi.mock('../config/passport', () => ({ + default: { + authenticate: vi.fn(() => (req: any, res: any, next: any) => { + if (mockUser) { + req.user = mockUser; + next(); + } else { + res.status(401).json({ success: false, error: { message: 'Unauthorized' } }); + } + }), + }, +})); + +// Mock receipt service +vi.mock('../services/receiptService.server', () => ({ + getReceipts: vi.fn(), + createReceipt: vi.fn(), + getReceiptById: vi.fn(), + deleteReceipt: vi.fn(), + getReceiptItems: vi.fn(), + updateReceiptItem: vi.fn(), + getUnaddedItems: vi.fn(), + getProcessingLogs: vi.fn(), +})); + +// Mock expiry service +vi.mock('../services/expiryService.server', () => ({ + addItemsFromReceipt: vi.fn(), +})); + +// Mock receipt queue +vi.mock('../services/queues.server', () => ({ + receiptQueue: { + add: vi.fn(), + }, +})); + +// Mock multer middleware +vi.mock('../middleware/multer.middleware', () => ({ + createUploadMiddleware: vi.fn(() => ({ + single: vi.fn(() => (req: any, _res: any, next: any) => { + // Simulate file upload + if (mockFile) { + req.file = mockFile; + } + next(); + }), + })), + handleMulterError: vi.fn((err: any, _req: any, res: any, next: any) => { + if (err) { + return res.status(400).json({ success: false, error: { message: err.message } }); + } + next(); + }), +})); + +// Mock file upload middleware +vi.mock('../middleware/fileUpload.middleware', () => ({ + requireFileUpload: vi.fn(() => (req: any, res: any, next: any) => { + if (!req.file) { + return res.status(400).json({ + success: false, + error: { message: 'File is required' }, + }); + } + next(); + }), +})); + +import * as receiptService from '../services/receiptService.server'; +import * as expiryService from '../services/expiryService.server'; +import { receiptQueue } from '../services/queues.server'; + +// Test state +let mockUser: ReturnType | null = null; +let mockFile: Express.Multer.File | null = null; + +// Helper to create mock receipt (ReceiptScan type) +function createMockReceipt(overrides: { status?: ReceiptStatus; [key: string]: unknown } = {}) { + return { + receipt_id: 1, + user_id: 'user-123', + receipt_image_url: '/uploads/receipts/receipt-123.jpg', + store_id: null, + transaction_date: null, + total_amount_cents: null, + status: 'pending' as ReceiptStatus, + raw_text: null, + store_confidence: null, + ocr_provider: null, + error_details: null, + retry_count: 0, + ocr_confidence: null, + currency: 'USD', + created_at: '2024-01-15T10:00:00Z', + processed_at: null, + updated_at: '2024-01-15T10:00:00Z', + ...overrides, + }; +} + +// Helper to create mock receipt item (ReceiptItem type) +function createMockReceiptItem( + overrides: { status?: ReceiptItemStatus; [key: string]: unknown } = {}, +) { + return { + receipt_item_id: 1, + receipt_id: 1, + raw_item_description: 'MILK 2% 4L', + quantity: 1, + price_paid_cents: 599, + master_item_id: null, + product_id: null, + status: 'unmatched' as ReceiptItemStatus, + line_number: 1, + match_confidence: null, + is_discount: false, + unit_price_cents: null, + unit_type: null, + added_to_pantry: false, + pantry_item_id: null, + upc_code: null, + created_at: '2024-01-15T10:00:00Z', + updated_at: '2024-01-15T10:00:00Z', + ...overrides, + }; +} + +// Helper to create mock processing log (ReceiptProcessingLogRecord type) +function createMockProcessingLog(overrides: Record = {}) { + return { + log_id: 1, + receipt_id: 1, + processing_step: 'upload' as const, + status: 'completed' as const, + provider: null, + duration_ms: null, + tokens_used: null, + cost_cents: null, + input_data: null, + output_data: null, + error_message: null, + created_at: '2024-01-15T10:00:00Z', + ...overrides, + }; +} + +describe('Receipt Routes', () => { + let app: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockUser = createMockUserProfile(); + mockFile = null; + app = createTestApp({ + router: receiptRouter, + basePath: '/receipts', + authenticatedUser: mockUser, + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + mockUser = null; + mockFile = null; + }); + + describe('GET /receipts', () => { + it('should return user receipts with default pagination', async () => { + const mockReceipts = [createMockReceipt(), createMockReceipt({ receipt_id: 2 })]; + vi.mocked(receiptService.getReceipts).mockResolvedValueOnce({ + receipts: mockReceipts, + total: 2, + }); + + const response = await request(app).get('/receipts'); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.receipts).toHaveLength(2); + expect(receiptService.getReceipts).toHaveBeenCalledWith( + expect.objectContaining({ + user_id: mockUser!.user.user_id, + limit: 50, + offset: 0, + }), + expect.anything(), + ); + }); + + it('should support status filter', async () => { + vi.mocked(receiptService.getReceipts).mockResolvedValueOnce({ + receipts: [createMockReceipt({ status: 'completed' })], + total: 1, + }); + + const response = await request(app).get('/receipts?status=completed'); + + expect(response.status).toBe(200); + expect(receiptService.getReceipts).toHaveBeenCalledWith( + expect.objectContaining({ status: 'completed' }), + expect.anything(), + ); + }); + + it('should support store_id filter', async () => { + vi.mocked(receiptService.getReceipts).mockResolvedValueOnce({ + receipts: [createMockReceipt({ store_id: 5 })], + total: 1, + }); + + const response = await request(app).get('/receipts?store_id=5'); + + expect(response.status).toBe(200); + expect(receiptService.getReceipts).toHaveBeenCalledWith( + expect.objectContaining({ store_id: 5 }), + expect.anything(), + ); + }); + + it('should support date range filter', async () => { + vi.mocked(receiptService.getReceipts).mockResolvedValueOnce({ + receipts: [], + total: 0, + }); + + const response = await request(app).get('/receipts?from_date=2024-01-01&to_date=2024-01-31'); + + expect(response.status).toBe(200); + expect(receiptService.getReceipts).toHaveBeenCalledWith( + expect.objectContaining({ + from_date: '2024-01-01', + to_date: '2024-01-31', + }), + expect.anything(), + ); + }); + + it('should support pagination', async () => { + vi.mocked(receiptService.getReceipts).mockResolvedValueOnce({ + receipts: [], + total: 100, + }); + + const response = await request(app).get('/receipts?limit=10&offset=20'); + + expect(response.status).toBe(200); + expect(receiptService.getReceipts).toHaveBeenCalledWith( + expect.objectContaining({ limit: 10, offset: 20 }), + expect.anything(), + ); + }); + + it('should reject invalid status', async () => { + const response = await request(app).get('/receipts?status=invalid'); + + expect(response.status).toBe(400); + }); + + it('should handle service error', async () => { + vi.mocked(receiptService.getReceipts).mockRejectedValueOnce(new Error('DB error')); + + const response = await request(app).get('/receipts'); + + expect(response.status).toBe(500); + }); + }); + + describe('POST /receipts', () => { + beforeEach(() => { + mockFile = { + fieldname: 'receipt', + originalname: 'receipt.jpg', + encoding: '7bit', + mimetype: 'image/jpeg', + destination: '/uploads/receipts', + filename: 'receipt-123.jpg', + path: '/uploads/receipts/receipt-123.jpg', + size: 1024000, + } as Express.Multer.File; + }); + + it('should upload receipt and queue for processing', async () => { + const mockReceipt = createMockReceipt(); + vi.mocked(receiptService.createReceipt).mockResolvedValueOnce(mockReceipt); + vi.mocked(receiptQueue.add).mockResolvedValueOnce({ id: 'job-123' } as any); + + const response = await request(app) + .post('/receipts') + .field('store_id', '1') + .field('transaction_date', '2024-01-15'); + + expect(response.status).toBe(201); + expect(response.body.success).toBe(true); + expect(response.body.data.receipt_id).toBe(1); + expect(response.body.data.job_id).toBe('job-123'); + expect(receiptService.createReceipt).toHaveBeenCalledWith( + mockUser!.user.user_id, + '/uploads/receipts/receipt-123.jpg', + expect.anything(), + expect.objectContaining({ + storeId: 1, + transactionDate: '2024-01-15', + }), + ); + expect(receiptQueue.add).toHaveBeenCalledWith( + 'process-receipt', + expect.objectContaining({ + receiptId: 1, + userId: mockUser!.user.user_id, + imagePath: '/uploads/receipts/receipt-123.jpg', + }), + expect.objectContaining({ + jobId: 'receipt-1', + }), + ); + }); + + it('should upload receipt without optional fields', async () => { + const mockReceipt = createMockReceipt(); + vi.mocked(receiptService.createReceipt).mockResolvedValueOnce(mockReceipt); + vi.mocked(receiptQueue.add).mockResolvedValueOnce({ id: 'job-456' } as any); + + const response = await request(app).post('/receipts'); + + expect(response.status).toBe(201); + expect(receiptService.createReceipt).toHaveBeenCalledWith( + mockUser!.user.user_id, + '/uploads/receipts/receipt-123.jpg', + expect.anything(), + expect.objectContaining({ + storeId: undefined, + transactionDate: undefined, + }), + ); + }); + + it('should reject request without file', async () => { + mockFile = null; + + const response = await request(app).post('/receipts'); + + expect(response.status).toBe(400); + expect(response.body.error.message).toContain('File is required'); + }); + + it('should handle service error', async () => { + vi.mocked(receiptService.createReceipt).mockRejectedValueOnce(new Error('Storage error')); + + const response = await request(app).post('/receipts'); + + expect(response.status).toBe(500); + }); + }); + + describe('GET /receipts/:receiptId', () => { + it('should return receipt with items', async () => { + const mockReceipt = createMockReceipt(); + const mockItems = [createMockReceiptItem(), createMockReceiptItem({ receipt_item_id: 2 })]; + + vi.mocked(receiptService.getReceiptById).mockResolvedValueOnce(mockReceipt); + vi.mocked(receiptService.getReceiptItems).mockResolvedValueOnce(mockItems); + + const response = await request(app).get('/receipts/1'); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.receipt.receipt_id).toBe(1); + expect(response.body.data.items).toHaveLength(2); + expect(receiptService.getReceiptById).toHaveBeenCalledWith( + 1, + mockUser!.user.user_id, + expect.anything(), + ); + }); + + it('should return 404 for non-existent receipt', async () => { + const notFoundError = new Error('Receipt not found'); + (notFoundError as any).statusCode = 404; + vi.mocked(receiptService.getReceiptById).mockRejectedValueOnce(notFoundError); + + const response = await request(app).get('/receipts/999'); + + expect(response.status).toBe(404); + }); + + it('should reject invalid receipt ID', async () => { + const response = await request(app).get('/receipts/invalid'); + + expect(response.status).toBe(400); + }); + }); + + describe('DELETE /receipts/:receiptId', () => { + it('should delete receipt successfully', async () => { + vi.mocked(receiptService.deleteReceipt).mockResolvedValueOnce(undefined); + + const response = await request(app).delete('/receipts/1'); + + expect(response.status).toBe(204); + expect(receiptService.deleteReceipt).toHaveBeenCalledWith( + 1, + mockUser!.user.user_id, + expect.anything(), + ); + }); + + it('should return 404 for non-existent receipt', async () => { + const notFoundError = new Error('Receipt not found'); + (notFoundError as any).statusCode = 404; + vi.mocked(receiptService.deleteReceipt).mockRejectedValueOnce(notFoundError); + + const response = await request(app).delete('/receipts/999'); + + expect(response.status).toBe(404); + }); + }); + + describe('POST /receipts/:receiptId/reprocess', () => { + it('should queue receipt for reprocessing', async () => { + const mockReceipt = createMockReceipt({ status: 'failed' }); + vi.mocked(receiptService.getReceiptById).mockResolvedValueOnce(mockReceipt); + vi.mocked(receiptQueue.add).mockResolvedValueOnce({ id: 'reprocess-job-123' } as any); + + const response = await request(app).post('/receipts/1/reprocess'); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.message).toContain('reprocessing'); + expect(response.body.data.job_id).toBe('reprocess-job-123'); + expect(receiptQueue.add).toHaveBeenCalledWith( + 'process-receipt', + expect.objectContaining({ + receiptId: 1, + imagePath: mockReceipt.receipt_image_url, + }), + expect.objectContaining({ + jobId: expect.stringMatching(/^receipt-1-reprocess-\d+$/), + }), + ); + }); + + it('should return 404 for non-existent receipt', async () => { + const notFoundError = new Error('Receipt not found'); + (notFoundError as any).statusCode = 404; + vi.mocked(receiptService.getReceiptById).mockRejectedValueOnce(notFoundError); + + const response = await request(app).post('/receipts/999/reprocess'); + + expect(response.status).toBe(404); + }); + }); + + describe('GET /receipts/:receiptId/items', () => { + it('should return receipt items', async () => { + const mockReceipt = createMockReceipt(); + const mockItems = [ + createMockReceiptItem(), + createMockReceiptItem({ receipt_item_id: 2, parsed_name: 'Bread' }), + ]; + + vi.mocked(receiptService.getReceiptById).mockResolvedValueOnce(mockReceipt); + vi.mocked(receiptService.getReceiptItems).mockResolvedValueOnce(mockItems); + + const response = await request(app).get('/receipts/1/items'); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.items).toHaveLength(2); + expect(response.body.data.total).toBe(2); + }); + + it('should return 404 if receipt not found', async () => { + const notFoundError = new Error('Receipt not found'); + (notFoundError as any).statusCode = 404; + vi.mocked(receiptService.getReceiptById).mockRejectedValueOnce(notFoundError); + + const response = await request(app).get('/receipts/999/items'); + + expect(response.status).toBe(404); + }); + }); + + describe('PUT /receipts/:receiptId/items/:itemId', () => { + it('should update receipt item status', async () => { + const mockReceipt = createMockReceipt(); + const updatedItem = createMockReceiptItem({ status: 'matched', match_confidence: 0.95 }); + + vi.mocked(receiptService.getReceiptById).mockResolvedValueOnce(mockReceipt); + vi.mocked(receiptService.updateReceiptItem).mockResolvedValueOnce(updatedItem); + + const response = await request(app) + .put('/receipts/1/items/1') + .send({ status: 'matched', match_confidence: 0.95 }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.status).toBe('matched'); + expect(receiptService.updateReceiptItem).toHaveBeenCalledWith( + 1, + expect.objectContaining({ status: 'matched', match_confidence: 0.95 }), + expect.anything(), + ); + }); + + it('should update item with master_item_id', async () => { + const mockReceipt = createMockReceipt(); + const updatedItem = createMockReceiptItem({ master_item_id: 42 }); + + vi.mocked(receiptService.getReceiptById).mockResolvedValueOnce(mockReceipt); + vi.mocked(receiptService.updateReceiptItem).mockResolvedValueOnce(updatedItem); + + const response = await request(app).put('/receipts/1/items/1').send({ master_item_id: 42 }); + + expect(response.status).toBe(200); + expect(response.body.data.master_item_id).toBe(42); + }); + + it('should reject empty update body', async () => { + const response = await request(app).put('/receipts/1/items/1').send({}); + + expect(response.status).toBe(400); + }); + + it('should reject invalid status value', async () => { + const response = await request(app) + .put('/receipts/1/items/1') + .send({ status: 'invalid_status' }); + + expect(response.status).toBe(400); + }); + + it('should reject invalid match_confidence', async () => { + const response = await request(app) + .put('/receipts/1/items/1') + .send({ match_confidence: 1.5 }); + + expect(response.status).toBe(400); + }); + }); + + describe('GET /receipts/:receiptId/items/unadded', () => { + it('should return unadded items', async () => { + const mockReceipt = createMockReceipt(); + const mockItems = [ + createMockReceiptItem({ added_to_inventory: false }), + createMockReceiptItem({ receipt_item_id: 2, added_to_inventory: false }), + ]; + + vi.mocked(receiptService.getReceiptById).mockResolvedValueOnce(mockReceipt); + vi.mocked(receiptService.getUnaddedItems).mockResolvedValueOnce(mockItems); + + const response = await request(app).get('/receipts/1/items/unadded'); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.items).toHaveLength(2); + expect(response.body.data.total).toBe(2); + }); + + it('should return empty array when all items added', async () => { + const mockReceipt = createMockReceipt(); + vi.mocked(receiptService.getReceiptById).mockResolvedValueOnce(mockReceipt); + vi.mocked(receiptService.getUnaddedItems).mockResolvedValueOnce([]); + + const response = await request(app).get('/receipts/1/items/unadded'); + + expect(response.status).toBe(200); + expect(response.body.data.items).toHaveLength(0); + expect(response.body.data.total).toBe(0); + }); + }); + + describe('POST /receipts/:receiptId/confirm', () => { + it('should confirm items for inventory', async () => { + const addedItems = [ + { inventory_id: 1, item_name: 'Milk 2%', quantity: 1 }, + { inventory_id: 2, item_name: 'Bread', quantity: 2 }, + ]; + + vi.mocked(expiryService.addItemsFromReceipt).mockResolvedValueOnce(addedItems as any); + + const response = await request(app) + .post('/receipts/1/confirm') + .send({ + items: [ + { receipt_item_id: 1, include: true, location: 'fridge' }, + { receipt_item_id: 2, include: true, location: 'pantry', expiry_date: '2024-01-20' }, + { receipt_item_id: 3, include: false }, + ], + }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.added_items).toHaveLength(2); + expect(response.body.data.count).toBe(2); + expect(expiryService.addItemsFromReceipt).toHaveBeenCalledWith( + mockUser!.user.user_id, + 1, + expect.arrayContaining([ + expect.objectContaining({ receipt_item_id: 1, include: true }), + expect.objectContaining({ receipt_item_id: 2, include: true }), + expect.objectContaining({ receipt_item_id: 3, include: false }), + ]), + expect.anything(), + ); + }); + + it('should accept custom item_name and quantity', async () => { + vi.mocked(expiryService.addItemsFromReceipt).mockResolvedValueOnce([ + { inventory_id: 1, item_name: 'Custom Name', quantity: 5 }, + ] as any); + + const response = await request(app) + .post('/receipts/1/confirm') + .send({ + items: [ + { + receipt_item_id: 1, + include: true, + item_name: 'Custom Name', + quantity: 5, + location: 'pantry', + }, + ], + }); + + expect(response.status).toBe(200); + expect(expiryService.addItemsFromReceipt).toHaveBeenCalledWith( + mockUser!.user.user_id, + 1, + expect.arrayContaining([ + expect.objectContaining({ + item_name: 'Custom Name', + quantity: 5, + }), + ]), + expect.anything(), + ); + }); + + it('should reject empty items array', async () => { + const response = await request(app).post('/receipts/1/confirm').send({ items: [] }); + + // Empty array is technically valid, service decides what to do + expect(response.status).toBe(200); + }); + + it('should reject missing items field', async () => { + const response = await request(app).post('/receipts/1/confirm').send({}); + + expect(response.status).toBe(400); + }); + + it('should reject invalid location', async () => { + const response = await request(app) + .post('/receipts/1/confirm') + .send({ + items: [{ receipt_item_id: 1, include: true, location: 'invalid_location' }], + }); + + expect(response.status).toBe(400); + }); + + it('should reject invalid expiry_date format', async () => { + const response = await request(app) + .post('/receipts/1/confirm') + .send({ + items: [{ receipt_item_id: 1, include: true, expiry_date: 'not-a-date' }], + }); + + expect(response.status).toBe(400); + }); + + it('should handle service error', async () => { + vi.mocked(expiryService.addItemsFromReceipt).mockRejectedValueOnce( + new Error('Failed to add items'), + ); + + const response = await request(app) + .post('/receipts/1/confirm') + .send({ + items: [{ receipt_item_id: 1, include: true }], + }); + + expect(response.status).toBe(500); + }); + }); + + describe('GET /receipts/:receiptId/logs', () => { + it('should return processing logs', async () => { + const mockReceipt = createMockReceipt(); + const mockLogs = [ + createMockProcessingLog({ + processing_step: 'ocr_extraction' as const, + status: 'completed' as const, + }), + createMockProcessingLog({ + log_id: 2, + processing_step: 'item_extraction' as const, + status: 'completed' as const, + }), + createMockProcessingLog({ + log_id: 3, + processing_step: 'item_matching' as const, + status: 'started' as const, + }), + ]; + + vi.mocked(receiptService.getReceiptById).mockResolvedValueOnce(mockReceipt); + vi.mocked(receiptService.getProcessingLogs).mockResolvedValueOnce(mockLogs); + + const response = await request(app).get('/receipts/1/logs'); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.logs).toHaveLength(3); + expect(response.body.data.total).toBe(3); + }); + + it('should return empty logs for new receipt', async () => { + const mockReceipt = createMockReceipt(); + vi.mocked(receiptService.getReceiptById).mockResolvedValueOnce(mockReceipt); + vi.mocked(receiptService.getProcessingLogs).mockResolvedValueOnce([]); + + const response = await request(app).get('/receipts/1/logs'); + + expect(response.status).toBe(200); + expect(response.body.data.logs).toHaveLength(0); + expect(response.body.data.total).toBe(0); + }); + + it('should return 404 for non-existent receipt', async () => { + const notFoundError = new Error('Receipt not found'); + (notFoundError as any).statusCode = 404; + vi.mocked(receiptService.getReceiptById).mockRejectedValueOnce(notFoundError); + + const response = await request(app).get('/receipts/999/logs'); + + expect(response.status).toBe(404); + }); + }); + + describe('Authentication', () => { + it('should reject unauthenticated requests', async () => { + mockUser = null; + app = createTestApp({ + router: receiptRouter, + basePath: '/receipts', + authenticatedUser: undefined, + }); + + const response = await request(app).get('/receipts'); + + expect(response.status).toBe(401); + }); + }); +}); diff --git a/src/routes/receipt.routes.ts b/src/routes/receipt.routes.ts new file mode 100644 index 0000000..4c9d61d --- /dev/null +++ b/src/routes/receipt.routes.ts @@ -0,0 +1,814 @@ +// src/routes/receipt.routes.ts +/** + * @file Receipt Scanning API Routes + * Provides endpoints for uploading, processing, and managing scanned receipts. + */ +import express, { Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import passport from '../config/passport'; +import type { UserProfile } from '../types'; +import { validateRequest } from '../middleware/validation.middleware'; +import { numericIdParam, optionalNumeric } from '../utils/zodUtils'; +import { sendSuccess, sendNoContent } from '../utils/apiResponse'; +import * as receiptService from '../services/receiptService.server'; +import * as expiryService from '../services/expiryService.server'; +import { createUploadMiddleware, handleMulterError } from '../middleware/multer.middleware'; +import { receiptQueue } from '../services/queues.server'; +import { requireFileUpload } from '../middleware/fileUpload.middleware'; + +const router = express.Router(); + +// Configure multer for receipt image uploads (max 10MB) +const receiptUpload = createUploadMiddleware({ + storageType: 'receipt', + fileSize: 10 * 1024 * 1024, // 10MB + fileFilter: 'image', +}); + +// --- Zod Schemas for Receipt Routes --- + +/** + * Receipt status validation + */ +const receiptStatusSchema = z.enum(['pending', 'processing', 'completed', 'failed']); + +/** + * Receipt item status validation + */ +const receiptItemStatusSchema = z.enum(['unmatched', 'matched', 'needs_review', 'ignored']); + +/** + * Storage location validation (for adding items to inventory) + */ +const storageLocationSchema = z.enum(['fridge', 'freezer', 'pantry', 'room_temp']); + +/** + * Schema for receipt ID parameter + */ +const receiptIdParamSchema = numericIdParam( + 'receiptId', + "Invalid ID for parameter 'receiptId'. Must be a number.", +); + +/** + * Schema for receipt item ID parameter + */ +const _receiptItemIdParamSchema = numericIdParam( + 'itemId', + "Invalid ID for parameter 'itemId'. Must be a number.", +); + +/** + * Schema for uploading a receipt (used with file upload, not base64) + */ +const uploadReceiptSchema = z.object({ + body: z.object({ + store_id: z + .string() + .optional() + .transform((val) => (val ? parseInt(val, 10) : undefined)) + .pipe(z.number().int().positive().optional()), + transaction_date: z.string().date('Transaction date must be in YYYY-MM-DD format.').optional(), + }), +}); + +/** + * Schema for receipt query + */ +const receiptQuerySchema = z.object({ + query: z.object({ + limit: optionalNumeric({ default: 50, min: 1, max: 100, integer: true }), + offset: optionalNumeric({ default: 0, min: 0, integer: true }), + status: receiptStatusSchema.optional(), + store_id: z + .string() + .optional() + .transform((val) => (val ? parseInt(val, 10) : undefined)) + .pipe(z.number().int().positive().optional()), + from_date: z.string().date().optional(), + to_date: z.string().date().optional(), + }), +}); + +/** + * Schema for updating a receipt item + */ +const updateReceiptItemSchema = z.object({ + params: z.object({ + receiptId: z.coerce.number().int().positive(), + itemId: z.coerce.number().int().positive(), + }), + body: z + .object({ + status: receiptItemStatusSchema.optional(), + master_item_id: z.number().int().positive().nullable().optional(), + product_id: z.number().int().positive().nullable().optional(), + match_confidence: z.number().min(0).max(1).optional(), + }) + .refine((data) => Object.keys(data).length > 0, { + message: 'At least one field to update must be provided.', + }), +}); + +/** + * Schema for confirming receipt items to add to inventory + */ +const confirmItemsSchema = z.object({ + params: z.object({ + receiptId: z.coerce.number().int().positive(), + }), + body: z.object({ + items: z.array( + z.object({ + receipt_item_id: z.number().int().positive(), + item_name: z.string().max(255).optional(), + quantity: z.number().positive().optional(), + location: storageLocationSchema.optional(), + expiry_date: z.string().date().optional(), + include: z.boolean(), + }), + ), + }), +}); + +// Middleware to ensure user is authenticated for all receipt routes +router.use(passport.authenticate('jwt', { session: false })); + +// ============================================================================ +// RECEIPT MANAGEMENT ENDPOINTS +// ============================================================================ + +/** + * @openapi + * /receipts: + * get: + * tags: [Receipts] + * summary: Get user's receipts + * description: Retrieve the user's scanned receipts with optional filtering. + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: limit + * schema: + * type: integer + * minimum: 1 + * maximum: 100 + * default: 50 + * - in: query + * name: offset + * schema: + * type: integer + * minimum: 0 + * default: 0 + * - in: query + * name: status + * schema: + * type: string + * enum: [pending, processing, completed, failed] + * - in: query + * name: store_id + * schema: + * type: integer + * - in: query + * name: from_date + * schema: + * type: string + * format: date + * - in: query + * name: to_date + * schema: + * type: string + * format: date + * responses: + * 200: + * description: Receipts retrieved + * 401: + * description: Unauthorized + */ +router.get( + '/', + validateRequest(receiptQuerySchema), + async (req: Request, res: Response, next: NextFunction) => { + const userProfile = req.user as UserProfile; + type ReceiptQueryRequest = z.infer; + const { query } = req as unknown as ReceiptQueryRequest; + + try { + const result = await receiptService.getReceipts( + { + user_id: userProfile.user.user_id, + status: query.status, + store_id: query.store_id, + from_date: query.from_date, + to_date: query.to_date, + limit: query.limit, + offset: query.offset, + }, + req.log, + ); + sendSuccess(res, result); + } catch (error) { + req.log.error({ error, userId: userProfile.user.user_id }, 'Error fetching receipts'); + next(error); + } + }, +); + +/** + * @openapi + * /receipts: + * post: + * tags: [Receipts] + * summary: Upload a receipt + * description: Upload a receipt image for processing and item extraction. + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * multipart/form-data: + * schema: + * type: object + * required: + * - receipt + * properties: + * receipt: + * type: string + * format: binary + * description: Receipt image file + * store_id: + * type: integer + * description: Store ID if known + * transaction_date: + * type: string + * format: date + * description: Transaction date if known (YYYY-MM-DD) + * responses: + * 201: + * description: Receipt uploaded and queued for processing + * 400: + * description: Validation error + * 401: + * description: Unauthorized + */ +router.post( + '/', + receiptUpload.single('receipt'), + requireFileUpload('receipt'), + validateRequest(uploadReceiptSchema), + async (req: Request, res: Response, next: NextFunction) => { + const userProfile = req.user as UserProfile; + type UploadReceiptRequest = z.infer; + const { body } = req as unknown as UploadReceiptRequest; + const file = req.file as Express.Multer.File; + + try { + req.log.info( + { userId: userProfile.user.user_id, filename: file.filename }, + 'Uploading receipt', + ); + + // Create receipt record with the actual file path + const receipt = await receiptService.createReceipt( + userProfile.user.user_id, + file.path, // Use the actual file path from multer + req.log, + { + storeId: body.store_id, + transactionDate: body.transaction_date, + }, + ); + + // Queue the receipt for processing via BullMQ + const bindings = req.log.bindings?.() || {}; + const job = await receiptQueue.add( + 'process-receipt', + { + receiptId: receipt.receipt_id, + userId: userProfile.user.user_id, + imagePath: file.path, + meta: { + requestId: bindings.request_id as string | undefined, + userId: userProfile.user.user_id, + origin: 'api', + }, + }, + { + jobId: `receipt-${receipt.receipt_id}`, + }, + ); + + req.log.info( + { receiptId: receipt.receipt_id, jobId: job.id }, + 'Receipt queued for processing', + ); + + sendSuccess(res, { ...receipt, job_id: job.id }, 201); + } catch (error) { + req.log.error({ error, userId: userProfile.user.user_id }, 'Error uploading receipt'); + next(error); + } + }, +); + +/** + * @openapi + * /receipts/{receiptId}: + * get: + * tags: [Receipts] + * summary: Get receipt by ID + * description: Retrieve a specific receipt with its extracted items. + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: receiptId + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Receipt retrieved + * 401: + * description: Unauthorized + * 404: + * description: Receipt not found + */ +router.get( + '/:receiptId', + validateRequest(receiptIdParamSchema), + async (req: Request, res: Response, next: NextFunction) => { + const userProfile = req.user as UserProfile; + type GetReceiptRequest = z.infer; + const { params } = req as unknown as GetReceiptRequest; + + try { + const receipt = await receiptService.getReceiptById( + params.receiptId, + userProfile.user.user_id, + req.log, + ); + + // Also get the items + const items = await receiptService.getReceiptItems(params.receiptId, req.log); + + sendSuccess(res, { receipt, items }); + } catch (error) { + req.log.error( + { error, userId: userProfile.user.user_id, receiptId: params.receiptId }, + 'Error fetching receipt', + ); + next(error); + } + }, +); + +/** + * @openapi + * /receipts/{receiptId}: + * delete: + * tags: [Receipts] + * summary: Delete receipt + * description: Delete a receipt and all associated data. + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: receiptId + * required: true + * schema: + * type: integer + * responses: + * 204: + * description: Receipt deleted + * 401: + * description: Unauthorized + * 404: + * description: Receipt not found + */ +router.delete( + '/:receiptId', + validateRequest(receiptIdParamSchema), + async (req: Request, res: Response, next: NextFunction) => { + const userProfile = req.user as UserProfile; + type DeleteReceiptRequest = z.infer; + const { params } = req as unknown as DeleteReceiptRequest; + + try { + await receiptService.deleteReceipt(params.receiptId, userProfile.user.user_id, req.log); + sendNoContent(res); + } catch (error) { + req.log.error( + { error, userId: userProfile.user.user_id, receiptId: params.receiptId }, + 'Error deleting receipt', + ); + next(error); + } + }, +); + +/** + * @openapi + * /receipts/{receiptId}/reprocess: + * post: + * tags: [Receipts] + * summary: Reprocess receipt + * description: Queue a failed receipt for reprocessing. + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: receiptId + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Receipt queued for reprocessing + * 401: + * description: Unauthorized + * 404: + * description: Receipt not found + */ +router.post( + '/:receiptId/reprocess', + validateRequest(receiptIdParamSchema), + async (req: Request, res: Response, next: NextFunction) => { + const userProfile = req.user as UserProfile; + type ReprocessReceiptRequest = z.infer; + const { params } = req as unknown as ReprocessReceiptRequest; + + try { + // Verify the receipt exists and belongs to user + const receipt = await receiptService.getReceiptById( + params.receiptId, + userProfile.user.user_id, + req.log, + ); + + // Queue for reprocessing via BullMQ + const bindings = req.log.bindings?.() || {}; + const job = await receiptQueue.add( + 'process-receipt', + { + receiptId: receipt.receipt_id, + userId: userProfile.user.user_id, + imagePath: receipt.receipt_image_url, // Use stored image path + meta: { + requestId: bindings.request_id as string | undefined, + userId: userProfile.user.user_id, + origin: 'api-reprocess', + }, + }, + { + jobId: `receipt-${receipt.receipt_id}-reprocess-${Date.now()}`, + }, + ); + + req.log.info( + { receiptId: params.receiptId, jobId: job.id }, + 'Receipt queued for reprocessing', + ); + + sendSuccess(res, { + message: 'Receipt queued for reprocessing', + receipt_id: receipt.receipt_id, + job_id: job.id, + }); + } catch (error) { + req.log.error( + { error, userId: userProfile.user.user_id, receiptId: params.receiptId }, + 'Error reprocessing receipt', + ); + next(error); + } + }, +); + +// ============================================================================ +// RECEIPT ITEMS ENDPOINTS +// ============================================================================ + +/** + * @openapi + * /receipts/{receiptId}/items: + * get: + * tags: [Receipts] + * summary: Get receipt items + * description: Get all extracted items from a receipt. + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: receiptId + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Receipt items retrieved + * 401: + * description: Unauthorized + * 404: + * description: Receipt not found + */ +router.get( + '/:receiptId/items', + validateRequest(receiptIdParamSchema), + async (req: Request, res: Response, next: NextFunction) => { + const userProfile = req.user as UserProfile; + type GetItemsRequest = z.infer; + const { params } = req as unknown as GetItemsRequest; + + try { + // Verify receipt belongs to user + await receiptService.getReceiptById(params.receiptId, userProfile.user.user_id, req.log); + + const items = await receiptService.getReceiptItems(params.receiptId, req.log); + sendSuccess(res, { items, total: items.length }); + } catch (error) { + req.log.error( + { error, userId: userProfile.user.user_id, receiptId: params.receiptId }, + 'Error fetching receipt items', + ); + next(error); + } + }, +); + +/** + * @openapi + * /receipts/{receiptId}/items/{itemId}: + * put: + * tags: [Receipts] + * summary: Update receipt item + * description: Update a receipt item's matching status or linked product. + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: receiptId + * required: true + * schema: + * type: integer + * - in: path + * name: itemId + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * enum: [unmatched, matched, needs_review, ignored] + * master_item_id: + * type: integer + * nullable: true + * product_id: + * type: integer + * nullable: true + * match_confidence: + * type: number + * minimum: 0 + * maximum: 1 + * responses: + * 200: + * description: Item updated + * 400: + * description: Validation error + * 401: + * description: Unauthorized + * 404: + * description: Receipt or item not found + */ +router.put( + '/:receiptId/items/:itemId', + validateRequest(updateReceiptItemSchema), + async (req: Request, res: Response, next: NextFunction) => { + const userProfile = req.user as UserProfile; + type UpdateItemRequest = z.infer; + const { params, body } = req as unknown as UpdateItemRequest; + + try { + // Verify receipt belongs to user + await receiptService.getReceiptById(params.receiptId, userProfile.user.user_id, req.log); + + const item = await receiptService.updateReceiptItem(params.itemId, body, req.log); + sendSuccess(res, item); + } catch (error) { + req.log.error( + { + error, + userId: userProfile.user.user_id, + receiptId: params.receiptId, + itemId: params.itemId, + }, + 'Error updating receipt item', + ); + next(error); + } + }, +); + +/** + * @openapi + * /receipts/{receiptId}/items/unadded: + * get: + * tags: [Receipts] + * summary: Get unadded items + * description: Get receipt items that haven't been added to inventory yet. + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: receiptId + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Unadded items retrieved + * 401: + * description: Unauthorized + * 404: + * description: Receipt not found + */ +router.get( + '/:receiptId/items/unadded', + validateRequest(receiptIdParamSchema), + async (req: Request, res: Response, next: NextFunction) => { + const userProfile = req.user as UserProfile; + type GetUnaddedRequest = z.infer; + const { params } = req as unknown as GetUnaddedRequest; + + try { + // Verify receipt belongs to user + await receiptService.getReceiptById(params.receiptId, userProfile.user.user_id, req.log); + + const items = await receiptService.getUnaddedItems(params.receiptId, req.log); + sendSuccess(res, { items, total: items.length }); + } catch (error) { + req.log.error( + { error, userId: userProfile.user.user_id, receiptId: params.receiptId }, + 'Error fetching unadded receipt items', + ); + next(error); + } + }, +); + +/** + * @openapi + * /receipts/{receiptId}/confirm: + * post: + * tags: [Receipts] + * summary: Confirm items for inventory + * description: Confirm selected receipt items to add to user's inventory. + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: receiptId + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - items + * properties: + * items: + * type: array + * items: + * type: object + * required: + * - receipt_item_id + * - include + * properties: + * receipt_item_id: + * type: integer + * item_name: + * type: string + * maxLength: 255 + * quantity: + * type: number + * minimum: 0 + * location: + * type: string + * enum: [fridge, freezer, pantry, room_temp] + * expiry_date: + * type: string + * format: date + * include: + * type: boolean + * responses: + * 200: + * description: Items added to inventory + * 400: + * description: Validation error + * 401: + * description: Unauthorized + * 404: + * description: Receipt not found + */ +router.post( + '/:receiptId/confirm', + validateRequest(confirmItemsSchema), + async (req: Request, res: Response, next: NextFunction) => { + const userProfile = req.user as UserProfile; + type ConfirmItemsRequest = z.infer; + const { params, body } = req as unknown as ConfirmItemsRequest; + + try { + req.log.info( + { + userId: userProfile.user.user_id, + receiptId: params.receiptId, + itemCount: body.items.length, + }, + 'Confirming receipt items for inventory', + ); + + const addedItems = await expiryService.addItemsFromReceipt( + userProfile.user.user_id, + params.receiptId, + body.items, + req.log, + ); + + sendSuccess(res, { added_items: addedItems, count: addedItems.length }); + } catch (error) { + req.log.error( + { error, userId: userProfile.user.user_id, receiptId: params.receiptId }, + 'Error confirming receipt items', + ); + next(error); + } + }, +); + +// ============================================================================ +// PROCESSING LOGS ENDPOINT +// ============================================================================ + +/** + * @openapi + * /receipts/{receiptId}/logs: + * get: + * tags: [Receipts] + * summary: Get processing logs + * description: Get the processing log history for a receipt. + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: receiptId + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Processing logs retrieved + * 401: + * description: Unauthorized + * 404: + * description: Receipt not found + */ +router.get( + '/:receiptId/logs', + validateRequest(receiptIdParamSchema), + async (req: Request, res: Response, next: NextFunction) => { + const userProfile = req.user as UserProfile; + type GetLogsRequest = z.infer; + const { params } = req as unknown as GetLogsRequest; + + try { + // Verify receipt belongs to user + await receiptService.getReceiptById(params.receiptId, userProfile.user.user_id, req.log); + + const logs = await receiptService.getProcessingLogs(params.receiptId, req.log); + sendSuccess(res, { logs, total: logs.length }); + } catch (error) { + req.log.error( + { error, userId: userProfile.user.user_id, receiptId: params.receiptId }, + 'Error fetching processing logs', + ); + next(error); + } + }, +); + +/* Catches errors from multer (e.g., file size, file filter) */ +router.use(handleMulterError); + +export default router; diff --git a/src/routes/recipe.routes.ts b/src/routes/recipe.routes.ts index 1869319..2ecdf77 100644 --- a/src/routes/recipe.routes.ts +++ b/src/routes/recipe.routes.ts @@ -3,7 +3,7 @@ import { Router } from 'express'; import { z } from 'zod'; import * as db from '../services/db/index.db'; import { aiService } from '../services/aiService.server'; -import passport from './passport.routes'; +import passport from '../config/passport'; import { validateRequest } from '../middleware/validation.middleware'; import { requiredString, numericIdParam, optionalNumeric } from '../utils/zodUtils'; import { publicReadLimiter, suggestionLimiter, userUpdateLimiter } from '../config/rateLimiters'; diff --git a/src/routes/upc.routes.test.ts b/src/routes/upc.routes.test.ts new file mode 100644 index 0000000..bb8ce57 --- /dev/null +++ b/src/routes/upc.routes.test.ts @@ -0,0 +1,525 @@ +// src/routes/upc.routes.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import supertest from 'supertest'; +import type { Request, Response, NextFunction } from 'express'; +import { createMockUserProfile } from '../tests/utils/mockFactories'; +import { createTestApp } from '../tests/utils/createTestApp'; +import { NotFoundError } from '../services/db/errors.db'; +import type { UpcScanSource } from '../types/upc'; + +// Mock the upcService module +vi.mock('../services/upcService.server', () => ({ + scanUpc: vi.fn(), + lookupUpc: vi.fn(), + getScanHistory: vi.fn(), + getScanById: vi.fn(), + getScanStats: vi.fn(), + linkUpcToProduct: vi.fn(), +})); + +// Mock the logger to keep test output clean +vi.mock('../services/logger.server', async () => ({ + logger: (await import('../tests/utils/mockLogger')).mockLogger, +})); + +// Import the router and mocked service AFTER all mocks are defined. +import upcRouter from './upc.routes'; +import * as upcService from '../services/upcService.server'; + +const mockUser = createMockUserProfile({ + user: { user_id: 'user-123', email: 'test@test.com' }, +}); + +const _mockAdminUser = createMockUserProfile({ + user: { user_id: 'admin-123', email: 'admin@test.com' }, + role: 'admin', +}); + +// Standardized mock for passport +vi.mock('../config/passport', () => ({ + default: { + authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => { + req.user = mockUser; + next(); + }), + initialize: () => (req: Request, res: Response, next: NextFunction) => next(), + }, + isAdmin: (req: Request, res: Response, next: NextFunction) => { + const user = req.user as typeof _mockAdminUser; + if (user?.role === 'admin') { + next(); + } else { + res.status(403).json({ success: false, error: { message: 'Forbidden' } }); + } + }, +})); + +// Define a reusable matcher for the logger object. +const expectLogger = expect.objectContaining({ + info: expect.any(Function), + error: expect.any(Function), +}); + +describe('UPC Routes (/api/upc)', () => { + const mockUserProfile = createMockUserProfile({ + user: { user_id: 'user-123', email: 'test@test.com' }, + }); + + const mockAdminProfile = createMockUserProfile({ + user: { user_id: 'admin-123', email: 'admin@test.com' }, + role: 'admin', + }); + + beforeEach(() => { + vi.clearAllMocks(); + // Provide default mock implementations + vi.mocked(upcService.getScanHistory).mockResolvedValue({ scans: [], total: 0 }); + vi.mocked(upcService.getScanStats).mockResolvedValue({ + total_scans: 0, + successful_lookups: 0, + unique_products: 0, + scans_today: 0, + scans_this_week: 0, + }); + }); + + const app = createTestApp({ + router: upcRouter, + basePath: '/api/upc', + authenticatedUser: mockUserProfile, + }); + + const adminApp = createTestApp({ + router: upcRouter, + basePath: '/api/upc', + authenticatedUser: mockAdminProfile, + }); + + describe('POST /scan', () => { + it('should scan a manually entered UPC code successfully', async () => { + const mockScanResult = { + scan_id: 1, + upc_code: '012345678905', + product: { + product_id: 1, + name: 'Test Product', + brand: 'Test Brand', + category: 'Snacks', + description: null, + size: '500g', + upc_code: '012345678905', + image_url: null, + master_item_id: null, + }, + external_lookup: null, + confidence: null, + lookup_successful: true, + is_new_product: false, + scanned_at: new Date().toISOString(), + }; + + vi.mocked(upcService.scanUpc).mockResolvedValue(mockScanResult); + + const response = await supertest(app).post('/api/upc/scan').send({ + upc_code: '012345678905', + scan_source: 'manual_entry', + }); + + expect(response.status).toBe(200); + expect(response.body.data.scan_id).toBe(1); + expect(response.body.data.upc_code).toBe('012345678905'); + expect(response.body.data.lookup_successful).toBe(true); + expect(upcService.scanUpc).toHaveBeenCalledWith( + mockUserProfile.user.user_id, + { upc_code: '012345678905', scan_source: 'manual_entry' }, + expectLogger, + ); + }); + + it('should scan from base64 image', async () => { + const mockScanResult = { + scan_id: 2, + upc_code: '987654321098', + product: null, + external_lookup: { + name: 'External Product', + brand: 'External Brand', + category: null, + description: null, + image_url: null, + source: 'openfoodfacts' as const, + }, + confidence: 0.95, + lookup_successful: true, + is_new_product: true, + scanned_at: new Date().toISOString(), + }; + + vi.mocked(upcService.scanUpc).mockResolvedValue(mockScanResult); + + const response = await supertest(app).post('/api/upc/scan').send({ + image_base64: 'SGVsbG8gV29ybGQ=', + scan_source: 'image_upload', + }); + + expect(response.status).toBe(200); + expect(response.body.data.confidence).toBe(0.95); + expect(response.body.data.is_new_product).toBe(true); + }); + + it('should return 400 when neither upc_code nor image_base64 is provided', async () => { + const response = await supertest(app).post('/api/upc/scan').send({ + scan_source: 'manual_entry', + }); + + expect(response.status).toBe(400); + expect(response.body.error.details).toBeDefined(); + }); + + it('should return 400 for invalid scan_source', async () => { + const response = await supertest(app).post('/api/upc/scan').send({ + upc_code: '012345678905', + scan_source: 'invalid_source', + }); + + expect(response.status).toBe(400); + }); + + it('should return 500 if the scan service fails', async () => { + vi.mocked(upcService.scanUpc).mockRejectedValue(new Error('Scan service error')); + + const response = await supertest(app).post('/api/upc/scan').send({ + upc_code: '012345678905', + scan_source: 'manual_entry', + }); + + expect(response.status).toBe(500); + expect(response.body.error.message).toBe('Scan service error'); + }); + }); + + describe('GET /lookup', () => { + it('should look up a UPC code successfully', async () => { + const mockLookupResult = { + upc_code: '012345678905', + product: { + product_id: 1, + name: 'Test Product', + brand: 'Test Brand', + category: 'Snacks', + description: null, + size: '500g', + upc_code: '012345678905', + image_url: null, + master_item_id: null, + }, + external_lookup: null, + found: true, + from_cache: false, + }; + + vi.mocked(upcService.lookupUpc).mockResolvedValue(mockLookupResult); + + const response = await supertest(app).get('/api/upc/lookup?upc_code=012345678905'); + + expect(response.status).toBe(200); + expect(response.body.data.upc_code).toBe('012345678905'); + expect(response.body.data.found).toBe(true); + }); + + it('should support include_external and force_refresh parameters', async () => { + const mockLookupResult = { + upc_code: '012345678905', + product: null, + external_lookup: { + name: 'External Product', + brand: 'External Brand', + category: null, + description: null, + image_url: null, + source: 'openfoodfacts' as const, + }, + found: true, + from_cache: false, + }; + + vi.mocked(upcService.lookupUpc).mockResolvedValue(mockLookupResult); + + const response = await supertest(app).get( + '/api/upc/lookup?upc_code=012345678905&include_external=true&force_refresh=true', + ); + + expect(response.status).toBe(200); + expect(upcService.lookupUpc).toHaveBeenCalledWith( + expect.objectContaining({ + upc_code: '012345678905', + force_refresh: true, + }), + expectLogger, + ); + }); + + it('should return 400 for invalid UPC code format', async () => { + const response = await supertest(app).get('/api/upc/lookup?upc_code=123'); + + expect(response.status).toBe(400); + expect(response.body.error.details[0].message).toMatch(/8-14 digits/); + }); + + it('should return 400 when upc_code is missing', async () => { + const response = await supertest(app).get('/api/upc/lookup'); + + expect(response.status).toBe(400); + }); + + it('should return 500 if the lookup service fails', async () => { + vi.mocked(upcService.lookupUpc).mockRejectedValue(new Error('Lookup error')); + + const response = await supertest(app).get('/api/upc/lookup?upc_code=012345678905'); + + expect(response.status).toBe(500); + }); + }); + + describe('GET /history', () => { + it('should return scan history with pagination', async () => { + const mockHistory = { + scans: [ + { + scan_id: 1, + user_id: 'user-123', + upc_code: '012345678905', + product_id: 1, + scan_source: 'manual_entry' as UpcScanSource, + scan_confidence: null, + raw_image_path: null, + lookup_successful: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ], + total: 1, + }; + + vi.mocked(upcService.getScanHistory).mockResolvedValue(mockHistory); + + const response = await supertest(app).get('/api/upc/history?limit=10&offset=0'); + + expect(response.status).toBe(200); + expect(response.body.data.scans).toHaveLength(1); + expect(response.body.data.total).toBe(1); + expect(upcService.getScanHistory).toHaveBeenCalledWith( + expect.objectContaining({ + user_id: mockUserProfile.user.user_id, + limit: 10, + offset: 0, + }), + expectLogger, + ); + }); + + it('should support filtering by lookup_successful', async () => { + vi.mocked(upcService.getScanHistory).mockResolvedValue({ scans: [], total: 0 }); + + const response = await supertest(app).get('/api/upc/history?lookup_successful=true'); + + expect(response.status).toBe(200); + expect(upcService.getScanHistory).toHaveBeenCalledWith( + expect.objectContaining({ + lookup_successful: true, + }), + expectLogger, + ); + }); + + it('should support filtering by scan_source', async () => { + vi.mocked(upcService.getScanHistory).mockResolvedValue({ scans: [], total: 0 }); + + const response = await supertest(app).get('/api/upc/history?scan_source=image_upload'); + + expect(response.status).toBe(200); + expect(upcService.getScanHistory).toHaveBeenCalledWith( + expect.objectContaining({ + scan_source: 'image_upload', + }), + expectLogger, + ); + }); + + it('should support filtering by date range', async () => { + vi.mocked(upcService.getScanHistory).mockResolvedValue({ scans: [], total: 0 }); + + const response = await supertest(app).get( + '/api/upc/history?from_date=2024-01-01&to_date=2024-01-31', + ); + + expect(response.status).toBe(200); + expect(upcService.getScanHistory).toHaveBeenCalledWith( + expect.objectContaining({ + from_date: '2024-01-01', + to_date: '2024-01-31', + }), + expectLogger, + ); + }); + + it('should return 400 for invalid date format', async () => { + const response = await supertest(app).get('/api/upc/history?from_date=01-01-2024'); + + expect(response.status).toBe(400); + }); + + it('should return 500 if the history service fails', async () => { + vi.mocked(upcService.getScanHistory).mockRejectedValue(new Error('History error')); + + const response = await supertest(app).get('/api/upc/history'); + + expect(response.status).toBe(500); + }); + }); + + describe('GET /history/:scanId', () => { + it('should return a specific scan by ID', async () => { + const mockScan = { + scan_id: 1, + user_id: 'user-123', + upc_code: '012345678905', + product_id: 1, + scan_source: 'manual_entry' as UpcScanSource, + scan_confidence: null, + raw_image_path: null, + lookup_successful: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + vi.mocked(upcService.getScanById).mockResolvedValue(mockScan); + + const response = await supertest(app).get('/api/upc/history/1'); + + expect(response.status).toBe(200); + expect(response.body.data.scan_id).toBe(1); + expect(upcService.getScanById).toHaveBeenCalledWith( + 1, + mockUserProfile.user.user_id, + expectLogger, + ); + }); + + it('should return 404 when scan not found', async () => { + vi.mocked(upcService.getScanById).mockRejectedValue(new NotFoundError('Scan not found')); + + const response = await supertest(app).get('/api/upc/history/999'); + + expect(response.status).toBe(404); + expect(response.body.error.message).toBe('Scan not found'); + }); + + it('should return 400 for invalid scan ID', async () => { + const response = await supertest(app).get('/api/upc/history/abc'); + + expect(response.status).toBe(400); + expect(response.body.error.details[0].message).toMatch(/Invalid ID|number/i); + }); + }); + + describe('GET /stats', () => { + it('should return scan statistics', async () => { + const mockStats = { + total_scans: 100, + successful_lookups: 80, + unique_products: 50, + scans_today: 5, + scans_this_week: 25, + }; + + vi.mocked(upcService.getScanStats).mockResolvedValue(mockStats); + + const response = await supertest(app).get('/api/upc/stats'); + + expect(response.status).toBe(200); + expect(response.body.data.total_scans).toBe(100); + expect(response.body.data.successful_lookups).toBe(80); + expect(upcService.getScanStats).toHaveBeenCalledWith( + mockUserProfile.user.user_id, + expectLogger, + ); + }); + + it('should return 500 if the stats service fails', async () => { + vi.mocked(upcService.getScanStats).mockRejectedValue(new Error('Stats error')); + + const response = await supertest(app).get('/api/upc/stats'); + + expect(response.status).toBe(500); + }); + }); + + describe('POST /link', () => { + it('should link UPC to product (admin only)', async () => { + vi.mocked(upcService.linkUpcToProduct).mockResolvedValue(undefined); + + const response = await supertest(adminApp).post('/api/upc/link').send({ + upc_code: '012345678905', + product_id: 1, + }); + + expect(response.status).toBe(204); + expect(upcService.linkUpcToProduct).toHaveBeenCalledWith(1, '012345678905', expectLogger); + }); + + it('should return 403 for non-admin users', async () => { + const response = await supertest(app).post('/api/upc/link').send({ + upc_code: '012345678905', + product_id: 1, + }); + + expect(response.status).toBe(403); + expect(upcService.linkUpcToProduct).not.toHaveBeenCalled(); + }); + + it('should return 400 for invalid UPC code format', async () => { + const response = await supertest(adminApp).post('/api/upc/link').send({ + upc_code: '123', + product_id: 1, + }); + + expect(response.status).toBe(400); + expect(response.body.error.details[0].message).toMatch(/8-14 digits/); + }); + + it('should return 400 for invalid product_id', async () => { + const response = await supertest(adminApp).post('/api/upc/link').send({ + upc_code: '012345678905', + product_id: -1, + }); + + expect(response.status).toBe(400); + }); + + it('should return 404 when product not found', async () => { + vi.mocked(upcService.linkUpcToProduct).mockRejectedValue( + new NotFoundError('Product not found'), + ); + + const response = await supertest(adminApp).post('/api/upc/link').send({ + upc_code: '012345678905', + product_id: 999, + }); + + expect(response.status).toBe(404); + expect(response.body.error.message).toBe('Product not found'); + }); + + it('should return 500 if the link service fails', async () => { + vi.mocked(upcService.linkUpcToProduct).mockRejectedValue(new Error('Link error')); + + const response = await supertest(adminApp).post('/api/upc/link').send({ + upc_code: '012345678905', + product_id: 1, + }); + + expect(response.status).toBe(500); + }); + }); +}); diff --git a/src/routes/upc.routes.ts b/src/routes/upc.routes.ts new file mode 100644 index 0000000..542a302 --- /dev/null +++ b/src/routes/upc.routes.ts @@ -0,0 +1,493 @@ +// src/routes/upc.routes.ts +/** + * @file UPC Scanning API Routes + * Provides endpoints for UPC barcode scanning, lookup, and scan history. + */ +import express, { Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import passport, { isAdmin } from '../config/passport'; +import type { UserProfile } from '../types'; +import { validateRequest } from '../middleware/validation.middleware'; +import { numericIdParam, optionalNumeric } from '../utils/zodUtils'; +import { sendSuccess, sendNoContent } from '../utils/apiResponse'; +import * as upcService from '../services/upcService.server'; + +const router = express.Router(); + +// --- Zod Schemas for UPC Routes --- + +/** + * UPC code validation (8-14 digits) + */ +const upcCodeSchema = z.string().regex(/^[0-9]{8,14}$/, 'UPC code must be 8-14 digits.'); + +/** + * Scan source validation + */ +const scanSourceSchema = z.enum(['image_upload', 'manual_entry', 'phone_app', 'camera_scan']); + +/** + * Schema for UPC scan request + */ +const scanUpcSchema = z.object({ + body: z + .object({ + upc_code: z.string().optional(), + image_base64: z.string().optional(), + scan_source: scanSourceSchema, + }) + .refine((data) => data.upc_code || data.image_base64, { + message: 'Either upc_code or image_base64 must be provided.', + }), +}); + +/** + * Schema for UPC lookup request (without recording scan) + */ +const lookupUpcSchema = z.object({ + query: z.object({ + upc_code: upcCodeSchema, + include_external: z + .string() + .optional() + .transform((val) => val === 'true'), + force_refresh: z + .string() + .optional() + .transform((val) => val === 'true'), + }), +}); + +/** + * Schema for linking UPC to product (admin) + */ +const linkUpcSchema = z.object({ + body: z.object({ + upc_code: upcCodeSchema, + product_id: z.number().int().positive('Product ID must be a positive integer.'), + }), +}); + +/** + * Schema for scan ID parameter + */ +const scanIdParamSchema = numericIdParam( + 'scanId', + "Invalid ID for parameter 'scanId'. Must be a number.", +); + +/** + * Schema for scan history query + */ +const scanHistoryQuerySchema = z.object({ + query: z.object({ + limit: optionalNumeric({ default: 50, min: 1, max: 100, integer: true }), + offset: optionalNumeric({ default: 0, min: 0, integer: true }), + lookup_successful: z + .string() + .optional() + .transform((val) => (val === 'true' ? true : val === 'false' ? false : undefined)), + scan_source: scanSourceSchema.optional(), + from_date: z.string().date().optional(), + to_date: z.string().date().optional(), + }), +}); + +// Middleware to ensure user is authenticated for all UPC routes +router.use(passport.authenticate('jwt', { session: false })); + +/** + * @openapi + * /upc/scan: + * post: + * tags: [UPC Scanning] + * summary: Scan a UPC barcode + * description: > + * Scans a UPC barcode either from a manually entered code or from an image. + * Records the scan in history and returns product information if found. + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - scan_source + * properties: + * upc_code: + * type: string + * pattern: '^[0-9]{8,14}$' + * description: UPC code (8-14 digits). Required if image_base64 is not provided. + * image_base64: + * type: string + * description: Base64-encoded image containing a barcode. Required if upc_code is not provided. + * scan_source: + * type: string + * enum: [image_upload, manual_entry, phone_app, camera_scan] + * description: How the scan was initiated. + * responses: + * 200: + * description: Scan completed successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SuccessResponse' + * 400: + * description: Validation error - invalid UPC code or missing data + * 401: + * description: Unauthorized - invalid or missing token + */ +router.post( + '/scan', + validateRequest(scanUpcSchema), + async (req: Request, res: Response, next: NextFunction) => { + const userProfile = req.user as UserProfile; + type ScanUpcRequest = z.infer; + const { body } = req as unknown as ScanUpcRequest; + + try { + req.log.info( + { userId: userProfile.user.user_id, scanSource: body.scan_source }, + 'UPC scan request received', + ); + + const result = await upcService.scanUpc(userProfile.user.user_id, body, req.log); + sendSuccess(res, result); + } catch (error) { + req.log.error( + { error, userId: userProfile.user.user_id, scanSource: body.scan_source }, + 'Error processing UPC scan', + ); + next(error); + } + }, +); + +/** + * @openapi + * /upc/lookup: + * get: + * tags: [UPC Scanning] + * summary: Look up a UPC code + * description: > + * Looks up product information for a UPC code without recording in scan history. + * Useful for verification or quick lookups. + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: upc_code + * required: true + * schema: + * type: string + * pattern: '^[0-9]{8,14}$' + * description: UPC code to look up (8-14 digits) + * - in: query + * name: include_external + * schema: + * type: boolean + * default: true + * description: Whether to check external APIs if not found locally + * - in: query + * name: force_refresh + * schema: + * type: boolean + * default: false + * description: Skip cache and perform fresh external lookup + * responses: + * 200: + * description: Lookup completed + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SuccessResponse' + * 400: + * description: Invalid UPC code format + * 401: + * description: Unauthorized - invalid or missing token + */ +router.get( + '/lookup', + validateRequest(lookupUpcSchema), + async (req: Request, res: Response, next: NextFunction) => { + type LookupUpcRequest = z.infer; + const { query } = req as unknown as LookupUpcRequest; + + try { + req.log.debug({ upcCode: query.upc_code }, 'UPC lookup request received'); + + const result = await upcService.lookupUpc( + { + upc_code: query.upc_code, + force_refresh: query.force_refresh, + }, + req.log, + ); + sendSuccess(res, result); + } catch (error) { + req.log.error({ error, upcCode: query.upc_code }, 'Error looking up UPC'); + next(error); + } + }, +); + +/** + * @openapi + * /upc/history: + * get: + * tags: [UPC Scanning] + * summary: Get scan history + * description: Retrieve the authenticated user's UPC scan history with optional filtering. + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: limit + * schema: + * type: integer + * minimum: 1 + * maximum: 100 + * default: 50 + * description: Maximum number of results + * - in: query + * name: offset + * schema: + * type: integer + * minimum: 0 + * default: 0 + * description: Number of results to skip + * - in: query + * name: lookup_successful + * schema: + * type: boolean + * description: Filter by lookup success status + * - in: query + * name: scan_source + * schema: + * type: string + * enum: [image_upload, manual_entry, phone_app, camera_scan] + * description: Filter by scan source + * - in: query + * name: from_date + * schema: + * type: string + * format: date + * description: Filter scans from this date (YYYY-MM-DD) + * - in: query + * name: to_date + * schema: + * type: string + * format: date + * description: Filter scans until this date (YYYY-MM-DD) + * responses: + * 200: + * description: Scan history retrieved + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SuccessResponse' + * 401: + * description: Unauthorized - invalid or missing token + */ +router.get( + '/history', + validateRequest(scanHistoryQuerySchema), + async (req: Request, res: Response, next: NextFunction) => { + const userProfile = req.user as UserProfile; + type ScanHistoryRequest = z.infer; + const { query } = req as unknown as ScanHistoryRequest; + + try { + const result = await upcService.getScanHistory( + { + user_id: userProfile.user.user_id, + limit: query.limit, + offset: query.offset, + lookup_successful: query.lookup_successful, + scan_source: query.scan_source, + from_date: query.from_date, + to_date: query.to_date, + }, + req.log, + ); + sendSuccess(res, result); + } catch (error) { + req.log.error({ error, userId: userProfile.user.user_id }, 'Error fetching scan history'); + next(error); + } + }, +); + +/** + * @openapi + * /upc/history/{scanId}: + * get: + * tags: [UPC Scanning] + * summary: Get scan by ID + * description: Retrieve a specific scan record by its ID. + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: scanId + * required: true + * schema: + * type: integer + * description: Scan ID + * responses: + * 200: + * description: Scan record retrieved + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SuccessResponse' + * 401: + * description: Unauthorized - invalid or missing token + * 404: + * description: Scan record not found + */ +router.get( + '/history/:scanId', + validateRequest(scanIdParamSchema), + async (req: Request, res: Response, next: NextFunction) => { + const userProfile = req.user as UserProfile; + type GetScanRequest = z.infer; + const { params } = req as unknown as GetScanRequest; + + try { + const scan = await upcService.getScanById(params.scanId, userProfile.user.user_id, req.log); + sendSuccess(res, scan); + } catch (error) { + req.log.error( + { error, userId: userProfile.user.user_id, scanId: params.scanId }, + 'Error fetching scan by ID', + ); + next(error); + } + }, +); + +/** + * @openapi + * /upc/stats: + * get: + * tags: [UPC Scanning] + * summary: Get scan statistics + * description: Get scanning statistics for the authenticated user. + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Scan statistics retrieved + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * data: + * type: object + * properties: + * total_scans: + * type: integer + * successful_lookups: + * type: integer + * unique_products: + * type: integer + * scans_today: + * type: integer + * scans_this_week: + * type: integer + * 401: + * description: Unauthorized - invalid or missing token + */ +router.get('/stats', async (req: Request, res: Response, next: NextFunction) => { + const userProfile = req.user as UserProfile; + + try { + const stats = await upcService.getScanStats(userProfile.user.user_id, req.log); + sendSuccess(res, stats); + } catch (error) { + req.log.error({ error, userId: userProfile.user.user_id }, 'Error fetching scan statistics'); + next(error); + } +}); + +/** + * @openapi + * /upc/link: + * post: + * tags: [UPC Scanning] + * summary: Link UPC to product (Admin) + * description: > + * Links a UPC code to an existing product in the database. + * This is an admin-only operation. + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - upc_code + * - product_id + * properties: + * upc_code: + * type: string + * pattern: '^[0-9]{8,14}$' + * description: UPC code to link (8-14 digits) + * product_id: + * type: integer + * description: Product ID to link the UPC to + * responses: + * 204: + * description: UPC linked successfully + * 400: + * description: Invalid UPC code or product ID + * 401: + * description: Unauthorized - invalid or missing token + * 403: + * description: Forbidden - user is not an admin + * 404: + * description: Product not found + * 409: + * description: UPC code already linked to another product + */ +router.post( + '/link', + isAdmin, // Admin role check - only admins can link UPC codes to products + validateRequest(linkUpcSchema), + async (req: Request, res: Response, next: NextFunction) => { + const userProfile = req.user as UserProfile; + type LinkUpcRequest = z.infer; + const { body } = req as unknown as LinkUpcRequest; + + try { + req.log.info( + { userId: userProfile.user.user_id, productId: body.product_id, upcCode: body.upc_code }, + 'UPC link request received', + ); + + await upcService.linkUpcToProduct(body.product_id, body.upc_code, req.log); + sendNoContent(res); + } catch (error) { + req.log.error( + { + error, + userId: userProfile.user.user_id, + productId: body.product_id, + upcCode: body.upc_code, + }, + 'Error linking UPC to product', + ); + next(error); + } + }, +); + +export default router; diff --git a/src/routes/user.routes.ts b/src/routes/user.routes.ts index 95fae67..0dbe7db 100644 --- a/src/routes/user.routes.ts +++ b/src/routes/user.routes.ts @@ -1,6 +1,6 @@ // src/routes/user.routes.ts import express, { Request, Response, NextFunction } from 'express'; -import passport from './passport.routes'; +import passport from '../config/passport'; // All route handlers now use req.log (request-scoped logger) as per ADR-004 import { z } from 'zod'; // Removed: import { logger } from '../services/logger.server'; diff --git a/src/services/aiService.server.ts b/src/services/aiService.server.ts index 5f07cbf..b2a8e52 100644 --- a/src/services/aiService.server.ts +++ b/src/services/aiService.server.ts @@ -819,7 +819,8 @@ export class AIService { logger.info({ baseUrl }, '[aiService] Enqueuing job with valid baseUrl.'); // --- END DEBUGGING --- - // 3. Add job to the queue + // 3. Add job to the queue with context propagation (ADR-051) + const bindings = logger.bindings?.() || {}; const job = await flyerQueue.add('process-flyer', { filePath: file.path, originalFileName: file.originalname, @@ -828,6 +829,11 @@ export class AIService { submitterIp: submitterIp, userProfileAddress: userProfileAddress, baseUrl: baseUrl, + meta: { + requestId: bindings.request_id as string | undefined, + userId: userProfile?.user.user_id, + origin: 'api', + }, }); logger.info(`Enqueued flyer for processing. File: ${file.originalname}, Job ID: ${job.id}`); @@ -1005,5 +1011,5 @@ export class AIService { } // Export a singleton instance of the service for use throughout the application. -import { logger } from './logger.server'; -export const aiService = new AIService(logger); +import { createScopedLogger } from './logger.server'; +export const aiService = new AIService(createScopedLogger('ai-service')); diff --git a/src/services/apiClient.test.ts b/src/services/apiClient.test.ts index e2fe63f..74ccfd7 100644 --- a/src/services/apiClient.test.ts +++ b/src/services/apiClient.test.ts @@ -197,6 +197,23 @@ describe('API Client', () => { ); }); + it('should handle x-request-id header on failure (Sentry optional)', async () => { + const requestId = 'req-123'; + + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: false, + status: 500, + headers: new Headers({ 'x-request-id': requestId }), + clone: () => ({ text: () => Promise.resolve('Error') }), + } as Response); + + // This should not throw even if Sentry is not installed + await apiClient.apiFetch('/error'); + + // The request should complete without error + expect(true).toBe(true); + }); + it('should handle 401 on initial call, refresh token, and then poll until completed', async () => { localStorage.setItem('authToken', 'expired-token'); // Mock the global fetch to return a sequence of responses: @@ -301,7 +318,10 @@ describe('API Client', () => { }); it('addWatchedItem should send a POST request with the correct body', async () => { - const watchedItemData = createMockWatchedItemPayload({ itemName: 'Apples', category: 'Produce' }); + const watchedItemData = createMockWatchedItemPayload({ + itemName: 'Apples', + category: 'Produce', + }); await apiClient.addWatchedItem(watchedItemData.itemName, watchedItemData.category); expect(capturedUrl?.pathname).toBe('/api/users/watched-items'); @@ -532,7 +552,10 @@ describe('API Client', () => { it('addRecipeComment should send a POST request with content and optional parentId', async () => { const recipeId = 456; - const commentData = createMockRecipeCommentPayload({ content: 'This is a reply', parentCommentId: 789 }); + const commentData = createMockRecipeCommentPayload({ + content: 'This is a reply', + parentCommentId: 789, + }); await apiClient.addRecipeComment(recipeId, commentData.content, commentData.parentCommentId); expect(capturedUrl?.pathname).toBe(`/api/recipes/${recipeId}/comments`); expect(capturedBody).toEqual(commentData); @@ -646,7 +669,10 @@ describe('API Client', () => { }); it('updateUserAddress should send a PUT request with address data', async () => { - const addressData = createMockAddressPayload({ address_line_1: '123 Main St', city: 'Anytown' }); + const addressData = createMockAddressPayload({ + address_line_1: '123 Main St', + city: 'Anytown', + }); await apiClient.updateUserAddress(addressData); expect(capturedUrl?.pathname).toBe('/api/users/profile/address'); expect(capturedBody).toEqual(addressData); @@ -744,6 +770,16 @@ describe('API Client', () => { expect(capturedUrl?.pathname).toBe('/api/health/redis'); }); + it('getQueueHealth should call the correct health check endpoint', async () => { + server.use( + http.get('http://localhost/api/health/queues', () => { + return HttpResponse.json({}); + }), + ); + await apiClient.getQueueHealth(); + expect(capturedUrl?.pathname).toBe('/api/health/queues'); + }); + it('checkPm2Status should call the correct system endpoint', async () => { server.use( http.get('http://localhost/api/system/pm2-status', () => { @@ -939,7 +975,11 @@ describe('API Client', () => { }); it('logSearchQuery should send a POST request with query data', async () => { - const queryData = createMockSearchQueryPayload({ query_text: 'apples', result_count: 10, was_successful: true }); + const queryData = createMockSearchQueryPayload({ + query_text: 'apples', + result_count: 10, + was_successful: true, + }); await apiClient.logSearchQuery(queryData as any); expect(capturedUrl?.pathname).toBe('/api/search/log'); expect(capturedBody).toEqual(queryData); diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index c55ca9d..19b41b6 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -3,6 +3,16 @@ import { Profile, ShoppingListItem, SearchQuery, Budget, Address } from '../type import { logger } from './logger.client'; import { eventBus } from './eventBus'; +// Sentry integration is optional - only used if @sentry/browser is installed +let Sentry: { setTag?: (key: string, value: string) => void } | null = null; +try { + // Dynamic import would be cleaner but this keeps the code synchronous + // eslint-disable-next-line @typescript-eslint/no-require-imports + Sentry = require('@sentry/browser'); +} catch { + // Sentry not installed, skip error tracking integration +} + // This constant should point to your backend API. // It's often a good practice to store this in an environment variable. // Using a relative path '/api' is the most robust method for production. @@ -148,9 +158,14 @@ export const apiFetch = async ( // --- DEBUG LOGGING for failed requests --- if (!response.ok) { + const requestId = response.headers.get('x-request-id'); + if (requestId && Sentry?.setTag) { + Sentry.setTag('api_request_id', requestId); + } + const responseText = await response.clone().text(); logger.error( - { url: fullUrl, status: response.status, body: responseText }, + { url: fullUrl, status: response.status, body: responseText, requestId }, 'apiFetch: Request failed', ); } @@ -272,6 +287,12 @@ export const checkDbPoolHealth = (): Promise => publicGet('/health/db- */ export const checkRedisHealth = (): Promise => publicGet('/health/redis'); +/** + * Fetches the health status of the background job queues. + * @returns A promise that resolves to the queue status object. + */ +export const getQueueHealth = (): Promise => publicGet('/health/queues'); + /** * Checks the status of the application process managed by PM2. * This is intended for development and diagnostic purposes. diff --git a/src/services/barcodeService.server.test.ts b/src/services/barcodeService.server.test.ts new file mode 100644 index 0000000..4f64e14 --- /dev/null +++ b/src/services/barcodeService.server.test.ts @@ -0,0 +1,404 @@ +// src/services/barcodeService.server.test.ts +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Logger } from 'pino'; +import type { Job } from 'bullmq'; +import type { BarcodeDetectionJobData } from '../types/job-data'; +import { createMockLogger } from '../tests/utils/mockLogger'; + +// Mock dependencies +vi.mock('zxing-wasm/reader', () => ({ + readBarcodesFromImageData: vi.fn(), +})); + +vi.mock('sharp', () => { + const mockSharp = vi.fn(() => ({ + metadata: vi.fn().mockResolvedValue({ width: 100, height: 100 }), + ensureAlpha: vi.fn().mockReturnThis(), + raw: vi.fn().mockReturnThis(), + toBuffer: vi.fn().mockResolvedValue({ + data: new Uint8Array(100 * 100 * 4), + info: { width: 100, height: 100 }, + }), + grayscale: vi.fn().mockReturnThis(), + normalize: vi.fn().mockReturnThis(), + sharpen: vi.fn().mockReturnThis(), + toFile: vi.fn().mockResolvedValue(undefined), + })); + return { default: mockSharp }; +}); + +vi.mock('node:fs/promises', () => ({ + default: { + readFile: vi.fn().mockResolvedValue(Buffer.from('mock image data')), + }, +})); + +vi.mock('./db/index.db', () => ({ + upcRepo: { + updateScanWithDetectedCode: vi.fn().mockResolvedValue(undefined), + }, +})); + +// Import after mocks are set up +import { + detectBarcode, + isValidUpcFormat, + calculateUpcCheckDigit, + validateUpcCheckDigit, + processBarcodeDetectionJob, + detectMultipleBarcodes, + enhanceImageForDetection, +} from './barcodeService.server'; + +describe('barcodeService.server', () => { + let mockLogger: Logger; + + beforeEach(() => { + vi.clearAllMocks(); + mockLogger = createMockLogger(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe('detectBarcode', () => { + it('should detect a valid UPC-A barcode from image', async () => { + const { readBarcodesFromImageData } = await import('zxing-wasm/reader'); + vi.mocked(readBarcodesFromImageData).mockResolvedValueOnce([ + { text: '012345678905', format: 'UPC-A' }, + ] as any); + + const result = await detectBarcode('/path/to/image.jpg', mockLogger); + + expect(result.detected).toBe(true); + expect(result.upc_code).toBe('012345678905'); + expect(result.format).toBe('UPC-A'); + expect(result.confidence).toBe(0.95); + expect(result.error).toBeNull(); + }); + + it('should detect a valid UPC-E barcode from image', async () => { + const { readBarcodesFromImageData } = await import('zxing-wasm/reader'); + vi.mocked(readBarcodesFromImageData).mockResolvedValueOnce([ + { text: '01234567', format: 'UPC-E' }, + ] as any); + + const result = await detectBarcode('/path/to/image.jpg', mockLogger); + + expect(result.detected).toBe(true); + expect(result.upc_code).toBe('01234567'); + expect(result.format).toBe('UPC-E'); + }); + + it('should detect a valid EAN-13 barcode from image', async () => { + const { readBarcodesFromImageData } = await import('zxing-wasm/reader'); + vi.mocked(readBarcodesFromImageData).mockResolvedValueOnce([ + { text: '5901234123457', format: 'EAN-13' }, + ] as any); + + const result = await detectBarcode('/path/to/image.jpg', mockLogger); + + expect(result.detected).toBe(true); + expect(result.upc_code).toBe('5901234123457'); + expect(result.format).toBe('EAN-13'); + }); + + it('should detect a valid EAN-8 barcode from image', async () => { + const { readBarcodesFromImageData } = await import('zxing-wasm/reader'); + vi.mocked(readBarcodesFromImageData).mockResolvedValueOnce([ + { text: '96385074', format: 'EAN-8' }, + ] as any); + + const result = await detectBarcode('/path/to/image.jpg', mockLogger); + + expect(result.detected).toBe(true); + expect(result.upc_code).toBe('96385074'); + expect(result.format).toBe('EAN-8'); + }); + + it('should return detected: false when no barcode found', async () => { + const { readBarcodesFromImageData } = await import('zxing-wasm/reader'); + vi.mocked(readBarcodesFromImageData).mockResolvedValueOnce([]); + + const result = await detectBarcode('/path/to/image.jpg', mockLogger); + + expect(result.detected).toBe(false); + expect(result.upc_code).toBeNull(); + expect(result.confidence).toBeNull(); + expect(result.format).toBeNull(); + expect(result.error).toBeNull(); + }); + + it('should return error when image dimensions cannot be determined', async () => { + const sharp = (await import('sharp')).default; + vi.mocked(sharp).mockReturnValueOnce({ + metadata: vi.fn().mockResolvedValue({}), + ensureAlpha: vi.fn().mockReturnThis(), + raw: vi.fn().mockReturnThis(), + toBuffer: vi.fn(), + } as any); + + const result = await detectBarcode('/path/to/image.jpg', mockLogger); + + expect(result.detected).toBe(false); + expect(result.error).toBe('Could not determine image dimensions'); + }); + + it('should handle errors during detection gracefully', async () => { + const { readBarcodesFromImageData } = await import('zxing-wasm/reader'); + vi.mocked(readBarcodesFromImageData).mockRejectedValueOnce(new Error('Detection failed')); + + const result = await detectBarcode('/path/to/image.jpg', mockLogger); + + expect(result.detected).toBe(false); + expect(result.error).toBe('Detection failed'); + }); + + it('should map unknown barcode formats to "unknown"', async () => { + const { readBarcodesFromImageData } = await import('zxing-wasm/reader'); + vi.mocked(readBarcodesFromImageData).mockResolvedValueOnce([ + { text: '12345678', format: 'SomeFutureFormat' }, + ] as any); + + const result = await detectBarcode('/path/to/image.jpg', mockLogger); + + expect(result.detected).toBe(true); + expect(result.format).toBe('unknown'); + }); + + it('should calculate lower confidence when text is empty', async () => { + const { readBarcodesFromImageData } = await import('zxing-wasm/reader'); + vi.mocked(readBarcodesFromImageData).mockResolvedValueOnce([ + { text: '', format: 'UPC-A' }, + ] as any); + + const result = await detectBarcode('/path/to/image.jpg', mockLogger); + + expect(result.detected).toBe(true); + expect(result.confidence).toBe(0.5); + }); + }); + + describe('isValidUpcFormat', () => { + it('should return true for valid 12-digit UPC-A', () => { + expect(isValidUpcFormat('012345678905')).toBe(true); + }); + + it('should return true for valid 8-digit UPC-E', () => { + expect(isValidUpcFormat('01234567')).toBe(true); + }); + + it('should return true for valid 13-digit EAN-13', () => { + expect(isValidUpcFormat('5901234123457')).toBe(true); + }); + + it('should return true for valid 8-digit EAN-8', () => { + expect(isValidUpcFormat('96385074')).toBe(true); + }); + + it('should return true for valid 14-digit GTIN-14', () => { + expect(isValidUpcFormat('00012345678905')).toBe(true); + }); + + it('should return false for code with less than 8 digits', () => { + expect(isValidUpcFormat('1234567')).toBe(false); + }); + + it('should return false for code with more than 14 digits', () => { + expect(isValidUpcFormat('123456789012345')).toBe(false); + }); + + it('should return false for code with non-numeric characters', () => { + expect(isValidUpcFormat('01234567890A')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(isValidUpcFormat('')).toBe(false); + }); + }); + + describe('calculateUpcCheckDigit', () => { + it('should calculate correct check digit for valid 11-digit code', () => { + // UPC-A: 01234567890 has check digit 5 + expect(calculateUpcCheckDigit('01234567890')).toBe(5); + }); + + it('should return null for code with wrong length', () => { + expect(calculateUpcCheckDigit('1234567890')).toBeNull(); // 10 digits + expect(calculateUpcCheckDigit('123456789012')).toBeNull(); // 12 digits + }); + + it('should return null for code with non-numeric characters', () => { + expect(calculateUpcCheckDigit('0123456789A')).toBeNull(); + }); + + it('should handle all zeros', () => { + // 00000000000 should produce a valid check digit + const checkDigit = calculateUpcCheckDigit('00000000000'); + expect(typeof checkDigit).toBe('number'); + expect(checkDigit).toBeGreaterThanOrEqual(0); + expect(checkDigit).toBeLessThanOrEqual(9); + }); + }); + + describe('validateUpcCheckDigit', () => { + it('should return true for valid UPC-A with correct check digit', () => { + expect(validateUpcCheckDigit('012345678905')).toBe(true); + }); + + it('should return false for UPC-A with incorrect check digit', () => { + expect(validateUpcCheckDigit('012345678901')).toBe(false); + }); + + it('should return false for code with wrong length', () => { + expect(validateUpcCheckDigit('01234567890')).toBe(false); // 11 digits + expect(validateUpcCheckDigit('0123456789012')).toBe(false); // 13 digits + }); + + it('should return false for code with non-numeric characters', () => { + expect(validateUpcCheckDigit('01234567890A')).toBe(false); + }); + }); + + describe('processBarcodeDetectionJob', () => { + it('should process job and update scan record when barcode detected', async () => { + const { readBarcodesFromImageData } = await import('zxing-wasm/reader'); + const { upcRepo } = await import('./db/index.db'); + + vi.mocked(readBarcodesFromImageData).mockResolvedValueOnce([ + { text: '012345678905', format: 'UPC-A' }, + ] as any); + + const mockJob = { + id: 'job-1', + data: { + scanId: 123, + imagePath: '/path/to/barcode.jpg', + userId: 'user-1', + meta: { requestId: 'req-1' }, + }, + } as Job; + + const result = await processBarcodeDetectionJob(mockJob, mockLogger); + + expect(result.detected).toBe(true); + expect(result.upc_code).toBe('012345678905'); + expect(upcRepo.updateScanWithDetectedCode).toHaveBeenCalledWith( + 123, + '012345678905', + 0.95, + expect.any(Object), + ); + }); + + it('should not update scan record when no barcode detected', async () => { + const { readBarcodesFromImageData } = await import('zxing-wasm/reader'); + const { upcRepo } = await import('./db/index.db'); + + vi.mocked(readBarcodesFromImageData).mockResolvedValueOnce([]); + + const mockJob = { + id: 'job-2', + data: { + scanId: 456, + imagePath: '/path/to/no-barcode.jpg', + userId: 'user-2', + }, + } as Job; + + const result = await processBarcodeDetectionJob(mockJob, mockLogger); + + expect(result.detected).toBe(false); + expect(upcRepo.updateScanWithDetectedCode).not.toHaveBeenCalled(); + }); + + it('should return error result when job processing fails', async () => { + const { readBarcodesFromImageData } = await import('zxing-wasm/reader'); + vi.mocked(readBarcodesFromImageData).mockRejectedValueOnce(new Error('Processing error')); + + const mockJob = { + id: 'job-3', + data: { + scanId: 789, + imagePath: '/path/to/error.jpg', + userId: 'user-3', + }, + } as Job; + + const result = await processBarcodeDetectionJob(mockJob, mockLogger); + + expect(result.detected).toBe(false); + expect(result.error).toBe('Processing error'); + }); + }); + + describe('detectMultipleBarcodes', () => { + it('should detect multiple barcodes in an image', async () => { + const { readBarcodesFromImageData } = await import('zxing-wasm/reader'); + vi.mocked(readBarcodesFromImageData).mockResolvedValueOnce([ + { text: '012345678905', format: 'UPC-A' }, + { text: '5901234123457', format: 'EAN-13' }, + { text: '96385074', format: 'EAN-8' }, + ] as any); + + const results = await detectMultipleBarcodes('/path/to/multi.jpg', mockLogger); + + expect(results).toHaveLength(3); + expect(results[0].upc_code).toBe('012345678905'); + expect(results[0].format).toBe('UPC-A'); + expect(results[1].upc_code).toBe('5901234123457'); + expect(results[1].format).toBe('EAN-13'); + expect(results[2].upc_code).toBe('96385074'); + expect(results[2].format).toBe('EAN-8'); + }); + + it('should return empty array when no barcodes detected', async () => { + const { readBarcodesFromImageData } = await import('zxing-wasm/reader'); + vi.mocked(readBarcodesFromImageData).mockResolvedValueOnce([]); + + const results = await detectMultipleBarcodes('/path/to/no-codes.jpg', mockLogger); + + expect(results).toEqual([]); + }); + + it('should return empty array on error', async () => { + const { readBarcodesFromImageData } = await import('zxing-wasm/reader'); + vi.mocked(readBarcodesFromImageData).mockRejectedValueOnce( + new Error('Multi-detection failed'), + ); + + const results = await detectMultipleBarcodes('/path/to/error.jpg', mockLogger); + + expect(results).toEqual([]); + }); + }); + + describe('enhanceImageForDetection', () => { + it('should enhance image and return new path', async () => { + const result = await enhanceImageForDetection('/path/to/image.jpg', mockLogger); + + expect(result).toBe('/path/to/image-enhanced.jpg'); + }); + + it('should handle different file extensions', async () => { + const result = await enhanceImageForDetection('/path/to/image.png', mockLogger); + + expect(result).toBe('/path/to/image-enhanced.png'); + }); + + it('should return original path on enhancement failure', async () => { + const sharp = (await import('sharp')).default; + vi.mocked(sharp).mockReturnValueOnce({ + grayscale: vi.fn().mockReturnThis(), + normalize: vi.fn().mockReturnThis(), + sharpen: vi.fn().mockReturnThis(), + toFile: vi.fn().mockRejectedValue(new Error('Enhancement failed')), + } as any); + + const result = await enhanceImageForDetection('/path/to/image.jpg', mockLogger); + + expect(result).toBe('/path/to/image.jpg'); + }); + }); +}); diff --git a/src/services/barcodeService.server.ts b/src/services/barcodeService.server.ts new file mode 100644 index 0000000..e96f2cd --- /dev/null +++ b/src/services/barcodeService.server.ts @@ -0,0 +1,335 @@ +// src/services/barcodeService.server.ts +/** + * @file Barcode Detection Service + * Provides barcode/UPC detection from images using zxing-wasm. + * Supports UPC-A, UPC-E, EAN-13, EAN-8, CODE-128, CODE-39, and QR codes. + */ +import type { Logger } from 'pino'; +import type { Job } from 'bullmq'; +import type { BarcodeDetectionJobData } from '../types/job-data'; +import type { BarcodeDetectionResult } from '../types/upc'; +import { upcRepo } from './db/index.db'; +import sharp from 'sharp'; +import fs from 'node:fs/promises'; + +/** + * Supported barcode formats for detection. + */ +export type BarcodeFormat = + | 'UPC-A' + | 'UPC-E' + | 'EAN-13' + | 'EAN-8' + | 'CODE-128' + | 'CODE-39' + | 'QR_CODE' + | 'unknown'; + +/** + * Maps zxing-wasm format names to our BarcodeFormat type. + */ +const formatMap: Record = { + 'UPC-A': 'UPC-A', + 'UPC-E': 'UPC-E', + 'EAN-13': 'EAN-13', + 'EAN-8': 'EAN-8', + Code128: 'CODE-128', + Code39: 'CODE-39', + QRCode: 'QR_CODE', +}; + +/** + * Detects barcodes in an image using zxing-wasm. + * + * @param imagePath Path to the image file + * @param logger Pino logger instance + * @returns Detection result with UPC code if found + */ +export const detectBarcode = async ( + imagePath: string, + logger: Logger, +): Promise => { + const detectionLogger = logger.child({ imagePath }); + detectionLogger.info('Starting barcode detection'); + + try { + // Dynamically import zxing-wasm (ES module) + const { readBarcodesFromImageData } = await import('zxing-wasm/reader'); + + // Read and process the image with sharp + const imageBuffer = await fs.readFile(imagePath); + + // Convert to raw pixel data (RGBA) + const image = sharp(imageBuffer); + const metadata = await image.metadata(); + + if (!metadata.width || !metadata.height) { + detectionLogger.warn('Could not determine image dimensions'); + return { + detected: false, + upc_code: null, + confidence: null, + format: null, + error: 'Could not determine image dimensions', + }; + } + + // Convert to raw RGBA pixels + const { data, info } = await image.ensureAlpha().raw().toBuffer({ resolveWithObject: true }); + + // Create ImageData-like object for zxing-wasm + const imageData = { + data: new Uint8ClampedArray(data), + width: info.width, + height: info.height, + colorSpace: 'srgb' as const, + }; + + detectionLogger.debug( + { width: info.width, height: info.height }, + 'Processing image for barcode detection', + ); + + // Attempt barcode detection + const results = await readBarcodesFromImageData(imageData as ImageData, { + tryHarder: true, + tryRotate: true, + tryInvert: true, + formats: ['UPC-A', 'UPC-E', 'EAN-13', 'EAN-8', 'Code128', 'Code39'], + }); + + if (results.length === 0) { + detectionLogger.info('No barcode detected in image'); + return { + detected: false, + upc_code: null, + confidence: null, + format: null, + error: null, + }; + } + + // Take the first (best) result + const bestResult = results[0]; + const format = formatMap[bestResult.format] || 'unknown'; + + // Calculate confidence based on result quality indicators + // zxing-wasm doesn't provide direct confidence, so we estimate based on format match + const confidence = bestResult.text ? 0.95 : 0.5; + + detectionLogger.info( + { upcCode: bestResult.text, format, confidence }, + 'Barcode detected successfully', + ); + + return { + detected: true, + upc_code: bestResult.text, + confidence, + format, + error: null, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + detectionLogger.error({ err: error }, 'Barcode detection failed'); + + return { + detected: false, + upc_code: null, + confidence: null, + format: null, + error: errorMessage, + }; + } +}; + +/** + * Validates a UPC code format. + * @param code The code to validate + * @returns True if valid UPC format + */ +export const isValidUpcFormat = (code: string): boolean => { + // UPC-A: 12 digits + // UPC-E: 8 digits + // EAN-13: 13 digits + // EAN-8: 8 digits + return /^[0-9]{8,14}$/.test(code); +}; + +/** + * Calculates the check digit for a UPC-A code. + * @param code The 11-digit UPC-A code (without check digit) + * @returns The check digit + */ +export const calculateUpcCheckDigit = (code: string): number | null => { + if (code.length !== 11 || !/^\d+$/.test(code)) { + return null; + } + + let sum = 0; + for (let i = 0; i < 11; i++) { + const digit = parseInt(code[i], 10); + // Odd positions (0, 2, 4, ...) multiplied by 3 + // Even positions (1, 3, 5, ...) multiplied by 1 + sum += digit * (i % 2 === 0 ? 3 : 1); + } + + const checkDigit = (10 - (sum % 10)) % 10; + return checkDigit; +}; + +/** + * Validates a UPC code including check digit. + * @param code The complete UPC code + * @returns True if check digit is valid + */ +export const validateUpcCheckDigit = (code: string): boolean => { + if (code.length !== 12 || !/^\d+$/.test(code)) { + return false; + } + + const codeWithoutCheck = code.slice(0, 11); + const providedCheck = parseInt(code[11], 10); + const calculatedCheck = calculateUpcCheckDigit(codeWithoutCheck); + + return calculatedCheck === providedCheck; +}; + +/** + * Processes a barcode detection job from the queue. + * @param job The BullMQ job + * @param logger Pino logger instance + * @returns Detection result + */ +export const processBarcodeDetectionJob = async ( + job: Job, + logger: Logger, +): Promise => { + const { scanId, imagePath, userId } = job.data; + const jobLogger = logger.child({ + jobId: job.id, + scanId, + userId, + requestId: job.data.meta?.requestId, + }); + + jobLogger.info('Processing barcode detection job'); + + try { + // Attempt barcode detection + const result = await detectBarcode(imagePath, jobLogger); + + // If a code was detected, update the scan record + if (result.detected && result.upc_code) { + await upcRepo.updateScanWithDetectedCode( + scanId, + result.upc_code, + result.confidence, + jobLogger, + ); + + jobLogger.info( + { upcCode: result.upc_code, confidence: result.confidence }, + 'Barcode detected and scan record updated', + ); + } else { + jobLogger.info('No barcode detected in image'); + } + + return result; + } catch (error) { + jobLogger.error({ err: error }, 'Barcode detection job failed'); + + return { + detected: false, + upc_code: null, + confidence: null, + format: null, + error: error instanceof Error ? error.message : String(error), + }; + } +}; + +/** + * Detects multiple barcodes in an image. + * Useful for receipts or product lists with multiple items. + * @param imagePath Path to the image file + * @param logger Pino logger instance + * @returns Array of detection results + */ +export const detectMultipleBarcodes = async ( + imagePath: string, + logger: Logger, +): Promise => { + const detectionLogger = logger.child({ imagePath }); + detectionLogger.info('Starting multiple barcode detection'); + + try { + const { readBarcodesFromImageData } = await import('zxing-wasm/reader'); + + // Read and process the image + const imageBuffer = await fs.readFile(imagePath); + const image = sharp(imageBuffer); + + const { data, info } = await image.ensureAlpha().raw().toBuffer({ resolveWithObject: true }); + + const imageData = { + data: new Uint8ClampedArray(data), + width: info.width, + height: info.height, + colorSpace: 'srgb' as const, + }; + + // Detect all barcodes + const results = await readBarcodesFromImageData(imageData as ImageData, { + tryHarder: true, + tryRotate: true, + tryInvert: true, + formats: ['UPC-A', 'UPC-E', 'EAN-13', 'EAN-8', 'Code128', 'Code39'], + }); + + detectionLogger.info({ count: results.length }, 'Multiple barcode detection complete'); + + return results.map((result) => ({ + detected: true, + upc_code: result.text, + confidence: 0.95, + format: formatMap[result.format] || 'unknown', + error: null, + })); + } catch (error) { + detectionLogger.error({ err: error }, 'Multiple barcode detection failed'); + return []; + } +}; + +/** + * Enhances image for better barcode detection. + * Applies preprocessing like grayscale conversion, contrast adjustment, etc. + * @param imagePath Path to the source image + * @param logger Pino logger instance + * @returns Path to enhanced image (or original if enhancement fails) + */ +export const enhanceImageForDetection = async ( + imagePath: string, + logger: Logger, +): Promise => { + const detectionLogger = logger.child({ imagePath }); + + try { + // Create enhanced version with improved contrast for barcode detection + const enhancedPath = imagePath.replace(/(\.[^.]+)$/, '-enhanced$1'); + + await sharp(imagePath) + .grayscale() + .normalize() // Improve contrast + .sharpen() // Enhance edges + .toFile(enhancedPath); + + detectionLogger.debug({ enhancedPath }, 'Image enhanced for barcode detection'); + return enhancedPath; + } catch (error) { + detectionLogger.warn({ err: error }, 'Image enhancement failed, using original'); + return imagePath; + } +}; diff --git a/src/services/db/expiry.db.test.ts b/src/services/db/expiry.db.test.ts new file mode 100644 index 0000000..23af7db --- /dev/null +++ b/src/services/db/expiry.db.test.ts @@ -0,0 +1,1079 @@ +// src/services/db/expiry.db.test.ts +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Logger } from 'pino'; +import { createMockLogger } from '../../tests/utils/mockLogger'; +import { ExpiryRepository } from './expiry.db'; +import { NotFoundError } from './errors.db'; +import type { InventorySource, ExpirySource, ExpiryRangeSource } from '../../types/expiry'; + +// Create mock pool +const mockQuery = vi.fn(); +const mockPool = { + query: mockQuery, +}; + +describe('ExpiryRepository', () => { + let repo: ExpiryRepository; + let mockLogger: Logger; + + beforeEach(() => { + vi.clearAllMocks(); + mockLogger = createMockLogger(); + repo = new ExpiryRepository(mockPool); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + // ============================================================================ + // INVENTORY ITEMS (pantry_items) + // ============================================================================ + + describe('addInventoryItem', () => { + it('should add inventory item with master item lookup', async () => { + // Master item lookup query + mockQuery.mockResolvedValueOnce({ + rowCount: 1, + rows: [{ name: 'Milk' }], + }); + + // Location upsert query + mockQuery.mockResolvedValueOnce({ + rows: [{ pantry_location_id: 1 }], + }); + + // Insert pantry item query + const pantryItemRow = { + pantry_item_id: 1, + user_id: 'user-1', + master_item_id: 100, + quantity: 2, + unit: 'liters', + best_before_date: '2024-02-15', + pantry_location_id: 1, + notification_sent_at: null, + updated_at: new Date().toISOString(), + purchase_date: '2024-01-10', + source: 'manual' as InventorySource, + receipt_item_id: null, + product_id: null, + expiry_source: 'manual' as ExpirySource, + is_consumed: false, + consumed_at: null, + }; + + mockQuery.mockResolvedValueOnce({ + rows: [pantryItemRow], + }); + + const result = await repo.addInventoryItem( + 'user-1', + { + item_name: 'Milk', + master_item_id: 100, + quantity: 2, + unit: 'liters', + expiry_date: '2024-02-15', + purchase_date: '2024-01-10', + source: 'manual', + location: 'fridge', + }, + mockLogger, + ); + + expect(result.inventory_id).toBe(1); + expect(result.item_name).toBe('Milk'); + expect(result.quantity).toBe(2); + }); + + it('should add inventory item with provided item name', async () => { + const pantryItemRow = { + pantry_item_id: 2, + user_id: 'user-1', + master_item_id: null, + quantity: 1, + unit: null, + best_before_date: null, + pantry_location_id: null, + notification_sent_at: null, + updated_at: new Date().toISOString(), + purchase_date: null, + source: 'manual' as InventorySource, + receipt_item_id: null, + product_id: null, + expiry_source: null, + is_consumed: false, + consumed_at: null, + }; + + mockQuery.mockResolvedValueOnce({ + rows: [pantryItemRow], + }); + + const result = await repo.addInventoryItem( + 'user-1', + { + item_name: 'Custom Item', + source: 'manual', + }, + mockLogger, + ); + + expect(result.inventory_id).toBe(2); + expect(result.item_name).toBe('Custom Item'); + }); + + it('should throw on database error', async () => { + mockQuery.mockRejectedValueOnce(new Error('DB connection failed')); + + await expect( + repo.addInventoryItem('user-1', { source: 'manual', item_name: 'Test' }, mockLogger), + ).rejects.toThrow(); + }); + }); + + describe('updateInventoryItem', () => { + it('should update inventory item successfully', async () => { + const updatedRow = { + pantry_item_id: 1, + user_id: 'user-1', + master_item_id: 100, + quantity: 3, + unit: 'liters', + best_before_date: '2024-02-20', + pantry_location_id: 1, + notification_sent_at: null, + updated_at: new Date().toISOString(), + purchase_date: '2024-01-10', + source: 'manual' as InventorySource, + receipt_item_id: null, + product_id: null, + expiry_source: 'manual' as ExpirySource, + is_consumed: false, + consumed_at: null, + item_name: 'Milk', + category_name: 'Dairy', + location_name: 'fridge', + }; + + mockQuery.mockResolvedValueOnce({ + rowCount: 1, + rows: [updatedRow], + }); + + const result = await repo.updateInventoryItem( + 1, + 'user-1', + { + quantity: 3, + expiry_date: '2024-02-20', + }, + mockLogger, + ); + + expect(result.quantity).toBe(3); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('UPDATE public.pantry_items'), + expect.any(Array), + ); + }); + + it('should update with location change', async () => { + // Location upsert query + mockQuery.mockResolvedValueOnce({ + rows: [{ pantry_location_id: 2 }], + }); + + const updatedRow = { + pantry_item_id: 1, + user_id: 'user-1', + master_item_id: 100, + quantity: 1, + unit: null, + best_before_date: null, + pantry_location_id: 2, + notification_sent_at: null, + updated_at: new Date().toISOString(), + purchase_date: null, + source: 'manual' as InventorySource, + receipt_item_id: null, + product_id: null, + expiry_source: null, + is_consumed: false, + consumed_at: null, + item_name: 'Milk', + category_name: 'Dairy', + location_name: 'freezer', + }; + + mockQuery.mockResolvedValueOnce({ + rowCount: 1, + rows: [updatedRow], + }); + + const result = await repo.updateInventoryItem( + 1, + 'user-1', + { location: 'freezer' }, + mockLogger, + ); + + expect(result.location).toBe('freezer'); + }); + + it('should throw NotFoundError when item not found', async () => { + mockQuery.mockResolvedValueOnce({ + rowCount: 0, + rows: [], + }); + + await expect( + repo.updateInventoryItem(999, 'user-1', { quantity: 1 }, mockLogger), + ).rejects.toThrow(NotFoundError); + }); + }); + + describe('markAsConsumed', () => { + it('should mark item as consumed', async () => { + mockQuery.mockResolvedValueOnce({ + rowCount: 1, + }); + + await repo.markAsConsumed(1, 'user-1', mockLogger); + + expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('is_consumed = true'), [ + 1, + 'user-1', + ]); + }); + + it('should throw NotFoundError when item not found', async () => { + mockQuery.mockResolvedValueOnce({ + rowCount: 0, + }); + + await expect(repo.markAsConsumed(999, 'user-1', mockLogger)).rejects.toThrow(NotFoundError); + }); + }); + + describe('deleteInventoryItem', () => { + it('should delete inventory item', async () => { + mockQuery.mockResolvedValueOnce({ + rowCount: 1, + }); + + await repo.deleteInventoryItem(1, 'user-1', mockLogger); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('DELETE FROM public.pantry_items'), + [1, 'user-1'], + ); + }); + + it('should throw NotFoundError when item not found', async () => { + mockQuery.mockResolvedValueOnce({ + rowCount: 0, + }); + + await expect(repo.deleteInventoryItem(999, 'user-1', mockLogger)).rejects.toThrow( + NotFoundError, + ); + }); + }); + + describe('getInventoryItemById', () => { + it('should return inventory item with details', async () => { + const itemRow = { + pantry_item_id: 1, + user_id: 'user-1', + master_item_id: 100, + quantity: 2, + unit: 'liters', + best_before_date: '2024-02-15', + pantry_location_id: 1, + notification_sent_at: null, + updated_at: new Date().toISOString(), + purchase_date: '2024-01-10', + source: 'manual' as InventorySource, + receipt_item_id: null, + product_id: null, + expiry_source: 'manual' as ExpirySource, + is_consumed: false, + consumed_at: null, + item_name: 'Milk', + category_name: 'Dairy', + location_name: 'fridge', + }; + + mockQuery.mockResolvedValueOnce({ + rowCount: 1, + rows: [itemRow], + }); + + const result = await repo.getInventoryItemById(1, 'user-1', mockLogger); + + expect(result.inventory_id).toBe(1); + expect(result.item_name).toBe('Milk'); + expect(result.category_name).toBe('Dairy'); + }); + + it('should throw NotFoundError when item not found', async () => { + mockQuery.mockResolvedValueOnce({ + rowCount: 0, + rows: [], + }); + + await expect(repo.getInventoryItemById(999, 'user-1', mockLogger)).rejects.toThrow( + NotFoundError, + ); + }); + }); + + describe('getInventory', () => { + it('should return paginated inventory', async () => { + // Count query + mockQuery.mockResolvedValueOnce({ + rows: [{ count: '10' }], + }); + + // Data query + mockQuery.mockResolvedValueOnce({ + rows: [ + { + pantry_item_id: 1, + user_id: 'user-1', + master_item_id: 100, + quantity: 1, + unit: null, + best_before_date: '2024-02-15', + pantry_location_id: 1, + notification_sent_at: null, + updated_at: new Date().toISOString(), + purchase_date: null, + source: 'manual' as InventorySource, + receipt_item_id: null, + product_id: null, + expiry_source: 'manual' as ExpirySource, + is_consumed: false, + consumed_at: null, + item_name: 'Milk', + category_name: 'Dairy', + location_name: 'fridge', + }, + ], + }); + + const result = await repo.getInventory( + { user_id: 'user-1', limit: 10, offset: 0 }, + mockLogger, + ); + + expect(result.total).toBe(10); + expect(result.items).toHaveLength(1); + }); + + it('should filter by location', async () => { + mockQuery.mockResolvedValueOnce({ rows: [{ count: '5' }] }); + mockQuery.mockResolvedValueOnce({ rows: [] }); + + await repo.getInventory({ user_id: 'user-1', location: 'fridge' }, mockLogger); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('pl.name = $'), + expect.any(Array), + ); + }); + + it('should filter by expiring within days', async () => { + mockQuery.mockResolvedValueOnce({ rows: [{ count: '3' }] }); + mockQuery.mockResolvedValueOnce({ rows: [] }); + + await repo.getInventory({ user_id: 'user-1', expiring_within_days: 7 }, mockLogger); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('best_before_date <= CURRENT_DATE + $'), + expect.any(Array), + ); + }); + + it('should filter by category', async () => { + mockQuery.mockResolvedValueOnce({ rows: [{ count: '4' }] }); + mockQuery.mockResolvedValueOnce({ rows: [] }); + + await repo.getInventory({ user_id: 'user-1', category_id: 5 }, mockLogger); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('mgi.category_id = $'), + expect.any(Array), + ); + }); + + it('should search by item name', async () => { + mockQuery.mockResolvedValueOnce({ rows: [{ count: '2' }] }); + mockQuery.mockResolvedValueOnce({ rows: [] }); + + await repo.getInventory({ user_id: 'user-1', search: 'milk' }, mockLogger); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('mgi.name ILIKE $'), + expect.any(Array), + ); + }); + }); + + describe('getExpiringItems', () => { + it('should return items expiring within specified days', async () => { + const items = [ + { + pantry_item_id: 1, + user_id: 'user-1', + master_item_id: 100, + quantity: 1, + unit: null, + best_before_date: '2024-01-20', + pantry_location_id: 1, + notification_sent_at: null, + updated_at: new Date().toISOString(), + purchase_date: null, + source: 'manual' as InventorySource, + receipt_item_id: null, + product_id: null, + expiry_source: 'manual' as ExpirySource, + is_consumed: false, + consumed_at: null, + item_name: 'Milk', + category_name: 'Dairy', + location_name: 'fridge', + }, + ]; + + mockQuery.mockResolvedValueOnce({ + rows: items, + }); + + const result = await repo.getExpiringItems('user-1', 7, mockLogger); + + expect(result).toHaveLength(1); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('best_before_date <= CURRENT_DATE + $2'), + ['user-1', 7], + ); + }); + }); + + describe('getExpiredItems', () => { + it('should return already expired items', async () => { + const items = [ + { + pantry_item_id: 1, + user_id: 'user-1', + master_item_id: 100, + quantity: 1, + unit: null, + best_before_date: '2024-01-01', + pantry_location_id: 1, + notification_sent_at: null, + updated_at: new Date().toISOString(), + purchase_date: null, + source: 'manual' as InventorySource, + receipt_item_id: null, + product_id: null, + expiry_source: 'manual' as ExpirySource, + is_consumed: false, + consumed_at: null, + item_name: 'Old Milk', + category_name: 'Dairy', + location_name: 'fridge', + }, + ]; + + mockQuery.mockResolvedValueOnce({ + rows: items, + }); + + const result = await repo.getExpiredItems('user-1', mockLogger); + + expect(result).toHaveLength(1); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('best_before_date < CURRENT_DATE'), + ['user-1'], + ); + }); + }); + + // ============================================================================ + // EXPIRY DATE RANGES + // ============================================================================ + + describe('getExpiryRangeForItem', () => { + it('should find expiry range by master item id', async () => { + const range = { + expiry_range_id: 1, + master_item_id: 100, + category_id: null, + item_pattern: null, + storage_location: 'fridge', + min_days: 5, + max_days: 10, + typical_days: 7, + notes: 'Keep refrigerated', + source: 'usda' as ExpiryRangeSource, + }; + + mockQuery.mockResolvedValueOnce({ + rowCount: 1, + rows: [range], + }); + + const result = await repo.getExpiryRangeForItem('fridge', mockLogger, { + masterItemId: 100, + }); + + expect(result).not.toBeNull(); + expect(result?.typical_days).toBe(7); + }); + + it('should find expiry range by category if master item not found', async () => { + // Master item lookup - not found + mockQuery.mockResolvedValueOnce({ + rowCount: 0, + rows: [], + }); + + // Category lookup - found + const range = { + expiry_range_id: 2, + master_item_id: null, + category_id: 5, + item_pattern: null, + storage_location: 'fridge', + min_days: 7, + max_days: 14, + typical_days: 10, + notes: null, + source: null, + }; + + mockQuery.mockResolvedValueOnce({ + rowCount: 1, + rows: [range], + }); + + const result = await repo.getExpiryRangeForItem('fridge', mockLogger, { + masterItemId: 100, + categoryId: 5, + }); + + expect(result).not.toBeNull(); + expect(result?.category_id).toBe(5); + }); + + it('should find expiry range by pattern matching', async () => { + const range = { + expiry_range_id: 3, + master_item_id: null, + category_id: null, + item_pattern: '.*cheese.*', + storage_location: 'fridge', + min_days: 14, + max_days: 30, + typical_days: 21, + notes: null, + source: null, + }; + + mockQuery.mockResolvedValueOnce({ + rowCount: 1, + rows: [range], + }); + + const result = await repo.getExpiryRangeForItem('fridge', mockLogger, { + itemName: 'Cheddar Cheese', + }); + + expect(result).not.toBeNull(); + expect(result?.item_pattern).toBe('.*cheese.*'); + }); + + it('should return null when no range found', async () => { + const result = await repo.getExpiryRangeForItem('fridge', mockLogger, {}); + + expect(result).toBeNull(); + }); + }); + + describe('addExpiryRange', () => { + it('should add new expiry range', async () => { + const range = { + expiry_range_id: 1, + master_item_id: 100, + category_id: null, + item_pattern: null, + storage_location: 'fridge', + min_days: 5, + max_days: 10, + typical_days: 7, + notes: 'Keep refrigerated', + source: 'usda' as ExpiryRangeSource, + }; + + mockQuery.mockResolvedValueOnce({ + rows: [range], + }); + + const result = await repo.addExpiryRange( + { + master_item_id: 100, + storage_location: 'fridge', + min_days: 5, + max_days: 10, + typical_days: 7, + notes: 'Keep refrigerated', + source: 'usda', + }, + mockLogger, + ); + + expect(result.typical_days).toBe(7); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO public.expiry_date_ranges'), + expect.any(Array), + ); + }); + }); + + describe('getExpiryRanges', () => { + it('should return paginated expiry ranges', async () => { + // Count query + mockQuery.mockResolvedValueOnce({ + rows: [{ count: '20' }], + }); + + // Data query + mockQuery.mockResolvedValueOnce({ + rows: [ + { + expiry_range_id: 1, + master_item_id: 100, + category_id: null, + item_pattern: null, + storage_location: 'fridge', + min_days: 5, + max_days: 10, + typical_days: 7, + notes: null, + source: null, + }, + ], + }); + + const result = await repo.getExpiryRanges({ limit: 10, offset: 0 }, mockLogger); + + expect(result.total).toBe(20); + expect(result.ranges).toHaveLength(1); + }); + + it('should filter by storage location', async () => { + mockQuery.mockResolvedValueOnce({ rows: [{ count: '10' }] }); + mockQuery.mockResolvedValueOnce({ rows: [] }); + + await repo.getExpiryRanges({ storage_location: 'freezer' }, mockLogger); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('storage_location = $1'), + expect.any(Array), + ); + }); + }); + + // ============================================================================ + // EXPIRY ALERTS + // ============================================================================ + + describe('getUserAlertSettings', () => { + it('should return user alert settings', async () => { + const settings = [ + { + alert_id: 1, + user_id: 'user-1', + alert_method: 'email', + days_before_expiry: 3, + is_enabled: true, + last_alert_sent_at: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + { + alert_id: 2, + user_id: 'user-1', + alert_method: 'push', + days_before_expiry: 1, + is_enabled: false, + last_alert_sent_at: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ]; + + mockQuery.mockResolvedValueOnce({ + rows: settings, + }); + + const result = await repo.getUserAlertSettings('user-1', mockLogger); + + expect(result).toHaveLength(2); + expect(result[0].alert_method).toBe('email'); + }); + }); + + describe('upsertAlertSettings', () => { + it('should create new alert settings', async () => { + const settings = { + alert_id: 1, + user_id: 'user-1', + alert_method: 'email', + days_before_expiry: 3, + is_enabled: true, + last_alert_sent_at: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + mockQuery.mockResolvedValueOnce({ + rows: [settings], + }); + + const result = await repo.upsertAlertSettings( + 'user-1', + 'email', + { days_before_expiry: 3, is_enabled: true }, + mockLogger, + ); + + expect(result.alert_method).toBe('email'); + expect(result.days_before_expiry).toBe(3); + }); + + it('should update existing alert settings', async () => { + const settings = { + alert_id: 1, + user_id: 'user-1', + alert_method: 'email', + days_before_expiry: 5, + is_enabled: false, + last_alert_sent_at: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + mockQuery.mockResolvedValueOnce({ + rows: [settings], + }); + + const result = await repo.upsertAlertSettings( + 'user-1', + 'email', + { days_before_expiry: 5, is_enabled: false }, + mockLogger, + ); + + expect(result.days_before_expiry).toBe(5); + expect(result.is_enabled).toBe(false); + }); + }); + + describe('logAlert', () => { + it('should log expiry alert', async () => { + const logRecord = { + log_id: 1, + user_id: 'user-1', + pantry_item_id: 1, + alert_type: 'expiring_soon', + alert_method: 'email', + item_name: 'Milk', + expiry_date: '2024-01-20', + days_until_expiry: 3, + created_at: new Date().toISOString(), + }; + + mockQuery.mockResolvedValueOnce({ + rows: [logRecord], + }); + + const result = await repo.logAlert('user-1', 'expiring_soon', 'email', 'Milk', mockLogger, { + pantryItemId: 1, + expiryDate: '2024-01-20', + daysUntilExpiry: 3, + }); + + expect(result.alert_type).toBe('expiring_soon'); + expect(result.item_name).toBe('Milk'); + }); + }); + + describe('getUsersWithExpiringItems', () => { + it('should return users with expiring items who need alerts', async () => { + const users = [ + { + user_id: 'user-1', + email: 'user1@example.com', + alert_method: 'email', + days_before_expiry: 3, + }, + { + user_id: 'user-2', + email: 'user2@example.com', + alert_method: 'push', + days_before_expiry: 1, + }, + ]; + + mockQuery.mockResolvedValueOnce({ + rows: users, + }); + + const result = await repo.getUsersWithExpiringItems(mockLogger); + + expect(result).toHaveLength(2); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('ea.is_enabled = true'), + undefined, + ); + }); + }); + + describe('markAlertSent', () => { + it('should update last_alert_sent_at timestamp', async () => { + mockQuery.mockResolvedValueOnce({ + rowCount: 1, + }); + + await repo.markAlertSent('user-1', 'email', mockLogger); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('last_alert_sent_at = NOW()'), + ['user-1', 'email'], + ); + }); + }); + + // ============================================================================ + // RECIPE SUGGESTIONS + // ============================================================================ + + describe('getRecipesForExpiringItems', () => { + it('should return recipes using expiring items', async () => { + // Expiring items query + mockQuery.mockResolvedValueOnce({ + rows: [{ master_item_id: 100 }, { master_item_id: 101 }], + }); + + // Count query + mockQuery.mockResolvedValueOnce({ + rows: [{ total: '5' }], + }); + + // Recipes query + mockQuery.mockResolvedValueOnce({ + rows: [ + { + recipe_id: 1, + recipe_name: 'Milk Smoothie', + description: 'A healthy smoothie', + prep_time_minutes: 5, + cook_time_minutes: 0, + servings: 2, + photo_url: null, + matching_master_item_ids: [100], + match_count: '1', + }, + { + recipe_id: 2, + recipe_name: 'French Toast', + description: 'Classic breakfast', + prep_time_minutes: 10, + cook_time_minutes: 15, + servings: 4, + photo_url: null, + matching_master_item_ids: [100, 101], + match_count: '2', + }, + ], + }); + + const result = await repo.getRecipesForExpiringItems('user-1', 7, 10, 0, mockLogger); + + expect(result.total).toBe(5); + expect(result.recipes).toHaveLength(2); + expect(result.recipes[0].match_count).toBe(1); + expect(result.recipes[1].match_count).toBe(2); + }); + + it('should return empty when no expiring items', async () => { + mockQuery.mockResolvedValueOnce({ + rows: [], + }); + + const result = await repo.getRecipesForExpiringItems('user-1', 7, 10, 0, mockLogger); + + expect(result.total).toBe(0); + expect(result.recipes).toHaveLength(0); + }); + }); + + // ============================================================================ + // HELPER METHODS (tested indirectly) + // ============================================================================ + + describe('expiry status calculation', () => { + it('should calculate expiry status correctly for fresh items', async () => { + // Create an item with expiry date 10 days in the future + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 10); + + const itemRow = { + pantry_item_id: 1, + user_id: 'user-1', + master_item_id: 100, + quantity: 1, + unit: null, + best_before_date: futureDate.toISOString().split('T')[0], + pantry_location_id: null, + notification_sent_at: null, + updated_at: new Date().toISOString(), + purchase_date: null, + source: 'manual' as InventorySource, + receipt_item_id: null, + product_id: null, + expiry_source: 'manual' as ExpirySource, + is_consumed: false, + consumed_at: null, + item_name: 'Fresh Milk', + category_name: 'Dairy', + location_name: null, + }; + + mockQuery.mockResolvedValueOnce({ + rowCount: 1, + rows: [itemRow], + }); + + const result = await repo.getInventoryItemById(1, 'user-1', mockLogger); + + expect(result.expiry_status).toBe('fresh'); + expect(result.days_until_expiry).toBeGreaterThan(7); + }); + + it('should calculate expiry status correctly for expiring soon items', async () => { + // Create an item with expiry date 3 days in the future + const soonDate = new Date(); + soonDate.setDate(soonDate.getDate() + 3); + + const itemRow = { + pantry_item_id: 1, + user_id: 'user-1', + master_item_id: 100, + quantity: 1, + unit: null, + best_before_date: soonDate.toISOString().split('T')[0], + pantry_location_id: null, + notification_sent_at: null, + updated_at: new Date().toISOString(), + purchase_date: null, + source: 'manual' as InventorySource, + receipt_item_id: null, + product_id: null, + expiry_source: 'manual' as ExpirySource, + is_consumed: false, + consumed_at: null, + item_name: 'Expiring Milk', + category_name: 'Dairy', + location_name: null, + }; + + mockQuery.mockResolvedValueOnce({ + rowCount: 1, + rows: [itemRow], + }); + + const result = await repo.getInventoryItemById(1, 'user-1', mockLogger); + + expect(result.expiry_status).toBe('expiring_soon'); + expect(result.days_until_expiry).toBeLessThanOrEqual(7); + expect(result.days_until_expiry).toBeGreaterThanOrEqual(0); + }); + + it('should calculate expiry status correctly for expired items', async () => { + // Create an item with expiry date 3 days in the past + const pastDate = new Date(); + pastDate.setDate(pastDate.getDate() - 3); + + const itemRow = { + pantry_item_id: 1, + user_id: 'user-1', + master_item_id: 100, + quantity: 1, + unit: null, + best_before_date: pastDate.toISOString().split('T')[0], + pantry_location_id: null, + notification_sent_at: null, + updated_at: new Date().toISOString(), + purchase_date: null, + source: 'manual' as InventorySource, + receipt_item_id: null, + product_id: null, + expiry_source: 'manual' as ExpirySource, + is_consumed: false, + consumed_at: null, + item_name: 'Expired Milk', + category_name: 'Dairy', + location_name: null, + }; + + mockQuery.mockResolvedValueOnce({ + rowCount: 1, + rows: [itemRow], + }); + + const result = await repo.getInventoryItemById(1, 'user-1', mockLogger); + + expect(result.expiry_status).toBe('expired'); + expect(result.days_until_expiry).toBeLessThan(0); + }); + + it('should return unknown status when no expiry date', async () => { + const itemRow = { + pantry_item_id: 1, + user_id: 'user-1', + master_item_id: 100, + quantity: 1, + unit: null, + best_before_date: null, + pantry_location_id: null, + notification_sent_at: null, + updated_at: new Date().toISOString(), + purchase_date: null, + source: 'manual' as InventorySource, + receipt_item_id: null, + product_id: null, + expiry_source: null, + is_consumed: false, + consumed_at: null, + item_name: 'No Expiry Item', + category_name: 'Dry Goods', + location_name: null, + }; + + mockQuery.mockResolvedValueOnce({ + rowCount: 1, + rows: [itemRow], + }); + + const result = await repo.getInventoryItemById(1, 'user-1', mockLogger); + + expect(result.expiry_status).toBe('unknown'); + expect(result.days_until_expiry).toBeNull(); + }); + }); +}); diff --git a/src/services/db/expiry.db.ts b/src/services/db/expiry.db.ts new file mode 100644 index 0000000..9606f8d --- /dev/null +++ b/src/services/db/expiry.db.ts @@ -0,0 +1,1111 @@ +// src/services/db/expiry.db.ts +import type { Pool, PoolClient } from 'pg'; +import { getPool } from './connection.db'; +import { NotFoundError, handleDbError } from './errors.db'; +import type { Logger } from 'pino'; +import type { + StorageLocation, + InventorySource, + ExpirySource, + AlertMethod, + ExpiryAlertType, + UserInventoryItem, + AddInventoryItemRequest, + UpdateInventoryItemRequest, + ExpiryDateRange, + AddExpiryRangeRequest, + ExpiryAlertSettings, + UpdateExpiryAlertSettingsRequest, + ExpiryAlertLogRecord, + InventoryQueryOptions, + ExpiryRangeQueryOptions, +} from '../../types/expiry'; + +/** + * Database row type for pantry_items table. + */ +interface PantryItemRow { + pantry_item_id: number; + user_id: string; + master_item_id: number | null; + quantity: number; + unit: string | null; + best_before_date: string | null; + pantry_location_id: number | null; + notification_sent_at: string | null; + updated_at: string; + purchase_date: string | null; + source: string | null; + receipt_item_id: number | null; + product_id: number | null; + expiry_source: string | null; + is_consumed: boolean | null; + consumed_at: string | null; +} + +/** + * Extended pantry item row with joined master item and category names. + */ +interface PantryItemWithDetailsRow extends PantryItemRow { + item_name: string; + category_name: string | null; + location_name: string | null; +} + +/** + * Repository for expiry date tracking and inventory management. + * Handles pantry items, expiry date ranges, alerts, and alert logs. + */ +export class ExpiryRepository { + private db: Pick; + + constructor(db: Pick = getPool()) { + this.db = db; + } + + // ============================================================================ + // INVENTORY ITEMS (pantry_items) + // ============================================================================ + + /** + * Adds a new item to the user's inventory. + */ + async addInventoryItem( + userId: string, + item: AddInventoryItemRequest, + logger: Logger, + ): Promise { + try { + // Get item name from master_item if not provided directly + let itemName = item.item_name; + if (!itemName && item.master_item_id) { + const nameRes = await this.db.query<{ name: string }>( + `SELECT name FROM public.master_grocery_items WHERE master_grocery_item_id = $1`, + [item.master_item_id], + ); + if (nameRes.rowCount && nameRes.rowCount > 0) { + itemName = nameRes.rows[0].name; + } + } + + // Get or create pantry location if provided + let pantryLocationId: number | null = null; + if (item.location) { + const locationRes = await this.db.query<{ pantry_location_id: number }>( + `INSERT INTO public.pantry_locations (user_id, name) + VALUES ($1, $2) + ON CONFLICT (user_id, name) DO UPDATE SET name = EXCLUDED.name + RETURNING pantry_location_id`, + [userId, item.location], + ); + pantryLocationId = locationRes.rows[0].pantry_location_id; + } + + const res = await this.db.query( + `INSERT INTO public.pantry_items + (user_id, master_item_id, product_id, quantity, unit, best_before_date, + purchase_date, source, pantry_location_id, expiry_source) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING *`, + [ + userId, + item.master_item_id || null, + item.product_id || null, + item.quantity ?? 1, + item.unit || null, + item.expiry_date || null, + item.purchase_date || null, + item.source, + pantryLocationId, + item.expiry_date ? 'manual' : null, + ], + ); + + return this.mapPantryItemToInventoryItem(res.rows[0], itemName); + } catch (error) { + handleDbError( + error, + logger, + 'Database error in addInventoryItem', + { userId, item }, + { + fkMessage: 'The specified product or master item does not exist.', + defaultMessage: 'Failed to add item to inventory.', + }, + ); + } + } + + /** + * Updates an existing inventory item. + */ + async updateInventoryItem( + inventoryId: number, + userId: string, + updates: UpdateInventoryItemRequest, + logger: Logger, + ): Promise { + try { + // Build dynamic SET clause + const setClauses: string[] = ['updated_at = NOW()']; + const values: (string | number | boolean | null)[] = []; + let paramIndex = 1; + + if (updates.quantity !== undefined) { + setClauses.push(`quantity = $${paramIndex++}`); + values.push(updates.quantity); + } + + if (updates.unit !== undefined) { + setClauses.push(`unit = $${paramIndex++}`); + values.push(updates.unit); + } + + if (updates.expiry_date !== undefined) { + setClauses.push(`best_before_date = $${paramIndex++}`); + values.push(updates.expiry_date); + setClauses.push(`expiry_source = 'manual'`); + } + + if (updates.location !== undefined) { + // Get or create pantry location + const locationRes = await this.db.query<{ pantry_location_id: number }>( + `INSERT INTO public.pantry_locations (user_id, name) + VALUES ($1, $2) + ON CONFLICT (user_id, name) DO UPDATE SET name = EXCLUDED.name + RETURNING pantry_location_id`, + [userId, updates.location], + ); + setClauses.push(`pantry_location_id = $${paramIndex++}`); + values.push(locationRes.rows[0].pantry_location_id); + } + + if (updates.notes !== undefined) { + // Note: pantry_items doesn't have notes column, so we skip this + // Could add via ALTER TABLE if needed + } + + if (updates.is_consumed !== undefined) { + setClauses.push(`is_consumed = $${paramIndex++}`); + values.push(updates.is_consumed); + if (updates.is_consumed) { + setClauses.push(`consumed_at = NOW()`); + } else { + setClauses.push(`consumed_at = NULL`); + } + } + + values.push(inventoryId, userId); + + const res = await this.db.query( + `UPDATE public.pantry_items + SET ${setClauses.join(', ')} + WHERE pantry_item_id = $${paramIndex++} AND user_id = $${paramIndex} + RETURNING *, + (SELECT name FROM public.master_grocery_items WHERE master_grocery_item_id = pantry_items.master_item_id) AS item_name, + (SELECT c.name FROM public.master_grocery_items mgi + JOIN public.categories c ON mgi.category_id = c.category_id + WHERE mgi.master_grocery_item_id = pantry_items.master_item_id) AS category_name, + (SELECT name FROM public.pantry_locations WHERE pantry_location_id = pantry_items.pantry_location_id) AS location_name`, + values, + ); + + if (res.rowCount === 0) { + throw new NotFoundError('Inventory item not found or user does not have permission.'); + } + + return this.mapPantryItemWithDetailsToInventoryItem(res.rows[0]); + } catch (error) { + handleDbError( + error, + logger, + 'Database error in updateInventoryItem', + { inventoryId, userId, updates }, + { defaultMessage: 'Failed to update inventory item.' }, + ); + } + } + + /** + * Marks an inventory item as consumed. + */ + async markAsConsumed(inventoryId: number, userId: string, logger: Logger): Promise { + try { + const res = await this.db.query( + `UPDATE public.pantry_items + SET is_consumed = true, consumed_at = NOW(), updated_at = NOW() + WHERE pantry_item_id = $1 AND user_id = $2`, + [inventoryId, userId], + ); + + if (res.rowCount === 0) { + throw new NotFoundError('Inventory item not found or user does not have permission.'); + } + } catch (error) { + handleDbError( + error, + logger, + 'Database error in markAsConsumed', + { inventoryId, userId }, + { + defaultMessage: 'Failed to mark item as consumed.', + }, + ); + } + } + + /** + * Deletes an inventory item. + */ + async deleteInventoryItem(inventoryId: number, userId: string, logger: Logger): Promise { + try { + const res = await this.db.query( + `DELETE FROM public.pantry_items WHERE pantry_item_id = $1 AND user_id = $2`, + [inventoryId, userId], + ); + + if (res.rowCount === 0) { + throw new NotFoundError('Inventory item not found or user does not have permission.'); + } + } catch (error) { + handleDbError( + error, + logger, + 'Database error in deleteInventoryItem', + { inventoryId, userId }, + { + defaultMessage: 'Failed to delete inventory item.', + }, + ); + } + } + + /** + * Gets a single inventory item by ID. + */ + async getInventoryItemById( + inventoryId: number, + userId: string, + logger: Logger, + ): Promise { + try { + const res = await this.db.query( + `SELECT + pi.*, + COALESCE(mgi.name, 'Unknown Item') AS item_name, + c.name AS category_name, + pl.name AS location_name + FROM public.pantry_items pi + LEFT JOIN public.master_grocery_items mgi ON pi.master_item_id = mgi.master_grocery_item_id + LEFT JOIN public.categories c ON mgi.category_id = c.category_id + LEFT JOIN public.pantry_locations pl ON pi.pantry_location_id = pl.pantry_location_id + WHERE pi.pantry_item_id = $1 AND pi.user_id = $2`, + [inventoryId, userId], + ); + + if (res.rowCount === 0) { + throw new NotFoundError('Inventory item not found.'); + } + + return this.mapPantryItemWithDetailsToInventoryItem(res.rows[0]); + } catch (error) { + handleDbError( + error, + logger, + 'Database error in getInventoryItemById', + { inventoryId, userId }, + { + defaultMessage: 'Failed to get inventory item.', + }, + ); + } + } + + /** + * Gets user's inventory with filtering and pagination. + */ + async getInventory( + options: InventoryQueryOptions, + logger: Logger, + ): Promise<{ items: UserInventoryItem[]; total: number }> { + const { + user_id, + location, + is_consumed = false, + expiring_within_days, + category_id, + search, + limit = 50, + offset = 0, + sort_by = 'expiry_date', + sort_order = 'asc', + } = options; + + try { + // Build dynamic WHERE clause + const conditions: string[] = ['pi.user_id = $1']; + const params: (string | number | boolean)[] = [user_id]; + let paramIndex = 2; + + // Filter by consumed status + if (is_consumed !== undefined) { + conditions.push(`(pi.is_consumed = $${paramIndex++} OR pi.is_consumed IS NULL)`); + params.push(is_consumed); + } + + // Filter by location + if (location) { + conditions.push(`pl.name = $${paramIndex++}`); + params.push(location); + } + + // Filter by expiring within N days + if (expiring_within_days !== undefined) { + conditions.push(`pi.best_before_date IS NOT NULL`); + conditions.push(`pi.best_before_date <= CURRENT_DATE + $${paramIndex++}`); + params.push(expiring_within_days); + } + + // Filter by category + if (category_id) { + conditions.push(`mgi.category_id = $${paramIndex++}`); + params.push(category_id); + } + + // Search by item name + if (search) { + conditions.push(`mgi.name ILIKE $${paramIndex++}`); + params.push(`%${search}%`); + } + + const whereClause = conditions.join(' AND '); + + // Determine sort column + let sortColumn: string; + switch (sort_by) { + case 'expiry_date': + sortColumn = 'pi.best_before_date'; + break; + case 'purchase_date': + sortColumn = 'pi.purchase_date'; + break; + case 'item_name': + sortColumn = 'mgi.name'; + break; + default: + sortColumn = 'pi.updated_at'; + } + + // Get total count + const countQuery = ` + SELECT COUNT(*) + FROM public.pantry_items pi + LEFT JOIN public.master_grocery_items mgi ON pi.master_item_id = mgi.master_grocery_item_id + LEFT JOIN public.pantry_locations pl ON pi.pantry_location_id = pl.pantry_location_id + WHERE ${whereClause} + `; + const countRes = await this.db.query<{ count: string }>(countQuery, params); + const total = parseInt(countRes.rows[0].count, 10); + + // Get paginated results + const dataParams = [...params, limit, offset]; + const dataQuery = ` + SELECT + pi.*, + COALESCE(mgi.name, 'Unknown Item') AS item_name, + c.name AS category_name, + pl.name AS location_name + FROM public.pantry_items pi + LEFT JOIN public.master_grocery_items mgi ON pi.master_item_id = mgi.master_grocery_item_id + LEFT JOIN public.categories c ON mgi.category_id = c.category_id + LEFT JOIN public.pantry_locations pl ON pi.pantry_location_id = pl.pantry_location_id + WHERE ${whereClause} + ORDER BY ${sortColumn} ${sort_order === 'desc' ? 'DESC' : 'ASC'} NULLS LAST + LIMIT $${paramIndex++} OFFSET $${paramIndex} + `; + const dataRes = await this.db.query(dataQuery, dataParams); + + return { + items: dataRes.rows.map((row) => this.mapPantryItemWithDetailsToInventoryItem(row)), + total, + }; + } catch (error) { + handleDbError( + error, + logger, + 'Database error in getInventory', + { options }, + { + defaultMessage: 'Failed to get inventory.', + }, + ); + } + } + + /** + * Gets items expiring within a specified number of days. + */ + async getExpiringItems( + userId: string, + daysAhead: number, + logger: Logger, + ): Promise { + try { + const res = await this.db.query( + `SELECT + pi.*, + COALESCE(mgi.name, 'Unknown Item') AS item_name, + c.name AS category_name, + pl.name AS location_name + FROM public.pantry_items pi + LEFT JOIN public.master_grocery_items mgi ON pi.master_item_id = mgi.master_grocery_item_id + LEFT JOIN public.categories c ON mgi.category_id = c.category_id + LEFT JOIN public.pantry_locations pl ON pi.pantry_location_id = pl.pantry_location_id + WHERE pi.user_id = $1 + AND pi.best_before_date IS NOT NULL + AND pi.best_before_date <= CURRENT_DATE + $2 + AND (pi.is_consumed = false OR pi.is_consumed IS NULL) + ORDER BY pi.best_before_date ASC`, + [userId, daysAhead], + ); + + return res.rows.map((row) => this.mapPantryItemWithDetailsToInventoryItem(row)); + } catch (error) { + handleDbError( + error, + logger, + 'Database error in getExpiringItems', + { userId, daysAhead }, + { + defaultMessage: 'Failed to get expiring items.', + }, + ); + } + } + + /** + * Gets items that are already expired. + */ + async getExpiredItems(userId: string, logger: Logger): Promise { + try { + const res = await this.db.query( + `SELECT + pi.*, + COALESCE(mgi.name, 'Unknown Item') AS item_name, + c.name AS category_name, + pl.name AS location_name + FROM public.pantry_items pi + LEFT JOIN public.master_grocery_items mgi ON pi.master_item_id = mgi.master_grocery_item_id + LEFT JOIN public.categories c ON mgi.category_id = c.category_id + LEFT JOIN public.pantry_locations pl ON pi.pantry_location_id = pl.pantry_location_id + WHERE pi.user_id = $1 + AND pi.best_before_date IS NOT NULL + AND pi.best_before_date < CURRENT_DATE + AND (pi.is_consumed = false OR pi.is_consumed IS NULL) + ORDER BY pi.best_before_date ASC`, + [userId], + ); + + return res.rows.map((row) => this.mapPantryItemWithDetailsToInventoryItem(row)); + } catch (error) { + handleDbError( + error, + logger, + 'Database error in getExpiredItems', + { userId }, + { + defaultMessage: 'Failed to get expired items.', + }, + ); + } + } + + // ============================================================================ + // EXPIRY DATE RANGES (Reference Data) + // ============================================================================ + + /** + * Gets the expiry date range for an item based on master item, category, or pattern. + * Returns the most specific match found. + */ + async getExpiryRangeForItem( + storageLocation: StorageLocation, + logger: Logger, + options: { + masterItemId?: number; + categoryId?: number; + itemName?: string; + } = {}, + ): Promise { + const { masterItemId, categoryId, itemName } = options; + + try { + // Try to find by master_item_id first (most specific) + if (masterItemId) { + const res = await this.db.query( + `SELECT * FROM public.expiry_date_ranges + WHERE master_item_id = $1 AND storage_location = $2`, + [masterItemId, storageLocation], + ); + if (res.rowCount && res.rowCount > 0) { + return res.rows[0]; + } + } + + // Try category_id + if (categoryId) { + const res = await this.db.query( + `SELECT * FROM public.expiry_date_ranges + WHERE category_id = $1 AND storage_location = $2 AND master_item_id IS NULL`, + [categoryId, storageLocation], + ); + if (res.rowCount && res.rowCount > 0) { + return res.rows[0]; + } + } + + // Try item_pattern matching + if (itemName) { + const res = await this.db.query( + `SELECT * FROM public.expiry_date_ranges + WHERE item_pattern IS NOT NULL + AND storage_location = $1 + AND $2 ~* item_pattern + ORDER BY LENGTH(item_pattern) DESC + LIMIT 1`, + [storageLocation, itemName], + ); + if (res.rowCount && res.rowCount > 0) { + return res.rows[0]; + } + } + + return null; + } catch (error) { + handleDbError( + error, + logger, + 'Database error in getExpiryRangeForItem', + { storageLocation, masterItemId, categoryId, itemName }, + { defaultMessage: 'Failed to get expiry range for item.' }, + ); + } + } + + /** + * Adds a new expiry date range (admin operation). + */ + async addExpiryRange(range: AddExpiryRangeRequest, logger: Logger): Promise { + try { + const res = await this.db.query( + `INSERT INTO public.expiry_date_ranges + (master_item_id, category_id, item_pattern, storage_location, min_days, max_days, typical_days, notes, source) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING *`, + [ + range.master_item_id || null, + range.category_id || null, + range.item_pattern || null, + range.storage_location, + range.min_days, + range.max_days, + range.typical_days, + range.notes || null, + range.source || null, + ], + ); + + return res.rows[0]; + } catch (error) { + handleDbError( + error, + logger, + 'Database error in addExpiryRange', + { range }, + { + fkMessage: 'The specified master item or category does not exist.', + uniqueMessage: 'An expiry range for this item/location combination already exists.', + checkMessage: 'Invalid storage location or day values.', + defaultMessage: 'Failed to add expiry range.', + }, + ); + } + } + + /** + * Gets all expiry ranges with optional filtering. + */ + async getExpiryRanges( + options: ExpiryRangeQueryOptions, + logger: Logger, + ): Promise<{ ranges: ExpiryDateRange[]; total: number }> { + const { + master_item_id, + category_id, + storage_location, + source, + limit = 50, + offset = 0, + } = options; + + try { + const conditions: string[] = []; + const params: (string | number)[] = []; + let paramIndex = 1; + + if (master_item_id) { + conditions.push(`master_item_id = $${paramIndex++}`); + params.push(master_item_id); + } + + if (category_id) { + conditions.push(`category_id = $${paramIndex++}`); + params.push(category_id); + } + + if (storage_location) { + conditions.push(`storage_location = $${paramIndex++}`); + params.push(storage_location); + } + + if (source) { + conditions.push(`source = $${paramIndex++}`); + params.push(source); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + // Get total count + const countRes = await this.db.query<{ count: string }>( + `SELECT COUNT(*) FROM public.expiry_date_ranges ${whereClause}`, + params, + ); + const total = parseInt(countRes.rows[0].count, 10); + + // Get paginated results + const dataParams = [...params, limit, offset]; + const dataRes = await this.db.query( + `SELECT * FROM public.expiry_date_ranges + ${whereClause} + ORDER BY storage_location, master_item_id NULLS LAST, category_id NULLS LAST + LIMIT $${paramIndex++} OFFSET $${paramIndex}`, + dataParams, + ); + + return { ranges: dataRes.rows, total }; + } catch (error) { + handleDbError( + error, + logger, + 'Database error in getExpiryRanges', + { options }, + { + defaultMessage: 'Failed to get expiry ranges.', + }, + ); + } + } + + // ============================================================================ + // EXPIRY ALERTS + // ============================================================================ + + /** + * Gets a user's expiry alert settings. + */ + async getUserAlertSettings(userId: string, logger: Logger): Promise { + try { + const res = await this.db.query( + `SELECT * FROM public.expiry_alerts WHERE user_id = $1 ORDER BY alert_method`, + [userId], + ); + return res.rows; + } catch (error) { + handleDbError( + error, + logger, + 'Database error in getUserAlertSettings', + { userId }, + { + defaultMessage: 'Failed to get alert settings.', + }, + ); + } + } + + /** + * Creates or updates a user's expiry alert settings for a specific method. + */ + async upsertAlertSettings( + userId: string, + alertMethod: AlertMethod, + settings: UpdateExpiryAlertSettingsRequest, + logger: Logger, + ): Promise { + try { + const res = await this.db.query( + `INSERT INTO public.expiry_alerts (user_id, alert_method, days_before_expiry, is_enabled) + VALUES ($1, $2, $3, $4) + ON CONFLICT (user_id, alert_method) DO UPDATE SET + days_before_expiry = COALESCE($3, expiry_alerts.days_before_expiry), + is_enabled = COALESCE($4, expiry_alerts.is_enabled), + updated_at = NOW() + RETURNING *`, + [userId, alertMethod, settings.days_before_expiry ?? 3, settings.is_enabled ?? true], + ); + + return res.rows[0]; + } catch (error) { + handleDbError( + error, + logger, + 'Database error in upsertAlertSettings', + { userId, alertMethod, settings }, + { + checkMessage: 'Invalid alert method or days value.', + defaultMessage: 'Failed to update alert settings.', + }, + ); + } + } + + /** + * Logs a sent expiry alert. + */ + async logAlert( + userId: string, + alertType: ExpiryAlertType, + alertMethod: AlertMethod, + itemName: string, + logger: Logger, + options: { + pantryItemId?: number | null; + expiryDate?: string | null; + daysUntilExpiry?: number | null; + } = {}, + ): Promise { + try { + const res = await this.db.query( + `INSERT INTO public.expiry_alert_log + (user_id, pantry_item_id, alert_type, alert_method, item_name, expiry_date, days_until_expiry) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *`, + [ + userId, + options.pantryItemId || null, + alertType, + alertMethod, + itemName, + options.expiryDate || null, + options.daysUntilExpiry ?? null, + ], + ); + + return res.rows[0]; + } catch (error) { + handleDbError( + error, + logger, + 'Database error in logAlert', + { userId, alertType, alertMethod, itemName }, + { + defaultMessage: 'Failed to log expiry alert.', + }, + ); + } + } + + /** + * Gets users who have enabled alerts and have expiring items. + * Used by the alert worker to batch process notifications. + */ + async getUsersWithExpiringItems( + logger: Logger, + ): Promise< + Array<{ user_id: string; email: string; alert_method: AlertMethod; days_before_expiry: number }> + > { + try { + const res = await this.db.query<{ + user_id: string; + email: string; + alert_method: AlertMethod; + days_before_expiry: number; + }>( + `SELECT DISTINCT + ea.user_id, + u.email, + ea.alert_method, + ea.days_before_expiry + FROM public.expiry_alerts ea + JOIN public.users u ON ea.user_id = u.user_id + JOIN public.pantry_items pi ON ea.user_id = pi.user_id + WHERE ea.is_enabled = true + AND pi.best_before_date IS NOT NULL + AND pi.best_before_date <= CURRENT_DATE + ea.days_before_expiry + AND (pi.is_consumed = false OR pi.is_consumed IS NULL) + AND (ea.last_alert_sent_at IS NULL OR ea.last_alert_sent_at < CURRENT_DATE)`, + ); + + return res.rows; + } catch (error) { + handleDbError( + error, + logger, + 'Database error in getUsersWithExpiringItems', + {}, + { + defaultMessage: 'Failed to get users with expiring items.', + }, + ); + } + } + + /** + * Updates the last_alert_sent_at timestamp for a user's alert setting. + */ + async markAlertSent(userId: string, alertMethod: AlertMethod, logger: Logger): Promise { + try { + await this.db.query( + `UPDATE public.expiry_alerts + SET last_alert_sent_at = NOW(), updated_at = NOW() + WHERE user_id = $1 AND alert_method = $2`, + [userId, alertMethod], + ); + } catch (error) { + handleDbError( + error, + logger, + 'Database error in markAlertSent', + { userId, alertMethod }, + { + defaultMessage: 'Failed to mark alert as sent.', + }, + ); + } + } + + // ============================================================================ + // HELPER METHODS + // ============================================================================ + + /** + * Maps a basic pantry item row to UserInventoryItem. + */ + private mapPantryItemToInventoryItem(row: PantryItemRow, itemName: string): UserInventoryItem { + const daysUntilExpiry = row.best_before_date + ? Math.ceil((new Date(row.best_before_date).getTime() - Date.now()) / (1000 * 60 * 60 * 24)) + : null; + + return { + inventory_id: row.pantry_item_id, + user_id: row.user_id, + product_id: row.product_id, + master_item_id: row.master_item_id, + item_name: itemName, + quantity: Number(row.quantity), + unit: row.unit, + purchase_date: row.purchase_date, + expiry_date: row.best_before_date, + source: (row.source as InventorySource) || 'manual', + location: null, + notes: null, + is_consumed: row.is_consumed ?? false, + consumed_at: row.consumed_at, + expiry_source: row.expiry_source as ExpirySource | null, + receipt_item_id: row.receipt_item_id, + pantry_location_id: row.pantry_location_id, + notification_sent_at: row.notification_sent_at, + created_at: row.updated_at, // pantry_items doesn't have created_at + updated_at: row.updated_at, + days_until_expiry: daysUntilExpiry, + expiry_status: this.calculateExpiryStatus(daysUntilExpiry), + }; + } + + // ============================================================================ + // RECIPE SUGGESTIONS + // ============================================================================ + + /** + * Finds recipes that use the user's expiring inventory items. + * Returns recipes ranked by how many expiring items they use. + * @param userId The user's ID + * @param daysAhead Number of days to look ahead for expiring items + * @param limit Maximum number of recipes to return + * @param offset Offset for pagination + * @param logger Pino logger instance + * @returns Recipes with their matching expiring ingredients and counts + */ + async getRecipesForExpiringItems( + userId: string, + daysAhead: number, + limit: number, + offset: number, + logger: Logger, + ): Promise<{ + recipes: Array<{ + recipe_id: number; + recipe_name: string; + description: string | null; + prep_time_minutes: number | null; + cook_time_minutes: number | null; + servings: number | null; + photo_url: string | null; + matching_master_item_ids: number[]; + match_count: number; + }>; + total: number; + }> { + try { + // First, get the expiring items' master_item_ids + const expiringItemsQuery = ` + SELECT DISTINCT pi.master_item_id + FROM public.pantry_items pi + WHERE pi.user_id = $1 + AND pi.master_item_id IS NOT NULL + AND pi.best_before_date IS NOT NULL + AND pi.best_before_date <= CURRENT_DATE + $2 + AND pi.best_before_date >= CURRENT_DATE -- Not yet expired + AND (pi.is_consumed = false OR pi.is_consumed IS NULL) + `; + const expiringRes = await this.db.query<{ master_item_id: number }>(expiringItemsQuery, [ + userId, + daysAhead, + ]); + + if (expiringRes.rows.length === 0) { + return { recipes: [], total: 0 }; + } + + const masterItemIds = expiringRes.rows.map((row) => row.master_item_id); + + // Get total count of matching recipes + const countQuery = ` + SELECT COUNT(DISTINCT r.recipe_id) as total + FROM public.recipes r + INNER JOIN public.recipe_ingredients ri ON r.recipe_id = ri.recipe_id + WHERE ri.master_item_id = ANY($1) + AND r.status = 'public' + `; + const countRes = await this.db.query<{ total: string }>(countQuery, [masterItemIds]); + const total = parseInt(countRes.rows[0].total, 10); + + // Get recipes that use any of the expiring items, ranked by match count + const recipesQuery = ` + SELECT + r.recipe_id, + r.name as recipe_name, + r.description, + r.prep_time_minutes, + r.cook_time_minutes, + r.servings, + r.photo_url, + array_agg(DISTINCT ri.master_item_id) FILTER (WHERE ri.master_item_id = ANY($1)) as matching_master_item_ids, + COUNT(DISTINCT ri.master_item_id) FILTER (WHERE ri.master_item_id = ANY($1)) as match_count + FROM public.recipes r + INNER JOIN public.recipe_ingredients ri ON r.recipe_id = ri.recipe_id + WHERE ri.master_item_id = ANY($1) + AND r.status = 'public' + GROUP BY r.recipe_id, r.name, r.description, r.prep_time_minutes, r.cook_time_minutes, r.servings, r.photo_url + ORDER BY match_count DESC, r.name ASC + LIMIT $2 OFFSET $3 + `; + const recipesRes = await this.db.query<{ + recipe_id: number; + recipe_name: string; + description: string | null; + prep_time_minutes: number | null; + cook_time_minutes: number | null; + servings: number | null; + photo_url: string | null; + matching_master_item_ids: number[]; + match_count: string; + }>(recipesQuery, [masterItemIds, limit, offset]); + + return { + recipes: recipesRes.rows.map((row) => ({ + recipe_id: row.recipe_id, + recipe_name: row.recipe_name, + description: row.description, + prep_time_minutes: row.prep_time_minutes, + cook_time_minutes: row.cook_time_minutes, + servings: row.servings, + photo_url: row.photo_url, + matching_master_item_ids: row.matching_master_item_ids || [], + match_count: parseInt(row.match_count, 10), + })), + total, + }; + } catch (error) { + handleDbError( + error, + logger, + 'Database error in getRecipesForExpiringItems', + { userId, daysAhead }, + { + defaultMessage: 'Failed to get recipe suggestions for expiring items.', + }, + ); + } + } + + // ============================================================================ + // PRIVATE HELPERS + // ============================================================================ + + /** + * Maps a pantry item row with joined details to UserInventoryItem. + */ + private mapPantryItemWithDetailsToInventoryItem( + row: PantryItemWithDetailsRow, + ): UserInventoryItem { + const daysUntilExpiry = row.best_before_date + ? Math.ceil((new Date(row.best_before_date).getTime() - Date.now()) / (1000 * 60 * 60 * 24)) + : null; + + return { + inventory_id: row.pantry_item_id, + user_id: row.user_id, + product_id: row.product_id, + master_item_id: row.master_item_id, + item_name: row.item_name, + quantity: Number(row.quantity), + unit: row.unit, + purchase_date: row.purchase_date, + expiry_date: row.best_before_date, + source: (row.source as InventorySource) || 'manual', + location: row.location_name as StorageLocation | null, + notes: null, + is_consumed: row.is_consumed ?? false, + consumed_at: row.consumed_at, + expiry_source: row.expiry_source as ExpirySource | null, + receipt_item_id: row.receipt_item_id, + pantry_location_id: row.pantry_location_id, + notification_sent_at: row.notification_sent_at, + created_at: row.updated_at, + updated_at: row.updated_at, + days_until_expiry: daysUntilExpiry, + expiry_status: this.calculateExpiryStatus(daysUntilExpiry), + master_item_name: row.item_name, + category_name: row.category_name ?? undefined, + }; + } + + /** + * Calculates the expiry status based on days until expiry. + */ + private calculateExpiryStatus( + daysUntilExpiry: number | null, + ): 'fresh' | 'expiring_soon' | 'expired' | 'unknown' { + if (daysUntilExpiry === null) { + return 'unknown'; + } + if (daysUntilExpiry < 0) { + return 'expired'; + } + if (daysUntilExpiry <= 7) { + return 'expiring_soon'; + } + return 'fresh'; + } +} diff --git a/src/services/db/index.db.ts b/src/services/db/index.db.ts index e0f30a3..f3b9026 100644 --- a/src/services/db/index.db.ts +++ b/src/services/db/index.db.ts @@ -12,6 +12,9 @@ import { GamificationRepository } from './gamification.db'; import { AdminRepository } from './admin.db'; import { reactionRepo } from './reaction.db'; import { conversionRepo } from './conversion.db'; +import { UpcRepository } from './upc.db'; +import { ExpiryRepository } from './expiry.db'; +import { ReceiptRepository } from './receipt.db'; const userRepo = new UserRepository(); const flyerRepo = new FlyerRepository(); @@ -23,6 +26,9 @@ const notificationRepo = new NotificationRepository(); const budgetRepo = new BudgetRepository(); const gamificationRepo = new GamificationRepository(); const adminRepo = new AdminRepository(); +const upcRepo = new UpcRepository(); +const expiryRepo = new ExpiryRepository(); +const receiptRepo = new ReceiptRepository(); export { userRepo, @@ -37,5 +43,8 @@ export { adminRepo, reactionRepo, conversionRepo, + upcRepo, + expiryRepo, + receiptRepo, withTransaction, }; diff --git a/src/services/db/receipt.db.test.ts b/src/services/db/receipt.db.test.ts new file mode 100644 index 0000000..129828d --- /dev/null +++ b/src/services/db/receipt.db.test.ts @@ -0,0 +1,1226 @@ +// src/services/db/receipt.db.test.ts +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Logger } from 'pino'; +import { createMockLogger } from '../../tests/utils/mockLogger'; +import { ReceiptRepository } from './receipt.db'; +import { NotFoundError } from './errors.db'; + +// Create mock pool +const mockQuery = vi.fn(); +const mockPool = { + query: mockQuery, +}; + +describe('ReceiptRepository', () => { + let repo: ReceiptRepository; + let mockLogger: Logger; + + beforeEach(() => { + vi.clearAllMocks(); + mockLogger = createMockLogger(); + repo = new ReceiptRepository(mockPool); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + // ============================================================================ + // RECEIPTS + // ============================================================================ + + describe('createReceipt', () => { + it('should create a new receipt successfully', async () => { + const receiptRow = { + receipt_id: 1, + user_id: 'user-1', + store_id: 5, + receipt_image_url: '/uploads/receipts/receipt-1.jpg', + transaction_date: '2024-01-15', + total_amount_cents: null, + status: 'pending', + raw_text: null, + store_confidence: null, + ocr_provider: null, + error_details: null, + retry_count: 0, + ocr_confidence: null, + currency: 'CAD', + created_at: new Date().toISOString(), + processed_at: null, + updated_at: new Date().toISOString(), + }; + + mockQuery.mockResolvedValueOnce({ + rows: [receiptRow], + }); + + const result = await repo.createReceipt( + { + user_id: 'user-1', + receipt_image_url: '/uploads/receipts/receipt-1.jpg', + store_id: 5, + transaction_date: '2024-01-15', + }, + mockLogger, + ); + + expect(result.receipt_id).toBe(1); + expect(result.user_id).toBe('user-1'); + expect(result.status).toBe('pending'); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO public.receipts'), + ['user-1', '/uploads/receipts/receipt-1.jpg', 5, '2024-01-15'], + ); + }); + + it('should create receipt without optional fields', async () => { + const receiptRow = { + receipt_id: 2, + user_id: 'user-1', + store_id: null, + receipt_image_url: '/uploads/receipts/receipt-2.jpg', + transaction_date: null, + total_amount_cents: null, + status: 'pending', + raw_text: null, + store_confidence: null, + ocr_provider: null, + error_details: null, + retry_count: 0, + ocr_confidence: null, + currency: 'CAD', + created_at: new Date().toISOString(), + processed_at: null, + updated_at: new Date().toISOString(), + }; + + mockQuery.mockResolvedValueOnce({ + rows: [receiptRow], + }); + + const result = await repo.createReceipt( + { + user_id: 'user-1', + receipt_image_url: '/uploads/receipts/receipt-2.jpg', + }, + mockLogger, + ); + + expect(result.store_id).toBeNull(); + expect(result.transaction_date).toBeNull(); + }); + + it('should throw on database error', async () => { + mockQuery.mockRejectedValueOnce(new Error('DB connection failed')); + + await expect( + repo.createReceipt( + { + user_id: 'user-1', + receipt_image_url: '/uploads/receipts/receipt-1.jpg', + }, + mockLogger, + ), + ).rejects.toThrow(); + }); + }); + + describe('getReceiptById', () => { + it('should return receipt when found', async () => { + const receiptRow = { + receipt_id: 1, + user_id: 'user-1', + store_id: 5, + receipt_image_url: '/uploads/receipts/receipt-1.jpg', + transaction_date: '2024-01-15', + total_amount_cents: 5499, + status: 'completed', + raw_text: 'Store Name\nItem 1 $10.00', + store_confidence: 0.95, + ocr_provider: 'gemini', + error_details: null, + retry_count: 0, + ocr_confidence: 0.9, + currency: 'CAD', + created_at: new Date().toISOString(), + processed_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + mockQuery.mockResolvedValueOnce({ + rowCount: 1, + rows: [receiptRow], + }); + + const result = await repo.getReceiptById(1, 'user-1', mockLogger); + + expect(result.receipt_id).toBe(1); + expect(result.status).toBe('completed'); + expect(result.store_confidence).toBe(0.95); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('WHERE receipt_id = $1 AND user_id = $2'), + [1, 'user-1'], + ); + }); + + it('should throw NotFoundError when receipt not found', async () => { + mockQuery.mockResolvedValueOnce({ + rowCount: 0, + rows: [], + }); + + await expect(repo.getReceiptById(999, 'user-1', mockLogger)).rejects.toThrow(NotFoundError); + }); + }); + + describe('getReceipts', () => { + it('should return paginated receipts', async () => { + // Count query + mockQuery.mockResolvedValueOnce({ + rows: [{ count: '10' }], + }); + + // Data query + mockQuery.mockResolvedValueOnce({ + rows: [ + { + receipt_id: 1, + user_id: 'user-1', + store_id: 5, + receipt_image_url: '/uploads/receipts/receipt-1.jpg', + transaction_date: '2024-01-15', + total_amount_cents: 5499, + status: 'completed', + raw_text: null, + store_confidence: null, + ocr_provider: null, + error_details: null, + retry_count: 0, + ocr_confidence: null, + currency: 'CAD', + created_at: new Date().toISOString(), + processed_at: null, + updated_at: new Date().toISOString(), + }, + ], + }); + + const result = await repo.getReceipts( + { user_id: 'user-1', limit: 10, offset: 0 }, + mockLogger, + ); + + expect(result.total).toBe(10); + expect(result.receipts).toHaveLength(1); + }); + + it('should filter by status', async () => { + mockQuery.mockResolvedValueOnce({ rows: [{ count: '5' }] }); + mockQuery.mockResolvedValueOnce({ rows: [] }); + + await repo.getReceipts({ user_id: 'user-1', status: 'completed' }, mockLogger); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('status = $2'), + expect.any(Array), + ); + }); + + it('should filter by store_id', async () => { + mockQuery.mockResolvedValueOnce({ rows: [{ count: '3' }] }); + mockQuery.mockResolvedValueOnce({ rows: [] }); + + await repo.getReceipts({ user_id: 'user-1', store_id: 5 }, mockLogger); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('store_id = $2'), + expect.any(Array), + ); + }); + + it('should filter by date range', async () => { + mockQuery.mockResolvedValueOnce({ rows: [{ count: '2' }] }); + mockQuery.mockResolvedValueOnce({ rows: [] }); + + await repo.getReceipts( + { + user_id: 'user-1', + from_date: '2024-01-01', + to_date: '2024-01-31', + }, + mockLogger, + ); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('created_at >= $2'), + expect.any(Array), + ); + }); + }); + + describe('updateReceipt', () => { + it('should update receipt successfully', async () => { + const updatedRow = { + receipt_id: 1, + user_id: 'user-1', + store_id: 5, + receipt_image_url: '/uploads/receipts/receipt-1.jpg', + transaction_date: '2024-01-15', + total_amount_cents: 5499, + status: 'completed', + raw_text: 'Extracted text here', + store_confidence: 0.95, + ocr_provider: 'gemini', + error_details: null, + retry_count: 0, + ocr_confidence: 0.88, + currency: 'CAD', + created_at: new Date().toISOString(), + processed_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + mockQuery.mockResolvedValueOnce({ + rowCount: 1, + rows: [updatedRow], + }); + + const result = await repo.updateReceipt( + 1, + { + status: 'completed', + raw_text: 'Extracted text here', + ocr_provider: 'gemini', + ocr_confidence: 0.88, + total_amount_cents: 5499, + }, + mockLogger, + ); + + expect(result.status).toBe('completed'); + expect(result.ocr_confidence).toBe(0.88); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('UPDATE public.receipts SET'), + expect.any(Array), + ); + }); + + it('should throw NotFoundError when receipt not found', async () => { + mockQuery.mockResolvedValueOnce({ + rowCount: 0, + rows: [], + }); + + await expect(repo.updateReceipt(999, { status: 'completed' }, mockLogger)).rejects.toThrow( + NotFoundError, + ); + }); + }); + + describe('incrementRetryCount', () => { + it('should increment retry count successfully', async () => { + mockQuery.mockResolvedValueOnce({ + rowCount: 1, + rows: [{ retry_count: 2 }], + }); + + const result = await repo.incrementRetryCount(1, mockLogger); + + expect(result).toBe(2); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('retry_count = retry_count + 1'), + [1], + ); + }); + + it('should throw NotFoundError when receipt not found', async () => { + mockQuery.mockResolvedValueOnce({ + rowCount: 0, + rows: [], + }); + + await expect(repo.incrementRetryCount(999, mockLogger)).rejects.toThrow(NotFoundError); + }); + }); + + describe('getReceiptsNeedingProcessing', () => { + it('should return pending and retriable receipts', async () => { + const receipts = [ + { + receipt_id: 1, + user_id: 'user-1', + store_id: null, + receipt_image_url: '/uploads/receipts/receipt-1.jpg', + transaction_date: null, + total_amount_cents: null, + status: 'pending', + raw_text: null, + store_confidence: null, + ocr_provider: null, + error_details: null, + retry_count: 0, + ocr_confidence: null, + currency: 'CAD', + created_at: new Date().toISOString(), + processed_at: null, + updated_at: new Date().toISOString(), + }, + { + receipt_id: 2, + user_id: 'user-2', + store_id: null, + receipt_image_url: '/uploads/receipts/receipt-2.jpg', + transaction_date: null, + total_amount_cents: null, + status: 'failed', + raw_text: null, + store_confidence: null, + ocr_provider: null, + error_details: null, + retry_count: 1, + ocr_confidence: null, + currency: 'CAD', + created_at: new Date().toISOString(), + processed_at: null, + updated_at: new Date().toISOString(), + }, + ]; + + mockQuery.mockResolvedValueOnce({ + rowCount: 2, + rows: receipts, + }); + + const result = await repo.getReceiptsNeedingProcessing(3, 10, mockLogger); + + expect(result).toHaveLength(2); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("status = 'pending' OR (status = 'failed' AND retry_count < $1)"), + [3, 10], + ); + }); + }); + + describe('deleteReceipt', () => { + it('should delete receipt successfully', async () => { + mockQuery.mockResolvedValueOnce({ + rowCount: 1, + }); + + await repo.deleteReceipt(1, 'user-1', mockLogger); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('DELETE FROM public.receipts'), + [1, 'user-1'], + ); + }); + + it('should throw NotFoundError when receipt not found or unauthorized', async () => { + mockQuery.mockResolvedValueOnce({ + rowCount: 0, + }); + + await expect(repo.deleteReceipt(999, 'user-1', mockLogger)).rejects.toThrow(NotFoundError); + }); + }); + + // ============================================================================ + // RECEIPT ITEMS + // ============================================================================ + + describe('addReceiptItems', () => { + it('should add multiple receipt items', async () => { + const itemRows = [ + { + receipt_item_id: 1, + receipt_id: 1, + raw_item_description: 'Milk 2L', + quantity: 1, + price_paid_cents: 499, + master_item_id: null, + product_id: null, + status: 'pending', + line_number: 1, + match_confidence: null, + is_discount: false, + unit_price_cents: 499, + unit_type: null, + added_to_pantry: false, + pantry_item_id: null, + upc_code: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + { + receipt_item_id: 2, + receipt_id: 1, + raw_item_description: 'Bread', + quantity: 2, + price_paid_cents: 598, + master_item_id: null, + product_id: null, + status: 'pending', + line_number: 2, + match_confidence: null, + is_discount: false, + unit_price_cents: 299, + unit_type: null, + added_to_pantry: false, + pantry_item_id: null, + upc_code: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ]; + + mockQuery.mockResolvedValueOnce({ + rowCount: 2, + rows: itemRows, + }); + + const result = await repo.addReceiptItems( + [ + { + receipt_id: 1, + raw_item_description: 'Milk 2L', + price_paid_cents: 499, + line_number: 1, + unit_price_cents: 499, + }, + { + receipt_id: 1, + raw_item_description: 'Bread', + quantity: 2, + price_paid_cents: 598, + line_number: 2, + unit_price_cents: 299, + }, + ], + mockLogger, + ); + + expect(result).toHaveLength(2); + expect(result[0].raw_item_description).toBe('Milk 2L'); + expect(result[1].quantity).toBe(2); + }); + + it('should return empty array for empty input', async () => { + const result = await repo.addReceiptItems([], mockLogger); + + expect(result).toHaveLength(0); + expect(mockQuery).not.toHaveBeenCalled(); + }); + + it('should handle discount items', async () => { + const itemRow = { + receipt_item_id: 1, + receipt_id: 1, + raw_item_description: 'DISCOUNT -$1.00', + quantity: 1, + price_paid_cents: -100, + master_item_id: null, + product_id: null, + status: 'pending', + line_number: 5, + match_confidence: null, + is_discount: true, + unit_price_cents: null, + unit_type: null, + added_to_pantry: false, + pantry_item_id: null, + upc_code: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + mockQuery.mockResolvedValueOnce({ + rowCount: 1, + rows: [itemRow], + }); + + const result = await repo.addReceiptItems( + [ + { + receipt_id: 1, + raw_item_description: 'DISCOUNT -$1.00', + price_paid_cents: -100, + line_number: 5, + is_discount: true, + }, + ], + mockLogger, + ); + + expect(result[0].is_discount).toBe(true); + expect(result[0].price_paid_cents).toBe(-100); + }); + }); + + describe('getReceiptItems', () => { + it('should return receipt items ordered by line number', async () => { + const itemRows = [ + { + receipt_item_id: 1, + receipt_id: 1, + raw_item_description: 'Item 1', + quantity: 1, + price_paid_cents: 100, + master_item_id: null, + product_id: null, + status: 'pending', + line_number: 1, + match_confidence: null, + is_discount: false, + unit_price_cents: null, + unit_type: null, + added_to_pantry: false, + pantry_item_id: null, + upc_code: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + { + receipt_item_id: 2, + receipt_id: 1, + raw_item_description: 'Item 2', + quantity: 1, + price_paid_cents: 200, + master_item_id: null, + product_id: null, + status: 'pending', + line_number: 2, + match_confidence: null, + is_discount: false, + unit_price_cents: null, + unit_type: null, + added_to_pantry: false, + pantry_item_id: null, + upc_code: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ]; + + mockQuery.mockResolvedValueOnce({ + rows: itemRows, + }); + + const result = await repo.getReceiptItems(1, mockLogger); + + expect(result).toHaveLength(2); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('ORDER BY line_number ASC NULLS LAST'), + [1], + ); + }); + }); + + describe('updateReceiptItem', () => { + it('should update receipt item with product match', async () => { + const updatedRow = { + receipt_item_id: 1, + receipt_id: 1, + raw_item_description: 'Milk 2L', + quantity: 1, + price_paid_cents: 499, + master_item_id: 100, + product_id: 50, + status: 'matched', + line_number: 1, + match_confidence: 0.95, + is_discount: false, + unit_price_cents: 499, + unit_type: null, + added_to_pantry: false, + pantry_item_id: null, + upc_code: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + mockQuery.mockResolvedValueOnce({ + rowCount: 1, + rows: [updatedRow], + }); + + const result = await repo.updateReceiptItem( + 1, + { + status: 'matched', + master_item_id: 100, + product_id: 50, + match_confidence: 0.95, + }, + mockLogger, + ); + + expect(result.status).toBe('matched'); + expect(result.match_confidence).toBe(0.95); + }); + + it('should update receipt item with pantry linkage', async () => { + const updatedRow = { + receipt_item_id: 1, + receipt_id: 1, + raw_item_description: 'Milk 2L', + quantity: 1, + price_paid_cents: 499, + master_item_id: 100, + product_id: 50, + status: 'matched', + line_number: 1, + match_confidence: 0.95, + is_discount: false, + unit_price_cents: 499, + unit_type: null, + added_to_pantry: true, + pantry_item_id: 25, + upc_code: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + mockQuery.mockResolvedValueOnce({ + rowCount: 1, + rows: [updatedRow], + }); + + const result = await repo.updateReceiptItem( + 1, + { + added_to_pantry: true, + pantry_item_id: 25, + }, + mockLogger, + ); + + expect(result.added_to_pantry).toBe(true); + expect(result.pantry_item_id).toBe(25); + }); + + it('should throw NotFoundError when item not found', async () => { + mockQuery.mockResolvedValueOnce({ + rowCount: 0, + rows: [], + }); + + await expect(repo.updateReceiptItem(999, { status: 'matched' }, mockLogger)).rejects.toThrow( + NotFoundError, + ); + }); + }); + + describe('getUnaddedReceiptItems', () => { + it('should return items not added to pantry excluding discounts', async () => { + const itemRows = [ + { + receipt_item_id: 1, + receipt_id: 1, + raw_item_description: 'Milk 2L', + quantity: 1, + price_paid_cents: 499, + master_item_id: null, + product_id: null, + status: 'pending', + line_number: 1, + match_confidence: null, + is_discount: false, + unit_price_cents: null, + unit_type: null, + added_to_pantry: false, + pantry_item_id: null, + upc_code: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ]; + + mockQuery.mockResolvedValueOnce({ + rows: itemRows, + }); + + const result = await repo.getUnaddedReceiptItems(1, mockLogger); + + expect(result).toHaveLength(1); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('added_to_pantry = false'), + [1], + ); + expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('is_discount = false'), [1]); + }); + }); + + // ============================================================================ + // PROCESSING LOG + // ============================================================================ + + describe('logProcessingStep', () => { + it('should log processing step with all options', async () => { + const logRecord = { + log_id: 1, + receipt_id: 1, + processing_step: 'ocr_extraction', + status: 'completed', + provider: 'gemini', + duration_ms: 1500, + tokens_used: 1000, + cost_cents: 5, + input_data: null, + output_data: JSON.stringify({ text: 'Extracted text' }), + error_message: null, + created_at: new Date().toISOString(), + }; + + mockQuery.mockResolvedValueOnce({ + rows: [logRecord], + }); + + const result = await repo.logProcessingStep(1, 'ocr_extraction', 'completed', mockLogger, { + provider: 'gemini', + durationMs: 1500, + tokensUsed: 1000, + costCents: 5, + outputData: { text: 'Extracted text' }, + }); + + expect(result.processing_step).toBe('ocr_extraction'); + expect(result.status).toBe('completed'); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO public.receipt_processing_log'), + expect.any(Array), + ); + }); + + it('should log processing step with error', async () => { + const logRecord = { + log_id: 2, + receipt_id: 1, + processing_step: 'ocr_extraction', + status: 'failed', + provider: 'gemini', + duration_ms: 500, + tokens_used: null, + cost_cents: null, + input_data: null, + output_data: null, + error_message: 'OCR service unavailable', + created_at: new Date().toISOString(), + }; + + mockQuery.mockResolvedValueOnce({ + rows: [logRecord], + }); + + const result = await repo.logProcessingStep(1, 'ocr_extraction', 'failed', mockLogger, { + provider: 'gemini', + durationMs: 500, + errorMessage: 'OCR service unavailable', + }); + + expect(result.status).toBe('failed'); + expect(result.error_message).toBe('OCR service unavailable'); + }); + }); + + describe('getProcessingLogs', () => { + it('should return processing logs for receipt', async () => { + const logs = [ + { + log_id: 1, + receipt_id: 1, + processing_step: 'upload', + status: 'success', + provider: null, + duration_ms: 100, + tokens_used: null, + cost_cents: null, + input_data: null, + output_data: null, + error_message: null, + created_at: new Date().toISOString(), + }, + { + log_id: 2, + receipt_id: 1, + processing_step: 'ocr', + status: 'success', + provider: 'gemini', + duration_ms: 1500, + tokens_used: 1000, + cost_cents: 5, + input_data: null, + output_data: null, + error_message: null, + created_at: new Date().toISOString(), + }, + ]; + + mockQuery.mockResolvedValueOnce({ + rows: logs, + }); + + const result = await repo.getProcessingLogs(1, mockLogger); + + expect(result).toHaveLength(2); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('ORDER BY created_at ASC'), + [1], + ); + }); + }); + + describe('getProcessingStats', () => { + it('should return processing statistics', async () => { + // Receipt stats query + mockQuery.mockResolvedValueOnce({ + rows: [ + { + total_receipts: '100', + completed: '80', + failed: '10', + pending: '10', + }, + ], + }); + + // Processing log stats query + mockQuery.mockResolvedValueOnce({ + rows: [ + { + avg_duration_ms: '1500.5', + total_cost_cents: '500', + }, + ], + }); + + const result = await repo.getProcessingStats(mockLogger); + + expect(result.total_receipts).toBe(100); + expect(result.completed).toBe(80); + expect(result.failed).toBe(10); + expect(result.pending).toBe(10); + expect(result.avg_processing_time_ms).toBe(1500.5); + expect(result.total_cost_cents).toBe(500); + }); + + it('should filter by date range', async () => { + mockQuery.mockResolvedValueOnce({ + rows: [{ total_receipts: '50', completed: '40', failed: '5', pending: '5' }], + }); + mockQuery.mockResolvedValueOnce({ + rows: [{ avg_duration_ms: '1200', total_cost_cents: '250' }], + }); + + await repo.getProcessingStats(mockLogger, { + fromDate: '2024-01-01', + toDate: '2024-01-31', + }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('created_at >= $1'), + expect.any(Array), + ); + }); + }); + + // ============================================================================ + // STORE RECEIPT PATTERNS + // ============================================================================ + + describe('getActiveStorePatterns', () => { + it('should return active patterns ordered by priority', async () => { + const patterns = [ + { + pattern_id: 1, + store_id: 5, + pattern_type: 'header_regex', + pattern_value: 'LOBLAWS', + priority: 100, + is_active: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + { + pattern_id: 2, + store_id: 5, + pattern_type: 'phone_number', + pattern_value: '416-555-1234', + priority: 50, + is_active: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ]; + + mockQuery.mockResolvedValueOnce({ + rows: patterns, + }); + + const result = await repo.getActiveStorePatterns(mockLogger); + + expect(result).toHaveLength(2); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('is_active = true'), + undefined, + ); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('ORDER BY priority DESC'), + undefined, + ); + }); + }); + + describe('addStorePattern', () => { + it('should add new store pattern', async () => { + const pattern = { + pattern_id: 1, + store_id: 5, + pattern_type: 'header_regex', + pattern_value: 'METRO', + priority: 100, + is_active: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + mockQuery.mockResolvedValueOnce({ + rows: [pattern], + }); + + const result = await repo.addStorePattern(5, 'header_regex', 'METRO', mockLogger, { + priority: 100, + }); + + expect(result.store_id).toBe(5); + expect(result.pattern_type).toBe('header_regex'); + expect(result.priority).toBe(100); + }); + + it('should use default priority when not specified', async () => { + const pattern = { + pattern_id: 2, + store_id: 5, + pattern_type: 'phone_number', + pattern_value: '416-555-0000', + priority: 0, + is_active: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + mockQuery.mockResolvedValueOnce({ + rows: [pattern], + }); + + const result = await repo.addStorePattern(5, 'phone_number', '416-555-0000', mockLogger); + + expect(result.priority).toBe(0); + }); + }); + + describe('deactivateStorePattern', () => { + it('should deactivate pattern successfully', async () => { + mockQuery.mockResolvedValueOnce({ + rowCount: 1, + }); + + await repo.deactivateStorePattern(1, mockLogger); + + expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('SET is_active = false'), [1]); + }); + + it('should throw NotFoundError when pattern not found', async () => { + mockQuery.mockResolvedValueOnce({ + rowCount: 0, + }); + + await expect(repo.deactivateStorePattern(999, mockLogger)).rejects.toThrow(NotFoundError); + }); + }); + + describe('detectStoreFromText', () => { + it('should detect store from receipt text using patterns', async () => { + const patterns = [ + { + pattern_id: 1, + store_id: 5, + pattern_type: 'header_regex', + pattern_value: 'LOBLAWS', + priority: 100, + is_active: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + { + pattern_id: 2, + store_id: 5, + pattern_type: 'phone_number', + pattern_value: '416-555-1234', + priority: 50, + is_active: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ]; + + mockQuery.mockResolvedValueOnce({ + rows: patterns, + }); + + const result = await repo.detectStoreFromText( + 'LOBLAWS\n123 Main St\nPhone: 416-555-1234\nItem 1 $10.00', + mockLogger, + ); + + expect(result).not.toBeNull(); + expect(result?.store_id).toBe(5); + expect(result?.confidence).toBeGreaterThan(0); + }); + + it('should return null when no patterns match', async () => { + const patterns = [ + { + pattern_id: 1, + store_id: 5, + pattern_type: 'header_regex', + pattern_value: 'LOBLAWS', + priority: 100, + is_active: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ]; + + mockQuery.mockResolvedValueOnce({ + rows: patterns, + }); + + const result = await repo.detectStoreFromText('METRO\n123 Main St', mockLogger); + + expect(result).toBeNull(); + }); + + it('should handle regex pattern matching', async () => { + const patterns = [ + { + pattern_id: 1, + store_id: 5, + pattern_type: 'header_regex', + pattern_value: 'LOBLAWS.*SUPERSTORE', + priority: 100, + is_active: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ]; + + mockQuery.mockResolvedValueOnce({ + rows: patterns, + }); + + const result = await repo.detectStoreFromText( + 'LOBLAWS GREAT FOOD SUPERSTORE\nItem 1 $10.00', + mockLogger, + ); + + expect(result).not.toBeNull(); + expect(result?.store_id).toBe(5); + }); + + it('should handle invalid regex gracefully', async () => { + const patterns = [ + { + pattern_id: 1, + store_id: 5, + pattern_type: 'header_regex', + pattern_value: '[invalid(regex', + priority: 100, + is_active: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + { + pattern_id: 2, + store_id: 5, + pattern_type: 'phone_number', + pattern_value: '416-555-1234', + priority: 50, + is_active: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ]; + + mockQuery.mockResolvedValueOnce({ + rows: patterns, + }); + + // Should not throw and should still match the phone number pattern + const result = await repo.detectStoreFromText('Phone: 416-555-1234', mockLogger); + + expect(result).not.toBeNull(); + expect(result?.store_id).toBe(5); + }); + + it('should select best match when multiple stores match', async () => { + const patterns = [ + // Store 5 patterns + { + pattern_id: 1, + store_id: 5, + pattern_type: 'header_regex', + pattern_value: 'LOBLAWS', + priority: 100, + is_active: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + // Store 6 patterns (more matches) + { + pattern_id: 2, + store_id: 6, + pattern_type: 'header_regex', + pattern_value: 'METRO', + priority: 100, + is_active: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + { + pattern_id: 3, + store_id: 6, + pattern_type: 'phone_number', + pattern_value: '416-555-9999', + priority: 50, + is_active: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + { + pattern_id: 4, + store_id: 6, + pattern_type: 'address_fragment', + pattern_value: '456 Oak Ave', + priority: 25, + is_active: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ]; + + mockQuery.mockResolvedValueOnce({ + rows: patterns, + }); + + const result = await repo.detectStoreFromText( + 'METRO\n456 Oak Ave\nPhone: 416-555-9999\nItem 1 $10.00', + mockLogger, + ); + + // Should match store 6 because it has more matches + expect(result).not.toBeNull(); + expect(result?.store_id).toBe(6); + }); + }); +}); diff --git a/src/services/db/receipt.db.ts b/src/services/db/receipt.db.ts new file mode 100644 index 0000000..5c321c6 --- /dev/null +++ b/src/services/db/receipt.db.ts @@ -0,0 +1,1074 @@ +// src/services/db/receipt.db.ts +import type { Pool, PoolClient } from 'pg'; +import { getPool } from './connection.db'; +import { NotFoundError, handleDbError } from './errors.db'; +import type { Logger } from 'pino'; +import type { + ReceiptStatus, + ReceiptItemStatus, + ReceiptProcessingStep, + ReceiptProcessingStatus, + OcrProvider, + ReceiptScan, + ReceiptItem, + ReceiptProcessingLogRecord, +} from '../../types/expiry'; + +/** + * Database row type for receipts table. + */ +interface ReceiptRow { + receipt_id: number; + user_id: string; + store_id: number | null; + receipt_image_url: string; + transaction_date: string | null; + total_amount_cents: number | null; + status: ReceiptStatus; + raw_text: string | null; + store_confidence: number | null; + ocr_provider: OcrProvider | null; + error_details: string | null; + retry_count: number; + ocr_confidence: number | null; + currency: string; + created_at: string; + processed_at: string | null; + updated_at: string; +} + +/** + * Database row type for receipt_items table. + */ +interface ReceiptItemRow { + receipt_item_id: number; + receipt_id: number; + raw_item_description: string; + quantity: number; + price_paid_cents: number; + master_item_id: number | null; + product_id: number | null; + status: ReceiptItemStatus; + line_number: number | null; + match_confidence: number | null; + is_discount: boolean; + unit_price_cents: number | null; + unit_type: string | null; + added_to_pantry: boolean; + pantry_item_id: number | null; + upc_code: string | null; + created_at: string; + updated_at: string; +} + +/** + * Database row type for store_receipt_patterns table. + */ +interface StoreReceiptPatternRow { + pattern_id: number; + store_id: number; + pattern_type: string; + pattern_value: string; + priority: number; + is_active: boolean; + created_at: string; + updated_at: string; +} + +/** + * Request to create a new receipt scan. + */ +export interface CreateReceiptRequest { + user_id: string; + receipt_image_url: string; + store_id?: number; + transaction_date?: string; +} + +/** + * Request to update receipt processing status. + */ +export interface UpdateReceiptStatusRequest { + status?: ReceiptStatus; + raw_text?: string; + store_id?: number; + store_confidence?: number; + total_amount_cents?: number; + transaction_date?: string; + ocr_provider?: OcrProvider; + ocr_confidence?: number; + error_details?: Record; + processed_at?: string; +} + +/** + * Request to add a receipt item. + */ +export interface AddReceiptItemRequest { + receipt_id: number; + raw_item_description: string; + quantity?: number; + price_paid_cents: number; + line_number?: number; + is_discount?: boolean; + unit_price_cents?: number; + unit_type?: string; + upc_code?: string; +} + +/** + * Request to update a receipt item. + */ +export interface UpdateReceiptItemRequest { + status?: ReceiptItemStatus; + master_item_id?: number | null; + product_id?: number | null; + match_confidence?: number; + added_to_pantry?: boolean; + pantry_item_id?: number | null; +} + +/** + * Options for querying receipts. + */ +export interface ReceiptQueryOptions { + user_id: string; + status?: ReceiptStatus; + store_id?: number; + from_date?: string; + to_date?: string; + limit?: number; + offset?: number; +} + +/** + * Repository for receipt scanning database operations. + * Handles receipts, receipt items, processing logs, and store patterns. + */ +export class ReceiptRepository { + private db: Pick; + + constructor(db: Pick = getPool()) { + this.db = db; + } + + // ============================================================================ + // RECEIPTS + // ============================================================================ + + /** + * Creates a new receipt scan record. + */ + async createReceipt(request: CreateReceiptRequest, logger: Logger): Promise { + try { + logger.debug({ request }, 'Creating new receipt record'); + + const res = await this.db.query( + `INSERT INTO public.receipts + (user_id, receipt_image_url, store_id, transaction_date, status) + VALUES ($1, $2, $3, $4, 'pending') + RETURNING *`, + [ + request.user_id, + request.receipt_image_url, + request.store_id || null, + request.transaction_date || null, + ], + ); + + logger.info({ receiptId: res.rows[0].receipt_id }, 'Receipt record created'); + return this.mapReceiptRowToReceipt(res.rows[0]); + } catch (error) { + handleDbError( + error, + logger, + 'Database error in createReceipt', + { request }, + { + fkMessage: 'The specified store does not exist.', + defaultMessage: 'Failed to create receipt record.', + }, + ); + } + } + + /** + * Gets a receipt by ID. + */ + async getReceiptById(receiptId: number, userId: string, logger: Logger): Promise { + try { + const res = await this.db.query( + `SELECT * FROM public.receipts WHERE receipt_id = $1 AND user_id = $2`, + [receiptId, userId], + ); + + if (res.rowCount === 0) { + throw new NotFoundError('Receipt not found.'); + } + + return this.mapReceiptRowToReceipt(res.rows[0]); + } catch (error) { + handleDbError( + error, + logger, + 'Database error in getReceiptById', + { receiptId, userId }, + { + defaultMessage: 'Failed to get receipt.', + }, + ); + } + } + + /** + * Gets receipts for a user with optional filtering. + */ + async getReceipts( + options: ReceiptQueryOptions, + logger: Logger, + ): Promise<{ receipts: ReceiptScan[]; total: number }> { + const { user_id, status, store_id, from_date, to_date, limit = 50, offset = 0 } = options; + + try { + // Build dynamic WHERE clause + const conditions: string[] = ['user_id = $1']; + const params: (string | number)[] = [user_id]; + let paramIndex = 2; + + if (status) { + conditions.push(`status = $${paramIndex++}`); + params.push(status); + } + + if (store_id) { + conditions.push(`store_id = $${paramIndex++}`); + params.push(store_id); + } + + if (from_date) { + conditions.push(`created_at >= $${paramIndex++}`); + params.push(from_date); + } + + if (to_date) { + conditions.push(`created_at <= $${paramIndex++}`); + params.push(to_date); + } + + const whereClause = conditions.join(' AND '); + + // Get total count + const countRes = await this.db.query<{ count: string }>( + `SELECT COUNT(*) FROM public.receipts WHERE ${whereClause}`, + params, + ); + const total = parseInt(countRes.rows[0].count, 10); + + // Get paginated results + const dataParams = [...params, limit, offset]; + const dataRes = await this.db.query( + `SELECT * FROM public.receipts + WHERE ${whereClause} + ORDER BY created_at DESC + LIMIT $${paramIndex++} OFFSET $${paramIndex}`, + dataParams, + ); + + return { + receipts: dataRes.rows.map((row) => this.mapReceiptRowToReceipt(row)), + total, + }; + } catch (error) { + handleDbError( + error, + logger, + 'Database error in getReceipts', + { options }, + { + defaultMessage: 'Failed to get receipts.', + }, + ); + } + } + + /** + * Updates a receipt's processing status and data. + */ + async updateReceipt( + receiptId: number, + updates: UpdateReceiptStatusRequest, + logger: Logger, + ): Promise { + try { + logger.debug({ receiptId, updates }, 'Updating receipt'); + + // Build dynamic SET clause + const setClauses: string[] = ['updated_at = NOW()']; + const values: (string | number | null)[] = []; + let paramIndex = 1; + + if (updates.status !== undefined) { + setClauses.push(`status = $${paramIndex++}`); + values.push(updates.status); + } + + if (updates.raw_text !== undefined) { + setClauses.push(`raw_text = $${paramIndex++}`); + values.push(updates.raw_text); + } + + if (updates.store_id !== undefined) { + setClauses.push(`store_id = $${paramIndex++}`); + values.push(updates.store_id); + } + + if (updates.store_confidence !== undefined) { + setClauses.push(`store_confidence = $${paramIndex++}`); + values.push(updates.store_confidence); + } + + if (updates.total_amount_cents !== undefined) { + setClauses.push(`total_amount_cents = $${paramIndex++}`); + values.push(updates.total_amount_cents); + } + + if (updates.transaction_date !== undefined) { + setClauses.push(`transaction_date = $${paramIndex++}`); + values.push(updates.transaction_date); + } + + if (updates.ocr_provider !== undefined) { + setClauses.push(`ocr_provider = $${paramIndex++}`); + values.push(updates.ocr_provider); + } + + if (updates.ocr_confidence !== undefined) { + setClauses.push(`ocr_confidence = $${paramIndex++}`); + values.push(updates.ocr_confidence); + } + + if (updates.error_details !== undefined) { + setClauses.push(`error_details = $${paramIndex++}`); + values.push(JSON.stringify(updates.error_details)); + } + + if (updates.processed_at !== undefined) { + setClauses.push(`processed_at = $${paramIndex++}`); + values.push(updates.processed_at); + } + + values.push(receiptId); + + const res = await this.db.query( + `UPDATE public.receipts SET ${setClauses.join(', ')} WHERE receipt_id = $${paramIndex} RETURNING *`, + values, + ); + + if (res.rowCount === 0) { + throw new NotFoundError('Receipt not found.'); + } + + logger.info({ receiptId, status: res.rows[0].status }, 'Receipt updated'); + return this.mapReceiptRowToReceipt(res.rows[0]); + } catch (error) { + handleDbError( + error, + logger, + 'Database error in updateReceipt', + { receiptId, updates }, + { + defaultMessage: 'Failed to update receipt.', + }, + ); + } + } + + /** + * Increments the retry count for a failed receipt. + */ + async incrementRetryCount(receiptId: number, logger: Logger): Promise { + try { + const res = await this.db.query<{ retry_count: number }>( + `UPDATE public.receipts + SET retry_count = retry_count + 1, updated_at = NOW() + WHERE receipt_id = $1 + RETURNING retry_count`, + [receiptId], + ); + + if (res.rowCount === 0) { + throw new NotFoundError('Receipt not found.'); + } + + logger.debug({ receiptId, retryCount: res.rows[0].retry_count }, 'Retry count incremented'); + return res.rows[0].retry_count; + } catch (error) { + handleDbError( + error, + logger, + 'Database error in incrementRetryCount', + { receiptId }, + { + defaultMessage: 'Failed to increment retry count.', + }, + ); + } + } + + /** + * Gets receipts that need processing (pending or failed with retries remaining). + */ + async getReceiptsNeedingProcessing( + maxRetries: number, + limit: number, + logger: Logger, + ): Promise { + try { + const res = await this.db.query( + `SELECT * FROM public.receipts + WHERE (status = 'pending' OR (status = 'failed' AND retry_count < $1)) + ORDER BY created_at ASC + LIMIT $2`, + [maxRetries, limit], + ); + + logger.debug({ count: res.rowCount }, 'Fetched receipts needing processing'); + return res.rows.map((row) => this.mapReceiptRowToReceipt(row)); + } catch (error) { + handleDbError( + error, + logger, + 'Database error in getReceiptsNeedingProcessing', + { maxRetries, limit }, + { + defaultMessage: 'Failed to get receipts needing processing.', + }, + ); + } + } + + /** + * Deletes a receipt and all associated items. + */ + async deleteReceipt(receiptId: number, userId: string, logger: Logger): Promise { + try { + const res = await this.db.query( + `DELETE FROM public.receipts WHERE receipt_id = $1 AND user_id = $2`, + [receiptId, userId], + ); + + if (res.rowCount === 0) { + throw new NotFoundError('Receipt not found or user does not have permission.'); + } + + logger.info({ receiptId }, 'Receipt deleted'); + } catch (error) { + handleDbError( + error, + logger, + 'Database error in deleteReceipt', + { receiptId, userId }, + { + defaultMessage: 'Failed to delete receipt.', + }, + ); + } + } + + // ============================================================================ + // RECEIPT ITEMS + // ============================================================================ + + /** + * Adds items extracted from a receipt. + */ + async addReceiptItems(items: AddReceiptItemRequest[], logger: Logger): Promise { + if (items.length === 0) { + return []; + } + + try { + logger.debug({ count: items.length, receiptId: items[0].receipt_id }, 'Adding receipt items'); + + // Build batch insert + const values: (string | number | boolean | null)[] = []; + const valuePlaceholders: string[] = []; + let paramIndex = 1; + + for (const item of items) { + valuePlaceholders.push( + `($${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++})`, + ); + values.push( + item.receipt_id, + item.raw_item_description, + item.quantity ?? 1, + item.price_paid_cents, + item.line_number ?? null, + item.is_discount ?? false, + item.unit_price_cents ?? null, + item.unit_type ?? null, + item.upc_code ?? null, + ); + } + + const res = await this.db.query( + `INSERT INTO public.receipt_items + (receipt_id, raw_item_description, quantity, price_paid_cents, line_number, is_discount, unit_price_cents, unit_type, upc_code) + VALUES ${valuePlaceholders.join(', ')} + RETURNING *`, + values, + ); + + logger.info({ count: res.rowCount, receiptId: items[0].receipt_id }, 'Receipt items added'); + return res.rows.map((row) => this.mapReceiptItemRowToReceiptItem(row)); + } catch (error) { + handleDbError( + error, + logger, + 'Database error in addReceiptItems', + { itemCount: items.length }, + { + fkMessage: 'The specified receipt does not exist.', + defaultMessage: 'Failed to add receipt items.', + }, + ); + } + } + + /** + * Gets all items for a receipt. + */ + async getReceiptItems(receiptId: number, logger: Logger): Promise { + try { + const res = await this.db.query( + `SELECT * FROM public.receipt_items + WHERE receipt_id = $1 + ORDER BY line_number ASC NULLS LAST, receipt_item_id ASC`, + [receiptId], + ); + + return res.rows.map((row) => this.mapReceiptItemRowToReceiptItem(row)); + } catch (error) { + handleDbError( + error, + logger, + 'Database error in getReceiptItems', + { receiptId }, + { + defaultMessage: 'Failed to get receipt items.', + }, + ); + } + } + + /** + * Updates a receipt item (matching, pantry linking, etc.). + */ + async updateReceiptItem( + receiptItemId: number, + updates: UpdateReceiptItemRequest, + logger: Logger, + ): Promise { + try { + const setClauses: string[] = ['updated_at = NOW()']; + const values: (string | number | boolean | null)[] = []; + let paramIndex = 1; + + if (updates.status !== undefined) { + setClauses.push(`status = $${paramIndex++}`); + values.push(updates.status); + } + + if (updates.master_item_id !== undefined) { + setClauses.push(`master_item_id = $${paramIndex++}`); + values.push(updates.master_item_id); + } + + if (updates.product_id !== undefined) { + setClauses.push(`product_id = $${paramIndex++}`); + values.push(updates.product_id); + } + + if (updates.match_confidence !== undefined) { + setClauses.push(`match_confidence = $${paramIndex++}`); + values.push(updates.match_confidence); + } + + if (updates.added_to_pantry !== undefined) { + setClauses.push(`added_to_pantry = $${paramIndex++}`); + values.push(updates.added_to_pantry); + } + + if (updates.pantry_item_id !== undefined) { + setClauses.push(`pantry_item_id = $${paramIndex++}`); + values.push(updates.pantry_item_id); + } + + values.push(receiptItemId); + + const res = await this.db.query( + `UPDATE public.receipt_items SET ${setClauses.join(', ')} WHERE receipt_item_id = $${paramIndex} RETURNING *`, + values, + ); + + if (res.rowCount === 0) { + throw new NotFoundError('Receipt item not found.'); + } + + return this.mapReceiptItemRowToReceiptItem(res.rows[0]); + } catch (error) { + handleDbError( + error, + logger, + 'Database error in updateReceiptItem', + { receiptItemId, updates }, + { + defaultMessage: 'Failed to update receipt item.', + }, + ); + } + } + + /** + * Gets receipt items that haven't been added to pantry. + */ + async getUnaddedReceiptItems(receiptId: number, logger: Logger): Promise { + try { + const res = await this.db.query( + `SELECT * FROM public.receipt_items + WHERE receipt_id = $1 + AND added_to_pantry = false + AND is_discount = false + ORDER BY line_number ASC NULLS LAST`, + [receiptId], + ); + + return res.rows.map((row) => this.mapReceiptItemRowToReceiptItem(row)); + } catch (error) { + handleDbError( + error, + logger, + 'Database error in getUnaddedReceiptItems', + { receiptId }, + { + defaultMessage: 'Failed to get unadded receipt items.', + }, + ); + } + } + + // ============================================================================ + // PROCESSING LOG + // ============================================================================ + + /** + * Logs a processing step for a receipt. + */ + async logProcessingStep( + receiptId: number, + step: ReceiptProcessingStep, + status: ReceiptProcessingStatus, + logger: Logger, + options: { + provider?: OcrProvider; + durationMs?: number; + tokensUsed?: number; + costCents?: number; + inputData?: Record; + outputData?: Record; + errorMessage?: string; + } = {}, + ): Promise { + try { + logger.debug({ receiptId, step, status }, 'Logging processing step'); + + const res = await this.db.query( + `INSERT INTO public.receipt_processing_log + (receipt_id, processing_step, status, provider, duration_ms, tokens_used, cost_cents, input_data, output_data, error_message) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING *`, + [ + receiptId, + step, + status, + options.provider || null, + options.durationMs ?? null, + options.tokensUsed ?? null, + options.costCents ?? null, + options.inputData ? JSON.stringify(options.inputData) : null, + options.outputData ? JSON.stringify(options.outputData) : null, + options.errorMessage || null, + ], + ); + + return res.rows[0]; + } catch (error) { + handleDbError( + error, + logger, + 'Database error in logProcessingStep', + { receiptId, step, status }, + { + defaultMessage: 'Failed to log processing step.', + }, + ); + } + } + + /** + * Gets processing logs for a receipt. + */ + async getProcessingLogs( + receiptId: number, + logger: Logger, + ): Promise { + try { + const res = await this.db.query( + `SELECT * FROM public.receipt_processing_log + WHERE receipt_id = $1 + ORDER BY created_at ASC`, + [receiptId], + ); + + return res.rows; + } catch (error) { + handleDbError( + error, + logger, + 'Database error in getProcessingLogs', + { receiptId }, + { + defaultMessage: 'Failed to get processing logs.', + }, + ); + } + } + + /** + * Gets processing statistics for monitoring. + */ + async getProcessingStats( + logger: Logger, + options: { fromDate?: string; toDate?: string } = {}, + ): Promise<{ + total_receipts: number; + completed: number; + failed: number; + pending: number; + avg_processing_time_ms: number; + total_cost_cents: number; + }> { + try { + const conditions: string[] = []; + const params: string[] = []; + let paramIndex = 1; + + if (options.fromDate) { + conditions.push(`created_at >= $${paramIndex++}`); + params.push(options.fromDate); + } + + if (options.toDate) { + conditions.push(`created_at <= $${paramIndex++}`); + params.push(options.toDate); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + const receiptStatsRes = await this.db.query<{ + total_receipts: string; + completed: string; + failed: string; + pending: string; + }>( + `SELECT + COUNT(*) AS total_receipts, + COUNT(*) FILTER (WHERE status = 'completed') AS completed, + COUNT(*) FILTER (WHERE status = 'failed') AS failed, + COUNT(*) FILTER (WHERE status = 'pending') AS pending + FROM public.receipts ${whereClause}`, + params, + ); + + const processingStatsRes = await this.db.query<{ + avg_duration_ms: string; + total_cost_cents: string; + }>( + `SELECT + COALESCE(AVG(duration_ms), 0) AS avg_duration_ms, + COALESCE(SUM(cost_cents), 0) AS total_cost_cents + FROM public.receipt_processing_log ${whereClause}`, + params, + ); + + const receiptStats = receiptStatsRes.rows[0]; + const processingStats = processingStatsRes.rows[0]; + + return { + total_receipts: parseInt(receiptStats.total_receipts, 10), + completed: parseInt(receiptStats.completed, 10), + failed: parseInt(receiptStats.failed, 10), + pending: parseInt(receiptStats.pending, 10), + avg_processing_time_ms: parseFloat(processingStats.avg_duration_ms), + total_cost_cents: parseInt(processingStats.total_cost_cents, 10), + }; + } catch (error) { + handleDbError( + error, + logger, + 'Database error in getProcessingStats', + { options }, + { + defaultMessage: 'Failed to get processing statistics.', + }, + ); + } + } + + // ============================================================================ + // STORE RECEIPT PATTERNS + // ============================================================================ + + /** + * Gets all active patterns for store detection. + */ + async getActiveStorePatterns(logger: Logger): Promise { + try { + const res = await this.db.query( + `SELECT * FROM public.store_receipt_patterns + WHERE is_active = true + ORDER BY priority DESC, pattern_type`, + ); + + return res.rows; + } catch (error) { + handleDbError( + error, + logger, + 'Database error in getActiveStorePatterns', + {}, + { + defaultMessage: 'Failed to get store patterns.', + }, + ); + } + } + + /** + * Adds a new store receipt pattern. + */ + async addStorePattern( + storeId: number, + patternType: string, + patternValue: string, + logger: Logger, + options: { priority?: number } = {}, + ): Promise { + try { + const res = await this.db.query( + `INSERT INTO public.store_receipt_patterns + (store_id, pattern_type, pattern_value, priority) + VALUES ($1, $2, $3, $4) + RETURNING *`, + [storeId, patternType, patternValue, options.priority ?? 0], + ); + + logger.info({ storeId, patternType }, 'Store pattern added'); + return res.rows[0]; + } catch (error) { + handleDbError( + error, + logger, + 'Database error in addStorePattern', + { storeId, patternType, patternValue }, + { + fkMessage: 'The specified store does not exist.', + uniqueMessage: 'This pattern already exists for this store.', + checkMessage: 'Invalid pattern type or empty pattern value.', + defaultMessage: 'Failed to add store pattern.', + }, + ); + } + } + + /** + * Deactivates a store pattern. + */ + async deactivateStorePattern(patternId: number, logger: Logger): Promise { + try { + const res = await this.db.query( + `UPDATE public.store_receipt_patterns + SET is_active = false, updated_at = NOW() + WHERE pattern_id = $1`, + [patternId], + ); + + if (res.rowCount === 0) { + throw new NotFoundError('Pattern not found.'); + } + + logger.info({ patternId }, 'Store pattern deactivated'); + } catch (error) { + handleDbError( + error, + logger, + 'Database error in deactivateStorePattern', + { patternId }, + { + defaultMessage: 'Failed to deactivate store pattern.', + }, + ); + } + } + + /** + * Detects store from receipt text using patterns. + */ + async detectStoreFromText( + receiptText: string, + logger: Logger, + ): Promise<{ store_id: number; confidence: number } | null> { + try { + logger.debug('Attempting to detect store from receipt text'); + + const patterns = await this.getActiveStorePatterns(logger); + + // Group patterns by store + const storePatternMap = new Map(); + for (const pattern of patterns) { + const existing = storePatternMap.get(pattern.store_id) || []; + existing.push(pattern); + storePatternMap.set(pattern.store_id, existing); + } + + // Try to match each store's patterns + let bestMatch: { store_id: number; confidence: number; matchCount: number } | null = null; + + for (const [storeId, storePatterns] of storePatternMap) { + let matchCount = 0; + let totalPriority = 0; + + for (const pattern of storePatterns) { + try { + let matches = false; + + if ( + pattern.pattern_type === 'header_regex' || + pattern.pattern_type === 'footer_regex' + ) { + const regex = new RegExp(pattern.pattern_value, 'i'); + matches = regex.test(receiptText); + } else { + // Literal text match for phone_number, address_fragment, etc. + matches = receiptText.toLowerCase().includes(pattern.pattern_value.toLowerCase()); + } + + if (matches) { + matchCount++; + totalPriority += pattern.priority; + } + } catch (regexError) { + // Invalid regex pattern, skip it + logger.warn( + { patternId: pattern.pattern_id, error: regexError }, + 'Invalid regex pattern', + ); + } + } + + if (matchCount > 0) { + // Calculate confidence based on match count and priority + const confidence = Math.min( + 1, + (matchCount / storePatterns.length) * 0.7 + (totalPriority / 100) * 0.3, + ); + + if ( + !bestMatch || + matchCount > bestMatch.matchCount || + (matchCount === bestMatch.matchCount && confidence > bestMatch.confidence) + ) { + bestMatch = { store_id: storeId, confidence, matchCount }; + } + } + } + + if (bestMatch) { + logger.info( + { storeId: bestMatch.store_id, confidence: bestMatch.confidence }, + 'Store detected from receipt text', + ); + return { store_id: bestMatch.store_id, confidence: bestMatch.confidence }; + } + + logger.debug('No store match found from receipt text'); + return null; + } catch (error) { + handleDbError( + error, + logger, + 'Database error in detectStoreFromText', + {}, + { + defaultMessage: 'Failed to detect store from receipt text.', + }, + ); + } + } + + // ============================================================================ + // HELPER METHODS + // ============================================================================ + + /** + * Maps a receipt database row to ReceiptScan type. + */ + private mapReceiptRowToReceipt(row: ReceiptRow): ReceiptScan { + return { + receipt_id: row.receipt_id, + user_id: row.user_id, + store_id: row.store_id, + receipt_image_url: row.receipt_image_url, + transaction_date: row.transaction_date, + total_amount_cents: row.total_amount_cents, + status: row.status, + raw_text: row.raw_text, + store_confidence: row.store_confidence !== null ? Number(row.store_confidence) : null, + ocr_provider: row.ocr_provider, + error_details: row.error_details ? JSON.parse(row.error_details) : null, + retry_count: row.retry_count, + ocr_confidence: row.ocr_confidence !== null ? Number(row.ocr_confidence) : null, + currency: row.currency, + created_at: row.created_at, + processed_at: row.processed_at, + updated_at: row.updated_at, + }; + } + + /** + * Maps a receipt item database row to ReceiptItem type. + */ + private mapReceiptItemRowToReceiptItem(row: ReceiptItemRow): ReceiptItem { + return { + receipt_item_id: row.receipt_item_id, + receipt_id: row.receipt_id, + raw_item_description: row.raw_item_description, + quantity: Number(row.quantity), + price_paid_cents: row.price_paid_cents, + master_item_id: row.master_item_id, + product_id: row.product_id, + status: row.status, + line_number: row.line_number, + match_confidence: row.match_confidence !== null ? Number(row.match_confidence) : null, + is_discount: row.is_discount, + unit_price_cents: row.unit_price_cents, + unit_type: row.unit_type, + added_to_pantry: row.added_to_pantry, + pantry_item_id: row.pantry_item_id, + upc_code: row.upc_code, + created_at: row.created_at, + updated_at: row.updated_at, + }; + } +} diff --git a/src/services/db/upc.db.test.ts b/src/services/db/upc.db.test.ts new file mode 100644 index 0000000..6ccc614 --- /dev/null +++ b/src/services/db/upc.db.test.ts @@ -0,0 +1,518 @@ +// src/services/db/upc.db.test.ts +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Logger } from 'pino'; +import { createMockLogger } from '../../tests/utils/mockLogger'; +import { UpcRepository } from './upc.db'; +import { NotFoundError } from './errors.db'; + +// Create mock pool +const mockQuery = vi.fn(); +const mockPool = { + query: mockQuery, +}; + +describe('UpcRepository', () => { + let repo: UpcRepository; + let mockLogger: Logger; + + beforeEach(() => { + vi.clearAllMocks(); + mockLogger = createMockLogger(); + repo = new UpcRepository(mockPool); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe('findProductByUpc', () => { + it('should return product when found', async () => { + mockQuery.mockResolvedValueOnce({ + rowCount: 1, + rows: [ + { + product_id: 1, + name: 'Test Product', + description: 'A test product', + size: '500g', + upc_code: '012345678905', + master_item_id: 5, + brand_name: 'Test Brand', + category_name: 'Snacks', + image_url: null, + }, + ], + }); + + const result = await repo.findProductByUpc('012345678905', mockLogger); + + expect(result).not.toBeNull(); + expect(result?.product_id).toBe(1); + expect(result?.name).toBe('Test Product'); + expect(result?.brand).toBe('Test Brand'); + expect(result?.category).toBe('Snacks'); + expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('WHERE p.upc_code = $1'), [ + '012345678905', + ]); + }); + + it('should return null when product not found', async () => { + mockQuery.mockResolvedValueOnce({ + rowCount: 0, + rows: [], + }); + + const result = await repo.findProductByUpc('999999999999', mockLogger); + + expect(result).toBeNull(); + }); + + it('should throw on database error', async () => { + mockQuery.mockRejectedValueOnce(new Error('DB connection failed')); + + await expect(repo.findProductByUpc('012345678905', mockLogger)).rejects.toThrow(); + }); + }); + + describe('linkUpcToProduct', () => { + it('should link UPC to product successfully', async () => { + mockQuery.mockResolvedValueOnce({ + rowCount: 1, + rows: [ + { + product_id: 1, + name: 'Test Product', + brand_id: 1, + category_id: 1, + description: null, + size: null, + upc_code: '012345678905', + master_item_id: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ], + }); + + const result = await repo.linkUpcToProduct(1, '012345678905', mockLogger); + + expect(result.upc_code).toBe('012345678905'); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('UPDATE public.products SET upc_code = $1'), + ['012345678905', 1], + ); + }); + + it('should throw NotFoundError when product not found', async () => { + mockQuery.mockResolvedValueOnce({ + rowCount: 0, + rows: [], + }); + + await expect(repo.linkUpcToProduct(999, '012345678905', mockLogger)).rejects.toThrow( + NotFoundError, + ); + }); + }); + + describe('recordScan', () => { + it('should record a scan successfully', async () => { + const scanRecord = { + scan_id: 1, + user_id: 'user-1', + upc_code: '012345678905', + product_id: 1, + scan_source: 'manual_entry', + scan_confidence: 1.0, + raw_image_path: null, + lookup_successful: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + mockQuery.mockResolvedValueOnce({ + rows: [scanRecord], + }); + + const result = await repo.recordScan('user-1', '012345678905', 'manual_entry', mockLogger, { + productId: 1, + scanConfidence: 1.0, + lookupSuccessful: true, + }); + + expect(result.scan_id).toBe(1); + expect(result.upc_code).toBe('012345678905'); + expect(result.lookup_successful).toBe(true); + }); + + it('should record scan with default options', async () => { + const scanRecord = { + scan_id: 2, + user_id: 'user-1', + upc_code: '012345678905', + product_id: null, + scan_source: 'image_upload', + scan_confidence: null, + raw_image_path: null, + lookup_successful: false, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + mockQuery.mockResolvedValueOnce({ + rows: [scanRecord], + }); + + const result = await repo.recordScan('user-1', '012345678905', 'image_upload', mockLogger); + + expect(result.product_id).toBeNull(); + expect(result.lookup_successful).toBe(false); + }); + }); + + describe('getScanHistory', () => { + it('should return paginated scan history', async () => { + // Count query + mockQuery.mockResolvedValueOnce({ + rows: [{ count: '10' }], + }); + + // Data query + mockQuery.mockResolvedValueOnce({ + rows: [ + { + scan_id: 1, + user_id: 'user-1', + upc_code: '012345678905', + product_id: 1, + scan_source: 'manual_entry', + scan_confidence: 1.0, + raw_image_path: null, + lookup_successful: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ], + }); + + const result = await repo.getScanHistory( + { user_id: 'user-1', limit: 10, offset: 0 }, + mockLogger, + ); + + expect(result.total).toBe(10); + expect(result.scans).toHaveLength(1); + }); + + it('should filter by lookup_successful', async () => { + mockQuery.mockResolvedValueOnce({ rows: [{ count: '5' }] }); + mockQuery.mockResolvedValueOnce({ rows: [] }); + + await repo.getScanHistory({ user_id: 'user-1', lookup_successful: true }, mockLogger); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('lookup_successful = $2'), + expect.any(Array), + ); + }); + + it('should filter by scan_source', async () => { + mockQuery.mockResolvedValueOnce({ rows: [{ count: '3' }] }); + mockQuery.mockResolvedValueOnce({ rows: [] }); + + await repo.getScanHistory({ user_id: 'user-1', scan_source: 'image_upload' }, mockLogger); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('scan_source = $2'), + expect.any(Array), + ); + }); + + it('should filter by date range', async () => { + mockQuery.mockResolvedValueOnce({ rows: [{ count: '2' }] }); + mockQuery.mockResolvedValueOnce({ rows: [] }); + + await repo.getScanHistory( + { + user_id: 'user-1', + from_date: '2024-01-01', + to_date: '2024-01-31', + }, + mockLogger, + ); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('created_at >= $2'), + expect.any(Array), + ); + }); + }); + + describe('getScanById', () => { + it('should return scan record when found', async () => { + const scanRecord = { + scan_id: 1, + user_id: 'user-1', + upc_code: '012345678905', + product_id: 1, + scan_source: 'manual_entry', + scan_confidence: 1.0, + raw_image_path: null, + lookup_successful: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + mockQuery.mockResolvedValueOnce({ + rowCount: 1, + rows: [scanRecord], + }); + + const result = await repo.getScanById(1, 'user-1', mockLogger); + + expect(result.scan_id).toBe(1); + expect(result.user_id).toBe('user-1'); + }); + + it('should throw NotFoundError when scan not found', async () => { + mockQuery.mockResolvedValueOnce({ + rowCount: 0, + rows: [], + }); + + await expect(repo.getScanById(999, 'user-1', mockLogger)).rejects.toThrow(NotFoundError); + }); + }); + + describe('findExternalLookup', () => { + it('should return cached lookup when found and not expired', async () => { + const lookupRecord = { + lookup_id: 1, + upc_code: '012345678905', + product_name: 'External Product', + brand_name: 'External Brand', + category: 'Snacks', + description: null, + image_url: null, + external_source: 'openfoodfacts', + lookup_data: null, + lookup_successful: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + mockQuery.mockResolvedValueOnce({ + rowCount: 1, + rows: [lookupRecord], + }); + + const result = await repo.findExternalLookup('012345678905', 168, mockLogger); + + expect(result).not.toBeNull(); + expect(result?.product_name).toBe('External Product'); + }); + + it('should return null when lookup not cached', async () => { + mockQuery.mockResolvedValueOnce({ + rowCount: 0, + rows: [], + }); + + const result = await repo.findExternalLookup('999999999999', 168, mockLogger); + + expect(result).toBeNull(); + }); + }); + + describe('upsertExternalLookup', () => { + it('should insert new external lookup', async () => { + const lookupRecord = { + lookup_id: 1, + upc_code: '012345678905', + product_name: 'New Product', + brand_name: 'New Brand', + category: 'Food', + description: 'A description', + image_url: 'https://example.com/image.jpg', + external_source: 'openfoodfacts', + lookup_data: null, + lookup_successful: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + mockQuery.mockResolvedValueOnce({ + rows: [lookupRecord], + }); + + const result = await repo.upsertExternalLookup( + '012345678905', + 'openfoodfacts', + true, + mockLogger, + { + productName: 'New Product', + brandName: 'New Brand', + category: 'Food', + description: 'A description', + imageUrl: 'https://example.com/image.jpg', + }, + ); + + expect(result.product_name).toBe('New Product'); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('ON CONFLICT (upc_code) DO UPDATE'), + expect.any(Array), + ); + }); + + it('should update existing external lookup on conflict', async () => { + const updatedRecord = { + lookup_id: 1, + upc_code: '012345678905', + product_name: 'Updated Product', + brand_name: 'Updated Brand', + category: null, + description: null, + image_url: null, + external_source: 'upcitemdb', + lookup_data: null, + lookup_successful: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + mockQuery.mockResolvedValueOnce({ + rows: [updatedRecord], + }); + + const result = await repo.upsertExternalLookup( + '012345678905', + 'upcitemdb', + true, + mockLogger, + { + productName: 'Updated Product', + brandName: 'Updated Brand', + }, + ); + + expect(result.product_name).toBe('Updated Product'); + expect(result.external_source).toBe('upcitemdb'); + }); + }); + + describe('getExternalLookupByUpc', () => { + it('should return lookup without cache expiry check', async () => { + const lookupRecord = { + lookup_id: 1, + upc_code: '012345678905', + product_name: 'Product', + brand_name: null, + category: null, + description: null, + image_url: null, + external_source: 'openfoodfacts', + lookup_data: null, + lookup_successful: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + mockQuery.mockResolvedValueOnce({ + rowCount: 1, + rows: [lookupRecord], + }); + + const result = await repo.getExternalLookupByUpc('012345678905', mockLogger); + + expect(result?.product_name).toBe('Product'); + expect(mockQuery).toHaveBeenCalledWith(expect.not.stringContaining('interval'), [ + '012345678905', + ]); + }); + + it('should return null when not found', async () => { + mockQuery.mockResolvedValueOnce({ + rowCount: 0, + rows: [], + }); + + const result = await repo.getExternalLookupByUpc('999999999999', mockLogger); + + expect(result).toBeNull(); + }); + }); + + describe('deleteOldExternalLookups', () => { + it('should delete old lookups and return count', async () => { + mockQuery.mockResolvedValueOnce({ + rowCount: 5, + }); + + const deleted = await repo.deleteOldExternalLookups(30, mockLogger); + + expect(deleted).toBe(5); + expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining("interval '1 day'"), [30]); + }); + + it('should return 0 when no records deleted', async () => { + mockQuery.mockResolvedValueOnce({ + rowCount: 0, + }); + + const deleted = await repo.deleteOldExternalLookups(30, mockLogger); + + expect(deleted).toBe(0); + }); + }); + + describe('getUserScanStats', () => { + it('should return user scan statistics', async () => { + mockQuery.mockResolvedValueOnce({ + rows: [ + { + total_scans: '100', + successful_lookups: '80', + unique_products: '50', + scans_today: '5', + scans_this_week: '25', + }, + ], + }); + + const stats = await repo.getUserScanStats('user-1', mockLogger); + + expect(stats.total_scans).toBe(100); + expect(stats.successful_lookups).toBe(80); + expect(stats.unique_products).toBe(50); + expect(stats.scans_today).toBe(5); + expect(stats.scans_this_week).toBe(25); + }); + }); + + describe('updateScanWithDetectedCode', () => { + it('should update scan with detected code', async () => { + mockQuery.mockResolvedValueOnce({ + rowCount: 1, + }); + + await repo.updateScanWithDetectedCode(1, '012345678905', 0.95, mockLogger); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('UPDATE public.upc_scan_history'), + [1, '012345678905', 0.95], + ); + }); + + it('should throw NotFoundError when scan not found', async () => { + mockQuery.mockResolvedValueOnce({ + rowCount: 0, + }); + + await expect( + repo.updateScanWithDetectedCode(999, '012345678905', 0.95, mockLogger), + ).rejects.toThrow(NotFoundError); + }); + }); +}); diff --git a/src/services/db/upc.db.ts b/src/services/db/upc.db.ts new file mode 100644 index 0000000..ed75b2c --- /dev/null +++ b/src/services/db/upc.db.ts @@ -0,0 +1,556 @@ +// src/services/db/upc.db.ts +import type { Pool, PoolClient } from 'pg'; +import { getPool } from './connection.db'; +import { NotFoundError, handleDbError } from './errors.db'; +import type { Logger } from 'pino'; +import type { + UpcScanSource, + UpcExternalSource, + UpcScanHistoryRecord, + UpcExternalLookupRecord, + UpcProductMatch, + UpcScanHistoryQueryOptions, +} from '../../types/upc'; + +/** + * Database row type for products table with UPC-relevant fields. + */ +interface ProductRow { + product_id: number; + name: string; + brand_id: number | null; + category_id: number | null; + description: string | null; + size: string | null; + upc_code: string | null; + master_item_id: number | null; + created_at: string; + updated_at: string; +} + +/** + * Extended product row with joined brand and category names. + */ +interface ProductWithDetailsRow extends ProductRow { + brand_name: string | null; + category_name: string | null; + image_url: string | null; +} + +/** + * Repository for UPC scanning related database operations. + * Handles scan history tracking, external lookup caching, and product UPC matching. + */ +export class UpcRepository { + private db: Pick; + + constructor(db: Pick = getPool()) { + this.db = db; + } + + // ============================================================================ + // PRODUCT UPC LOOKUP + // ============================================================================ + + /** + * Finds a product by its UPC code. + * Returns null if no product is found with the given UPC. + */ + async findProductByUpc(upcCode: string, logger: Logger): Promise { + try { + const query = ` + SELECT + p.product_id, + p.name, + p.description, + p.size, + p.upc_code, + p.master_item_id, + b.name AS brand_name, + c.name AS category_name, + NULL AS image_url + FROM public.products p + LEFT JOIN public.brands b ON p.brand_id = b.brand_id + LEFT JOIN public.master_grocery_items mgi ON p.master_item_id = mgi.master_grocery_item_id + LEFT JOIN public.categories c ON mgi.category_id = c.category_id + WHERE p.upc_code = $1 + `; + const res = await this.db.query(query, [upcCode]); + + if (res.rowCount === 0) { + return null; + } + + const row = res.rows[0]; + return { + product_id: row.product_id, + name: row.name, + brand: row.brand_name, + category: row.category_name, + description: row.description, + size: row.size, + upc_code: row.upc_code ?? upcCode, + image_url: row.image_url, + master_item_id: row.master_item_id, + }; + } catch (error) { + handleDbError( + error, + logger, + 'Database error in findProductByUpc', + { upcCode }, + { + defaultMessage: 'Failed to look up product by UPC code.', + }, + ); + } + } + + /** + * Links a UPC code to an existing product. + * Updates the product's upc_code field. + */ + async linkUpcToProduct(productId: number, upcCode: string, logger: Logger): Promise { + try { + const res = await this.db.query( + `UPDATE public.products SET upc_code = $1, updated_at = NOW() WHERE product_id = $2 RETURNING *`, + [upcCode, productId], + ); + + if (res.rowCount === 0) { + throw new NotFoundError('Product not found.'); + } + + return res.rows[0]; + } catch (error) { + handleDbError( + error, + logger, + 'Database error in linkUpcToProduct', + { productId, upcCode }, + { + uniqueMessage: 'This UPC code is already linked to another product.', + fkMessage: 'The specified product does not exist.', + defaultMessage: 'Failed to link UPC code to product.', + }, + ); + } + } + + // ============================================================================ + // SCAN HISTORY + // ============================================================================ + + /** + * Records a UPC scan in the history table. + * Creates an audit trail of all scans performed by users. + */ + async recordScan( + userId: string, + upcCode: string, + scanSource: UpcScanSource, + logger: Logger, + options: { + productId?: number | null; + scanConfidence?: number | null; + rawImagePath?: string | null; + lookupSuccessful?: boolean; + } = {}, + ): Promise { + const { + productId = null, + scanConfidence = null, + rawImagePath = null, + lookupSuccessful = false, + } = options; + + try { + const res = await this.db.query( + `INSERT INTO public.upc_scan_history + (user_id, upc_code, product_id, scan_source, scan_confidence, raw_image_path, lookup_successful) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *`, + [userId, upcCode, productId, scanSource, scanConfidence, rawImagePath, lookupSuccessful], + ); + + return res.rows[0]; + } catch (error) { + handleDbError( + error, + logger, + 'Database error in recordScan', + { userId, upcCode, scanSource, productId }, + { + fkMessage: 'The specified user or product does not exist.', + checkMessage: 'Invalid UPC code format or scan source.', + defaultMessage: 'Failed to record UPC scan.', + }, + ); + } + } + + /** + * Retrieves the scan history for a user with optional filtering. + */ + async getScanHistory( + options: UpcScanHistoryQueryOptions, + logger: Logger, + ): Promise<{ scans: UpcScanHistoryRecord[]; total: number }> { + const { + user_id, + limit = 50, + offset = 0, + lookup_successful, + scan_source, + from_date, + to_date, + } = options; + + try { + // Build dynamic WHERE clause + const conditions: string[] = ['user_id = $1']; + const params: (string | number | boolean)[] = [user_id]; + let paramIndex = 2; + + if (lookup_successful !== undefined) { + conditions.push(`lookup_successful = $${paramIndex++}`); + params.push(lookup_successful); + } + + if (scan_source) { + conditions.push(`scan_source = $${paramIndex++}`); + params.push(scan_source); + } + + if (from_date) { + conditions.push(`created_at >= $${paramIndex++}`); + params.push(from_date); + } + + if (to_date) { + conditions.push(`created_at <= $${paramIndex++}`); + params.push(to_date); + } + + const whereClause = conditions.join(' AND '); + + // Get total count + const countRes = await this.db.query<{ count: string }>( + `SELECT COUNT(*) FROM public.upc_scan_history WHERE ${whereClause}`, + params, + ); + const total = parseInt(countRes.rows[0].count, 10); + + // Get paginated results + const dataParams = [...params, limit, offset]; + const dataRes = await this.db.query( + `SELECT * FROM public.upc_scan_history + WHERE ${whereClause} + ORDER BY created_at DESC + LIMIT $${paramIndex++} OFFSET $${paramIndex}`, + dataParams, + ); + + return { scans: dataRes.rows, total }; + } catch (error) { + handleDbError( + error, + logger, + 'Database error in getScanHistory', + { options }, + { + defaultMessage: 'Failed to retrieve scan history.', + }, + ); + } + } + + /** + * Gets a single scan record by ID. + */ + async getScanById(scanId: number, userId: string, logger: Logger): Promise { + try { + const res = await this.db.query( + `SELECT * FROM public.upc_scan_history WHERE scan_id = $1 AND user_id = $2`, + [scanId, userId], + ); + + if (res.rowCount === 0) { + throw new NotFoundError('Scan record not found.'); + } + + return res.rows[0]; + } catch (error) { + handleDbError( + error, + logger, + 'Database error in getScanById', + { scanId, userId }, + { + defaultMessage: 'Failed to retrieve scan record.', + }, + ); + } + } + + // ============================================================================ + // EXTERNAL LOOKUP CACHE + // ============================================================================ + + /** + * Finds a cached external lookup result for a UPC code. + * Returns null if not cached or cache is expired. + */ + async findExternalLookup( + upcCode: string, + maxAgeHours: number, + logger: Logger, + ): Promise { + try { + const res = await this.db.query( + `SELECT * FROM public.upc_external_lookups + WHERE upc_code = $1 + AND created_at > NOW() - ($2 * interval '1 hour')`, + [upcCode, maxAgeHours], + ); + + if (res.rowCount === 0) { + return null; + } + + return res.rows[0]; + } catch (error) { + handleDbError( + error, + logger, + 'Database error in findExternalLookup', + { upcCode, maxAgeHours }, + { + defaultMessage: 'Failed to find cached external lookup.', + }, + ); + } + } + + /** + * Creates or updates a cached external lookup result. + * Uses UPSERT to handle both new and existing records. + */ + async upsertExternalLookup( + upcCode: string, + externalSource: UpcExternalSource, + lookupSuccessful: boolean, + logger: Logger, + data: { + productName?: string | null; + brandName?: string | null; + category?: string | null; + description?: string | null; + imageUrl?: string | null; + lookupData?: Record | null; + } = {}, + ): Promise { + const { + productName = null, + brandName = null, + category = null, + description = null, + imageUrl = null, + lookupData = null, + } = data; + + try { + const res = await this.db.query( + `INSERT INTO public.upc_external_lookups + (upc_code, product_name, brand_name, category, description, image_url, external_source, lookup_data, lookup_successful) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (upc_code) DO UPDATE SET + product_name = EXCLUDED.product_name, + brand_name = EXCLUDED.brand_name, + category = EXCLUDED.category, + description = EXCLUDED.description, + image_url = EXCLUDED.image_url, + external_source = EXCLUDED.external_source, + lookup_data = EXCLUDED.lookup_data, + lookup_successful = EXCLUDED.lookup_successful, + updated_at = NOW() + RETURNING *`, + [ + upcCode, + productName, + brandName, + category, + description, + imageUrl, + externalSource, + lookupData ? JSON.stringify(lookupData) : null, + lookupSuccessful, + ], + ); + + return res.rows[0]; + } catch (error) { + handleDbError( + error, + logger, + 'Database error in upsertExternalLookup', + { upcCode, externalSource, lookupSuccessful }, + { + checkMessage: 'Invalid UPC code format or external source.', + defaultMessage: 'Failed to cache external lookup result.', + }, + ); + } + } + + /** + * Gets an external lookup record by UPC code (without cache expiry check). + */ + async getExternalLookupByUpc( + upcCode: string, + logger: Logger, + ): Promise { + try { + const res = await this.db.query( + `SELECT * FROM public.upc_external_lookups WHERE upc_code = $1`, + [upcCode], + ); + + if (res.rowCount === 0) { + return null; + } + + return res.rows[0]; + } catch (error) { + handleDbError( + error, + logger, + 'Database error in getExternalLookupByUpc', + { upcCode }, + { + defaultMessage: 'Failed to get external lookup record.', + }, + ); + } + } + + /** + * Deletes old external lookup cache entries. + * Used for periodic cleanup. + */ + async deleteOldExternalLookups(daysOld: number, logger: Logger): Promise { + try { + const res = await this.db.query( + `DELETE FROM public.upc_external_lookups WHERE updated_at < NOW() - ($1 * interval '1 day')`, + [daysOld], + ); + return res.rowCount ?? 0; + } catch (error) { + handleDbError( + error, + logger, + 'Database error in deleteOldExternalLookups', + { daysOld }, + { + defaultMessage: 'Failed to delete old external lookups.', + }, + ); + } + } + + // ============================================================================ + // STATISTICS + // ============================================================================ + + /** + * Gets scan statistics for a user. + */ + async getUserScanStats( + userId: string, + logger: Logger, + ): Promise<{ + total_scans: number; + successful_lookups: number; + unique_products: number; + scans_today: number; + scans_this_week: number; + }> { + try { + const res = await this.db.query<{ + total_scans: string; + successful_lookups: string; + unique_products: string; + scans_today: string; + scans_this_week: string; + }>( + `SELECT + COUNT(*) AS total_scans, + COUNT(*) FILTER (WHERE lookup_successful = true) AS successful_lookups, + COUNT(DISTINCT product_id) FILTER (WHERE product_id IS NOT NULL) AS unique_products, + COUNT(*) FILTER (WHERE created_at >= CURRENT_DATE) AS scans_today, + COUNT(*) FILTER (WHERE created_at >= CURRENT_DATE - interval '7 days') AS scans_this_week + FROM public.upc_scan_history + WHERE user_id = $1`, + [userId], + ); + + const row = res.rows[0]; + return { + total_scans: parseInt(row.total_scans, 10), + successful_lookups: parseInt(row.successful_lookups, 10), + unique_products: parseInt(row.unique_products, 10), + scans_today: parseInt(row.scans_today, 10), + scans_this_week: parseInt(row.scans_this_week, 10), + }; + } catch (error) { + handleDbError( + error, + logger, + 'Database error in getUserScanStats', + { userId }, + { + defaultMessage: 'Failed to get scan statistics.', + }, + ); + } + } + + /** + * Updates a scan record with the detected UPC code from image processing. + * Used by the barcode detection worker after processing an uploaded image. + */ + async updateScanWithDetectedCode( + scanId: number, + upcCode: string, + confidence: number | null, + logger: Logger, + ): Promise { + try { + const query = ` + UPDATE public.upc_scan_history + SET + upc_code = $2, + scan_confidence = $3, + updated_at = NOW() + WHERE scan_id = $1 + `; + const res = await this.db.query(query, [scanId, upcCode, confidence]); + + if (res.rowCount === 0) { + throw new NotFoundError('Scan record not found.'); + } + + logger.info({ scanId, upcCode, confidence }, 'Updated scan with detected code'); + } catch (error) { + handleDbError( + error, + logger, + 'Database error in updateScanWithDetectedCode', + { scanId, upcCode }, + { + defaultMessage: 'Failed to update scan with detected code.', + }, + ); + } + } +} diff --git a/src/services/expiryService.server.test.ts b/src/services/expiryService.server.test.ts new file mode 100644 index 0000000..2a40944 --- /dev/null +++ b/src/services/expiryService.server.test.ts @@ -0,0 +1,933 @@ +// src/services/expiryService.server.test.ts +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Logger } from 'pino'; +import type { Job } from 'bullmq'; +import type { ExpiryAlertJobData } from '../types/job-data'; +import { createMockLogger } from '../tests/utils/mockLogger'; +import type { + InventorySource, + StorageLocation, + ExpiryStatus, + ExpiryRangeSource, + AlertMethod, + UserInventoryItem, + ReceiptStatus, + ReceiptItemStatus, + ExpiryAlertLogRecord, + ExpiryAlertType, +} from '../types/expiry'; + +// Mock dependencies +vi.mock('./db/index.db', () => ({ + expiryRepo: { + addInventoryItem: vi.fn(), + updateInventoryItem: vi.fn(), + markAsConsumed: vi.fn(), + deleteInventoryItem: vi.fn(), + getInventoryItemById: vi.fn(), + getInventory: vi.fn(), + getExpiringItems: vi.fn(), + getExpiredItems: vi.fn(), + getExpiryRangeForItem: vi.fn(), + getExpiryRanges: vi.fn(), + addExpiryRange: vi.fn(), + getUserAlertSettings: vi.fn(), + upsertAlertSettings: vi.fn(), + getUsersWithExpiringItems: vi.fn(), + logAlert: vi.fn(), + markAlertSent: vi.fn(), + getRecipesForExpiringItems: vi.fn(), + }, + receiptRepo: { + getReceiptById: vi.fn(), + getReceiptItems: vi.fn(), + updateReceiptItem: vi.fn(), + }, +})); + +vi.mock('./emailService.server', () => ({ + sendEmail: vi.fn(), +})); + +vi.mock('./logger.server', () => ({ + logger: { + child: vi.fn().mockReturnThis(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +// Import after mocks are set up +import { + addInventoryItem, + updateInventoryItem, + markItemConsumed, + deleteInventoryItem, + getInventoryItemById, + getInventory, + getExpiringItemsGrouped, + getExpiringItems, + getExpiredItems, + calculateExpiryDate, + getExpiryRanges, + addExpiryRange, + getAlertSettings, + updateAlertSettings, + processExpiryAlerts, + addItemsFromReceipt, + getRecipeSuggestionsForExpiringItems, + processExpiryAlertJob, +} from './expiryService.server'; + +import { expiryRepo, receiptRepo } from './db/index.db'; +import * as emailService from './emailService.server'; + +// Helper to create mock alert log record +function createMockAlertLogRecord( + overrides: Partial = {}, +): ExpiryAlertLogRecord { + return { + alert_log_id: 1, + user_id: 'user-1', + pantry_item_id: null, + alert_type: 'expiring_soon' as ExpiryAlertType, + alert_method: 'email' as AlertMethod, + item_name: 'Test Item', + expiry_date: null, + days_until_expiry: null, + sent_at: new Date().toISOString(), + ...overrides, + }; +} + +describe('expiryService.server', () => { + let mockLogger: Logger; + + beforeEach(() => { + vi.clearAllMocks(); + mockLogger = createMockLogger(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe('addInventoryItem', () => { + it('should add item to inventory without expiry date', async () => { + const mockItem: UserInventoryItem = { + inventory_id: 1, + user_id: 'user-1', + product_id: null, + master_item_id: null, + item_name: 'Milk', + quantity: 1, + unit: 'gallon', + purchase_date: null, + expiry_date: null, + source: 'manual', + location: 'fridge', + notes: null, + is_consumed: false, + consumed_at: null, + expiry_source: null, + receipt_item_id: null, + pantry_location_id: null, + notification_sent_at: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + days_until_expiry: null, + expiry_status: 'unknown', + }; + + vi.mocked(expiryRepo.addInventoryItem).mockResolvedValueOnce(mockItem); + + const result = await addInventoryItem( + 'user-1', + { item_name: 'Milk', quantity: 1, source: 'manual', location: 'fridge' }, + mockLogger, + ); + + expect(result.inventory_id).toBe(1); + expect(result.item_name).toBe('Milk'); + }); + + it('should calculate expiry date when purchase date and location provided', async () => { + const mockItem: UserInventoryItem = { + inventory_id: 2, + user_id: 'user-1', + product_id: null, + master_item_id: 5, + item_name: 'Milk', + quantity: 1, + unit: 'gallon', + purchase_date: '2024-01-15', + expiry_date: '2024-01-22', // calculated + source: 'manual', + location: 'fridge', + notes: null, + is_consumed: false, + consumed_at: null, + expiry_source: 'calculated', + receipt_item_id: null, + pantry_location_id: null, + notification_sent_at: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + days_until_expiry: 7, + expiry_status: 'fresh', + }; + + vi.mocked(expiryRepo.getExpiryRangeForItem).mockResolvedValueOnce({ + expiry_range_id: 1, + master_item_id: 5, + category_id: null, + item_pattern: null, + storage_location: 'fridge', + min_days: 5, + max_days: 10, + typical_days: 7, + notes: null, + source: 'usda', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }); + + vi.mocked(expiryRepo.addInventoryItem).mockResolvedValueOnce(mockItem); + + const result = await addInventoryItem( + 'user-1', + { + item_name: 'Milk', + master_item_id: 5, + quantity: 1, + source: 'manual', + location: 'fridge', + purchase_date: '2024-01-15', + }, + mockLogger, + ); + + expect(result.expiry_date).toBe('2024-01-22'); + }); + }); + + describe('updateInventoryItem', () => { + it('should update inventory item', async () => { + const mockUpdatedItem: UserInventoryItem = { + inventory_id: 1, + user_id: 'user-1', + product_id: null, + master_item_id: null, + item_name: 'Milk', + quantity: 2, // updated + unit: 'gallon', + purchase_date: null, + expiry_date: '2024-01-25', + source: 'manual', + location: 'fridge', + notes: 'Almost gone', + is_consumed: false, + consumed_at: null, + expiry_source: null, + receipt_item_id: null, + pantry_location_id: null, + notification_sent_at: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + days_until_expiry: 5, + expiry_status: 'expiring_soon', + }; + + vi.mocked(expiryRepo.updateInventoryItem).mockResolvedValueOnce(mockUpdatedItem); + + const result = await updateInventoryItem( + 1, + 'user-1', + { quantity: 2, notes: 'Almost gone' }, + mockLogger, + ); + + expect(result.quantity).toBe(2); + expect(result.notes).toBe('Almost gone'); + }); + }); + + describe('markItemConsumed', () => { + it('should mark item as consumed', async () => { + vi.mocked(expiryRepo.markAsConsumed).mockResolvedValueOnce(undefined); + + await markItemConsumed(1, 'user-1', mockLogger); + + expect(expiryRepo.markAsConsumed).toHaveBeenCalledWith(1, 'user-1', mockLogger); + }); + }); + + describe('deleteInventoryItem', () => { + it('should delete inventory item', async () => { + vi.mocked(expiryRepo.deleteInventoryItem).mockResolvedValueOnce(undefined); + + await deleteInventoryItem(1, 'user-1', mockLogger); + + expect(expiryRepo.deleteInventoryItem).toHaveBeenCalledWith(1, 'user-1', mockLogger); + }); + }); + + describe('getInventoryItemById', () => { + it('should return inventory item by ID', async () => { + const mockItem: UserInventoryItem = { + inventory_id: 1, + user_id: 'user-1', + product_id: null, + master_item_id: null, + item_name: 'Eggs', + quantity: 12, + unit: null, + purchase_date: null, + expiry_date: null, + source: 'manual', + location: 'fridge', + notes: null, + is_consumed: false, + consumed_at: null, + expiry_source: null, + receipt_item_id: null, + pantry_location_id: null, + notification_sent_at: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + days_until_expiry: null, + expiry_status: 'unknown', + }; + + vi.mocked(expiryRepo.getInventoryItemById).mockResolvedValueOnce(mockItem); + + const result = await getInventoryItemById(1, 'user-1', mockLogger); + + expect(result.item_name).toBe('Eggs'); + }); + }); + + describe('getInventory', () => { + it('should return paginated inventory', async () => { + const mockInventory = { + items: [ + { + inventory_id: 1, + user_id: 'user-1', + product_id: null, + master_item_id: null, + item_name: 'Butter', + quantity: 1, + unit: null, + purchase_date: null, + expiry_date: null, + source: 'manual' as InventorySource, + location: 'fridge' as StorageLocation, + notes: null, + is_consumed: false, + consumed_at: null, + expiry_source: null, + receipt_item_id: null, + pantry_location_id: null, + notification_sent_at: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + days_until_expiry: null, + expiry_status: 'unknown' as ExpiryStatus, + }, + ], + total: 1, + }; + + vi.mocked(expiryRepo.getInventory).mockResolvedValueOnce(mockInventory); + + const result = await getInventory({ user_id: 'user-1', limit: 10, offset: 0 }, mockLogger); + + expect(result.items).toHaveLength(1); + expect(result.total).toBe(1); + }); + + it('should filter by location', async () => { + vi.mocked(expiryRepo.getInventory).mockResolvedValueOnce({ items: [], total: 0 }); + + await getInventory({ user_id: 'user-1', location: 'freezer' }, mockLogger); + + expect(expiryRepo.getInventory).toHaveBeenCalledWith( + { user_id: 'user-1', location: 'freezer' }, + mockLogger, + ); + }); + }); + + describe('getExpiringItemsGrouped', () => { + it('should return items grouped by expiry urgency', async () => { + const expiringItems = [ + createMockInventoryItem({ days_until_expiry: 0 }), // today + createMockInventoryItem({ days_until_expiry: 3 }), // this week + createMockInventoryItem({ days_until_expiry: 15 }), // this month + ]; + const expiredItems = [createMockInventoryItem({ days_until_expiry: -2 })]; + + vi.mocked(expiryRepo.getExpiringItems).mockResolvedValueOnce(expiringItems); + vi.mocked(expiryRepo.getExpiredItems).mockResolvedValueOnce(expiredItems); + + const result = await getExpiringItemsGrouped('user-1', mockLogger); + + expect(result.expiring_today).toHaveLength(1); + expect(result.expiring_this_week).toHaveLength(1); + expect(result.expiring_this_month).toHaveLength(1); + expect(result.already_expired).toHaveLength(1); + expect(result.counts.total).toBe(4); + }); + }); + + describe('getExpiringItems', () => { + it('should return items expiring within specified days', async () => { + const mockItems = [createMockInventoryItem({ days_until_expiry: 5 })]; + vi.mocked(expiryRepo.getExpiringItems).mockResolvedValueOnce(mockItems); + + const result = await getExpiringItems('user-1', 7, mockLogger); + + expect(result).toHaveLength(1); + expect(expiryRepo.getExpiringItems).toHaveBeenCalledWith('user-1', 7, mockLogger); + }); + }); + + describe('getExpiredItems', () => { + it('should return expired items', async () => { + const mockItems = [createMockInventoryItem({ days_until_expiry: -3 })]; + vi.mocked(expiryRepo.getExpiredItems).mockResolvedValueOnce(mockItems); + + const result = await getExpiredItems('user-1', mockLogger); + + expect(result).toHaveLength(1); + }); + }); + + describe('calculateExpiryDate', () => { + it('should calculate expiry date based on storage location', async () => { + vi.mocked(expiryRepo.getExpiryRangeForItem).mockResolvedValueOnce({ + expiry_range_id: 1, + master_item_id: null, + category_id: 1, + item_pattern: null, + storage_location: 'fridge', + min_days: 7, + max_days: 14, + typical_days: 10, + notes: null, + source: 'usda', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }); + + const result = await calculateExpiryDate( + { + item_name: 'Cheese', + storage_location: 'fridge', + purchase_date: '2024-01-15', + }, + mockLogger, + ); + + expect(result).toBe('2024-01-25'); // 10 days after purchase + }); + + it('should return null when no expiry range found', async () => { + vi.mocked(expiryRepo.getExpiryRangeForItem).mockResolvedValueOnce(null); + + const result = await calculateExpiryDate( + { + item_name: 'Unknown Item', + storage_location: 'pantry', + purchase_date: '2024-01-15', + }, + mockLogger, + ); + + expect(result).toBeNull(); + }); + }); + + describe('getExpiryRanges', () => { + it('should return paginated expiry ranges', async () => { + const mockRanges = { + ranges: [ + { + expiry_range_id: 1, + master_item_id: null, + category_id: 1, + item_pattern: null, + storage_location: 'fridge' as StorageLocation, + min_days: 7, + max_days: 14, + typical_days: 10, + notes: null, + source: 'usda' as ExpiryRangeSource, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ], + total: 1, + }; + + vi.mocked(expiryRepo.getExpiryRanges).mockResolvedValueOnce(mockRanges); + + const result = await getExpiryRanges({}, mockLogger); + + expect(result.ranges).toHaveLength(1); + expect(result.total).toBe(1); + }); + }); + + describe('addExpiryRange', () => { + it('should add new expiry range', async () => { + const mockRange = { + expiry_range_id: 2, + master_item_id: null, + category_id: 2, + item_pattern: null, + storage_location: 'freezer' as StorageLocation, + min_days: 30, + max_days: 90, + typical_days: 60, + notes: 'Best stored in back of freezer', + source: 'manual' as ExpiryRangeSource, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + vi.mocked(expiryRepo.addExpiryRange).mockResolvedValueOnce(mockRange); + + const result = await addExpiryRange( + { + category_id: 2, + storage_location: 'freezer', + min_days: 30, + max_days: 90, + typical_days: 60, + notes: 'Best stored in back of freezer', + }, + mockLogger, + ); + + expect(result.typical_days).toBe(60); + }); + }); + + describe('getAlertSettings', () => { + it('should return user alert settings', async () => { + const mockSettings = [ + { + expiry_alert_id: 1, + user_id: 'user-1', + days_before_expiry: 3, + alert_method: 'email' as AlertMethod, + is_enabled: true, + last_alert_sent_at: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ]; + + vi.mocked(expiryRepo.getUserAlertSettings).mockResolvedValueOnce(mockSettings); + + const result = await getAlertSettings('user-1', mockLogger); + + expect(result).toHaveLength(1); + expect(result[0].alert_method).toBe('email'); + }); + }); + + describe('updateAlertSettings', () => { + it('should update alert settings', async () => { + const mockUpdatedSettings = { + expiry_alert_id: 1, + user_id: 'user-1', + days_before_expiry: 5, + alert_method: 'email' as AlertMethod, + is_enabled: true, + last_alert_sent_at: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + vi.mocked(expiryRepo.upsertAlertSettings).mockResolvedValueOnce(mockUpdatedSettings); + + const result = await updateAlertSettings( + 'user-1', + 'email', + { days_before_expiry: 5 }, + mockLogger, + ); + + expect(result.days_before_expiry).toBe(5); + }); + }); + + describe('processExpiryAlerts', () => { + it('should process alerts for users with expiring items', async () => { + vi.mocked(expiryRepo.getUsersWithExpiringItems).mockResolvedValueOnce([ + { + user_id: 'user-1', + email: 'user1@example.com', + alert_method: 'email' as AlertMethod, + days_before_expiry: 3, + }, + ]); + + vi.mocked(expiryRepo.getExpiringItems).mockResolvedValueOnce([ + createMockInventoryItem({ days_until_expiry: 2 }), + ]); + + vi.mocked(emailService.sendEmail).mockResolvedValueOnce(undefined); + vi.mocked(expiryRepo.logAlert).mockResolvedValue(createMockAlertLogRecord()); + vi.mocked(expiryRepo.markAlertSent).mockResolvedValue(undefined); + + const alertsSent = await processExpiryAlerts(mockLogger); + + expect(alertsSent).toBe(1); + }); + + it('should skip users with no expiring items', async () => { + vi.mocked(expiryRepo.getUsersWithExpiringItems).mockResolvedValueOnce([ + { + user_id: 'user-1', + email: 'user1@example.com', + alert_method: 'email' as AlertMethod, + days_before_expiry: 3, + }, + ]); + + vi.mocked(expiryRepo.getExpiringItems).mockResolvedValueOnce([]); + + const alertsSent = await processExpiryAlerts(mockLogger); + + expect(alertsSent).toBe(0); + }); + }); + + describe('addItemsFromReceipt', () => { + it('should add items from receipt to inventory', async () => { + const mockReceipt = { + receipt_id: 1, + user_id: 'user-1', + store_id: null, + receipt_image_url: '/uploads/receipt.jpg', + transaction_date: '2024-01-15', + total_amount_cents: 2500, + status: 'completed' as ReceiptStatus, + raw_text: 'test text', + store_confidence: null, + ocr_provider: null, + error_details: null, + retry_count: 0, + ocr_confidence: null, + currency: 'USD', + created_at: new Date().toISOString(), + processed_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + const mockReceiptItems = [ + { + receipt_item_id: 1, + receipt_id: 1, + raw_item_description: 'MILK 2%', + quantity: 1, + price_paid_cents: 399, + master_item_id: 5, + product_id: null, + status: 'matched' as ReceiptItemStatus, + line_number: 1, + match_confidence: 0.95, + is_discount: false, + unit_price_cents: null, + unit_type: null, + added_to_pantry: false, + pantry_item_id: null, + upc_code: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ]; + + vi.mocked(receiptRepo.getReceiptById).mockResolvedValueOnce(mockReceipt); + vi.mocked(receiptRepo.getReceiptItems).mockResolvedValueOnce(mockReceiptItems); + vi.mocked(expiryRepo.addInventoryItem).mockResolvedValueOnce( + createMockInventoryItem({ inventory_id: 10 }), + ); + vi.mocked(receiptRepo.updateReceiptItem).mockResolvedValueOnce(mockReceiptItems[0] as any); + + const result = await addItemsFromReceipt( + 'user-1', + 1, + [{ receipt_item_id: 1, location: 'fridge', include: true }], + mockLogger, + ); + + expect(result).toHaveLength(1); + expect(receiptRepo.updateReceiptItem).toHaveBeenCalledWith( + 1, + expect.objectContaining({ added_to_pantry: true }), + expect.any(Object), + ); + }); + + it('should skip items with include: false', async () => { + const mockReceipt = { + receipt_id: 1, + user_id: 'user-1', + store_id: null, + receipt_image_url: '/uploads/receipt.jpg', + transaction_date: '2024-01-15', + total_amount_cents: 2500, + status: 'completed' as ReceiptStatus, + raw_text: 'test text', + store_confidence: null, + ocr_provider: null, + error_details: null, + retry_count: 0, + ocr_confidence: null, + currency: 'USD', + created_at: new Date().toISOString(), + processed_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + vi.mocked(receiptRepo.getReceiptById).mockResolvedValueOnce(mockReceipt); + + const result = await addItemsFromReceipt( + 'user-1', + 1, + [{ receipt_item_id: 1, include: false }], + mockLogger, + ); + + expect(result).toHaveLength(0); + expect(expiryRepo.addInventoryItem).not.toHaveBeenCalled(); + }); + }); + + describe('getRecipeSuggestionsForExpiringItems', () => { + it('should return recipes using expiring items', async () => { + const expiringItems = [ + createMockInventoryItem({ master_item_id: 5, days_until_expiry: 2 }), + createMockInventoryItem({ master_item_id: 10, days_until_expiry: 4 }), + ]; + + const mockRecipes = { + recipes: [ + { + recipe_id: 1, + recipe_name: 'Quick Breakfast', + description: 'Easy breakfast recipe', + prep_time_minutes: 10, + cook_time_minutes: 15, + servings: 2, + photo_url: null, + matching_master_item_ids: [5], + match_count: 1, + }, + ], + total: 1, + }; + + vi.mocked(expiryRepo.getExpiringItems).mockResolvedValueOnce(expiringItems); + vi.mocked(expiryRepo.getRecipesForExpiringItems).mockResolvedValueOnce(mockRecipes); + + const result = await getRecipeSuggestionsForExpiringItems('user-1', 7, mockLogger); + + expect(result.recipes).toHaveLength(1); + expect(result.recipes[0].matching_items).toHaveLength(1); + expect(result.considered_items).toHaveLength(2); + }); + + it('should return empty results when no expiring items', async () => { + vi.mocked(expiryRepo.getExpiringItems).mockResolvedValueOnce([]); + + const result = await getRecipeSuggestionsForExpiringItems('user-1', 7, mockLogger); + + expect(result.recipes).toHaveLength(0); + expect(result.total).toBe(0); + }); + }); + + describe('processExpiryAlertJob', () => { + it('should process user-specific alert job', async () => { + vi.mocked(expiryRepo.getUserAlertSettings).mockResolvedValueOnce([ + { + expiry_alert_id: 1, + user_id: 'user-1', + days_before_expiry: 7, + alert_method: 'email' as AlertMethod, + is_enabled: true, + last_alert_sent_at: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ]); + + vi.mocked(expiryRepo.getExpiringItems).mockResolvedValueOnce([ + createMockInventoryItem({ days_until_expiry: 3 }), + ]); + + vi.mocked(expiryRepo.logAlert).mockResolvedValue(createMockAlertLogRecord()); + vi.mocked(expiryRepo.upsertAlertSettings).mockResolvedValue({ + expiry_alert_id: 1, + user_id: 'user-1', + days_before_expiry: 7, + alert_method: 'email' as AlertMethod, + is_enabled: true, + last_alert_sent_at: new Date().toISOString(), + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }); + + const mockJob = { + id: 'job-1', + data: { + alertType: 'user_specific' as const, + userId: 'user-1', + daysAhead: 7, + meta: { requestId: 'req-1' }, + }, + } as Job; + + const result = await processExpiryAlertJob(mockJob, mockLogger); + + expect(result.success).toBe(true); + expect(result.alertsSent).toBe(1); + expect(result.usersNotified).toBe(1); + }); + + it('should process daily check job for all users', async () => { + vi.mocked(expiryRepo.getUsersWithExpiringItems).mockResolvedValueOnce([ + { + user_id: 'user-1', + email: 'user1@example.com', + alert_method: 'email' as AlertMethod, + days_before_expiry: 7, + }, + { + user_id: 'user-2', + email: 'user2@example.com', + alert_method: 'email' as AlertMethod, + days_before_expiry: 7, + }, + ]); + + vi.mocked(expiryRepo.getUserAlertSettings) + .mockResolvedValueOnce([ + { + expiry_alert_id: 1, + user_id: 'user-1', + days_before_expiry: 7, + alert_method: 'email' as AlertMethod, + is_enabled: true, + last_alert_sent_at: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ]) + .mockResolvedValueOnce([ + { + expiry_alert_id: 2, + user_id: 'user-2', + days_before_expiry: 7, + alert_method: 'email' as AlertMethod, + is_enabled: true, + last_alert_sent_at: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ]); + + vi.mocked(expiryRepo.getExpiringItems) + .mockResolvedValueOnce([createMockInventoryItem({ days_until_expiry: 3 })]) + .mockResolvedValueOnce([createMockInventoryItem({ days_until_expiry: 5 })]); + + vi.mocked(expiryRepo.logAlert).mockResolvedValue(createMockAlertLogRecord()); + vi.mocked(expiryRepo.upsertAlertSettings).mockResolvedValue({ + expiry_alert_id: 1, + user_id: 'user-1', + days_before_expiry: 7, + alert_method: 'email' as AlertMethod, + is_enabled: true, + last_alert_sent_at: new Date().toISOString(), + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }); + + const mockJob = { + id: 'job-2', + data: { + alertType: 'daily_check' as const, + daysAhead: 7, + }, + } as Job; + + const result = await processExpiryAlertJob(mockJob, mockLogger); + + expect(result.success).toBe(true); + expect(result.usersNotified).toBe(2); + }); + + it('should handle job processing errors', async () => { + vi.mocked(expiryRepo.getUserAlertSettings).mockRejectedValueOnce(new Error('DB error')); + + const mockJob = { + id: 'job-3', + data: { + alertType: 'user_specific' as const, + userId: 'user-1', + }, + } as Job; + + await expect(processExpiryAlertJob(mockJob, mockLogger)).rejects.toThrow('DB error'); + }); + }); +}); + +// Helper function to create mock inventory items +function createMockInventoryItem( + overrides: Partial<{ + inventory_id: number; + master_item_id: number | null; + days_until_expiry: number | null; + }>, +): UserInventoryItem { + const daysUntilExpiry = overrides.days_until_expiry ?? 5; + const expiryStatus: ExpiryStatus = + daysUntilExpiry !== null && daysUntilExpiry < 0 + ? 'expired' + : daysUntilExpiry !== null && daysUntilExpiry <= 7 + ? 'expiring_soon' + : 'fresh'; + return { + inventory_id: overrides.inventory_id ?? 1, + user_id: 'user-1', + product_id: null, + master_item_id: overrides.master_item_id ?? null, + item_name: 'Test Item', + quantity: 1, + unit: null, + purchase_date: null, + expiry_date: '2024-01-25', + source: 'manual' as InventorySource, + location: 'fridge' as StorageLocation, + notes: null, + is_consumed: false, + consumed_at: null, + expiry_source: null, + receipt_item_id: null, + pantry_location_id: null, + notification_sent_at: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + days_until_expiry: daysUntilExpiry, + expiry_status: expiryStatus, + }; +} diff --git a/src/services/expiryService.server.ts b/src/services/expiryService.server.ts new file mode 100644 index 0000000..9d68c8d --- /dev/null +++ b/src/services/expiryService.server.ts @@ -0,0 +1,955 @@ +// src/services/expiryService.server.ts +/** + * @file Expiry Date Tracking Service + * Handles inventory management, expiry date calculations, and expiry alerts. + * Provides functionality for tracking food items and notifying users about expiring items. + */ +import type { Logger } from 'pino'; +import { expiryRepo, receiptRepo } from './db/index.db'; +import type { + StorageLocation, + AlertMethod, + UserInventoryItem, + AddInventoryItemRequest, + UpdateInventoryItemRequest, + ExpiryDateRange, + AddExpiryRangeRequest, + ExpiryAlertSettings, + UpdateExpiryAlertSettingsRequest, + ExpiringItemsResponse, + InventoryQueryOptions, + ExpiryRangeQueryOptions, + CalculateExpiryOptions, +} from '../types/expiry'; + +/** + * Default expiry warning threshold in days + */ +const DEFAULT_EXPIRY_WARNING_DAYS = 7; + +/** + * Number of days to consider an item "expiring soon" + */ +const EXPIRING_SOON_THRESHOLD = 7; + +/** + * Number of days to consider for "this month" expiry grouping + */ +const THIS_MONTH_THRESHOLD = 30; + +// ============================================================================ +// INVENTORY MANAGEMENT +// ============================================================================ + +/** + * Adds an item to the user's inventory. + * If no expiry date is provided, attempts to calculate one based on storage location. + * @param userId The user's ID + * @param item The item to add + * @param logger Pino logger instance + * @returns The created inventory item with computed expiry status + */ +export const addInventoryItem = async ( + userId: string, + item: AddInventoryItemRequest, + logger: Logger, +): Promise => { + const itemLogger = logger.child({ userId, itemName: item.item_name }); + itemLogger.info('Adding item to inventory'); + + // If no expiry date provided and we have purchase date + location, try to calculate + if (!item.expiry_date && item.purchase_date && item.location) { + const calculatedExpiry = await calculateExpiryDate( + { + master_item_id: item.master_item_id, + item_name: item.item_name, + storage_location: item.location, + purchase_date: item.purchase_date, + }, + itemLogger, + ); + + if (calculatedExpiry) { + itemLogger.debug({ calculatedExpiry }, 'Calculated expiry date from storage location'); + item.expiry_date = calculatedExpiry; + } + } + + const inventoryItem = await expiryRepo.addInventoryItem(userId, item, itemLogger); + itemLogger.info({ inventoryId: inventoryItem.inventory_id }, 'Item added to inventory'); + + return inventoryItem; +}; + +/** + * Updates an existing inventory item. + * @param inventoryId The inventory item ID + * @param userId The user's ID (for authorization) + * @param updates The updates to apply + * @param logger Pino logger instance + * @returns The updated inventory item + */ +export const updateInventoryItem = async ( + inventoryId: number, + userId: string, + updates: UpdateInventoryItemRequest, + logger: Logger, +): Promise => { + logger.debug({ inventoryId, userId, updates }, 'Updating inventory item'); + return expiryRepo.updateInventoryItem(inventoryId, userId, updates, logger); +}; + +/** + * Marks an inventory item as consumed. + * @param inventoryId The inventory item ID + * @param userId The user's ID (for authorization) + * @param logger Pino logger instance + */ +export const markItemConsumed = async ( + inventoryId: number, + userId: string, + logger: Logger, +): Promise => { + logger.debug({ inventoryId, userId }, 'Marking item as consumed'); + await expiryRepo.markAsConsumed(inventoryId, userId, logger); + logger.info({ inventoryId }, 'Item marked as consumed'); +}; + +/** + * Deletes an inventory item. + * @param inventoryId The inventory item ID + * @param userId The user's ID (for authorization) + * @param logger Pino logger instance + */ +export const deleteInventoryItem = async ( + inventoryId: number, + userId: string, + logger: Logger, +): Promise => { + logger.debug({ inventoryId, userId }, 'Deleting inventory item'); + await expiryRepo.deleteInventoryItem(inventoryId, userId, logger); + logger.info({ inventoryId }, 'Item deleted from inventory'); +}; + +/** + * Gets a single inventory item by ID. + * @param inventoryId The inventory item ID + * @param userId The user's ID (for authorization) + * @param logger Pino logger instance + * @returns The inventory item + */ +export const getInventoryItemById = async ( + inventoryId: number, + userId: string, + logger: Logger, +): Promise => { + return expiryRepo.getInventoryItemById(inventoryId, userId, logger); +}; + +/** + * Gets the user's inventory with optional filtering and pagination. + * @param options Query options + * @param logger Pino logger instance + * @returns Paginated inventory items + */ +export const getInventory = async ( + options: InventoryQueryOptions, + logger: Logger, +): Promise<{ items: UserInventoryItem[]; total: number }> => { + logger.debug({ userId: options.user_id }, 'Fetching user inventory'); + return expiryRepo.getInventory(options, logger); +}; + +// ============================================================================ +// EXPIRING ITEMS +// ============================================================================ + +/** + * Gets items grouped by expiry urgency for dashboard display. + * @param userId The user's ID + * @param logger Pino logger instance + * @returns Items grouped by expiry status with counts + */ +export const getExpiringItemsGrouped = async ( + userId: string, + logger: Logger, +): Promise => { + logger.debug({ userId }, 'Fetching expiring items grouped by urgency'); + + // Get all expiring items within 30 days + expired items + const expiringThisMonth = await expiryRepo.getExpiringItems(userId, THIS_MONTH_THRESHOLD, logger); + const expiredItems = await expiryRepo.getExpiredItems(userId, logger); + + // Group items by urgency + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const expiringToday: UserInventoryItem[] = []; + const expiringThisWeek: UserInventoryItem[] = []; + const expiringLater: UserInventoryItem[] = []; + + for (const item of expiringThisMonth) { + if (item.days_until_expiry === null) { + continue; + } + + if (item.days_until_expiry === 0) { + expiringToday.push(item); + } else if (item.days_until_expiry <= EXPIRING_SOON_THRESHOLD) { + expiringThisWeek.push(item); + } else { + expiringLater.push(item); + } + } + + const response: ExpiringItemsResponse = { + expiring_today: expiringToday, + expiring_this_week: expiringThisWeek, + expiring_this_month: expiringLater, + already_expired: expiredItems, + counts: { + today: expiringToday.length, + this_week: expiringThisWeek.length, + this_month: expiringLater.length, + expired: expiredItems.length, + total: + expiringToday.length + expiringThisWeek.length + expiringLater.length + expiredItems.length, + }, + }; + + logger.info( + { + userId, + counts: response.counts, + }, + 'Expiring items fetched', + ); + + return response; +}; + +/** + * Gets items expiring within a specified number of days. + * @param userId The user's ID + * @param daysAhead Number of days to look ahead + * @param logger Pino logger instance + * @returns Items expiring within the specified timeframe + */ +export const getExpiringItems = async ( + userId: string, + daysAhead: number, + logger: Logger, +): Promise => { + logger.debug({ userId, daysAhead }, 'Fetching expiring items'); + return expiryRepo.getExpiringItems(userId, daysAhead, logger); +}; + +/** + * Gets items that have already expired. + * @param userId The user's ID + * @param logger Pino logger instance + * @returns Expired items + */ +export const getExpiredItems = async ( + userId: string, + logger: Logger, +): Promise => { + logger.debug({ userId }, 'Fetching expired items'); + return expiryRepo.getExpiredItems(userId, logger); +}; + +// ============================================================================ +// EXPIRY DATE CALCULATION +// ============================================================================ + +/** + * Calculates an estimated expiry date based on item and storage location. + * Uses expiry_date_ranges table for reference data. + * @param options Calculation options + * @param logger Pino logger instance + * @returns Calculated expiry date string (ISO format) or null if unable to calculate + */ +export const calculateExpiryDate = async ( + options: CalculateExpiryOptions, + logger: Logger, +): Promise => { + const { master_item_id, category_id, item_name, storage_location, purchase_date } = options; + + logger.debug( + { + masterItemId: master_item_id, + categoryId: category_id, + itemName: item_name, + storageLocation: storage_location, + }, + 'Calculating expiry date', + ); + + // Look up expiry range for this item/category/pattern + const expiryRange = await expiryRepo.getExpiryRangeForItem(storage_location, logger, { + masterItemId: master_item_id, + categoryId: category_id, + itemName: item_name, + }); + + if (!expiryRange) { + logger.debug('No expiry range found for item'); + return null; + } + + // Calculate expiry date using typical_days + const purchaseDateTime = new Date(purchase_date); + purchaseDateTime.setDate(purchaseDateTime.getDate() + expiryRange.typical_days); + + const expiryDateStr = purchaseDateTime.toISOString().split('T')[0]; + + logger.debug( + { + purchaseDate: purchase_date, + typicalDays: expiryRange.typical_days, + expiryDate: expiryDateStr, + }, + 'Expiry date calculated', + ); + + return expiryDateStr; +}; + +/** + * Gets expiry date ranges with optional filtering. + * @param options Query options + * @param logger Pino logger instance + * @returns Paginated expiry date ranges + */ +export const getExpiryRanges = async ( + options: ExpiryRangeQueryOptions, + logger: Logger, +): Promise<{ ranges: ExpiryDateRange[]; total: number }> => { + return expiryRepo.getExpiryRanges(options, logger); +}; + +/** + * Adds a new expiry date range (admin operation). + * @param range The range to add + * @param logger Pino logger instance + * @returns The created expiry range + */ +export const addExpiryRange = async ( + range: AddExpiryRangeRequest, + logger: Logger, +): Promise => { + logger.info( + { storageLocation: range.storage_location, typicalDays: range.typical_days }, + 'Adding expiry range', + ); + return expiryRepo.addExpiryRange(range, logger); +}; + +// ============================================================================ +// EXPIRY ALERTS +// ============================================================================ + +/** + * Gets the user's expiry alert settings. + * @param userId The user's ID + * @param logger Pino logger instance + * @returns Array of alert settings + */ +export const getAlertSettings = async ( + userId: string, + logger: Logger, +): Promise => { + return expiryRepo.getUserAlertSettings(userId, logger); +}; + +/** + * Updates the user's expiry alert settings for a specific alert method. + * @param userId The user's ID + * @param alertMethod The alert delivery method + * @param settings The settings to update + * @param logger Pino logger instance + * @returns Updated alert settings + */ +export const updateAlertSettings = async ( + userId: string, + alertMethod: AlertMethod, + settings: UpdateExpiryAlertSettingsRequest, + logger: Logger, +): Promise => { + logger.debug({ userId, alertMethod, settings }, 'Updating alert settings'); + return expiryRepo.upsertAlertSettings(userId, alertMethod, settings, logger); +}; + +/** + * Processes expiry alerts for all users. + * This should be called by a scheduled worker job. + * @param logger Pino logger instance + * @returns Number of alerts sent + */ +export const processExpiryAlerts = async (logger: Logger): Promise => { + logger.info('Starting expiry alert processing'); + + // Get all users with expiring items who have alerts enabled + const usersToNotify = await expiryRepo.getUsersWithExpiringItems(logger); + logger.debug({ userCount: usersToNotify.length }, 'Found users to notify'); + + let alertsSent = 0; + + for (const user of usersToNotify) { + try { + // Get the expiring items for this user + const expiringItems = await expiryRepo.getExpiringItems( + user.user_id, + user.days_before_expiry, + logger, + ); + + if (expiringItems.length === 0) { + continue; + } + + // Send notification based on alert method + switch (user.alert_method) { + case 'email': + await sendExpiryEmailAlert(user.user_id, user.email, expiringItems, logger); + break; + case 'push': + // TODO: Implement push notifications + logger.debug({ userId: user.user_id }, 'Push notifications not yet implemented'); + break; + case 'in_app': + // TODO: Implement in-app notifications + logger.debug({ userId: user.user_id }, 'In-app notifications not yet implemented'); + break; + } + + // Log the alert and mark as sent + for (const item of expiringItems) { + await expiryRepo.logAlert( + user.user_id, + 'expiring_soon', + user.alert_method, + item.item_name, + logger, + { + pantryItemId: item.inventory_id, + expiryDate: item.expiry_date, + daysUntilExpiry: item.days_until_expiry, + }, + ); + } + + await expiryRepo.markAlertSent(user.user_id, user.alert_method, logger); + alertsSent++; + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + logger.error({ err, userId: user.user_id }, 'Error processing expiry alert for user'); + } + } + + logger.info({ alertsSent }, 'Expiry alert processing completed'); + return alertsSent; +}; + +/** + * Sends an email alert about expiring items. + * @param userId The user's ID + * @param email The user's email + * @param items The expiring items + * @param logger Pino logger instance + */ +const sendExpiryEmailAlert = async ( + userId: string, + email: string, + items: UserInventoryItem[], + logger: Logger, +): Promise => { + const alertLogger = logger.child({ userId, email, itemCount: items.length }); + alertLogger.info('Sending expiry alert email'); + + // Group items by urgency + const expiredItems = items.filter((i) => i.days_until_expiry !== null && i.days_until_expiry < 0); + const todayItems = items.filter((i) => i.days_until_expiry === 0); + const soonItems = items.filter( + (i) => i.days_until_expiry !== null && i.days_until_expiry > 0 && i.days_until_expiry <= 3, + ); + const laterItems = items.filter((i) => i.days_until_expiry !== null && i.days_until_expiry > 3); + + // Build the email content + const subject = + todayItems.length > 0 || expiredItems.length > 0 + ? '⚠️ Food Items Expiring Today or Already Expired!' + : `🕐 ${items.length} Food Item${items.length > 1 ? 's' : ''} Expiring Soon`; + + const buildItemList = (itemList: UserInventoryItem[], emoji: string): string => { + if (itemList.length === 0) return ''; + return itemList + .map((item) => { + const daysText = + item.days_until_expiry === 0 + ? 'today' + : item.days_until_expiry === 1 + ? 'tomorrow' + : item.days_until_expiry !== null && item.days_until_expiry < 0 + ? `${Math.abs(item.days_until_expiry)} day${Math.abs(item.days_until_expiry) > 1 ? 's' : ''} ago` + : `in ${item.days_until_expiry} days`; + const location = item.location ? ` (${item.location})` : ''; + return `${emoji} ${item.item_name}${location} - expires ${daysText}`; + }) + .join('
'); + }; + + let htmlBody = ''; + + if (expiredItems.length > 0) { + htmlBody += `

Already Expired (${expiredItems.length})

+

${buildItemList(expiredItems, '❌')}

`; + } + + if (todayItems.length > 0) { + htmlBody += `

Expiring Today (${todayItems.length})

+

${buildItemList(todayItems, '⚠️')}

`; + } + + if (soonItems.length > 0) { + htmlBody += `

Expiring Within 3 Days (${soonItems.length})

+

${buildItemList(soonItems, '🕐')}

`; + } + + if (laterItems.length > 0) { + htmlBody += `

Expiring This Week (${laterItems.length})

+

${buildItemList(laterItems, '📅')}

`; + } + + const html = ` +
+

Food Expiry Alert

+

The following items in your pantry need attention:

+ ${htmlBody} +
+

+ Visit your inventory page + to manage these items. You can also find + recipe suggestions + to use them before they expire! +

+

+ To manage your alert preferences, visit your settings page. +

+
+ `; + + // Build plain text version + const buildTextList = (itemList: UserInventoryItem[]): string => { + return itemList + .map((item) => { + const daysText = + item.days_until_expiry === 0 + ? 'today' + : item.days_until_expiry === 1 + ? 'tomorrow' + : item.days_until_expiry !== null && item.days_until_expiry < 0 + ? `${Math.abs(item.days_until_expiry)} day(s) ago` + : `in ${item.days_until_expiry} days`; + return ` - ${item.item_name} - expires ${daysText}`; + }) + .join('\n'); + }; + + let textBody = 'Food Expiry Alert\n\nThe following items need attention:\n\n'; + if (expiredItems.length > 0) { + textBody += `Already Expired:\n${buildTextList(expiredItems)}\n\n`; + } + if (todayItems.length > 0) { + textBody += `Expiring Today:\n${buildTextList(todayItems)}\n\n`; + } + if (soonItems.length > 0) { + textBody += `Expiring Within 3 Days:\n${buildTextList(soonItems)}\n\n`; + } + if (laterItems.length > 0) { + textBody += `Expiring This Week:\n${buildTextList(laterItems)}\n\n`; + } + textBody += 'Visit your inventory page to manage these items.\n\nFlyer Crawler'; + + try { + await emailService.sendEmail( + { + to: email, + subject, + text: textBody, + html, + }, + alertLogger, + ); + alertLogger.info('Expiry alert email sent successfully'); + } catch (error) { + alertLogger.error({ err: error }, 'Failed to send expiry alert email'); + throw error; + } +}; + +// ============================================================================ +// RECEIPT INTEGRATION +// ============================================================================ + +/** + * Adds items from a confirmed receipt to the user's inventory. + * @param userId The user's ID + * @param receiptId The receipt ID + * @param itemConfirmations Array of item confirmations with storage locations + * @param logger Pino logger instance + * @returns Array of created inventory items + */ +export const addItemsFromReceipt = async ( + userId: string, + receiptId: number, + itemConfirmations: Array<{ + receipt_item_id: number; + item_name?: string; + quantity?: number; + location?: StorageLocation; + expiry_date?: string; + include: boolean; + }>, + logger: Logger, +): Promise => { + const receiptLogger = logger.child({ userId, receiptId }); + receiptLogger.info( + { itemCount: itemConfirmations.length }, + 'Adding items from receipt to inventory', + ); + + const createdItems: UserInventoryItem[] = []; + + // Get receipt details for purchase date + const receipt = await receiptRepo.getReceiptById(receiptId, userId, receiptLogger); + + for (const confirmation of itemConfirmations) { + if (!confirmation.include) { + receiptLogger.debug( + { receiptItemId: confirmation.receipt_item_id }, + 'Skipping excluded item', + ); + continue; + } + + try { + // Get the receipt item details + const receiptItems = await receiptRepo.getReceiptItems(receiptId, receiptLogger); + const receiptItem = receiptItems.find( + (ri) => ri.receipt_item_id === confirmation.receipt_item_id, + ); + + if (!receiptItem) { + receiptLogger.warn( + { receiptItemId: confirmation.receipt_item_id }, + 'Receipt item not found', + ); + continue; + } + + // Create inventory item + const inventoryItem = await addInventoryItem( + userId, + { + product_id: receiptItem.product_id ?? undefined, + master_item_id: receiptItem.master_item_id ?? undefined, + item_name: confirmation.item_name || receiptItem.raw_item_description, + quantity: confirmation.quantity || receiptItem.quantity, + purchase_date: receipt.transaction_date || receipt.created_at.split('T')[0], + expiry_date: confirmation.expiry_date, + source: 'receipt_scan', + location: confirmation.location, + }, + receiptLogger, + ); + + // Update receipt item to mark as added to pantry + await receiptRepo.updateReceiptItem( + confirmation.receipt_item_id, + { + added_to_pantry: true, + pantry_item_id: inventoryItem.inventory_id, + }, + receiptLogger, + ); + + createdItems.push(inventoryItem); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + receiptLogger.error( + { err, receiptItemId: confirmation.receipt_item_id }, + 'Error adding receipt item to inventory', + ); + } + } + + receiptLogger.info({ createdCount: createdItems.length }, 'Items added from receipt'); + return createdItems; +}; + +/** + * Gets recipe suggestions based on expiring items. + * Prioritizes recipes that use items closest to expiry. + * @param userId The user's ID + * @param daysAhead Number of days to look ahead for expiring items + * @param logger Pino logger instance + * @param options Pagination options + * @returns Recipes with matching expiring ingredients + */ +export const getRecipeSuggestionsForExpiringItems = async ( + userId: string, + daysAhead: number, + logger: Logger, + options: { limit?: number; offset?: number } = {}, +): Promise<{ + recipes: Array<{ + recipe_id: number; + recipe_name: string; + description: string | null; + prep_time_minutes: number | null; + cook_time_minutes: number | null; + servings: number | null; + photo_url: string | null; + matching_items: UserInventoryItem[]; + match_count: number; + }>; + total: number; + considered_items: UserInventoryItem[]; +}> => { + const { limit = 10, offset = 0 } = options; + const suggestionLogger = logger.child({ userId, daysAhead }); + suggestionLogger.debug('Fetching recipe suggestions for expiring items'); + + // Get expiring items to include in the response + const expiringItems = await getExpiringItems(userId, daysAhead, logger); + + if (expiringItems.length === 0) { + suggestionLogger.debug('No expiring items found, returning empty suggestions'); + return { + recipes: [], + total: 0, + considered_items: [], + }; + } + + // Get recipes that use the expiring items + const recipeData = await expiryRepo.getRecipesForExpiringItems( + userId, + daysAhead, + limit, + offset, + suggestionLogger, + ); + + // Map the expiring items by master_item_id for quick lookup + const itemsByMasterId = new Map(); + for (const item of expiringItems) { + if (item.master_item_id && !itemsByMasterId.has(item.master_item_id)) { + itemsByMasterId.set(item.master_item_id, item); + } + } + + // Build the response with matching items + const recipes = recipeData.recipes.map((recipe) => ({ + recipe_id: recipe.recipe_id, + recipe_name: recipe.recipe_name, + description: recipe.description, + prep_time_minutes: recipe.prep_time_minutes, + cook_time_minutes: recipe.cook_time_minutes, + servings: recipe.servings, + photo_url: recipe.photo_url, + matching_items: recipe.matching_master_item_ids + .map((id) => itemsByMasterId.get(id)) + .filter((item): item is UserInventoryItem => item !== undefined), + match_count: recipe.match_count, + })); + + suggestionLogger.info( + { + recipeCount: recipes.length, + total: recipeData.total, + expiringItemCount: expiringItems.length, + }, + 'Recipe suggestions fetched for expiring items', + ); + + return { + recipes, + total: recipeData.total, + considered_items: expiringItems, + }; +}; + +// ============================================================================ +// JOB PROCESSING +// ============================================================================ + +import type { Job } from 'bullmq'; +import type { ExpiryAlertJobData } from '../types/job-data'; +import * as emailService from './emailService.server'; + +/** + * Processes an expiry alert job from the queue. + * This is the main entry point for background expiry alert processing. + * @param job The BullMQ job + * @param logger Pino logger instance + * @returns Processing result with counts of alerts sent + */ +export const processExpiryAlertJob = async ( + job: Job, + logger: Logger, +): Promise<{ success: boolean; alertsSent: number; usersNotified: number }> => { + const { + alertType, + userId, + daysAhead = DEFAULT_EXPIRY_WARNING_DAYS, + scheduledAt: _scheduledAt, + } = job.data; + const jobLogger = logger.child({ + jobId: job.id, + alertType, + userId, + daysAhead, + requestId: job.data.meta?.requestId, + }); + + jobLogger.info('Starting expiry alert job'); + + try { + let alertsSent = 0; + let usersNotified = 0; + + if (alertType === 'user_specific' && userId) { + // Process alerts for a single user + const result = await processUserExpiryAlerts(userId, daysAhead, jobLogger); + alertsSent = result.alertsSent; + usersNotified = result.alertsSent > 0 ? 1 : 0; + } else if (alertType === 'daily_check') { + // Process daily alerts for all users with expiring items + const result = await processDailyExpiryAlerts(daysAhead, jobLogger); + alertsSent = result.totalAlerts; + usersNotified = result.usersNotified; + } + + jobLogger.info({ alertsSent, usersNotified }, 'Expiry alert job completed'); + + return { success: true, alertsSent, usersNotified }; + } catch (error) { + jobLogger.error({ err: error }, 'Expiry alert job failed'); + throw error; + } +}; + +/** + * Processes expiry alerts for a single user. + * @param userId The user's ID + * @param daysAhead Days ahead to check for expiring items + * @param logger Pino logger instance + * @returns Number of alerts sent + */ +const processUserExpiryAlerts = async ( + userId: string, + daysAhead: number, + logger: Logger, +): Promise<{ alertsSent: number }> => { + const userLogger = logger.child({ userId }); + + // Get user's alert settings + const settings = await expiryRepo.getUserAlertSettings(userId, userLogger); + const enabledSettings = settings.filter((s) => s.is_enabled); + + if (enabledSettings.length === 0) { + userLogger.debug('No enabled alert settings for user'); + return { alertsSent: 0 }; + } + + // Get expiring items + const expiringItems = await getExpiringItems(userId, daysAhead, userLogger); + + if (expiringItems.length === 0) { + userLogger.debug('No expiring items for user'); + return { alertsSent: 0 }; + } + + let alertsSent = 0; + + // Group items by urgency for the alert (kept for future use in alert formatting) + const _expiredItems = expiringItems.filter((i) => i.expiry_status === 'expired'); + const _soonItems = expiringItems.filter((i) => i.expiry_status === 'expiring_soon'); + + // Check if we should send alerts based on settings + for (const setting of enabledSettings) { + const relevantItems = expiringItems.filter( + (item) => + item.days_until_expiry !== null && item.days_until_expiry <= setting.days_before_expiry, + ); + + if (relevantItems.length > 0) { + // Log the alert + for (const item of relevantItems) { + const alertType: ExpiryAlertType = + item.expiry_status === 'expired' ? 'expired' : 'expiring_soon'; + await expiryRepo.logAlert( + userId, + alertType, + setting.alert_method, + item.item_name, + userLogger, + { + pantryItemId: item.inventory_id, + expiryDate: item.expiry_date || null, + daysUntilExpiry: item.days_until_expiry, + }, + ); + alertsSent++; + } + + // Update last alert sent time via upsert + await expiryRepo.upsertAlertSettings(userId, setting.alert_method, {}, userLogger); + } + } + + userLogger.info({ alertsSent, itemCount: expiringItems.length }, 'Processed user expiry alerts'); + return { alertsSent }; +}; + +/** + * Processes daily expiry alerts for all users. + * @param daysAhead Days ahead to check for expiring items + * @param logger Pino logger instance + * @returns Total alerts and users notified + */ +const processDailyExpiryAlerts = async ( + daysAhead: number, + logger: Logger, +): Promise<{ totalAlerts: number; usersNotified: number }> => { + // Get all users with items expiring within the threshold + const usersWithExpiringItems = await expiryRepo.getUsersWithExpiringItems(logger); + + // Get unique user IDs + const uniqueUserIds = [...new Set(usersWithExpiringItems.map((u) => u.user_id))]; + + let totalAlerts = 0; + let usersNotified = 0; + + for (const userId of uniqueUserIds) { + try { + const result = await processUserExpiryAlerts(userId, daysAhead, logger); + totalAlerts += result.alertsSent; + if (result.alertsSent > 0) { + usersNotified++; + } + } catch (error) { + logger.error({ err: error, userId }, 'Failed to process alerts for user'); + // Continue with other users + } + } + + logger.info( + { totalAlerts, usersNotified, totalUsers: uniqueUserIds.length }, + 'Daily expiry alert processing complete', + ); + + return { totalAlerts, usersNotified }; +}; diff --git a/src/services/flyerProcessingService.server.ts b/src/services/flyerProcessingService.server.ts index 02228cf..b09e1ec 100644 --- a/src/services/flyerProcessingService.server.ts +++ b/src/services/flyerProcessingService.server.ts @@ -13,10 +13,12 @@ import { AiDataValidationError, } from './processingErrors'; import { NotFoundError } from './db/errors.db'; -import { logger as globalLogger } from './logger.server'; // This was a duplicate, fixed. +import { createScopedLogger } from './logger.server'; import { generateFlyerIcon } from '../utils/imageProcessor'; import type { FlyerPersistenceService } from './flyerPersistenceService.server'; +const globalLogger = createScopedLogger('flyer-processing-service'); + // Define ProcessingStage locally as it's not exported from the types file. export type ProcessingStage = { name: string; @@ -75,8 +77,20 @@ export class FlyerProcessingService { * @returns An object containing the ID of the newly created flyer. */ async processJob(job: Job): Promise<{ flyerId: number }> { + // Extract context metadata (ADR-051) for request tracing + const { meta, ...jobDataWithoutMeta } = job.data; + // Create a logger instance with job-specific context for better traceability. - const logger = globalLogger.child({ jobId: job.id, jobName: job.name, ...job.data }); + // Uses request_id from the original API request if available (ADR-051). + const logger = globalLogger.child({ + jobId: job.id, + jobName: job.name, + request_id: meta?.requestId, // Propagate original request ID + user_id: meta?.userId, + origin: meta?.origin || 'unknown', + service: 'flyer-worker', + ...jobDataWithoutMeta, + }); logger.info('Picked up flyer processing job.'); const stages: ProcessingStage[] = [ diff --git a/src/services/logger.server.ts b/src/services/logger.server.ts index f2e6d8d..492a5c5 100644 --- a/src/services/logger.server.ts +++ b/src/services/logger.server.ts @@ -41,3 +41,15 @@ export const logger = pino({ censor: '[REDACTED]', }, }); + +const debugModules = (process.env.DEBUG_MODULES || '').split(',').map((s) => s.trim()); + +export const createScopedLogger = (moduleName: string) => { + // If DEBUG_MODULES contains "ai-service" or "*", force level to 'debug' + const isDebugEnabled = debugModules.includes('*') || debugModules.includes(moduleName); + + return logger.child({ + module: moduleName, + level: isDebugEnabled ? 'debug' : logger.level, + }); +}; diff --git a/src/services/monitoringService.server.ts b/src/services/monitoringService.server.ts index 4c5e218..c4ff8b3 100644 --- a/src/services/monitoringService.server.ts +++ b/src/services/monitoringService.server.ts @@ -5,13 +5,15 @@ import { analyticsQueue, cleanupQueue, weeklyAnalyticsQueue, -} from './queueService.server'; + tokenCleanupQueue, +} from './queues.server'; import { analyticsWorker, cleanupWorker, emailWorker, flyerWorker, weeklyAnalyticsWorker, + tokenCleanupWorker, flyerProcessingService, } from './workers.server'; import type { Queue } from 'bullmq'; @@ -35,6 +37,7 @@ class MonitoringService { analyticsWorker, cleanupWorker, weeklyAnalyticsWorker, + tokenCleanupWorker, ]; return Promise.all( workers.map(async (worker) => ({ @@ -49,7 +52,14 @@ class MonitoringService { * @returns A promise that resolves to an array of queue statuses. */ async getQueueStatuses() { - const queues = [flyerQueue, emailQueue, analyticsQueue, cleanupQueue, weeklyAnalyticsQueue]; + const queues = [ + flyerQueue, + emailQueue, + analyticsQueue, + cleanupQueue, + weeklyAnalyticsQueue, + tokenCleanupQueue, + ]; return Promise.all( queues.map(async (queue) => ({ name: queue.name, @@ -77,7 +87,8 @@ class MonitoringService { 'email-sending': emailQueue, 'analytics-reporting': analyticsQueue, 'file-cleanup': cleanupQueue, - 'weekly-analytics-reporting': weeklyAnalyticsQueue, // This was a duplicate, fixed. + 'weekly-analytics-reporting': weeklyAnalyticsQueue, + 'token-cleanup': tokenCleanupQueue, }; const queue = queueMap[queueName]; diff --git a/src/services/queueService.server.ts b/src/services/queueService.server.ts index 95372e0..a068a1a 100644 --- a/src/services/queueService.server.ts +++ b/src/services/queueService.server.ts @@ -8,6 +8,9 @@ import { weeklyAnalyticsQueue, cleanupQueue, tokenCleanupQueue, + receiptQueue, + expiryAlertQueue, + barcodeQueue, } from './queues.server'; // Re-export everything for backward compatibility where possible @@ -33,6 +36,9 @@ export const gracefulShutdown = async (signal: string) => { { name: 'cleanupQueue', close: () => cleanupQueue.close() }, { name: 'weeklyAnalyticsQueue', close: () => weeklyAnalyticsQueue.close() }, { name: 'tokenCleanupQueue', close: () => tokenCleanupQueue.close() }, + { name: 'receiptQueue', close: () => receiptQueue.close() }, + { name: 'expiryAlertQueue', close: () => expiryAlertQueue.close() }, + { name: 'barcodeQueue', close: () => barcodeQueue.close() }, { name: 'redisConnection', close: () => connection.quit() }, ]; diff --git a/src/services/queues.server.ts b/src/services/queues.server.ts index de9ea9f..46cefb0 100644 --- a/src/services/queues.server.ts +++ b/src/services/queues.server.ts @@ -7,6 +7,9 @@ import type { WeeklyAnalyticsJobData, CleanupJobData, TokenCleanupJobData, + ReceiptJobData, + ExpiryAlertJobData, + BarcodeDetectionJobData, } from '../types/job-data'; // --- Queues --- @@ -46,15 +49,18 @@ export const analyticsQueue = new Queue('analytics-reporting', }, }); -export const weeklyAnalyticsQueue = new Queue('weekly-analytics-reporting', { - connection, - defaultJobOptions: { - attempts: 2, - backoff: { type: 'exponential', delay: 3600000 }, - removeOnComplete: true, - removeOnFail: 50, +export const weeklyAnalyticsQueue = new Queue( + 'weekly-analytics-reporting', + { + connection, + defaultJobOptions: { + attempts: 2, + backoff: { type: 'exponential', delay: 3600000 }, + removeOnComplete: true, + removeOnFail: 50, + }, }, -}); +); export const cleanupQueue = new Queue('file-cleanup', { connection, @@ -73,4 +79,43 @@ export const tokenCleanupQueue = new Queue('token-cleanup', removeOnComplete: true, removeOnFail: 10, }, -}); \ No newline at end of file +}); + +// --- Receipt Processing Queue --- +export const receiptQueue = new Queue('receipt-processing', { + connection, + defaultJobOptions: { + attempts: 3, + backoff: { + type: 'exponential', + delay: 10000, // 10 seconds initial delay + }, + removeOnComplete: 100, // Keep last 100 completed jobs + removeOnFail: 50, + }, +}); + +// --- Expiry Alert Queue --- +export const expiryAlertQueue = new Queue('expiry-alerts', { + connection, + defaultJobOptions: { + attempts: 2, + backoff: { type: 'exponential', delay: 300000 }, // 5 minutes + removeOnComplete: true, + removeOnFail: 20, + }, +}); + +// --- Barcode Detection Queue --- +export const barcodeQueue = new Queue('barcode-detection', { + connection, + defaultJobOptions: { + attempts: 2, + backoff: { + type: 'exponential', + delay: 5000, + }, + removeOnComplete: 50, + removeOnFail: 20, + }, +}); diff --git a/src/services/receiptService.server.test.ts b/src/services/receiptService.server.test.ts new file mode 100644 index 0000000..2c52011 --- /dev/null +++ b/src/services/receiptService.server.test.ts @@ -0,0 +1,791 @@ +// src/services/receiptService.server.test.ts +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Logger } from 'pino'; +import type { Job } from 'bullmq'; +import type { ReceiptJobData } from '../types/job-data'; +import { createMockLogger } from '../tests/utils/mockLogger'; +import type { + ReceiptStatus, + ReceiptItemStatus, + ReceiptProcessingStep, + ReceiptProcessingStatus, + OcrProvider, + ReceiptProcessingLogRecord, +} from '../types/expiry'; + +// Mock dependencies +vi.mock('./db/index.db', () => ({ + receiptRepo: { + createReceipt: vi.fn(), + getReceiptById: vi.fn(), + getReceipts: vi.fn(), + updateReceipt: vi.fn(), + deleteReceipt: vi.fn(), + logProcessingStep: vi.fn(), + detectStoreFromText: vi.fn(), + addReceiptItems: vi.fn(), + incrementRetryCount: vi.fn(), + getReceiptItems: vi.fn(), + updateReceiptItem: vi.fn(), + getUnaddedReceiptItems: vi.fn(), + getProcessingLogs: vi.fn(), + getProcessingStats: vi.fn(), + getReceiptsNeedingProcessing: vi.fn(), + addStorePattern: vi.fn(), + getActiveStorePatterns: vi.fn(), + }, +})); + +vi.mock('../config/env', () => ({ + isAiConfigured: false, + config: { + gemini: { + apiKey: undefined, + }, + }, +})); + +vi.mock('./aiService.server', () => ({ + aiService: { + extractItemsFromReceiptImage: vi.fn(), + }, +})); + +vi.mock('./logger.server', () => ({ + logger: { + child: vi.fn().mockReturnThis(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('node:fs/promises', () => ({ + default: { + access: vi.fn(), + }, +})); + +// Import after mocks are set up +import { + createReceipt, + getReceiptById, + getReceipts, + deleteReceipt, + processReceipt, + getReceiptItems, + updateReceiptItem, + getUnaddedItems, + getProcessingLogs, + getProcessingStats, + getReceiptsNeedingProcessing, + addStorePattern, + getActiveStorePatterns, + processReceiptJob, +} from './receiptService.server'; + +import { receiptRepo } from './db/index.db'; + +// Helper to create mock processing log record +function createMockProcessingLogRecord( + overrides: Partial = {}, +): ReceiptProcessingLogRecord { + return { + log_id: 1, + receipt_id: 1, + processing_step: 'upload' as ReceiptProcessingStep, + status: 'completed' as ReceiptProcessingStatus, + provider: null, + duration_ms: null, + tokens_used: null, + cost_cents: null, + input_data: null, + output_data: null, + error_message: null, + created_at: new Date().toISOString(), + ...overrides, + }; +} + +// Helper to create mock store pattern row +interface StoreReceiptPatternRow { + pattern_id: number; + store_id: number; + pattern_type: string; + pattern_value: string; + priority: number; + is_active: boolean; + created_at: string; + updated_at: string; +} + +function createMockStorePatternRow( + overrides: Partial = {}, +): StoreReceiptPatternRow { + return { + pattern_id: 1, + store_id: 1, + pattern_type: 'name', + pattern_value: 'WALMART', + priority: 0, + is_active: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + ...overrides, + }; +} + +describe('receiptService.server', () => { + let mockLogger: Logger; + + beforeEach(() => { + vi.clearAllMocks(); + mockLogger = createMockLogger(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe('createReceipt', () => { + it('should create a new receipt and log upload step', async () => { + const mockReceipt = { + receipt_id: 1, + user_id: 'user-1', + store_id: null, + receipt_image_url: '/uploads/receipt.jpg', + transaction_date: null, + total_amount_cents: null, + status: 'pending' as ReceiptStatus, + raw_text: null, + store_confidence: null, + ocr_provider: null, + error_details: null, + retry_count: 0, + ocr_confidence: null, + currency: 'USD', + created_at: new Date().toISOString(), + processed_at: null, + updated_at: new Date().toISOString(), + }; + + vi.mocked(receiptRepo.createReceipt).mockResolvedValueOnce(mockReceipt); + vi.mocked(receiptRepo.logProcessingStep).mockResolvedValueOnce( + createMockProcessingLogRecord(), + ); + + const result = await createReceipt('user-1', '/uploads/receipt.jpg', mockLogger); + + expect(result.receipt_id).toBe(1); + expect(receiptRepo.createReceipt).toHaveBeenCalledWith( + { + user_id: 'user-1', + receipt_image_url: '/uploads/receipt.jpg', + store_id: undefined, + transaction_date: undefined, + }, + mockLogger, + ); + expect(receiptRepo.logProcessingStep).toHaveBeenCalledWith( + 1, + 'upload', + 'completed', + mockLogger, + expect.any(Object), + ); + }); + + it('should create receipt with optional store ID and transaction date', async () => { + const mockReceipt = { + receipt_id: 2, + user_id: 'user-1', + store_id: 5, + receipt_image_url: '/uploads/receipt2.jpg', + transaction_date: '2024-01-15', + total_amount_cents: null, + status: 'pending' as ReceiptStatus, + raw_text: null, + store_confidence: null, + ocr_provider: null, + error_details: null, + retry_count: 0, + ocr_confidence: null, + currency: 'USD', + created_at: new Date().toISOString(), + processed_at: null, + updated_at: new Date().toISOString(), + }; + + vi.mocked(receiptRepo.createReceipt).mockResolvedValueOnce(mockReceipt); + vi.mocked(receiptRepo.logProcessingStep).mockResolvedValueOnce( + createMockProcessingLogRecord(), + ); + + const result = await createReceipt('user-1', '/uploads/receipt2.jpg', mockLogger, { + storeId: 5, + transactionDate: '2024-01-15', + }); + + expect(result.store_id).toBe(5); + expect(result.transaction_date).toBe('2024-01-15'); + }); + }); + + describe('getReceiptById', () => { + it('should return receipt by ID', async () => { + const mockReceipt = { + receipt_id: 1, + user_id: 'user-1', + store_id: null, + receipt_image_url: '/uploads/receipt.jpg', + transaction_date: null, + total_amount_cents: null, + status: 'pending' as ReceiptStatus, + raw_text: null, + store_confidence: null, + ocr_provider: null, + error_details: null, + retry_count: 0, + ocr_confidence: null, + currency: 'USD', + created_at: new Date().toISOString(), + processed_at: null, + updated_at: new Date().toISOString(), + }; + + vi.mocked(receiptRepo.getReceiptById).mockResolvedValueOnce(mockReceipt); + + const result = await getReceiptById(1, 'user-1', mockLogger); + + expect(result.receipt_id).toBe(1); + expect(receiptRepo.getReceiptById).toHaveBeenCalledWith(1, 'user-1', mockLogger); + }); + }); + + describe('getReceipts', () => { + it('should return paginated receipts for user', async () => { + const mockReceipts = { + receipts: [ + { + receipt_id: 1, + user_id: 'user-1', + store_id: null, + receipt_image_url: '/uploads/receipt1.jpg', + transaction_date: null, + total_amount_cents: null, + status: 'completed' as ReceiptStatus, + raw_text: null, + store_confidence: null, + ocr_provider: null, + error_details: null, + retry_count: 0, + ocr_confidence: null, + currency: 'USD', + created_at: new Date().toISOString(), + processed_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ], + total: 1, + }; + + vi.mocked(receiptRepo.getReceipts).mockResolvedValueOnce(mockReceipts); + + const result = await getReceipts({ user_id: 'user-1', limit: 10, offset: 0 }, mockLogger); + + expect(result.receipts).toHaveLength(1); + expect(result.total).toBe(1); + }); + + it('should filter by status', async () => { + vi.mocked(receiptRepo.getReceipts).mockResolvedValueOnce({ receipts: [], total: 0 }); + + await getReceipts({ user_id: 'user-1', status: 'completed' }, mockLogger); + + expect(receiptRepo.getReceipts).toHaveBeenCalledWith( + { user_id: 'user-1', status: 'completed' }, + mockLogger, + ); + }); + }); + + describe('deleteReceipt', () => { + it('should delete receipt', async () => { + vi.mocked(receiptRepo.deleteReceipt).mockResolvedValueOnce(undefined); + + await deleteReceipt(1, 'user-1', mockLogger); + + expect(receiptRepo.deleteReceipt).toHaveBeenCalledWith(1, 'user-1', mockLogger); + }); + }); + + describe('processReceipt', () => { + it('should process receipt and return items when AI not configured', async () => { + const mockReceipt = { + receipt_id: 1, + user_id: 'user-1', + store_id: null, + receipt_image_url: '/uploads/receipt.jpg', + transaction_date: null, + total_amount_cents: null, + status: 'pending' as ReceiptStatus, + raw_text: null, + store_confidence: null, + ocr_provider: null, + error_details: null, + retry_count: 0, + ocr_confidence: null, + currency: 'USD', + created_at: new Date().toISOString(), + processed_at: null, + updated_at: new Date().toISOString(), + }; + + const mockUpdatedReceipt = { ...mockReceipt, status: 'processing' }; + const mockCompletedReceipt = { ...mockReceipt, status: 'completed' }; + + vi.mocked(receiptRepo.updateReceipt) + .mockResolvedValueOnce(mockUpdatedReceipt as any) // status: processing + .mockResolvedValueOnce({ ...mockUpdatedReceipt, raw_text: '[AI not configured]' } as any) // raw_text update + .mockResolvedValueOnce(mockCompletedReceipt as any); // status: completed + + vi.mocked(receiptRepo.logProcessingStep).mockResolvedValue(createMockProcessingLogRecord()); + vi.mocked(receiptRepo.detectStoreFromText).mockResolvedValueOnce(null); + vi.mocked(receiptRepo.addReceiptItems).mockResolvedValueOnce([]); + + const result = await processReceipt(1, mockLogger); + + expect(result.receipt.status).toBe('completed'); + expect(receiptRepo.updateReceipt).toHaveBeenCalledWith( + 1, + { status: 'processing' }, + expect.any(Object), + ); + }); + + it('should detect store from receipt text', async () => { + const mockReceipt = { + receipt_id: 2, + user_id: 'user-1', + store_id: null, + receipt_image_url: '/uploads/receipt.jpg', + transaction_date: null, + total_amount_cents: null, + status: 'pending' as ReceiptStatus, + raw_text: null, + store_confidence: null, + ocr_provider: null, + error_details: null, + retry_count: 0, + ocr_confidence: null, + currency: 'USD', + created_at: new Date().toISOString(), + processed_at: null, + updated_at: new Date().toISOString(), + }; + + vi.mocked(receiptRepo.updateReceipt).mockResolvedValue({ + ...mockReceipt, + status: 'completed' as ReceiptStatus, + } as any); + vi.mocked(receiptRepo.logProcessingStep).mockResolvedValue(createMockProcessingLogRecord()); + vi.mocked(receiptRepo.detectStoreFromText).mockResolvedValueOnce({ + store_id: 10, + confidence: 0.9, + }); + vi.mocked(receiptRepo.addReceiptItems).mockResolvedValueOnce([]); + + await processReceipt(2, mockLogger); + + expect(receiptRepo.updateReceipt).toHaveBeenCalledWith( + 2, + expect.objectContaining({ store_id: 10, store_confidence: 0.9 }), + expect.any(Object), + ); + }); + + it('should handle processing errors', async () => { + vi.mocked(receiptRepo.updateReceipt).mockRejectedValueOnce(new Error('DB error')); + vi.mocked(receiptRepo.incrementRetryCount).mockResolvedValueOnce(1); + vi.mocked(receiptRepo.logProcessingStep).mockResolvedValue(createMockProcessingLogRecord()); + + await expect(processReceipt(1, mockLogger)).rejects.toThrow('DB error'); + + expect(receiptRepo.incrementRetryCount).toHaveBeenCalledWith(1, expect.any(Object)); + }); + }); + + describe('getReceiptItems', () => { + it('should return receipt items', async () => { + const mockItems = [ + { + receipt_item_id: 1, + receipt_id: 1, + raw_item_description: 'MILK 2%', + quantity: 1, + price_paid_cents: 399, + master_item_id: null, + product_id: null, + status: 'unmatched' as ReceiptItemStatus, + line_number: 1, + match_confidence: null, + is_discount: false, + unit_price_cents: null, + unit_type: null, + added_to_pantry: false, + pantry_item_id: null, + upc_code: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ]; + + vi.mocked(receiptRepo.getReceiptItems).mockResolvedValueOnce(mockItems); + + const result = await getReceiptItems(1, mockLogger); + + expect(result).toHaveLength(1); + expect(result[0].raw_item_description).toBe('MILK 2%'); + }); + }); + + describe('updateReceiptItem', () => { + it('should update receipt item', async () => { + const mockUpdatedItem = { + receipt_item_id: 1, + receipt_id: 1, + raw_item_description: 'MILK 2%', + quantity: 2, + price_paid_cents: 399, + master_item_id: 5, + product_id: null, + status: 'matched' as ReceiptItemStatus, + line_number: 1, + match_confidence: 0.95, + is_discount: false, + unit_price_cents: null, + unit_type: null, + added_to_pantry: false, + pantry_item_id: null, + upc_code: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + vi.mocked(receiptRepo.updateReceiptItem).mockResolvedValueOnce(mockUpdatedItem); + + const result = await updateReceiptItem( + 1, + { master_item_id: 5, status: 'matched' as ReceiptItemStatus, match_confidence: 0.95 }, + mockLogger, + ); + + expect(result.quantity).toBe(2); + expect(result.master_item_id).toBe(5); + expect(result.status).toBe('matched'); + }); + }); + + describe('getUnaddedItems', () => { + it('should return items not yet added to pantry', async () => { + const mockItems = [ + { + receipt_item_id: 1, + receipt_id: 1, + raw_item_description: 'BREAD', + quantity: 1, + price_paid_cents: 299, + master_item_id: null, + product_id: null, + status: 'unmatched' as ReceiptItemStatus, + line_number: 1, + match_confidence: null, + is_discount: false, + unit_price_cents: null, + unit_type: null, + added_to_pantry: false, + pantry_item_id: null, + upc_code: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ]; + + vi.mocked(receiptRepo.getUnaddedReceiptItems).mockResolvedValueOnce(mockItems); + + const result = await getUnaddedItems(1, mockLogger); + + expect(result).toHaveLength(1); + expect(result[0].added_to_pantry).toBe(false); + }); + }); + + describe('getProcessingLogs', () => { + it('should return processing logs for receipt', async () => { + const mockLogs = [ + { + log_id: 1, + receipt_id: 1, + processing_step: 'upload' as ReceiptProcessingStep, + status: 'completed' as ReceiptProcessingStatus, + provider: 'internal' as OcrProvider, + duration_ms: 50, + tokens_used: null, + cost_cents: null, + input_data: null, + output_data: null, + error_message: null, + created_at: new Date().toISOString(), + }, + ]; + + vi.mocked(receiptRepo.getProcessingLogs).mockResolvedValueOnce(mockLogs); + + const result = await getProcessingLogs(1, mockLogger); + + expect(result).toHaveLength(1); + expect(result[0].processing_step).toBe('upload'); + }); + }); + + describe('getProcessingStats', () => { + it('should return processing statistics', async () => { + const mockStats = { + total_receipts: 100, + completed: 85, + failed: 10, + pending: 5, + avg_processing_time_ms: 2500, + total_cost_cents: 0, + }; + + vi.mocked(receiptRepo.getProcessingStats).mockResolvedValueOnce(mockStats); + + const result = await getProcessingStats(mockLogger); + + expect(result.total_receipts).toBe(100); + expect(result.completed).toBe(85); + }); + + it('should filter by date range', async () => { + const mockStats = { + total_receipts: 20, + completed: 18, + failed: 2, + pending: 0, + avg_processing_time_ms: 2000, + total_cost_cents: 0, + }; + + vi.mocked(receiptRepo.getProcessingStats).mockResolvedValueOnce(mockStats); + + await getProcessingStats(mockLogger, { + fromDate: '2024-01-01', + toDate: '2024-01-31', + }); + + expect(receiptRepo.getProcessingStats).toHaveBeenCalledWith(mockLogger, { + fromDate: '2024-01-01', + toDate: '2024-01-31', + }); + }); + }); + + describe('getReceiptsNeedingProcessing', () => { + it('should return pending receipts for processing', async () => { + const mockReceipts = [ + { + receipt_id: 1, + user_id: 'user-1', + store_id: null, + receipt_image_url: '/uploads/receipt.jpg', + transaction_date: null, + total_amount_cents: null, + status: 'pending' as ReceiptStatus, + raw_text: null, + store_confidence: null, + ocr_provider: null, + error_details: null, + retry_count: 0, + ocr_confidence: null, + currency: 'USD', + created_at: new Date().toISOString(), + processed_at: null, + updated_at: new Date().toISOString(), + }, + ]; + + vi.mocked(receiptRepo.getReceiptsNeedingProcessing).mockResolvedValueOnce(mockReceipts); + + const result = await getReceiptsNeedingProcessing(10, mockLogger); + + expect(result).toHaveLength(1); + expect(result[0].status).toBe('pending'); + }); + }); + + describe('addStorePattern', () => { + it('should add store pattern', async () => { + vi.mocked(receiptRepo.addStorePattern).mockResolvedValueOnce(createMockStorePatternRow()); + + await addStorePattern(1, 'name', 'WALMART', mockLogger, { priority: 1 }); + + expect(receiptRepo.addStorePattern).toHaveBeenCalledWith(1, 'name', 'WALMART', mockLogger, { + priority: 1, + }); + }); + }); + + describe('getActiveStorePatterns', () => { + it('should return active store patterns', async () => { + const mockPatterns = [ + createMockStorePatternRow({ + pattern_id: 1, + store_id: 1, + pattern_type: 'name', + pattern_value: 'WALMART', + }), + ]; + + vi.mocked(receiptRepo.getActiveStorePatterns).mockResolvedValueOnce(mockPatterns); + + const result = await getActiveStorePatterns(mockLogger); + + expect(result).toHaveLength(1); + }); + }); + + describe('processReceiptJob', () => { + it('should process receipt job successfully', async () => { + const mockReceipt = { + receipt_id: 1, + user_id: 'user-1', + store_id: null, + receipt_image_url: '/uploads/receipt.jpg', + transaction_date: null, + total_amount_cents: null, + status: 'pending' as ReceiptStatus, + raw_text: null, + store_confidence: null, + ocr_provider: null, + error_details: null, + retry_count: 0, + ocr_confidence: null, + currency: 'USD', + created_at: new Date().toISOString(), + processed_at: null, + updated_at: new Date().toISOString(), + }; + + vi.mocked(receiptRepo.getReceiptById).mockResolvedValueOnce(mockReceipt); + vi.mocked(receiptRepo.updateReceipt).mockResolvedValue({ + ...mockReceipt, + status: 'completed' as ReceiptStatus, + } as any); + vi.mocked(receiptRepo.logProcessingStep).mockResolvedValue(createMockProcessingLogRecord()); + vi.mocked(receiptRepo.detectStoreFromText).mockResolvedValueOnce(null); + vi.mocked(receiptRepo.addReceiptItems).mockResolvedValueOnce([]); + + const mockJob = { + id: 'job-1', + data: { + receiptId: 1, + userId: 'user-1', + meta: { requestId: 'req-1' }, + }, + attemptsMade: 0, + } as Job; + + const result = await processReceiptJob(mockJob, mockLogger); + + expect(result.success).toBe(true); + expect(result.receiptId).toBe(1); + }); + + it('should skip already completed receipts', async () => { + const mockReceipt = { + receipt_id: 1, + user_id: 'user-1', + store_id: null, + receipt_image_url: '/uploads/receipt.jpg', + transaction_date: null, + total_amount_cents: null, + status: 'completed' as ReceiptStatus, + raw_text: 'Previous text', + store_confidence: null, + ocr_provider: null, + error_details: null, + retry_count: 0, + ocr_confidence: null, + currency: 'USD', + created_at: new Date().toISOString(), + processed_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + vi.mocked(receiptRepo.getReceiptById).mockResolvedValueOnce(mockReceipt); + + const mockJob = { + id: 'job-2', + data: { + receiptId: 1, + userId: 'user-1', + }, + attemptsMade: 0, + } as Job; + + const result = await processReceiptJob(mockJob, mockLogger); + + expect(result.success).toBe(true); + expect(result.itemsFound).toBe(0); + expect(receiptRepo.updateReceipt).not.toHaveBeenCalled(); + }); + + it('should handle job processing errors', async () => { + const mockReceipt = { + receipt_id: 1, + user_id: 'user-1', + store_id: null, + receipt_image_url: '/uploads/receipt.jpg', + transaction_date: null, + total_amount_cents: null, + status: 'pending' as ReceiptStatus, + raw_text: null, + store_confidence: null, + ocr_provider: null, + error_details: null, + retry_count: 0, + ocr_confidence: null, + currency: 'USD', + created_at: new Date().toISOString(), + processed_at: null, + updated_at: new Date().toISOString(), + }; + + vi.mocked(receiptRepo.getReceiptById).mockResolvedValueOnce(mockReceipt); + vi.mocked(receiptRepo.updateReceipt) + .mockRejectedValueOnce(new Error('Processing failed')) + .mockResolvedValueOnce({ ...mockReceipt, status: 'failed' } as any); + vi.mocked(receiptRepo.incrementRetryCount).mockResolvedValueOnce(1); + vi.mocked(receiptRepo.logProcessingStep).mockResolvedValue(createMockProcessingLogRecord()); + + const mockJob = { + id: 'job-3', + data: { + receiptId: 1, + userId: 'user-1', + }, + attemptsMade: 1, + } as Job; + + await expect(processReceiptJob(mockJob, mockLogger)).rejects.toThrow('Processing failed'); + + expect(receiptRepo.updateReceipt).toHaveBeenCalledWith( + 1, + expect.objectContaining({ status: 'failed' }), + expect.any(Object), + ); + }); + }); +}); diff --git a/src/services/receiptService.server.ts b/src/services/receiptService.server.ts new file mode 100644 index 0000000..283b497 --- /dev/null +++ b/src/services/receiptService.server.ts @@ -0,0 +1,843 @@ +// src/services/receiptService.server.ts +/** + * @file Receipt Scanning Service + * Handles receipt image processing, OCR extraction, item parsing, and store detection. + * Integrates with expiry tracking for adding scanned items to inventory. + */ +import type { Logger } from 'pino'; +import { receiptRepo } from './db/index.db'; +import type { + OcrProvider, + ReceiptScan, + ReceiptItem, + ReceiptProcessingLogRecord, +} from '../types/expiry'; +import type { UpdateReceiptItemRequest, ReceiptQueryOptions } from './db/receipt.db'; + +/** + * Maximum number of retry attempts for failed receipt processing + */ +const MAX_RETRY_ATTEMPTS = 3; + +/** + * Default OCR provider to use + */ +const DEFAULT_OCR_PROVIDER: OcrProvider = 'internal'; + +// ============================================================================ +// RECEIPT MANAGEMENT +// ============================================================================ + +/** + * Creates a new receipt record for processing. + * @param userId The user's ID + * @param imageUrl URL or path to the receipt image + * @param logger Pino logger instance + * @param options Optional store ID and transaction date if known + * @returns The created receipt record + */ +export const createReceipt = async ( + userId: string, + imageUrl: string, + logger: Logger, + options: { storeId?: number; transactionDate?: string } = {}, +): Promise => { + logger.info({ userId, imageUrl }, 'Creating new receipt for processing'); + + const receipt = await receiptRepo.createReceipt( + { + user_id: userId, + receipt_image_url: imageUrl, + store_id: options.storeId, + transaction_date: options.transactionDate, + }, + logger, + ); + + // Log the upload step + await receiptRepo.logProcessingStep(receipt.receipt_id, 'upload', 'completed', logger, { + provider: DEFAULT_OCR_PROVIDER, + }); + + return receipt; +}; + +/** + * Gets a receipt by ID. + * @param receiptId The receipt ID + * @param userId The user's ID (for authorization) + * @param logger Pino logger instance + * @returns The receipt record + */ +export const getReceiptById = async ( + receiptId: number, + userId: string, + logger: Logger, +): Promise => { + return receiptRepo.getReceiptById(receiptId, userId, logger); +}; + +/** + * Gets receipts for a user with optional filtering. + * @param options Query options + * @param logger Pino logger instance + * @returns Paginated receipts + */ +export const getReceipts = async ( + options: ReceiptQueryOptions, + logger: Logger, +): Promise<{ receipts: ReceiptScan[]; total: number }> => { + logger.debug({ userId: options.user_id }, 'Fetching receipts'); + return receiptRepo.getReceipts(options, logger); +}; + +/** + * Deletes a receipt and all associated data. + * @param receiptId The receipt ID + * @param userId The user's ID (for authorization) + * @param logger Pino logger instance + */ +export const deleteReceipt = async ( + receiptId: number, + userId: string, + logger: Logger, +): Promise => { + logger.info({ receiptId, userId }, 'Deleting receipt'); + await receiptRepo.deleteReceipt(receiptId, userId, logger); +}; + +// ============================================================================ +// RECEIPT PROCESSING +// ============================================================================ + +/** + * Processes a receipt through OCR and item extraction. + * This is the main entry point for receipt processing, typically called by a worker. + * @param receiptId The receipt ID to process + * @param logger Pino logger instance + * @returns The processed receipt with extracted items + */ +export const processReceipt = async ( + receiptId: number, + logger: Logger, +): Promise<{ receipt: ReceiptScan; items: ReceiptItem[] }> => { + const processLogger = logger.child({ receiptId }); + processLogger.info('Starting receipt processing'); + + const startTime = Date.now(); + + try { + // Update status to processing + let receipt = await receiptRepo.updateReceipt( + receiptId, + { status: 'processing' }, + processLogger, + ); + + // Step 1: OCR Extraction + processLogger.debug('Starting OCR extraction'); + const ocrResult = await performOcrExtraction(receipt.receipt_image_url, processLogger); + + await receiptRepo.logProcessingStep(receiptId, 'ocr_extraction', 'completed', processLogger, { + provider: ocrResult.provider, + durationMs: ocrResult.durationMs, + outputData: { textLength: ocrResult.text.length, confidence: ocrResult.confidence }, + }); + + // Update receipt with OCR results + receipt = await receiptRepo.updateReceipt( + receiptId, + { + raw_text: ocrResult.text, + ocr_provider: ocrResult.provider, + ocr_confidence: ocrResult.confidence, + }, + processLogger, + ); + + // Step 2: Store Detection (if not already set) + if (!receipt.store_id) { + processLogger.debug('Attempting store detection'); + const storeDetection = await receiptRepo.detectStoreFromText(ocrResult.text, processLogger); + + if (storeDetection) { + receipt = await receiptRepo.updateReceipt( + receiptId, + { + store_id: storeDetection.store_id, + store_confidence: storeDetection.confidence, + }, + processLogger, + ); + + await receiptRepo.logProcessingStep( + receiptId, + 'store_detection', + 'completed', + processLogger, + { + outputData: { storeId: storeDetection.store_id, confidence: storeDetection.confidence }, + }, + ); + } else { + await receiptRepo.logProcessingStep( + receiptId, + 'store_detection', + 'completed', + processLogger, + { + outputData: { storeId: null, message: 'No store match found' }, + }, + ); + } + } + + // Step 3: Parse receipt text and extract items + // If AI extracted items directly, use those; otherwise fall back to text parsing + processLogger.debug('Starting text parsing and item extraction'); + const parseStartTime = Date.now(); + + let itemsToAdd: Array<{ + receipt_id: number; + raw_item_description: string; + quantity: number; + price_paid_cents: number; + line_number: number; + is_discount: boolean; + unit_price_cents?: number; + unit_type?: string; + }>; + + if (ocrResult.extractedItems && ocrResult.extractedItems.length > 0) { + // Use AI-extracted items directly (more accurate) + processLogger.info( + { itemCount: ocrResult.extractedItems.length }, + 'Using AI-extracted items directly', + ); + itemsToAdd = ocrResult.extractedItems.map((item, index) => ({ + receipt_id: receiptId, + raw_item_description: item.raw_item_description, + quantity: 1, // AI doesn't extract quantity separately yet + price_paid_cents: item.price_paid_cents, + line_number: index + 1, + is_discount: item.price_paid_cents < 0, + })); + } else { + // Fall back to text parsing + const extractedItems = await parseReceiptText(ocrResult.text, processLogger); + itemsToAdd = extractedItems.map((item) => ({ + receipt_id: receiptId, + raw_item_description: item.description, + quantity: item.quantity, + price_paid_cents: item.priceCents, + line_number: item.lineNumber, + is_discount: item.isDiscount, + unit_price_cents: item.unitPriceCents, + unit_type: item.unitType, + })); + } + + await receiptRepo.logProcessingStep(receiptId, 'text_parsing', 'completed', processLogger, { + durationMs: Date.now() - parseStartTime, + outputData: { itemCount: itemsToAdd.length, usedAiExtraction: !!ocrResult.extractedItems }, + }); + + // Step 4: Add extracted items to database + const items = await receiptRepo.addReceiptItems(itemsToAdd, processLogger); + + await receiptRepo.logProcessingStep(receiptId, 'item_extraction', 'completed', processLogger, { + outputData: { itemsAdded: items.length }, + }); + + // Step 5: Extract total and transaction date + const receiptMetadata = extractReceiptMetadata(ocrResult.text, processLogger); + + if (receiptMetadata.totalCents || receiptMetadata.transactionDate) { + receipt = await receiptRepo.updateReceipt( + receiptId, + { + total_amount_cents: receiptMetadata.totalCents, + transaction_date: receiptMetadata.transactionDate, + }, + processLogger, + ); + } + + // Step 6: Mark as completed + receipt = await receiptRepo.updateReceipt( + receiptId, + { + status: 'completed', + processed_at: new Date().toISOString(), + }, + processLogger, + ); + + await receiptRepo.logProcessingStep(receiptId, 'finalization', 'completed', processLogger, { + durationMs: Date.now() - startTime, + outputData: { totalItems: items.length, status: 'completed' }, + }); + + processLogger.info( + { receiptId, itemCount: items.length, durationMs: Date.now() - startTime }, + 'Receipt processing completed successfully', + ); + + return { receipt, items }; + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + processLogger.error({ err, receiptId }, 'Receipt processing failed'); + + // Increment retry count and update status + const _retryCount = await receiptRepo.incrementRetryCount(receiptId, processLogger); + + await receiptRepo.updateReceipt( + receiptId, + { + status: 'failed', + error_details: { + message: err.message, + stack: err.stack, + timestamp: new Date().toISOString(), + }, + }, + processLogger, + ); + + await receiptRepo.logProcessingStep(receiptId, 'finalization', 'failed', processLogger, { + durationMs: Date.now() - startTime, + errorMessage: err.message, + }); + + throw err; + } +}; + +/** + * Performs OCR extraction on a receipt image using Gemini Vision API. + * Falls back to basic text extraction if AI is not configured. + * @param imageUrl URL or path to the receipt image + * @param logger Pino logger instance + * @returns OCR extraction result + */ +const performOcrExtraction = async ( + imageUrl: string, + logger: Logger, +): Promise<{ + text: string; + provider: OcrProvider; + confidence: number; + durationMs: number; + extractedItems?: Array<{ raw_item_description: string; price_paid_cents: number }>; +}> => { + const startTime = Date.now(); + + // Check if AI services are configured + if (!isAiConfigured) { + logger.warn({ imageUrl }, 'AI not configured - OCR extraction unavailable'); + return { + text: '[AI not configured - please set GEMINI_API_KEY]', + provider: 'internal', + confidence: 0, + durationMs: Date.now() - startTime, + }; + } + + try { + // Determine if imageUrl is a local file path or URL + const isLocalPath = !imageUrl.startsWith('http'); + + if (!isLocalPath) { + logger.warn({ imageUrl }, 'Remote URLs not yet supported for OCR - use local file path'); + return { + text: '[Remote URL OCR not yet implemented - upload file directly]', + provider: 'internal', + confidence: 0, + durationMs: Date.now() - startTime, + }; + } + + // Determine MIME type from extension + const ext = path.extname(imageUrl).toLowerCase(); + const mimeTypeMap: Record = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + }; + const mimeType = mimeTypeMap[ext] || 'image/jpeg'; + + // Verify file exists + try { + await fs.access(imageUrl); + } catch { + logger.error({ imageUrl }, 'Receipt image file not found'); + return { + text: '[Receipt image file not found]', + provider: 'internal', + confidence: 0, + durationMs: Date.now() - startTime, + }; + } + + logger.info({ imageUrl, mimeType }, 'Starting OCR extraction with Gemini Vision'); + + // Use the AI service to extract items from the receipt + const extractedItems = await aiService.extractItemsFromReceiptImage(imageUrl, mimeType, logger); + + if (!extractedItems || extractedItems.length === 0) { + logger.warn({ imageUrl }, 'No items extracted from receipt image'); + return { + text: '[No text could be extracted from receipt]', + provider: 'gemini', + confidence: 0.3, + durationMs: Date.now() - startTime, + }; + } + + // Convert extracted items to text representation for storage + const textLines = extractedItems.map( + (item) => `${item.raw_item_description} - $${(item.price_paid_cents / 100).toFixed(2)}`, + ); + const extractedText = textLines.join('\n'); + + logger.info( + { imageUrl, itemCount: extractedItems.length, durationMs: Date.now() - startTime }, + 'OCR extraction completed successfully', + ); + + return { + text: extractedText, + provider: 'gemini', + confidence: 0.85, + durationMs: Date.now() - startTime, + extractedItems, // Pass along for direct use + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error({ err: error, imageUrl }, 'OCR extraction failed'); + + return { + text: `[OCR extraction failed: ${errorMessage}]`, + provider: 'internal', + confidence: 0, + durationMs: Date.now() - startTime, + }; + } +}; + +/** + * Parses receipt text to extract individual line items. + * @param text Raw OCR text from receipt + * @param logger Pino logger instance + * @returns Array of extracted items + */ +const parseReceiptText = async ( + text: string, + logger: Logger, +): Promise< + Array<{ + description: string; + quantity: number; + priceCents: number; + lineNumber: number; + isDiscount: boolean; + unitPriceCents?: number; + unitType?: string; + }> +> => { + // TODO: Implement actual receipt text parsing + // This would use regex patterns and/or ML to: + // - Identify item lines vs headers/footers + // - Extract item names, quantities, and prices + // - Detect discount/coupon lines + // - Handle multi-line items + + logger.debug({ textLength: text.length }, 'Parsing receipt text'); + + // Common receipt patterns to look for: + // - "ITEM NAME $X.XX" + // - "2 @ $X.XX $Y.YY" + // - "DISCOUNT -$X.XX" + + const items: Array<{ + description: string; + quantity: number; + priceCents: number; + lineNumber: number; + isDiscount: boolean; + unitPriceCents?: number; + unitType?: string; + }> = []; + + // Simple line-by-line parsing as placeholder + const lines = text.split('\n').filter((line) => line.trim()); + + // Pattern for price at end of line: $X.XX or X.XX + const pricePattern = /\$?(\d+)\.(\d{2})\s*$/; + // Pattern for quantity: "2 @" or "2x" or just a number at start + const quantityPattern = /^(\d+)\s*[@xX]/; + + let lineNumber = 0; + for (const line of lines) { + lineNumber++; + const trimmedLine = line.trim(); + + // Skip empty lines and common receipt headers/footers + if (!trimmedLine || isHeaderOrFooter(trimmedLine)) { + continue; + } + + const priceMatch = trimmedLine.match(pricePattern); + if (priceMatch) { + const dollars = parseInt(priceMatch[1], 10); + const cents = parseInt(priceMatch[2], 10); + let priceCents = dollars * 100 + cents; + + // Check if it's a discount (negative) + const isDiscount = + trimmedLine.includes('-') || trimmedLine.toLowerCase().includes('discount'); + if (isDiscount) { + priceCents = -Math.abs(priceCents); + } + + // Extract description (everything before the price) + let description = trimmedLine.replace(pricePattern, '').trim(); + let quantity = 1; + + // Check for quantity pattern + const quantityMatch = description.match(quantityPattern); + if (quantityMatch) { + quantity = parseInt(quantityMatch[1], 10); + description = description.replace(quantityPattern, '').trim(); + } + + // Clean up description + description = description.replace(/[-]+\s*$/, '').trim(); + + if (description) { + items.push({ + description, + quantity, + priceCents, + lineNumber, + isDiscount, + }); + } + } + } + + logger.debug({ extractedCount: items.length }, 'Receipt text parsing complete'); + return items; +}; + +/** + * Checks if a line is likely a header or footer to skip. + */ +const isHeaderOrFooter = (line: string): boolean => { + const lowercaseLine = line.toLowerCase(); + const skipPatterns = [ + 'thank you', + 'thanks for', + 'visit us', + 'total', + 'subtotal', + 'tax', + 'change', + 'cash', + 'credit', + 'debit', + 'visa', + 'mastercard', + 'approved', + 'transaction', + 'terminal', + 'receipt', + 'store #', + 'date:', + 'time:', + 'cashier', + ]; + + return skipPatterns.some((pattern) => lowercaseLine.includes(pattern)); +}; + +/** + * Extracts metadata from receipt text (total, date, etc.). + */ +const extractReceiptMetadata = ( + text: string, + logger: Logger, +): { + totalCents?: number; + transactionDate?: string; +} => { + const result: { totalCents?: number; transactionDate?: string } = {}; + + // Look for total amount + const totalPatterns = [ + /total[:\s]+\$?(\d+)\.(\d{2})/i, + /grand total[:\s]+\$?(\d+)\.(\d{2})/i, + /amount due[:\s]+\$?(\d+)\.(\d{2})/i, + ]; + + for (const pattern of totalPatterns) { + const match = text.match(pattern); + if (match) { + result.totalCents = parseInt(match[1], 10) * 100 + parseInt(match[2], 10); + break; + } + } + + // Look for transaction date + const datePatterns = [ + /(\d{1,2})\/(\d{1,2})\/(\d{2,4})/, // MM/DD/YYYY or M/D/YY + /(\d{4})-(\d{2})-(\d{2})/, // YYYY-MM-DD + ]; + + for (const pattern of datePatterns) { + const match = text.match(pattern); + if (match) { + // Try to parse the date + try { + let year: number; + let month: number; + let day: number; + + if (match[0].includes('-')) { + // YYYY-MM-DD format + year = parseInt(match[1], 10); + month = parseInt(match[2], 10); + day = parseInt(match[3], 10); + } else { + // MM/DD/YYYY format + month = parseInt(match[1], 10); + day = parseInt(match[2], 10); + year = parseInt(match[3], 10); + if (year < 100) { + year += 2000; + } + } + + const date = new Date(year, month - 1, day); + if (!isNaN(date.getTime())) { + result.transactionDate = date.toISOString().split('T')[0]; + break; + } + } catch { + // Continue to next pattern + } + } + } + + logger.debug({ result }, 'Extracted receipt metadata'); + return result; +}; + +// ============================================================================ +// RECEIPT ITEMS +// ============================================================================ + +/** + * Gets all items for a receipt. + * @param receiptId The receipt ID + * @param logger Pino logger instance + * @returns Array of receipt items + */ +export const getReceiptItems = async ( + receiptId: number, + logger: Logger, +): Promise => { + return receiptRepo.getReceiptItems(receiptId, logger); +}; + +/** + * Updates a receipt item (e.g., after manual matching). + * @param receiptItemId The receipt item ID + * @param updates Updates to apply + * @param logger Pino logger instance + * @returns Updated receipt item + */ +export const updateReceiptItem = async ( + receiptItemId: number, + updates: UpdateReceiptItemRequest, + logger: Logger, +): Promise => { + return receiptRepo.updateReceiptItem(receiptItemId, updates, logger); +}; + +/** + * Gets receipt items that haven't been added to inventory. + * @param receiptId The receipt ID + * @param logger Pino logger instance + * @returns Array of unadded items + */ +export const getUnaddedItems = async ( + receiptId: number, + logger: Logger, +): Promise => { + return receiptRepo.getUnaddedReceiptItems(receiptId, logger); +}; + +// ============================================================================ +// PROCESSING LOGS AND STATS +// ============================================================================ + +/** + * Gets processing logs for a receipt. + * @param receiptId The receipt ID + * @param logger Pino logger instance + * @returns Array of processing log records + */ +export const getProcessingLogs = async ( + receiptId: number, + logger: Logger, +): Promise => { + return receiptRepo.getProcessingLogs(receiptId, logger); +}; + +/** + * Gets receipt processing statistics. + * @param logger Pino logger instance + * @param options Date range options + * @returns Processing statistics + */ +export const getProcessingStats = async ( + logger: Logger, + options: { fromDate?: string; toDate?: string } = {}, +): Promise<{ + total_receipts: number; + completed: number; + failed: number; + pending: number; + avg_processing_time_ms: number; + total_cost_cents: number; +}> => { + return receiptRepo.getProcessingStats(logger, options); +}; + +/** + * Gets receipts that need processing (for worker). + * @param limit Maximum number of receipts to return + * @param logger Pino logger instance + * @returns Array of receipts needing processing + */ +export const getReceiptsNeedingProcessing = async ( + limit: number, + logger: Logger, +): Promise => { + return receiptRepo.getReceiptsNeedingProcessing(MAX_RETRY_ATTEMPTS, limit, logger); +}; + +// ============================================================================ +// STORE PATTERNS (Admin) +// ============================================================================ + +/** + * Adds a new store receipt pattern for detection. + * @param storeId The store ID + * @param patternType The pattern type + * @param patternValue The pattern value + * @param logger Pino logger instance + * @param options Additional options + */ +export const addStorePattern = async ( + storeId: number, + patternType: string, + patternValue: string, + logger: Logger, + options: { priority?: number } = {}, +) => { + return receiptRepo.addStorePattern(storeId, patternType, patternValue, logger, options); +}; + +/** + * Gets all active store patterns. + * @param logger Pino logger instance + */ +export const getActiveStorePatterns = async (logger: Logger) => { + return receiptRepo.getActiveStorePatterns(logger); +}; + +// ============================================================================ +// JOB PROCESSING +// ============================================================================ + +import type { Job } from 'bullmq'; +import type { ReceiptJobData } from '../types/job-data'; +import { aiService } from './aiService.server'; +import { isAiConfigured } from '../config/env'; +import path from 'node:path'; +import fs from 'node:fs/promises'; + +/** + * Processes a receipt processing job from the queue. + * This is the main entry point for background receipt processing. + * @param job The BullMQ job + * @param logger Pino logger instance + * @returns Processing result + */ +export const processReceiptJob = async ( + job: Job, + logger: Logger, +): Promise<{ success: boolean; itemsFound: number; receiptId: number }> => { + const { receiptId, userId } = job.data; + const jobLogger = logger.child({ + jobId: job.id, + receiptId, + userId, + requestId: job.data.meta?.requestId, + }); + + jobLogger.info('Starting receipt processing job'); + + try { + // Get the receipt record to verify ownership and status + const existingReceipt = await receiptRepo.getReceiptById(receiptId, userId, jobLogger); + + if (existingReceipt.status === 'completed') { + jobLogger.info('Receipt already processed, skipping'); + return { success: true, itemsFound: 0, receiptId }; + } + + // Process the receipt (this handles status updates internally) + const result = await processReceipt(receiptId, jobLogger); + + const itemsFound = result.items.length; + const isSuccess = result.receipt.status === 'completed'; + + jobLogger.info( + { itemsFound, status: result.receipt.status }, + 'Receipt processing job completed', + ); + + return { + success: isSuccess, + itemsFound, + receiptId, + }; + } catch (error) { + jobLogger.error({ err: error }, 'Receipt processing job failed'); + + // Update receipt status to failed + try { + await receiptRepo.updateReceipt( + receiptId, + { + status: 'failed', + error_details: { + error: error instanceof Error ? error.message : String(error), + jobId: job.id, + attemptsMade: job.attemptsMade, + }, + }, + jobLogger, + ); + } catch (updateError) { + jobLogger.error({ err: updateError }, 'Failed to update receipt status after error'); + } + + throw error; + } +}; diff --git a/src/services/sentry.client.ts b/src/services/sentry.client.ts new file mode 100644 index 0000000..84e90c4 --- /dev/null +++ b/src/services/sentry.client.ts @@ -0,0 +1,124 @@ +// src/services/sentry.client.ts +/** + * Sentry SDK initialization for client-side error tracking. + * Implements ADR-015: Application Performance Monitoring and Error Tracking. + * + * This module configures @sentry/react to send errors to our self-hosted + * Bugsink instance, which is Sentry-compatible. + * + * IMPORTANT: This module should be imported and initialized at the very top + * of index.tsx, before any other imports, to ensure all errors are captured. + */ +import * as Sentry from '@sentry/react'; +import config from '../config'; +import { logger } from './logger.client'; + +/** Whether Sentry is properly configured (DSN present and enabled) */ +export const isSentryConfigured = !!config.sentry.dsn && config.sentry.enabled; + +/** + * Initializes the Sentry SDK for the browser. + * Should be called once at application startup. + */ +export function initSentry(): void { + if (!isSentryConfigured) { + logger.info('[Sentry] Error tracking disabled (VITE_SENTRY_DSN not configured)'); + return; + } + + Sentry.init({ + dsn: config.sentry.dsn, + environment: config.sentry.environment, + debug: config.sentry.debug, + + // Performance monitoring - disabled for now to keep it simple + tracesSampleRate: 0, + + // Capture console.error as breadcrumbs + integrations: [ + Sentry.breadcrumbsIntegration({ + console: true, + dom: true, + fetch: true, + history: true, + xhr: true, + }), + ], + + // Filter out development-only errors and noise + beforeSend(event) { + // Skip errors from browser extensions + if ( + event.exception?.values?.[0]?.stacktrace?.frames?.some((frame) => + frame.filename?.includes('extension://'), + ) + ) { + return null; + } + return event; + }, + }); + + logger.info(`[Sentry] Error tracking initialized (${config.sentry.environment})`); +} + +/** + * Captures an exception and sends it to Sentry. + * Use this for errors that are caught and handled gracefully. + */ +export function captureException( + error: Error, + context?: Record, +): string | undefined { + if (!isSentryConfigured) { + return undefined; + } + + if (context) { + Sentry.setContext('additional', context); + } + + return Sentry.captureException(error); +} + +/** + * Captures a message and sends it to Sentry. + * Use this for non-exception events that should be tracked. + */ +export function captureMessage( + message: string, + level: Sentry.SeverityLevel = 'info', +): string | undefined { + if (!isSentryConfigured) { + return undefined; + } + + return Sentry.captureMessage(message, level); +} + +/** + * Sets the user context for all subsequent events. + * Call this after user authentication. + */ +export function setUser(user: { id: string; email?: string; username?: string } | null): void { + if (!isSentryConfigured) { + return; + } + + Sentry.setUser(user); +} + +/** + * Adds a breadcrumb to the current scope. + * Breadcrumbs are logged actions that led up to an error. + */ +export function addBreadcrumb(breadcrumb: Sentry.Breadcrumb): void { + if (!isSentryConfigured) { + return; + } + + Sentry.addBreadcrumb(breadcrumb); +} + +// Re-export Sentry for advanced usage (Error Boundary, etc.) +export { Sentry }; diff --git a/src/services/sentry.server.ts b/src/services/sentry.server.ts new file mode 100644 index 0000000..cea4473 --- /dev/null +++ b/src/services/sentry.server.ts @@ -0,0 +1,161 @@ +// src/services/sentry.server.ts +/** + * Sentry SDK initialization for error tracking. + * Implements ADR-015: Application Performance Monitoring and Error Tracking. + * + * This module configures @sentry/node to send errors to our self-hosted + * Bugsink instance, which is Sentry-compatible. + * + * IMPORTANT: This module should be imported and initialized at the very top + * of server.ts, before any other imports, to ensure all errors are captured. + * + * Note: Uses Sentry SDK v8+ API which differs significantly from v7. + */ +import * as Sentry from '@sentry/node'; +import type { Request, Response, NextFunction, ErrorRequestHandler } from 'express'; +import { config, isSentryConfigured, isProduction, isTest } from '../config/env'; +import { logger } from './logger.server'; + +/** + * Initializes the Sentry SDK with the configured DSN. + * Should be called once at application startup. + */ +export function initSentry(): void { + if (!isSentryConfigured) { + logger.info('[Sentry] Error tracking disabled (SENTRY_DSN not configured)'); + return; + } + + // Don't initialize Sentry in test environment + if (isTest) { + logger.debug('[Sentry] Skipping initialization in test environment'); + return; + } + + Sentry.init({ + dsn: config.sentry.dsn, + environment: config.sentry.environment || config.server.nodeEnv, + debug: config.sentry.debug, + + // Performance monitoring - disabled for now to keep it simple + tracesSampleRate: 0, + + // Before sending an event, add additional context + beforeSend(event, hint) { + // In development, log errors to console as well + if (!isProduction && hint.originalException) { + logger.error( + { err: hint.originalException, sentryEventId: event.event_id }, + '[Sentry] Capturing error', + ); + } + return event; + }, + }); + + logger.info( + { environment: config.sentry.environment || config.server.nodeEnv }, + '[Sentry] Error tracking initialized', + ); +} + +/** + * Creates Sentry middleware for Express. + * Returns the request handler and error handler middleware. + * + * In Sentry SDK v8+, the old Handlers.requestHandler and Handlers.errorHandler + * have been replaced. Request context is now captured automatically via the + * Express integration. We provide a custom error handler that filters errors. + */ +export function getSentryMiddleware(): { + requestHandler: (req: Request, res: Response, next: NextFunction) => void; + errorHandler: ErrorRequestHandler; +} { + if (!isSentryConfigured || isTest) { + // Return no-op middleware when Sentry is not configured + return { + requestHandler: (_req: Request, _res: Response, next: NextFunction) => next(), + errorHandler: (_err: Error, _req: Request, _res: Response, next: NextFunction) => next(_err), + }; + } + + return { + // In SDK v8+, request context is captured automatically. + // This middleware is a placeholder for compatibility. + requestHandler: (_req: Request, _res: Response, next: NextFunction) => next(), + + // Custom error handler that captures errors to Sentry + errorHandler: (err: Error, _req: Request, _res: Response, next: NextFunction) => { + // Only send 5xx errors to Sentry by default + const statusCode = + (err as Error & { statusCode?: number }).statusCode || + (err as Error & { status?: number }).status || + 500; + + if (statusCode >= 500) { + Sentry.captureException(err); + } + + // Pass the error to the next error handler + next(err); + }, + }; +} + +/** + * Captures an exception and sends it to Sentry. + * Use this for errors that are caught and handled gracefully. + */ +export function captureException(error: Error, context?: Record): string | null { + if (!isSentryConfigured || isTest) { + return null; + } + + if (context) { + Sentry.setContext('additional', context); + } + + return Sentry.captureException(error); +} + +/** + * Captures a message and sends it to Sentry. + * Use this for non-exception events that should be tracked. + */ +export function captureMessage( + message: string, + level: Sentry.SeverityLevel = 'info', +): string | null { + if (!isSentryConfigured || isTest) { + return null; + } + + return Sentry.captureMessage(message, level); +} + +/** + * Sets the user context for all subsequent events. + * Call this after user authentication. + */ +export function setUser(user: { id: string; email?: string; username?: string } | null): void { + if (!isSentryConfigured || isTest) { + return; + } + + Sentry.setUser(user); +} + +/** + * Adds a breadcrumb to the current scope. + * Breadcrumbs are logged actions that led up to an error. + */ +export function addBreadcrumb(breadcrumb: Sentry.Breadcrumb): void { + if (!isSentryConfigured || isTest) { + return; + } + + Sentry.addBreadcrumb(breadcrumb); +} + +// Re-export Sentry for advanced usage +export { Sentry }; diff --git a/src/services/upcService.server.test.ts b/src/services/upcService.server.test.ts new file mode 100644 index 0000000..2c50a0c --- /dev/null +++ b/src/services/upcService.server.test.ts @@ -0,0 +1,674 @@ +// src/services/upcService.server.test.ts +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Logger } from 'pino'; +import { createMockLogger } from '../tests/utils/mockLogger'; +import type { UpcScanSource, UpcExternalLookupRecord, UpcExternalSource } from '../types/upc'; + +// Mock dependencies +vi.mock('./db/index.db', () => ({ + upcRepo: { + recordScan: vi.fn(), + findProductByUpc: vi.fn(), + findExternalLookup: vi.fn(), + upsertExternalLookup: vi.fn(), + linkUpcToProduct: vi.fn(), + getScanHistory: vi.fn(), + getUserScanStats: vi.fn(), + getScanById: vi.fn(), + }, +})); + +vi.mock('../config/env', () => ({ + config: { + upc: { + upcItemDbApiKey: undefined, + barcodeLookupApiKey: undefined, + }, + }, + isUpcItemDbConfigured: false, + isBarcodeLookupConfigured: false, +})); + +vi.mock('./logger.server', () => ({ + logger: { + child: vi.fn().mockReturnThis(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +// Mock global fetch +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +// Import after mocks are set up +import { + isValidUpcCode, + normalizeUpcCode, + detectBarcodeFromImage, + lookupExternalUpc, + scanUpc, + lookupUpc, + linkUpcToProduct, + getScanHistory, + getScanStats, + getScanById, +} from './upcService.server'; + +import { upcRepo } from './db/index.db'; + +// Helper to create mock UpcExternalLookupRecord +function createMockExternalLookupRecord( + overrides: Partial = {}, +): UpcExternalLookupRecord { + return { + lookup_id: 1, + upc_code: '012345678905', + product_name: null, + brand_name: null, + category: null, + description: null, + image_url: null, + external_source: 'openfoodfacts' as UpcExternalSource, + lookup_data: null, + lookup_successful: false, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + ...overrides, + }; +} + +// Helper to create mock ProductRow (from db layer - matches upc.db.ts) +interface ProductRow { + product_id: number; + name: string; + brand_id: number | null; + category_id: number | null; + description: string | null; + size: string | null; + upc_code: string | null; + master_item_id: number | null; + created_at: string; + updated_at: string; +} + +function createMockProductRow(overrides: Partial = {}): ProductRow { + return { + product_id: 1, + name: 'Test Product', + brand_id: null, + category_id: null, + description: null, + size: null, + upc_code: '012345678905', + master_item_id: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + ...overrides, + }; +} + +describe('upcService.server', () => { + let mockLogger: Logger; + + beforeEach(() => { + vi.clearAllMocks(); + mockLogger = createMockLogger(); + mockFetch.mockReset(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe('isValidUpcCode', () => { + it('should return true for valid 12-digit UPC-A', () => { + expect(isValidUpcCode('012345678905')).toBe(true); + }); + + it('should return true for valid 8-digit UPC-E', () => { + expect(isValidUpcCode('01234567')).toBe(true); + }); + + it('should return true for valid 13-digit EAN-13', () => { + expect(isValidUpcCode('5901234123457')).toBe(true); + }); + + it('should return true for valid 8-digit EAN-8', () => { + expect(isValidUpcCode('96385074')).toBe(true); + }); + + it('should return true for valid 14-digit GTIN-14', () => { + expect(isValidUpcCode('00012345678905')).toBe(true); + }); + + it('should return false for code with less than 8 digits', () => { + expect(isValidUpcCode('1234567')).toBe(false); + }); + + it('should return false for code with more than 14 digits', () => { + expect(isValidUpcCode('123456789012345')).toBe(false); + }); + + it('should return false for code with non-numeric characters', () => { + expect(isValidUpcCode('01234567890A')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(isValidUpcCode('')).toBe(false); + }); + }); + + describe('normalizeUpcCode', () => { + it('should remove spaces from UPC code', () => { + expect(normalizeUpcCode('012 345 678 905')).toBe('012345678905'); + }); + + it('should remove dashes from UPC code', () => { + expect(normalizeUpcCode('012-345-678-905')).toBe('012345678905'); + }); + + it('should remove mixed spaces and dashes', () => { + expect(normalizeUpcCode('012-345 678-905')).toBe('012345678905'); + }); + + it('should return unchanged if no spaces or dashes', () => { + expect(normalizeUpcCode('012345678905')).toBe('012345678905'); + }); + }); + + describe('detectBarcodeFromImage', () => { + it('should return not implemented error', async () => { + const result = await detectBarcodeFromImage('base64imagedata', mockLogger); + + expect(result.detected).toBe(false); + expect(result.upc_code).toBeNull(); + expect(result.error).toBe( + 'Barcode detection from images is not yet implemented. Please use manual entry.', + ); + }); + }); + + describe('lookupExternalUpc', () => { + it('should return product info from Open Food Facts on success', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + status: 1, + product: { + product_name: 'Test Product', + brands: 'Test Brand', + categories_tags: ['en:snacks'], + ingredients_text: 'Test ingredients', + image_url: 'https://example.com/image.jpg', + }, + }), + }); + + const result = await lookupExternalUpc('012345678905', mockLogger); + + expect(result).not.toBeNull(); + expect(result?.name).toBe('Test Product'); + expect(result?.brand).toBe('Test Brand'); + expect(result?.source).toBe('openfoodfacts'); + }); + + it('should return null when Open Food Facts returns status 0', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + status: 0, + product: null, + }), + }); + + const result = await lookupExternalUpc('012345678905', mockLogger); + + expect(result).toBeNull(); + }); + + it('should return null when Open Food Facts request fails', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + }); + + const result = await lookupExternalUpc('012345678905', mockLogger); + + expect(result).toBeNull(); + }); + + it('should return null on network error', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + const result = await lookupExternalUpc('012345678905', mockLogger); + + expect(result).toBeNull(); + }); + + it('should use generic_name when product_name is missing', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + status: 1, + product: { + generic_name: 'Generic Product Name', + brands: null, + }, + }), + }); + + const result = await lookupExternalUpc('012345678905', mockLogger); + + expect(result?.name).toBe('Generic Product Name'); + }); + }); + + describe('scanUpc', () => { + it('should scan with manual entry and return product from database', async () => { + const mockProduct = { + product_id: 1, + name: 'Test Product', + brand: 'Test Brand', + category: 'Snacks', + description: null, + size: '100g', + upc_code: '012345678905', + image_url: null, + master_item_id: null, + }; + + vi.mocked(upcRepo.findProductByUpc).mockResolvedValueOnce(mockProduct); + vi.mocked(upcRepo.recordScan).mockResolvedValueOnce({ + scan_id: 1, + user_id: 'user-1', + upc_code: '012345678905', + product_id: 1, + scan_source: 'manual_entry', + scan_confidence: 1.0, + raw_image_path: null, + lookup_successful: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }); + + const result = await scanUpc( + 'user-1', + { upc_code: '012345678905', scan_source: 'manual_entry' }, + mockLogger, + ); + + expect(result.upc_code).toBe('012345678905'); + expect(result.product).toEqual(mockProduct); + expect(result.lookup_successful).toBe(true); + expect(result.is_new_product).toBe(false); + expect(result.confidence).toBe(1.0); + }); + + it('should scan with manual entry and perform external lookup when not in database', async () => { + vi.mocked(upcRepo.findProductByUpc).mockResolvedValueOnce(null); + vi.mocked(upcRepo.findExternalLookup).mockResolvedValueOnce(null); + vi.mocked(upcRepo.upsertExternalLookup).mockResolvedValueOnce( + createMockExternalLookupRecord(), + ); + vi.mocked(upcRepo.recordScan).mockResolvedValueOnce({ + scan_id: 2, + user_id: 'user-1', + upc_code: '012345678905', + product_id: null, + scan_source: 'manual_entry', + scan_confidence: 1.0, + raw_image_path: null, + lookup_successful: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + status: 1, + product: { + product_name: 'External Product', + brands: 'External Brand', + }, + }), + }); + + const result = await scanUpc( + 'user-1', + { upc_code: '012345678905', scan_source: 'manual_entry' }, + mockLogger, + ); + + expect(result.product).toBeNull(); + expect(result.external_lookup).not.toBeNull(); + expect(result.external_lookup?.name).toBe('External Product'); + expect(result.is_new_product).toBe(true); + }); + + it('should use cached external lookup when available', async () => { + vi.mocked(upcRepo.findProductByUpc).mockResolvedValueOnce(null); + vi.mocked(upcRepo.findExternalLookup).mockResolvedValueOnce({ + lookup_id: 1, + upc_code: '012345678905', + product_name: 'Cached Product', + brand_name: 'Cached Brand', + category: 'Cached Category', + description: null, + image_url: null, + external_source: 'openfoodfacts', + lookup_data: null, + lookup_successful: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }); + vi.mocked(upcRepo.recordScan).mockResolvedValueOnce({ + scan_id: 3, + user_id: 'user-1', + upc_code: '012345678905', + product_id: null, + scan_source: 'manual_entry', + scan_confidence: 1.0, + raw_image_path: null, + lookup_successful: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }); + + const result = await scanUpc( + 'user-1', + { upc_code: '012345678905', scan_source: 'manual_entry' }, + mockLogger, + ); + + expect(result.external_lookup?.name).toBe('Cached Product'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should throw error for invalid UPC code format', async () => { + await expect( + scanUpc('user-1', { upc_code: 'invalid', scan_source: 'manual_entry' }, mockLogger), + ).rejects.toThrow('Invalid UPC code format. UPC codes must be 8-14 digits.'); + }); + + it('should throw error when neither upc_code nor image_base64 provided', async () => { + await expect( + scanUpc('user-1', { scan_source: 'manual_entry' } as any, mockLogger), + ).rejects.toThrow('Either upc_code or image_base64 must be provided.'); + }); + + it('should record failed scan when image detection fails', async () => { + vi.mocked(upcRepo.recordScan).mockResolvedValueOnce({ + scan_id: 4, + user_id: 'user-1', + upc_code: 'DETECTION_FAILED', + product_id: null, + scan_source: 'image_upload', + scan_confidence: 0, + raw_image_path: null, + lookup_successful: false, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }); + + const result = await scanUpc( + 'user-1', + { image_base64: 'base64data', scan_source: 'image_upload' }, + mockLogger, + ); + + expect(result.lookup_successful).toBe(false); + expect(result.confidence).toBe(0); + }); + }); + + describe('lookupUpc', () => { + it('should return product from database when found', async () => { + const mockProduct = { + product_id: 1, + name: 'Test Product', + brand: 'Test Brand', + category: 'Snacks', + description: null, + size: '100g', + upc_code: '012345678905', + image_url: null, + master_item_id: null, + }; + + vi.mocked(upcRepo.findProductByUpc).mockResolvedValueOnce(mockProduct); + + const result = await lookupUpc({ upc_code: '012345678905' }, mockLogger); + + expect(result.found).toBe(true); + expect(result.product).toEqual(mockProduct); + expect(result.from_cache).toBe(false); + }); + + it('should return cached external lookup when available', async () => { + vi.mocked(upcRepo.findProductByUpc).mockResolvedValueOnce(null); + vi.mocked(upcRepo.findExternalLookup).mockResolvedValueOnce({ + lookup_id: 1, + upc_code: '012345678905', + product_name: 'Cached Product', + brand_name: null, + category: null, + description: null, + image_url: null, + external_source: 'openfoodfacts', + lookup_data: null, + lookup_successful: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }); + + const result = await lookupUpc({ upc_code: '012345678905' }, mockLogger); + + expect(result.found).toBe(true); + expect(result.from_cache).toBe(true); + expect(result.external_lookup?.name).toBe('Cached Product'); + }); + + it('should return cached unsuccessful lookup', async () => { + vi.mocked(upcRepo.findProductByUpc).mockResolvedValueOnce(null); + vi.mocked(upcRepo.findExternalLookup).mockResolvedValueOnce({ + lookup_id: 1, + upc_code: '012345678905', + product_name: null, + brand_name: null, + category: null, + description: null, + image_url: null, + external_source: 'unknown', + lookup_data: null, + lookup_successful: false, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }); + + const result = await lookupUpc({ upc_code: '012345678905' }, mockLogger); + + expect(result.found).toBe(false); + expect(result.from_cache).toBe(true); + }); + + it('should perform fresh external lookup when force_refresh is true', async () => { + vi.mocked(upcRepo.findProductByUpc).mockResolvedValueOnce(null); + vi.mocked(upcRepo.upsertExternalLookup).mockResolvedValueOnce( + createMockExternalLookupRecord(), + ); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + status: 1, + product: { + product_name: 'Fresh External Product', + brands: 'Fresh Brand', + }, + }), + }); + + const result = await lookupUpc({ upc_code: '012345678905', force_refresh: true }, mockLogger); + + expect(result.from_cache).toBe(false); + expect(result.external_lookup?.name).toBe('Fresh External Product'); + expect(upcRepo.findExternalLookup).not.toHaveBeenCalled(); + }); + + it('should throw error for invalid UPC code', async () => { + await expect(lookupUpc({ upc_code: 'invalid' }, mockLogger)).rejects.toThrow( + 'Invalid UPC code format. UPC codes must be 8-14 digits.', + ); + }); + + it('should normalize UPC code before lookup', async () => { + vi.mocked(upcRepo.findProductByUpc).mockResolvedValueOnce(null); + vi.mocked(upcRepo.findExternalLookup).mockResolvedValueOnce(null); + vi.mocked(upcRepo.upsertExternalLookup).mockResolvedValueOnce( + createMockExternalLookupRecord(), + ); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ status: 0 }), + }); + + const result = await lookupUpc({ upc_code: '012-345-678-905' }, mockLogger); + + expect(result.upc_code).toBe('012345678905'); + }); + }); + + describe('linkUpcToProduct', () => { + it('should link UPC code to product successfully', async () => { + vi.mocked(upcRepo.linkUpcToProduct).mockResolvedValueOnce(createMockProductRow()); + + await linkUpcToProduct(1, '012345678905', mockLogger); + + expect(upcRepo.linkUpcToProduct).toHaveBeenCalledWith(1, '012345678905', mockLogger); + }); + + it('should throw error for invalid UPC code', async () => { + await expect(linkUpcToProduct(1, 'invalid', mockLogger)).rejects.toThrow( + 'Invalid UPC code format. UPC codes must be 8-14 digits.', + ); + }); + + it('should normalize UPC code before linking', async () => { + vi.mocked(upcRepo.linkUpcToProduct).mockResolvedValueOnce(createMockProductRow()); + + await linkUpcToProduct(1, '012-345-678-905', mockLogger); + + expect(upcRepo.linkUpcToProduct).toHaveBeenCalledWith(1, '012345678905', mockLogger); + }); + }); + + describe('getScanHistory', () => { + it('should return paginated scan history', async () => { + const mockHistory = { + scans: [ + { + scan_id: 1, + user_id: 'user-1', + upc_code: '012345678905', + product_id: 1, + scan_source: 'manual_entry' as UpcScanSource, + scan_confidence: 1.0, + raw_image_path: null, + lookup_successful: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ], + total: 1, + }; + + vi.mocked(upcRepo.getScanHistory).mockResolvedValueOnce(mockHistory); + + const result = await getScanHistory({ user_id: 'user-1', limit: 10, offset: 0 }, mockLogger); + + expect(result.scans).toHaveLength(1); + expect(result.total).toBe(1); + }); + + it('should filter by scan source', async () => { + vi.mocked(upcRepo.getScanHistory).mockResolvedValueOnce({ scans: [], total: 0 }); + + await getScanHistory({ user_id: 'user-1', scan_source: 'image_upload' }, mockLogger); + + expect(upcRepo.getScanHistory).toHaveBeenCalledWith( + { user_id: 'user-1', scan_source: 'image_upload' }, + mockLogger, + ); + }); + + it('should filter by date range', async () => { + vi.mocked(upcRepo.getScanHistory).mockResolvedValueOnce({ scans: [], total: 0 }); + + await getScanHistory( + { + user_id: 'user-1', + from_date: '2024-01-01', + to_date: '2024-01-31', + }, + mockLogger, + ); + + expect(upcRepo.getScanHistory).toHaveBeenCalledWith( + { + user_id: 'user-1', + from_date: '2024-01-01', + to_date: '2024-01-31', + }, + mockLogger, + ); + }); + }); + + describe('getScanStats', () => { + it('should return user scan statistics', async () => { + const mockStats = { + total_scans: 100, + successful_lookups: 80, + unique_products: 50, + scans_today: 5, + scans_this_week: 20, + }; + + vi.mocked(upcRepo.getUserScanStats).mockResolvedValueOnce(mockStats); + + const result = await getScanStats('user-1', mockLogger); + + expect(result).toEqual(mockStats); + expect(upcRepo.getUserScanStats).toHaveBeenCalledWith('user-1', mockLogger); + }); + }); + + describe('getScanById', () => { + it('should return scan record by ID', async () => { + const mockScan = { + scan_id: 1, + user_id: 'user-1', + upc_code: '012345678905', + product_id: 1, + scan_source: 'manual_entry' as UpcScanSource, + scan_confidence: 1.0, + raw_image_path: null, + lookup_successful: true, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + vi.mocked(upcRepo.getScanById).mockResolvedValueOnce(mockScan); + + const result = await getScanById(1, 'user-1', mockLogger); + + expect(result).toEqual(mockScan); + expect(upcRepo.getScanById).toHaveBeenCalledWith(1, 'user-1', mockLogger); + }); + }); +}); diff --git a/src/services/upcService.server.ts b/src/services/upcService.server.ts new file mode 100644 index 0000000..075b272 --- /dev/null +++ b/src/services/upcService.server.ts @@ -0,0 +1,614 @@ +// src/services/upcService.server.ts +/** + * @file UPC Scanning Service + * Handles UPC barcode scanning, lookup, and external API integration. + * Provides functionality for scanning barcodes from images and manual entry. + */ +import type { Logger } from 'pino'; +import { upcRepo } from './db/index.db'; +import type { + UpcScanRequest, + UpcScanResult, + UpcLookupResult, + UpcProductMatch, + UpcExternalProductInfo, + UpcExternalLookupOptions, + UpcScanHistoryQueryOptions, + UpcScanHistoryRecord, + BarcodeDetectionResult, +} from '../types/upc'; +import { config, isUpcItemDbConfigured, isBarcodeLookupConfigured } from '../config/env'; + +/** + * Default cache age for external lookups (7 days in hours) + */ +const DEFAULT_CACHE_AGE_HOURS = 168; + +/** + * UPC code validation regex (8-14 digits) + */ +const UPC_CODE_REGEX = /^[0-9]{8,14}$/; + +/** + * Validates a UPC code format. + * @param upcCode The UPC code to validate + * @returns True if the UPC code is valid, false otherwise + */ +export const isValidUpcCode = (upcCode: string): boolean => { + return UPC_CODE_REGEX.test(upcCode); +}; + +/** + * Normalizes a UPC code by removing spaces and dashes. + * @param upcCode The raw UPC code input + * @returns Normalized UPC code + */ +export const normalizeUpcCode = (upcCode: string): string => { + return upcCode.replace(/[\s-]/g, ''); +}; + +/** + * Detects and decodes a barcode from an image. + * This is a placeholder for actual barcode detection implementation. + * In production, this would use a library like zxing-js, quagga, or an external service. + * @param imageBase64 Base64-encoded image data + * @param logger Pino logger instance + * @returns Barcode detection result + */ +export const detectBarcodeFromImage = async ( + imageBase64: string, + logger: Logger, +): Promise => { + logger.debug({ imageLength: imageBase64.length }, 'Attempting to detect barcode from image'); + + // TODO: Implement actual barcode detection using a library like: + // - @nickvdyck/barcode-reader (pure JS) + // - dynamsoft-javascript-barcode (commercial) + // - External service like Google Cloud Vision API + // + // For now, return a placeholder response indicating detection is not yet implemented + logger.warn('Barcode detection from images is not yet implemented'); + + return { + detected: false, + upc_code: null, + confidence: null, + format: null, + error: 'Barcode detection from images is not yet implemented. Please use manual entry.', + }; +}; + +/** + * Looks up product in Open Food Facts API (free, open source). + * @param upcCode The UPC code to look up + * @param logger Pino logger instance + * @returns External product information or null if not found + */ +const lookupOpenFoodFacts = async ( + upcCode: string, + logger: Logger, +): Promise => { + try { + const openFoodFactsUrl = `https://world.openfoodfacts.org/api/v2/product/${upcCode}`; + logger.debug({ url: openFoodFactsUrl }, 'Querying Open Food Facts API'); + + const response = await fetch(openFoodFactsUrl, { + headers: { + 'User-Agent': 'FlyerCrawler/1.0 (contact@projectium.com)', + }, + }); + + if (response.ok) { + const data = await response.json(); + + if (data.status === 1 && data.product) { + const product = data.product; + logger.info( + { upcCode, productName: product.product_name }, + 'Found product in Open Food Facts', + ); + + return { + name: product.product_name || product.generic_name || 'Unknown Product', + brand: product.brands || null, + category: product.categories_tags?.[0]?.replace('en:', '') || null, + description: product.ingredients_text || null, + image_url: product.image_url || product.image_front_url || null, + source: 'openfoodfacts', + raw_data: product, + }; + } + } + + logger.debug({ upcCode }, 'Product not found in Open Food Facts'); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + logger.warn({ err, upcCode }, 'Error querying Open Food Facts API'); + } + + return null; +}; + +/** + * Looks up product in UPC Item DB API. + * Requires UPC_ITEM_DB_API_KEY environment variable. + * @see https://www.upcitemdb.com/wp/docs/main/development/ + * @param upcCode The UPC code to look up + * @param logger Pino logger instance + * @returns External product information or null if not found + */ +const lookupUpcItemDb = async ( + upcCode: string, + logger: Logger, +): Promise => { + if (!isUpcItemDbConfigured) { + logger.debug('UPC Item DB API key not configured, skipping'); + return null; + } + + try { + const url = `https://api.upcitemdb.com/prod/trial/lookup?upc=${upcCode}`; + logger.debug({ url }, 'Querying UPC Item DB API'); + + const response = await fetch(url, { + headers: { + 'Content-Type': 'application/json', + user_key: config.upc.upcItemDbApiKey!, + key_type: '3scale', + }, + }); + + if (response.ok) { + const data = await response.json(); + + if (data.code === 'OK' && data.items && data.items.length > 0) { + const item = data.items[0]; + logger.info({ upcCode, productName: item.title }, 'Found product in UPC Item DB'); + + return { + name: item.title || 'Unknown Product', + brand: item.brand || null, + category: item.category || null, + description: item.description || null, + image_url: item.images?.[0] || null, + source: 'upcitemdb', + raw_data: item, + }; + } + } else if (response.status === 429) { + logger.warn({ upcCode }, 'UPC Item DB rate limit exceeded'); + } + + logger.debug({ upcCode }, 'Product not found in UPC Item DB'); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + logger.warn({ err, upcCode }, 'Error querying UPC Item DB API'); + } + + return null; +}; + +/** + * Looks up product in Barcode Lookup API. + * Requires BARCODE_LOOKUP_API_KEY environment variable. + * @see https://www.barcodelookup.com/api + * @param upcCode The UPC code to look up + * @param logger Pino logger instance + * @returns External product information or null if not found + */ +const lookupBarcodeLookup = async ( + upcCode: string, + logger: Logger, +): Promise => { + if (!isBarcodeLookupConfigured) { + logger.debug('Barcode Lookup API key not configured, skipping'); + return null; + } + + try { + const url = `https://api.barcodelookup.com/v3/products?barcode=${upcCode}&key=${config.upc.barcodeLookupApiKey}`; + logger.debug('Querying Barcode Lookup API'); + + const response = await fetch(url, { + headers: { + Accept: 'application/json', + }, + }); + + if (response.ok) { + const data = await response.json(); + + if (data.products && data.products.length > 0) { + const product = data.products[0]; + logger.info({ upcCode, productName: product.title }, 'Found product in Barcode Lookup'); + + return { + name: product.title || product.product_name || 'Unknown Product', + brand: product.brand || null, + category: product.category || null, + description: product.description || null, + image_url: product.images?.[0] || null, + source: 'barcodelookup', + raw_data: product, + }; + } + } else if (response.status === 429) { + logger.warn({ upcCode }, 'Barcode Lookup rate limit exceeded'); + } else if (response.status === 404) { + logger.debug({ upcCode }, 'Product not found in Barcode Lookup'); + } + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + logger.warn({ err, upcCode }, 'Error querying Barcode Lookup API'); + } + + return null; +}; + +/** + * Looks up product information from external UPC databases. + * Tries multiple APIs in order of preference: + * 1. Open Food Facts (free, open source) + * 2. UPC Item DB (requires API key) + * 3. Barcode Lookup (requires API key) + * @param upcCode The UPC code to look up + * @param logger Pino logger instance + * @returns External product information or null if not found + */ +export const lookupExternalUpc = async ( + upcCode: string, + logger: Logger, +): Promise => { + logger.debug({ upcCode }, 'Looking up UPC in external databases'); + + // Try Open Food Facts first (free, no API key needed) + let result = await lookupOpenFoodFacts(upcCode, logger); + if (result) { + return result; + } + + // Try UPC Item DB if configured + result = await lookupUpcItemDb(upcCode, logger); + if (result) { + return result; + } + + // Try Barcode Lookup if configured + result = await lookupBarcodeLookup(upcCode, logger); + if (result) { + return result; + } + + logger.debug({ upcCode }, 'No external product information found'); + return null; +}; + +/** + * Performs a UPC scan operation including barcode detection, database lookup, + * and optional external API lookup. + * @param userId The user performing the scan + * @param request The scan request containing UPC code or image + * @param logger Pino logger instance + * @returns Complete scan result with product information + */ +export const scanUpc = async ( + userId: string, + request: UpcScanRequest, + logger: Logger, +): Promise => { + const scanLogger = logger.child({ userId, scanSource: request.scan_source }); + scanLogger.info('Starting UPC scan'); + + let upcCode: string | null = null; + let scanConfidence: number | null = null; + + // Step 1: Get UPC code from request (manual entry or image detection) + if (request.upc_code) { + // Manual entry - normalize and validate + upcCode = normalizeUpcCode(request.upc_code); + + if (!isValidUpcCode(upcCode)) { + scanLogger.warn({ upcCode }, 'Invalid UPC code format'); + throw new Error('Invalid UPC code format. UPC codes must be 8-14 digits.'); + } + + scanConfidence = 1.0; // Manual entry has 100% confidence + scanLogger.debug({ upcCode }, 'Using manually entered UPC code'); + } else if (request.image_base64) { + // Image detection + const detection = await detectBarcodeFromImage(request.image_base64, scanLogger); + + if (!detection.detected || !detection.upc_code) { + // Record the failed scan attempt + const scanRecord = await upcRepo.recordScan( + userId, + 'DETECTION_FAILED', + request.scan_source, + scanLogger, + { + scanConfidence: 0, + lookupSuccessful: false, + }, + ); + + return { + scan_id: scanRecord.scan_id, + upc_code: '', + product: null, + external_lookup: null, + confidence: 0, + lookup_successful: false, + is_new_product: false, + scanned_at: scanRecord.created_at, + }; + } + + upcCode = detection.upc_code; + scanConfidence = detection.confidence; + scanLogger.info({ upcCode, confidence: scanConfidence }, 'Barcode detected from image'); + } else { + throw new Error('Either upc_code or image_base64 must be provided.'); + } + + // Step 2: Look up product in our database + let product: UpcProductMatch | null = null; + product = await upcRepo.findProductByUpc(upcCode, scanLogger); + + const isNewProduct = !product; + scanLogger.debug({ upcCode, found: !!product, isNewProduct }, 'Local database lookup complete'); + + // Step 3: If not found locally, check external APIs + let externalLookup: UpcExternalProductInfo | null = null; + + if (!product) { + // Check cache first + const cachedLookup = await upcRepo.findExternalLookup( + upcCode, + DEFAULT_CACHE_AGE_HOURS, + scanLogger, + ); + + if (cachedLookup) { + scanLogger.debug({ upcCode }, 'Using cached external lookup'); + + if (cachedLookup.lookup_successful) { + externalLookup = { + name: cachedLookup.product_name || 'Unknown Product', + brand: cachedLookup.brand_name, + category: cachedLookup.category, + description: cachedLookup.description, + image_url: cachedLookup.image_url, + source: cachedLookup.external_source, + raw_data: cachedLookup.lookup_data ?? undefined, + }; + } + } else { + // Perform fresh external lookup + externalLookup = await lookupExternalUpc(upcCode, scanLogger); + + // Cache the result (success or failure) + await upcRepo.upsertExternalLookup( + upcCode, + externalLookup?.source || 'unknown', + !!externalLookup, + scanLogger, + externalLookup + ? { + productName: externalLookup.name, + brandName: externalLookup.brand, + category: externalLookup.category, + description: externalLookup.description, + imageUrl: externalLookup.image_url, + lookupData: externalLookup.raw_data as Record | undefined, + } + : {}, + ); + } + } + + // Step 4: Record the scan in history + const lookupSuccessful = !!(product || externalLookup); + const scanRecord = await upcRepo.recordScan(userId, upcCode, request.scan_source, scanLogger, { + productId: product?.product_id, + scanConfidence, + lookupSuccessful, + }); + + scanLogger.info( + { scanId: scanRecord.scan_id, upcCode, lookupSuccessful, isNewProduct }, + 'UPC scan completed', + ); + + return { + scan_id: scanRecord.scan_id, + upc_code: upcCode, + product, + external_lookup: externalLookup, + confidence: scanConfidence, + lookup_successful: lookupSuccessful, + is_new_product: isNewProduct, + scanned_at: scanRecord.created_at, + }; +}; + +/** + * Looks up a UPC code without recording scan history. + * Useful for quick lookups or verification. + * @param options Lookup options + * @param logger Pino logger instance + * @returns Lookup result with product information + */ +export const lookupUpc = async ( + options: UpcExternalLookupOptions, + logger: Logger, +): Promise => { + const { + upc_code, + force_refresh = false, + max_cache_age_hours = DEFAULT_CACHE_AGE_HOURS, + } = options; + const lookupLogger = logger.child({ upcCode: upc_code }); + + lookupLogger.debug('Performing UPC lookup'); + + const normalizedUpc = normalizeUpcCode(upc_code); + + if (!isValidUpcCode(normalizedUpc)) { + throw new Error('Invalid UPC code format. UPC codes must be 8-14 digits.'); + } + + // Check local database + const product = await upcRepo.findProductByUpc(normalizedUpc, lookupLogger); + + if (product) { + lookupLogger.debug({ productId: product.product_id }, 'Found product in local database'); + return { + upc_code: normalizedUpc, + product, + external_lookup: null, + found: true, + from_cache: false, + }; + } + + // Check external cache (unless force refresh) + if (!force_refresh) { + const cachedLookup = await upcRepo.findExternalLookup( + normalizedUpc, + max_cache_age_hours, + lookupLogger, + ); + + if (cachedLookup) { + lookupLogger.debug('Returning cached external lookup'); + + if (cachedLookup.lookup_successful) { + return { + upc_code: normalizedUpc, + product: null, + external_lookup: { + name: cachedLookup.product_name || 'Unknown Product', + brand: cachedLookup.brand_name, + category: cachedLookup.category, + description: cachedLookup.description, + image_url: cachedLookup.image_url, + source: cachedLookup.external_source, + raw_data: cachedLookup.lookup_data ?? undefined, + }, + found: true, + from_cache: true, + }; + } + + // Cached lookup was unsuccessful + return { + upc_code: normalizedUpc, + product: null, + external_lookup: null, + found: false, + from_cache: true, + }; + } + } + + // Perform fresh external lookup + const externalLookup = await lookupExternalUpc(normalizedUpc, lookupLogger); + + // Cache the result + await upcRepo.upsertExternalLookup( + normalizedUpc, + externalLookup?.source || 'unknown', + !!externalLookup, + lookupLogger, + externalLookup + ? { + productName: externalLookup.name, + brandName: externalLookup.brand, + category: externalLookup.category, + description: externalLookup.description, + imageUrl: externalLookup.image_url, + lookupData: externalLookup.raw_data as Record | undefined, + } + : {}, + ); + + return { + upc_code: normalizedUpc, + product: null, + external_lookup: externalLookup, + found: !!externalLookup, + from_cache: false, + }; +}; + +/** + * Links a UPC code to an existing product (admin operation). + * @param productId The product ID to link + * @param upcCode The UPC code to link + * @param logger Pino logger instance + */ +export const linkUpcToProduct = async ( + productId: number, + upcCode: string, + logger: Logger, +): Promise => { + const normalizedUpc = normalizeUpcCode(upcCode); + + if (!isValidUpcCode(normalizedUpc)) { + throw new Error('Invalid UPC code format. UPC codes must be 8-14 digits.'); + } + + logger.info({ productId, upcCode: normalizedUpc }, 'Linking UPC code to product'); + await upcRepo.linkUpcToProduct(productId, normalizedUpc, logger); + logger.info({ productId, upcCode: normalizedUpc }, 'UPC code linked successfully'); +}; + +/** + * Gets the scan history for a user. + * @param options Query options + * @param logger Pino logger instance + * @returns Paginated scan history + */ +export const getScanHistory = async ( + options: UpcScanHistoryQueryOptions, + logger: Logger, +): Promise<{ scans: UpcScanHistoryRecord[]; total: number }> => { + logger.debug({ userId: options.user_id }, 'Fetching scan history'); + return upcRepo.getScanHistory(options, logger); +}; + +/** + * Gets scan statistics for a user. + * @param userId The user ID + * @param logger Pino logger instance + * @returns Scan statistics + */ +export const getScanStats = async ( + userId: string, + logger: Logger, +): Promise<{ + total_scans: number; + successful_lookups: number; + unique_products: number; + scans_today: number; + scans_this_week: number; +}> => { + logger.debug({ userId }, 'Fetching scan statistics'); + return upcRepo.getUserScanStats(userId, logger); +}; + +/** + * Gets a single scan record by ID. + * @param scanId The scan ID + * @param userId The user ID (for authorization) + * @param logger Pino logger instance + * @returns The scan record + */ +export const getScanById = async ( + scanId: number, + userId: string, + logger: Logger, +): Promise => { + logger.debug({ scanId, userId }, 'Fetching scan by ID'); + return upcRepo.getScanById(scanId, userId, logger); +}; diff --git a/src/services/workers.server.ts b/src/services/workers.server.ts index 8b39042..2be7cf5 100644 --- a/src/services/workers.server.ts +++ b/src/services/workers.server.ts @@ -23,6 +23,9 @@ import { analyticsQueue, weeklyAnalyticsQueue, tokenCleanupQueue, + receiptQueue, + expiryAlertQueue, + barcodeQueue, } from './queues.server'; import type { FlyerJobData, @@ -31,8 +34,15 @@ import type { WeeklyAnalyticsJobData, CleanupJobData, TokenCleanupJobData, + ReceiptJobData, + ExpiryAlertJobData, + BarcodeDetectionJobData, } from '../types/job-data'; +import * as receiptService from './receiptService.server'; +import * as expiryService from './expiryService.server'; +import * as barcodeService from './barcodeService.server'; import { FlyerFileHandler, type IFileSystem } from './flyerFileHandler.server'; +import { defaultWorkerOptions } from '../config/workerOptions'; const execAsync = promisify(exec); @@ -98,10 +108,15 @@ export const flyerWorker = new Worker( 'flyer-processing', createWorkerProcessor((job) => flyerProcessingService.processJob(job)), { + ...defaultWorkerOptions, connection, concurrency: parseInt(process.env.WORKER_CONCURRENCY || '1', 10), // Increase lock duration to prevent jobs from being re-processed prematurely. - lockDuration: parseInt(process.env.WORKER_LOCK_DURATION || '30000', 10), + // We use the env var if set, otherwise fallback to the defaultWorkerOptions value (30000) + lockDuration: parseInt( + process.env.WORKER_LOCK_DURATION || String(defaultWorkerOptions.lockDuration), + 10, + ), }, ); @@ -109,6 +124,7 @@ export const emailWorker = new Worker( 'email-sending', createWorkerProcessor((job) => emailService.processEmailJob(job)), { + ...defaultWorkerOptions, connection, concurrency: parseInt(process.env.EMAIL_WORKER_CONCURRENCY || '10', 10), }, @@ -118,6 +134,7 @@ export const analyticsWorker = new Worker( 'analytics-reporting', createWorkerProcessor((job) => analyticsService.processDailyReportJob(job)), { + ...defaultWorkerOptions, connection, concurrency: parseInt(process.env.ANALYTICS_WORKER_CONCURRENCY || '1', 10), }, @@ -127,6 +144,7 @@ export const cleanupWorker = new Worker( 'file-cleanup', createWorkerProcessor((job) => flyerProcessingService.processCleanupJob(job)), { + ...defaultWorkerOptions, connection, concurrency: parseInt(process.env.CLEANUP_WORKER_CONCURRENCY || '10', 10), }, @@ -136,6 +154,7 @@ export const weeklyAnalyticsWorker = new Worker( 'weekly-analytics-reporting', createWorkerProcessor((job) => analyticsService.processWeeklyReportJob(job)), { + ...defaultWorkerOptions, connection, concurrency: parseInt(process.env.WEEKLY_ANALYTICS_WORKER_CONCURRENCY || '1', 10), }, @@ -145,17 +164,51 @@ export const tokenCleanupWorker = new Worker( 'token-cleanup', createWorkerProcessor((job) => userService.processTokenCleanupJob(job)), { + ...defaultWorkerOptions, connection, concurrency: 1, }, ); +export const receiptWorker = new Worker( + 'receipt-processing', + createWorkerProcessor((job) => receiptService.processReceiptJob(job, logger)), + { + ...defaultWorkerOptions, + connection, + concurrency: parseInt(process.env.RECEIPT_WORKER_CONCURRENCY || '2', 10), + }, +); + +export const expiryAlertWorker = new Worker( + 'expiry-alerts', + createWorkerProcessor((job) => expiryService.processExpiryAlertJob(job, logger)), + { + ...defaultWorkerOptions, + connection, + concurrency: parseInt(process.env.EXPIRY_ALERT_WORKER_CONCURRENCY || '1', 10), + }, +); + +export const barcodeWorker = new Worker( + 'barcode-detection', + createWorkerProcessor((job) => barcodeService.processBarcodeDetectionJob(job, logger)), + { + ...defaultWorkerOptions, + connection, + concurrency: parseInt(process.env.BARCODE_WORKER_CONCURRENCY || '2', 10), + }, +); + attachWorkerEventListeners(flyerWorker); attachWorkerEventListeners(emailWorker); attachWorkerEventListeners(analyticsWorker); attachWorkerEventListeners(cleanupWorker); attachWorkerEventListeners(weeklyAnalyticsWorker); attachWorkerEventListeners(tokenCleanupWorker); +attachWorkerEventListeners(receiptWorker); +attachWorkerEventListeners(expiryAlertWorker); +attachWorkerEventListeners(barcodeWorker); logger.info('All workers started and listening for jobs.'); @@ -173,6 +226,9 @@ export const closeWorkers = async () => { cleanupWorker.close(), weeklyAnalyticsWorker.close(), tokenCleanupWorker.close(), + receiptWorker.close(), + expiryAlertWorker.close(), + barcodeWorker.close(), ]); }; @@ -215,6 +271,9 @@ export const gracefulShutdown = async (signal: string) => { { name: 'cleanupWorker', close: () => cleanupWorker.close() }, { name: 'weeklyAnalyticsWorker', close: () => weeklyAnalyticsWorker.close() }, { name: 'tokenCleanupWorker', close: () => tokenCleanupWorker.close() }, + { name: 'receiptWorker', close: () => receiptWorker.close() }, + { name: 'expiryAlertWorker', close: () => expiryAlertWorker.close() }, + { name: 'barcodeWorker', close: () => barcodeWorker.close() }, ]; const queueResources = [ @@ -224,6 +283,9 @@ export const gracefulShutdown = async (signal: string) => { { name: 'cleanupQueue', close: () => cleanupQueue.close() }, { name: 'weeklyAnalyticsQueue', close: () => weeklyAnalyticsQueue.close() }, { name: 'tokenCleanupQueue', close: () => tokenCleanupQueue.close() }, + { name: 'receiptQueue', close: () => receiptQueue.close() }, + { name: 'expiryAlertQueue', close: () => expiryAlertQueue.close() }, + { name: 'barcodeQueue', close: () => barcodeQueue.close() }, ]; // 1. Close workers first diff --git a/src/tests/e2e/error-reporting.e2e.test.ts b/src/tests/e2e/error-reporting.e2e.test.ts new file mode 100644 index 0000000..200b6a9 --- /dev/null +++ b/src/tests/e2e/error-reporting.e2e.test.ts @@ -0,0 +1,252 @@ +// src/tests/e2e/error-reporting.e2e.test.ts +/** + * E2E tests for error reporting to Bugsink/Sentry (ADR-015). + * + * These tests verify that errors are properly captured and can be sent + * to the error tracking system. They test both the backend (Express/Node) + * and frontend (React) error handling paths. + * + * Note: These tests don't actually verify Bugsink receives the errors + * (that would require Bugsink to be running). Instead, they verify: + * 1. The Sentry SDK is properly initialized + * 2. Errors trigger the capture functions + * 3. The middleware chain works correctly + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import express from 'express'; +import request from 'supertest'; + +/** + * @vitest-environment node + */ + +describe('Error Reporting E2E', () => { + describe('Backend Error Handling', () => { + let app: express.Application; + let mockCaptureException: ReturnType; + + beforeEach(() => { + // Reset modules to get fresh instances + vi.resetModules(); + + // Mock Sentry before importing the module + mockCaptureException = vi.fn().mockReturnValue('mock-event-id'); + vi.doMock('@sentry/node', () => ({ + init: vi.fn(), + captureException: mockCaptureException, + captureMessage: vi.fn(), + setUser: vi.fn(), + setContext: vi.fn(), + addBreadcrumb: vi.fn(), + })); + + // Create a test Express app with error handling + app = express(); + app.use(express.json()); + + // Test route that throws a 500 error + app.get('/api/test/error-500', (_req, res, next) => { + const error = new Error('Test 500 error for Sentry'); + (error as Error & { statusCode: number }).statusCode = 500; + next(error); + }); + + // Test route that throws a 400 error (should NOT be sent to Sentry) + app.get('/api/test/error-400', (_req, res, next) => { + const error = new Error('Test 400 error'); + (error as Error & { statusCode: number }).statusCode = 400; + next(error); + }); + + // Test route that succeeds + app.get('/api/test/success', (_req, res) => { + res.json({ success: true }); + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('should have a test endpoint that throws a 500 error', async () => { + // Add error handler + app.use( + ( + err: Error & { statusCode?: number }, + _req: express.Request, + res: express.Response, + _next: express.NextFunction, + ) => { + const statusCode = err.statusCode || 500; + res.status(statusCode).json({ error: err.message }); + }, + ); + + const response = await request(app).get('/api/test/error-500'); + + expect(response.status).toBe(500); + expect(response.body).toEqual({ error: 'Test 500 error for Sentry' }); + }); + + it('should have a test endpoint that throws a 400 error', async () => { + // Add error handler + app.use( + ( + err: Error & { statusCode?: number }, + _req: express.Request, + res: express.Response, + _next: express.NextFunction, + ) => { + const statusCode = err.statusCode || 500; + res.status(statusCode).json({ error: err.message }); + }, + ); + + const response = await request(app).get('/api/test/error-400'); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'Test 400 error' }); + }); + + it('should have a success endpoint that returns 200', async () => { + const response = await request(app).get('/api/test/success'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ success: true }); + }); + }); + + describe('Sentry Module Configuration', () => { + it('should export initSentry function', async () => { + vi.doMock('@sentry/node', () => ({ + init: vi.fn(), + captureException: vi.fn(), + captureMessage: vi.fn(), + setUser: vi.fn(), + setContext: vi.fn(), + addBreadcrumb: vi.fn(), + })); + + const { initSentry } = await import('../../services/sentry.server'); + expect(typeof initSentry).toBe('function'); + }); + + it('should export getSentryMiddleware function', async () => { + vi.doMock('@sentry/node', () => ({ + init: vi.fn(), + captureException: vi.fn(), + captureMessage: vi.fn(), + setUser: vi.fn(), + setContext: vi.fn(), + addBreadcrumb: vi.fn(), + })); + + const { getSentryMiddleware } = await import('../../services/sentry.server'); + expect(typeof getSentryMiddleware).toBe('function'); + }); + + it('should export captureException function', async () => { + vi.doMock('@sentry/node', () => ({ + init: vi.fn(), + captureException: vi.fn(), + captureMessage: vi.fn(), + setUser: vi.fn(), + setContext: vi.fn(), + addBreadcrumb: vi.fn(), + })); + + const { captureException } = await import('../../services/sentry.server'); + expect(typeof captureException).toBe('function'); + }); + + it('should export captureMessage function', async () => { + vi.doMock('@sentry/node', () => ({ + init: vi.fn(), + captureException: vi.fn(), + captureMessage: vi.fn(), + setUser: vi.fn(), + setContext: vi.fn(), + addBreadcrumb: vi.fn(), + })); + + const { captureMessage } = await import('../../services/sentry.server'); + expect(typeof captureMessage).toBe('function'); + }); + + it('should export setUser function', async () => { + vi.doMock('@sentry/node', () => ({ + init: vi.fn(), + captureException: vi.fn(), + captureMessage: vi.fn(), + setUser: vi.fn(), + setContext: vi.fn(), + addBreadcrumb: vi.fn(), + })); + + const { setUser } = await import('../../services/sentry.server'); + expect(typeof setUser).toBe('function'); + }); + }); + + describe('Frontend Sentry Client Configuration', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('should export initSentry function for frontend', async () => { + // Mock @sentry/react + vi.doMock('@sentry/react', () => ({ + init: vi.fn(), + captureException: vi.fn(), + captureMessage: vi.fn(), + setUser: vi.fn(), + setContext: vi.fn(), + addBreadcrumb: vi.fn(), + breadcrumbsIntegration: vi.fn(() => ({})), + ErrorBoundary: vi.fn(() => null), + })); + + // Mock the config + vi.doMock('../../config', () => ({ + default: { + sentry: { + dsn: '', + environment: 'test', + debug: false, + enabled: false, + }, + }, + })); + + const { initSentry } = await import('../../services/sentry.client'); + expect(typeof initSentry).toBe('function'); + }); + + it('should export captureException function for frontend', async () => { + vi.doMock('@sentry/react', () => ({ + init: vi.fn(), + captureException: vi.fn(), + captureMessage: vi.fn(), + setUser: vi.fn(), + setContext: vi.fn(), + addBreadcrumb: vi.fn(), + breadcrumbsIntegration: vi.fn(() => ({})), + ErrorBoundary: vi.fn(() => null), + })); + + vi.doMock('../../config', () => ({ + default: { + sentry: { + dsn: '', + environment: 'test', + debug: false, + enabled: false, + }, + }, + })); + + const { captureException } = await import('../../services/sentry.client'); + expect(typeof captureException).toBe('function'); + }); + }); +}); diff --git a/src/tests/e2e/inventory-journey.e2e.test.ts b/src/tests/e2e/inventory-journey.e2e.test.ts new file mode 100644 index 0000000..7b3ea46 --- /dev/null +++ b/src/tests/e2e/inventory-journey.e2e.test.ts @@ -0,0 +1,406 @@ +// src/tests/e2e/inventory-journey.e2e.test.ts +/** + * End-to-End test for the Inventory/Expiry management user journey. + * Tests the complete flow from adding inventory items to tracking expiry and alerts. + */ +import { describe, it, expect, afterAll } from 'vitest'; +import * as apiClient from '../../services/apiClient'; +import { cleanupDb } from '../utils/cleanup'; +import { poll } from '../utils/poll'; +import { getPool } from '../../services/db/connection.db'; + +/** + * @vitest-environment node + */ + +const API_BASE_URL = process.env.VITE_API_BASE_URL || 'http://localhost:3000/api'; + +// Helper to make authenticated API calls +const authedFetch = async ( + path: string, + options: RequestInit & { token?: string } = {}, +): Promise => { + const { token, ...fetchOptions } = options; + const headers: Record = { + 'Content-Type': 'application/json', + ...(fetchOptions.headers as Record), + }; + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + return fetch(`${API_BASE_URL}${path}`, { + ...fetchOptions, + headers, + }); +}; + +describe('E2E Inventory/Expiry Management Journey', () => { + const uniqueId = Date.now(); + const userEmail = `inventory-e2e-${uniqueId}@example.com`; + const userPassword = 'StrongInventoryPassword123!'; + + let authToken: string; + let userId: string | null = null; + const createdInventoryIds: number[] = []; + + afterAll(async () => { + const pool = getPool(); + + // Clean up alert logs + if (createdInventoryIds.length > 0) { + await pool.query('DELETE FROM public.expiry_alert_log WHERE inventory_id = ANY($1::int[])', [ + createdInventoryIds, + ]); + } + + // Clean up inventory items + if (createdInventoryIds.length > 0) { + await pool.query('DELETE FROM public.user_inventory WHERE inventory_id = ANY($1::int[])', [ + createdInventoryIds, + ]); + } + + // Clean up user alert settings + if (userId) { + await pool.query('DELETE FROM public.user_expiry_alert_settings WHERE user_id = $1', [ + userId, + ]); + } + + // Clean up user + await cleanupDb({ + userIds: [userId], + }); + }); + + it('should complete inventory journey: Register -> Add Items -> Track Expiry -> Consume -> Configure Alerts', async () => { + // Step 1: Register a new user + const registerResponse = await apiClient.registerUser( + userEmail, + userPassword, + 'Inventory E2E User', + ); + expect(registerResponse.status).toBe(201); + + // Step 2: Login to get auth token + const { response: loginResponse, responseBody: loginResponseBody } = await poll( + async () => { + const response = await apiClient.loginUser(userEmail, userPassword, false); + const responseBody = response.ok ? await response.clone().json() : {}; + return { response, responseBody }; + }, + (result) => result.response.ok, + { timeout: 10000, interval: 1000, description: 'user login after registration' }, + ); + + expect(loginResponse.status).toBe(200); + authToken = loginResponseBody.data.token; + userId = loginResponseBody.data.userprofile.user.user_id; + expect(authToken).toBeDefined(); + + // Calculate dates for testing + const today = new Date(); + const tomorrow = new Date(today.getTime() + 24 * 60 * 60 * 1000); + const nextWeek = new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000); + const nextMonth = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000); + const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000); + + const formatDate = (d: Date) => d.toISOString().split('T')[0]; + + // Step 3: Add multiple inventory items with different expiry dates + const items = [ + { + item_name: 'Milk', + quantity: 2, + location: 'fridge', + expiry_date: formatDate(tomorrow), + notes: 'Low-fat milk', + }, + { + item_name: 'Frozen Pizza', + quantity: 3, + location: 'freezer', + expiry_date: formatDate(nextMonth), + }, + { + item_name: 'Bread', + quantity: 1, + location: 'pantry', + expiry_date: formatDate(nextWeek), + }, + { + item_name: 'Apples', + quantity: 6, + location: 'fridge', + expiry_date: formatDate(nextWeek), + }, + { + item_name: 'Rice', + quantity: 1, + location: 'pantry', + // No expiry date - non-perishable + }, + ]; + + for (const item of items) { + const addResponse = await authedFetch('/inventory', { + method: 'POST', + token: authToken, + body: JSON.stringify(item), + }); + + expect(addResponse.status).toBe(201); + const addData = await addResponse.json(); + expect(addData.data.item_name).toBe(item.item_name); + createdInventoryIds.push(addData.data.inventory_id); + } + + // Add an expired item directly to the database for testing expired endpoint + const pool = getPool(); + const expiredResult = await pool.query( + `INSERT INTO public.user_inventory (user_id, item_name, quantity, location, expiry_date) + VALUES ($1, 'Expired Yogurt', 1, 'fridge', $2) + RETURNING inventory_id`, + [userId, formatDate(yesterday)], + ); + createdInventoryIds.push(expiredResult.rows[0].inventory_id); + + // Step 4: View all inventory + const listResponse = await authedFetch('/inventory', { + method: 'GET', + token: authToken, + }); + + expect(listResponse.status).toBe(200); + const listData = await listResponse.json(); + expect(listData.data.items.length).toBe(6); // All our items + expect(listData.data.total).toBe(6); + + // Step 5: Filter by location + const fridgeResponse = await authedFetch('/inventory?location=fridge', { + method: 'GET', + token: authToken, + }); + + expect(fridgeResponse.status).toBe(200); + const fridgeData = await fridgeResponse.json(); + fridgeData.data.items.forEach((item: { location: string }) => { + expect(item.location).toBe('fridge'); + }); + expect(fridgeData.data.items.length).toBe(3); // Milk, Apples, Expired Yogurt + + // Step 6: View expiring items + const expiringResponse = await authedFetch('/inventory/expiring?days_ahead=3', { + method: 'GET', + token: authToken, + }); + + expect(expiringResponse.status).toBe(200); + const expiringData = await expiringResponse.json(); + // Should include the Milk (tomorrow) + expect(expiringData.data.items.length).toBeGreaterThanOrEqual(1); + + // Step 7: View expired items + const expiredResponse = await authedFetch('/inventory/expired', { + method: 'GET', + token: authToken, + }); + + expect(expiredResponse.status).toBe(200); + const expiredData = await expiredResponse.json(); + expect(expiredData.data.items.length).toBeGreaterThanOrEqual(1); + + // Find the expired yogurt + const expiredYogurt = expiredData.data.items.find( + (i: { item_name: string }) => i.item_name === 'Expired Yogurt', + ); + expect(expiredYogurt).toBeDefined(); + + // Step 8: Get specific item details + const milkId = createdInventoryIds[0]; + const detailResponse = await authedFetch(`/inventory/${milkId}`, { + method: 'GET', + token: authToken, + }); + + expect(detailResponse.status).toBe(200); + const detailData = await detailResponse.json(); + expect(detailData.data.item.item_name).toBe('Milk'); + expect(detailData.data.item.quantity).toBe(2); + + // Step 9: Update item quantity and location + const updateResponse = await authedFetch(`/inventory/${milkId}`, { + method: 'PUT', + token: authToken, + body: JSON.stringify({ + quantity: 1, + notes: 'One bottle used', + }), + }); + + expect(updateResponse.status).toBe(200); + const updateData = await updateResponse.json(); + expect(updateData.data.quantity).toBe(1); + + // Step 10: Consume some apples + const applesId = createdInventoryIds[3]; + const consumeResponse = await authedFetch(`/inventory/${applesId}/consume`, { + method: 'POST', + token: authToken, + body: JSON.stringify({ quantity_consumed: 2 }), + }); + + expect(consumeResponse.status).toBe(200); + const consumeData = await consumeResponse.json(); + expect(consumeData.data.quantity).toBe(4); // 6 - 2 + + // Step 11: Configure alert settings + const alertSettingsResponse = await authedFetch('/inventory/alerts/settings', { + method: 'PUT', + token: authToken, + body: JSON.stringify({ + alerts_enabled: true, + days_before_expiry: 3, + alert_time: '08:00', + email_notifications: true, + push_notifications: false, + }), + }); + + expect(alertSettingsResponse.status).toBe(200); + const alertSettingsData = await alertSettingsResponse.json(); + expect(alertSettingsData.data.settings.alerts_enabled).toBe(true); + expect(alertSettingsData.data.settings.days_before_expiry).toBe(3); + + // Step 12: Verify alert settings were saved + const getSettingsResponse = await authedFetch('/inventory/alerts/settings', { + method: 'GET', + token: authToken, + }); + + expect(getSettingsResponse.status).toBe(200); + const getSettingsData = await getSettingsResponse.json(); + expect(getSettingsData.data.settings.alerts_enabled).toBe(true); + + // Step 13: Get recipe suggestions based on expiring items + const suggestionsResponse = await authedFetch('/inventory/recipes/suggestions', { + method: 'GET', + token: authToken, + }); + + expect(suggestionsResponse.status).toBe(200); + const suggestionsData = await suggestionsResponse.json(); + expect(Array.isArray(suggestionsData.data.suggestions)).toBe(true); + + // Step 14: Fully consume an item + const breadId = createdInventoryIds[2]; + const fullConsumeResponse = await authedFetch(`/inventory/${breadId}/consume`, { + method: 'POST', + token: authToken, + body: JSON.stringify({ quantity_consumed: 1 }), + }); + + expect(fullConsumeResponse.status).toBe(200); + const fullConsumeData = await fullConsumeResponse.json(); + expect(fullConsumeData.data.is_consumed).toBe(true); + + // Step 15: Delete an item + const riceId = createdInventoryIds[4]; + const deleteResponse = await authedFetch(`/inventory/${riceId}`, { + method: 'DELETE', + token: authToken, + }); + + expect(deleteResponse.status).toBe(204); + + // Remove from tracking list + const deleteIndex = createdInventoryIds.indexOf(riceId); + if (deleteIndex > -1) { + createdInventoryIds.splice(deleteIndex, 1); + } + + // Step 16: Verify deletion + const verifyDeleteResponse = await authedFetch(`/inventory/${riceId}`, { + method: 'GET', + token: authToken, + }); + + expect(verifyDeleteResponse.status).toBe(404); + + // Step 17: Verify another user cannot access our inventory + const otherUserEmail = `other-inventory-e2e-${uniqueId}@example.com`; + await apiClient.registerUser(otherUserEmail, userPassword, 'Other Inventory User'); + + const { responseBody: otherLoginData } = await poll( + async () => { + const response = await apiClient.loginUser(otherUserEmail, userPassword, false); + const responseBody = response.ok ? await response.clone().json() : {}; + return { response, responseBody }; + }, + (result) => result.response.ok, + { timeout: 10000, interval: 1000, description: 'other user login' }, + ); + + const otherToken = otherLoginData.data.token; + const otherUserId = otherLoginData.data.userprofile.user.user_id; + + // Other user should not see our inventory + const otherDetailResponse = await authedFetch(`/inventory/${milkId}`, { + method: 'GET', + token: otherToken, + }); + + expect(otherDetailResponse.status).toBe(404); + + // Other user's inventory should be empty + const otherListResponse = await authedFetch('/inventory', { + method: 'GET', + token: otherToken, + }); + + expect(otherListResponse.status).toBe(200); + const otherListData = await otherListResponse.json(); + expect(otherListData.data.total).toBe(0); + + // Clean up other user + await cleanupDb({ userIds: [otherUserId] }); + + // Step 18: Move frozen item to fridge (simulating thawing) + const pizzaId = createdInventoryIds[1]; + const moveResponse = await authedFetch(`/inventory/${pizzaId}`, { + method: 'PUT', + token: authToken, + body: JSON.stringify({ + location: 'fridge', + expiry_date: formatDate(nextWeek), // Update expiry since thawed + notes: 'Thawed for dinner', + }), + }); + + expect(moveResponse.status).toBe(200); + const moveData = await moveResponse.json(); + expect(moveData.data.location).toBe('fridge'); + + // Step 19: Final inventory check + const finalListResponse = await authedFetch('/inventory', { + method: 'GET', + token: authToken, + }); + + expect(finalListResponse.status).toBe(200); + const finalListData = await finalListResponse.json(); + // We should have: Milk (1), Pizza (thawed, 3), Bread (consumed), Apples (4), Expired Yogurt (1) + // Rice was deleted, Bread was consumed + expect(finalListData.data.total).toBeLessThanOrEqual(5); + + // Step 20: Delete account + const deleteAccountResponse = await apiClient.deleteUserAccount(userPassword, { + tokenOverride: authToken, + }); + + expect(deleteAccountResponse.status).toBe(200); + userId = null; + }); +}); diff --git a/src/tests/e2e/receipt-journey.e2e.test.ts b/src/tests/e2e/receipt-journey.e2e.test.ts new file mode 100644 index 0000000..4ceebcf --- /dev/null +++ b/src/tests/e2e/receipt-journey.e2e.test.ts @@ -0,0 +1,364 @@ +// src/tests/e2e/receipt-journey.e2e.test.ts +/** + * End-to-End test for the Receipt processing user journey. + * Tests the complete flow from user registration to uploading receipts and managing items. + */ +import { describe, it, expect, afterAll } from 'vitest'; +import * as apiClient from '../../services/apiClient'; +import { cleanupDb } from '../utils/cleanup'; +import { poll } from '../utils/poll'; +import { getPool } from '../../services/db/connection.db'; +import FormData from 'form-data'; + +/** + * @vitest-environment node + */ + +const API_BASE_URL = process.env.VITE_API_BASE_URL || 'http://localhost:3000/api'; + +// Helper to make authenticated API calls +const authedFetch = async ( + path: string, + options: RequestInit & { token?: string } = {}, +): Promise => { + const { token, ...fetchOptions } = options; + const headers: Record = { + ...(fetchOptions.headers as Record), + }; + + // Only add Content-Type for JSON (not for FormData) + if (!(fetchOptions.body instanceof FormData)) { + headers['Content-Type'] = 'application/json'; + } + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + return fetch(`${API_BASE_URL}${path}`, { + ...fetchOptions, + headers, + }); +}; + +describe('E2E Receipt Processing Journey', () => { + const uniqueId = Date.now(); + const userEmail = `receipt-e2e-${uniqueId}@example.com`; + const userPassword = 'StrongReceiptPassword123!'; + + let authToken: string; + let userId: string | null = null; + const createdReceiptIds: number[] = []; + const createdInventoryIds: number[] = []; + + afterAll(async () => { + const pool = getPool(); + + // Clean up inventory items + if (createdInventoryIds.length > 0) { + await pool.query('DELETE FROM public.user_inventory WHERE inventory_id = ANY($1::int[])', [ + createdInventoryIds, + ]); + } + + // Clean up receipt items and receipts + if (createdReceiptIds.length > 0) { + await pool.query('DELETE FROM public.receipt_items WHERE receipt_id = ANY($1::int[])', [ + createdReceiptIds, + ]); + await pool.query( + 'DELETE FROM public.receipt_processing_logs WHERE receipt_id = ANY($1::int[])', + [createdReceiptIds], + ); + await pool.query('DELETE FROM public.receipts WHERE receipt_id = ANY($1::int[])', [ + createdReceiptIds, + ]); + } + + // Clean up user + await cleanupDb({ + userIds: [userId], + }); + }); + + it('should complete receipt journey: Register -> Upload -> View -> Manage Items -> Add to Inventory', async () => { + // Step 1: Register a new user + const registerResponse = await apiClient.registerUser( + userEmail, + userPassword, + 'Receipt E2E User', + ); + expect(registerResponse.status).toBe(201); + + // Step 2: Login to get auth token + const { response: loginResponse, responseBody: loginResponseBody } = await poll( + async () => { + const response = await apiClient.loginUser(userEmail, userPassword, false); + const responseBody = response.ok ? await response.clone().json() : {}; + return { response, responseBody }; + }, + (result) => result.response.ok, + { timeout: 10000, interval: 1000, description: 'user login after registration' }, + ); + + expect(loginResponse.status).toBe(200); + authToken = loginResponseBody.data.token; + userId = loginResponseBody.data.userprofile.user.user_id; + expect(authToken).toBeDefined(); + + // Step 3: Create a receipt directly in the database (simulating a completed upload) + // In a real E2E test with full BullMQ setup, we would upload and wait for processing + const pool = getPool(); + const receiptResult = await pool.query( + `INSERT INTO public.receipts (user_id, receipt_image_url, status, store_name, total_amount, transaction_date) + VALUES ($1, '/uploads/receipts/e2e-test.jpg', 'completed', 'E2E Test Store', 49.99, '2024-01-15') + RETURNING receipt_id`, + [userId], + ); + const receiptId = receiptResult.rows[0].receipt_id; + createdReceiptIds.push(receiptId); + + // Add receipt items + const itemsResult = await pool.query( + `INSERT INTO public.receipt_items (receipt_id, raw_text, parsed_name, quantity, unit_price, total_price, status, added_to_inventory) + VALUES + ($1, 'MILK 2% 4L', 'Milk 2%', 1, 5.99, 5.99, 'matched', false), + ($1, 'BREAD WHITE', 'White Bread', 2, 2.49, 4.98, 'unmatched', false), + ($1, 'EGGS LARGE 12', 'Large Eggs', 1, 4.99, 4.99, 'matched', false) + RETURNING receipt_item_id`, + [receiptId], + ); + const itemIds = itemsResult.rows.map((r) => r.receipt_item_id); + + // Step 4: View receipt list + const listResponse = await authedFetch('/receipts', { + method: 'GET', + token: authToken, + }); + + expect(listResponse.status).toBe(200); + const listData = await listResponse.json(); + expect(listData.success).toBe(true); + expect(listData.data.receipts.length).toBeGreaterThanOrEqual(1); + + // Find our receipt + const ourReceipt = listData.data.receipts.find( + (r: { receipt_id: number }) => r.receipt_id === receiptId, + ); + expect(ourReceipt).toBeDefined(); + expect(ourReceipt.store_name).toBe('E2E Test Store'); + + // Step 5: View receipt details + const detailResponse = await authedFetch(`/receipts/${receiptId}`, { + method: 'GET', + token: authToken, + }); + + expect(detailResponse.status).toBe(200); + const detailData = await detailResponse.json(); + expect(detailData.data.receipt.receipt_id).toBe(receiptId); + expect(detailData.data.items.length).toBe(3); + + // Step 6: View receipt items + const itemsResponse = await authedFetch(`/receipts/${receiptId}/items`, { + method: 'GET', + token: authToken, + }); + + expect(itemsResponse.status).toBe(200); + const itemsData = await itemsResponse.json(); + expect(itemsData.data.items.length).toBe(3); + + // Step 7: Update an item's status + const updateItemResponse = await authedFetch(`/receipts/${receiptId}/items/${itemIds[1]}`, { + method: 'PUT', + token: authToken, + body: JSON.stringify({ + status: 'matched', + match_confidence: 0.85, + }), + }); + + expect(updateItemResponse.status).toBe(200); + const updateItemData = await updateItemResponse.json(); + expect(updateItemData.data.status).toBe('matched'); + + // Step 8: View unadded items + const unaddedResponse = await authedFetch(`/receipts/${receiptId}/items/unadded`, { + method: 'GET', + token: authToken, + }); + + expect(unaddedResponse.status).toBe(200); + const unaddedData = await unaddedResponse.json(); + expect(unaddedData.data.items.length).toBe(3); // None added yet + + // Step 9: Confirm items to add to inventory + const confirmResponse = await authedFetch(`/receipts/${receiptId}/confirm`, { + method: 'POST', + token: authToken, + body: JSON.stringify({ + items: [ + { + receipt_item_id: itemIds[0], + include: true, + item_name: 'Milk 2%', + quantity: 1, + location: 'fridge', + expiry_date: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], + }, + { + receipt_item_id: itemIds[1], + include: true, + item_name: 'White Bread', + quantity: 2, + location: 'pantry', + }, + { + receipt_item_id: itemIds[2], + include: false, // Skip the eggs + }, + ], + }), + }); + + expect(confirmResponse.status).toBe(200); + const confirmData = await confirmResponse.json(); + expect(confirmData.data.count).toBeGreaterThanOrEqual(0); + + // Track inventory items for cleanup + if (confirmData.data.added_items) { + confirmData.data.added_items.forEach((item: { inventory_id: number }) => { + if (item.inventory_id) { + createdInventoryIds.push(item.inventory_id); + } + }); + } + + // Step 10: Verify items in inventory + const inventoryResponse = await authedFetch('/inventory', { + method: 'GET', + token: authToken, + }); + + expect(inventoryResponse.status).toBe(200); + const inventoryData = await inventoryResponse.json(); + // Should have at least the items we added + expect(inventoryData.data.items.length).toBeGreaterThanOrEqual(0); + + // Step 11: Add processing logs (simulating backend activity) + await pool.query( + `INSERT INTO public.receipt_processing_logs (receipt_id, step, status, message) + VALUES + ($1, 'ocr', 'completed', 'OCR completed successfully'), + ($1, 'item_extraction', 'completed', 'Extracted 3 items'), + ($1, 'matching', 'completed', 'Matched 2 items')`, + [receiptId], + ); + + // Step 12: View processing logs + const logsResponse = await authedFetch(`/receipts/${receiptId}/logs`, { + method: 'GET', + token: authToken, + }); + + expect(logsResponse.status).toBe(200); + const logsData = await logsResponse.json(); + expect(logsData.data.logs.length).toBe(3); + + // Step 13: Verify another user cannot access our receipt + const otherUserEmail = `other-receipt-e2e-${uniqueId}@example.com`; + await apiClient.registerUser(otherUserEmail, userPassword, 'Other Receipt User'); + + const { responseBody: otherLoginData } = await poll( + async () => { + const response = await apiClient.loginUser(otherUserEmail, userPassword, false); + const responseBody = response.ok ? await response.clone().json() : {}; + return { response, responseBody }; + }, + (result) => result.response.ok, + { timeout: 10000, interval: 1000, description: 'other user login' }, + ); + + const otherToken = otherLoginData.data.token; + const otherUserId = otherLoginData.data.userprofile.user.user_id; + + // Other user should not see our receipt + const otherDetailResponse = await authedFetch(`/receipts/${receiptId}`, { + method: 'GET', + token: otherToken, + }); + + expect(otherDetailResponse.status).toBe(404); + + // Clean up other user + await cleanupDb({ userIds: [otherUserId] }); + + // Step 14: Create a second receipt to test listing and filtering + const receipt2Result = await pool.query( + `INSERT INTO public.receipts (user_id, receipt_image_url, status, store_name, total_amount) + VALUES ($1, '/uploads/receipts/e2e-test-2.jpg', 'failed', 'Failed Store', 25.00) + RETURNING receipt_id`, + [userId], + ); + createdReceiptIds.push(receipt2Result.rows[0].receipt_id); + + // Step 15: Test filtering by status + const completedResponse = await authedFetch('/receipts?status=completed', { + method: 'GET', + token: authToken, + }); + + expect(completedResponse.status).toBe(200); + const completedData = await completedResponse.json(); + completedData.data.receipts.forEach((r: { status: string }) => { + expect(r.status).toBe('completed'); + }); + + // Step 16: Test reprocessing a failed receipt + const reprocessResponse = await authedFetch( + `/receipts/${receipt2Result.rows[0].receipt_id}/reprocess`, + { + method: 'POST', + token: authToken, + }, + ); + + expect(reprocessResponse.status).toBe(200); + const reprocessData = await reprocessResponse.json(); + expect(reprocessData.data.message).toContain('reprocessing'); + + // Step 17: Delete the failed receipt + const deleteResponse = await authedFetch(`/receipts/${receipt2Result.rows[0].receipt_id}`, { + method: 'DELETE', + token: authToken, + }); + + expect(deleteResponse.status).toBe(204); + + // Remove from cleanup list since we deleted it + const deleteIndex = createdReceiptIds.indexOf(receipt2Result.rows[0].receipt_id); + if (deleteIndex > -1) { + createdReceiptIds.splice(deleteIndex, 1); + } + + // Step 18: Verify deletion + const verifyDeleteResponse = await authedFetch( + `/receipts/${receipt2Result.rows[0].receipt_id}`, + { + method: 'GET', + token: authToken, + }, + ); + + expect(verifyDeleteResponse.status).toBe(404); + + // Step 19: Delete account + const deleteAccountResponse = await apiClient.deleteUserAccount(userPassword, { + tokenOverride: authToken, + }); + + expect(deleteAccountResponse.status).toBe(200); + userId = null; + }); +}); diff --git a/src/tests/e2e/upc-journey.e2e.test.ts b/src/tests/e2e/upc-journey.e2e.test.ts new file mode 100644 index 0000000..73793cc --- /dev/null +++ b/src/tests/e2e/upc-journey.e2e.test.ts @@ -0,0 +1,247 @@ +// src/tests/e2e/upc-journey.e2e.test.ts +/** + * End-to-End test for the UPC scanning user journey. + * Tests the complete flow from user registration to scanning UPCs and viewing history. + */ +import { describe, it, expect, afterAll } from 'vitest'; +import * as apiClient from '../../services/apiClient'; +import { cleanupDb } from '../utils/cleanup'; +import { poll } from '../utils/poll'; +import { getPool } from '../../services/db/connection.db'; + +/** + * @vitest-environment node + */ + +const API_BASE_URL = process.env.VITE_API_BASE_URL || 'http://localhost:3000/api'; + +// Helper to make authenticated API calls +const authedFetch = async ( + path: string, + options: RequestInit & { token?: string } = {}, +): Promise => { + const { token, ...fetchOptions } = options; + const headers: Record = { + 'Content-Type': 'application/json', + ...(fetchOptions.headers as Record), + }; + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + return fetch(`${API_BASE_URL}${path}`, { + ...fetchOptions, + headers, + }); +}; + +describe('E2E UPC Scanning Journey', () => { + const uniqueId = Date.now(); + const userEmail = `upc-e2e-${uniqueId}@example.com`; + const userPassword = 'StrongUpcPassword123!'; + + let authToken: string; + let userId: string | null = null; + const createdScanIds: number[] = []; + const createdProductIds: number[] = []; + + afterAll(async () => { + const pool = getPool(); + + // Clean up scan history + if (createdScanIds.length > 0) { + await pool.query('DELETE FROM public.upc_scan_history WHERE scan_id = ANY($1::int[])', [ + createdScanIds, + ]); + } + + // Clean up test products + if (createdProductIds.length > 0) { + await pool.query('DELETE FROM public.products WHERE product_id = ANY($1::int[])', [ + createdProductIds, + ]); + } + + // Clean up user + await cleanupDb({ + userIds: [userId], + }); + }); + + it('should complete full UPC scanning journey: Register -> Scan -> Lookup -> History -> Stats', async () => { + // Step 1: Register a new user + const registerResponse = await apiClient.registerUser(userEmail, userPassword, 'UPC E2E User'); + expect(registerResponse.status).toBe(201); + + // Step 2: Login to get auth token + const { response: loginResponse, responseBody: loginResponseBody } = await poll( + async () => { + const response = await apiClient.loginUser(userEmail, userPassword, false); + const responseBody = response.ok ? await response.clone().json() : {}; + return { response, responseBody }; + }, + (result) => result.response.ok, + { timeout: 10000, interval: 1000, description: 'user login after registration' }, + ); + + expect(loginResponse.status).toBe(200); + authToken = loginResponseBody.data.token; + userId = loginResponseBody.data.userprofile.user.user_id; + expect(authToken).toBeDefined(); + + // Step 3: Create a test product with UPC in the database + const pool = getPool(); + const testUpc = `${Date.now()}`.slice(-12).padStart(12, '0'); + const productResult = await pool.query( + `INSERT INTO public.products (name, brand_id, category_id, upc_code, description) + VALUES ('E2E Test Product', 1, 1, $1, 'Product for E2E testing') + RETURNING product_id`, + [testUpc], + ); + const productId = productResult.rows[0].product_id; + createdProductIds.push(productId); + + // Step 4: Scan the UPC code + const scanResponse = await authedFetch('/upc/scan', { + method: 'POST', + token: authToken, + body: JSON.stringify({ + upc_code: testUpc, + scan_source: 'manual_entry', + }), + }); + + expect(scanResponse.status).toBe(201); + const scanData = await scanResponse.json(); + expect(scanData.success).toBe(true); + expect(scanData.data.scan.upc_code).toBe(testUpc); + const scanId = scanData.data.scan.scan_id; + createdScanIds.push(scanId); + + // Step 5: Lookup the product by UPC + const lookupResponse = await authedFetch(`/upc/lookup?upc_code=${testUpc}`, { + method: 'GET', + token: authToken, + }); + + expect(lookupResponse.status).toBe(200); + const lookupData = await lookupResponse.json(); + expect(lookupData.success).toBe(true); + expect(lookupData.data.product).toBeDefined(); + expect(lookupData.data.product.name).toBe('E2E Test Product'); + + // Step 6: Scan a few more items to build history + for (let i = 0; i < 3; i++) { + const additionalScan = await authedFetch('/upc/scan', { + method: 'POST', + token: authToken, + body: JSON.stringify({ + upc_code: `00000000000${i}`, + scan_source: i % 2 === 0 ? 'manual_entry' : 'image_upload', + }), + }); + + if (additionalScan.ok) { + const additionalData = await additionalScan.json(); + if (additionalData.data?.scan?.scan_id) { + createdScanIds.push(additionalData.data.scan.scan_id); + } + } + } + + // Step 7: View scan history + const historyResponse = await authedFetch('/upc/history', { + method: 'GET', + token: authToken, + }); + + expect(historyResponse.status).toBe(200); + const historyData = await historyResponse.json(); + expect(historyData.success).toBe(true); + expect(historyData.data.scans.length).toBeGreaterThanOrEqual(4); // At least our 4 scans + expect(historyData.data.total).toBeGreaterThanOrEqual(4); + + // Step 8: View specific scan details + const scanDetailResponse = await authedFetch(`/upc/history/${scanId}`, { + method: 'GET', + token: authToken, + }); + + expect(scanDetailResponse.status).toBe(200); + const scanDetailData = await scanDetailResponse.json(); + expect(scanDetailData.data.scan.scan_id).toBe(scanId); + expect(scanDetailData.data.scan.upc_code).toBe(testUpc); + + // Step 9: Check user scan statistics + const statsResponse = await authedFetch('/upc/stats', { + method: 'GET', + token: authToken, + }); + + expect(statsResponse.status).toBe(200); + const statsData = await statsResponse.json(); + expect(statsData.success).toBe(true); + expect(statsData.data.stats.total_scans).toBeGreaterThanOrEqual(4); + + // Step 10: Test history filtering by scan_source + const filteredHistoryResponse = await authedFetch('/upc/history?scan_source=manual_entry', { + method: 'GET', + token: authToken, + }); + + expect(filteredHistoryResponse.status).toBe(200); + const filteredData = await filteredHistoryResponse.json(); + filteredData.data.scans.forEach((scan: { scan_source: string }) => { + expect(scan.scan_source).toBe('manual_entry'); + }); + + // Step 11: Verify another user cannot see our scans + const otherUserEmail = `other-upc-e2e-${uniqueId}@example.com`; + await apiClient.registerUser(otherUserEmail, userPassword, 'Other UPC User'); + + const { responseBody: otherLoginData } = await poll( + async () => { + const response = await apiClient.loginUser(otherUserEmail, userPassword, false); + const responseBody = response.ok ? await response.clone().json() : {}; + return { response, responseBody }; + }, + (result) => result.response.ok, + { timeout: 10000, interval: 1000, description: 'other user login' }, + ); + + const otherToken = otherLoginData.data.token; + const otherUserId = otherLoginData.data.userprofile.user.user_id; + + // Other user should not see our scan + const otherScanDetailResponse = await authedFetch(`/upc/history/${scanId}`, { + method: 'GET', + token: otherToken, + }); + + expect(otherScanDetailResponse.status).toBe(404); + + // Other user's history should be empty + const otherHistoryResponse = await authedFetch('/upc/history', { + method: 'GET', + token: otherToken, + }); + + expect(otherHistoryResponse.status).toBe(200); + const otherHistoryData = await otherHistoryResponse.json(); + expect(otherHistoryData.data.total).toBe(0); + + // Clean up other user + await cleanupDb({ userIds: [otherUserId] }); + + // Step 12: Delete account (self-service) + const deleteAccountResponse = await apiClient.deleteUserAccount(userPassword, { + tokenOverride: authToken, + }); + + expect(deleteAccountResponse.status).toBe(200); + + // Mark userId as null to avoid double deletion in afterAll + userId = null; + }); +}); diff --git a/src/tests/integration/inventory.integration.test.ts b/src/tests/integration/inventory.integration.test.ts new file mode 100644 index 0000000..18e4ba6 --- /dev/null +++ b/src/tests/integration/inventory.integration.test.ts @@ -0,0 +1,650 @@ +// src/tests/integration/inventory.integration.test.ts +/** + * Integration tests for Inventory/Expiry management workflow. + * Tests the complete flow from adding items to tracking expiry and alerts. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import supertest from 'supertest'; +import type { UserProfile } from '../../types'; +import { createAndLoginUser } from '../utils/testHelpers'; +import { cleanupDb } from '../utils/cleanup'; +import { getPool } from '../../services/db/connection.db'; + +/** + * @vitest-environment node + */ + +describe('Inventory/Expiry Integration Tests (/api/inventory)', () => { + let request: ReturnType; + let authToken = ''; + let testUser: UserProfile; + const createdUserIds: string[] = []; + const createdInventoryIds: number[] = []; + + beforeAll(async () => { + vi.stubEnv('FRONTEND_URL', 'https://example.com'); + const app = (await import('../../../server')).default; + request = supertest(app); + + // Create a user for inventory tests + const { user, token } = await createAndLoginUser({ + email: `inventory-test-user-${Date.now()}@example.com`, + fullName: 'Inventory Test User', + request, + }); + testUser = user; + authToken = token; + createdUserIds.push(user.user.user_id); + }); + + afterAll(async () => { + vi.unstubAllEnvs(); + + const pool = getPool(); + + // Clean up alert logs + if (createdInventoryIds.length > 0) { + await pool.query('DELETE FROM public.expiry_alert_log WHERE inventory_id = ANY($1::int[])', [ + createdInventoryIds, + ]); + } + + // Clean up inventory items + if (createdInventoryIds.length > 0) { + await pool.query('DELETE FROM public.user_inventory WHERE inventory_id = ANY($1::int[])', [ + createdInventoryIds, + ]); + } + + // Clean up user alert settings + await pool.query('DELETE FROM public.user_expiry_alert_settings WHERE user_id = $1', [ + testUser.user.user_id, + ]); + + await cleanupDb({ userIds: createdUserIds }); + }); + + describe('POST /api/inventory - Add Inventory Item', () => { + it('should add a new inventory item', async () => { + const response = await request + .post('/api/inventory') + .set('Authorization', `Bearer ${authToken}`) + .send({ + item_name: 'Milk 2%', + quantity: 2, + location: 'fridge', + expiry_date: '2024-02-15', + }); + + expect(response.status).toBe(201); + expect(response.body.success).toBe(true); + expect(response.body.data.inventory_id).toBeDefined(); + expect(response.body.data.item_name).toBe('Milk 2%'); + expect(response.body.data.quantity).toBe(2); + expect(response.body.data.location).toBe('fridge'); + + createdInventoryIds.push(response.body.data.inventory_id); + }); + + it('should add item without expiry date', async () => { + const response = await request + .post('/api/inventory') + .set('Authorization', `Bearer ${authToken}`) + .send({ + item_name: 'Rice', + quantity: 1, + location: 'pantry', + }); + + expect(response.status).toBe(201); + expect(response.body.data.expiry_date).toBeNull(); + + createdInventoryIds.push(response.body.data.inventory_id); + }); + + it('should add item with notes and purchase_date', async () => { + const response = await request + .post('/api/inventory') + .set('Authorization', `Bearer ${authToken}`) + .send({ + item_name: 'Cheese', + quantity: 1, + location: 'fridge', + expiry_date: '2024-03-01', + notes: 'Sharp cheddar from local farm', + purchase_date: '2024-01-10', + }); + + expect(response.status).toBe(201); + expect(response.body.data.notes).toBe('Sharp cheddar from local farm'); + + createdInventoryIds.push(response.body.data.inventory_id); + }); + + it('should reject invalid location', async () => { + const response = await request + .post('/api/inventory') + .set('Authorization', `Bearer ${authToken}`) + .send({ + item_name: 'Test Item', + quantity: 1, + location: 'invalid_location', + }); + + expect(response.status).toBe(400); + }); + + it('should reject missing item_name', async () => { + const response = await request + .post('/api/inventory') + .set('Authorization', `Bearer ${authToken}`) + .send({ + quantity: 1, + location: 'fridge', + }); + + expect(response.status).toBe(400); + }); + + it('should reject unauthenticated requests', async () => { + const response = await request.post('/api/inventory').send({ + item_name: 'Test Item', + quantity: 1, + location: 'fridge', + }); + + expect(response.status).toBe(401); + }); + }); + + describe('GET /api/inventory - List Inventory', () => { + beforeAll(async () => { + // Create varied inventory items for testing + const items = [ + { name: 'Yogurt', location: 'fridge', expiry: '2024-01-20' }, + { name: 'Frozen Peas', location: 'freezer', expiry: '2024-06-01' }, + { name: 'Pasta', location: 'pantry', expiry: null }, + { name: 'Bananas', location: 'room_temp', expiry: '2024-01-18' }, + ]; + + for (const item of items) { + const response = await request + .post('/api/inventory') + .set('Authorization', `Bearer ${authToken}`) + .send({ + item_name: item.name, + quantity: 1, + location: item.location, + expiry_date: item.expiry, + }); + + if (response.body.data?.inventory_id) { + createdInventoryIds.push(response.body.data.inventory_id); + } + } + }); + + it('should return all inventory items', async () => { + const response = await request + .get('/api/inventory') + .set('Authorization', `Bearer ${authToken}`); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.items).toBeDefined(); + expect(Array.isArray(response.body.data.items)).toBe(true); + expect(response.body.data.total).toBeGreaterThanOrEqual(4); + }); + + it('should filter by location', async () => { + const response = await request + .get('/api/inventory') + .query({ location: 'fridge' }) + .set('Authorization', `Bearer ${authToken}`); + + expect(response.status).toBe(200); + response.body.data.items.forEach((item: { location: string }) => { + expect(item.location).toBe('fridge'); + }); + }); + + it('should support pagination', async () => { + const response = await request + .get('/api/inventory') + .query({ limit: 2, offset: 0 }) + .set('Authorization', `Bearer ${authToken}`); + + expect(response.status).toBe(200); + expect(response.body.data.items.length).toBeLessThanOrEqual(2); + }); + + it('should filter by expiry_status', async () => { + const response = await request + .get('/api/inventory') + .query({ expiry_status: 'fresh' }) + .set('Authorization', `Bearer ${authToken}`); + + expect(response.status).toBe(200); + // All returned items should have fresh status + response.body.data.items.forEach((item: { expiry_status: string }) => { + expect(item.expiry_status).toBe('fresh'); + }); + }); + + it('should only return items for the authenticated user', async () => { + const { user: otherUser, token: otherToken } = await createAndLoginUser({ + email: `other-inventory-user-${Date.now()}@example.com`, + fullName: 'Other Inventory User', + request, + }); + createdUserIds.push(otherUser.user.user_id); + + const response = await request + .get('/api/inventory') + .set('Authorization', `Bearer ${otherToken}`); + + expect(response.status).toBe(200); + expect(response.body.data.total).toBe(0); + }); + }); + + describe('GET /api/inventory/:id - Get Single Item', () => { + let testItemId: number; + + beforeAll(async () => { + const response = await request + .post('/api/inventory') + .set('Authorization', `Bearer ${authToken}`) + .send({ + item_name: 'Single Item Test', + quantity: 3, + location: 'fridge', + expiry_date: '2024-02-20', + }); + + testItemId = response.body.data.inventory_id; + createdInventoryIds.push(testItemId); + }); + + it('should return item details', async () => { + const response = await request + .get(`/api/inventory/${testItemId}`) + .set('Authorization', `Bearer ${authToken}`); + + expect(response.status).toBe(200); + expect(response.body.data.item.inventory_id).toBe(testItemId); + expect(response.body.data.item.item_name).toBe('Single Item Test'); + }); + + it('should return 404 for non-existent item', async () => { + const response = await request + .get('/api/inventory/999999') + .set('Authorization', `Bearer ${authToken}`); + + expect(response.status).toBe(404); + }); + + it("should not allow accessing another user's item", async () => { + const { user: otherUser, token: otherToken } = await createAndLoginUser({ + email: `item-access-test-${Date.now()}@example.com`, + fullName: 'Item Access Test', + request, + }); + createdUserIds.push(otherUser.user.user_id); + + const response = await request + .get(`/api/inventory/${testItemId}`) + .set('Authorization', `Bearer ${otherToken}`); + + expect(response.status).toBe(404); + }); + }); + + describe('PUT /api/inventory/:id - Update Item', () => { + let updateItemId: number; + + beforeAll(async () => { + const response = await request + .post('/api/inventory') + .set('Authorization', `Bearer ${authToken}`) + .send({ + item_name: 'Update Test Item', + quantity: 1, + location: 'fridge', + }); + + updateItemId = response.body.data.inventory_id; + createdInventoryIds.push(updateItemId); + }); + + it('should update item quantity', async () => { + const response = await request + .put(`/api/inventory/${updateItemId}`) + .set('Authorization', `Bearer ${authToken}`) + .send({ quantity: 5 }); + + expect(response.status).toBe(200); + expect(response.body.data.quantity).toBe(5); + }); + + it('should update item location', async () => { + const response = await request + .put(`/api/inventory/${updateItemId}`) + .set('Authorization', `Bearer ${authToken}`) + .send({ location: 'freezer' }); + + expect(response.status).toBe(200); + expect(response.body.data.location).toBe('freezer'); + }); + + it('should update expiry_date', async () => { + const response = await request + .put(`/api/inventory/${updateItemId}`) + .set('Authorization', `Bearer ${authToken}`) + .send({ expiry_date: '2024-03-15' }); + + expect(response.status).toBe(200); + expect(response.body.data.expiry_date).toContain('2024-03-15'); + }); + + it('should reject empty update body', async () => { + const response = await request + .put(`/api/inventory/${updateItemId}`) + .set('Authorization', `Bearer ${authToken}`) + .send({}); + + expect(response.status).toBe(400); + }); + }); + + describe('DELETE /api/inventory/:id - Delete Item', () => { + it('should delete an inventory item', async () => { + // Create item to delete + const createResponse = await request + .post('/api/inventory') + .set('Authorization', `Bearer ${authToken}`) + .send({ + item_name: 'Delete Test Item', + quantity: 1, + location: 'pantry', + }); + + const itemId = createResponse.body.data.inventory_id; + + const response = await request + .delete(`/api/inventory/${itemId}`) + .set('Authorization', `Bearer ${authToken}`); + + expect(response.status).toBe(204); + + // Verify deletion + const verifyResponse = await request + .get(`/api/inventory/${itemId}`) + .set('Authorization', `Bearer ${authToken}`); + + expect(verifyResponse.status).toBe(404); + }); + }); + + describe('POST /api/inventory/:id/consume - Mark as Consumed', () => { + let consumeItemId: number; + + beforeAll(async () => { + const response = await request + .post('/api/inventory') + .set('Authorization', `Bearer ${authToken}`) + .send({ + item_name: 'Consume Test Item', + quantity: 5, + location: 'fridge', + }); + + consumeItemId = response.body.data.inventory_id; + createdInventoryIds.push(consumeItemId); + }); + + it('should mark item as consumed', async () => { + const response = await request + .post(`/api/inventory/${consumeItemId}/consume`) + .set('Authorization', `Bearer ${authToken}`) + .send({ quantity_consumed: 2 }); + + expect(response.status).toBe(200); + expect(response.body.data.quantity).toBe(3); // 5 - 2 + }); + + it('should fully consume item when all used', async () => { + const response = await request + .post(`/api/inventory/${consumeItemId}/consume`) + .set('Authorization', `Bearer ${authToken}`) + .send({ quantity_consumed: 3 }); + + expect(response.status).toBe(200); + expect(response.body.data.is_consumed).toBe(true); + }); + + it('should reject consuming more than available', async () => { + // Create new item first + const createResponse = await request + .post('/api/inventory') + .set('Authorization', `Bearer ${authToken}`) + .send({ + item_name: 'Limited Item', + quantity: 1, + location: 'fridge', + }); + + const itemId = createResponse.body.data.inventory_id; + createdInventoryIds.push(itemId); + + const response = await request + .post(`/api/inventory/${itemId}/consume`) + .set('Authorization', `Bearer ${authToken}`) + .send({ quantity_consumed: 10 }); + + expect(response.status).toBe(400); + }); + }); + + describe('GET /api/inventory/expiring - Expiring Items', () => { + beforeAll(async () => { + // Create items with various expiry dates + const today = new Date(); + const items = [ + { + name: 'Expiring Tomorrow', + expiry: new Date(today.getTime() + 24 * 60 * 60 * 1000).toISOString().split('T')[0], + }, + { + name: 'Expiring in 3 days', + expiry: new Date(today.getTime() + 3 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], + }, + { + name: 'Expiring in 10 days', + expiry: new Date(today.getTime() + 10 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], + }, + ]; + + for (const item of items) { + const response = await request + .post('/api/inventory') + .set('Authorization', `Bearer ${authToken}`) + .send({ + item_name: item.name, + quantity: 1, + location: 'fridge', + expiry_date: item.expiry, + }); + + if (response.body.data?.inventory_id) { + createdInventoryIds.push(response.body.data.inventory_id); + } + } + }); + + it('should return items expiring within default days', async () => { + const response = await request + .get('/api/inventory/expiring') + .set('Authorization', `Bearer ${authToken}`); + + expect(response.status).toBe(200); + expect(response.body.data.items).toBeDefined(); + expect(Array.isArray(response.body.data.items)).toBe(true); + }); + + it('should respect days_ahead parameter', async () => { + const response = await request + .get('/api/inventory/expiring') + .query({ days_ahead: 2 }) + .set('Authorization', `Bearer ${authToken}`); + + expect(response.status).toBe(200); + // Should only include items expiring within 2 days + }); + }); + + describe('GET /api/inventory/expired - Expired Items', () => { + beforeAll(async () => { + // Insert an already expired item directly into the database + const pool = getPool(); + const pastDate = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + const result = await pool.query( + `INSERT INTO public.user_inventory (user_id, item_name, quantity, location, expiry_date) + VALUES ($1, 'Expired Item', 1, 'fridge', $2) + RETURNING inventory_id`, + [testUser.user.user_id, pastDate], + ); + createdInventoryIds.push(result.rows[0].inventory_id); + }); + + it('should return expired items', async () => { + const response = await request + .get('/api/inventory/expired') + .set('Authorization', `Bearer ${authToken}`); + + expect(response.status).toBe(200); + expect(response.body.data.items).toBeDefined(); + expect(Array.isArray(response.body.data.items)).toBe(true); + // Should include the expired item + expect(response.body.data.items.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('Alert Settings', () => { + describe('GET /api/inventory/alerts/settings', () => { + it('should return default alert settings', async () => { + const response = await request + .get('/api/inventory/alerts/settings') + .set('Authorization', `Bearer ${authToken}`); + + expect(response.status).toBe(200); + expect(response.body.data.settings).toBeDefined(); + expect(response.body.data.settings.alerts_enabled).toBeDefined(); + }); + }); + + describe('PUT /api/inventory/alerts/settings', () => { + it('should update alert settings', async () => { + const response = await request + .put('/api/inventory/alerts/settings') + .set('Authorization', `Bearer ${authToken}`) + .send({ + alerts_enabled: true, + days_before_expiry: 5, + alert_time: '09:00', + }); + + expect(response.status).toBe(200); + expect(response.body.data.settings.alerts_enabled).toBe(true); + expect(response.body.data.settings.days_before_expiry).toBe(5); + }); + + it('should reject invalid days_before_expiry', async () => { + const response = await request + .put('/api/inventory/alerts/settings') + .set('Authorization', `Bearer ${authToken}`) + .send({ + days_before_expiry: -1, + }); + + expect(response.status).toBe(400); + }); + }); + }); + + describe('GET /api/inventory/recipes/suggestions - Recipe Suggestions', () => { + it('should return recipe suggestions for expiring items', async () => { + const response = await request + .get('/api/inventory/recipes/suggestions') + .set('Authorization', `Bearer ${authToken}`); + + expect(response.status).toBe(200); + expect(response.body.data.suggestions).toBeDefined(); + expect(Array.isArray(response.body.data.suggestions)).toBe(true); + }); + }); + + describe('Complete Inventory Workflow', () => { + it('should handle full add-track-consume workflow', async () => { + // Step 1: Add item + const addResponse = await request + .post('/api/inventory') + .set('Authorization', `Bearer ${authToken}`) + .send({ + item_name: 'Workflow Test Item', + quantity: 10, + location: 'fridge', + expiry_date: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], + }); + + expect(addResponse.status).toBe(201); + const itemId = addResponse.body.data.inventory_id; + createdInventoryIds.push(itemId); + + // Step 2: Verify in list + const listResponse = await request + .get('/api/inventory') + .set('Authorization', `Bearer ${authToken}`); + + const found = listResponse.body.data.items.find( + (i: { inventory_id: number }) => i.inventory_id === itemId, + ); + expect(found).toBeDefined(); + + // Step 3: Check in expiring items + const expiringResponse = await request + .get('/api/inventory/expiring') + .query({ days_ahead: 10 }) + .set('Authorization', `Bearer ${authToken}`); + + expect(expiringResponse.status).toBe(200); + + // Step 4: Consume some + const consumeResponse = await request + .post(`/api/inventory/${itemId}/consume`) + .set('Authorization', `Bearer ${authToken}`) + .send({ quantity_consumed: 5 }); + + expect(consumeResponse.status).toBe(200); + expect(consumeResponse.body.data.quantity).toBe(5); + + // Step 5: Update location + const updateResponse = await request + .put(`/api/inventory/${itemId}`) + .set('Authorization', `Bearer ${authToken}`) + .send({ location: 'freezer' }); + + expect(updateResponse.status).toBe(200); + expect(updateResponse.body.data.location).toBe('freezer'); + + // Step 6: Fully consume + const finalConsumeResponse = await request + .post(`/api/inventory/${itemId}/consume`) + .set('Authorization', `Bearer ${authToken}`) + .send({ quantity_consumed: 5 }); + + expect(finalConsumeResponse.status).toBe(200); + expect(finalConsumeResponse.body.data.is_consumed).toBe(true); + }); + }); +}); diff --git a/src/tests/integration/receipt.integration.test.ts b/src/tests/integration/receipt.integration.test.ts new file mode 100644 index 0000000..cbe9d0a --- /dev/null +++ b/src/tests/integration/receipt.integration.test.ts @@ -0,0 +1,591 @@ +// src/tests/integration/receipt.integration.test.ts +/** + * Integration tests for Receipt processing workflow. + * Tests the complete flow from receipt upload to item extraction and inventory addition. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import supertest from 'supertest'; +import type { UserProfile } from '../../types'; +import { createAndLoginUser } from '../utils/testHelpers'; +import { cleanupDb } from '../utils/cleanup'; +import { getPool } from '../../services/db/connection.db'; + +/** + * @vitest-environment node + */ + +// Mock the receipt queue to prevent actual background processing +vi.mock('../../services/queues.server', () => ({ + receiptQueue: { + add: vi.fn().mockResolvedValue({ id: 'mock-job-id' }), + }, +})); + +describe('Receipt Processing Integration Tests (/api/receipts)', () => { + let request: ReturnType; + let authToken = ''; + let testUser: UserProfile; + const createdUserIds: string[] = []; + const createdReceiptIds: number[] = []; + const createdInventoryIds: number[] = []; + + beforeAll(async () => { + vi.stubEnv('FRONTEND_URL', 'https://example.com'); + const app = (await import('../../../server')).default; + request = supertest(app); + + // Create a user for receipt tests + const { user, token } = await createAndLoginUser({ + email: `receipt-test-user-${Date.now()}@example.com`, + fullName: 'Receipt Test User', + request, + }); + testUser = user; + authToken = token; + createdUserIds.push(user.user.user_id); + }); + + afterAll(async () => { + vi.unstubAllEnvs(); + + const pool = getPool(); + + // Clean up inventory items + if (createdInventoryIds.length > 0) { + await pool.query('DELETE FROM public.user_inventory WHERE inventory_id = ANY($1::int[])', [ + createdInventoryIds, + ]); + } + + // Clean up receipt items and receipts + if (createdReceiptIds.length > 0) { + await pool.query('DELETE FROM public.receipt_items WHERE receipt_id = ANY($1::int[])', [ + createdReceiptIds, + ]); + await pool.query( + 'DELETE FROM public.receipt_processing_logs WHERE receipt_id = ANY($1::int[])', + [createdReceiptIds], + ); + await pool.query('DELETE FROM public.receipts WHERE receipt_id = ANY($1::int[])', [ + createdReceiptIds, + ]); + } + + await cleanupDb({ userIds: createdUserIds }); + }); + + describe('POST /api/receipts - Upload Receipt', () => { + it('should upload a receipt image successfully', async () => { + // Create a simple test image buffer + const testImageBuffer = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'base64', + ); + + const response = await request + .post('/api/receipts') + .set('Authorization', `Bearer ${authToken}`) + .attach('receipt', testImageBuffer, 'test-receipt.png') + .field('store_id', '1') + .field('transaction_date', '2024-01-15'); + + expect(response.status).toBe(201); + expect(response.body.success).toBe(true); + expect(response.body.data.receipt_id).toBeDefined(); + expect(response.body.data.job_id).toBe('mock-job-id'); + + createdReceiptIds.push(response.body.data.receipt_id); + }); + + it('should upload receipt without optional fields', async () => { + const testImageBuffer = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'base64', + ); + + const response = await request + .post('/api/receipts') + .set('Authorization', `Bearer ${authToken}`) + .attach('receipt', testImageBuffer, 'test-receipt-2.png'); + + expect(response.status).toBe(201); + expect(response.body.data.receipt_id).toBeDefined(); + + createdReceiptIds.push(response.body.data.receipt_id); + }); + + it('should reject request without file', async () => { + const response = await request + .post('/api/receipts') + .set('Authorization', `Bearer ${authToken}`); + + expect(response.status).toBe(400); + }); + + it('should reject unauthenticated requests', async () => { + const testImageBuffer = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'base64', + ); + + const response = await request + .post('/api/receipts') + .attach('receipt', testImageBuffer, 'test-receipt.png'); + + expect(response.status).toBe(401); + }); + }); + + describe('GET /api/receipts - List Receipts', () => { + beforeAll(async () => { + // Create some receipts for testing + const pool = getPool(); + for (let i = 0; i < 3; i++) { + const result = await pool.query( + `INSERT INTO public.receipts (user_id, receipt_image_url, status) + VALUES ($1, $2, $3) + RETURNING receipt_id`, + [ + testUser.user.user_id, + `/uploads/receipts/test-${i}.jpg`, + i === 0 ? 'completed' : 'pending', + ], + ); + createdReceiptIds.push(result.rows[0].receipt_id); + } + }); + + it('should return paginated list of receipts', async () => { + const response = await request + .get('/api/receipts') + .set('Authorization', `Bearer ${authToken}`); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.receipts).toBeDefined(); + expect(Array.isArray(response.body.data.receipts)).toBe(true); + expect(response.body.data.total).toBeGreaterThanOrEqual(3); + }); + + it('should support status filter', async () => { + const response = await request + .get('/api/receipts') + .query({ status: 'completed' }) + .set('Authorization', `Bearer ${authToken}`); + + expect(response.status).toBe(200); + response.body.data.receipts.forEach((receipt: { status: string }) => { + expect(receipt.status).toBe('completed'); + }); + }); + + it('should support pagination', async () => { + const response = await request + .get('/api/receipts') + .query({ limit: 2, offset: 0 }) + .set('Authorization', `Bearer ${authToken}`); + + expect(response.status).toBe(200); + expect(response.body.data.receipts.length).toBeLessThanOrEqual(2); + }); + + it('should only return receipts for the authenticated user', async () => { + // Create another user + const { user: otherUser, token: otherToken } = await createAndLoginUser({ + email: `other-receipt-user-${Date.now()}@example.com`, + fullName: 'Other Receipt User', + request, + }); + createdUserIds.push(otherUser.user.user_id); + + const response = await request + .get('/api/receipts') + .set('Authorization', `Bearer ${otherToken}`); + + expect(response.status).toBe(200); + // Other user should have no receipts + expect(response.body.data.total).toBe(0); + }); + }); + + describe('GET /api/receipts/:receiptId - Get Receipt Details', () => { + let testReceiptId: number; + + beforeAll(async () => { + const pool = getPool(); + const result = await pool.query( + `INSERT INTO public.receipts (user_id, receipt_image_url, status, store_name, total_amount) + VALUES ($1, $2, 'completed', 'Test Store', 99.99) + RETURNING receipt_id`, + [testUser.user.user_id, '/uploads/receipts/detail-test.jpg'], + ); + testReceiptId = result.rows[0].receipt_id; + createdReceiptIds.push(testReceiptId); + + // Add some items to the receipt + await pool.query( + `INSERT INTO public.receipt_items (receipt_id, raw_text, parsed_name, quantity, unit_price, total_price, status) + VALUES ($1, 'MILK 2% 4L', 'Milk 2%', 1, 5.99, 5.99, 'matched'), + ($1, 'BREAD WHITE', 'White Bread', 2, 2.49, 4.98, 'unmatched')`, + [testReceiptId], + ); + }); + + it('should return receipt with items', async () => { + const response = await request + .get(`/api/receipts/${testReceiptId}`) + .set('Authorization', `Bearer ${authToken}`); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.receipt).toBeDefined(); + expect(response.body.data.receipt.receipt_id).toBe(testReceiptId); + expect(response.body.data.receipt.store_name).toBe('Test Store'); + expect(response.body.data.items).toBeDefined(); + expect(response.body.data.items.length).toBe(2); + }); + + it('should return 404 for non-existent receipt', async () => { + const response = await request + .get('/api/receipts/999999') + .set('Authorization', `Bearer ${authToken}`); + + expect(response.status).toBe(404); + }); + + it("should not allow accessing another user's receipt", async () => { + const { user: otherUser, token: otherToken } = await createAndLoginUser({ + email: `receipt-access-test-${Date.now()}@example.com`, + fullName: 'Receipt Access Test', + request, + }); + createdUserIds.push(otherUser.user.user_id); + + const response = await request + .get(`/api/receipts/${testReceiptId}`) + .set('Authorization', `Bearer ${otherToken}`); + + expect(response.status).toBe(404); + }); + }); + + describe('DELETE /api/receipts/:receiptId - Delete Receipt', () => { + it('should delete a receipt', async () => { + // Create a receipt to delete + const pool = getPool(); + const result = await pool.query( + `INSERT INTO public.receipts (user_id, receipt_image_url, status) + VALUES ($1, '/uploads/receipts/delete-test.jpg', 'pending') + RETURNING receipt_id`, + [testUser.user.user_id], + ); + const receiptId = result.rows[0].receipt_id; + + const response = await request + .delete(`/api/receipts/${receiptId}`) + .set('Authorization', `Bearer ${authToken}`); + + expect(response.status).toBe(204); + + // Verify deletion + const verifyResponse = await request + .get(`/api/receipts/${receiptId}`) + .set('Authorization', `Bearer ${authToken}`); + + expect(verifyResponse.status).toBe(404); + }); + }); + + describe('POST /api/receipts/:receiptId/reprocess - Reprocess Receipt', () => { + let failedReceiptId: number; + + beforeAll(async () => { + const pool = getPool(); + const result = await pool.query( + `INSERT INTO public.receipts (user_id, receipt_image_url, status, error_message) + VALUES ($1, '/uploads/receipts/failed-test.jpg', 'failed', 'OCR failed') + RETURNING receipt_id`, + [testUser.user.user_id], + ); + failedReceiptId = result.rows[0].receipt_id; + createdReceiptIds.push(failedReceiptId); + }); + + it('should queue a failed receipt for reprocessing', async () => { + const response = await request + .post(`/api/receipts/${failedReceiptId}/reprocess`) + .set('Authorization', `Bearer ${authToken}`); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.message).toContain('reprocessing'); + expect(response.body.data.job_id).toBe('mock-job-id'); + }); + + it('should return 404 for non-existent receipt', async () => { + const response = await request + .post('/api/receipts/999999/reprocess') + .set('Authorization', `Bearer ${authToken}`); + + expect(response.status).toBe(404); + }); + }); + + describe('Receipt Items Management', () => { + let receiptWithItemsId: number; + let testItemId: number; + + beforeAll(async () => { + const pool = getPool(); + const receiptResult = await pool.query( + `INSERT INTO public.receipts (user_id, receipt_image_url, status) + VALUES ($1, '/uploads/receipts/items-test.jpg', 'completed') + RETURNING receipt_id`, + [testUser.user.user_id], + ); + receiptWithItemsId = receiptResult.rows[0].receipt_id; + createdReceiptIds.push(receiptWithItemsId); + + const itemResult = await pool.query( + `INSERT INTO public.receipt_items (receipt_id, raw_text, parsed_name, quantity, unit_price, total_price, status) + VALUES ($1, 'EGGS LARGE 12CT', 'Large Eggs', 1, 4.99, 4.99, 'unmatched') + RETURNING receipt_item_id`, + [receiptWithItemsId], + ); + testItemId = itemResult.rows[0].receipt_item_id; + }); + + describe('GET /api/receipts/:receiptId/items', () => { + it('should return all receipt items', async () => { + const response = await request + .get(`/api/receipts/${receiptWithItemsId}/items`) + .set('Authorization', `Bearer ${authToken}`); + + expect(response.status).toBe(200); + expect(response.body.data.items).toBeDefined(); + expect(response.body.data.items.length).toBeGreaterThanOrEqual(1); + expect(response.body.data.total).toBeGreaterThanOrEqual(1); + }); + }); + + describe('PUT /api/receipts/:receiptId/items/:itemId', () => { + it('should update item status', async () => { + const response = await request + .put(`/api/receipts/${receiptWithItemsId}/items/${testItemId}`) + .set('Authorization', `Bearer ${authToken}`) + .send({ status: 'matched', match_confidence: 0.95 }); + + expect(response.status).toBe(200); + expect(response.body.data.status).toBe('matched'); + }); + + it('should reject invalid status', async () => { + const response = await request + .put(`/api/receipts/${receiptWithItemsId}/items/${testItemId}`) + .set('Authorization', `Bearer ${authToken}`) + .send({ status: 'invalid_status' }); + + expect(response.status).toBe(400); + }); + }); + + describe('GET /api/receipts/:receiptId/items/unadded', () => { + it('should return unadded items', async () => { + const response = await request + .get(`/api/receipts/${receiptWithItemsId}/items/unadded`) + .set('Authorization', `Bearer ${authToken}`); + + expect(response.status).toBe(200); + expect(response.body.data.items).toBeDefined(); + expect(Array.isArray(response.body.data.items)).toBe(true); + }); + }); + }); + + describe('POST /api/receipts/:receiptId/confirm - Confirm Items to Inventory', () => { + let receiptForConfirmId: number; + let itemToConfirmId: number; + + beforeAll(async () => { + const pool = getPool(); + const receiptResult = await pool.query( + `INSERT INTO public.receipts (user_id, receipt_image_url, status) + VALUES ($1, '/uploads/receipts/confirm-test.jpg', 'completed') + RETURNING receipt_id`, + [testUser.user.user_id], + ); + receiptForConfirmId = receiptResult.rows[0].receipt_id; + createdReceiptIds.push(receiptForConfirmId); + + const itemResult = await pool.query( + `INSERT INTO public.receipt_items (receipt_id, raw_text, parsed_name, quantity, unit_price, total_price, status, added_to_inventory) + VALUES ($1, 'YOGURT GREEK', 'Greek Yogurt', 2, 3.99, 7.98, 'matched', false) + RETURNING receipt_item_id`, + [receiptForConfirmId], + ); + itemToConfirmId = itemResult.rows[0].receipt_item_id; + }); + + it('should confirm items and add to inventory', async () => { + const response = await request + .post(`/api/receipts/${receiptForConfirmId}/confirm`) + .set('Authorization', `Bearer ${authToken}`) + .send({ + items: [ + { + receipt_item_id: itemToConfirmId, + include: true, + item_name: 'Greek Yogurt', + quantity: 2, + location: 'fridge', + expiry_date: '2024-02-15', + }, + ], + }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.added_items).toBeDefined(); + expect(response.body.data.count).toBeGreaterThanOrEqual(0); + + // Track created inventory items for cleanup + if (response.body.data.added_items) { + response.body.data.added_items.forEach((item: { inventory_id: number }) => { + if (item.inventory_id) { + createdInventoryIds.push(item.inventory_id); + } + }); + } + }); + + it('should skip items with include: false', async () => { + const pool = getPool(); + const itemResult = await pool.query( + `INSERT INTO public.receipt_items (receipt_id, raw_text, parsed_name, quantity, unit_price, total_price, status, added_to_inventory) + VALUES ($1, 'CHIPS BBQ', 'BBQ Chips', 1, 4.99, 4.99, 'matched', false) + RETURNING receipt_item_id`, + [receiptForConfirmId], + ); + const skipItemId = itemResult.rows[0].receipt_item_id; + + const response = await request + .post(`/api/receipts/${receiptForConfirmId}/confirm`) + .set('Authorization', `Bearer ${authToken}`) + .send({ + items: [ + { + receipt_item_id: skipItemId, + include: false, + }, + ], + }); + + expect(response.status).toBe(200); + // No items should be added when all are excluded + }); + + it('should reject invalid location', async () => { + const response = await request + .post(`/api/receipts/${receiptForConfirmId}/confirm`) + .set('Authorization', `Bearer ${authToken}`) + .send({ + items: [ + { + receipt_item_id: itemToConfirmId, + include: true, + location: 'invalid_location', + }, + ], + }); + + expect(response.status).toBe(400); + }); + }); + + describe('GET /api/receipts/:receiptId/logs - Processing Logs', () => { + let receiptWithLogsId: number; + + beforeAll(async () => { + const pool = getPool(); + const receiptResult = await pool.query( + `INSERT INTO public.receipts (user_id, receipt_image_url, status) + VALUES ($1, '/uploads/receipts/logs-test.jpg', 'completed') + RETURNING receipt_id`, + [testUser.user.user_id], + ); + receiptWithLogsId = receiptResult.rows[0].receipt_id; + createdReceiptIds.push(receiptWithLogsId); + + // Add processing logs + await pool.query( + `INSERT INTO public.receipt_processing_logs (receipt_id, step, status, message) + VALUES ($1, 'ocr', 'completed', 'OCR completed successfully'), + ($1, 'item_extraction', 'completed', 'Extracted 5 items'), + ($1, 'matching', 'completed', 'Matched 3 items')`, + [receiptWithLogsId], + ); + }); + + it('should return processing logs', async () => { + const response = await request + .get(`/api/receipts/${receiptWithLogsId}/logs`) + .set('Authorization', `Bearer ${authToken}`); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.logs).toBeDefined(); + expect(response.body.data.logs.length).toBe(3); + expect(response.body.data.total).toBe(3); + }); + }); + + describe('Complete Receipt Workflow', () => { + it('should handle full upload-process-confirm workflow', async () => { + // Note: Full workflow with actual processing would require BullMQ worker + // This test verifies the API contract works correctly + + // Step 1: Upload receipt + const testImageBuffer = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'base64', + ); + + const uploadResponse = await request + .post('/api/receipts') + .set('Authorization', `Bearer ${authToken}`) + .attach('receipt', testImageBuffer, 'workflow-test.png') + .field('transaction_date', '2024-01-20'); + + expect(uploadResponse.status).toBe(201); + const receiptId = uploadResponse.body.data.receipt_id; + createdReceiptIds.push(receiptId); + + // Step 2: Verify receipt was created + const getResponse = await request + .get(`/api/receipts/${receiptId}`) + .set('Authorization', `Bearer ${authToken}`); + + expect(getResponse.status).toBe(200); + expect(getResponse.body.data.receipt.receipt_id).toBe(receiptId); + + // Step 3: Check it appears in list + const listResponse = await request + .get('/api/receipts') + .set('Authorization', `Bearer ${authToken}`); + + expect(listResponse.status).toBe(200); + const found = listResponse.body.data.receipts.find( + (r: { receipt_id: number }) => r.receipt_id === receiptId, + ); + expect(found).toBeDefined(); + + // Step 4: Verify logs endpoint works (empty for new receipt) + const logsResponse = await request + .get(`/api/receipts/${receiptId}/logs`) + .set('Authorization', `Bearer ${authToken}`); + + expect(logsResponse.status).toBe(200); + expect(Array.isArray(logsResponse.body.data.logs)).toBe(true); + }); + }); +}); diff --git a/src/tests/integration/upc.integration.test.ts b/src/tests/integration/upc.integration.test.ts new file mode 100644 index 0000000..0b0377e --- /dev/null +++ b/src/tests/integration/upc.integration.test.ts @@ -0,0 +1,450 @@ +// src/tests/integration/upc.integration.test.ts +/** + * Integration tests for UPC scanning workflow. + * Tests the complete flow from scanning a UPC code to product lookup and history tracking. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import supertest from 'supertest'; +import type { UserProfile } from '../../types'; +import { createAndLoginUser } from '../utils/testHelpers'; +import { cleanupDb } from '../utils/cleanup'; +import { getPool } from '../../services/db/connection.db'; + +/** + * @vitest-environment node + */ + +describe('UPC Scanning Integration Tests (/api/upc)', () => { + let request: ReturnType; + let authToken = ''; + let _testUser: UserProfile; + let adminToken = ''; + let _adminUser: UserProfile; + const createdUserIds: string[] = []; + const createdScanIds: number[] = []; + const createdProductIds: number[] = []; + + beforeAll(async () => { + vi.stubEnv('FRONTEND_URL', 'https://example.com'); + const app = (await import('../../../server')).default; + request = supertest(app); + + // Create a regular user for UPC scanning tests + const { user, token } = await createAndLoginUser({ + email: `upc-test-user-${Date.now()}@example.com`, + fullName: 'UPC Test User', + request, + }); + _testUser = user; + authToken = token; + createdUserIds.push(user.user.user_id); + + // Create an admin user for admin-only routes + const { user: admin, token: aToken } = await createAndLoginUser({ + email: `upc-admin-${Date.now()}@example.com`, + fullName: 'UPC Admin', + role: 'admin', + request, + }); + _adminUser = admin; + adminToken = aToken; + createdUserIds.push(admin.user.user_id); + }); + + afterAll(async () => { + vi.unstubAllEnvs(); + + // Clean up scan history records + const pool = getPool(); + if (createdScanIds.length > 0) { + await pool.query('DELETE FROM public.upc_scan_history WHERE scan_id = ANY($1::int[])', [ + createdScanIds, + ]); + } + + // Clean up any created products + if (createdProductIds.length > 0) { + await pool.query('DELETE FROM public.products WHERE product_id = ANY($1::int[])', [ + createdProductIds, + ]); + } + + await cleanupDb({ userIds: createdUserIds }); + }); + + describe('POST /api/upc/scan - Manual UPC Entry', () => { + it('should record a manual UPC scan successfully', async () => { + const response = await request + .post('/api/upc/scan') + .set('Authorization', `Bearer ${authToken}`) + .send({ + upc_code: '012345678905', + scan_source: 'manual_entry', + }); + + expect(response.status).toBe(201); + expect(response.body.success).toBe(true); + expect(response.body.data.scan).toBeDefined(); + expect(response.body.data.scan.upc_code).toBe('012345678905'); + expect(response.body.data.scan.scan_source).toBe('manual_entry'); + + // Track for cleanup + if (response.body.data.scan.scan_id) { + createdScanIds.push(response.body.data.scan.scan_id); + } + }); + + it('should record scan with product lookup result', async () => { + // First, create a product to lookup + const pool = getPool(); + const productResult = await pool.query( + `INSERT INTO public.products (name, brand_id, category_id, upc_code) + VALUES ('Integration Test Product', 1, 1, '111222333444') + RETURNING product_id`, + ); + const productId = productResult.rows[0].product_id; + createdProductIds.push(productId); + + const response = await request + .post('/api/upc/scan') + .set('Authorization', `Bearer ${authToken}`) + .send({ + upc_code: '111222333444', + scan_source: 'manual_entry', + }); + + expect(response.status).toBe(201); + expect(response.body.data.scan.upc_code).toBe('111222333444'); + // The scan might have lookup_successful based on whether product was found + expect(response.body.data.scan.scan_id).toBeDefined(); + + if (response.body.data.scan.scan_id) { + createdScanIds.push(response.body.data.scan.scan_id); + } + }); + + it('should reject invalid UPC code format', async () => { + const response = await request + .post('/api/upc/scan') + .set('Authorization', `Bearer ${authToken}`) + .send({ + upc_code: 'invalid', + scan_source: 'manual_entry', + }); + + expect(response.status).toBe(400); + }); + + it('should reject invalid scan_source', async () => { + const response = await request + .post('/api/upc/scan') + .set('Authorization', `Bearer ${authToken}`) + .send({ + upc_code: '012345678905', + scan_source: 'unknown_source', + }); + + expect(response.status).toBe(400); + }); + + it('should reject unauthenticated requests', async () => { + const response = await request.post('/api/upc/scan').send({ + upc_code: '012345678905', + scan_source: 'manual_entry', + }); + + expect(response.status).toBe(401); + }); + }); + + describe('GET /api/upc/lookup - Product Lookup', () => { + it('should return null for unknown UPC code', async () => { + const response = await request + .get('/api/upc/lookup') + .query({ upc_code: '999888777666' }) + .set('Authorization', `Bearer ${authToken}`); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + // Product not found should return null or empty + expect(response.body.data.product).toBeNull(); + }); + + it('should return product for known UPC code', async () => { + // Create a product with UPC + const pool = getPool(); + const productResult = await pool.query( + `INSERT INTO public.products (name, brand_id, category_id, upc_code, description) + VALUES ('Lookup Test Product', 1, 1, '555666777888', 'Test product for lookup') + RETURNING product_id`, + ); + const productId = productResult.rows[0].product_id; + createdProductIds.push(productId); + + const response = await request + .get('/api/upc/lookup') + .query({ upc_code: '555666777888' }) + .set('Authorization', `Bearer ${authToken}`); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.product).toBeDefined(); + expect(response.body.data.product.name).toBe('Lookup Test Product'); + }); + + it('should reject missing upc_code parameter', async () => { + const response = await request + .get('/api/upc/lookup') + .set('Authorization', `Bearer ${authToken}`); + + expect(response.status).toBe(400); + }); + }); + + describe('GET /api/upc/history - Scan History', () => { + beforeAll(async () => { + // Create some scan history for testing + for (let i = 0; i < 5; i++) { + const response = await request + .post('/api/upc/scan') + .set('Authorization', `Bearer ${authToken}`) + .send({ + upc_code: `00000000000${i}`, + scan_source: i % 2 === 0 ? 'manual_entry' : 'image_upload', + }); + + if (response.body.data?.scan?.scan_id) { + createdScanIds.push(response.body.data.scan.scan_id); + } + } + }); + + it('should return paginated scan history', async () => { + const response = await request + .get('/api/upc/history') + .set('Authorization', `Bearer ${authToken}`); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.scans).toBeDefined(); + expect(Array.isArray(response.body.data.scans)).toBe(true); + expect(response.body.data.total).toBeGreaterThanOrEqual(5); + }); + + it('should support pagination parameters', async () => { + const response = await request + .get('/api/upc/history') + .query({ limit: 2, offset: 0 }) + .set('Authorization', `Bearer ${authToken}`); + + expect(response.status).toBe(200); + expect(response.body.data.scans.length).toBeLessThanOrEqual(2); + }); + + it('should filter by scan_source', async () => { + const response = await request + .get('/api/upc/history') + .query({ scan_source: 'manual_entry' }) + .set('Authorization', `Bearer ${authToken}`); + + expect(response.status).toBe(200); + response.body.data.scans.forEach((scan: { scan_source: string }) => { + expect(scan.scan_source).toBe('manual_entry'); + }); + }); + + it('should only return scans for the authenticated user', async () => { + // Create another user and verify they don't see the first user's scans + const { user: otherUser, token: otherToken } = await createAndLoginUser({ + email: `other-upc-user-${Date.now()}@example.com`, + fullName: 'Other UPC User', + request, + }); + createdUserIds.push(otherUser.user.user_id); + + const response = await request + .get('/api/upc/history') + .set('Authorization', `Bearer ${otherToken}`); + + expect(response.status).toBe(200); + // Other user should have no scan history (or only their own) + expect(response.body.data.total).toBe(0); + }); + }); + + describe('GET /api/upc/history/:scanId - Single Scan', () => { + let testScanId: number; + + beforeAll(async () => { + // Create a scan to retrieve + const response = await request + .post('/api/upc/scan') + .set('Authorization', `Bearer ${authToken}`) + .send({ + upc_code: '123456789012', + scan_source: 'manual_entry', + }); + + testScanId = response.body.data.scan.scan_id; + createdScanIds.push(testScanId); + }); + + it('should return a specific scan by ID', async () => { + const response = await request + .get(`/api/upc/history/${testScanId}`) + .set('Authorization', `Bearer ${authToken}`); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.scan.scan_id).toBe(testScanId); + expect(response.body.data.scan.upc_code).toBe('123456789012'); + }); + + it('should return 404 for non-existent scan', async () => { + const response = await request + .get('/api/upc/history/999999') + .set('Authorization', `Bearer ${authToken}`); + + expect(response.status).toBe(404); + }); + + it("should not allow accessing another user's scan", async () => { + const { user: otherUser, token: otherToken } = await createAndLoginUser({ + email: `scan-access-test-${Date.now()}@example.com`, + fullName: 'Scan Access Test', + request, + }); + createdUserIds.push(otherUser.user.user_id); + + const response = await request + .get(`/api/upc/history/${testScanId}`) + .set('Authorization', `Bearer ${otherToken}`); + + expect(response.status).toBe(404); + }); + }); + + describe('GET /api/upc/stats - User Statistics', () => { + it('should return user scan statistics', async () => { + const response = await request + .get('/api/upc/stats') + .set('Authorization', `Bearer ${authToken}`); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.stats).toBeDefined(); + expect(response.body.data.stats.total_scans).toBeGreaterThanOrEqual(0); + expect(response.body.data.stats.successful_lookups).toBeGreaterThanOrEqual(0); + expect(response.body.data.stats.unique_products).toBeGreaterThanOrEqual(0); + }); + }); + + describe('POST /api/upc/link - Admin Link UPC (Admin Only)', () => { + let testProductId: number; + + beforeAll(async () => { + // Create a product without UPC for linking + const pool = getPool(); + const result = await pool.query( + `INSERT INTO public.products (name, brand_id, category_id) + VALUES ('Product to Link', 1, 1) + RETURNING product_id`, + ); + testProductId = result.rows[0].product_id; + createdProductIds.push(testProductId); + }); + + it('should allow admin to link UPC to product', async () => { + const response = await request + .post('/api/upc/link') + .set('Authorization', `Bearer ${adminToken}`) + .send({ + product_id: testProductId, + upc_code: '999111222333', + }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data.product.upc_code).toBe('999111222333'); + }); + + it('should reject non-admin users', async () => { + const response = await request + .post('/api/upc/link') + .set('Authorization', `Bearer ${authToken}`) + .send({ + product_id: testProductId, + upc_code: '888777666555', + }); + + expect(response.status).toBe(403); + }); + + it('should reject invalid product_id', async () => { + const response = await request + .post('/api/upc/link') + .set('Authorization', `Bearer ${adminToken}`) + .send({ + product_id: 999999, + upc_code: '777666555444', + }); + + expect(response.status).toBe(404); + }); + }); + + describe('Complete UPC Workflow', () => { + it('should handle full scan-lookup-history workflow', async () => { + const uniqueUpc = `${Date.now()}`.slice(-12).padStart(12, '0'); + + // Step 1: Create a product with this UPC + const pool = getPool(); + const productResult = await pool.query( + `INSERT INTO public.products (name, brand_id, category_id, upc_code, description) + VALUES ('Workflow Test Product', 1, 1, $1, 'Product for workflow test') + RETURNING product_id`, + [uniqueUpc], + ); + createdProductIds.push(productResult.rows[0].product_id); + + // Step 2: Scan the UPC + const scanResponse = await request + .post('/api/upc/scan') + .set('Authorization', `Bearer ${authToken}`) + .send({ + upc_code: uniqueUpc, + scan_source: 'manual_entry', + }); + + expect(scanResponse.status).toBe(201); + const scanId = scanResponse.body.data.scan.scan_id; + createdScanIds.push(scanId); + + // Step 3: Lookup the product + const lookupResponse = await request + .get('/api/upc/lookup') + .query({ upc_code: uniqueUpc }) + .set('Authorization', `Bearer ${authToken}`); + + expect(lookupResponse.status).toBe(200); + expect(lookupResponse.body.data.product).toBeDefined(); + expect(lookupResponse.body.data.product.name).toBe('Workflow Test Product'); + + // Step 4: Verify in history + const historyResponse = await request + .get(`/api/upc/history/${scanId}`) + .set('Authorization', `Bearer ${authToken}`); + + expect(historyResponse.status).toBe(200); + expect(historyResponse.body.data.scan.upc_code).toBe(uniqueUpc); + + // Step 5: Check stats updated + const statsResponse = await request + .get('/api/upc/stats') + .set('Authorization', `Bearer ${authToken}`); + + expect(statsResponse.status).toBe(200); + expect(statsResponse.body.data.stats.total_scans).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/types/expiry.ts b/src/types/expiry.ts new file mode 100644 index 0000000..85a2d7e --- /dev/null +++ b/src/types/expiry.ts @@ -0,0 +1,585 @@ +// src/types/expiry.ts +// ============================================================================ +// EXPIRY DATE TRACKING TYPE DEFINITIONS +// ============================================================================ +// Type definitions for the expiry date tracking and inventory management feature. +// Covers pantry items, expiry ranges, alerts, and receipt scanning. +// ============================================================================ + +/** + * Storage locations for pantry items. + * Used to determine appropriate shelf life. + */ +export type StorageLocation = 'fridge' | 'freezer' | 'pantry' | 'room_temp'; + +/** + * Sources for how an item was added to inventory. + */ +export type InventorySource = 'manual' | 'receipt_scan' | 'upc_scan'; + +/** + * How the expiry date was determined. + */ +export type ExpirySource = 'manual' | 'calculated' | 'package' | 'receipt'; + +/** + * Alert delivery methods for expiry notifications. + */ +export type AlertMethod = 'email' | 'push' | 'in_app'; + +/** + * Types of expiry alerts. + */ +export type ExpiryAlertType = 'expiring_soon' | 'expired' | 'expiry_reminder'; + +/** + * Expiry status for inventory items. + */ +export type ExpiryStatus = 'fresh' | 'expiring_soon' | 'expired' | 'unknown'; + +/** + * Data sources for expiry range information. + */ +export type ExpiryRangeSource = 'usda' | 'fda' | 'manual' | 'community'; + +// ============================================================================ +// PANTRY/INVENTORY ITEM TYPES +// ============================================================================ + +/** + * User inventory item with all fields including calculated expiry status. + * Extended version of pantry_items table with computed fields. + */ +export interface UserInventoryItem { + /** Primary key */ + inventory_id: number; + /** Owning user */ + user_id: string; + /** Link to products table if from UPC scan */ + product_id: number | null; + /** Link to master grocery items */ + master_item_id: number | null; + /** Item name (required fallback) */ + item_name: string; + /** Quantity of item */ + quantity: number; + /** Unit of measurement */ + unit: string | null; + /** When the item was purchased */ + purchase_date: string | null; + /** Expected expiry date */ + expiry_date: string | null; + /** How the item was added */ + source: InventorySource; + /** Where the item is stored */ + location: StorageLocation | null; + /** User notes */ + notes: string | null; + /** Whether fully consumed */ + is_consumed: boolean; + /** When consumed */ + consumed_at: string | null; + /** How expiry was determined */ + expiry_source: ExpirySource | null; + /** Link to receipt item if from receipt */ + receipt_item_id: number | null; + /** Pantry location reference */ + pantry_location_id: number | null; + /** When notification was sent */ + notification_sent_at: string | null; + /** Record timestamps */ + created_at: string; + updated_at: string; + + // Computed fields (not in database) + /** Days until expiry (negative = already expired) */ + days_until_expiry: number | null; + /** Current expiry status */ + expiry_status: ExpiryStatus; + /** Item name from master items if linked */ + master_item_name?: string; + /** Category name if linked */ + category_name?: string; +} + +/** + * Request to add an item to user inventory. + */ +export interface AddInventoryItemRequest { + /** Link to products table */ + product_id?: number; + /** Link to master grocery items */ + master_item_id?: number; + /** Item name (required if no product/master item) */ + item_name: string; + /** Quantity of item */ + quantity?: number; + /** Unit of measurement */ + unit?: string; + /** When the item was purchased */ + purchase_date?: string; + /** Expected expiry date (if known) */ + expiry_date?: string; + /** How the item is being added */ + source: InventorySource; + /** Where the item will be stored */ + location?: StorageLocation; + /** User notes */ + notes?: string; +} + +/** + * Request to update an existing inventory item. + */ +export interface UpdateInventoryItemRequest { + /** Updated quantity */ + quantity?: number; + /** Updated unit */ + unit?: string; + /** Updated expiry date */ + expiry_date?: string; + /** Updated storage location */ + location?: StorageLocation; + /** Updated notes */ + notes?: string; + /** Mark as consumed */ + is_consumed?: boolean; +} + +// ============================================================================ +// EXPIRY DATE RANGE TYPES +// ============================================================================ + +/** + * Reference data for typical shelf life of items. + * Maps to public.expiry_date_ranges table. + */ +export interface ExpiryDateRange { + /** Primary key */ + expiry_range_id: number; + /** Specific item this applies to */ + master_item_id: number | null; + /** Category this applies to */ + category_id: number | null; + /** Regex pattern for item name matching */ + item_pattern: string | null; + /** Storage location this range applies to */ + storage_location: StorageLocation; + /** Minimum shelf life in days */ + min_days: number; + /** Maximum shelf life in days */ + max_days: number; + /** Typical/recommended shelf life in days */ + typical_days: number; + /** Storage tips or warnings */ + notes: string | null; + /** Data source */ + source: ExpiryRangeSource | null; + /** Record timestamps */ + created_at: string; + updated_at: string; +} + +/** + * Request to add a new expiry date range (admin operation). + */ +export interface AddExpiryRangeRequest { + /** Specific item this applies to */ + master_item_id?: number; + /** Category this applies to */ + category_id?: number; + /** Regex pattern for item name matching */ + item_pattern?: string; + /** Storage location this range applies to */ + storage_location: StorageLocation; + /** Minimum shelf life in days */ + min_days: number; + /** Maximum shelf life in days */ + max_days: number; + /** Typical/recommended shelf life in days */ + typical_days: number; + /** Storage tips or warnings */ + notes?: string; + /** Data source */ + source?: ExpiryRangeSource; +} + +// ============================================================================ +// ALERT TYPES +// ============================================================================ + +/** + * User's expiry alert settings. + * Maps to public.expiry_alerts table. + */ +export interface ExpiryAlertSettings { + /** Primary key */ + expiry_alert_id: number; + /** User ID */ + user_id: string; + /** Days before expiry to send alert */ + days_before_expiry: number; + /** How to deliver the alert */ + alert_method: AlertMethod; + /** Whether this alert type is enabled */ + is_enabled: boolean; + /** Last time an alert was sent */ + last_alert_sent_at: string | null; + /** Record timestamps */ + created_at: string; + updated_at: string; +} + +/** + * Request to update expiry alert settings. + */ +export interface UpdateExpiryAlertSettingsRequest { + /** Days before expiry to send alert */ + days_before_expiry?: number; + /** Whether this alert type is enabled */ + is_enabled?: boolean; +} + +/** + * Record of a sent expiry alert. + * Maps to public.expiry_alert_log table. + */ +export interface ExpiryAlertLogRecord { + /** Primary key */ + alert_log_id: number; + /** User who received the alert */ + user_id: string; + /** Pantry item that triggered the alert */ + pantry_item_id: number | null; + /** Type of alert sent */ + alert_type: ExpiryAlertType; + /** How the alert was delivered */ + alert_method: AlertMethod; + /** Item name at time of alert */ + item_name: string; + /** Expiry date that triggered alert */ + expiry_date: string | null; + /** Days until expiry when alert was sent */ + days_until_expiry: number | null; + /** When the alert was sent */ + sent_at: string; +} + +// ============================================================================ +// RESPONSE TYPES +// ============================================================================ + +/** + * Grouped response for expiring items by urgency. + */ +export interface ExpiringItemsResponse { + /** Items expiring today */ + expiring_today: UserInventoryItem[]; + /** Items expiring within 7 days */ + expiring_this_week: UserInventoryItem[]; + /** Items expiring within 30 days */ + expiring_this_month: UserInventoryItem[]; + /** Items already expired */ + already_expired: UserInventoryItem[]; + /** Summary counts */ + counts: { + today: number; + this_week: number; + this_month: number; + expired: number; + total: number; + }; +} + +/** + * Recipe that can be made with expiring ingredients. + */ +export interface RecipeWithExpiringIngredients { + /** Recipe ID */ + recipe_id: number; + /** Recipe name */ + recipe_name: string; + /** Recipe image URL */ + photo_url: string | null; + /** Prep time in minutes */ + prep_time_minutes: number | null; + /** Cook time in minutes */ + cook_time_minutes: number | null; + /** Number of servings */ + servings: number | null; + /** Average rating */ + avg_rating: number; + /** Expiring items that match recipe ingredients */ + matching_expiring_items: UserInventoryItem[]; + /** Number of matching ingredients */ + matching_count: number; + /** Total recipe ingredients */ + total_ingredients: number; + /** Percentage of ingredients matched by expiring items */ + match_percentage: number; +} + +/** + * Recipe suggestions based on expiring items. + */ +export interface ExpiryRecipeSuggestionsResponse { + /** Recipes that use expiring items */ + recipes: RecipeWithExpiringIngredients[]; + /** Total count for pagination */ + total: number; + /** Items that were considered for matching */ + considered_items: UserInventoryItem[]; +} + +// ============================================================================ +// RECEIPT SCANNING TYPES +// ============================================================================ + +/** + * Receipt processing status. + */ +export type ReceiptStatus = 'pending' | 'processing' | 'completed' | 'failed'; + +/** + * Receipt item matching status. + */ +export type ReceiptItemStatus = 'unmatched' | 'matched' | 'needs_review' | 'ignored'; + +/** + * Receipt processing step for logging. + */ +export type ReceiptProcessingStep = + | 'upload' + | 'ocr_extraction' + | 'text_parsing' + | 'store_detection' + | 'item_extraction' + | 'item_matching' + | 'price_parsing' + | 'finalization'; + +/** + * Receipt processing log status. + */ +export type ReceiptProcessingStatus = 'started' | 'completed' | 'failed' | 'skipped'; + +/** + * OCR providers for receipt processing. + */ +export type OcrProvider = + | 'tesseract' + | 'openai' + | 'anthropic' + | 'google_vision' + | 'aws_textract' + | 'gemini' + | 'internal'; + +/** + * Receipt scan record from database. + * Maps to public.receipts table. + */ +export interface ReceiptScan { + /** Primary key */ + receipt_id: number; + /** User who uploaded the receipt */ + user_id: string; + /** Detected store */ + store_id: number | null; + /** Path to receipt image */ + receipt_image_url: string; + /** Transaction date from receipt */ + transaction_date: string | null; + /** Total amount in cents */ + total_amount_cents: number | null; + /** Processing status */ + status: ReceiptStatus; + /** Raw OCR text */ + raw_text: string | null; + /** Store detection confidence */ + store_confidence: number | null; + /** OCR provider used */ + ocr_provider: OcrProvider | null; + /** Error details if failed */ + error_details: Record | null; + /** Number of retry attempts */ + retry_count: number; + /** OCR confidence score */ + ocr_confidence: number | null; + /** Detected currency */ + currency: string; + /** Record timestamps */ + created_at: string; + processed_at: string | null; + updated_at: string; +} + +/** + * Item extracted from a receipt. + * Maps to public.receipt_items table. + */ +export interface ReceiptItem { + /** Primary key */ + receipt_item_id: number; + /** Parent receipt */ + receipt_id: number; + /** Raw item text from receipt */ + raw_item_description: string; + /** Quantity purchased */ + quantity: number; + /** Price paid in cents */ + price_paid_cents: number; + /** Matched master item */ + master_item_id: number | null; + /** Matched product */ + product_id: number | null; + /** Matching status */ + status: ReceiptItemStatus; + /** Line number on receipt */ + line_number: number | null; + /** Match confidence score */ + match_confidence: number | null; + /** Whether this is a discount line */ + is_discount: boolean; + /** Unit price if detected */ + unit_price_cents: number | null; + /** Unit type if detected */ + unit_type: string | null; + /** Whether added to pantry */ + added_to_pantry: boolean; + /** Link to pantry item if added */ + pantry_item_id: number | null; + /** UPC code if extracted */ + upc_code: string | null; + /** Record timestamps */ + created_at: string; + updated_at: string; +} + +/** + * Request to upload a receipt for scanning. + */ +export interface UploadReceiptRequest { + /** Base64-encoded receipt image */ + image_base64: string; + /** Known store ID (optional) */ + store_id?: number; + /** Known transaction date (optional) */ + transaction_date?: string; +} + +/** + * Request to confirm receipt items and add to inventory. + */ +export interface ConfirmReceiptItemsRequest { + /** Items to add to inventory */ + items: Array<{ + /** Receipt item ID */ + receipt_item_id: number; + /** Override item name */ + item_name?: string; + /** Override quantity */ + quantity?: number; + /** Storage location */ + location?: StorageLocation; + /** Expiry date if known */ + expiry_date?: string; + /** Whether to add this item (false = skip) */ + include: boolean; + }>; +} + +/** + * Receipt processing log record. + * Maps to public.receipt_processing_log table. + */ +export interface ReceiptProcessingLogRecord { + /** Primary key */ + log_id: number; + /** Parent receipt */ + receipt_id: number; + /** Processing step */ + processing_step: ReceiptProcessingStep; + /** Step status */ + status: ReceiptProcessingStatus; + /** Provider used */ + provider: OcrProvider | null; + /** Duration in milliseconds */ + duration_ms: number | null; + /** Tokens used (for LLM) */ + tokens_used: number | null; + /** Cost in cents */ + cost_cents: number | null; + /** Input data */ + input_data: Record | null; + /** Output data */ + output_data: Record | null; + /** Error message if failed */ + error_message: string | null; + /** When logged */ + created_at: string; +} + +// ============================================================================ +// QUERY OPTION TYPES +// ============================================================================ + +/** + * Options for querying user inventory. + */ +export interface InventoryQueryOptions { + /** User ID to filter by */ + user_id: string; + /** Filter by storage location */ + location?: StorageLocation; + /** Filter by consumed status */ + is_consumed?: boolean; + /** Filter items expiring within N days */ + expiring_within_days?: number; + /** Filter by category ID */ + category_id?: number; + /** Search by item name */ + search?: string; + /** Maximum number of results */ + limit?: number; + /** Offset for pagination */ + offset?: number; + /** Sort field */ + sort_by?: 'expiry_date' | 'purchase_date' | 'item_name' | 'created_at'; + /** Sort direction */ + sort_order?: 'asc' | 'desc'; +} + +/** + * Options for querying expiry date ranges. + */ +export interface ExpiryRangeQueryOptions { + /** Filter by master item ID */ + master_item_id?: number; + /** Filter by category ID */ + category_id?: number; + /** Filter by storage location */ + storage_location?: StorageLocation; + /** Filter by source */ + source?: ExpiryRangeSource; + /** Maximum number of results */ + limit?: number; + /** Offset for pagination */ + offset?: number; +} + +/** + * Options for calculating expiry date. + */ +export interface CalculateExpiryOptions { + /** Master item ID for lookup */ + master_item_id?: number; + /** Category ID for fallback lookup */ + category_id?: number; + /** Item name for pattern matching fallback */ + item_name?: string; + /** Storage location */ + storage_location: StorageLocation; + /** Purchase date to calculate from */ + purchase_date: string; +} diff --git a/src/types/job-data.ts b/src/types/job-data.ts index fc38311..3cf299e 100644 --- a/src/types/job-data.ts +++ b/src/types/job-data.ts @@ -1,5 +1,18 @@ // src/types/job-data.ts +/** + * Common metadata for context propagation across job queues (ADR-051). + * This enables tracing a request from API layer through worker execution. + */ +export interface JobMeta { + /** The request ID from the initiating API request */ + requestId?: string; + /** The user ID who triggered the job (if authenticated) */ + userId?: string; + /** The origin of the job (e.g., 'api', 'scheduler', 'admin') */ + origin?: 'api' | 'api-reprocess' | 'scheduler' | 'admin' | 'manual'; +} + /** * Defines the shape of the data payload for a flyer processing job. * This is the data that gets passed to the BullMQ worker. @@ -12,6 +25,8 @@ export interface FlyerJobData { submitterIp?: string; userProfileAddress?: string; baseUrl: string; + /** Context propagation metadata (ADR-051) */ + meta?: JobMeta; } /** @@ -20,6 +35,8 @@ export interface FlyerJobData { export interface CleanupJobData { flyerId: number; paths?: string[]; + /** Context propagation metadata (ADR-051) */ + meta?: JobMeta; } /** @@ -27,6 +44,8 @@ export interface CleanupJobData { */ export interface TokenCleanupJobData { timestamp: string; + /** Context propagation metadata (ADR-051) */ + meta?: JobMeta; } /** @@ -34,6 +53,8 @@ export interface TokenCleanupJobData { */ export interface AnalyticsJobData { reportDate: string; + /** Context propagation metadata (ADR-051) */ + meta?: JobMeta; } /** @@ -42,6 +63,8 @@ export interface AnalyticsJobData { export interface WeeklyAnalyticsJobData { reportYear: number; reportWeek: number; + /** Context propagation metadata (ADR-051) */ + meta?: JobMeta; } /** @@ -52,4 +75,52 @@ export interface EmailJobData { subject: string; text: string; html: string; -} \ No newline at end of file + /** Context propagation metadata (ADR-051) */ + meta?: JobMeta; +} + +/** + * Defines the shape of the data payload for a receipt processing job. + */ +export interface ReceiptJobData { + /** ID of the receipt record in the database */ + receiptId: number; + /** Path to the uploaded receipt image */ + imagePath: string; + /** User who uploaded the receipt */ + userId: string; + /** Known store ID (optional, for hint to OCR) */ + storeId?: number; + /** Context propagation metadata (ADR-051) */ + meta?: JobMeta; +} + +/** + * Defines the shape of the data payload for an expiry alert job. + */ +export interface ExpiryAlertJobData { + /** Type of alert job */ + alertType: 'daily_check' | 'user_specific'; + /** For user_specific jobs, the target user */ + userId?: string; + /** Days ahead to check for expiring items */ + daysAhead?: number; + /** Timestamp when the job was scheduled */ + scheduledAt: string; + /** Context propagation metadata (ADR-051) */ + meta?: JobMeta; +} + +/** + * Defines the shape of the data payload for a barcode detection job. + */ +export interface BarcodeDetectionJobData { + /** ID of the scan record in the database */ + scanId: number; + /** Path to the uploaded barcode image */ + imagePath: string; + /** User who uploaded the image */ + userId: string; + /** Context propagation metadata (ADR-051) */ + meta?: JobMeta; +} diff --git a/src/types/upc.ts b/src/types/upc.ts new file mode 100644 index 0000000..1b38305 --- /dev/null +++ b/src/types/upc.ts @@ -0,0 +1,282 @@ +// src/types/upc.ts +// ============================================================================ +// UPC SCANNING TYPE DEFINITIONS +// ============================================================================ +// Type definitions for the UPC barcode scanning feature. +// Covers scan requests, results, history, and external API lookups. +// ============================================================================ + +/** + * Valid sources for UPC barcode scans. + * - image_upload: User uploaded an image containing a barcode + * - manual_entry: User typed the UPC code manually + * - phone_app: Scan came from a mobile phone app + * - camera_scan: Real-time camera scanning in browser + */ +export type UpcScanSource = 'image_upload' | 'manual_entry' | 'phone_app' | 'camera_scan'; + +/** + * External UPC database providers supported for lookups. + * - openfoodfacts: Open Food Facts API (free, open source) + * - upcitemdb: UPC Item DB API + * - barcodelookup: Barcode Lookup API + * - manual: Manually entered product info + * - unknown: Source not identified + */ +export type UpcExternalSource = + | 'openfoodfacts' + | 'upcitemdb' + | 'barcodelookup' + | 'manual' + | 'unknown'; + +// ============================================================================ +// REQUEST TYPES +// ============================================================================ + +/** + * Request to scan a UPC barcode. + * Either upc_code (manual entry) or image_base64 (image scan) must be provided. + */ +export interface UpcScanRequest { + /** UPC code entered manually (8-14 digits) */ + upc_code?: string; + /** Base64-encoded image containing barcode */ + image_base64?: string; + /** How the scan was initiated */ + scan_source: UpcScanSource; +} + +/** + * Request to look up a UPC code without recording the scan. + */ +export interface UpcLookupRequest { + /** UPC code to look up (8-14 digits) */ + upc_code: string; + /** Whether to check external APIs if not found locally */ + include_external?: boolean; +} + +/** + * Request to link a UPC code to an existing product (admin operation). + */ +export interface UpcLinkRequest { + /** The UPC code to link */ + upc_code: string; + /** The product ID to link to */ + product_id: number; +} + +// ============================================================================ +// RESPONSE TYPES +// ============================================================================ + +/** + * Product information returned when a UPC match is found. + */ +export interface UpcProductMatch { + /** Internal product ID */ + product_id: number; + /** Product name */ + name: string; + /** Brand name, if known */ + brand: string | null; + /** Product category */ + category: string | null; + /** Product description */ + description: string | null; + /** Product size/weight (e.g., "500g") */ + size: string | null; + /** The UPC code */ + upc_code: string; + /** Product image URL, if available */ + image_url: string | null; + /** Link to master grocery item */ + master_item_id: number | null; +} + +/** + * Product information from external UPC database lookup. + */ +export interface UpcExternalProductInfo { + /** Product name from external source */ + name: string; + /** Brand name from external source */ + brand: string | null; + /** Product category from external source */ + category: string | null; + /** Product description from external source */ + description: string | null; + /** Product image URL from external source */ + image_url: string | null; + /** Which external API provided this data */ + source: UpcExternalSource; + /** Raw JSON data from the external API */ + raw_data?: Record; +} + +/** + * Complete result from a UPC scan operation. + */ +export interface UpcScanResult { + /** ID of the recorded scan (for history tracking) */ + scan_id: number; + /** The scanned UPC code */ + upc_code: string; + /** Matched product from our database, if found */ + product: UpcProductMatch | null; + /** Product info from external lookup, if performed */ + external_lookup: UpcExternalProductInfo | null; + /** Confidence score of barcode detection (0.0-1.0) */ + confidence: number | null; + /** Whether any product info was found (internal or external) */ + lookup_successful: boolean; + /** Whether this UPC was not previously in our database */ + is_new_product: boolean; + /** Timestamp of the scan */ + scanned_at: string; +} + +/** + * Result from a UPC lookup (without recording scan history). + */ +export interface UpcLookupResult { + /** The looked up UPC code */ + upc_code: string; + /** Matched product from our database, if found */ + product: UpcProductMatch | null; + /** Product info from external lookup, if performed and enabled */ + external_lookup: UpcExternalProductInfo | null; + /** Whether any product info was found */ + found: boolean; + /** Whether the lookup result came from cache */ + from_cache: boolean; +} + +// ============================================================================ +// DATABASE ENTITY TYPES +// ============================================================================ + +/** + * UPC scan history record from the database. + * Maps to public.upc_scan_history table. + */ +export interface UpcScanHistoryRecord { + /** Primary key */ + scan_id: number; + /** User who performed the scan */ + user_id: string; + /** The scanned UPC code */ + upc_code: string; + /** Matched product ID, if found */ + product_id: number | null; + /** How the scan was performed */ + scan_source: UpcScanSource; + /** Confidence score from barcode detection */ + scan_confidence: number | null; + /** Path to uploaded barcode image */ + raw_image_path: string | null; + /** Whether the lookup found product info */ + lookup_successful: boolean; + /** When the scan was recorded */ + created_at: string; + /** Last update timestamp */ + updated_at: string; +} + +/** + * External UPC lookup cache record from the database. + * Maps to public.upc_external_lookups table. + */ +export interface UpcExternalLookupRecord { + /** Primary key */ + lookup_id: number; + /** The UPC code that was looked up */ + upc_code: string; + /** Product name from external API */ + product_name: string | null; + /** Brand name from external API */ + brand_name: string | null; + /** Product category from external API */ + category: string | null; + /** Product description from external API */ + description: string | null; + /** Product image URL from external API */ + image_url: string | null; + /** Which external API provided this data */ + external_source: UpcExternalSource; + /** Full raw JSON response from external API */ + lookup_data: Record | null; + /** Whether the external lookup found product info */ + lookup_successful: boolean; + /** When the lookup was cached */ + created_at: string; + /** Last update timestamp */ + updated_at: string; +} + +// ============================================================================ +// API RESPONSE TYPES +// ============================================================================ + +/** + * Response for scan history listing. + */ +export interface UpcScanHistoryResponse { + /** List of scan history records */ + scans: UpcScanHistoryRecord[]; + /** Total count for pagination */ + total: number; +} + +/** + * Barcode detection result from image processing. + */ +export interface BarcodeDetectionResult { + /** Whether a barcode was detected */ + detected: boolean; + /** The decoded UPC code, if detected */ + upc_code: string | null; + /** Confidence score of detection (0.0-1.0) */ + confidence: number | null; + /** Barcode format detected (UPC-A, UPC-E, EAN-13, etc.) */ + format: string | null; + /** Error message if detection failed */ + error: string | null; +} + +// ============================================================================ +// UTILITY TYPES +// ============================================================================ + +/** + * Options for UPC scan history queries. + */ +export interface UpcScanHistoryQueryOptions { + /** User ID to filter by */ + user_id: string; + /** Maximum number of results */ + limit?: number; + /** Offset for pagination */ + offset?: number; + /** Filter by lookup success status */ + lookup_successful?: boolean; + /** Filter by scan source */ + scan_source?: UpcScanSource; + /** Filter by date range start */ + from_date?: string; + /** Filter by date range end */ + to_date?: string; +} + +/** + * Options for external UPC lookup. + */ +export interface UpcExternalLookupOptions { + /** UPC code to look up */ + upc_code: string; + /** Whether to skip cache and force fresh lookup */ + force_refresh?: boolean; + /** Maximum age of cached data in hours (default: 168 = 7 days) */ + max_cache_age_hours?: number; +}