diff --git a/.gitea/workflows/deploy-to-prod.yml b/.gitea/workflows/deploy-to-prod.yml index 7f1d5175..3fedd2db 100644 --- a/.gitea/workflows/deploy-to-prod.yml +++ b/.gitea/workflows/deploy-to-prod.yml @@ -93,8 +93,9 @@ jobs: exit 1 fi GITEA_SERVER_URL="https://gitea.projectium.com" - COMMIT_MESSAGE=$(git log -1 --pretty=%s) - VITE_APP_VERSION="$(date +'%Y%m%d-%H%M'):$(git rev-parse --short HEAD)" \ + COMMIT_MESSAGE=$(git log -1 --grep="\[skip ci\]" --invert-grep --pretty=%s) + PACKAGE_VERSION=$(node -p "require('./package.json').version") + VITE_APP_VERSION="$(date +'%Y%m%d-%H%M'):$(git rev-parse --short HEAD):$PACKAGE_VERSION" \ VITE_APP_COMMIT_URL="$GITEA_SERVER_URL/${{ gitea.repository }}/commit/${{ gitea.sha }}" \ VITE_APP_COMMIT_MESSAGE="$COMMIT_MESSAGE" \ VITE_API_BASE_URL=/api VITE_API_KEY=${{ secrets.VITE_GOOGLE_GENAI_API_KEY }} npm run build @@ -162,7 +163,12 @@ jobs: echo "Updating schema hash in production database..." CURRENT_HASH=$(cat sql/master_schema_rollup.sql | dos2unix | sha256sum | awk '{ print $1 }') PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c \ - "INSERT INTO public.schema_info (environment, schema_hash, deployed_at) VALUES ('production', '$CURRENT_HASH', NOW()) + "CREATE TABLE IF NOT EXISTS public.schema_info ( + environment VARCHAR(50) PRIMARY KEY, + schema_hash VARCHAR(64) NOT NULL, + deployed_at TIMESTAMP DEFAULT NOW() + ); + INSERT INTO public.schema_info (environment, schema_hash, deployed_at) VALUES ('production', '$CURRENT_HASH', NOW()) ON CONFLICT (environment) DO UPDATE SET schema_hash = EXCLUDED.schema_hash, deployed_at = NOW();" UPDATED_HASH=$(PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c "SELECT schema_hash FROM public.schema_info WHERE environment = 'production';" -t -A) diff --git a/.gitea/workflows/deploy-to-test.yml b/.gitea/workflows/deploy-to-test.yml index 133ea4af..6b2c00fe 100644 --- a/.gitea/workflows/deploy-to-test.yml +++ b/.gitea/workflows/deploy-to-test.yml @@ -282,6 +282,9 @@ jobs: if [ -z "$DEPLOYED_HASH" ]; then echo "WARNING: No schema hash found in the test database." echo "This is expected for a first-time deployment. The hash will be set after a successful deployment." + echo "--- Debug: Dumping schema_info table ---" + PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=0 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c "SELECT * FROM public.schema_info;" || true + echo "----------------------------------------" # We allow the deployment to continue, but a manual schema update is required. # You could choose to fail here by adding `exit 1`. elif [ "$CURRENT_HASH" != "$DEPLOYED_HASH" ]; then @@ -305,8 +308,9 @@ jobs: fi GITEA_SERVER_URL="https://gitea.projectium.com" # Your Gitea instance URL - COMMIT_MESSAGE=$(git log -1 --pretty=%s) - VITE_APP_VERSION="$(date +'%Y%m%d-%H%M'):$(git rev-parse --short HEAD)" \ + COMMIT_MESSAGE=$(git log -1 --grep="\[skip ci\]" --invert-grep --pretty=%s) + PACKAGE_VERSION=$(node -p "require('./package.json').version") + VITE_APP_VERSION="$(date +'%Y%m%d-%H%M'):$(git rev-parse --short HEAD):$PACKAGE_VERSION" \ VITE_APP_COMMIT_URL="$GITEA_SERVER_URL/${{ gitea.repository }}/commit/${{ gitea.sha }}" \ VITE_APP_COMMIT_MESSAGE="$COMMIT_MESSAGE" \ VITE_API_BASE_URL="https://flyer-crawler-test.projectium.com/api" VITE_API_KEY=${{ secrets.VITE_GOOGLE_GENAI_API_KEY_TEST }} npm run build @@ -380,7 +384,12 @@ jobs: echo "Updating schema hash in test database..." CURRENT_HASH=$(cat sql/master_schema_rollup.sql | dos2unix | sha256sum | awk '{ print $1 }') PGPASSWORD="$DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -p 5432 -U "$DB_USER" -d "$DB_NAME" -c \ - "INSERT INTO public.schema_info (environment, schema_hash, deployed_at) VALUES ('test', '$CURRENT_HASH', NOW()) + "CREATE TABLE IF NOT EXISTS public.schema_info ( + environment VARCHAR(50) PRIMARY KEY, + schema_hash VARCHAR(64) NOT NULL, + deployed_at TIMESTAMP DEFAULT NOW() + ); + INSERT INTO public.schema_info (environment, schema_hash, deployed_at) VALUES ('test', '$CURRENT_HASH', NOW()) ON CONFLICT (environment) DO UPDATE SET schema_hash = EXCLUDED.schema_hash, deployed_at = NOW();" # Verify the hash was updated diff --git a/.gitea/workflows/manual-deploy-major.yml b/.gitea/workflows/manual-deploy-major.yml index 8a0bada8..d73402df 100644 --- a/.gitea/workflows/manual-deploy-major.yml +++ b/.gitea/workflows/manual-deploy-major.yml @@ -92,8 +92,9 @@ jobs: exit 1 fi GITEA_SERVER_URL="https://gitea.projectium.com" - COMMIT_MESSAGE=$(git log -1 --pretty=%s) - VITE_APP_VERSION="$(date +'%Y%m%d-%H%M'):$(git rev-parse --short HEAD)" \ + COMMIT_MESSAGE=$(git log -1 --grep="\[skip ci\]" --invert-grep --pretty=%s) + PACKAGE_VERSION=$(node -p "require('./package.json').version") + VITE_APP_VERSION="$(date +'%Y%m%d-%H%M'):$(git rev-parse --short HEAD):$PACKAGE_VERSION" \ VITE_APP_COMMIT_URL="$GITEA_SERVER_URL/${{ gitea.repository }}/commit/${{ gitea.sha }}" \ VITE_APP_COMMIT_MESSAGE="$COMMIT_MESSAGE" \ VITE_API_BASE_URL=/api VITE_API_KEY=${{ secrets.VITE_GOOGLE_GENAI_API_KEY }} npm run build diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index bd402335..7f082208 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -18,12 +18,46 @@ module.exports = { NODE_ENV: 'production', // Set the Node.js environment to production name: 'flyer-crawler-api', cwd: '/var/www/flyer-crawler.projectium.com', + // Inherit secrets from the deployment environment + DB_HOST: process.env.DB_HOST, + DB_USER: process.env.DB_USER, + DB_PASSWORD: process.env.DB_PASSWORD, + DB_NAME: process.env.DB_NAME, + REDIS_URL: process.env.REDIS_URL, + REDIS_PASSWORD: process.env.REDIS_PASSWORD, + FRONTEND_URL: process.env.FRONTEND_URL, + JWT_SECRET: process.env.JWT_SECRET, + GEMINI_API_KEY: process.env.GEMINI_API_KEY, + GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY, + SMTP_HOST: process.env.SMTP_HOST, + SMTP_PORT: process.env.SMTP_PORT, + SMTP_SECURE: process.env.SMTP_SECURE, + SMTP_USER: process.env.SMTP_USER, + SMTP_PASS: process.env.SMTP_PASS, + SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL, }, // Test Environment Settings env_test: { NODE_ENV: 'test', // Set to 'test' to match the environment purpose and disable pino-pretty name: 'flyer-crawler-api-test', cwd: '/var/www/flyer-crawler-test.projectium.com', + // Inherit secrets from the deployment environment + DB_HOST: process.env.DB_HOST, + DB_USER: process.env.DB_USER, + DB_PASSWORD: process.env.DB_PASSWORD, + DB_NAME: process.env.DB_NAME, + REDIS_URL: process.env.REDIS_URL, + REDIS_PASSWORD: process.env.REDIS_PASSWORD, + FRONTEND_URL: process.env.FRONTEND_URL, + JWT_SECRET: process.env.JWT_SECRET, + GEMINI_API_KEY: process.env.GEMINI_API_KEY, + GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY, + SMTP_HOST: process.env.SMTP_HOST, + SMTP_PORT: process.env.SMTP_PORT, + SMTP_SECURE: process.env.SMTP_SECURE, + SMTP_USER: process.env.SMTP_USER, + SMTP_PASS: process.env.SMTP_PASS, + SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL, }, // Development Environment Settings env_development: { @@ -31,6 +65,23 @@ module.exports = { name: 'flyer-crawler-api-dev', watch: true, ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'], + // Inherit secrets from the deployment environment + DB_HOST: process.env.DB_HOST, + DB_USER: process.env.DB_USER, + DB_PASSWORD: process.env.DB_PASSWORD, + DB_NAME: process.env.DB_NAME, + REDIS_URL: process.env.REDIS_URL, + REDIS_PASSWORD: process.env.REDIS_PASSWORD, + FRONTEND_URL: process.env.FRONTEND_URL, + JWT_SECRET: process.env.JWT_SECRET, + GEMINI_API_KEY: process.env.GEMINI_API_KEY, + GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY, + SMTP_HOST: process.env.SMTP_HOST, + SMTP_PORT: process.env.SMTP_PORT, + SMTP_SECURE: process.env.SMTP_SECURE, + SMTP_USER: process.env.SMTP_USER, + SMTP_PASS: process.env.SMTP_PASS, + SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL, }, }, { @@ -43,12 +94,46 @@ module.exports = { NODE_ENV: 'production', name: 'flyer-crawler-worker', cwd: '/var/www/flyer-crawler.projectium.com', + // Inherit secrets from the deployment environment + DB_HOST: process.env.DB_HOST, + DB_USER: process.env.DB_USER, + DB_PASSWORD: process.env.DB_PASSWORD, + DB_NAME: process.env.DB_NAME, + REDIS_URL: process.env.REDIS_URL, + REDIS_PASSWORD: process.env.REDIS_PASSWORD, + FRONTEND_URL: process.env.FRONTEND_URL, + JWT_SECRET: process.env.JWT_SECRET, + GEMINI_API_KEY: process.env.GEMINI_API_KEY, + GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY, + SMTP_HOST: process.env.SMTP_HOST, + SMTP_PORT: process.env.SMTP_PORT, + SMTP_SECURE: process.env.SMTP_SECURE, + SMTP_USER: process.env.SMTP_USER, + SMTP_PASS: process.env.SMTP_PASS, + SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL, }, // Test Environment Settings env_test: { NODE_ENV: 'test', name: 'flyer-crawler-worker-test', cwd: '/var/www/flyer-crawler-test.projectium.com', + // Inherit secrets from the deployment environment + DB_HOST: process.env.DB_HOST, + DB_USER: process.env.DB_USER, + DB_PASSWORD: process.env.DB_PASSWORD, + DB_NAME: process.env.DB_NAME, + REDIS_URL: process.env.REDIS_URL, + REDIS_PASSWORD: process.env.REDIS_PASSWORD, + FRONTEND_URL: process.env.FRONTEND_URL, + JWT_SECRET: process.env.JWT_SECRET, + GEMINI_API_KEY: process.env.GEMINI_API_KEY, + GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY, + SMTP_HOST: process.env.SMTP_HOST, + SMTP_PORT: process.env.SMTP_PORT, + SMTP_SECURE: process.env.SMTP_SECURE, + SMTP_USER: process.env.SMTP_USER, + SMTP_PASS: process.env.SMTP_PASS, + SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL, }, // Development Environment Settings env_development: { @@ -56,6 +141,23 @@ module.exports = { name: 'flyer-crawler-worker-dev', watch: true, ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'], + // Inherit secrets from the deployment environment + DB_HOST: process.env.DB_HOST, + DB_USER: process.env.DB_USER, + DB_PASSWORD: process.env.DB_PASSWORD, + DB_NAME: process.env.DB_NAME, + REDIS_URL: process.env.REDIS_URL, + REDIS_PASSWORD: process.env.REDIS_PASSWORD, + FRONTEND_URL: process.env.FRONTEND_URL, + JWT_SECRET: process.env.JWT_SECRET, + GEMINI_API_KEY: process.env.GEMINI_API_KEY, + GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY, + SMTP_HOST: process.env.SMTP_HOST, + SMTP_PORT: process.env.SMTP_PORT, + SMTP_SECURE: process.env.SMTP_SECURE, + SMTP_USER: process.env.SMTP_USER, + SMTP_PASS: process.env.SMTP_PASS, + SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL, }, }, { @@ -68,12 +170,46 @@ module.exports = { NODE_ENV: 'production', name: 'flyer-crawler-analytics-worker', cwd: '/var/www/flyer-crawler.projectium.com', + // Inherit secrets from the deployment environment + DB_HOST: process.env.DB_HOST, + DB_USER: process.env.DB_USER, + DB_PASSWORD: process.env.DB_PASSWORD, + DB_NAME: process.env.DB_NAME, + REDIS_URL: process.env.REDIS_URL, + REDIS_PASSWORD: process.env.REDIS_PASSWORD, + FRONTEND_URL: process.env.FRONTEND_URL, + JWT_SECRET: process.env.JWT_SECRET, + GEMINI_API_KEY: process.env.GEMINI_API_KEY, + GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY, + SMTP_HOST: process.env.SMTP_HOST, + SMTP_PORT: process.env.SMTP_PORT, + SMTP_SECURE: process.env.SMTP_SECURE, + SMTP_USER: process.env.SMTP_USER, + SMTP_PASS: process.env.SMTP_PASS, + SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL, }, // Test Environment Settings env_test: { NODE_ENV: 'test', name: 'flyer-crawler-analytics-worker-test', cwd: '/var/www/flyer-crawler-test.projectium.com', + // Inherit secrets from the deployment environment + DB_HOST: process.env.DB_HOST, + DB_USER: process.env.DB_USER, + DB_PASSWORD: process.env.DB_PASSWORD, + DB_NAME: process.env.DB_NAME, + REDIS_URL: process.env.REDIS_URL, + REDIS_PASSWORD: process.env.REDIS_PASSWORD, + FRONTEND_URL: process.env.FRONTEND_URL, + JWT_SECRET: process.env.JWT_SECRET, + GEMINI_API_KEY: process.env.GEMINI_API_KEY, + GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY, + SMTP_HOST: process.env.SMTP_HOST, + SMTP_PORT: process.env.SMTP_PORT, + SMTP_SECURE: process.env.SMTP_SECURE, + SMTP_USER: process.env.SMTP_USER, + SMTP_PASS: process.env.SMTP_PASS, + SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL, }, // Development Environment Settings env_development: { @@ -81,6 +217,23 @@ module.exports = { name: 'flyer-crawler-analytics-worker-dev', watch: true, ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'], + // Inherit secrets from the deployment environment + DB_HOST: process.env.DB_HOST, + DB_USER: process.env.DB_USER, + DB_PASSWORD: process.env.DB_PASSWORD, + DB_NAME: process.env.DB_NAME, + REDIS_URL: process.env.REDIS_URL, + REDIS_PASSWORD: process.env.REDIS_PASSWORD, + FRONTEND_URL: process.env.FRONTEND_URL, + JWT_SECRET: process.env.JWT_SECRET, + GEMINI_API_KEY: process.env.GEMINI_API_KEY, + GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY, + SMTP_HOST: process.env.SMTP_HOST, + SMTP_PORT: process.env.SMTP_PORT, + SMTP_SECURE: process.env.SMTP_SECURE, + SMTP_USER: process.env.SMTP_USER, + SMTP_PASS: process.env.SMTP_PASS, + SMTP_FROM_EMAIL: process.env.SMTP_FROM_EMAIL, }, }, ], diff --git a/src/App.test.tsx b/src/App.test.tsx index 6bfaa20a..d554484c 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -36,7 +36,7 @@ vi.mock('pdfjs-dist', () => ({ // Mock the new config module vi.mock('./config', () => ({ default: { - app: { version: '1.0.0', commitMessage: 'Initial commit', commitUrl: '#' }, + app: { version: '20250101-1200:abc1234:1.0.0', commitMessage: 'Initial commit', commitUrl: '#' }, google: { mapsEmbedApiKey: 'mock-key' }, }, })); @@ -588,11 +588,11 @@ describe('App Component', () => { // Mock the config module for this specific test vi.mock('./config', () => ({ default: { - app: { version: '1.0.1', commitMessage: 'New feature!', commitUrl: '#' }, + app: { version: '20250101-1200:abc1234:1.0.1', commitMessage: 'New feature!', commitUrl: '#' }, google: { mapsEmbedApiKey: 'mock-key' }, }, })); - localStorageMock.setItem('lastSeenVersion', '1.0.0'); + localStorageMock.setItem('lastSeenVersion', '20250101-1200:abc1234:1.0.0'); renderApp(); await expect(screen.findByTestId('whats-new-modal-mock')).resolves.toBeInTheDocument(); }); @@ -741,7 +741,7 @@ describe('App Component', () => { vi.mock('./config', () => ({ default: { app: { - version: '2.0.0', + version: '20250101-1200:abc1234:2.0.0', commitMessage: 'A new version!', commitUrl: 'http://example.com/commit/2.0.0', }, @@ -752,14 +752,14 @@ describe('App Component', () => { it('should display the version number and commit link', () => { renderApp(); - const versionLink = screen.getByText(`Version: 2.0.0`); + const versionLink = screen.getByText(`Version: 20250101-1200:abc1234:2.0.0`); expect(versionLink).toBeInTheDocument(); expect(versionLink).toHaveAttribute('href', 'http://example.com/commit/2.0.0'); }); it('should open the "What\'s New" modal when the question mark icon is clicked', async () => { // Pre-set the localStorage to prevent the modal from opening automatically - localStorageMock.setItem('lastSeenVersion', '2.0.0'); + localStorageMock.setItem('lastSeenVersion', '20250101-1200:abc1234:2.0.0'); renderApp(); expect(screen.queryByTestId('whats-new-modal-mock')).not.toBeInTheDocument(); diff --git a/src/routes/ai.routes.test.ts b/src/routes/ai.routes.test.ts index 96de6940..00c8fbcf 100644 --- a/src/routes/ai.routes.test.ts +++ b/src/routes/ai.routes.test.ts @@ -535,6 +535,27 @@ describe('AI Routes (/api/ai)', () => { const flyerDataArg = vi.mocked(mockedDb.createFlyerAndItems).mock.calls[0][0]; expect(flyerDataArg.store_name).toBe('Root Store'); }); + + it('should default item quantity to 1 if missing', async () => { + const payloadMissingQuantity = { + checksum: 'qty-checksum', + originalFileName: 'flyer-qty.jpg', + extractedData: { + store_name: 'Qty Store', + items: [{ name: 'Item without qty', price: 100 }], + }, + }; + + const response = await supertest(app) + .post('/api/ai/flyers/process') + .field('data', JSON.stringify(payloadMissingQuantity)) + .attach('flyerImage', imagePath); + + expect(response.status).toBe(201); + expect(mockedDb.createFlyerAndItems).toHaveBeenCalledTimes(1); + const itemsArg = vi.mocked(mockedDb.createFlyerAndItems).mock.calls[0][1]; + expect(itemsArg[0].quantity).toBe(1); + }); }); describe('POST /check-flyer', () => { diff --git a/src/routes/ai.routes.ts b/src/routes/ai.routes.ts index aa04cc26..9ec19985 100644 --- a/src/routes/ai.routes.ts +++ b/src/routes/ai.routes.ts @@ -424,6 +424,7 @@ router.post( const itemsForDb = itemsArray.map((item: Partial) => ({ ...item, master_item_id: item.master_item_id === null ? undefined : item.master_item_id, + quantity: item.quantity ?? 1, // Default to 1 to satisfy DB constraint view_count: 0, click_count: 0, updated_at: new Date().toISOString(), diff --git a/src/tests/integration/price.integration.test.ts b/src/tests/integration/price.integration.test.ts index cb8f8627..db914077 100644 --- a/src/tests/integration/price.integration.test.ts +++ b/src/tests/integration/price.integration.test.ts @@ -56,15 +56,15 @@ describe('Price History API Integration Test (/api/price-history)', () => { // 4. Create flyer items linking the master item to the flyers with prices await pool.query( - `INSERT INTO public.flyer_items (flyer_id, master_item_id, item, price_in_cents, price_display) VALUES ($1, $2, 'Apples', 199, '$1.99')`, + `INSERT INTO public.flyer_items (flyer_id, master_item_id, item, price_in_cents, price_display, quantity) VALUES ($1, $2, 'Apples', 199, '$1.99', '1')`, [flyerId1, masterItemId], ); await pool.query( - `INSERT INTO public.flyer_items (flyer_id, master_item_id, item, price_in_cents, price_display) VALUES ($1, $2, 'Apples', 249, '$2.49')`, + `INSERT INTO public.flyer_items (flyer_id, master_item_id, item, price_in_cents, price_display, quantity) VALUES ($1, $2, 'Apples', 249, '$2.49', '1')`, [flyerId2, masterItemId], ); await pool.query( - `INSERT INTO public.flyer_items (flyer_id, master_item_id, item, price_in_cents, price_display) VALUES ($1, $2, 'Apples', 299, '$2.99')`, + `INSERT INTO public.flyer_items (flyer_id, master_item_id, item, price_in_cents, price_display, quantity) VALUES ($1, $2, 'Apples', 299, '$2.99', '1')`, [flyerId3, masterItemId], ); });