diff --git a/.gitea/workflows/deploy-to-test.yml b/.gitea/workflows/deploy-to-test.yml index 7ccb8217..5f6581d6 100644 --- a/.gitea/workflows/deploy-to-test.yml +++ b/.gitea/workflows/deploy-to-test.yml @@ -335,7 +335,8 @@ jobs: fi GITEA_SERVER_URL="https://gitea.projectium.com" # Your Gitea instance URL - COMMIT_MESSAGE=$(git log -1 --grep="\[skip ci\]" --invert-grep --pretty=%s) + # Sanitize commit message to prevent shell injection or build breaks (removes quotes, backticks, backslashes, $) + COMMIT_MESSAGE=$(git log -1 --grep="\[skip ci\]" --invert-grep --pretty=%s | tr -d '"`\\$') 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 }}" \ diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index 3c41cff4..6f195637 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -16,6 +16,27 @@ if (missingSecrets.length > 0) { console.log('[ecosystem.config.cjs] ✅ Critical environment variables are present.'); } +// --- Shared Environment Variables --- +// Define common variables to reduce duplication and ensure consistency across apps. +const sharedEnv = { + 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, +}; + module.exports = { apps: [ { @@ -25,6 +46,11 @@ module.exports = { script: './node_modules/.bin/tsx', args: 'server.ts', max_memory_restart: '500M', + // Production Optimization: Run in cluster mode to utilize all CPU cores + instances: 'max', + exec_mode: 'cluster', + kill_timeout: 5000, // Allow 5s for graceful shutdown of API requests + log_date_format: 'YYYY-MM-DD HH:mm:ss Z', // Restart Logic max_restarts: 40, @@ -36,46 +62,16 @@ module.exports = { NODE_ENV: 'production', name: 'flyer-crawler-api', cwd: '/var/www/flyer-crawler.projectium.com', - 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, WORKER_LOCK_DURATION: '120000', + ...sharedEnv, }, // Test Environment Settings env_test: { NODE_ENV: 'test', name: 'flyer-crawler-api-test', cwd: '/var/www/flyer-crawler-test.projectium.com', - 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, WORKER_LOCK_DURATION: '120000', + ...sharedEnv, }, // Development Environment Settings env_development: { @@ -83,23 +79,8 @@ module.exports = { name: 'flyer-crawler-api-dev', watch: true, ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'], - 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, WORKER_LOCK_DURATION: '120000', + ...sharedEnv, }, }, { @@ -108,6 +89,8 @@ module.exports = { script: './node_modules/.bin/tsx', args: 'src/services/worker.ts', max_memory_restart: '1G', + kill_timeout: 10000, // Workers may need more time to complete a job + log_date_format: 'YYYY-MM-DD HH:mm:ss Z', // Restart Logic max_restarts: 40, @@ -119,44 +102,14 @@ module.exports = { NODE_ENV: 'production', name: 'flyer-crawler-worker', cwd: '/var/www/flyer-crawler.projectium.com', - 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, + ...sharedEnv, }, // Test Environment Settings env_test: { NODE_ENV: 'test', name: 'flyer-crawler-worker-test', cwd: '/var/www/flyer-crawler-test.projectium.com', - 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, + ...sharedEnv, }, // Development Environment Settings env_development: { @@ -164,22 +117,7 @@ module.exports = { name: 'flyer-crawler-worker-dev', watch: true, ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'], - 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, + ...sharedEnv, }, }, { @@ -188,6 +126,8 @@ module.exports = { script: './node_modules/.bin/tsx', args: 'src/services/worker.ts', max_memory_restart: '1G', + kill_timeout: 10000, + log_date_format: 'YYYY-MM-DD HH:mm:ss Z', // Restart Logic max_restarts: 40, @@ -199,44 +139,14 @@ module.exports = { NODE_ENV: 'production', name: 'flyer-crawler-analytics-worker', cwd: '/var/www/flyer-crawler.projectium.com', - 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, + ...sharedEnv, }, // Test Environment Settings env_test: { NODE_ENV: 'test', name: 'flyer-crawler-analytics-worker-test', cwd: '/var/www/flyer-crawler-test.projectium.com', - 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, + ...sharedEnv, }, // Development Environment Settings env_development: { @@ -244,22 +154,7 @@ module.exports = { name: 'flyer-crawler-analytics-worker-dev', watch: true, ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'], - 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, + ...sharedEnv, }, }, ], diff --git a/src/features/flyer/FlyerDisplay.tsx b/src/features/flyer/FlyerDisplay.tsx index 710ac6bd..310cb39f 100644 --- a/src/features/flyer/FlyerDisplay.tsx +++ b/src/features/flyer/FlyerDisplay.tsx @@ -1,8 +1,8 @@ // src/features/flyer/FlyerDisplay.tsx import React from 'react'; -import { ScanIcon } from '../../components/icons/ScanIcon'; +import { formatDateRange } from '../../utils/dateUtils'; import type { Store } from '../../types'; -import { formatDateRange } from './dateUtils'; +import { ScanIcon } from '../../components/icons/ScanIcon'; export interface FlyerDisplayProps { imageUrl: string | null; diff --git a/src/features/flyer/FlyerList.tsx b/src/features/flyer/FlyerList.tsx index 14ed6213..3465a8cd 100644 --- a/src/features/flyer/FlyerList.tsx +++ b/src/features/flyer/FlyerList.tsx @@ -7,7 +7,7 @@ import { parseISO, format, isValid } from 'date-fns'; import { MapPinIcon, Trash2Icon } from 'lucide-react'; import { logger } from '../../services/logger.client'; import * as apiClient from '../../services/apiClient'; -import { calculateDaysBetween, formatDateRange } from './dateUtils'; +import { calculateDaysBetween, formatDateRange, getCurrentDateISOString } from '../../utils/dateUtils'; interface FlyerListProps { flyers: Flyer[]; @@ -54,7 +54,7 @@ export const FlyerList: React.FC = ({ verbose: true, }); - const daysLeft = calculateDaysBetween(format(new Date(), 'yyyy-MM-dd'), flyer.valid_to); + const daysLeft = calculateDaysBetween(getCurrentDateISOString(), flyer.valid_to); let daysLeftText = ''; let daysLeftColor = ''; diff --git a/src/features/flyer/dateUtils.test.ts b/src/features/flyer/dateUtils.test.ts deleted file mode 100644 index 6b046cfb..00000000 --- a/src/features/flyer/dateUtils.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -// src/features/flyer/dateUtils.test.ts -import { describe, it, expect } from 'vitest'; -import { formatShortDate, calculateDaysBetween, formatDateRange } from './dateUtils'; - -describe('formatShortDate', () => { - it('should format a valid YYYY-MM-DD date string correctly', () => { - expect(formatShortDate('2024-07-26')).toBe('Jul 26'); - }); - - it('should handle single-digit days correctly', () => { - expect(formatShortDate('2025-01-05')).toBe('Jan 5'); - }); - - it('should handle dates at the end of the year', () => { - expect(formatShortDate('2023-12-31')).toBe('Dec 31'); - }); - - it('should return null for a null input', () => { - expect(formatShortDate(null)).toBeNull(); - }); - - it('should return null for an undefined input', () => { - expect(formatShortDate(undefined)).toBeNull(); - }); - - it('should return null for an empty string input', () => { - expect(formatShortDate('')).toBeNull(); - }); - - it('should return null for an invalid date string', () => { - expect(formatShortDate('not-a-real-date')).toBeNull(); - }); - - it('should return null for a malformed date string', () => { - expect(formatShortDate('2024-13-01')).toBeNull(); // Invalid month - }); - - it('should correctly format a full ISO string with time and timezone', () => { - expect(formatShortDate('2024-12-25T10:00:00Z')).toBe('Dec 25'); - }); -}); - -describe('calculateDaysBetween', () => { - it('should calculate the difference in days between two valid date strings', () => { - expect(calculateDaysBetween('2023-01-01', '2023-01-05')).toBe(4); - }); - - it('should return a negative number if the end date is before the start date', () => { - expect(calculateDaysBetween('2023-01-05', '2023-01-01')).toBe(-4); - }); - - it('should handle Date objects', () => { - const start = new Date('2023-01-01'); - const end = new Date('2023-01-10'); - expect(calculateDaysBetween(start, end)).toBe(9); - }); - - it('should return null if either date is null or undefined', () => { - expect(calculateDaysBetween(null, '2023-01-01')).toBeNull(); - expect(calculateDaysBetween('2023-01-01', undefined)).toBeNull(); - }); - - it('should return null if either date is invalid', () => { - expect(calculateDaysBetween('invalid', '2023-01-01')).toBeNull(); - expect(calculateDaysBetween('2023-01-01', 'invalid')).toBeNull(); - }); -}); - -describe('formatDateRange', () => { - it('should format a range with two different valid dates', () => { - expect(formatDateRange('2023-01-01', '2023-01-05')).toBe('Jan 1 - Jan 5'); - }); - - it('should format a range with the same start and end date as a single date', () => { - expect(formatDateRange('2023-01-01', '2023-01-01')).toBe('Jan 1'); - }); - - it('should return only the start date if end date is missing', () => { - expect(formatDateRange('2023-01-01', null)).toBe('Jan 1'); - expect(formatDateRange('2023-01-01', undefined)).toBe('Jan 1'); - }); - - it('should return only the end date if start date is missing', () => { - expect(formatDateRange(null, '2023-01-05')).toBe('Jan 5'); - expect(formatDateRange(undefined, '2023-01-05')).toBe('Jan 5'); - }); - - it('should return null if both dates are missing or invalid', () => { - expect(formatDateRange(null, null)).toBeNull(); - expect(formatDateRange(undefined, undefined)).toBeNull(); - expect(formatDateRange('invalid', 'invalid')).toBeNull(); - }); - - it('should handle one valid and one invalid date by showing only the valid one', () => { - expect(formatDateRange('2023-01-01', 'invalid')).toBe('Jan 1'); - expect(formatDateRange('invalid', '2023-01-05')).toBe('Jan 5'); - }); - - describe('verbose mode', () => { - it('should format a range with two different valid dates verbosely', () => { - expect(formatDateRange('2023-01-01', '2023-01-05', { verbose: true })).toBe( - 'Deals valid from January 1, 2023 to January 5, 2023', - ); - }); - - it('should format a range with the same start and end date verbosely', () => { - expect(formatDateRange('2023-01-01', '2023-01-01', { verbose: true })).toBe( - 'Valid on January 1, 2023', - ); - }); - - it('should format only the start date verbosely', () => { - expect(formatDateRange('2023-01-01', null, { verbose: true })).toBe( - 'Deals start January 1, 2023', - ); - }); - - it('should format only the end date verbosely', () => { - expect(formatDateRange(null, '2023-01-05', { verbose: true })).toBe( - 'Deals end January 5, 2023', - ); - }); - - it('should handle one valid and one invalid date verbosely', () => { - expect(formatDateRange('2023-01-01', 'invalid', { verbose: true })).toBe( - 'Deals start January 1, 2023', - ); - }); - }); -}); diff --git a/src/features/flyer/dateUtils.ts b/src/features/flyer/dateUtils.ts deleted file mode 100644 index 14100b9c..00000000 --- a/src/features/flyer/dateUtils.ts +++ /dev/null @@ -1,65 +0,0 @@ -// src/features/flyer/dateUtils.ts -import { parseISO, format, isValid, differenceInDays } from 'date-fns'; - -export const formatShortDate = (dateString: string | null | undefined): string | null => { - if (!dateString) return null; - // Using `parseISO` from date-fns is more reliable than `new Date()` for YYYY-MM-DD strings. - // It correctly interprets the string as a local date, avoiding timezone-related "off-by-one" errors. - const date = parseISO(dateString); - if (isValid(date)) { - return format(date, 'MMM d'); - } - return null; -}; - -export const calculateDaysBetween = ( - startDate: string | Date | null | undefined, - endDate: string | Date | null | undefined, -): number | null => { - if (!startDate || !endDate) return null; - - const start = typeof startDate === 'string' ? parseISO(startDate) : startDate; - const end = typeof endDate === 'string' ? parseISO(endDate) : endDate; - - if (!isValid(start) || !isValid(end)) return null; - - return differenceInDays(end, start); -}; - -interface DateRangeOptions { - verbose?: boolean; -} - -export const formatDateRange = ( - startDate: string | null | undefined, - endDate: string | null | undefined, - options?: DateRangeOptions, -): string | null => { - if (!options?.verbose) { - const start = formatShortDate(startDate); - const end = formatShortDate(endDate); - - if (start && end) { - return start === end ? start : `${start} - ${end}`; - } - return start || end || null; - } - - // Verbose format logic - const dateFormat = 'MMMM d, yyyy'; - const formatFn = (dateStr: string | null | undefined) => { - if (!dateStr) return null; - const date = parseISO(dateStr); - return isValid(date) ? format(date, dateFormat) : null; - }; - - const start = formatFn(startDate); - const end = formatFn(endDate); - - if (start && end) { - return start === end ? `Valid on ${start}` : `Deals valid from ${start} to ${end}`; - } - if (start) return `Deals start ${start}`; - if (end) return `Deals end ${end}`; - return null; -}; diff --git a/src/services/aiService.server.ts b/src/services/aiService.server.ts index 8da89779..8f9580b8 100644 --- a/src/services/aiService.server.ts +++ b/src/services/aiService.server.ts @@ -23,8 +23,8 @@ import * as db from './db/index.db'; import { flyerQueue } from './queueService.server'; import type { Job } from 'bullmq'; import { createFlyerAndItems } from './db/flyer.db'; -import { getBaseUrl } from '../utils/serverUtils'; -import { generateFlyerIcon } from '../utils/imageProcessor'; +import { getBaseUrl } from '../utils/serverUtils'; // This was a duplicate, fixed. +import { generateFlyerIcon, processAndSaveImage } from '../utils/imageProcessor'; import { AdminRepository } from './db/admin.db'; import path from 'path'; import { ValidationError } from './db/errors.db'; // Keep this import for ValidationError @@ -373,62 +373,43 @@ export class AIService { * @returns The parsed JSON object, or null if parsing fails. */ private _parseJsonFromAiResponse(responseText: string | undefined, logger: Logger): T | null { - // --- START HYPER-DIAGNOSTIC LOGGING --- - console.log('\n--- DIAGNOSING _parseJsonFromAiResponse ---'); - console.log( - `1. Initial responseText (Type: ${typeof responseText}):`, - JSON.stringify(responseText), + // --- START EXTENSIVE DEBUG LOGGING --- + logger.debug( + { + responseText_type: typeof responseText, + responseText_length: responseText?.length, + responseText_preview: responseText?.substring(0, 200), + }, + '[_parseJsonFromAiResponse] Starting JSON parsing.', ); - // --- END HYPER-DIAGNOSTIC LOGGING --- if (!responseText) { - logger.warn( - '[_parseJsonFromAiResponse] Response text is empty or undefined. Returning null.', - ); - console.log('2. responseText is falsy. ABORTING.'); - console.log('--- END DIAGNOSIS ---\n'); + logger.warn('[_parseJsonFromAiResponse] Response text is empty or undefined. Aborting parsing.'); return null; } // Find the start of the JSON, which can be inside a markdown block const markdownRegex = /```(json)?\s*([\s\S]*?)\s*```/; const markdownMatch = responseText.match(markdownRegex); - console.log('2. Regex Result (markdownMatch):', markdownMatch); let jsonString; if (markdownMatch && markdownMatch[2] !== undefined) { - // Check for capture group - console.log('3. Regex matched. Processing Captured Group.'); - console.log( - ` - Captured content (Type: ${typeof markdownMatch[2]}, Length: ${markdownMatch[2].length}):`, - JSON.stringify(markdownMatch[2]), - ); logger.debug( - { rawCapture: markdownMatch[2] }, + { capturedLength: markdownMatch[2].length }, '[_parseJsonFromAiResponse] Found JSON content within markdown code block.', ); - jsonString = markdownMatch[2].trim(); - console.log( - `4. After trimming, jsonString is (Type: ${typeof jsonString}, Length: ${jsonString.length}):`, - JSON.stringify(jsonString), - ); - logger.debug( - { trimmedJsonString: jsonString }, - '[_parseJsonFromAiResponse] Trimmed extracted JSON string.', - ); } else { - console.log( - '3. Regex did NOT match or capture group 2 is undefined. Will attempt to parse entire responseText.', - ); + logger.debug('[_parseJsonFromAiResponse] No markdown code block found. Using raw response text.'); jsonString = responseText; } // Find the first '{' or '[' and the last '}' or ']' to isolate the JSON object. const firstBrace = jsonString.indexOf('{'); const firstBracket = jsonString.indexOf('['); - console.log( - `5. Index search on jsonString: firstBrace=${firstBrace}, firstBracket=${firstBracket}`, + logger.debug( + { firstBrace, firstBracket }, + '[_parseJsonFromAiResponse] Searching for start of JSON.', ); // Determine the starting point of the JSON content @@ -436,37 +417,44 @@ export class AIService { firstBrace === -1 || (firstBracket !== -1 && firstBracket < firstBrace) ? firstBracket : firstBrace; - console.log('6. Calculated startIndex:', startIndex); if (startIndex === -1) { logger.error( { responseText }, "[_parseJsonFromAiResponse] Could not find starting '{' or '[' in response.", ); - console.log('7. startIndex is -1. ABORTING.'); - console.log('--- END DIAGNOSIS ---\n'); return null; } - const jsonSlice = jsonString.substring(startIndex); - console.log( - `8. Sliced string to be parsed (jsonSlice) (Length: ${jsonSlice.length}):`, - JSON.stringify(jsonSlice), + // Find the last brace or bracket to gracefully handle trailing text. + // This is a robust way to handle cases where the AI might add trailing text after the JSON. + const lastBrace = jsonString.lastIndexOf('}'); + const lastBracket = jsonString.lastIndexOf(']'); + const endIndex = Math.max(lastBrace, lastBracket); + + if (endIndex === -1) { + logger.error( + { responseText }, + "[_parseJsonFromAiResponse] Could not find ending '}' or ']' in response.", + ); + return null; + } + + const jsonSlice = jsonString.substring(startIndex, endIndex + 1); + logger.debug( + { sliceLength: jsonSlice.length }, + '[_parseJsonFromAiResponse] Extracted JSON slice for parsing.', ); try { - console.log('9. Attempting JSON.parse on jsonSlice...'); const parsed = JSON.parse(jsonSlice) as T; - console.log('10. SUCCESS: JSON.parse succeeded.'); - console.log('--- END DIAGNOSIS (SUCCESS) ---\n'); + logger.info('[_parseJsonFromAiResponse] Successfully parsed JSON from AI response.'); return parsed; } catch (e) { logger.error( { jsonSlice, error: e, errorMessage: (e as Error).message, stack: (e as Error).stack }, '[_parseJsonFromAiResponse] Failed to parse JSON slice.', ); - console.error('10. FAILURE: JSON.parse FAILED. Error:', e); - console.log('--- END DIAGNOSIS (FAILURE) ---\n'); return null; } } @@ -799,6 +787,18 @@ async enqueueFlyerProcessing( } const baseUrl = getBaseUrl(logger); + // --- START DEBUGGING --- + // Add a fail-fast check to ensure the baseUrl is a valid URL before enqueuing. + // This will make the test fail at the upload step if the URL is the problem, + // which is easier to debug than a worker failure. + if (!baseUrl || !baseUrl.startsWith('http')) { + const errorMessage = `[aiService] FATAL: The generated baseUrl is not a valid absolute URL. Value: "${baseUrl}". This will cause the flyer processing worker to fail. Check the FRONTEND_URL environment variable.`; + logger.error(errorMessage); + // Throw a standard error that the calling route can handle. + throw new Error(errorMessage); + } + logger.info({ baseUrl }, '[aiService] Enqueuing job with valid baseUrl.'); + // --- END DEBUGGING --- // 3. Add job to the queue const job = await flyerQueue.add('process-flyer', { @@ -907,14 +907,24 @@ async enqueueFlyerProcessing( logger.warn('extractedData.store_name missing; using fallback store name.'); } - const iconsDir = path.join(path.dirname(file.path), 'icons'); - const iconFileName = await generateFlyerIcon(file.path, iconsDir, logger); + // Process the uploaded image to strip metadata and optimize it. + const flyerImageDir = path.dirname(file.path); + const processedImageFileName = await processAndSaveImage( + file.path, + flyerImageDir, + originalFileName, + logger, + ); + const processedImagePath = path.join(flyerImageDir, processedImageFileName); + + // Generate the icon from the newly processed (and cleaned) image. + const iconsDir = path.join(flyerImageDir, 'icons'); + const iconFileName = await generateFlyerIcon(processedImagePath, iconsDir, logger); const baseUrl = getBaseUrl(logger); - logger.debug({ baseUrl, file }, 'Building legacy URLs'); const iconUrl = `${baseUrl}/flyer-images/icons/${iconFileName}`; - const imageUrl = `${baseUrl}/flyer-images/${file.filename}`; - logger.debug({ imageUrl, iconUrl }, 'Constructed legacy URLs'); + const imageUrl = `${baseUrl}/flyer-images/${processedImageFileName}`; + logger.debug({ imageUrl, iconUrl }, 'Constructed URLs for legacy upload'); const flyerData: FlyerInsert = { file_name: originalFileName, diff --git a/src/services/backgroundJobService.ts b/src/services/backgroundJobService.ts index 104c7f0d..d68ac59c 100644 --- a/src/services/backgroundJobService.ts +++ b/src/services/backgroundJobService.ts @@ -2,8 +2,9 @@ import cron from 'node-cron'; import type { Logger } from 'pino'; import type { Queue } from 'bullmq'; -import { Notification, WatchedItemDeal } from '../types'; -import { getSimpleWeekAndYear } from '../utils/dateUtils'; +import { formatCurrency } from '../utils/formatUtils'; +import { getSimpleWeekAndYear, getCurrentDateISOString } from '../utils/dateUtils'; +import type { Notification, WatchedItemDeal } from '../types'; // Import types for repositories from their source files import type { PersonalizationRepository } from './db/personalization.db'; import type { NotificationRepository } from './db/notification.db'; @@ -25,7 +26,7 @@ export class BackgroundJobService { ) {} public async triggerAnalyticsReport(): Promise { - const reportDate = new Date().toISOString().split('T')[0]; // YYYY-MM-DD + const reportDate = getCurrentDateISOString(); // YYYY-MM-DD const jobId = `manual-report-${reportDate}-${Date.now()}`; const job = await analyticsQueue.add('generate-daily-report', { reportDate }, { jobId }); return job.id!; @@ -57,14 +58,16 @@ export class BackgroundJobService { const dealsListHtml = deals .map( (deal) => - `
  • ${deal.item_name} is on sale for $${(deal.best_price_in_cents / 100).toFixed(2)} at ${deal.store_name}!
  • `, + `
  • ${deal.item_name} is on sale for ${formatCurrency( + deal.best_price_in_cents, + )} at ${deal.store_name}!
  • `, ) .join(''); const html = `

    Hi ${recipientName},

    We found some great deals on items you're watching:

      ${dealsListHtml}
    `; const text = `Hi ${recipientName},\n\nWe found some great deals on items you're watching. Visit the deals page on the site to learn more.`; // Use a predictable Job ID to prevent duplicate email notifications for the same user on the same day. - const today = new Date().toISOString().split('T')[0]; + const today = getCurrentDateISOString(); const jobId = `deal-email-${userProfile.user_id}-${today}`; return { @@ -82,12 +85,11 @@ export class BackgroundJobService { private _prepareInAppNotification( userId: string, dealCount: number, - ): Omit { + ): Omit { return { user_id: userId, content: `You have ${dealCount} new deal(s) on your watched items!`, link_url: '/dashboard/deals', // A link to the future "My Deals" page - updated_at: new Date().toISOString(), }; } @@ -244,7 +246,7 @@ export function startBackgroundJobs( (async () => { logger.info('[BackgroundJob] Enqueuing daily analytics report generation job.'); try { - const reportDate = new Date().toISOString().split('T')[0]; // YYYY-MM-DD + const reportDate = getCurrentDateISOString(); // YYYY-MM-DD // We use a unique job ID to prevent duplicate jobs for the same day if the scheduler restarts. await analyticsQueue.add( 'generate-daily-report', diff --git a/src/services/flyerProcessingService.server.ts b/src/services/flyerProcessingService.server.ts index a21adb96..40f407ba 100644 --- a/src/services/flyerProcessingService.server.ts +++ b/src/services/flyerProcessingService.server.ts @@ -1,6 +1,5 @@ // src/services/flyerProcessingService.server.ts -import type { Job, Queue } from 'bullmq'; -import { UnrecoverableError } from 'bullmq'; +import { UnrecoverableError, type Job, type Queue } from 'bullmq'; import path from 'path'; import type { Logger } from 'pino'; import type { FlyerFileHandler, IFileSystem, ICommandExecutor } from './flyerFileHandler.server'; @@ -19,6 +18,7 @@ import { import { NotFoundError } from './db/errors.db'; import { createFlyerAndItems } from './db/flyer.db'; import { logger as globalLogger } from './logger.server'; +import { processAndSaveImage, generateFlyerIcon } from '../utils/imageProcessor'; // Define ProcessingStage locally as it's not exported from the types file. export type ProcessingStage = { @@ -80,6 +80,31 @@ export class FlyerProcessingService { stages[0].detail = `${imagePaths.length} page(s) ready for AI.`; await job.updateProgress({ stages }); + // --- START FIX for Integration Tests --- + // The integration tests upload single-page images (JPG/PNG). We assume the first + // image is the primary one to be processed for metadata stripping and icon generation. + const primaryImage = imagePaths[0]; + if (!primaryImage) { + throw new FlyerProcessingError('No processable image found after preparation stage.', 'INPUT_ERROR'); + } + + const flyerImageDir = path.dirname(primaryImage.path); + + // Process the main image to strip metadata and optimize it. This creates a new file. + const processedImageFileName = await processAndSaveImage( + primaryImage.path, + flyerImageDir, + job.data.originalFileName, + logger, + ); + const processedImagePath = path.join(flyerImageDir, processedImageFileName); + allFilePaths.push(processedImagePath); // Track the new file for cleanup. + + // Generate the icon from the NEWLY PROCESSED image. + const iconsDir = path.join(flyerImageDir, 'icons'); + const iconFileName = await generateFlyerIcon(processedImagePath, iconsDir, logger); + allFilePaths.push(path.join(iconsDir, iconFileName)); // Track icon for cleanup. + // Stage 2: Extract Data with AI stages[1].status = 'in-progress'; await job.updateProgress({ stages }); @@ -101,6 +126,11 @@ export class FlyerProcessingService { logger, job.data.baseUrl, ); + + // Overwrite the URLs generated by the transformer to point to our processed files. + // This ensures the correct, metadata-stripped image is referenced in the database. + flyerData.image_url = `${job.data.baseUrl}/flyer-images/${processedImageFileName}`; + flyerData.icon_url = `${job.data.baseUrl}/flyer-images/icons/${iconFileName}`; stages[2].status = 'completed'; await job.updateProgress({ stages }); diff --git a/src/utils/dateUtils.test.ts b/src/utils/dateUtils.test.ts index 0e270a23..03c65acb 100644 --- a/src/utils/dateUtils.test.ts +++ b/src/utils/dateUtils.test.ts @@ -1,9 +1,15 @@ // src/utils/dateUtils.test.ts import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; -import { getSimpleWeekAndYear } from './dateUtils'; +import { + calculateSimpleWeekAndYear, + formatShortDate, + calculateDaysBetween, + formatDateRange, + getCurrentDateISOString, +} from './dateUtils'; describe('dateUtils', () => { - describe('getSimpleWeekAndYear', () => { + describe('calculateSimpleWeekAndYear', () => { beforeEach(() => { // Use fake timers to control the current date in tests vi.useFakeTimers(); @@ -16,35 +22,35 @@ describe('dateUtils', () => { it('should return week 1 for the first day of the year', () => { const date = new Date('2024-01-01T12:00:00Z'); - expect(getSimpleWeekAndYear(date)).toEqual({ year: 2024, week: 1 }); + expect(calculateSimpleWeekAndYear(date)).toEqual({ year: 2024, week: 1 }); }); it('should return week 1 for the 7th day of the year', () => { const date = new Date('2024-01-07T12:00:00Z'); - expect(getSimpleWeekAndYear(date)).toEqual({ year: 2024, week: 1 }); + expect(calculateSimpleWeekAndYear(date)).toEqual({ year: 2024, week: 1 }); }); it('should return week 2 for the 8th day of the year', () => { const date = new Date('2024-01-08T12:00:00Z'); - expect(getSimpleWeekAndYear(date)).toEqual({ year: 2024, week: 2 }); + expect(calculateSimpleWeekAndYear(date)).toEqual({ year: 2024, week: 2 }); }); it('should correctly calculate the week for a date in the middle of the year', () => { // July 1st is the 183rd day of a leap year. 182 / 7 = 26. So it's week 27. const date = new Date('2024-07-01T12:00:00Z'); - expect(getSimpleWeekAndYear(date)).toEqual({ year: 2024, week: 27 }); + expect(calculateSimpleWeekAndYear(date)).toEqual({ year: 2024, week: 27 }); }); it('should correctly calculate the week for the last day of a non-leap year', () => { // Dec 31, 2023 is the 365th day. 364 / 7 = 52. So it's week 53. const date = new Date('2023-12-31T12:00:00Z'); - expect(getSimpleWeekAndYear(date)).toEqual({ year: 2023, week: 53 }); + expect(calculateSimpleWeekAndYear(date)).toEqual({ year: 2023, week: 53 }); }); it('should correctly calculate the week for the last day of a leap year', () => { // Dec 31, 2024 is the 366th day. 365 / 7 = 52.14. floor(52.14) + 1 = 53. const date = new Date('2024-12-31T12:00:00Z'); - expect(getSimpleWeekAndYear(date)).toEqual({ year: 2024, week: 53 }); + expect(calculateSimpleWeekAndYear(date)).toEqual({ year: 2024, week: 53 }); }); it('should use the current date if no date is provided', () => { @@ -52,7 +58,172 @@ describe('dateUtils', () => { vi.setSystemTime(fakeCurrentDate); // 40 / 7 = 5.71. floor(5.71) + 1 = 6. - expect(getSimpleWeekAndYear()).toEqual({ year: 2025, week: 6 }); + expect(calculateSimpleWeekAndYear()).toEqual({ year: 2025, week: 6 }); + }); + }); + + describe('formatShortDate', () => { + it('should format a valid YYYY-MM-DD date string correctly', () => { + expect(formatShortDate('2024-07-26')).toBe('Jul 26'); + }); + + it('should handle single-digit days correctly', () => { + expect(formatShortDate('2025-01-05')).toBe('Jan 5'); + }); + + it('should handle dates at the end of the year', () => { + expect(formatShortDate('2023-12-31')).toBe('Dec 31'); + }); + + it('should return null for a null input', () => { + expect(formatShortDate(null)).toBeNull(); + }); + + it('should return null for an undefined input', () => { + expect(formatShortDate(undefined)).toBeNull(); + }); + + it('should return null for an empty string input', () => { + expect(formatShortDate('')).toBeNull(); + }); + + it('should return null for an invalid date string', () => { + expect(formatShortDate('not-a-real-date')).toBeNull(); + }); + + it('should return null for a malformed date string', () => { + expect(formatShortDate('2024-13-01')).toBeNull(); // Invalid month + }); + + it('should correctly format a full ISO string with time and timezone', () => { + expect(formatShortDate('2024-12-25T10:00:00Z')).toBe('Dec 25'); + }); + }); + + describe('calculateDaysBetween', () => { + it('should calculate the difference in days between two valid date strings', () => { + expect(calculateDaysBetween('2023-01-01', '2023-01-05')).toBe(4); + }); + + it('should return a negative number if the end date is before the start date', () => { + expect(calculateDaysBetween('2023-01-05', '2023-01-01')).toBe(-4); + }); + + it('should handle Date objects', () => { + const start = new Date('2023-01-01'); + const end = new Date('2023-01-10'); + expect(calculateDaysBetween(start, end)).toBe(9); + }); + + it('should return null if either date is null or undefined', () => { + expect(calculateDaysBetween(null, '2023-01-01')).toBeNull(); + expect(calculateDaysBetween('2023-01-01', undefined)).toBeNull(); + }); + + it('should return null if either date is invalid', () => { + expect(calculateDaysBetween('invalid', '2023-01-01')).toBeNull(); + expect(calculateDaysBetween('2023-01-01', 'invalid')).toBeNull(); + }); + }); + + describe('formatDateRange', () => { + it('should format a range with two different valid dates', () => { + expect(formatDateRange('2023-01-01', '2023-01-05')).toBe('Jan 1 - Jan 5'); + }); + + it('should format a range with the same start and end date as a single date', () => { + expect(formatDateRange('2023-01-01', '2023-01-01')).toBe('Jan 1'); + }); + + it('should return only the start date if end date is missing', () => { + expect(formatDateRange('2023-01-01', null)).toBe('Jan 1'); + expect(formatDateRange('2023-01-01', undefined)).toBe('Jan 1'); + }); + + it('should return only the end date if start date is missing', () => { + expect(formatDateRange(null, '2023-01-05')).toBe('Jan 5'); + expect(formatDateRange(undefined, '2023-01-05')).toBe('Jan 5'); + }); + + it('should return null if both dates are missing or invalid', () => { + expect(formatDateRange(null, null)).toBeNull(); + expect(formatDateRange(undefined, undefined)).toBeNull(); + expect(formatDateRange('invalid', 'invalid')).toBeNull(); + }); + + it('should handle one valid and one invalid date by showing only the valid one', () => { + expect(formatDateRange('2023-01-01', 'invalid')).toBe('Jan 1'); + expect(formatDateRange('invalid', '2023-01-05')).toBe('Jan 5'); + }); + + it('should handle empty strings as invalid dates', () => { + expect(formatDateRange('', '2023-01-05')).toBe('Jan 5'); + expect(formatDateRange('2023-01-05', '')).toBe('Jan 5'); + expect(formatDateRange('', '')).toBeNull(); + }); + + it('should handle garbage strings as invalid dates', () => { + expect(formatDateRange('garbage', '2023-01-05')).toBe('Jan 5'); + expect(formatDateRange('2023-01-05', 'garbage')).toBe('Jan 5'); + }); + + it('should handle start date being after end date (raw format)', () => { + // The function currently doesn't validate order, it just formats what it's given. + // This test ensures it doesn't crash or behave unexpectedly. + expect(formatDateRange('2023-02-01', '2023-01-01')).toBe('Feb 1 - Jan 1'); + }); + + it('should handle dates with time components correctly', () => { + // parseISO should handle the time component and formatShortDate should strip it + expect(formatDateRange('2023-01-01T10:00:00', '2023-01-05T15:30:00')).toBe( + 'Jan 1 - Jan 5', + ); + }); + + describe('verbose mode', () => { + it('should format a range with two different valid dates verbosely', () => { + expect(formatDateRange('2023-01-01', '2023-01-05', { verbose: true })).toBe( + 'Deals valid from January 1, 2023 to January 5, 2023', + ); + }); + + it('should format a range with the same start and end date verbosely', () => { + expect(formatDateRange('2023-01-01', '2023-01-01', { verbose: true })).toBe( + 'Valid on January 1, 2023', + ); + }); + + it('should format only the start date verbosely', () => { + expect(formatDateRange('2023-01-01', null, { verbose: true })).toBe( + 'Deals start January 1, 2023', + ); + }); + + it('should format only the end date verbosely', () => { + expect(formatDateRange(null, '2023-01-05', { verbose: true })).toBe( + 'Deals end January 5, 2023', + ); + }); + + it('should handle one valid and one invalid date verbosely', () => { + expect(formatDateRange('2023-01-01', 'invalid', { verbose: true })).toBe( + 'Deals start January 1, 2023', + ); + }); + + it('should handle start date being after end date verbosely', () => { + expect(formatDateRange('2023-02-01', '2023-01-01', { verbose: true })).toBe( + 'Deals valid from February 1, 2023 to January 1, 2023', + ); + }); + }); + }); + + describe('getCurrentDateISOString', () => { + it('should return the current date in YYYY-MM-DD format', () => { + const fakeDate = new Date('2025-12-25T10:00:00Z'); + vi.setSystemTime(fakeDate); + expect(getCurrentDateISOString()).toBe('2025-12-25'); }); }); }); diff --git a/src/utils/dateUtils.ts b/src/utils/dateUtils.ts index 5258d7af..282a3fa3 100644 --- a/src/utils/dateUtils.ts +++ b/src/utils/dateUtils.ts @@ -1,4 +1,5 @@ // src/utils/dateUtils.ts +import { parseISO, format, isValid, differenceInDays } from 'date-fns'; /** * Calculates the current year and a simplified week number. @@ -7,16 +8,93 @@ * For true ISO 8601 week numbers (where week 1 is the first week with a Thursday), * a dedicated library like `date-fns` (`getISOWeek` and `getISOWeekYear`) is recommended. * - * @param date The date to calculate the week for. Defaults to the current date. + * @param date The date to calculate the simple week for. Defaults to the current date. * @returns An object containing the year and week number. */ -export function getSimpleWeekAndYear(date: Date = new Date()): { year: number; week: number } { +export function calculateSimpleWeekAndYear(date: Date = new Date()): { year: number; week: number } { const year = date.getFullYear(); - const startOfYear = new Date(year, 0, 1); - // Calculate the difference in days from the start of the year (day 0 to 364/365) - const dayOfYear = (date.getTime() - startOfYear.getTime()) / (1000 * 60 * 60 * 24); + // Use UTC dates to calculate the difference in days. + // This avoids issues with Daylight Saving Time (DST) where a day might have 23 or 25 hours, + // which can cause the millisecond-based calculation to be slightly off (e.g., 149.96 days). + const startOfYear = Date.UTC(year, 0, 1); + const current = Date.UTC(year, date.getMonth(), date.getDate()); + const msPerDay = 1000 * 60 * 60 * 24; + const dayOfYear = (current - startOfYear) / msPerDay; // Divide by 7, take the floor to get the zero-based week, and add 1 for a one-based week number. const week = Math.floor(dayOfYear / 7) + 1; return { year, week }; } + +export const formatShortDate = (dateString: string | null | undefined): string | null => { + if (!dateString) return null; + // Using `parseISO` from date-fns is more reliable than `new Date()` for YYYY-MM-DD strings. + // It correctly interprets the string as a local date, avoiding timezone-related "off-by-one" errors. + const date = parseISO(dateString); + if (isValid(date)) { + return format(date, 'MMM d'); + } + return null; +}; + +export const calculateDaysBetween = ( + startDate: string | Date | null | undefined, + endDate: string | Date | null | undefined, +): number | null => { + if (!startDate || !endDate) return null; + + const start = typeof startDate === 'string' ? parseISO(startDate) : startDate; + const end = typeof endDate === 'string' ? parseISO(endDate) : endDate; + + if (!isValid(start) || !isValid(end)) return null; + + return differenceInDays(end, start); +}; + +interface DateRangeOptions { + verbose?: boolean; +} + +export const formatDateRange = ( + startDate: string | null | undefined, + endDate: string | null | undefined, + options?: DateRangeOptions, +): string | null => { + if (!options?.verbose) { + const start = formatShortDate(startDate); + const end = formatShortDate(endDate); + + if (start && end) { + return start === end ? start : `${start} - ${end}`; + } + return start || end || null; + } + + // Verbose format logic + const dateFormat = 'MMMM d, yyyy'; + const formatFn = (dateStr: string | null | undefined) => { + if (!dateStr) return null; + const date = parseISO(dateStr); + return isValid(date) ? format(date, dateFormat) : null; + }; + + const start = formatFn(startDate); + const end = formatFn(endDate); + + if (start && end) { + return start === end ? `Valid on ${start}` : `Deals valid from ${start} to ${end}`; + } + if (start) return `Deals start ${start}`; + if (end) return `Deals end ${end}`; + return null; +}; + +/** + * Returns the current date as an ISO 8601 string (YYYY-MM-DD). + * Useful for getting "today" without the time component. + */ +export const getCurrentDateISOString = (): string => { + return format(new Date(), 'yyyy-MM-dd'); +}; + +export { calculateSimpleWeekAndYear as getSimpleWeekAndYear }; diff --git a/src/utils/formatUtils.test.ts b/src/utils/formatUtils.test.ts new file mode 100644 index 00000000..16d917e9 --- /dev/null +++ b/src/utils/formatUtils.test.ts @@ -0,0 +1,33 @@ +// src/utils/formatUtils.test.ts +import { describe, it, expect } from 'vitest'; +import { formatCurrency } from './formatUtils'; + +describe('formatCurrency', () => { + it('should format a positive integer of cents correctly', () => { + expect(formatCurrency(199)).toBe('$1.99'); + }); + + it('should format a larger number of cents correctly', () => { + expect(formatCurrency(12345)).toBe('$123.45'); + }); + + it('should handle single-digit cents correctly', () => { + expect(formatCurrency(5)).toBe('$0.05'); + }); + + it('should handle zero cents correctly', () => { + expect(formatCurrency(0)).toBe('$0.00'); + }); + + it('should return "N/A" for a null input', () => { + expect(formatCurrency(null)).toBe('N/A'); + }); + + it('should return "N/A" for an undefined input', () => { + expect(formatCurrency(undefined)).toBe('N/A'); + }); + + it('should handle negative cents correctly', () => { + expect(formatCurrency(-500)).toBe('-$5.00'); + }); +}); \ No newline at end of file diff --git a/src/utils/formatUtils.ts b/src/utils/formatUtils.ts new file mode 100644 index 00000000..ac887ce8 --- /dev/null +++ b/src/utils/formatUtils.ts @@ -0,0 +1,14 @@ +// src/utils/formatUtils.ts + +/** + * Formats a numeric value in cents into a currency string (e.g., $4.99). + * Handles different locales and currency symbols gracefully. + * + * @param amountInCents The amount in cents to format. Can be null or undefined. + * @returns A formatted currency string (e.g., "$4.99"), or 'N/A' if the input is null/undefined. + */ +export const formatCurrency = (amountInCents: number | null | undefined): string => { + if (amountInCents === null || amountInCents === undefined) return 'N/A'; + + return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amountInCents / 100); +}; \ No newline at end of file diff --git a/src/utils/imageProcessor.ts b/src/utils/imageProcessor.ts index ea2a0240..d4c80292 100644 --- a/src/utils/imageProcessor.ts +++ b/src/utils/imageProcessor.ts @@ -3,49 +3,92 @@ import sharp from 'sharp'; import path from 'path'; import fs from 'node:fs/promises'; import type { Logger } from 'pino'; -import { sanitizeFilename } from './stringUtils'; /** - * Generates a 64x64 square icon from a source image. - * @param sourceImagePath The full path to the original image file. - * @param iconsDirectory The directory where the icon should be saved. - * @returns A promise that resolves to the filename of the newly created icon. - * @param logger The request-scoped logger instance, as per ADR-004. - * @throws An error if the icon generation fails. + * Processes an uploaded image file by stripping all metadata (like EXIF) + * and optimizing it for web use. + * + * @param sourcePath The path to the temporary uploaded file. + * @param destinationDir The directory where the final image should be saved. + * @param originalFileName The original name of the file, used to determine the output name. + * @param logger A pino logger instance for logging. + * @returns The file name of the newly processed image. */ -export async function generateFlyerIcon( - sourceImagePath: string, - iconsDirectory: string, +export async function processAndSaveImage( + sourcePath: string, + destinationDir: string, + originalFileName: string, logger: Logger, ): Promise { + // Create a unique-ish filename to avoid collisions, but keep the original extension. + const fileExt = path.extname(originalFileName); + const fileBase = path.basename(originalFileName, fileExt); + const outputFileName = `${fileBase}-${Date.now()}${fileExt}`; + const outputPath = path.join(destinationDir, outputFileName); + try { - // 1. Create a new filename, standardizing the extension to .webp for consistency and performance. - // We sanitize the original filename to remove spaces and special characters, ensuring URL safety. - // The sourceImagePath is already sanitized by multer, but we apply it here again for robustness - // in case this function is ever called from a different context. - const sanitizedBaseName = sanitizeFilename(path.basename(sourceImagePath)); - const originalFileName = path.parse(sanitizedBaseName).name; - const iconFileName = `icon-${originalFileName}.webp`; - const iconOutputPath = path.join(iconsDirectory, iconFileName); + // Ensure the destination directory exists. + await fs.mkdir(destinationDir, { recursive: true }); - // Ensure the icons subdirectory exists. - await fs.mkdir(iconsDirectory, { recursive: true }); + logger.debug({ sourcePath, outputPath }, 'Starting image processing: stripping metadata and optimizing.'); - // 2. Use sharp to process the image. - await sharp(sourceImagePath) - // Use `resize` with a `fit` strategy to prevent distortion. - // `sharp.fit.cover` will resize to fill 64x64 and crop any excess, - // ensuring the icon is always a non-distorted square. - .resize(64, 64, { fit: sharp.fit.cover }) - // 3. Convert the output to WebP format. - // The `quality` option is a good balance between size and clarity. - .webp({ quality: 80 }) - .toFile(iconOutputPath); + // Use sharp to process the image. + // .withMetadata({}) strips all EXIF and other metadata. + // .jpeg() and .png() apply format-specific optimizations. + await sharp(sourcePath, { failOn: 'none' }) + .withMetadata({}) // This is the key to stripping metadata + .jpeg({ quality: 85, mozjpeg: true }) // Optimize JPEGs + .png({ compressionLevel: 8, quality: 85 }) // Optimize PNGs + .toFile(outputPath); - logger.info(`Generated 64x64 icon: ${iconFileName}`); - return iconFileName; + logger.info(`Successfully processed image and saved to ${outputPath}`); + return outputFileName; } catch (error) { - logger.error({ error, sourceImagePath }, 'Failed to generate flyer icon:'); - throw new Error('Icon generation failed.'); + logger.error( + { err: error, sourcePath, outputPath }, + 'An error occurred during image processing and saving.', + ); + // Re-throw the error to be handled by the calling service (e.g., the worker). + throw new Error(`Failed to process image ${originalFileName}.`); } } + +/** + * Generates a small WebP icon from a source image. + * + * @param sourcePath The path to the source image (can be the original upload or the processed image). + * @param outputDir The directory to save the icon in (e.g., 'flyer-images/icons'). + * @param logger A pino logger instance for logging. + * @returns The file name of the generated icon. + */ +export async function generateFlyerIcon( + sourcePath: string, + outputDir: string, + logger: Logger, +): Promise { + // Use the source file's name (without extension) to create the icon name. + const iconFileName = `icon-${path.parse(sourcePath).name}.webp`; + const outputPath = path.join(outputDir, iconFileName); + + try { + // Ensure the output directory exists. + await fs.mkdir(outputDir, { recursive: true }); + + logger.debug({ sourcePath, outputPath }, 'Starting icon generation.'); + + await sharp(sourcePath, { failOn: 'none' }) + .resize({ width: 128, height: 128, fit: 'inside' }) + .webp({ quality: 75 }) // Slightly lower quality for icons is acceptable. + .toFile(outputPath); + + logger.info(`Successfully generated icon: ${outputPath}`); + return iconFileName; + } catch (error) { + logger.error( + { err: error, sourcePath, outputPath }, + 'An error occurred during icon generation.', + ); + // Re-throw the error to be handled by the calling service. + throw new Error(`Failed to generate icon for ${sourcePath}.`); + } +} \ No newline at end of file