feat: Update AI service to use new Google Generative AI SDK
Some checks are pending
Deploy to Test Environment / deploy-to-test (push) Has started running

- Refactored AIService to integrate with the latest GoogleGenAI SDK, updating the generateContent method signature and response handling.
- Adjusted error handling and logging for improved clarity and consistency.
- Enhanced mock implementations in tests to align with the new SDK structure.
refactor: Modify Admin DB service to use Profile type
- Updated AdminRepository to replace User type with Profile in relevant methods.
- Enhanced test cases to utilize mock factories for creating Profile and AdminUserView objects.
fix: Improve error handling in BudgetRepository
- Implemented type-safe checks for PostgreSQL error codes to enhance error handling in createBudget method.
test: Refactor Deals DB tests for type safety
- Updated DealsRepository tests to use Pool type for mock instances, ensuring type safety.
chore: Add new mock factories for testing
- Introduced mock factories for UserWithPasswordHash, Profile, WatchedItemDeal, LeaderboardUser, and UnmatchedFlyerItem to streamline test data creation.
style: Clean up queue service tests
- Refactored queue service tests to improve readability and maintainability, including better handling of mock worker instances.
docs: Update types to include UserWithPasswordHash
- Added UserWithPasswordHash interface to types for better clarity on user authentication data structure.
chore: Remove deprecated Google AI SDK references
- Updated code and documentation to reflect the migration to the new Google Generative AI SDK, removing references to the deprecated SDK.
This commit is contained in:
2025-12-14 17:14:44 -08:00
parent eb0e183f61
commit f73b1422ab
42 changed files with 530 additions and 365 deletions

View File

