Compare commits
2 Commits
5173059621
...
3912139273
| Author | SHA1 | Date | |
|---|---|---|---|
| 3912139273 | |||
| b5f7f5e4d1 |
@@ -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
|
||||
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
};
|
||||
@@ -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) => {
|
||||
try {
|
||||
const items = await db.adminRepo.getUnmatchedFlyerItems(req.log);
|
||||
res.json(items);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching unmatched items');
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
router.get(
|
||||
'/unmatched-items',
|
||||
validateRequest(emptySchema),
|
||||
async (req, res, next: NextFunction) => {
|
||||
try {
|
||||
const items = await db.adminRepo.getUnmatchedFlyerItems(req.log);
|
||||
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({
|
||||
message:
|
||||
'Daily deal check job has been triggered successfully. It will run in the background.',
|
||||
});
|
||||
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({
|
||||
message: `Analytics report generation job has been enqueued successfully. Job ID: ${jobId}`,
|
||||
});
|
||||
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);
|
||||
@@ -512,22 +528,24 @@ router.post(
|
||||
adminTriggerLimiter,
|
||||
validateRequest(emptySchema),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const userProfile = req.user as UserProfile;
|
||||
req.log.info(
|
||||
`[Admin] Manual trigger for a failing job received from user: ${userProfile.user.user_id}`,
|
||||
);
|
||||
const userProfile = req.user as UserProfile;
|
||||
req.log.info(
|
||||
`[Admin] Manual trigger for a failing job received from user: ${userProfile.user.user_id}`,
|
||||
);
|
||||
|
||||
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}` });
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error enqueuing failing job');
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
try {
|
||||
// Add a job with a special 'forceFail' flag that the worker will recognize.
|
||||
const job = await analyticsQueue.add('generate-daily-report', { reportDate: 'FAIL' });
|
||||
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) => {
|
||||
try {
|
||||
const workerStatuses = await monitoringService.getWorkerStatuses();
|
||||
res.json(workerStatuses);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching worker statuses');
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
router.get(
|
||||
'/workers/status',
|
||||
validateRequest(emptySchema),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const workerStatuses = await monitoringService.getWorkerStatuses();
|
||||
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) => {
|
||||
try {
|
||||
const queueStatuses = await monitoringService.getQueueStatuses();
|
||||
res.json(queueStatuses);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching queue statuses');
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
router.get(
|
||||
'/queues/status',
|
||||
validateRequest(emptySchema),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const queueStatuses = await monitoringService.getQueueStatuses();
|
||||
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;
|
||||
|
||||
@@ -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({
|
||||
message: 'Flyer accepted for processing.',
|
||||
jobId: job.id,
|
||||
});
|
||||
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 {
|
||||
|
||||
@@ -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) => {
|
||||
const { refreshToken } = req.cookies;
|
||||
if (!refreshToken) {
|
||||
return res.status(401).json({ message: 'Refresh token not found.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authService.refreshAccessToken(refreshToken, req.log);
|
||||
if (!result) {
|
||||
return res.status(403).json({ message: 'Invalid or expired refresh token.' });
|
||||
router.post(
|
||||
'/refresh-token',
|
||||
refreshTokenLimiter,
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const { refreshToken } = req.cookies;
|
||||
if (!refreshToken) {
|
||||
return sendError(res, ErrorCode.UNAUTHORIZED, 'Refresh token not found.', 401);
|
||||
}
|
||||
res.json({ token: result.accessToken });
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'An error occurred during /refresh-token.');
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await authService.refreshAccessToken(refreshToken, req.log);
|
||||
if (!result) {
|
||||
return sendError(res, ErrorCode.FORBIDDEN, 'Invalid or expired refresh token.', 403);
|
||||
}
|
||||
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 ---
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> => {
|
||||
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);
|
||||
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);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, 'Error fetching flyers in /api/flyers:');
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
const flyers = await db.flyerRepo.getFlyers(req.log, limit, offset);
|
||||
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> => {
|
||||
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);
|
||||
} catch (error) {
|
||||
req.log.error({ error, flyerId: req.params.id }, 'Error fetching flyer by ID:');
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
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);
|
||||
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,22 +159,27 @@ 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 => {
|
||||
try {
|
||||
// Explicitly parse to get coerced types.
|
||||
const { params, body } = trackItemSchema.parse({ params: req.params, body: req.body });
|
||||
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 });
|
||||
|
||||
// Fire-and-forget: we don't await the tracking call to avoid delaying the response.
|
||||
// We add a .catch to log any potential errors without crashing the server process.
|
||||
db.flyerRepo.trackFlyerItemInteraction(params.itemId, body.type, req.log).catch((error) => {
|
||||
req.log.error({ error, itemId: params.itemId }, 'Flyer item interaction tracking failed');
|
||||
});
|
||||
// Fire-and-forget: we don't await the tracking call to avoid delaying the response.
|
||||
// We add a .catch to log any potential errors without crashing the server process.
|
||||
db.flyerRepo.trackFlyerItemInteraction(params.itemId, body.type, req.log).catch((error) => {
|
||||
req.log.error({ error, itemId: params.itemId }, 'Flyer item interaction tracking failed');
|
||||
});
|
||||
|
||||
res.status(202).send();
|
||||
} catch (error) {
|
||||
// This will catch Zod parsing errors if they occur.
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
sendSuccess(res, { message: 'Tracking accepted' }, 202);
|
||||
} catch (error) {
|
||||
// This will catch Zod parsing errors if they occur.
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -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,11 +121,9 @@ adminGamificationRouter.post(
|
||||
const { body } = req as unknown as AwardAchievementRequest;
|
||||
try {
|
||||
await gamificationService.awardAchievement(body.userId, body.achievementName, req.log);
|
||||
res
|
||||
.status(200)
|
||||
.json({
|
||||
message: `Successfully awarded '${body.achievementName}' to user ${body.userId}.`,
|
||||
});
|
||||
sendSuccess(res, {
|
||||
message: `Successfully awarded '${body.achievementName}' to user ${body.userId}.`,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -6,16 +6,15 @@ 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({
|
||||
message: 'masterItemIds must be a non-empty array of positive integers.',
|
||||
}),
|
||||
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 }),
|
||||
offset: optionalNumeric({ default: 0, integer: true, nonnegative: 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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) => {
|
||||
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);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, `Error fetching comments for recipe ID ${req.params.recipeId}:`);
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
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);
|
||||
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) => {
|
||||
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);
|
||||
} catch (error) {
|
||||
req.log.error({ error }, `Error fetching recipe ID ${req.params.recipeId}:`);
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
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);
|
||||
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);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
165
src/types/api.ts
Normal 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
183
src/utils/apiResponse.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user