Compare commits

...

6 Commits

Author SHA1 Message Date
Gitea Actions
275741c79e ci: Bump version to 0.9.27 [skip ci] 2026-01-05 15:32:08 +05:00
3a40249ddb even more and more test fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 22m19s
2026-01-05 02:30:28 -08:00
Gitea Actions
4c70905950 ci: Bump version to 0.9.26 [skip ci] 2026-01-05 14:51:27 +05:00
0b4884ff2a even more and more test fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 26m1s
2026-01-05 01:50:54 -08:00
Gitea Actions
e4acab77c8 ci: Bump version to 0.9.25 [skip ci] 2026-01-05 14:26:57 +05:00
4e20b1b430 even more and more test fixes
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 54s
2026-01-05 01:26:12 -08:00
23 changed files with 766 additions and 549 deletions

View File

@@ -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 }}" \

View File

@@ -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,
},
},
],

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "flyer-crawler",
"version": "0.9.24",
"version": "0.9.27",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flyer-crawler",
"version": "0.9.24",
"version": "0.9.27",
"dependencies": {
"@bull-board/api": "^6.14.2",
"@bull-board/express": "^6.14.2",

View File

@@ -1,7 +1,7 @@
{
"name": "flyer-crawler",
"private": true,
"version": "0.9.24",
"version": "0.9.27",
"type": "module",
"scripts": {
"dev": "concurrently \"npm:start:dev\" \"vite\"",

View File

@@ -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;

View File

@@ -3,7 +3,7 @@ import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
import { FlyerList } from './FlyerList';
import { formatShortDate } from './dateUtils';
import { formatShortDate } from '../../utils/dateUtils';
import type { Flyer, UserProfile } from '../../types';
import { createMockUserProfile } from '../../tests/utils/mockFactories';
import { createMockFlyer } from '../../tests/utils/mockFactories';

View File

@@ -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<FlyerListProps> = ({
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 = '';

View File

@@ -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',
);
});
});
});

View File

@@ -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;
};

View File