@@ -1,9 +1,9 @@
// src/routes/admin.content.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express';
import adminRouter from './admin.routes';
import { createMockUserProfile, createMockSuggestedCorrection, createMockBrand, createMockRecipe, createMockRecipeComment, createMockFlyerItem } from '../tests/utils/mockFactories';
import { Request, Response, NextFunction } from 'express';
import adminRouter from './admin.routes'; // This was a duplicate, fixed.
import { createMockUserProfile, createMockSuggestedCorrection, createMockBrand, createMockRecipe, createMockRecipeComment, createMockUnmatchedFlyerItem } from '../tests/utils/mockFactories';
import { SuggestedCorrection, Brand, UserProfile, UnmatchedFlyerItem } from '../types';
import { NotFoundError } from '../services/db/errors.db';
import { mockLogger } from '../tests/utils/mockLogger';
@@ -235,15 +235,7 @@ describe('Admin Content Management Routes (/api/admin)', () => {
describe('Unmatched Items Route', () => {
it('GET /unmatched-items should return a list of unmatched items', async () => {
// Correctly create a mock for UnmatchedFlyerItem.
// It's a combination of a flyer item and some context, including the 'flyer_item_name'.
const mockFlyerItem = createMockFlyerItem({ flyer_item_id: 101, item: 'Mystery Item' });
const mockUnmatchedItems: UnmatchedFlyerItem[] = [{
...mockFlyerItem,
unmatched_flyer_item_id: 1,
status: 'pending',
store_name: 'Test Store',
flyer_item_name: mockFlyerItem.item, // Add the missing required property
}];
const mockUnmatchedItems: UnmatchedFlyerItem[] = [createMockUnmatchedFlyerItem({ unmatched_flyer_item_id: 1, flyer_item_name: 'Mystery Item' })];
vi.mocked(mockedDb.adminRepo.getUnmatchedFlyerItems).mockResolvedValue(mockUnmatchedItems);
const response = await supertest(app).get('/api/admin/unmatched-items');
expect(response.status).toBe(200);

View File

@@ -1,16 +1,7 @@
// --- FIX REGISTRY ---
//
// 1) Fixed `vi.mock('child_process')` to use a simple factory pattern for `exec` to avoid default export issues and ensure proper mocking.
//
// 2) Updated `vi.mock` for `../services/db/index.db` to use a simple module mock.
// The previous incomplete factory mock was causing 'undefined' errors for named exports
// (like repository classes) that other modules depended on.
// --- END FIX REGISTRY ---
// src/routes/admin.jobs.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express';
import { Request, Response, NextFunction } from 'express';
import adminRouter from './admin.routes';
import { createMockUserProfile } from '../tests/utils/mockFactories';
import { Job } from 'bullmq';

View File

@@ -1,19 +1,7 @@
// src/routes/admin.monitoring.routes.test.ts
// --- FIX REGISTRY ---
//
// 1) Added `weeklyAnalyticsQueue` and `weeklyAnalyticsWorker` to the `queueService.server` mock.
// These were missing, causing an import error in `admin.routes.ts` which depends on them.
//
// 2) Updated `vi.mock` for `../services/db/index.db` to use `vi.hoisted` and `importOriginal`.
// This preserves named exports (like repository classes) from the original module,
// fixing 'undefined' errors when other modules tried to import them from the mock.
//
// 3) Added `weeklyAnalyticsQueue` and `weeklyAnalyticsWorker` to the `queueService.server` mock.
//
// --- END FIX REGISTRY ---
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express';
import { Request, Response, NextFunction } from 'express';
import adminRouter from './admin.routes';
import { createMockUserProfile, createMockActivityLogItem } from '../tests/utils/mockFactories';
import { UserProfile } from '../types';

View File

@@ -149,10 +149,10 @@ router.get('/stats/daily', async (req, res, next: NextFunction) => {
});
router.post('/corrections/:id/approve', validateRequest(numericIdParamSchema('id')), async (req: Request, res: Response, next: NextFunction) => {
type ApproveCorrectionRequest = z.infer<typeof updateCorrectionSchema>;
const { params } = req as unknown as ApproveCorrectionRequest;
// Apply ADR-003 pattern for type safety
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>;
try {
await db.adminRepo.approveCorrection(params.id, req.log);
await db.adminRepo.approveCorrection(params.id, req.log); // params.id is now safely typed as number
res.status(200).json({ message: 'Correction approved successfully.' });
} catch (error) {
next(error);
@@ -160,10 +160,10 @@ router.post('/corrections/:id/approve', validateRequest(numericIdParamSchema('id
});
router.post('/corrections/:id/reject', validateRequest(numericIdParamSchema('id')), async (req: Request, res: Response, next: NextFunction) => {
type RejectCorrectionRequest = z.infer<typeof updateCorrectionSchema>;
const { params } = req as unknown as RejectCorrectionRequest;
// Apply ADR-003 pattern for type safety
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>;
try {
await db.adminRepo.rejectCorrection(params.id, req.log);
await db.adminRepo.rejectCorrection(params.id, req.log); // params.id is now safely typed as number
res.status(200).json({ message: 'Correction rejected successfully.' });
} catch (error) {
next(error);
@@ -171,8 +171,8 @@ router.post('/corrections/:id/reject', validateRequest(numericIdParamSchema('id'
});
router.put('/corrections/:id', validateRequest(updateCorrectionSchema), async (req: Request, res: Response, next: NextFunction) => {
type UpdateCorrectionRequest = z.infer<typeof updateCorrectionSchema>;
const { params, body } = req as unknown as UpdateCorrectionRequest;
// Apply ADR-003 pattern for type safety
const { params, body } = req as unknown as z.infer<typeof updateCorrectionSchema>;
try {
const updatedCorrection = await db.adminRepo.updateSuggestedCorrection(params.id, body.suggested_value, req.log);
res.status(200).json(updatedCorrection);
@@ -182,8 +182,8 @@ router.put('/corrections/:id', validateRequest(updateCorrectionSchema), async (r
});
router.put('/recipes/:id/status', validateRequest(updateRecipeStatusSchema), async (req: Request, res: Response, next: NextFunction) => {
type UpdateRecipeStatusRequest = z.infer<typeof updateRecipeStatusSchema>;
const { params, body } = req as unknown as UpdateRecipeStatusRequest;
// Apply ADR-003 pattern for type safety
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);
@@ -193,6 +193,7 @@ router.put('/recipes/:id/status', validateRequest(updateRecipeStatusSchema), asy
});
router.post('/brands/:id/logo', validateRequest(numericIdParamSchema('id')), upload.single('logoImage'), requireFileUpload('logoImage'), async (req: Request, res: Response, next: NextFunction) => {
// Apply ADR-003 pattern for type safety
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>;
try {
// Although requireFileUpload middleware should ensure the file exists,
@@ -223,8 +224,10 @@ router.get('/unmatched-items', async (req, res, next: NextFunction) => {
* DELETE /api/admin/recipes/:recipeId - Admin endpoint to delete any recipe.
*/
router.delete('/recipes/:recipeId', validateRequest(numericIdParamSchema('recipeId')), async (req: Request, res: Response, next: NextFunction) => {
// Define schema locally to simplify type inference
const schema = numericIdParamSchema('recipeId');
const adminUser = req.user as UserProfile;
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>;
const { params } = req as unknown as z.infer<typeof schema>;
try {
// The isAdmin flag bypasses the ownership check in the repository method.
await db.recipeRepo.deleteRecipe(params.recipeId, adminUser.user_id, true, req.log);
@@ -238,7 +241,9 @@ router.delete('/recipes/:recipeId', validateRequest(numericIdParamSchema('recipe
* DELETE /api/admin/flyers/:flyerId - Admin endpoint to delete a flyer and its items.
*/
router.delete('/flyers/:flyerId', validateRequest(numericIdParamSchema('flyerId')), async (req: Request, res: Response, next: NextFunction) => {
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>;
// Define schema locally to simplify type inference
const schema = numericIdParamSchema('flyerId');
const { params } = req as unknown as z.infer<typeof schema>;
try {
await db.flyerRepo.deleteFlyer(params.flyerId, req.log);
res.status(204).send();
@@ -248,8 +253,8 @@ router.delete('/flyers/:flyerId', validateRequest(numericIdParamSchema('flyerId'
});
router.put('/comments/:id/status', validateRequest(updateCommentStatusSchema), async (req: Request, res: Response, next: NextFunction) => {
type UpdateCommentStatusRequest = z.infer<typeof updateCommentStatusSchema>;
const { params, body } = req as unknown as UpdateCommentStatusRequest;
// Apply ADR-003 pattern for type safety
const { params, body } = req as unknown as z.infer<typeof updateCommentStatusSchema>;
try {
const updatedComment = await db.adminRepo.updateRecipeCommentStatus(params.id, body.status, req.log); // This is still a standalone function in admin.db.ts
res.status(200).json(updatedComment);
@@ -268,8 +273,8 @@ router.get('/users', async (req, res, next: NextFunction) => {
});
router.get('/activity-log', validateRequest(activityLogSchema), async (req: Request, res: Response, next: NextFunction) => {
type ActivityLogRequest = z.infer<typeof activityLogSchema>;
const { query } = req as unknown as ActivityLogRequest;
// Apply ADR-003 pattern for type safety
const { query } = req as unknown as z.infer<typeof activityLogSchema>;
try {
const logs = await db.adminRepo.getActivityLog(query.limit, query.offset, req.log);
res.json(logs);
@@ -279,7 +284,8 @@ router.get('/activity-log', validateRequest(activityLogSchema), async (req: Requ
});
router.get('/users/:id', validateRequest(uuidParamSchema('id')), async (req: Request, res: Response, next: NextFunction) => {
const { params } = req as z.infer<ReturnType<typeof uuidParamSchema>>;
// Apply ADR-003 pattern for type safety
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);
@@ -289,8 +295,8 @@ router.get('/users/:id', validateRequest(uuidParamSchema('id')), async (req: Req
});
router.put('/users/:id', validateRequest(updateUserRoleSchema), async (req: Request, res: Response, next: NextFunction) => {
type UpdateUserRoleRequest = z.infer<typeof updateUserRoleSchema>;
const { params, body } = req as unknown as UpdateUserRoleRequest;
// Apply ADR-003 pattern for type safety
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);
@@ -302,7 +308,8 @@ router.put('/users/:id', validateRequest(updateUserRoleSchema), async (req: Requ
router.delete('/users/:id', validateRequest(uuidParamSchema('id')), async (req: Request, res: Response, next: NextFunction) => {
const adminUser = req.user as UserProfile;
const { params } = req as z.infer<ReturnType<typeof uuidParamSchema>>;
// Apply ADR-003 pattern for type safety
const { params } = req as unknown as z.infer<ReturnType<typeof uuidParamSchema>>;
try {
if (adminUser.user.user_id === params.id) {
throw new ValidationError([], 'Admins cannot delete their own account.');
@@ -360,8 +367,10 @@ router.post('/trigger/analytics-report', async (req: Request, res: Response, nex
* This is triggered by an admin after they have verified the flyer processing was successful.
*/
router.post('/flyers/:flyerId/cleanup', validateRequest(numericIdParamSchema('flyerId')), async (req: Request, res: Response, next: NextFunction) => {
// Define schema locally to simplify type inference
const schema = numericIdParamSchema('flyerId');
const adminUser = req.user as UserProfile;
const { params } = req as unknown as z.infer<ReturnType<typeof numericIdParamSchema>>;
const { params } = req as unknown as z.infer<typeof schema>;
logger.info(`[Admin] Manual trigger for flyer file cleanup received from user: ${adminUser.user_id} for flyer ID: ${params.flyerId}`);
// Enqueue the cleanup job. The worker will handle the file deletion.

View File

@@ -1,7 +1,7 @@
// src/routes/admin.stats.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express';
import { Request, Response, NextFunction } from 'express';
import adminRouter from './admin.routes';
import { createMockUserProfile } from '../tests/utils/mockFactories';
import { UserProfile } from '../types';

View File

@@ -1,7 +1,7 @@
// src/routes/admin.system.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express';
import { Request, Response, NextFunction } from 'express';
import adminRouter from './admin.routes';
import { createMockUserProfile } from '../tests/utils/mockFactories';
import { createTestApp } from '../tests/utils/createTestApp';

View File

@@ -1,10 +1,10 @@
// src/routes/admin.users.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express';
import adminRouter from './admin.routes';
import { createMockUserProfile } from '../tests/utils/mockFactories';
import { User, UserProfile } from '../types';
import { Request, Response, NextFunction } from 'express';
import adminRouter from './admin.routes'; // This was a duplicate, fixed.
import { createMockUserProfile, createMockAdminUserView } from '../tests/utils/mockFactories';
import { User, UserProfile, Profile } from '../types';
import { NotFoundError } from '../services/db/errors.db';
import { mockLogger } from '../tests/utils/mockLogger';
import { createTestApp } from '../tests/utils/createTestApp';
@@ -85,19 +85,10 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
describe('GET /users', () => {
it('should return a list of all users on success', async () => {
// Define a specific type for the user list returned by the admin endpoint
// to avoid using `any` and improve test type safety.
type UserSummary = {
user_id: string;
email: string;
role: 'user' | 'admin';
created_at: string;
full_name: string | null;
avatar_url: string | null;
};
const mockUsers: UserSummary[] = [
{ user_id: '1', email: 'user1@test.com', role: 'user', created_at: new Date().toISOString(), full_name: 'User One', avatar_url: null },
{ user_id: '2', email: 'user2@test.com', role: 'admin', created_at: new Date().toISOString(), full_name: 'Admin Two', avatar_url: null },
// Use the mock factory to create consistent test data.
const mockUsers = [
createMockAdminUserView({ user_id: '1', email: 'user1@test.com', role: 'user' }),
createMockAdminUserView({ user_id: '2', email: 'user2@test.com', role: 'admin' }),
];
vi.mocked(mockedDb.adminRepo.getAllUsers).mockResolvedValue(mockUsers);
const response = await supertest(app).get('/api/admin/users');
@@ -127,7 +118,12 @@ describe('Admin User Management Routes (/api/admin/users)', () => {
describe('PUT /users/:id', () => {
it('should update a user role successfully', async () => {
const updatedUser: User = { user_id: 'user-to-update', email: 'test@test.com' };
// The `updateUserRole` function returns a `Profile` object, not a `User` object.
const updatedUser: Profile = {
user_id: 'user-to-update',
role: 'admin',
points: 0,
};
vi.mocked(mockedDb.adminRepo.updateUserRole).mockResolvedValue(updatedUser);
const response = await supertest(app)
.put('/api/admin/users/user-to-update')

View File

@@ -1,7 +1,7 @@
// src/routes/ai.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import express, { type Request, type Response, type NextFunction } from 'express';
import { type Request, type Response, type NextFunction } from 'express';
import path from 'node:path';
import type { Job } from 'bullmq';
import aiRouter from './ai.routes';

View File

@@ -1,12 +1,11 @@
// src/routes/auth.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express';
import { Request, Response, NextFunction } from 'express';
import cookieParser from 'cookie-parser';
import * as bcrypt from 'bcrypt';
import { errorHandler } from '../middleware/errorHandler';
import { UserProfile } from '../types';
import { createMockUserProfile } from '../tests/utils/mockFactories';
import { createMockUserProfile, createMockUserWithPasswordHash } from '../tests/utils/mockFactories';
import { mockLogger } from '../tests/utils/mockLogger';
import { createTestApp } from '../tests/utils/createTestApp';
@@ -142,15 +141,7 @@ describe('Auth Routes (/api/auth)', () => {
it('should successfully register a new user with a strong password', async () => {
// Arrange:
const mockNewUser: UserProfile = {
user_id: 'new-user-id',
user: { user_id: 'new-user-id', email: newUserEmail },
role: 'user',
points: 0,
full_name: 'Test User',
avatar_url: null,
preferences: {}
};
const mockNewUser = createMockUserProfile({ user_id: 'new-user-id', user: { user_id: 'new-user-id', email: newUserEmail }, full_name: 'Test User' });
// FIX: Mock the method on the imported singleton instance `userRepo` directly,
// as this is what the route handler uses. Spying on the prototype does not
@@ -325,13 +316,7 @@ describe('Auth Routes (/api/auth)', () => {
describe('POST /forgot-password', () => {
it('should send a reset link if the user exists', async () => {
// Arrange
vi.mocked(db.userRepo.findUserByEmail).mockResolvedValue({
user_id: 'user-123',
email: 'test@test.com',
password_hash: 'some_hash',
failed_login_attempts: 0,
last_failed_login: null,
});
vi.mocked(db.userRepo.findUserByEmail).mockResolvedValue(createMockUserWithPasswordHash({ user_id: 'user-123', email: 'test@test.com' }));
vi.mocked(db.userRepo.createPasswordResetToken).mockResolvedValue(undefined);
// Act
@@ -367,13 +352,7 @@ describe('Auth Routes (/api/auth)', () => {
it('should still return 200 OK if the email service fails', async () => {
// Arrange
vi.mocked(db.userRepo.findUserByEmail).mockResolvedValue({
user_id: 'user-123',
email: 'test@test.com',
password_hash: 'some_hash',
failed_login_attempts: 0,
last_failed_login: null,
});
vi.mocked(db.userRepo.findUserByEmail).mockResolvedValue(createMockUserWithPasswordHash({ user_id: 'user-123', email: 'test@test.com' }));
vi.mocked(db.userRepo.createPasswordResetToken).mockResolvedValue(undefined);
// Mock the email service to fail
const { sendPasswordResetEmail } = await import('../services/emailService.server');
@@ -447,13 +426,7 @@ describe('Auth Routes (/api/auth)', () => {
describe('POST /refresh-token', () => {
it('should issue a new access token with a valid refresh token cookie', async () => {
const mockUser = {
user_id: 'user-123',
email: 'test@test.com',
password_hash: 'some_hash',
failed_login_attempts: 0,
last_failed_login: null,
};
const mockUser = createMockUserWithPasswordHash({ user_id: 'user-123', email: 'test@test.com' });
vi.mocked(db.userRepo.findUserByRefreshToken).mockResolvedValue(mockUser);
const response = await supertest(app)

View File

@@ -1,7 +1,7 @@
// src/routes/budget.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express';
import { Request, Response, NextFunction } from 'express';
import budgetRouter from './budget.routes';
import * as db from '../services/db/index.db';
import { createMockUserProfile, createMockBudget, createMockSpendingByCategory } from '../tests/utils/mockFactories';

View File

@@ -1,11 +1,11 @@
// src/routes/deals.routes.test.ts
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express';
import { Request, Response, NextFunction } from 'express';
import dealsRouter from './deals.routes';
import { dealsRepo } from '../services/db/deals.db';
import { createMockUserProfile } from '../tests/utils/mockFactories';
import type { UserProfile, WatchedItemDeal } from '../types';
import { createMockUserProfile, createMockWatchedItemDeal } from '../tests/utils/mockFactories';
import type { WatchedItemDeal } from '../types';
import { mockLogger } from '../tests/utils/mockLogger';
import { createTestApp } from '../tests/utils/createTestApp';
@@ -52,14 +52,7 @@ describe('Deals Routes (/api/users/deals)', () => {
});
it('should return a list of deals for an authenticated user', async () => {
const mockDeals: WatchedItemDeal[] = [{
master_item_id: 123,
item_name: 'Apples',
best_price_in_cents: 199,
store_name: 'Mock Store',
flyer_id: 101,
valid_to: new Date().toISOString(),
}];
const mockDeals: WatchedItemDeal[] = [createMockWatchedItemDeal({ item_name: 'Apples' })];
vi.mocked(dealsRepo.findBestPricesForWatchedItems).mockResolvedValue(mockDeals);
const response = await supertest(authenticatedApp).get('/api/users/deals/best-watched-prices');

View File

@@ -1,7 +1,6 @@
// src/routes/flyer.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import express from 'express';
import flyerRouter from './flyer.routes';
import { createMockFlyer, createMockFlyerItem } from '../tests/utils/mockFactories';
import { NotFoundError } from '../services/db/errors.db';

View File

@@ -1,8 +1,7 @@
// src/routes/flyer.routes.ts
import { Router, Request, Response, NextFunction } from 'express';
import { Router } from 'express';
import * as db from '../services/db/index.db';
import { z } from 'zod';
import { logger } from '../services/logger.server';
import { validateRequest } from '../middleware/validation.middleware';
const router = Router();

View File

@@ -1,10 +1,10 @@
// src/routes/gamification.test.ts
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express';
import { Request, Response, NextFunction } from 'express';
import gamificationRouter from './gamification.routes';
import * as db from '../services/db/index.db';
import { createMockUserProfile, createMockAchievement, createMockUserAchievement } from '../tests/utils/mockFactories';
import { createMockUserProfile, createMockAchievement, createMockUserAchievement, createMockLeaderboardUser } from '../tests/utils/mockFactories';
import { ForeignKeyConstraintError } from '../services/db/errors.db';
import { createTestApp } from '../tests/utils/createTestApp';
@@ -207,8 +207,8 @@ describe('Gamification Routes (/api/achievements)', () => {
describe('GET /leaderboard', () => {
it('should return a list of top users (public endpoint)', async () => {
const mockLeaderboard = [{ user_id: 'user-1', full_name: 'Leader', points: 1000, rank: '1' }];
vi.mocked(db.gamificationRepo.getLeaderboard).mockResolvedValue(mockLeaderboard as any);
const mockLeaderboard = [createMockLeaderboardUser({ user_id: 'user-1', full_name: 'Leader', points: 1000, rank: '1' })];
vi.mocked(db.gamificationRepo.getLeaderboard).mockResolvedValue(mockLeaderboard);
const response = await supertest(unauthenticatedApp).get('/api/achievements/leaderboard?limit=5');

View File

@@ -1,5 +1,5 @@
// src/routes/gamification.routes.ts
import express, { Request, Response, NextFunction } from 'express';
import express, { NextFunction } from 'express';
import { z } from 'zod';
import passport, { isAdmin } from './passport.routes';
import { gamificationRepo } from '../services/db/index.db';

View File

@@ -1,7 +1,6 @@
// src/routes/health.routes.test.ts
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
import supertest from 'supertest';
import express from 'express';
import healthRouter from './health.routes';
import * as dbConnection from '../services/db/connection.db';
import { connection as redisConnection } from '../services/queueService.server';

View File

@@ -1,7 +1,6 @@
// src/routes/personalization.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express';
import personalizationRouter from './personalization.routes';
import { createMockMasterGroceryItem, createMockDietaryRestriction, createMockAppliance } from '../tests/utils/mockFactories';
import { mockLogger } from '../tests/utils/mockLogger';

View File

@@ -1,7 +1,6 @@
// src/routes/price.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import express from 'express';
import priceRouter from './price.routes';
import { createTestApp } from '../tests/utils/createTestApp';

View File

@@ -1,7 +1,6 @@
// src/routes/price.routes.ts
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { logger } from '../services/logger.server';
import { validateRequest } from '../middleware/validation.middleware';
const router = Router();

View File

@@ -1,7 +1,6 @@
// src/routes/recipe.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express';
import recipeRouter from './recipe.routes';
import { mockLogger } from '../tests/utils/mockLogger';
import { createMockRecipe, createMockRecipeComment } from '../tests/utils/mockFactories';

View File

@@ -1,5 +1,5 @@
// src/routes/recipe.routes.ts
import { Router, type Request, type Response, type NextFunction } from 'express';
import { Router } from 'express';
import { z } from 'zod';
import * as db from '../services/db/index.db';
import { validateRequest } from '../middleware/validation.middleware';

View File

@@ -1,7 +1,6 @@
// src/routes/stats.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express';
import statsRouter from './stats.routes';
import { mockLogger } from '../tests/utils/mockLogger';
import { createTestApp } from '../tests/utils/createTestApp';

View File

@@ -2,7 +2,6 @@
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import * as db from '../services/db/index.db';
import { logger } from '../services/logger.server';
import { validateRequest } from '../middleware/validation.middleware';
const router = Router();

View File

@@ -1,7 +1,6 @@
// src/routes/system.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import express from 'express';
import systemRouter from './system.routes';
import { exec, type ExecException } from 'child_process';
import { geocodingService } from '../services/geocodingService.server';

View File

@@ -1,12 +1,12 @@
// src/routes/user.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express';
import express from 'express';
// Use * as bcrypt to match the implementation's import style and ensure mocks align.
import * as bcrypt from 'bcrypt';
import userRouter from './user.routes';
import { createMockUserProfile, createMockMasterGroceryItem, createMockShoppingList, createMockShoppingListItem, createMockRecipe } from '../tests/utils/mockFactories';
import { Appliance, Notification } from '../types';
import { createMockUserProfile, createMockMasterGroceryItem, createMockShoppingList, createMockShoppingListItem, createMockRecipe, createMockNotification, createMockDietaryRestriction, createMockAppliance, createMockUserWithPasswordHash } from '../tests/utils/mockFactories';
import { Appliance, Notification, DietaryRestriction } from '../types';
import { ForeignKeyConstraintError, NotFoundError } from '../services/db/errors.db';
import { createTestApp } from '../tests/utils/createTestApp';
@@ -351,12 +351,7 @@ describe('User Routes (/api/users)', () => {
describe('DELETE /account', () => {
it('should delete the account with the correct password', async () => {
const userWithHash = {
...mockUserProfile.user,
password_hash: 'hashed-password',
failed_login_attempts: 0, // Add missing properties
last_failed_login: null
};
const userWithHash = createMockUserWithPasswordHash({ ...mockUserProfile.user, password_hash: 'hashed-password' });
vi.mocked(db.userRepo.findUserWithPasswordHashById).mockResolvedValue(userWithHash);
vi.mocked(db.userRepo.deleteUserById).mockResolvedValue(undefined);
vi.mocked(bcrypt.compare).mockResolvedValue(true as never);
@@ -368,12 +363,7 @@ describe('User Routes (/api/users)', () => {
});
it('should return 403 for an incorrect password', async () => {
const userWithHash = {
...mockUserProfile.user,
password_hash: 'hashed-password',
failed_login_attempts: 0,
last_failed_login: null
};
const userWithHash = createMockUserWithPasswordHash({ ...mockUserProfile.user, password_hash: 'hashed-password' });
vi.mocked(db.userRepo.findUserWithPasswordHashById).mockResolvedValue(userWithHash);
vi.mocked(bcrypt.compare).mockResolvedValue(false as never);
const response = await supertest(app)
@@ -422,8 +412,8 @@ describe('User Routes (/api/users)', () => {
describe('GET and PUT /users/me/dietary-restrictions', () => {
it('GET should return a list of restriction IDs', async () => {
const mockRestrictions = [{ dietary_restriction_id: 1, name: 'Gluten-Free', type: 'diet' as const }];
vi.mocked(db.personalizationRepo.getUserDietaryRestrictions).mockResolvedValue(mockRestrictions);
const mockRestrictions: DietaryRestriction[] = [createMockDietaryRestriction({ name: 'Gluten-Free' })];
vi.mocked(db.personalizationRepo.getUserDietaryRestrictions).mockResolvedValue(mockRestrictions);
const response = await supertest(app).get('/api/users/me/dietary-restrictions');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockRestrictions);
@@ -455,8 +445,8 @@ describe('User Routes (/api/users)', () => {
describe('GET and PUT /users/me/appliances', () => {
it('GET should return a list of appliance IDs', async () => {
const mockAppliances: Appliance[] = [{ appliance_id: 2, name: 'Air Fryer' }];
vi.mocked(db.personalizationRepo.getUserAppliances).mockResolvedValue(mockAppliances);
const mockAppliances: Appliance[] = [createMockAppliance({ name: 'Air Fryer' })];
vi.mocked(db.personalizationRepo.getUserAppliances).mockResolvedValue(mockAppliances);
const response = await supertest(app).get('/api/users/me/appliances');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockAppliances);
@@ -482,7 +472,7 @@ describe('User Routes (/api/users)', () => {
describe('Notification Routes', () => {
it('GET /notifications should return notifications for the user', async () => {
const mockNotifications: Notification[] = [{ notification_id: 1, user_id: 'user-123', content: 'Test', is_read: false, created_at: '', link_url: null }];
const mockNotifications: Notification[] = [createMockNotification({ user_id: 'user-123', content: 'Test' })];
vi.mocked(db.notificationRepo.getNotificationsForUser).mockResolvedValue(mockNotifications);
const response = await supertest(app).get('/api/users/notifications?limit=10&offset=0');
@@ -500,16 +490,17 @@ describe('User Routes (/api/users)', () => {
});
it('POST /notifications/:notificationId/mark-read should return 204', async () => {
vi.mocked(db.notificationRepo.markNotificationAsRead).mockResolvedValue({} as any);
// Fix: Return a mock notification object to match the function's signature.
vi.mocked(db.notificationRepo.markNotificationAsRead).mockResolvedValue(createMockNotification({ notification_id: 1, user_id: 'user-123' }));
const response = await supertest(app).post('/api/users/notifications/1/mark-read');
expect(response.status).toBe(204);
expect(db.notificationRepo.markNotificationAsRead).toHaveBeenCalledWith(1, 'user-123');
});
it('should return 400 for an invalid notificationId', async () => {
const response = await supertest(app).post('/api/users/notifications/abc/mark-read');
const response = await supertest(app).post('/api/users/notifications/abc/mark-read').send({});
expect(response.status).toBe(400);
expect(response.body.message).toBe('Invalid notification ID.');
expect(response.body.errors[0].message).toBe("Invalid ID for parameter 'notificationId'. Must be a number.");
});
});
@@ -539,7 +530,7 @@ describe('User Routes (/api/users)', () => {
const appWithUser = createTestApp({ router: userRouter, basePath, authenticatedUser: { ...mockUserProfile, address_id: null } }); // User has no address yet
const addressData = { address_line_1: '123 New St' };
vi.mocked(db.addressRepo.upsertAddress).mockResolvedValue(5); // New address ID is 5
vi.mocked(db.userRepo.updateUserProfile).mockResolvedValue({} as any);
vi.mocked(db.userRepo.updateUserProfile).mockResolvedValue({ ...mockUserProfile, address_id: 5 });
const response = await supertest(appWithUser)
.put('/api/users/profile/address')

View File

@@ -17,6 +17,23 @@ import { validateRequest } from '../middleware/validation.middleware';
const router = express.Router();
/**
* Validates the strength of a password using zxcvbn.
* @param password The password to check.
* @returns An object with `isValid` and an optional `feedback` message.
*/
const validatePasswordStrength = (password: string): { isValid: boolean; feedback?: string } => {
const MIN_PASSWORD_SCORE = 3; // Require a 'Good' or 'Strong' password (score 3 or 4)
const strength = zxcvbn(password);
if (strength.score < MIN_PASSWORD_SCORE) {
const feedbackMessage = strength.feedback.warning || (strength.feedback.suggestions && strength.feedback.suggestions[0]);
return { isValid: false, feedback: `Password is too weak. ${feedbackMessage || 'Please choose a stronger password.'}`.trim() };
}
return { isValid: true };
};
// --- Zod Schemas for User Routes (as per ADR-003) ---
const numericIdParam = (key: string) => z.object({
@@ -24,15 +41,16 @@ const numericIdParam = (key: string) => z.object({
});
const updateProfileSchema = z.object({
body: z.object({
full_name: z.string().optional(),
avatar_url: z.string().url().optional(),
}).refine(data => Object.keys(data).length > 0, { message: 'At least one field to update must be provided.' }),
body: z.object({ full_name: z.string().optional(), avatar_url: z.string().url().optional() })
.refine(data => Object.keys(data).length > 0, { message: 'At least one field to update must be provided.' }),
});
const updatePasswordSchema = z.object({
body: z.object({
newPassword: z.string().min(1, 'New password is required.'),
newPassword: z.string().min(8, 'Password must be at least 8 characters long.').superRefine((password, ctx) => {
const strength = validatePasswordStrength(password);
if (!strength.isValid) ctx.addIssue({ code: 'custom', message: strength.feedback });
}),
}),
});
@@ -100,11 +118,15 @@ router.post(
'/profile/avatar',
avatarUpload.single('avatar'),
async (req: Request, res: Response, next: NextFunction) => {
if (!req.file) return res.status(400).json({ message: 'No avatar file uploaded.' });
const user = req.user as User;
const avatarUrl = `/uploads/avatars/${req.file.filename}`; // This was a duplicate, fixed.
const updatedProfile = await db.userRepo.updateUserProfile(user.user_id, { avatar_url: avatarUrl }, req.log);
res.json(updatedProfile);
try { // The try-catch block was already correct here.
if (!req.file) return res.status(400).json({ message: 'No avatar file uploaded.' });
const user = req.user as UserProfile;
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
const updatedProfile = await db.userRepo.updateUserProfile(user.user_id, { avatar_url: avatarUrl }, req.log);
res.json(updatedProfile);
} catch (error) {
next(error);
}
}
);
@@ -117,11 +139,16 @@ router.get(
'/notifications',
validateRequest(notificationQuerySchema),
async (req: Request, res: Response, next: NextFunction) => {
const user = req.user as User;
// Fix: Cast to UserProfile and access the nested user property.
const user = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
const { query } = req as unknown as GetNotificationsRequest;
const notifications = await db.notificationRepo.getNotificationsForUser(user.user_id, query.limit, query.offset, req.log);
res.json(notifications);
try {
const { query } = req as unknown as GetNotificationsRequest;
const notifications = await db.notificationRepo.getNotificationsForUser(user.user_id, query.limit, query.offset, req.log);
res.json(notifications);
} catch (error) {
next(error);
}
}
);
@@ -132,9 +159,14 @@ router.post(
'/notifications/mark-all-read',
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
const user = req.user as User;
await db.notificationRepo.markAllNotificationsAsRead(user.user_id, req.log);
res.status(204).send(); // No Content
try { // The try-catch block was already correct here.
// Fix: Cast to UserProfile and access the nested user property.
const user = req.user as UserProfile;
await db.notificationRepo.markAllNotificationsAsRead(user.user_id, req.log);
res.status(204).send(); // No Content
} catch (error) {
next(error);
}
}
);
@@ -146,12 +178,16 @@ type MarkNotificationReadRequest = z.infer<typeof notificationIdSchema>;
router.post(
'/notifications/:notificationId/mark-read', validateRequest(notificationIdSchema),
async (req: Request, res: Response, next: NextFunction) => {
const user = req.user as User;
// Apply ADR-003 pattern for type safety
const { params } = req as unknown as MarkNotificationReadRequest;
await db.notificationRepo.markNotificationAsRead(params.notificationId, user.user_id, req.log);
res.status(204).send(); // Success, no content to return
try { // The try-catch block was already correct here.
// Fix: Cast to UserProfile and access the nested user property.
const user = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
const { params } = req as unknown as MarkNotificationReadRequest;
await db.notificationRepo.markNotificationAsRead(params.notificationId, user.user_id, req.log);
res.status(204).send(); // Success, no content to return
} catch (error) {
next(error);
}
}
);
@@ -196,17 +232,11 @@ type UpdatePasswordRequest = z.infer<typeof updatePasswordSchema>;
router.put('/profile/password', validateRequest(updatePasswordSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] PUT /api/users/profile/password - ENTER`);
const user = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
const { body } = req as unknown as UpdatePasswordRequest;
const MIN_PASSWORD_SCORE = 3;
const strength = zxcvbn(body.newPassword);
if (strength.score < MIN_PASSWORD_SCORE) {
const feedback = strength.feedback.warning || (strength.feedback.suggestions && strength.feedback.suggestions[0]);
return res.status(400).json({ message: `New password is too weak. ${feedback || ''}`.trim() });
}
try {
const saltRounds = 10; // This was a duplicate, fixed.
const saltRounds = 10;
const hashedPassword = await bcrypt.hash(body.newPassword, saltRounds);
await db.userRepo.updateUserPassword(user.user_id, hashedPassword, req.log);
res.status(200).json({ message: 'Password updated successfully.' });
@@ -223,6 +253,7 @@ type DeleteAccountRequest = z.infer<typeof deleteAccountSchema>;
router.delete('/account', validateRequest(deleteAccountSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] DELETE /api/users/account - ENTER`);
const user = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
const { body } = req as unknown as DeleteAccountRequest;
try {
@@ -231,7 +262,6 @@ router.delete('/account', validateRequest(deleteAccountSchema), async (req, res,
return res.status(404).json({ message: 'User not found or password not set.' });
}
// Per ADR-001, findUserWithPasswordHashById will throw if user or hash is missing.
const isMatch = await bcrypt.compare(body.password, userWithHash.password_hash);
if (!isMatch) {
return res.status(403).json({ message: 'Incorrect password.' });
@@ -267,6 +297,7 @@ type AddWatchedItemRequest = z.infer<typeof addWatchedItemSchema>;
router.post('/watched-items', validateRequest(addWatchedItemSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] POST /api/users/watched-items - ENTER`);
const user = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
const { body } = req as unknown as AddWatchedItemRequest;
try {
const newItem = await db.personalizationRepo.addWatchedItem(user.user_id, body.itemName, body.category, req.log);
@@ -275,7 +306,7 @@ router.post('/watched-items', validateRequest(addWatchedItemSchema), async (req,
if (error instanceof ForeignKeyConstraintError) {
return res.status(400).json({ message: error.message });
}
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred'; // This was a duplicate, fixed.
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
logger.error({
errorMessage,
body: req.body,
@@ -292,6 +323,7 @@ type DeleteWatchedItemRequest = z.infer<typeof watchedItemIdSchema>;
router.delete('/watched-items/:masterItemId', validateRequest(watchedItemIdSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] DELETE /api/users/watched-items/:masterItemId - ENTER`);
const user = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
const { params } = req as unknown as DeleteWatchedItemRequest;
try {
await db.personalizationRepo.removeWatchedItem(user.user_id, params.masterItemId, req.log);
@@ -342,6 +374,7 @@ type CreateShoppingListRequest = z.infer<typeof createShoppingListSchema>;
router.post('/shopping-lists', validateRequest(createShoppingListSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] POST /api/users/shopping-lists - ENTER`);
const user = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
const { body } = req as unknown as CreateShoppingListRequest;
try {
const newList = await db.shoppingRepo.createShoppingList(user.user_id, body.name, req.log);
@@ -350,7 +383,7 @@ router.post('/shopping-lists', validateRequest(createShoppingListSchema), async
if (error instanceof ForeignKeyConstraintError) {
return res.status(400).json({ message: error.message });
}
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred'; // This was a duplicate, fixed.
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
logger.error({
errorMessage,
body: req.body,
@@ -365,6 +398,7 @@ router.post('/shopping-lists', validateRequest(createShoppingListSchema), async
router.delete('/shopping-lists/:listId', validateRequest(shoppingListIdSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] DELETE /api/users/shopping-lists/:listId - ENTER`);
const user = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
const { params } = req as unknown as GetShoppingListRequest;
try {
await db.shoppingRepo.deleteShoppingList(params.listId, user.user_id, req.log);
@@ -388,6 +422,7 @@ const addShoppingListItemSchema = shoppingListIdSchema.extend({
type AddShoppingListItemRequest = z.infer<typeof addShoppingListItemSchema>;
router.post('/shopping-lists/:listId/items', validateRequest(addShoppingListItemSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] POST /api/users/shopping-lists/:listId/items - ENTER`);
// Apply ADR-003 pattern for type safety
const { params, body } = req as unknown as AddShoppingListItemRequest;
try {
const newItem = await db.shoppingRepo.addShoppingListItem(params.listId, body, req.log);
@@ -396,7 +431,7 @@ router.post('/shopping-lists/:listId/items', validateRequest(addShoppingListItem
if (error instanceof ForeignKeyConstraintError) {
return res.status(400).json({ message: error.message });
}
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred'; // This was a duplicate, fixed.
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
logger.error({
errorMessage,
params: req.params, body: req.body
@@ -417,6 +452,7 @@ const updateShoppingListItemSchema = numericIdParam('itemId').extend({
type UpdateShoppingListItemRequest = z.infer<typeof updateShoppingListItemSchema>;
router.put('/shopping-lists/items/:itemId', validateRequest(updateShoppingListItemSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] PUT /api/users/shopping-lists/items/:itemId - ENTER`);
// Apply ADR-003 pattern for type safety
const { params, body } = req as unknown as UpdateShoppingListItemRequest;
try {
const updatedItem = await db.shoppingRepo.updateShoppingListItem(params.itemId, body, req.log);
@@ -434,6 +470,7 @@ const shoppingListItemIdSchema = numericIdParam('itemId');
type DeleteShoppingListItemRequest = z.infer<typeof shoppingListItemIdSchema>;
router.delete('/shopping-lists/items/:itemId', validateRequest(shoppingListItemIdSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] DELETE /api/users/shopping-lists/items/:itemId - ENTER`);
// Apply ADR-003 pattern for type safety
const { params } = req as unknown as DeleteShoppingListItemRequest;
try {
await db.shoppingRepo.removeShoppingListItem(params.itemId, req.log);
@@ -454,6 +491,7 @@ type UpdatePreferencesRequest = z.infer<typeof updatePreferencesSchema>;
router.put('/profile/preferences', validateRequest(updatePreferencesSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] PUT /api/users/profile/preferences - ENTER`);
const user = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
const { body } = req as unknown as UpdatePreferencesRequest;
try {
const updatedProfile = await db.userRepo.updateUserPreferences(user.user_id, body, req.log);
@@ -483,6 +521,7 @@ type SetUserRestrictionsRequest = z.infer<typeof setUserRestrictionsSchema>;
router.put('/me/dietary-restrictions', validateRequest(setUserRestrictionsSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] PUT /api/users/me/dietary-restrictions - ENTER`);
const user = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
const { body } = req as unknown as SetUserRestrictionsRequest;
try {
await db.personalizationRepo.setUserDietaryRestrictions(user.user_id, body.restrictionIds, req.log);
@@ -491,7 +530,7 @@ router.put('/me/dietary-restrictions', validateRequest(setUserRestrictionsSchema
if (error instanceof ForeignKeyConstraintError) {
return res.status(400).json({ message: error.message });
}
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred'; // This was a duplicate, fixed.
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
logger.error({
errorMessage,
body: req.body,
@@ -519,6 +558,7 @@ type SetUserAppliancesRequest = z.infer<typeof setUserAppliancesSchema>;
router.put('/me/appliances', validateRequest(setUserAppliancesSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] PUT /api/users/me/appliances - ENTER`);
const user = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
const { body } = req as unknown as SetUserAppliancesRequest;
try {
await db.personalizationRepo.setUserAppliances(user.user_id, body.applianceIds, req.log);
@@ -527,7 +567,7 @@ router.put('/me/appliances', validateRequest(setUserAppliancesSchema), async (re
if (error instanceof ForeignKeyConstraintError) {
return res.status(400).json({ message: error.message });
}
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred'; // This was a duplicate, fixed.
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
logger.error({
errorMessage,
body: req.body,
@@ -544,15 +584,19 @@ const addressIdSchema = numericIdParam('addressId');
type GetAddressRequest = z.infer<typeof addressIdSchema>;
router.get('/addresses/:addressId', validateRequest(addressIdSchema), async (req, res, next: NextFunction) => {
const user = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
const { params } = req as unknown as GetAddressRequest;
const addressId = params.addressId;
// Security check: Ensure the requested addressId matches the one on the user's profile.
if (user.address_id !== addressId) {
return res.status(403).json({ message: 'Forbidden: You can only access your own address.' });
try {
const addressId = params.addressId;
// Security check: Ensure the requested addressId matches the one on the user's profile.
if (user.address_id !== addressId) {
return res.status(403).json({ message: 'Forbidden: You can only access your own address.' });
}
const address = await db.addressRepo.getAddressById(addressId, req.log); // This will throw NotFoundError if not found
res.json(address);
} catch (error) {
next(error);
}
const address = await db.addressRepo.getAddressById(addressId, req.log); // This will throw NotFoundError if not found
res.json(address);
});
/**
@@ -571,13 +615,14 @@ const updateUserAddressSchema = z.object({
type UpdateUserAddressRequest = z.infer<typeof updateUserAddressSchema>;
router.put('/profile/address', validateRequest(updateUserAddressSchema), async (req, res, next: NextFunction) => {
const user = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
const { body: addressData } = req as unknown as UpdateUserAddressRequest;
try {
// Per ADR-002, complex operations involving multiple database writes should be
// encapsulated in a single service method that manages the transaction.
// This ensures both the address upsert and the user profile update are atomic. // This was a duplicate, fixed.
const addressId = await userService.upsertUserAddress(user, addressData, req.log);
// This ensures both the address upsert and the user profile update are atomic.
const addressId = await userService.upsertUserAddress(user, addressData, req.log); // This was a duplicate, fixed.
res.status(200).json({ message: 'Address updated successfully', address_id: addressId });
} catch (error) {
next(error);
@@ -592,6 +637,7 @@ type DeleteRecipeRequest = z.infer<typeof recipeIdSchema>;
router.delete('/recipes/:recipeId', validateRequest(recipeIdSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] DELETE /api/users/recipes/:recipeId - ENTER`);
const user = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
const { params } = req as unknown as DeleteRecipeRequest;
try {
await db.recipeRepo.deleteRecipe(params.recipeId, user.user_id, false, req.log);
@@ -619,6 +665,7 @@ type UpdateRecipeRequest = z.infer<typeof updateRecipeSchema>;
router.put('/recipes/:recipeId', validateRequest(updateRecipeSchema), async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] PUT /api/users/recipes/:recipeId - ENTER`);
const user = req.user as UserProfile;
// Apply ADR-003 pattern for type safety
const { params, body } = req as unknown as UpdateRecipeRequest;
try {