Compare commits

...

2 Commits

Author SHA1 Message Date
3912139273 adr-028 and int tests
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 16m24s
2026-01-09 12:47:41 -08:00
b5f7f5e4d1 adr-0028 and int test fixes 2026-01-09 12:35:55 -08:00
22 changed files with 996 additions and 379 deletions

View File

@@ -2,7 +2,7 @@
**Date**: 2026-01-09
**Status**: Proposed
**Status**: Implemented
## Context
@@ -99,16 +99,44 @@ interface ApiErrorResponse {
### What's Implemented
- ❌ Not yet implemented
- ✅ Created `src/utils/apiResponse.ts` with helper functions (`sendSuccess`, `sendPaginated`, `sendError`, `sendNoContent`, `sendMessage`, `calculatePagination`)
- ✅ Created `src/types/api.ts` with response type definitions (`ApiSuccessResponse`, `ApiErrorResponse`, `PaginationMeta`, `ErrorCode`)
- ✅ Updated `src/middleware/errorHandler.ts` to use standard error format
- ✅ Migrated all route files to use standardized responses:
- `health.routes.ts`
- `flyer.routes.ts`
- `deals.routes.ts`
- `budget.routes.ts`
- `personalization.routes.ts`
- `price.routes.ts`
- `reactions.routes.ts`
- `stats.routes.ts`
- `system.routes.ts`
- `gamification.routes.ts`
- `recipe.routes.ts`
- `auth.routes.ts`
- `user.routes.ts`
- `admin.routes.ts`
- `ai.routes.ts`
### What Needs To Be Done
### Error Codes
1. Create `src/utils/apiResponse.ts` with helper functions
2. Create `src/types/api.ts` with response type definitions
3. Update `errorHandler.ts` to use standard error format
4. Create migration guide for existing endpoints
5. Update 2-3 routes as examples
6. Document pattern in this ADR
The following error codes are defined in `src/types/api.ts`:
| Code | HTTP Status | Description |
| ------------------------ | ----------- | ----------------------------------- |
| `VALIDATION_ERROR` | 400 | Request validation failed |
| `BAD_REQUEST` | 400 | Malformed request |
| `UNAUTHORIZED` | 401 | Authentication required |
| `FORBIDDEN` | 403 | Insufficient permissions |
| `NOT_FOUND` | 404 | Resource not found |
| `CONFLICT` | 409 | Resource conflict (e.g., duplicate) |
| `RATE_LIMITED` | 429 | Too many requests |
| `PAYLOAD_TOO_LARGE` | 413 | Request body too large |
| `INTERNAL_ERROR` | 500 | Server error |
| `NOT_IMPLEMENTED` | 501 | Feature not yet implemented |
| `SERVICE_UNAVAILABLE` | 503 | Service temporarily unavailable |
| `EXTERNAL_SERVICE_ERROR` | 502 | External service failure |
## Example Usage

View File

@@ -1,4 +1,11 @@
// src/middleware/errorHandler.ts
// ============================================================================
// CENTRALIZED ERROR HANDLING MIDDLEWARE
// ============================================================================
// This middleware standardizes all error responses per ADR-028.
// It should be the LAST `app.use()` call to catch all errors.
// ============================================================================
import { Request, Response, NextFunction } from 'express';
import crypto from 'crypto';
import { ZodError } from 'zod';
@@ -9,12 +16,43 @@ import {
ValidationError,
} from '../services/db/errors.db';
import { logger } from '../services/logger.server';
import { ErrorCode, ApiErrorResponse } from '../types/api';
/**
* Helper to send standardized error responses.
*/
function sendErrorResponse(
res: Response,
statusCode: number,
code: string,
message: string,
details?: unknown,
meta?: { requestId?: string; timestamp?: string },
): Response<ApiErrorResponse> {
const response: ApiErrorResponse = {
success: false,
error: {
code,
message,
},
};
if (details !== undefined) {
response.error.details = details;
}
if (meta) {
response.meta = meta;
}
return res.status(statusCode).json(response);
}
/**
* A centralized error handling middleware for the Express application.
* This middleware should be the LAST `app.use()` call to catch all errors from previous routes and middleware.
*
* It standardizes error responses and ensures consistent logging.
* It standardizes error responses per ADR-028 and ensures consistent logging per ADR-004.
*/
export const errorHandler = (err: Error, req: Request, res: Response, next: NextFunction) => {
// If headers have already been sent, delegate to the default Express error handler.
@@ -29,16 +67,19 @@ export const errorHandler = (err: Error, req: Request, res: Response, next: Next
if (err instanceof ZodError) {
const statusCode = 400;
const message = 'The request data is invalid.';
const errors = err.issues.map((e) => ({ path: e.path, message: e.message }));
log.warn({ err, validationErrors: errors, statusCode }, `Client Error on ${req.method} ${req.path}: ${message}`);
return res.status(statusCode).json({ message, errors });
const details = err.issues.map((e) => ({ path: e.path, message: e.message }));
log.warn(
{ err, validationErrors: details, statusCode },
`Client Error on ${req.method} ${req.path}: ${message}`,
);
return sendErrorResponse(res, statusCode, ErrorCode.VALIDATION_ERROR, message, details);
}
// --- Handle Custom Operational Errors ---
if (err instanceof NotFoundError) {
const statusCode = 404;
log.warn({ err, statusCode }, `Client Error on ${req.method} ${req.path}: ${err.message}`);
return res.status(statusCode).json({ message: err.message });
return sendErrorResponse(res, statusCode, ErrorCode.NOT_FOUND, err.message);
}
if (err instanceof ValidationError) {
@@ -47,30 +88,66 @@ export const errorHandler = (err: Error, req: Request, res: Response, next: Next
{ err, validationErrors: err.validationErrors, statusCode },
`Client Error on ${req.method} ${req.path}: ${err.message}`,
);
return res.status(statusCode).json({ message: err.message, errors: err.validationErrors });
return sendErrorResponse(
res,
statusCode,
ErrorCode.VALIDATION_ERROR,
err.message,
err.validationErrors,
);
}
if (err instanceof UniqueConstraintError) {
const statusCode = 409;
log.warn({ err, statusCode }, `Client Error on ${req.method} ${req.path}: ${err.message}`);
return res.status(statusCode).json({ message: err.message }); // Use 409 Conflict for unique constraints
return sendErrorResponse(res, statusCode, ErrorCode.CONFLICT, err.message);
}
if (err instanceof ForeignKeyConstraintError) {
const statusCode = 400;
log.warn({ err, statusCode }, `Client Error on ${req.method} ${req.path}: ${err.message}`);
return res.status(statusCode).json({ message: err.message });
return sendErrorResponse(res, statusCode, ErrorCode.BAD_REQUEST, err.message);
}
// --- Handle Generic Client Errors (e.g., from express-jwt, or manual status setting) ---
let status = (err as any).status || (err as any).statusCode;
const errWithStatus = err as Error & { status?: number; statusCode?: number };
let status = errWithStatus.status || errWithStatus.statusCode;
// Default UnauthorizedError to 401 if no status is present, a common case for express-jwt.
if (err.name === 'UnauthorizedError' && !status) {
status = 401;
}
if (status && status >= 400 && status < 500) {
log.warn({ err, statusCode: status }, `Client Error on ${req.method} ${req.path}: ${err.message}`);
return res.status(status).json({ message: err.message });
log.warn(
{ err, statusCode: status },
`Client Error on ${req.method} ${req.path}: ${err.message}`,
);
// Map status codes to error codes
let errorCode: string;
switch (status) {
case 400:
errorCode = ErrorCode.BAD_REQUEST;
break;
case 401:
errorCode = ErrorCode.UNAUTHORIZED;
break;
case 403:
errorCode = ErrorCode.FORBIDDEN;
break;
case 404:
errorCode = ErrorCode.NOT_FOUND;
break;
case 409:
errorCode = ErrorCode.CONFLICT;
break;
case 429:
errorCode = ErrorCode.RATE_LIMITED;
break;
default:
errorCode = ErrorCode.BAD_REQUEST;
}
return sendErrorResponse(res, status, errorCode, err.message);
}
// --- Handle All Other (500-level) Errors ---
@@ -91,11 +168,23 @@ export const errorHandler = (err: Error, req: Request, res: Response, next: Next
// In production, send a generic message to avoid leaking implementation details.
if (process.env.NODE_ENV === 'production') {
return res.status(500).json({
message: `An unexpected server error occurred. Please reference error ID: ${errorId}`,
});
return sendErrorResponse(
res,
500,
ErrorCode.INTERNAL_ERROR,
`An unexpected server error occurred. Please reference error ID: ${errorId}`,
undefined,
{ requestId: errorId },
);
}
// In non-production environments (dev, test, etc.), send more details for easier debugging.
return res.status(500).json({ message: err.message, stack: err.stack, errorId });
return sendErrorResponse(
res,
500,
ErrorCode.INTERNAL_ERROR,
err.message,
{ stack: err.stack },
{ requestId: errorId },
);
};

View File

@@ -2,7 +2,6 @@
import { Router, NextFunction, Request, Response } from 'express';
import passport from './passport.routes';
import { isAdmin } from './passport.routes'; // Correctly imported
import multer from 'multer';
import { z } from 'zod';
import * as db from '../services/db/index.db';
@@ -10,11 +9,8 @@ import type { UserProfile } from '../types';
import { geocodingService } from '../services/geocodingService.server';
import { cacheService } from '../services/cacheService.server';
import { requireFileUpload } from '../middleware/fileUpload.middleware'; // This was a duplicate, fixed.
import {
createUploadMiddleware,
handleMulterError,
} from '../middleware/multer.middleware';
import { NotFoundError, ValidationError } from '../services/db/errors.db';
import { createUploadMiddleware, handleMulterError } from '../middleware/multer.middleware';
import { ValidationError } from '../services/db/errors.db';
import { validateRequest } from '../middleware/validation.middleware';
// --- Bull Board (Job Queue UI) Imports ---
@@ -22,15 +18,14 @@ import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { ExpressAdapter } from '@bull-board/express';
import { backgroundJobService } from '../services/backgroundJobService';
import { flyerQueue, emailQueue, analyticsQueue, cleanupQueue, weeklyAnalyticsQueue } from '../services/queueService.server';
import { getSimpleWeekAndYear } from '../utils/dateUtils';
import {
requiredString,
numericIdParam,
uuidParamSchema,
optionalNumeric,
optionalString,
} from '../utils/zodUtils';
flyerQueue,
emailQueue,
analyticsQueue,
cleanupQueue,
weeklyAnalyticsQueue,
} from '../services/queueService.server';
import { numericIdParam, uuidParamSchema, optionalNumeric } from '../utils/zodUtils';
// Removed: import { logger } from '../services/logger.server';
// All route handlers now use req.log (request-scoped logger) as per ADR-004
import { monitoringService } from '../services/monitoringService.server';
@@ -38,6 +33,7 @@ import { userService } from '../services/userService';
import { cleanupUploadedFile } from '../utils/fileUtils';
import { brandService } from '../services/brandService';
import { adminTriggerLimiter, adminUploadLimiter } from '../config/rateLimiters';
import { sendSuccess, sendNoContent } from '../utils/apiResponse';
const updateCorrectionSchema = numericIdParam('id').extend({
body: z.object({
@@ -126,7 +122,7 @@ router.use(passport.authenticate('jwt', { session: false }), isAdmin);
router.get('/corrections', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
try {
const corrections = await db.adminRepo.getSuggestedCorrections(req.log);
res.json(corrections);
sendSuccess(res, corrections);
} catch (error) {
req.log.error({ error }, 'Error fetching suggested corrections');
next(error);
@@ -137,8 +133,11 @@ router.get('/review/flyers', validateRequest(emptySchema), async (req, res, next
try {
req.log.debug('Fetching flyers for review via adminRepo');
const flyers = await db.adminRepo.getFlyersForReview(req.log);
req.log.info({ count: Array.isArray(flyers) ? flyers.length : 'unknown' }, 'Successfully fetched flyers for review');
res.json(flyers);
req.log.info(
{ count: Array.isArray(flyers) ? flyers.length : 'unknown' },
'Successfully fetched flyers for review',
);
sendSuccess(res, flyers);
} catch (error) {
req.log.error({ error }, 'Error fetching flyers for review');
next(error);
@@ -148,7 +147,7 @@ router.get('/review/flyers', validateRequest(emptySchema), async (req, res, next
router.get('/brands', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
try {
const brands = await db.flyerRepo.getAllBrands(req.log);
res.json(brands);
sendSuccess(res, brands);
} catch (error) {
req.log.error({ error }, 'Error fetching brands');
next(error);
@@ -158,7 +157,7 @@ router.get('/brands', validateRequest(emptySchema), async (req, res, next: NextF
router.get('/stats', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
try {
const stats = await db.adminRepo.getApplicationStats(req.log);
res.json(stats);
sendSuccess(res, stats);
} catch (error) {
req.log.error({ error }, 'Error fetching application stats');
next(error);
@@ -168,7 +167,7 @@ router.get('/stats', validateRequest(emptySchema), async (req, res, next: NextFu
router.get('/stats/daily', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
try {
const dailyStats = await db.adminRepo.getDailyStatsForLast30Days(req.log);
res.json(dailyStats);
sendSuccess(res, dailyStats);
} catch (error) {
req.log.error({ error }, 'Error fetching daily stats');
next(error);
@@ -183,7 +182,7 @@ router.post(
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParam>>;
try {
await db.adminRepo.approveCorrection(params.id, req.log); // params.id is now safely typed as number
res.status(200).json({ message: 'Correction approved successfully.' });
sendSuccess(res, { message: 'Correction approved successfully.' });
} catch (error) {
req.log.error({ error }, 'Error approving correction');
next(error);
@@ -199,7 +198,7 @@ router.post(
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParam>>;
try {
await db.adminRepo.rejectCorrection(params.id, req.log); // params.id is now safely typed as number
res.status(200).json({ message: 'Correction rejected successfully.' });
sendSuccess(res, { message: 'Correction rejected successfully.' });
} catch (error) {
req.log.error({ error }, 'Error rejecting correction');
next(error);
@@ -219,7 +218,7 @@ router.put(
body.suggested_value,
req.log,
);
res.status(200).json(updatedCorrection);
sendSuccess(res, updatedCorrection);
} catch (error) {
req.log.error({ error }, 'Error updating suggested correction');
next(error);
@@ -235,7 +234,7 @@ router.put(
const { params, body } = req as unknown as z.infer<typeof updateRecipeStatusSchema>;
try {
const updatedRecipe = await db.adminRepo.updateRecipeStatus(params.id, body.status, req.log); // This is still a standalone function in admin.db.ts
res.status(200).json(updatedRecipe);
sendSuccess(res, updatedRecipe);
} catch (error) {
req.log.error({ error }, 'Error updating recipe status');
next(error); // Pass all errors to the central error handler
@@ -260,8 +259,11 @@ router.post(
const logoUrl = await brandService.updateBrandLogo(params.id, req.file, req.log);
req.log.info({ brandId: params.id, logoUrl }, `Brand logo updated for brand ID: ${params.id}`);
res.status(200).json({ message: 'Brand logo updated successfully.', logoUrl });
req.log.info(
{ brandId: params.id, logoUrl },
`Brand logo updated for brand ID: ${params.id}`,
);
sendSuccess(res, { message: 'Brand logo updated successfully.', logoUrl });
} catch (error) {
// If an error occurs after the file has been uploaded (e.g., DB error),
// we must clean up the orphaned file from the disk.
@@ -272,15 +274,19 @@ router.post(
},
);
router.get('/unmatched-items', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
router.get(
'/unmatched-items',
validateRequest(emptySchema),
async (req, res, next: NextFunction) => {
try {
const items = await db.adminRepo.getUnmatchedFlyerItems(req.log);
res.json(items);
sendSuccess(res, items);
} catch (error) {
req.log.error({ error }, 'Error fetching unmatched items');
next(error);
}
});
},
);
/**
* DELETE /api/admin/recipes/:recipeId - Admin endpoint to delete any recipe.
@@ -295,7 +301,7 @@ router.delete(
try {
// The isAdmin flag bypasses the ownership check in the repository method.
await db.recipeRepo.deleteRecipe(params.recipeId, userProfile.user.user_id, true, req.log);
res.status(204).send();
sendNoContent(res);
} catch (error: unknown) {
req.log.error({ error }, 'Error deleting recipe');
next(error);
@@ -314,7 +320,7 @@ router.delete(
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParam>>;
try {
await db.flyerRepo.deleteFlyer(params.flyerId, req.log);
res.status(204).send();
sendNoContent(res);
} catch (error: unknown) {
req.log.error({ error }, 'Error deleting flyer');
next(error);
@@ -334,7 +340,7 @@ router.put(
body.status,
req.log,
); // This is still a standalone function in admin.db.ts
res.status(200).json(updatedComment);
sendSuccess(res, updatedComment);
} catch (error: unknown) {
req.log.error({ error }, 'Error updating comment status');
next(error);
@@ -345,7 +351,7 @@ router.put(
router.get('/users', validateRequest(emptySchema), async (req, res, next: NextFunction) => {
try {
const users = await db.adminRepo.getAllUsers(req.log);
res.json(users);
sendSuccess(res, users);
} catch (error) {
req.log.error({ error }, 'Error fetching users');
next(error);
@@ -362,7 +368,7 @@ router.get(
try {
const logs = await db.adminRepo.getActivityLog(limit!, offset!, req.log);
res.json(logs);
sendSuccess(res, logs);
} catch (error) {
req.log.error({ error }, 'Error fetching activity log');
next(error);
@@ -378,7 +384,7 @@ router.get(
const { params } = req as unknown as z.infer<ReturnType<typeof uuidParamSchema>>;
try {
const user = await db.userRepo.findUserProfileById(params.id, req.log);
res.json(user);
sendSuccess(res, user);
} catch (error) {
req.log.error({ error }, 'Error fetching user profile');
next(error);
@@ -394,7 +400,7 @@ router.put(
const { params, body } = req as unknown as z.infer<typeof updateUserRoleSchema>;
try {
const updatedUser = await db.adminRepo.updateUserRole(params.id, body.role, req.log);
res.json(updatedUser);
sendSuccess(res, updatedUser);
} catch (error) {
req.log.error({ error }, `Error updating user ${params.id}:`);
next(error);
@@ -411,7 +417,7 @@ router.delete(
const { params } = req as unknown as z.infer<ReturnType<typeof uuidParamSchema>>;
try {
await userService.deleteUserAsAdmin(userProfile.user.user_id, params.id, req.log);
res.status(204).send();
sendNoContent(res);
} catch (error) {
req.log.error({ error }, 'Error deleting user');
next(error);
@@ -437,10 +443,14 @@ router.post(
// We call the function but don't wait for it to finish (no `await`).
// This is a "fire-and-forget" operation from the client's perspective.
backgroundJobService.runDailyDealCheck();
res.status(202).json({
sendSuccess(
res,
{
message:
'Daily deal check job has been triggered successfully. It will run in the background.',
});
},
202,
);
} catch (error) {
req.log.error({ error }, '[Admin] Failed to trigger daily deal check job.');
next(error);
@@ -464,9 +474,13 @@ router.post(
try {
const jobId = await backgroundJobService.triggerAnalyticsReport();
res.status(202).json({
sendSuccess(
res,
{
message: `Analytics report generation job has been enqueued successfully. Job ID: ${jobId}`,
});
},
202,
);
} catch (error) {
req.log.error({ error }, '[Admin] Failed to enqueue analytics report job.');
next(error);
@@ -493,9 +507,11 @@ router.post(
// Enqueue the cleanup job. The worker will handle the file deletion.
try {
await cleanupQueue.add('cleanup-flyer-files', { flyerId: params.flyerId });
res
.status(202)
.json({ message: `File cleanup job for flyer ID ${params.flyerId} has been enqueued.` });
sendSuccess(
res,
{ message: `File cleanup job for flyer ID ${params.flyerId} has been enqueued.` },
202,
);
} catch (error) {
req.log.error({ error }, 'Error enqueuing cleanup job');
next(error);
@@ -520,14 +536,16 @@ router.post(
try {
// Add a job with a special 'forceFail' flag that the worker will recognize.
const job = await analyticsQueue.add('generate-daily-report', { reportDate: 'FAIL' });
res
.status(202)
.json({ message: `Failing test job has been enqueued successfully. Job ID: ${job.id}` });
sendSuccess(
res,
{ message: `Failing test job has been enqueued successfully. Job ID: ${job.id}` },
202,
);
} catch (error) {
req.log.error({ error }, 'Error enqueuing failing job');
next(error);
}
}
},
);
/**
@@ -546,7 +564,7 @@ router.post(
try {
const keysDeleted = await geocodingService.clearGeocodeCache(req.log);
res.status(200).json({
sendSuccess(res, {
message: `Successfully cleared the geocode cache. ${keysDeleted} keys were removed.`,
});
} catch (error) {
@@ -560,29 +578,37 @@ router.post(
* GET /api/admin/workers/status - Get the current running status of all BullMQ workers.
* This is useful for a system health dashboard to see if any workers have crashed.
*/
router.get('/workers/status', validateRequest(emptySchema), async (req: Request, res: Response, next: NextFunction) => {
router.get(
'/workers/status',
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const workerStatuses = await monitoringService.getWorkerStatuses();
res.json(workerStatuses);
sendSuccess(res, workerStatuses);
} catch (error) {
req.log.error({ error }, 'Error fetching worker statuses');
next(error);
}
});
},
);
/**
* GET /api/admin/queues/status - Get job counts for all BullMQ queues.
* This is useful for monitoring the health and backlog of background jobs.
*/
router.get('/queues/status', validateRequest(emptySchema), async (req: Request, res: Response, next: NextFunction) => {
router.get(
'/queues/status',
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const queueStatuses = await monitoringService.getQueueStatuses();
res.json(queueStatuses);
sendSuccess(res, queueStatuses);
} catch (error) {
req.log.error({ error }, 'Error fetching queue statuses');
next(error);
}
});
},
);
/**
* POST /api/admin/jobs/:queueName/:jobId/retry - Retries a specific failed job.
@@ -598,12 +624,8 @@ router.post(
} = req as unknown as z.infer<typeof jobRetrySchema>;
try {
await monitoringService.retryFailedJob(
queueName,
jobId,
userProfile.user.user_id,
);
res.status(200).json({ message: `Job ${jobId} has been successfully marked for retry.` });
await monitoringService.retryFailedJob(queueName, jobId, userProfile.user.user_id);
sendSuccess(res, { message: `Job ${jobId} has been successfully marked for retry.` });
} catch (error) {
req.log.error({ error }, 'Error retrying job');
next(error);
@@ -626,9 +648,7 @@ router.post(
try {
const jobId = await backgroundJobService.triggerWeeklyAnalyticsReport();
res
.status(202)
.json({ message: 'Successfully enqueued weekly analytics job.', jobId });
sendSuccess(res, { message: 'Successfully enqueued weekly analytics job.', jobId }, 202);
} catch (error) {
req.log.error({ error }, 'Error enqueuing weekly analytics job');
next(error);
@@ -647,9 +667,7 @@ router.post(
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
req.log.info(
`[Admin] Manual cache clear received from user: ${userProfile.user.user_id}`,
);
req.log.info(`[Admin] Manual cache clear received from user: ${userProfile.user.user_id}`);
try {
const [flyersDeleted, brandsDeleted, statsDeleted] = await Promise.all([
@@ -659,7 +677,7 @@ router.post(
]);
const totalDeleted = flyersDeleted + brandsDeleted + statsDeleted;
res.status(200).json({
sendSuccess(res, {
message: `Successfully cleared the application cache. ${totalDeleted} keys were removed.`,
details: {
flyers: flyersDeleted,
@@ -677,5 +695,4 @@ router.post(
/* Catches errors from multer (e.g., file size, file filter) */
router.use(handleMulterError);
export default router;

View File

@@ -9,10 +9,7 @@ import { optionalAuth } from './passport.routes';
// All route handlers now use req.log (request-scoped logger) as per ADR-004
import { aiService, DuplicateFlyerError } from '../services/aiService.server';
// All route handlers now use req.log (request-scoped logger) as per ADR-004
import {
createUploadMiddleware,
handleMulterError,
} from '../middleware/multer.middleware';
import { createUploadMiddleware, handleMulterError } from '../middleware/multer.middleware';
import { logger } from '../services/logger.server'; // Needed for module-level logging (e.g., Zod schema transforms)
// All route handlers now use req.log (request-scoped logger) as per ADR-004
import { UserProfile } from '../types'; // This was a duplicate, fixed.
@@ -26,6 +23,7 @@ import { cleanupUploadedFile, cleanupUploadedFiles } from '../utils/fileUtils';
import { monitoringService } from '../services/monitoringService.server';
// All route handlers now use req.log (request-scoped logger) as per ADR-004
import { aiUploadLimiter, aiGenerationLimiter } from '../config/rateLimiters';
import { sendSuccess, sendError, ErrorCode } from '../utils/apiResponse';
const router = Router();
@@ -35,7 +33,8 @@ const uploadAndProcessSchema = z.object({
body: z.object({
// Stricter validation for SHA-256 checksum. It must be a 64-character hexadecimal string.
checksum: requiredString('File checksum is required.').pipe(
z.string()
z
.string()
.length(64, 'Checksum must be 64 characters long.')
.regex(/^[a-f0-9]+$/, 'Checksum must be a valid hexadecimal string.'),
),
@@ -96,8 +95,14 @@ const flyerItemForAnalysisSchema = z
// Sanitize item and name by trimming whitespace.
// The transform ensures that null/undefined values are preserved
// while trimming any actual string values.
item: z.string().nullish().transform(val => (val ? val.trim() : val)),
name: z.string().nullish().transform(val => (val ? val.trim() : val)),
item: z
.string()
.nullish()
.transform((val) => (val ? val.trim() : val)),
name: z
.string()
.nullish()
.transform((val) => (val ? val.trim() : val)),
})
// Using .passthrough() allows extra properties on the item object.
// If the intent is to strictly enforce only 'item' and 'name' (and other known properties),
@@ -190,7 +195,12 @@ router.post(
const { body } = uploadAndProcessSchema.parse({ body: req.body });
if (!req.file) {
return res.status(400).json({ message: 'A flyer file (PDF or image) is required.' });
return sendError(
res,
ErrorCode.BAD_REQUEST,
'A flyer file (PDF or image) is required.',
400,
);
}
req.log.debug(
@@ -215,15 +225,19 @@ router.post(
);
// Respond immediately to the client with 202 Accepted
res.status(202).json({
sendSuccess(
res,
{
message: 'Flyer accepted for processing.',
jobId: job.id,
});
},
202,
);
} catch (error) {
await cleanupUploadedFile(req.file);
if (error instanceof DuplicateFlyerError) {
req.log.warn(`Duplicate flyer upload attempt blocked for checksum: ${req.body?.checksum}`);
return res.status(409).json({ message: error.message, flyerId: error.flyerId });
return sendError(res, ErrorCode.CONFLICT, error.message, 409, { flyerId: error.flyerId });
}
next(error);
}
@@ -246,16 +260,21 @@ router.post(
async (req: Request, res: Response, next: NextFunction) => {
try {
if (!req.file) {
return res.status(400).json({ message: 'No flyer file uploaded.' });
return sendError(res, ErrorCode.BAD_REQUEST, 'No flyer file uploaded.', 400);
}
const userProfile = req.user as UserProfile;
const newFlyer = await aiService.processLegacyFlyerUpload(req.file, req.body, userProfile, req.log);
res.status(200).json(newFlyer);
const newFlyer = await aiService.processLegacyFlyerUpload(
req.file,
req.body,
userProfile,
req.log,
);
sendSuccess(res, newFlyer);
} catch (error) {
await cleanupUploadedFile(req.file);
if (error instanceof DuplicateFlyerError) {
req.log.warn(`Duplicate legacy flyer upload attempt blocked.`);
return res.status(409).json({ message: error.message, flyerId: error.flyerId });
return sendError(res, ErrorCode.CONFLICT, error.message, 409, { flyerId: error.flyerId });
}
next(error);
}
@@ -277,7 +296,7 @@ router.get(
try {
const jobStatus = await monitoringService.getFlyerJobStatus(jobId); // This was a duplicate, fixed.
req.log.debug(`[API /ai/jobs] Status check for job ${jobId}: ${jobStatus.state}`);
res.json(jobStatus);
sendSuccess(res, jobStatus);
} catch (error) {
next(error);
}
@@ -300,7 +319,7 @@ router.post(
async (req, res, next: NextFunction) => {
try {
if (!req.file) {
return res.status(400).json({ message: 'Flyer image file is required.' });
return sendError(res, ErrorCode.BAD_REQUEST, 'Flyer image file is required.', 400);
}
const userProfile = req.user as UserProfile | undefined;
@@ -312,12 +331,16 @@ router.post(
req.log,
);
res.status(201).json({ message: 'Flyer processed and saved successfully.', flyer: newFlyer });
sendSuccess(
res,
{ message: 'Flyer processed and saved successfully.', flyer: newFlyer },
201,
);
} catch (error) {
await cleanupUploadedFile(req.file);
if (error instanceof DuplicateFlyerError) {
req.log.warn(`Duplicate flyer upload attempt blocked.`);
return res.status(409).json({ message: error.message, flyerId: error.flyerId });
return sendError(res, ErrorCode.CONFLICT, error.message, 409, { flyerId: error.flyerId });
}
next(error);
}
@@ -336,10 +359,10 @@ router.post(
async (req, res, next: NextFunction) => {
try {
if (!req.file) {
return res.status(400).json({ message: 'Image file is required.' });
return sendError(res, ErrorCode.BAD_REQUEST, 'Image file is required.', 400);
}
req.log.info(`Server-side flyer check for file: ${req.file.originalname}`);
res.status(200).json({ is_flyer: true }); // Stubbed response
sendSuccess(res, { is_flyer: true }); // Stubbed response
} catch (error) {
next(error);
} finally {
@@ -356,10 +379,10 @@ router.post(
async (req, res, next: NextFunction) => {
try {
if (!req.file) {
return res.status(400).json({ message: 'Image file is required.' });
return sendError(res, ErrorCode.BAD_REQUEST, 'Image file is required.', 400);
}
req.log.info(`Server-side address extraction for file: ${req.file.originalname}`);
res.status(200).json({ address: 'not identified' }); // Updated stubbed response
sendSuccess(res, { address: 'not identified' }); // Updated stubbed response
} catch (error) {
next(error);
} finally {
@@ -376,10 +399,10 @@ router.post(
async (req, res, next: NextFunction) => {
try {
if (!req.files || !Array.isArray(req.files) || req.files.length === 0) {
return res.status(400).json({ message: 'Image files are required.' });
return sendError(res, ErrorCode.BAD_REQUEST, 'Image files are required.', 400);
}
req.log.info(`Server-side logo extraction for ${req.files.length} image(s).`);
res.status(200).json({ store_logo_base_64: null }); // Stubbed response
sendSuccess(res, { store_logo_base_64: null }); // Stubbed response
} catch (error) {
next(error);
} finally {
@@ -396,9 +419,7 @@ router.post(
async (req, res, next: NextFunction) => {
try {
req.log.info(`Server-side quick insights requested.`);
res
.status(200)
.json({ text: 'This is a server-generated quick insight: buy the cheap stuff!' }); // Stubbed response
sendSuccess(res, { text: 'This is a server-generated quick insight: buy the cheap stuff!' }); // Stubbed response
} catch (error) {
next(error);
}
@@ -413,9 +434,9 @@ router.post(
async (req, res, next: NextFunction) => {
try {
req.log.info(`Server-side deep dive requested.`);
res
.status(200)
.json({ text: 'This is a server-generated deep dive analysis. It is very detailed.' }); // Stubbed response
sendSuccess(res, {
text: 'This is a server-generated deep dive analysis. It is very detailed.',
}); // Stubbed response
} catch (error) {
next(error);
}
@@ -430,7 +451,7 @@ router.post(
async (req, res, next: NextFunction) => {
try {
req.log.info(`Server-side web search requested.`);
res.status(200).json({ text: 'The web says this is good.', sources: [] }); // Stubbed response
sendSuccess(res, { text: 'The web says this is good.', sources: [] }); // Stubbed response
} catch (error) {
next(error);
}
@@ -446,7 +467,7 @@ router.post(
try {
const { items } = req.body;
req.log.info(`Server-side price comparison requested for ${items.length} items.`);
res.status(200).json({
sendSuccess(res, {
text: 'This is a server-generated price comparison. Milk is cheaper at SuperMart.',
sources: [],
}); // Stubbed response
@@ -466,7 +487,7 @@ router.post(
const { items, store, userLocation } = req.body;
req.log.debug({ itemCount: items.length, storeName: store.name }, 'Trip planning requested.');
const result = await aiService.planTripWithMaps(items, store, userLocation);
res.status(200).json(result);
sendSuccess(res, result);
} catch (error) {
req.log.error({ error: errMsg(error) }, 'Error in /api/ai/plan-trip endpoint:');
next(error);
@@ -485,7 +506,7 @@ router.post(
// This endpoint is a placeholder for a future feature.
// Returning 501 Not Implemented is the correct HTTP response for this case.
req.log.info('Request received for unimplemented endpoint: /api/ai/generate-image');
res.status(501).json({ message: 'Image generation is not yet implemented.' });
sendError(res, ErrorCode.NOT_IMPLEMENTED, 'Image generation is not yet implemented.', 501);
},
);
@@ -498,7 +519,7 @@ router.post(
// This endpoint is a placeholder for a future feature.
// Returning 501 Not Implemented is the correct HTTP response for this case.
req.log.info('Request received for unimplemented endpoint: /api/ai/generate-speech');
res.status(501).json({ message: 'Speech generation is not yet implemented.' });
sendError(res, ErrorCode.NOT_IMPLEMENTED, 'Speech generation is not yet implemented.', 501);
},
);
@@ -515,7 +536,7 @@ router.post(
async (req, res, next: NextFunction) => {
try {
if (!req.file) {
return res.status(400).json({ message: 'Image file is required.' });
return sendError(res, ErrorCode.BAD_REQUEST, 'Image file is required.', 400);
}
// validateRequest transforms the cropArea JSON string into an object in req.body.
// So we use it directly instead of JSON.parse().
@@ -536,7 +557,7 @@ router.post(
req.log,
);
res.status(200).json(result);
sendSuccess(res, result);
} catch (error) {
next(error);
} finally {

View File

@@ -23,6 +23,7 @@ import {
refreshTokenLimiter,
logoutLimiter,
} from '../config/rateLimiters';
import { sendSuccess, sendError, ErrorCode } from '../utils/apiResponse';
// All route handlers now use req.log (request-scoped logger) as per ADR-004
import { authService } from '../services/authService';
@@ -103,13 +104,19 @@ router.post(
secure: process.env.NODE_ENV === 'production',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
return res
.status(201)
.json({ message: 'User registered successfully!', userprofile: newUserProfile, token: accessToken });
return sendSuccess(
res,
{
message: 'User registered successfully!',
userprofile: newUserProfile,
token: accessToken,
},
201,
);
} catch (error: unknown) {
if (error instanceof UniqueConstraintError) {
// If the email is a duplicate, return a 409 Conflict status.
return res.status(409).json({ message: error.message });
return sendError(res, ErrorCode.CONFLICT, error.message, 409);
}
req.log.error({ error }, `User registration route failed for email: ${email}.`);
// Pass the error to the centralized handler
@@ -143,13 +150,16 @@ router.post(
return next(err);
}
if (!user) {
return res.status(401).json({ message: info.message || 'Login failed' });
return sendError(res, ErrorCode.UNAUTHORIZED, info.message || 'Login failed', 401);
}
try {
const { rememberMe } = req.body;
const userProfile = user as UserProfile;
const { accessToken, refreshToken } = await authService.handleSuccessfulLogin(userProfile, req.log);
const { accessToken, refreshToken } = await authService.handleSuccessfulLogin(
userProfile,
req.log,
);
req.log.info(`JWT and refresh token issued for user: ${userProfile.user.email}`);
const cookieOptions = {
@@ -160,7 +170,7 @@ router.post(
res.cookie('refreshToken', refreshToken, cookieOptions);
// Return the full user profile object on login to avoid a second fetch on the client.
return res.json({ userprofile: userProfile, token: accessToken });
return sendSuccess(res, { userprofile: userProfile, token: accessToken });
} catch (tokenErr) {
const email = (user as UserProfile)?.user?.email || req.body.email;
req.log.error({ error: tokenErr }, `Failed to process login for user: ${email}`);
@@ -191,7 +201,7 @@ router.post(
message: 'If an account with that email exists, a password reset link has been sent.',
};
if (process.env.NODE_ENV === 'test' && token) responsePayload.token = token;
res.status(200).json(responsePayload);
sendSuccess(res, responsePayload);
} catch (error) {
req.log.error({ error }, `An error occurred during /forgot-password for email: ${email}`);
next(error);
@@ -214,10 +224,15 @@ router.post(
const resetSuccessful = await authService.updatePassword(token, newPassword, req.log);
if (!resetSuccessful) {
return res.status(400).json({ message: 'Invalid or expired password reset token.' });
return sendError(
res,
ErrorCode.BAD_REQUEST,
'Invalid or expired password reset token.',
400,
);
}
res.status(200).json({ message: 'Password has been reset successfully.' });
sendSuccess(res, { message: 'Password has been reset successfully.' });
} catch (error) {
req.log.error({ error }, `An error occurred during password reset.`);
next(error);
@@ -226,23 +241,27 @@ router.post(
);
// New Route to refresh the access token
router.post('/refresh-token', refreshTokenLimiter, async (req: Request, res: Response, next: NextFunction) => {
router.post(
'/refresh-token',
refreshTokenLimiter,
async (req: Request, res: Response, next: NextFunction) => {
const { refreshToken } = req.cookies;
if (!refreshToken) {
return res.status(401).json({ message: 'Refresh token not found.' });
return sendError(res, ErrorCode.UNAUTHORIZED, 'Refresh token not found.', 401);
}
try {
const result = await authService.refreshAccessToken(refreshToken, req.log);
if (!result) {
return res.status(403).json({ message: 'Invalid or expired refresh token.' });
return sendError(res, ErrorCode.FORBIDDEN, 'Invalid or expired refresh token.', 403);
}
res.json({ token: result.accessToken });
sendSuccess(res, { token: result.accessToken });
} catch (error) {
req.log.error({ error }, 'An error occurred during /refresh-token.');
next(error);
}
});
},
);
/**
* POST /api/auth/logout - Logs the user out by invalidating their refresh token.
@@ -264,7 +283,7 @@ router.post('/logout', logoutLimiter, async (req: Request, res: Response) => {
maxAge: 0, // Use maxAge for modern compatibility; Express sets 'Expires' as a fallback.
secure: process.env.NODE_ENV === 'production',
});
res.status(200).json({ message: 'Logged out successfully.' });
sendSuccess(res, { message: 'Logged out successfully.' });
});
// --- OAuth Routes ---

View File

@@ -7,11 +7,15 @@ import type { UserProfile } from '../types';
import { validateRequest } from '../middleware/validation.middleware';
import { requiredString, numericIdParam } from '../utils/zodUtils';
import { budgetUpdateLimiter } from '../config/rateLimiters';
import { sendSuccess, sendNoContent } from '../utils/apiResponse';
const router = express.Router();
// --- Zod Schemas for Budget Routes (as per ADR-003) ---
const budgetIdParamSchema = numericIdParam('id', "Invalid ID for parameter 'id'. Must be a number.");
const budgetIdParamSchema = numericIdParam(
'id',
"Invalid ID for parameter 'id'. Must be a number.",
);
const createBudgetSchema = z.object({
body: z.object({
@@ -48,7 +52,7 @@ router.get('/', async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
try {
const budgets = await budgetRepo.getBudgetsForUser(userProfile.user.user_id, req.log);
res.json(budgets);
sendSuccess(res, budgets);
} catch (error) {
req.log.error({ error, userId: userProfile.user.user_id }, 'Error fetching budgets');
next(error);
@@ -67,7 +71,7 @@ router.post(
const { body } = req as unknown as CreateBudgetRequest;
try {
const newBudget = await budgetRepo.createBudget(userProfile.user.user_id, body, req.log);
res.status(201).json(newBudget);
sendSuccess(res, newBudget, 201);
} catch (error: unknown) {
req.log.error({ error, userId: userProfile.user.user_id, body }, 'Error creating budget');
next(error);
@@ -92,7 +96,7 @@ router.put(
body,
req.log,
);
res.json(updatedBudget);
sendSuccess(res, updatedBudget);
} catch (error: unknown) {
req.log.error(
{ error, userId: userProfile.user.user_id, budgetId: params.id },
@@ -115,7 +119,7 @@ router.delete(
const { params } = req as unknown as DeleteBudgetRequest;
try {
await budgetRepo.deleteBudget(params.id, userProfile.user.user_id, req.log);
res.status(204).send(); // No Content
sendNoContent(res);
} catch (error: unknown) {
req.log.error(
{ error, userId: userProfile.user.user_id, budgetId: params.id },
@@ -147,7 +151,7 @@ router.get(
endDate,
req.log,
);
res.json(spendingData);
sendSuccess(res, spendingData);
} catch (error) {
req.log.error(
{ error, userId: userProfile.user.user_id, startDate, endDate },

View File

@@ -6,6 +6,7 @@ import { dealsRepo } from '../services/db/deals.db';
import type { UserProfile } from '../types';
import { validateRequest } from '../middleware/validation.middleware';
import { userReadLimiter } from '../config/rateLimiters';
import { sendSuccess } from '../utils/apiResponse';
const router = express.Router();
@@ -40,7 +41,7 @@ router.get(
req.log,
);
req.log.info({ dealCount: deals.length }, 'Successfully fetched best watched item deals.');
res.status(200).json(deals);
sendSuccess(res, deals);
} catch (error) {
req.log.error({ error }, 'Error fetching best watched item deals.');
next(error); // Pass errors to the global error handler

View File

@@ -4,11 +4,8 @@ import * as db from '../services/db/index.db';
import { z } from 'zod';
import { validateRequest } from '../middleware/validation.middleware';
import { optionalNumeric } from '../utils/zodUtils';
import {
publicReadLimiter,
batchLimiter,
trackingLimiter,
} from '../config/rateLimiters';
import { publicReadLimiter, batchLimiter, trackingLimiter } from '../config/rateLimiters';
import { sendSuccess } from '../utils/apiResponse';
const router = Router();
@@ -53,34 +50,44 @@ const trackItemSchema = z.object({
/**
* GET /api/flyers - Get a paginated list of all flyers.
*/
router.get('/', publicReadLimiter, validateRequest(getFlyersSchema), async (req, res, next): Promise<void> => {
router.get(
'/',
publicReadLimiter,
validateRequest(getFlyersSchema),
async (req, res, next): Promise<void> => {
try {
// The `validateRequest` middleware ensures `req.query` is valid.
// We parse it here to apply Zod's coercions (string to number) and defaults.
const { limit, offset } = getFlyersSchema.shape.query.parse(req.query);
const flyers = await db.flyerRepo.getFlyers(req.log, limit, offset);
res.json(flyers);
sendSuccess(res, flyers);
} catch (error) {
req.log.error({ error }, 'Error fetching flyers in /api/flyers:');
next(error);
}
});
},
);
/**
* GET /api/flyers/:id - Get a single flyer by its ID.
*/
router.get('/:id', publicReadLimiter, validateRequest(flyerIdParamSchema), async (req, res, next): Promise<void> => {
router.get(
'/:id',
publicReadLimiter,
validateRequest(flyerIdParamSchema),
async (req, res, next): Promise<void> => {
try {
// Explicitly parse to get the coerced number type for `id`.
const { id } = flyerIdParamSchema.shape.params.parse(req.params);
const flyer = await db.flyerRepo.getFlyerById(id);
res.json(flyer);
sendSuccess(res, flyer);
} catch (error) {
req.log.error({ error, flyerId: req.params.id }, 'Error fetching flyer by ID:');
next(error);
}
});
},
);
/**
* GET /api/flyers/:id/items - Get all items for a specific flyer.
@@ -90,14 +97,16 @@ router.get(
publicReadLimiter,
validateRequest(flyerIdParamSchema),
async (req, res, next): Promise<void> => {
type GetFlyerByIdRequest = z.infer<typeof flyerIdParamSchema>;
try {
// Explicitly parse to get the coerced number type for `id`.
const { id } = flyerIdParamSchema.shape.params.parse(req.params);
const items = await db.flyerRepo.getFlyerItems(id, req.log);
res.json(items);
sendSuccess(res, items);
} catch (error) {
req.log.error({ error, flyerId: req.params.id }, 'Error fetching flyer items in /api/flyers/:id/items:');
req.log.error(
{ error, flyerId: req.params.id },
'Error fetching flyer items in /api/flyers/:id/items:',
);
next(error);
}
},
@@ -117,7 +126,7 @@ router.post(
// No re-parsing needed here as `validateRequest` has already ensured the body shape,
// and `express.json()` has parsed it. There's no type coercion to apply.
const items = await db.flyerRepo.getFlyerItemsForFlyers(body.flyerIds, req.log);
res.json(items);
sendSuccess(res, items);
} catch (error) {
req.log.error({ error }, 'Error fetching batch flyer items');
next(error);
@@ -139,7 +148,7 @@ router.post(
// The schema ensures flyerIds is an array of numbers.
// The `?? []` was redundant as `validateRequest` would have already caught a missing `flyerIds`.
const count = await db.flyerRepo.countFlyerItemsForFlyers(body.flyerIds, req.log);
res.json({ count });
sendSuccess(res, { count });
} catch (error) {
req.log.error({ error }, 'Error counting batch flyer items');
next(error);
@@ -150,7 +159,11 @@ router.post(
/**
* POST /api/flyers/items/:itemId/track - Tracks a user interaction with a flyer item.
*/
router.post('/items/:itemId/track', trackingLimiter, validateRequest(trackItemSchema), (req, res, next): void => {
router.post(
'/items/:itemId/track',
trackingLimiter,
validateRequest(trackItemSchema),
(req, res, next): void => {
try {
// Explicitly parse to get coerced types.
const { params, body } = trackItemSchema.parse({ params: req.params, body: req.body });
@@ -161,11 +174,12 @@ router.post('/items/:itemId/track', trackingLimiter, validateRequest(trackItemSc
req.log.error({ error, itemId: params.itemId }, 'Flyer item interaction tracking failed');
});
res.status(202).send();
sendSuccess(res, { message: 'Tracking accepted' }, 202);
} catch (error) {
// This will catch Zod parsing errors if they occur.
next(error);
}
});
},
);
export default router;

View File

@@ -13,11 +13,8 @@ import { validateRequest } from '../middleware/validation.middleware';
// All route handlers now use req.log (request-scoped logger) as per ADR-004
import { requiredString, optionalNumeric } from '../utils/zodUtils';
// All route handlers now use req.log (request-scoped logger) as per ADR-004
import {
publicReadLimiter,
userReadLimiter,
adminTriggerLimiter,
} from '../config/rateLimiters';
import { publicReadLimiter, userReadLimiter, adminTriggerLimiter } from '../config/rateLimiters';
import { sendSuccess } from '../utils/apiResponse';
const router = express.Router();
const adminGamificationRouter = express.Router(); // Create a new router for admin-only routes.
@@ -48,7 +45,7 @@ const awardAchievementSchema = z.object({
router.get('/', publicReadLimiter, async (req, res, next: NextFunction) => {
try {
const achievements = await gamificationService.getAllAchievements(req.log);
res.json(achievements);
sendSuccess(res, achievements);
} catch (error) {
req.log.error({ error }, 'Error fetching all achievements in /api/achievements:');
next(error);
@@ -69,7 +66,7 @@ router.get(
// We parse it here to apply Zod's coercions (string to number) and defaults.
const { limit } = leaderboardQuerySchema.parse(req.query);
const leaderboard = await gamificationService.getLeaderboard(limit!, req.log);
res.json(leaderboard);
sendSuccess(res, leaderboard);
} catch (error) {
req.log.error({ error }, 'Error fetching leaderboard:');
next(error);
@@ -94,7 +91,7 @@ router.get(
userProfile.user.user_id,
req.log,
);
res.json(userAchievements);
sendSuccess(res, userAchievements);
} catch (error) {
req.log.error(
{ error, userId: userProfile.user.user_id },
@@ -124,9 +121,7 @@ adminGamificationRouter.post(
const { body } = req as unknown as AwardAchievementRequest;
try {
await gamificationService.awardAchievement(body.userId, body.achievementName, req.log);
res
.status(200)
.json({
sendSuccess(res, {
message: `Successfully awarded '${body.achievementName}' to user ${body.userId}.`,
});
} catch (error) {

View File

@@ -14,6 +14,7 @@ import { connection as redisConnection } from '../services/queueService.server';
import fs from 'node:fs/promises';
import { getSimpleWeekAndYear } from '../utils/dateUtils';
import { validateRequest } from '../middleware/validation.middleware';
import { sendSuccess, sendError, ErrorCode } from '../utils/apiResponse';
const router = Router();
@@ -129,7 +130,7 @@ const emptySchema = z.object({});
* GET /api/health/ping - A simple endpoint to check if the server is responsive.
*/
router.get('/ping', validateRequest(emptySchema), (_req: Request, res: Response) => {
res.status(200).send('pong');
return sendSuccess(res, { message: 'pong' });
});
// =============================================================================
@@ -146,7 +147,7 @@ router.get('/ping', validateRequest(emptySchema), (_req: Request, res: Response)
* It only checks that the Node.js process can handle HTTP requests.
*/
router.get('/live', validateRequest(emptySchema), (_req: Request, res: Response) => {
res.status(200).json({
return sendSuccess(res, {
status: 'ok',
timestamp: new Date().toISOString(),
});
@@ -198,9 +199,10 @@ router.get('/ready', validateRequest(emptySchema), async (req: Request, res: Res
// Return appropriate HTTP status code
// 200 = healthy or degraded (can still handle traffic)
// 503 = unhealthy (should not receive traffic)
const httpStatus = overallStatus === 'unhealthy' ? 503 : 200;
return res.status(httpStatus).json(response);
if (overallStatus === 'unhealthy') {
return sendError(res, ErrorCode.SERVICE_UNAVAILABLE, 'Service unhealthy', 503, response);
}
return sendSuccess(res, response);
});
/**
@@ -216,14 +218,13 @@ router.get('/startup', validateRequest(emptySchema), async (req: Request, res: R
const database = await checkDatabase();
if (database.status === 'unhealthy') {
return res.status(503).json({
return sendError(res, ErrorCode.SERVICE_UNAVAILABLE, 'Waiting for database connection', 503, {
status: 'starting',
message: 'Waiting for database connection',
database,
});
}
return res.status(200).json({
return sendSuccess(res, {
status: 'started',
timestamp: new Date().toISOString(),
database,
@@ -245,7 +246,7 @@ router.get('/db-schema', validateRequest(emptySchema), async (req, res, next: Ne
new Error(`Database schema check failed. Missing tables: ${missingTables.join(', ')}.`),
);
}
return res.status(200).json({ success: true, message: 'All required database tables exist.' });
return sendSuccess(res, { message: 'All required database tables exist.' });
} catch (error: unknown) {
if (error instanceof Error) {
return next(error);
@@ -266,8 +267,7 @@ router.get('/storage', validateRequest(emptySchema), async (req, res, next: Next
process.env.STORAGE_PATH || '/var/www/flyer-crawler.projectium.com/flyer-images';
try {
await fs.access(storagePath, fs.constants.W_OK); // Use fs.promises
return res.status(200).json({
success: true,
return sendSuccess(res, {
message: `Storage directory '${storagePath}' is accessible and writable.`,
});
} catch {
@@ -293,12 +293,16 @@ router.get(
const message = `Pool Status: ${status.totalCount} total, ${status.idleCount} idle, ${status.waitingCount} waiting.`;
if (isHealthy) {
return res.status(200).json({ success: true, message });
return sendSuccess(res, { message, ...status });
} else {
req.log.warn(`Database pool health check shows high waiting count: ${status.waitingCount}`);
return res
.status(500)
.json({ success: false, message: `Pool may be under stress. ${message}` });
return sendError(
res,
ErrorCode.INTERNAL_ERROR,
`Pool may be under stress. ${message}`,
500,
status,
);
}
} catch (error: unknown) {
if (error instanceof Error) {
@@ -319,7 +323,7 @@ router.get(
router.get('/time', validateRequest(emptySchema), (req: Request, res: Response) => {
const now = new Date();
const { year, week } = getSimpleWeekAndYear(now);
res.json({
return sendSuccess(res, {
currentTime: now.toISOString(),
year,
week,
@@ -336,7 +340,7 @@ router.get(
try {
const reply = await redisConnection.ping();
if (reply === 'PONG') {
return res.status(200).json({ success: true, message: 'Redis connection is healthy.' });
return sendSuccess(res, { message: 'Redis connection is healthy.' });
}
throw new Error(`Unexpected Redis ping response: ${reply}`); // This will be caught below
} catch (error: unknown) {

View File

@@ -4,6 +4,7 @@ import { z } from 'zod';
import * as db from '../services/db/index.db';
import { validateRequest } from '../middleware/validation.middleware';
import { publicReadLimiter } from '../config/rateLimiters';
import { sendSuccess } from '../utils/apiResponse';
const router = Router();
@@ -28,7 +29,7 @@ router.get(
res.set('Cache-Control', 'public, max-age=3600');
const masterItems = await db.personalizationRepo.getAllMasterItems(req.log);
res.json(masterItems);
sendSuccess(res, masterItems);
} catch (error) {
req.log.error({ error }, 'Error fetching master items in /api/personalization/master-items:');
next(error);
@@ -46,7 +47,7 @@ router.get(
async (req: Request, res: Response, next: NextFunction) => {
try {
const restrictions = await db.personalizationRepo.getDietaryRestrictions(req.log);
res.json(restrictions);
sendSuccess(res, restrictions);
} catch (error) {
req.log.error(
{ error },
@@ -67,7 +68,7 @@ router.get(
async (req: Request, res: Response, next: NextFunction) => {
try {
const appliances = await db.personalizationRepo.getAppliances(req.log);
res.json(appliances);
sendSuccess(res, appliances);
} catch (error) {
req.log.error({ error }, 'Error fetching appliances in /api/personalization/appliances:');
next(error);

View File

@@ -6,14 +6,13 @@ import { validateRequest } from '../middleware/validation.middleware';
import { priceRepo } from '../services/db/price.db';
import { optionalNumeric } from '../utils/zodUtils';
import { priceHistoryLimiter } from '../config/rateLimiters';
import { sendSuccess } from '../utils/apiResponse';
const router = Router();
const priceHistorySchema = z.object({
body: z.object({
masterItemIds: z
.array(z.number().int().positive('Number must be greater than 0'))
.nonempty({
masterItemIds: z.array(z.number().int().positive('Number must be greater than 0')).nonempty({
message: 'masterItemIds must be a non-empty array of positive integers.',
}),
limit: optionalNumeric({ default: 1000, integer: true, positive: true }),
@@ -44,7 +43,7 @@ router.post(
);
try {
const priceHistory = await priceRepo.getPriceHistory(masterItemIds, req.log, limit, offset);
res.status(200).json(priceHistory);
sendSuccess(res, priceHistory);
} catch (error) {
next(error);
}

View File

@@ -6,6 +6,7 @@ import passport from './passport.routes';
import { requiredString } from '../utils/zodUtils';
import { UserProfile } from '../types';
import { publicReadLimiter, reactionToggleLimiter } from '../config/rateLimiters';
import { sendSuccess } from '../utils/apiResponse';
const router = Router();
@@ -49,7 +50,7 @@ router.get(
try {
const { query } = getReactionsSchema.parse({ query: req.query });
const reactions = await reactionRepo.getReactions(query, req.log);
res.json(reactions);
sendSuccess(res, reactions);
} catch (error) {
req.log.error({ error }, 'Error fetching user reactions');
next(error);
@@ -69,8 +70,12 @@ router.get(
async (req: Request, res: Response, next: NextFunction) => {
try {
const { query } = getReactionSummarySchema.parse({ query: req.query });
const summary = await reactionRepo.getReactionSummary(query.entityType, query.entityId, req.log);
res.json(summary);
const summary = await reactionRepo.getReactionSummary(
query.entityType,
query.entityId,
req.log,
);
sendSuccess(res, summary);
} catch (error) {
req.log.error({ error }, 'Error fetching reaction summary');
next(error);
@@ -99,9 +104,9 @@ router.post(
};
const result = await reactionRepo.toggleReaction(reactionData, req.log);
if (result) {
res.status(201).json({ message: 'Reaction added.', reaction: result });
sendSuccess(res, { message: 'Reaction added.', reaction: result }, 201);
} else {
res.status(200).json({ message: 'Reaction removed.' });
sendSuccess(res, { message: 'Reaction removed.' });
}
} catch (error) {
req.log.error({ error, body }, 'Error toggling user reaction');

View File

@@ -7,6 +7,7 @@ import passport from './passport.routes';
import { validateRequest } from '../middleware/validation.middleware';
import { requiredString, numericIdParam, optionalNumeric } from '../utils/zodUtils';
import { publicReadLimiter, suggestionLimiter } from '../config/rateLimiters';
import { sendSuccess, sendError, ErrorCode } from '../utils/apiResponse';
const router = Router();
@@ -49,7 +50,7 @@ router.get(
// Explicitly parse req.query to apply coercion (string -> number) and default values
const { query } = bySalePercentageSchema.parse({ query: req.query });
const recipes = await db.recipeRepo.getRecipesBySalePercentage(query.minPercentage!, req.log);
res.json(recipes);
sendSuccess(res, recipes);
} catch (error) {
req.log.error({ error }, 'Error fetching recipes in /api/recipes/by-sale-percentage:');
next(error);
@@ -72,7 +73,7 @@ router.get(
query.minIngredients!,
req.log,
);
res.json(recipes);
sendSuccess(res, recipes);
} catch (error) {
req.log.error({ error }, 'Error fetching recipes in /api/recipes/by-sale-ingredients:');
next(error);
@@ -95,7 +96,7 @@ router.get(
query.tag,
req.log,
);
res.json(recipes);
sendSuccess(res, recipes);
} catch (error) {
req.log.error({ error }, 'Error fetching recipes in /api/recipes/by-ingredient-and-tag:');
next(error);
@@ -106,32 +107,42 @@ router.get(
/**
* GET /api/recipes/:recipeId/comments - Get all comments for a specific recipe.
*/
router.get('/:recipeId/comments', publicReadLimiter, validateRequest(recipeIdParamsSchema), async (req, res, next) => {
router.get(
'/:recipeId/comments',
publicReadLimiter,
validateRequest(recipeIdParamsSchema),
async (req, res, next) => {
try {
// Explicitly parse req.params to coerce recipeId to a number
const { params } = recipeIdParamsSchema.parse({ params: req.params });
const comments = await db.recipeRepo.getRecipeComments(params.recipeId, req.log);
res.json(comments);
sendSuccess(res, comments);
} catch (error) {
req.log.error({ error }, `Error fetching comments for recipe ID ${req.params.recipeId}:`);
next(error);
}
});
},
);
/**
* GET /api/recipes/:recipeId - Get a single recipe by its ID, including ingredients and tags.
*/
router.get('/:recipeId', publicReadLimiter, validateRequest(recipeIdParamsSchema), async (req, res, next) => {
router.get(
'/:recipeId',
publicReadLimiter,
validateRequest(recipeIdParamsSchema),
async (req, res, next) => {
try {
// Explicitly parse req.params to coerce recipeId to a number
const { params } = recipeIdParamsSchema.parse({ params: req.params });
const recipe = await db.recipeRepo.getRecipeById(params.recipeId, req.log);
res.json(recipe);
sendSuccess(res, recipe);
} catch (error) {
req.log.error({ error }, `Error fetching recipe ID ${req.params.recipeId}:`);
next(error);
}
});
},
);
/**
* POST /api/recipes/suggest - Generates a simple recipe suggestion from a list of ingredients.
@@ -148,12 +159,15 @@ router.post(
const suggestion = await aiService.generateRecipeSuggestion(body.ingredients, req.log);
if (!suggestion) {
return res
.status(503)
.json({ message: 'AI service is currently unavailable or failed to generate a suggestion.' });
return sendError(
res,
ErrorCode.SERVICE_UNAVAILABLE,
'AI service is currently unavailable or failed to generate a suggestion.',
503,
);
}
res.json({ suggestion });
sendSuccess(res, { suggestion });
} catch (error) {
req.log.error({ error }, 'Error generating recipe suggestion');
next(error);

View File

@@ -5,6 +5,7 @@ import * as db from '../services/db/index.db';
import { validateRequest } from '../middleware/validation.middleware';
import { optionalNumeric } from '../utils/zodUtils';
import { publicReadLimiter } from '../config/rateLimiters';
import { sendSuccess } from '../utils/apiResponse';
const router = Router();
@@ -34,7 +35,7 @@ router.get(
// We parse it here to apply Zod's coercions (string to number) and defaults.
const { days, limit } = statsQuerySchema.parse(req.query);
const items = await db.adminRepo.getMostFrequentSaleItems(days!, limit!, req.log);
res.json(items);
sendSuccess(res, items);
} catch (error) {
req.log.error(
{ error },

View File

@@ -14,6 +14,7 @@ import { requiredString } from '../utils/zodUtils';
import { systemService } from '../services/systemService';
// All route handlers now use req.log (request-scoped logger) as per ADR-004
import { geocodeLimiter } from '../config/rateLimiters';
import { sendSuccess, sendError, ErrorCode } from '../utils/apiResponse';
const router = Router();
@@ -36,7 +37,7 @@ router.get(
async (req: Request, res: Response, next: NextFunction) => {
try {
const status = await systemService.getPm2Status();
res.json(status);
sendSuccess(res, status);
} catch (error) {
next(error);
}
@@ -63,10 +64,10 @@ router.post(
if (!coordinates) {
// This check remains, but now it only fails if BOTH services fail.
return res.status(404).json({ message: 'Could not geocode the provided address.' });
return sendError(res, ErrorCode.NOT_FOUND, 'Could not geocode the provided address.', 404);
}
res.json(coordinates);
sendSuccess(res, coordinates);
} catch (error) {
req.log.error({ error }, 'Error geocoding address');
next(error);

View File

@@ -1,17 +1,13 @@
// src/routes/user.routes.ts
import express, { Request, Response, NextFunction } from 'express';
import passport from './passport.routes';
import multer from 'multer'; // Keep for MulterError type check
// All route handlers now use req.log (request-scoped logger) as per ADR-004
import { z } from 'zod';
// Removed: import { logger } from '../services/logger.server';
// All route handlers now use req.log (request-scoped logger) as per ADR-004
import { UserProfile } from '../types';
// All route handlers now use req.log (request-scoped logger) as per ADR-004
import {
createUploadMiddleware,
handleMulterError,
} from '../middleware/multer.middleware';
import { createUploadMiddleware, handleMulterError } from '../middleware/multer.middleware';
// All route handlers now use req.log (request-scoped logger) as per ADR-004
import { userService } from '../services/userService';
// All route handlers now use req.log (request-scoped logger) as per ADR-004
@@ -36,6 +32,7 @@ import {
userSensitiveUpdateLimiter,
userUploadLimiter,
} from '../config/rateLimiters';
import { sendSuccess, sendNoContent, sendError, ErrorCode } from '../utils/apiResponse';
const router = express.Router();
@@ -128,10 +125,14 @@ router.post(
// The try-catch block was already correct here.
try {
// The `requireFileUpload` middleware is not used here, so we must check for `req.file`.
if (!req.file) return res.status(400).json({ message: 'No avatar file uploaded.' });
if (!req.file) return sendError(res, ErrorCode.BAD_REQUEST, 'No avatar file uploaded.', 400);
const userProfile = req.user as UserProfile;
const updatedProfile = await userService.updateUserAvatar(userProfile.user.user_id, req.file, req.log);
res.json(updatedProfile);
const updatedProfile = await userService.updateUserAvatar(
userProfile.user.user_id,
req.file,
req.log,
);
sendSuccess(res, updatedProfile);
} catch (error) {
// If an error occurs after the file has been uploaded (e.g., DB error),
// we must clean up the orphaned file from the disk.
@@ -146,17 +147,14 @@ router.post(
* GET /api/users/notifications - Get notifications for the authenticated user.
* Supports pagination with `limit` and `offset` query parameters.
*/
type GetNotificationsRequest = z.infer<typeof notificationQuerySchema>;
router.get(
'/notifications',
validateRequest(notificationQuerySchema),
async (req: Request, res: Response, next: NextFunction) => {
// Cast to UserProfile to access user properties safely.
const userProfile = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
try {
const { query } = req as unknown as GetNotificationsRequest;
const parsedQuery = notificationQuerySchema.parse({ query: req.query }).query;
const parsedQuery = notificationQuerySchema.shape.query.parse(req.query);
const notifications = await db.notificationRepo.getNotificationsForUser(
userProfile.user.user_id,
parsedQuery.limit!,
@@ -164,7 +162,7 @@ router.get(
parsedQuery.includeRead!,
req.log,
);
res.json(notifications);
sendSuccess(res, notifications);
} catch (error) {
req.log.error({ error }, 'Error fetching notifications');
next(error);
@@ -182,7 +180,7 @@ router.post(
try {
const userProfile = req.user as UserProfile;
await db.notificationRepo.markAllNotificationsAsRead(userProfile.user.user_id, req.log);
res.status(204).send(); // No Content
sendNoContent(res);
} catch (error) {
req.log.error({ error }, 'Error marking all notifications as read');
next(error);
@@ -208,7 +206,7 @@ router.post(
userProfile.user.user_id,
req.log,
);
res.status(204).send(); // Success, no content to return
sendNoContent(res);
} catch (error) {
req.log.error({ error }, 'Error marking notification as read');
next(error);
@@ -230,7 +228,7 @@ router.get('/profile', validateRequest(emptySchema), async (req, res, next: Next
userProfile.user.user_id,
req.log,
);
res.json(fullUserProfile);
sendSuccess(res, fullUserProfile);
} catch (error) {
req.log.error({ error }, `[ROUTE] GET /api/users/profile - ERROR`);
next(error);
@@ -256,7 +254,7 @@ router.put(
body,
req.log,
);
res.json(updatedProfile);
sendSuccess(res, updatedProfile);
} catch (error) {
req.log.error({ error }, `[ROUTE] PUT /api/users/profile - ERROR`);
next(error);
@@ -280,7 +278,7 @@ router.put(
try {
await userService.updateUserPassword(userProfile.user.user_id, body.newPassword, req.log);
res.status(200).json({ message: 'Password updated successfully.' });
sendSuccess(res, { message: 'Password updated successfully.' });
} catch (error) {
req.log.error({ error }, `[ROUTE] PUT /api/users/profile/password - ERROR`);
next(error);
@@ -304,7 +302,7 @@ router.delete(
try {
await userService.deleteUserAccount(userProfile.user.user_id, body.password, req.log);
res.status(200).json({ message: 'Account deleted successfully.' });
sendSuccess(res, { message: 'Account deleted successfully.' });
} catch (error) {
req.log.error({ error }, `[ROUTE] DELETE /api/users/account - ERROR`);
next(error);
@@ -320,7 +318,7 @@ router.get('/watched-items', validateRequest(emptySchema), async (req, res, next
const userProfile = req.user as UserProfile;
try {
const items = await db.personalizationRepo.getWatchedItems(userProfile.user.user_id, req.log);
res.json(items);
sendSuccess(res, items);
} catch (error) {
req.log.error({ error }, `[ROUTE] GET /api/users/watched-items - ERROR`);
next(error);
@@ -347,10 +345,10 @@ router.post(
body.category,
req.log,
);
res.status(201).json(newItem);
sendSuccess(res, newItem, 201);
} catch (error) {
if (error instanceof ForeignKeyConstraintError) {
return res.status(400).json({ message: error.message });
return sendError(res, ErrorCode.BAD_REQUEST, error.message, 400);
}
req.log.error({ error, body: req.body }, 'Failed to add watched item');
next(error);
@@ -378,7 +376,7 @@ router.delete(
params.masterItemId,
req.log,
);
res.status(204).send();
sendNoContent(res);
} catch (error) {
req.log.error({ error }, `[ROUTE] DELETE /api/users/watched-items/:masterItemId - ERROR`);
next(error);
@@ -397,7 +395,7 @@ router.get(
const userProfile = req.user as UserProfile;
try {
const lists = await db.shoppingRepo.getShoppingLists(userProfile.user.user_id, req.log);
res.json(lists);
sendSuccess(res, lists);
} catch (error) {
req.log.error({ error }, `[ROUTE] GET /api/users/shopping-lists - ERROR`);
next(error);
@@ -423,7 +421,7 @@ router.get(
userProfile.user.user_id,
req.log,
);
res.json(list);
sendSuccess(res, list);
} catch (error) {
req.log.error(
{ error, listId: params.listId },
@@ -453,10 +451,10 @@ router.post(
body.name,
req.log,
);
res.status(201).json(newList);
sendSuccess(res, newList, 201);
} catch (error) {
if (error instanceof ForeignKeyConstraintError) {
return res.status(400).json({ message: error.message });
return sendError(res, ErrorCode.BAD_REQUEST, error.message, 400);
}
req.log.error({ error, body: req.body }, 'Failed to create shopping list');
next(error);
@@ -478,7 +476,7 @@ router.delete(
const { params } = req as unknown as GetShoppingListRequest;
try {
await db.shoppingRepo.deleteShoppingList(params.listId, userProfile.user.user_id, req.log);
res.status(204).send();
sendNoContent(res);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
req.log.error(
@@ -524,12 +522,15 @@ router.post(
body,
req.log,
);
res.status(201).json(newItem);
sendSuccess(res, newItem, 201);
} catch (error) {
if (error instanceof ForeignKeyConstraintError) {
return res.status(400).json({ message: error.message });
return sendError(res, ErrorCode.BAD_REQUEST, error.message, 400);
}
req.log.error({ error, params: req.params, body: req.body }, 'Failed to add shopping list item');
req.log.error(
{ error, params: req.params, body: req.body },
'Failed to add shopping list item',
);
next(error);
}
},
@@ -565,7 +566,7 @@ router.put(
body,
req.log,
);
res.json(updatedItem);
sendSuccess(res, updatedItem);
} catch (error: unknown) {
req.log.error(
{ error, params: req.params, body: req.body },
@@ -591,8 +592,12 @@ router.delete(
// Apply ADR-003 pattern for type safety
const { params } = req as unknown as DeleteShoppingListItemRequest;
try {
await db.shoppingRepo.removeShoppingListItem(params.itemId, userProfile.user.user_id, req.log);
res.status(204).send();
await db.shoppingRepo.removeShoppingListItem(
params.itemId,
userProfile.user.user_id,
req.log,
);
sendNoContent(res);
} catch (error: unknown) {
req.log.error(
{ error, params: req.params },
@@ -625,7 +630,7 @@ router.put(
body,
req.log,
);
res.json(updatedProfile);
sendSuccess(res, updatedProfile);
} catch (error) {
req.log.error({ error }, `[ROUTE] PUT /api/users/profile/preferences - ERROR`);
next(error);
@@ -644,7 +649,7 @@ router.get(
userProfile.user.user_id,
req.log,
);
res.json(restrictions);
sendSuccess(res, restrictions);
} catch (error) {
req.log.error({ error }, `[ROUTE] GET /api/users/me/dietary-restrictions - ERROR`);
next(error);
@@ -671,10 +676,10 @@ router.put(
body.restrictionIds,
req.log,
);
res.status(204).send();
sendNoContent(res);
} catch (error) {
if (error instanceof ForeignKeyConstraintError) {
return res.status(400).json({ message: error.message });
return sendError(res, ErrorCode.BAD_REQUEST, error.message, 400);
}
req.log.error({ error, body: req.body }, 'Failed to set user dietary restrictions');
next(error);
@@ -690,7 +695,7 @@ router.get('/me/appliances', validateRequest(emptySchema), async (req, res, next
userProfile.user.user_id,
req.log,
);
res.json(appliances);
sendSuccess(res, appliances);
} catch (error) {
req.log.error({ error }, `[ROUTE] GET /api/users/me/appliances - ERROR`);
next(error);
@@ -716,10 +721,10 @@ router.put(
body.applianceIds,
req.log,
);
res.status(204).send();
sendNoContent(res);
} catch (error) {
if (error instanceof ForeignKeyConstraintError) {
return res.status(400).json({ message: error.message });
return sendError(res, ErrorCode.BAD_REQUEST, error.message, 400);
}
req.log.error({ error, body: req.body }, 'Failed to set user appliances');
next(error);
@@ -743,7 +748,7 @@ router.get(
try {
const addressId = params.addressId;
const address = await userService.getUserAddress(userProfile, addressId, req.log);
res.json(address);
sendSuccess(res, address);
} catch (error) {
req.log.error({ error }, 'Error fetching user address');
next(error);
@@ -783,7 +788,7 @@ router.put(
// encapsulated in a single service method that manages the transaction.
// This ensures both the address upsert and the user profile update are atomic.
const addressId = await userService.upsertUserAddress(userProfile, addressData, req.log); // This was a duplicate, fixed.
res.status(200).json({ message: 'Address updated successfully', address_id: addressId });
sendSuccess(res, { message: 'Address updated successfully', address_id: addressId });
} catch (error) {
req.log.error({ error }, 'Error updating user address');
next(error);
@@ -803,12 +808,12 @@ router.post(
const { body } = req as unknown as z.infer<typeof createRecipeSchema>;
try {
const recipe = await db.recipeRepo.createRecipe(userProfile.user.user_id, body, req.log);
res.status(201).json(recipe);
sendSuccess(res, recipe, 201);
} catch (error) {
req.log.error({ error }, 'Error creating recipe');
next(error);
}
}
},
);
/**
@@ -827,7 +832,7 @@ router.delete(
const { params } = req as unknown as DeleteRecipeRequest;
try {
await db.recipeRepo.deleteRecipe(params.recipeId, userProfile.user.user_id, false, req.log);
res.status(204).send();
sendNoContent(res);
} catch (error) {
req.log.error(
{ error, params: req.params },
@@ -872,7 +877,7 @@ router.put(
body,
req.log,
);
res.json(updatedRecipe);
sendSuccess(res, updatedRecipe);
} catch (error) {
req.log.error(
{ error, params: req.params, body: req.body },

View File

@@ -23,6 +23,9 @@ export class FlyerPersistenceService {
* @internal
*/
_setWithTransaction(fn: WithTransactionFn): void {
console.error(
`[DEBUG] FlyerPersistenceService._setWithTransaction called, replacing withTransaction function`,
);
this.withTransaction = fn;
}
@@ -36,6 +39,12 @@ export class FlyerPersistenceService {
userId: string | undefined,
logger: Logger,
): Promise<Flyer> {
console.error(
`[DEBUG] FlyerPersistenceService.saveFlyer called, about to invoke withTransaction`,
);
console.error(
`[DEBUG] withTransaction function name: ${this.withTransaction.name || 'anonymous'}`,
);
const flyer = await this.withTransaction(async (client) => {
const { flyer, items } = await createFlyerAndItems(flyerData, itemsForDb, logger, client);

View File

@@ -602,9 +602,11 @@ describe('Flyer Processing Background Job Integration Test', () => {
// the worker imports the real module before our mock is applied.
const dbError = new Error('DB transaction failed');
const failingWithTransaction = vi.fn().mockRejectedValue(dbError);
console.error('[DB FAILURE TEST] About to inject failingWithTransaction mock');
workersModule.flyerProcessingService
._getPersistenceService()
._setWithTransaction(failingWithTransaction);
console.error('[DB FAILURE TEST] failingWithTransaction mock injected successfully');
// Arrange: Prepare a unique flyer file for upload.
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');

View File

@@ -1,5 +1,7 @@
// src/tests/setup/integration-global-setup.ts
import { execSync } from 'child_process';
import fs from 'node:fs/promises';
import path from 'path';
import type { Server } from 'http';
import { logger } from '../../services/logger.server';
import { getPool } from '../../services/db/connection.db';
@@ -18,28 +20,49 @@ async function cleanAllQueues() {
console.error(`[PID:${process.pid}] [QUEUE CLEANUP] Starting BullMQ queue cleanup...`);
try {
const { flyerQueue, cleanupQueue, emailQueue, analyticsQueue, weeklyAnalyticsQueue, tokenCleanupQueue } = await import('../../services/queues.server');
const {
flyerQueue,
cleanupQueue,
emailQueue,
analyticsQueue,
weeklyAnalyticsQueue,
tokenCleanupQueue,
} = await import('../../services/queues.server');
console.error(`[QUEUE CLEANUP] Successfully imported queue modules`);
const queues = [flyerQueue, cleanupQueue, emailQueue, analyticsQueue, weeklyAnalyticsQueue, tokenCleanupQueue];
const queues = [
flyerQueue,
cleanupQueue,
emailQueue,
analyticsQueue,
weeklyAnalyticsQueue,
tokenCleanupQueue,
];
for (const queue of queues) {
try {
// Log queue state before cleanup
const jobCounts = await queue.getJobCounts();
console.error(`[QUEUE CLEANUP] Queue "${queue.name}" before cleanup: ${JSON.stringify(jobCounts)}`);
console.error(
`[QUEUE CLEANUP] Queue "${queue.name}" before cleanup: ${JSON.stringify(jobCounts)}`,
);
// obliterate() removes ALL data associated with the queue from Redis
await queue.obliterate({ force: true });
console.error(` ✅ [QUEUE CLEANUP] Cleaned queue: ${queue.name}`);
} catch (error) {
// Log but don't fail - the queue might not exist yet
console.error(` ⚠️ [QUEUE CLEANUP] Could not clean queue ${queue.name}: ${error instanceof Error ? error.message : 'Unknown error'}`);
console.error(
` ⚠️ [QUEUE CLEANUP] Could not clean queue ${queue.name}: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}
}
console.error(`✅ [PID:${process.pid}] [QUEUE CLEANUP] All queues cleaned successfully.`);
} catch (error) {
console.error(`❌ [PID:${process.pid}] [QUEUE CLEANUP] CRITICAL ERROR during queue cleanup:`, error);
console.error(
`❌ [PID:${process.pid}] [QUEUE CLEANUP] CRITICAL ERROR during queue cleanup:`,
error,
);
// Don't throw - we want the tests to continue even if cleanup fails
}
}
@@ -50,7 +73,24 @@ export async function setup() {
// Fix: Set the FRONTEND_URL globally for the test server instance
process.env.FRONTEND_URL = 'https://example.com';
// CRITICAL: Set STORAGE_PATH before importing the server.
// The multer middleware runs an IIFE on import that creates directories based on this path.
// If not set, it defaults to /var/www/.../flyer-images which won't exist in the test environment.
if (!process.env.STORAGE_PATH) {
// Use path relative to the project root (where tests run from)
process.env.STORAGE_PATH = path.resolve(process.cwd(), 'flyer-images');
}
// Ensure the storage directories exist before the server starts
try {
await fs.mkdir(path.join(process.env.STORAGE_PATH, 'icons'), { recursive: true });
console.error(`[SETUP] Created storage directory: ${process.env.STORAGE_PATH}`);
} catch (error) {
console.error(`[SETUP] Warning: Could not create storage directory: ${error}`);
}
console.error(`\n--- [PID:${process.pid}] Running Integration Test GLOBAL Setup ---`);
console.error(`[SETUP] STORAGE_PATH: ${process.env.STORAGE_PATH}`);
console.error(`[SETUP] REDIS_URL: ${process.env.REDIS_URL}`);
console.error(`[SETUP] REDIS_PASSWORD is set: ${!!process.env.REDIS_PASSWORD}`);

165
src/types/api.ts Normal file
View File

@@ -0,0 +1,165 @@
// src/types/api.ts
// ============================================================================
// API RESPONSE TYPE DEFINITIONS
// ============================================================================
// Standardized response types for all API endpoints per ADR-028.
// These types ensure consistent response structure across the entire API.
// ============================================================================
/**
* Standard pagination metadata included in paginated responses.
*/
export interface PaginationMeta {
/** Current page number (1-indexed) */
page: number;
/** Number of items per page */
limit: number;
/** Total number of items across all pages */
total: number;
/** Total number of pages */
totalPages: number;
/** Whether there is a next page */
hasNextPage: boolean;
/** Whether there is a previous page */
hasPrevPage: boolean;
}
/**
* Optional metadata that can be included in any response.
*/
export interface ResponseMeta {
/** Unique request identifier for tracking/debugging */
requestId?: string;
/** ISO timestamp of when the response was generated */
timestamp?: string;
/** Pagination info (only for paginated responses) */
pagination?: PaginationMeta;
}
/**
* Standard success response envelope.
* All successful API responses should follow this structure.
*
* @example
* // Single item response
* {
* "success": true,
* "data": { "id": 1, "name": "Item" }
* }
*
* @example
* // Paginated list response
* {
* "success": true,
* "data": [{ "id": 1 }, { "id": 2 }],
* "meta": {
* "pagination": { "page": 1, "limit": 20, "total": 100, ... }
* }
* }
*/
export interface ApiSuccessResponse<T> {
success: true;
data: T;
meta?: ResponseMeta;
}
/**
* Standard error response envelope.
* All error responses should follow this structure.
*
* @example
* // Validation error
* {
* "success": false,
* "error": {
* "code": "VALIDATION_ERROR",
* "message": "The request data is invalid.",
* "details": [{ "path": ["email"], "message": "Invalid email format" }]
* }
* }
*
* @example
* // Not found error
* {
* "success": false,
* "error": {
* "code": "NOT_FOUND",
* "message": "User not found"
* }
* }
*/
export interface ApiErrorResponse {
success: false;
error: {
/** Machine-readable error code (e.g., 'VALIDATION_ERROR', 'NOT_FOUND') */
code: string;
/** Human-readable error message */
message: string;
/** Additional error details (validation errors, etc.) */
details?: unknown;
};
meta?: Pick<ResponseMeta, 'requestId' | 'timestamp'>;
}
/**
* Union type for all API responses.
* Useful for frontend type narrowing based on `success` field.
*/
export type ApiResponse<T> = ApiSuccessResponse<T> | ApiErrorResponse;
// ============================================================================
// ERROR CODES
// ============================================================================
// Standardized error codes for consistent error identification.
// ============================================================================
/**
* Standard error codes used across the API.
* These should be used with the `sendError` helper function.
*/
export const ErrorCode = {
// Client errors (4xx)
VALIDATION_ERROR: 'VALIDATION_ERROR',
NOT_FOUND: 'NOT_FOUND',
UNAUTHORIZED: 'UNAUTHORIZED',
FORBIDDEN: 'FORBIDDEN',
CONFLICT: 'CONFLICT',
BAD_REQUEST: 'BAD_REQUEST',
RATE_LIMITED: 'RATE_LIMITED',
PAYLOAD_TOO_LARGE: 'PAYLOAD_TOO_LARGE',
// Server errors (5xx)
INTERNAL_ERROR: 'INTERNAL_ERROR',
SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE',
EXTERNAL_SERVICE_ERROR: 'EXTERNAL_SERVICE_ERROR',
NOT_IMPLEMENTED: 'NOT_IMPLEMENTED',
} as const;
export type ErrorCodeType = (typeof ErrorCode)[keyof typeof ErrorCode];
// ============================================================================
// HELPER TYPES
// ============================================================================
/**
* Input for creating paginated responses.
*/
export interface PaginationInput {
page: number;
limit: number;
total: number;
}
/**
* Type guard to check if a response is a success response.
*/
export function isApiSuccess<T>(response: ApiResponse<T>): response is ApiSuccessResponse<T> {
return response.success === true;
}
/**
* Type guard to check if a response is an error response.
*/
export function isApiError<T>(response: ApiResponse<T>): response is ApiErrorResponse {
return response.success === false;
}

183
src/utils/apiResponse.ts Normal file
View File

@@ -0,0 +1,183 @@
// src/utils/apiResponse.ts
// ============================================================================
// API RESPONSE HELPERS
// ============================================================================
// Utility functions for creating standardized API responses per ADR-028.
// Use these helpers in all route handlers for consistent response formats.
// ============================================================================
import { Response } from 'express';
import {
ApiSuccessResponse,
ApiErrorResponse,
PaginationInput,
PaginationMeta,
ResponseMeta,
ErrorCodeType,
ErrorCode,
} from '../types/api';
/**
* Send a successful response with data.
*
* @param res - Express response object
* @param data - The response data
* @param statusCode - HTTP status code (default: 200)
* @param meta - Optional metadata (requestId, timestamp)
*
* @example
* // Simple success response
* sendSuccess(res, { id: 1, name: 'Item' });
*
* @example
* // Success with 201 Created
* sendSuccess(res, newUser, 201);
*/
export function sendSuccess<T>(
res: Response,
data: T,
statusCode: number = 200,
meta?: Omit<ResponseMeta, 'pagination'>,
): Response<ApiSuccessResponse<T>> {
const response: ApiSuccessResponse<T> = {
success: true,
data,
};
if (meta) {
response.meta = meta;
}
return res.status(statusCode).json(response);
}
/**
* Send a successful response with no content (204).
* Used for DELETE operations or actions that don't return data.
*
* @param res - Express response object
*
* @example
* // After deleting a resource
* sendNoContent(res);
*/
export function sendNoContent(res: Response): Response {
return res.status(204).send();
}
/**
* Calculate pagination metadata from input parameters.
*
* @param input - Pagination input (page, limit, total)
* @returns Calculated pagination metadata
*/
export function calculatePagination(input: PaginationInput): PaginationMeta {
const { page, limit, total } = input;
const totalPages = Math.ceil(total / limit);
return {
page,
limit,
total,
totalPages,
hasNextPage: page < totalPages,
hasPrevPage: page > 1,
};
}
/**
* Send a paginated success response.
*
* @param res - Express response object
* @param data - The array of items for the current page
* @param pagination - Pagination input (page, limit, total)
* @param meta - Optional additional metadata
*
* @example
* const { flyers, total } = await flyerService.getFlyers({ page, limit });
* sendPaginated(res, flyers, { page, limit, total });
*/
export function sendPaginated<T>(
res: Response,
data: T[],
pagination: PaginationInput,
meta?: Omit<ResponseMeta, 'pagination'>,
): Response<ApiSuccessResponse<T[]>> {
const response: ApiSuccessResponse<T[]> = {
success: true,
data,
meta: {
...meta,
pagination: calculatePagination(pagination),
},
};
return res.status(200).json(response);
}
/**
* Send an error response.
*
* @param res - Express response object
* @param code - Machine-readable error code
* @param message - Human-readable error message
* @param statusCode - HTTP status code (default: 400)
* @param details - Optional error details (validation errors, etc.)
* @param meta - Optional metadata (requestId for error tracking)
*
* @example
* // Validation error
* sendError(res, ErrorCode.VALIDATION_ERROR, 'Invalid email format', 400, errors);
*
* @example
* // Not found error
* sendError(res, ErrorCode.NOT_FOUND, 'User not found', 404);
*/
export function sendError(
res: Response,
code: ErrorCodeType | string,
message: string,
statusCode: number = 400,
details?: unknown,
meta?: Pick<ResponseMeta, 'requestId' | 'timestamp'>,
): Response<ApiErrorResponse> {
const response: ApiErrorResponse = {
success: false,
error: {
code,
message,
},
};
if (details !== undefined) {
response.error.details = details;
}
if (meta) {
response.meta = meta;
}
return res.status(statusCode).json(response);
}
/**
* Send a message-only success response.
* Useful for operations that complete successfully but don't return data.
*
* @param res - Express response object
* @param message - Success message
* @param statusCode - HTTP status code (default: 200)
*
* @example
* sendMessage(res, 'Password updated successfully');
*/
export function sendMessage(
res: Response,
message: string,
statusCode: number = 200,
): Response<ApiSuccessResponse<{ message: string }>> {
return sendSuccess(res, { message }, statusCode);
}
// Re-export ErrorCode for convenience
export { ErrorCode };