@@ -81,6 +81,7 @@ vi.mock('./db/flyer.db', () => ({
vi.mock('../utils/imageProcessor', () => ({
generateFlyerIcon: vi.fn(),
processAndSaveImage: vi.fn(),
}));
vi.mock('./db/admin.db', () => ({
@@ -93,8 +94,8 @@ vi.mock('./db/admin.db', () => ({
import * as dbModule from './db/index.db';
import { flyerQueue } from './queueService.server';
import { createFlyerAndItems } from './db/flyer.db';
import { withTransaction } from './db/index.db';
import { generateFlyerIcon } from '../utils/imageProcessor';
import { withTransaction } from './db/index.db'; // This was a duplicate, fixed.
import { generateFlyerIcon, processAndSaveImage } from '../utils/imageProcessor';
// Define a mock interface that closely resembles the actual Flyer type for testing purposes.
// This helps ensure type safety in mocks without relying on 'any'.
@@ -808,9 +809,11 @@ describe('AI Service (Server)', () => {
expect(
(localAiServiceInstance as any)._parseJsonFromAiResponse(responseText, localLogger),
).toBeNull(); // This was a duplicate, fixed.
// The code now fails earlier because it can't find the closing brace.
// We need to update the assertion to match the actual error log.
expect(localLogger.error).toHaveBeenCalledWith(
expect.objectContaining({ jsonSlice: '{ "key": "value"' }),
'[_parseJsonFromAiResponse] Failed to parse JSON slice.',
{ responseText }, // The log includes the full response text.
"[_parseJsonFromAiResponse] Could not find ending '}' or ']' in response.",
);
});
});
@@ -1052,6 +1055,7 @@ describe('AI Service (Server)', () => {
beforeEach(() => {
// Default success mocks. Use createMockFlyer for a more complete mock.
vi.mocked(dbModule.flyerRepo.findFlyerByChecksum).mockResolvedValue(undefined);
vi.mocked(processAndSaveImage).mockResolvedValue('processed.jpg');
vi.mocked(generateFlyerIcon).mockResolvedValue('icon.jpg');
vi.mocked(createFlyerAndItems).mockResolvedValue({
flyer: {

View File

@@ -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<T>(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,

View File

@@ -24,6 +24,19 @@ vi.mock('../services/logger.server', () => ({
// Mock the date utility to control the output for the weekly analytics job
vi.mock('../utils/dateUtils', () => ({
getSimpleWeekAndYear: vi.fn(() => ({ year: 2024, week: 42 })),
getCurrentDateISOString: vi.fn(() => '2024-10-18'),
}));
vi.mock('../services/queueService.server', () => ({
analyticsQueue: {
add: vi.fn(),
},
weeklyAnalyticsQueue: {
add: vi.fn(),
},
emailQueue: {
add: vi.fn(),
},
}));
import { BackgroundJobService, startBackgroundJobs } from './backgroundJobService';
@@ -32,6 +45,7 @@ import type { PersonalizationRepository } from './db/personalization.db';
import type { NotificationRepository } from './db/notification.db';
import { createMockWatchedItemDeal } from '../tests/utils/mockFactories';
import { logger as globalMockLogger } from '../services/logger.server'; // Import the mocked logger
import { analyticsQueue, weeklyAnalyticsQueue } from '../services/queueService.server';
describe('Background Job Service', () => {
// Create mock dependencies that will be injected into the service
@@ -118,6 +132,35 @@ describe('Background Job Service', () => {
mockServiceLogger,
);
describe('Manual Triggers', () => {
it('triggerAnalyticsReport should add a daily report job to the queue', async () => {
vi.mocked(analyticsQueue.add).mockResolvedValue({ id: 'manual-job-1' } as any);
const jobId = await service.triggerAnalyticsReport();
expect(jobId).toContain('manual-report-');
expect(analyticsQueue.add).toHaveBeenCalledWith(
'generate-daily-report',
{ reportDate: '2024-10-18' },
{ jobId: expect.stringContaining('manual-report-') },
);
});
it('triggerWeeklyAnalyticsReport should add a weekly report job to the queue', async () => {
vi.mocked(weeklyAnalyticsQueue.add).mockResolvedValue({ id: 'manual-weekly-job-1' } as any);
const jobId = await service.triggerWeeklyAnalyticsReport();
expect(jobId).toContain('manual-weekly-report-');
expect(weeklyAnalyticsQueue.add).toHaveBeenCalledWith(
'generate-weekly-report',
{
reportYear: 2024, // From mocked dateUtils
reportWeek: 42, // From mocked dateUtils
},
{ jobId: expect.stringContaining('manual-weekly-report-') },
);
});
});
it('should do nothing if no deals are found for any user', async () => {
mockPersonalizationRepo.getBestSalePricesForAllUsers.mockResolvedValue([]);
await service.runDailyDealCheck();
@@ -153,24 +196,27 @@ describe('Background Job Service', () => {
// Check that in-app notifications were created for both users
expect(mockNotificationRepo.createBulkNotifications).toHaveBeenCalledTimes(1);
const notificationPayload = mockNotificationRepo.createBulkNotifications.mock.calls[0][0];
expect(notificationPayload).toHaveLength(2);
// Use expect.arrayContaining to be order-agnostic.
expect(notificationPayload).toEqual(
expect.arrayContaining([
{
user_id: 'user-1',
content: 'You have 1 new deal(s) on your watched items!',
link_url: '/dashboard/deals',
updated_at: expect.any(String),
},
{
user_id: 'user-2',
content: 'You have 2 new deal(s) on your watched items!',
link_url: '/dashboard/deals',
updated_at: expect.any(String),
},
]),
// Sort by user_id to ensure a consistent order for a direct `toEqual` comparison.
// This provides a clearer diff on failure than `expect.arrayContaining`.
const sortedPayload = [...notificationPayload].sort((a, b) =>
a.user_id.localeCompare(b.user_id),
);
expect(sortedPayload).toEqual([
{
user_id: 'user-1',
content: 'You have 1 new deal(s) on your watched items!',
link_url: '/dashboard/deals',
updated_at: expect.any(String),
},
{
user_id: 'user-2',
content: 'You have 2 new deal(s) on your watched items!',
link_url: '/dashboard/deals',
updated_at: expect.any(String),
},
]);
});
it('should handle and log errors for individual users without stopping the process', async () => {
@@ -252,7 +298,7 @@ describe('Background Job Service', () => {
vi.mocked(mockWeeklyAnalyticsQueue.add).mockClear();
});
it('should schedule three cron jobs with the correct schedules', () => {
it('should schedule four cron jobs with the correct schedules', () => {
startBackgroundJobs(
mockBackgroundJobService,
mockAnalyticsQueue,

View File

@@ -2,13 +2,19 @@
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';
import { analyticsQueue, weeklyAnalyticsQueue } from './queueService.server';
type UserDealGroup = {
userProfile: { user_id: string; email: string; full_name: string | null };
deals: WatchedItemDeal[];
};
interface EmailJobData {
to: string;
subject: string;
@@ -25,7 +31,7 @@ export class BackgroundJobService {
) {}
public async triggerAnalyticsReport(): Promise<string> {
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 +63,16 @@ export class BackgroundJobService {
const dealsListHtml = deals
.map(
(deal) =>
`<li><strong>${deal.item_name}</strong> is on sale for <strong>$${(deal.best_price_in_cents / 100).toFixed(2)}</strong> at ${deal.store_name}!</li>`,
`<li><strong>${deal.item_name}</strong> is on sale for <strong>${formatCurrency(
deal.best_price_in_cents,
)}</strong> at ${deal.store_name}!</li>`,
)
.join('');
const html = `<p>Hi ${recipientName},</p><p>We found some great deals on items you're watching:</p><ul>${dealsListHtml}</ul>`;
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,15 +90,41 @@ export class BackgroundJobService {
private _prepareInAppNotification(
userId: string,
dealCount: number,
): Omit<Notification, 'notification_id' | 'is_read' | 'created_at'> {
): Omit<Notification, 'notification_id' | 'is_read' | 'created_at' | 'updated_at'> {
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(),
};
}
private async _processDealsForUser({
userProfile,
deals,
}: UserDealGroup): Promise<Omit<Notification, 'notification_id' | 'is_read' | 'created_at' | 'updated_at'> | null> {
try {
this.logger.info(
`[BackgroundJob] Found ${deals.length} deals for user ${userProfile.user_id}.`,
);
// Prepare in-app and email notifications.
const notification = this._prepareInAppNotification(userProfile.user_id, deals.length);
const { jobData, jobId } = this._prepareDealEmail(userProfile, deals);
// Enqueue an email notification job.
await this.emailQueue.add('send-deal-notification', jobData, { jobId });
// Return the notification to be collected for bulk insertion.
return notification;
} catch (userError) {
this.logger.error(
{ err: userError },
`[BackgroundJob] Failed to process deals for user ${userProfile.user_id}`,
);
return null; // Return null on error for this user.
}
}
/**
* Checks for new deals on watched items for all users and sends notifications.
* This function is designed to be run periodically (e.g., daily).
@@ -110,70 +144,47 @@ export class BackgroundJobService {
this.logger.info(`[BackgroundJob] Found ${allDeals.length} total deals across all users.`);
// 2. Group deals by user in memory.
const dealsByUser = allDeals.reduce<
Record<
string,
{
userProfile: { user_id: string; email: string; full_name: string | null };
deals: WatchedItemDeal[];
}
>
>((acc, deal) => {
if (!acc[deal.user_id]) {
acc[deal.user_id] = {
const dealsByUser = new Map<string, UserDealGroup>();
for (const deal of allDeals) {
let userGroup = dealsByUser.get(deal.user_id);
if (!userGroup) {
userGroup = {
userProfile: { user_id: deal.user_id, email: deal.email, full_name: deal.full_name },
deals: [],
};
dealsByUser.set(deal.user_id, userGroup);
}
acc[deal.user_id].deals.push(deal);
return acc;
}, {});
const allNotifications: Omit<Notification, 'notification_id' | 'is_read' | 'created_at'>[] =
[];
userGroup.deals.push(deal);
}
// 3. Process each user's deals in parallel.
const userProcessingPromises = Object.values(dealsByUser).map(
async ({ userProfile, deals }) => {
try {
this.logger.info(
`[BackgroundJob] Found ${deals.length} deals for user ${userProfile.user_id}.`,
);
// 4. Prepare in-app and email notifications.
const notification = this._prepareInAppNotification(userProfile.user_id, deals.length);
const { jobData, jobId } = this._prepareDealEmail(userProfile, deals);
// 5. Enqueue an email notification job.
await this.emailQueue.add('send-deal-notification', jobData, { jobId });
// Return the notification to be collected for bulk insertion.
return notification;
} catch (userError) {
this.logger.error(
{ err: userError },
`[BackgroundJob] Failed to process deals for user ${userProfile.user_id}`,
);
return null; // Return null on error for this user.
}
},
const userProcessingPromises = Array.from(dealsByUser.values()).map((userGroup) =>
this._processDealsForUser(userGroup),
);
// Wait for all user processing to complete.
const results = await Promise.allSettled(userProcessingPromises);
// 6. Collect all successfully created notifications.
results.forEach((result) => {
if (result.status === 'fulfilled' && result.value) {
allNotifications.push(result.value);
}
});
const successfulNotifications = results
.filter(
(
result,
): result is PromiseFulfilledResult<
Omit<Notification, 'notification_id' | 'is_read' | 'created_at' | 'updated_at'>
> => result.status === 'fulfilled' && !!result.value,
)
.map((result) => result.value);
// 7. Bulk insert all in-app notifications in a single query.
if (allNotifications.length > 0) {
await this.notificationRepo.createBulkNotifications(allNotifications, this.logger);
if (successfulNotifications.length > 0) {
const notificationsForDb = successfulNotifications.map((n) => ({
...n,
updated_at: new Date().toISOString(),
}));
await this.notificationRepo.createBulkNotifications(notificationsForDb, this.logger);
this.logger.info(
`[BackgroundJob] Successfully created ${allNotifications.length} in-app notifications.`,
`[BackgroundJob] Successfully created ${successfulNotifications.length} in-app notifications.`,
);
}
@@ -244,7 +255,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',

View File

@@ -25,6 +25,12 @@ vi.mock('node:fs/promises', async (importOriginal) => {
};
});
// Mock image processor functions
vi.mock('../utils/imageProcessor', () => ({
processAndSaveImage: vi.fn(),
generateFlyerIcon: vi.fn(),
}));
// Import service and dependencies (FlyerJobData already imported from types above)
import { FlyerProcessingService } from './flyerProcessingService.server';
import * as db from './db/index.db';
@@ -42,6 +48,7 @@ import { NotFoundError } from './db/errors.db';
import { FlyerFileHandler } from './flyerFileHandler.server';
import { FlyerAiProcessor } from './flyerAiProcessor.server';
import type { IFileSystem, ICommandExecutor } from './flyerFileHandler.server';
import { processAndSaveImage, generateFlyerIcon } from '../utils/imageProcessor';
import type { AIService } from './aiService.server';
// Mock dependencies
@@ -172,6 +179,10 @@ describe('FlyerProcessingService', () => {
// FIX: Provide a default mock for getAllMasterItems to prevent a TypeError on `.length`.
vi.mocked(mockedDb.personalizationRepo.getAllMasterItems).mockResolvedValue([]);
});
beforeEach(() => {
vi.mocked(processAndSaveImage).mockResolvedValue('processed-flyer.jpg');
vi.mocked(generateFlyerIcon).mockResolvedValue('icon-flyer.webp');
});
const createMockJob = (data: Partial<FlyerJobData>): Job<FlyerJobData> => {
return {
@@ -200,22 +211,49 @@ describe('FlyerProcessingService', () => {
};
describe('processJob (Orchestrator)', () => {
it('should process an image file successfully and enqueue a cleanup job', async () => {
const job = createMockJob({ filePath: '/tmp/flyer.jpg', originalFileName: 'flyer.jpg' });
it('should process an image file successfully, using processed image URLs, and enqueue a cleanup job', async () => {
const job = createMockJob({ filePath: '/tmp/flyer.jpg', originalFileName: 'flyer.jpg', baseUrl: 'http://test.com' });
// Simulate the file handler processing the image and returning the path to the new, cleaned file.
mockFileHandler.prepareImageInputs.mockResolvedValue({
imagePaths: [{ path: '/tmp/flyer-processed.jpeg', mimetype: 'image/jpeg' }],
createdImagePaths: ['/tmp/flyer-processed.jpeg'],
});
const result = await service.processJob(job);
expect(result).toEqual({ flyerId: 1 });
expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith(job.data.filePath, job, expect.any(Object));
expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1);
// Verify that the transaction function was called.
expect(mockedDb.withTransaction).toHaveBeenCalledTimes(1);
// Verify that the functions inside the transaction were called.
expect(createFlyerAndItems).toHaveBeenCalledTimes(1);
// Assert that the image processing functions were called correctly
// The first image path from prepareImageInputs is the *processed* one.
expect(processAndSaveImage).toHaveBeenCalledWith('/tmp/flyer-processed.jpeg', '/tmp', 'flyer.jpg', expect.any(Object));
// The icon is generated from the *newly processed* image from processAndSaveImage
expect(generateFlyerIcon).toHaveBeenCalledWith('/tmp/processed-flyer.jpg', '/tmp/icons', expect.any(Object));
// Assert that createFlyerAndItems was called with the CORRECT, overwritten URLs
const createFlyerAndItemsCall = vi.mocked(createFlyerAndItems).mock.calls[0];
const flyerDataArg = createFlyerAndItemsCall[0]; // The flyerData object
expect(flyerDataArg.image_url).toBe('http://test.com/flyer-images/processed-flyer.jpg');
expect(flyerDataArg.icon_url).toBe('http://test.com/flyer-images/icons/icon-flyer.webp');
expect(mocks.mockAdminLogActivity).toHaveBeenCalledTimes(1);
// Assert that the cleanup job includes all original and generated files
expect(mockCleanupQueue.add).toHaveBeenCalledWith(
'cleanup-flyer-files',
{ flyerId: 1, paths: ['/tmp/flyer.jpg'] },
{
flyerId: 1,
paths: [
'/tmp/flyer.jpg', // original job path
'/tmp/flyer-processed.jpeg', // from prepareImageInputs
'/tmp/processed-flyer.jpg', // from processAndSaveImage
'/tmp/icons/icon-flyer.webp', // from generateFlyerIcon
],
},
expect.any(Object),
);
});
@@ -226,7 +264,10 @@ describe('FlyerProcessingService', () => {
// Mock the file handler to return multiple created paths
const createdPaths = ['/tmp/flyer-1.jpg', '/tmp/flyer-2.jpg'];
mockFileHandler.prepareImageInputs.mockResolvedValue({
imagePaths: createdPaths.map(p => ({ path: p, mimetype: 'image/jpeg' })),
imagePaths: [
{ path: '/tmp/flyer-1.jpg', mimetype: 'image/jpeg' },
{ path: '/tmp/flyer-2.jpg', mimetype: 'image/jpeg' },
],
createdImagePaths: createdPaths,
});
@@ -237,15 +278,17 @@ describe('FlyerProcessingService', () => {
expect(mockFileHandler.prepareImageInputs).toHaveBeenCalledWith('/tmp/flyer.pdf', job, expect.any(Object));
expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1);
expect(createFlyerAndItems).toHaveBeenCalledTimes(1);
// Verify cleanup job includes original PDF and both generated images
// Verify cleanup job includes original PDF and all generated/processed images
expect(mockCleanupQueue.add).toHaveBeenCalledWith(
'cleanup-flyer-files',
{
flyerId: 1,
paths: [
'/tmp/flyer.pdf',
'/tmp/flyer-1.jpg',
'/tmp/flyer-2.jpg',
'/tmp/flyer.pdf', // original job path
'/tmp/flyer-1.jpg', // from prepareImageInputs
'/tmp/flyer-2.jpg', // from prepareImageInputs
'/tmp/processed-flyer.jpg', // from processAndSaveImage
'/tmp/icons/icon-flyer.webp', // from generateFlyerIcon
],
},
expect.any(Object),
@@ -387,7 +430,15 @@ describe('FlyerProcessingService', () => {
expect(mockAiProcessor.extractAndValidateData).toHaveBeenCalledTimes(1);
expect(mockCleanupQueue.add).toHaveBeenCalledWith(
'cleanup-flyer-files',
{ flyerId: 1, paths: ['/tmp/flyer.gif', convertedPath] },
{
flyerId: 1,
paths: [
'/tmp/flyer.gif', // original job path
convertedPath, // from prepareImageInputs
'/tmp/processed-flyer.jpg', // from processAndSaveImage
'/tmp/icons/icon-flyer.webp', // from generateFlyerIcon
],
},
expect.any(Object),
);
});
@@ -633,5 +684,30 @@ describe('FlyerProcessingService', () => {
'Job received no paths and could not derive any from the database. Skipping.',
);
});
it('should derive paths from DB and delete files if job paths are empty', async () => {
const job = createMockCleanupJob({ flyerId: 1, paths: [] }); // Empty paths
const mockFlyer = createMockFlyer({
image_url: 'http://localhost:3000/flyer-images/flyer-abc.jpg',
icon_url: 'http://localhost:3000/flyer-images/icons/icon-flyer-abc.webp',
});
// Mock DB call to return a flyer
vi.mocked(mockedDb.flyerRepo.getFlyerById).mockResolvedValue(mockFlyer);
mocks.unlink.mockResolvedValue(undefined);
// Mock process.env.STORAGE_PATH
vi.stubEnv('STORAGE_PATH', '/var/www/app/flyer-images');
const result = await service.processCleanupJob(job);
expect(result).toEqual({ status: 'success', deletedCount: 2 });
expect(mocks.unlink).toHaveBeenCalledTimes(2);
expect(mocks.unlink).toHaveBeenCalledWith('/var/www/app/flyer-images/flyer-abc.jpg');
expect(mocks.unlink).toHaveBeenCalledWith('/var/www/app/flyer-images/icons/icon-flyer-abc.webp');
const { logger } = await import('./logger.server');
expect(logger.warn).toHaveBeenCalledWith(
'Cleanup job for flyer 1 received no paths. Attempting to derive paths from DB.',
);
});
});
});

View File

@@ -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 });

View File

@@ -377,7 +377,7 @@ it(
async () => {
// Arrange: Mock the AI service to throw an error for this specific test.
const aiError = new Error('AI model failed to extract data.');
mockExtractCoreData.mockRejectedValueOnce(aiError);
mockExtractCoreData.mockRejectedValue(aiError);
// Arrange: Prepare a unique flyer file for upload.
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
@@ -426,7 +426,7 @@ it(
async () => {
// Arrange: Mock the database creation function to throw an error for this specific test.
const dbError = new Error('DB transaction failed');
vi.mocked(createFlyerAndItems).mockRejectedValueOnce(dbError);
vi.mocked(createFlyerAndItems).mockRejectedValue(dbError);
// Arrange: Prepare a unique flyer file for upload.
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
@@ -475,7 +475,7 @@ it(
async () => {
// Arrange: Mock the AI service to throw an error, causing the job to fail.
const aiError = new Error('Simulated AI failure for cleanup test.');
mockExtractCoreData.mockRejectedValueOnce(aiError);
mockExtractCoreData.mockRejectedValue(aiError);
// Arrange: Prepare a unique flyer file for upload.
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');

View File

@@ -333,7 +333,7 @@ describe('User API Routes Integration Tests', () => {
const updatedProfile = response.body;
expect(updatedProfile.avatar_url).toBeDefined();
expect(updatedProfile.avatar_url).not.toBeNull();
expect(updatedProfile.avatar_url).toContain('/uploads/avatars/avatar-');
expect(updatedProfile.avatar_url).toContain('/uploads/avatars/test-avatar');
// Assert (Verification): Fetch the profile again to ensure the change was persisted
const verifyResponse = await request
@@ -375,6 +375,6 @@ describe('User API Routes Integration Tests', () => {
// Assert: Check for a 400 Bad Request response from the multer error handler.
expect(response.status).toBe(400);
expect(response.body.message).toBe('File too large');
expect(response.body.message).toBe('File upload error: File too large');
});
});

View File

@@ -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');
});
});
});

View File

@@ -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 };

View File

@@ -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');
});
});

14
src/utils/formatUtils.ts Normal file
View File

@@ -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);
};

View File

@@ -71,7 +71,7 @@ describe('generateFlyerIcon', () => {
expect(mocks.mkdir).toHaveBeenCalledWith(iconsDirectory, { recursive: true });
// Check that sharp was called with the correct source
expect(mocks.sharp).toHaveBeenCalledWith(sourceImagePath);
expect(mocks.sharp).toHaveBeenCalledWith(sourceImagePath, { failOn: 'none' });
// Check the processing chain
expect(mocks.resize).toHaveBeenCalledWith(64, 64, { fit: 'cover' });
@@ -87,8 +87,8 @@ describe('generateFlyerIcon', () => {
mocks.toFile.mockRejectedValue(sharpError);
await expect(
generateFlyerIcon('/path/to/bad-image.jpg', '/path/to/icons', logger),
).rejects.toThrow('Icon generation failed.');
generateFlyerIcon('/path/to/bad-image.jpg', '/path/to/icons', logger), // This was a duplicate, fixed.
).rejects.toThrow('Failed to generate icon for /path/to/bad-image.jpg.');
expect(logger.error).toHaveBeenCalledWith(expect.any(Object), 'Failed to generate flyer icon:');
});
});

View File

@@ -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<string> {
// 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<string> {
// 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}.`);
}
}