diff --git a/apps/backend/src/services/auth.service.ts b/apps/backend/src/services/auth.service.ts index bc71a55..3fee752 100644 --- a/apps/backend/src/services/auth.service.ts +++ b/apps/backend/src/services/auth.service.ts @@ -21,7 +21,33 @@ interface AuthResult { tokens: AuthTokens; } +function requireEmail(email: string | null | undefined, statusCode: number, code: string): string { + if (!email) { + throw new AppError(statusCode, code, "Account has no email address"); + } + return email; +} + export class AuthService { + private async provisionAppUser(input: { + id: string; + email: string; + displayName?: string | null; + updateDisplayName?: boolean; + }): Promise { + await prisma.user.upsert({ + where: { id: input.id }, + create: { + id: input.id, + email: input.email, + displayName: input.displayName ?? null, + }, + update: input.updateDisplayName + ? { email: input.email, displayName: input.displayName ?? null } + : { email: input.email }, + }); + } + async signUp(input: SignupInput): Promise { const { data, error } = await supabaseAdmin.auth.signUp({ email: input.email, @@ -43,21 +69,15 @@ export class AuthService { throw new AppError(400, "SIGNUP_FAILED", "Failed to create account"); } const userId = data.user.id; - const email = data.user.email!; + const email = requireEmail(data.user.email, 400, "SIGNUP_FAILED"); // Provision the application user row with the Supabase user id — // every domain table (logs, plans, preferences) hangs off this record. - await prisma.user.upsert({ - where: { id: userId }, - create: { - id: userId, - email, - displayName: input.displayName, - }, - update: { - email, - displayName: input.displayName, - }, + await this.provisionAppUser({ + id: userId, + email, + displayName: input.displayName, + updateDisplayName: true, }); // Warm up a per-user model in the background; auth should not fail if ML training is down. @@ -94,24 +114,20 @@ export class AuthService { if (!data.user || !data.session) { throw new AppError(401, "INVALID_CREDENTIALS", "Invalid email or password"); } + const email = requireEmail(data.user.email, 401, "INVALID_CREDENTIALS"); // Self-heal accounts that pre-date signup provisioning: create the app // user row if it's missing, but never clobber an existing profile. - await prisma.user.upsert({ - where: { id: data.user.id }, - create: { - id: data.user.id, - email: data.user.email!, - displayName: - (data.user.user_metadata?.display_name as string | undefined) ?? null, - }, - update: { email: data.user.email! }, + await this.provisionAppUser({ + id: data.user.id, + email, + displayName: (data.user.user_metadata?.display_name as string | undefined) ?? null, }); return { user: { id: data.user.id, - email: data.user.email!, + email, }, tokens: { accessToken: data.session.access_token, @@ -143,11 +159,20 @@ export class AuthService { if (error || !data.user || !data.session) { throw new AppError(401, "REFRESH_FAILED", "Invalid or expired refresh token"); } + const email = requireEmail(data.user.email, 401, "REFRESH_FAILED"); + + // Refresh can be the only auth call a long-lived client makes, so it must + // repair missing application user rows just like password login does. + await this.provisionAppUser({ + id: data.user.id, + email, + displayName: (data.user.user_metadata?.display_name as string | undefined) ?? null, + }); return { user: { id: data.user.id, - email: data.user.email!, + email, }, tokens: { accessToken: data.session.access_token, diff --git a/apps/backend/src/services/meal-log.service.ts b/apps/backend/src/services/meal-log.service.ts index 21927fd..eabc3ba 100644 --- a/apps/backend/src/services/meal-log.service.ts +++ b/apps/backend/src/services/meal-log.service.ts @@ -1,6 +1,7 @@ import type { MealLog } from "@snacktrack/shared-types"; import { prisma } from "../config/database.js"; import { AppError } from "../utils/AppError.js"; +import { nutritionService } from "./nutrition.service.js"; type CreateMealLogInput = { recipeId?: string | null; @@ -97,6 +98,8 @@ export class MealLogService { }, }); + await nutritionService.invalidateDailySummary(userId, log.loggedAt); + return mapMealLog(log); } @@ -139,6 +142,7 @@ export class MealLogService { } await prisma.mealLog.delete({ where: { id: logId } }); + await nutritionService.invalidateDailySummary(userId, log.loggedAt); } } diff --git a/apps/backend/src/services/nutrition.service.ts b/apps/backend/src/services/nutrition.service.ts index a920acc..fc6968a 100644 --- a/apps/backend/src/services/nutrition.service.ts +++ b/apps/backend/src/services/nutrition.service.ts @@ -17,13 +17,18 @@ function percentage(consumed: number, target: number | null | undefined): number return Math.round((consumed / target) * 100); } +function dailyCacheKey(userId: string, date: string): string { + return `nutrition:daily:${userId}:${date}`; +} + +function dateKey(date: Date): string { + return date.toISOString().split("T")[0]!; +} + export class NutritionService { - async getDailySummary( - userId: string, - date: string, - ): Promise { + async getDailySummary(userId: string, date: string): Promise { // Check Redis cache (populated by nutrition-precompute job) - const cacheKey = `nutrition:daily:${userId}:${date}`; + const cacheKey = dailyCacheKey(userId, date); try { const cached = await redis.get(cacheKey); if (cached) return JSON.parse(cached) as DailyNutritionSummary; @@ -96,10 +101,15 @@ export class NutritionService { return summary; } - async getWeeklySummary( - userId: string, - weekStart: string, - ): Promise { + async invalidateDailySummary(userId: string, loggedAt: Date): Promise { + try { + await redis.del(dailyCacheKey(userId, dateKey(loggedAt))); + } catch { + // Cache invalidation is best-effort; the database write already succeeded. + } + } + + async getWeeklySummary(userId: string, weekStart: string): Promise { const startDate = new Date(weekStart); startDate.setHours(0, 0, 0, 0); // endDate is the exclusive query bound (start + 7d); the reported diff --git a/apps/backend/src/services/recipe.service.ts b/apps/backend/src/services/recipe.service.ts index 2c3f37d..9e67fa8 100644 --- a/apps/backend/src/services/recipe.service.ts +++ b/apps/backend/src/services/recipe.service.ts @@ -5,17 +5,14 @@ import { redis } from "../config/redis.js"; import { AppError } from "../utils/AppError.js"; import { logger } from "../utils/logger.js"; import { CACHE_TTL } from "../config/constants.js"; -import { - spoonacularService, - type SpoonacularRecipeDetail, -} from "./spoonacular.service.js"; +import { spoonacularService, type SpoonacularRecipeDetail } from "./spoonacular.service.js"; import { allergenService } from "./allergen.service.js"; import { mlService } from "./ml.service.js"; import { captureMlFailure } from "../config/sentry.js"; // Prisma requires Prisma.JsonNull instead of null for nullable JSON fields function jsonOrNull(value: unknown): Prisma.InputJsonValue | typeof Prisma.JsonNull { - return value === null || value === undefined ? Prisma.JsonNull : value as Prisma.InputJsonValue; + return value === null || value === undefined ? Prisma.JsonNull : (value as Prisma.InputJsonValue); } function nutrientValue( @@ -42,19 +39,23 @@ function mapSpoonacularToRecipeData(detail: SpoonacularRecipeDetail) { sodiumMg: nutrientValue(nutrients, "Sodium"), fiberG: nutrientValue(nutrients, "Fiber"), sugarG: nutrientValue(nutrients, "Sugar"), - ingredients: jsonOrNull(detail.extendedIngredients?.map((i) => ({ - name: i.name, - amount: i.amount, - unit: i.unit, - original: i.original, - })) ?? null), + ingredients: jsonOrNull( + detail.extendedIngredients?.map((i) => ({ + name: i.name, + amount: i.amount, + unit: i.unit, + original: i.original, + })) ?? null, + ), allergens: [] as string[], dietLabels: detail.diets ?? [], cuisineTypes: detail.cuisines ?? [], - instructions: jsonOrNull(detail.analyzedInstructions?.[0]?.steps?.map((s) => ({ - number: s.number, - step: s.step, - })) ?? null), + instructions: jsonOrNull( + detail.analyzedInstructions?.[0]?.steps?.map((s) => ({ + number: s.number, + step: s.step, + })) ?? null, + ), expiresAt: new Date(Date.now() + CACHE_TTL.RECIPE_DB_DAYS * 86400 * 1000), }; } @@ -103,6 +104,67 @@ function mapPrismaToRecipe(r: { }; } +const ALLERGEN_QUERY_ALIASES: Record = { + dairy: ["dairy", "milk"], + egg: ["egg", "eggs"], + eggs: ["eggs", "egg"], + fish: ["fish", "seafood"], + milk: ["milk", "dairy"], + peanut: ["peanut", "peanuts"], + peanuts: ["peanuts", "peanut"], + seafood: ["seafood", "fish"], + sesame: ["sesame"], + shellfish: ["shellfish"], + soy: ["soy", "soybeans"], + soybeans: ["soybeans", "soy"], + "tree nut": ["tree nut", "tree nuts", "tree_nut", "tree_nuts"], + "tree nuts": ["tree nuts", "tree nut", "tree_nuts", "tree_nut"], + wheat: ["wheat", "gluten"], +}; + +const SPOONACULAR_INTOLERANCES: Record = { + dairy: "dairy", + egg: "egg", + eggs: "egg", + gluten: "gluten", + milk: "dairy", + peanut: "peanut", + peanuts: "peanut", + seafood: "seafood", + sesame: "sesame", + shellfish: "shellfish", + soy: "soy", + soybeans: "soy", + sulfite: "sulfite", + "tree nut": "tree nut", + "tree nuts": "tree nut", + tree_nut: "tree nut", + tree_nuts: "tree nut", + wheat: "wheat", +}; + +function expandAllergenQueryTerms(allergens: string[]): string[] { + return [ + ...new Set( + allergens.flatMap((allergen) => { + const normalized = allergen.toLowerCase().replaceAll("_", " "); + return ALLERGEN_QUERY_ALIASES[normalized] ?? [normalized]; + }), + ), + ]; +} + +function spoonacularIntolerances(allergens: string[]): string | undefined { + const values = [ + ...new Set( + allergens + .map((allergen) => SPOONACULAR_INTOLERANCES[allergen.toLowerCase().replaceAll("_", " ")]) + .filter((value): value is string => Boolean(value)), + ), + ]; + return values.length > 0 ? values.join(",") : undefined; +} + export class RecipeService { async getRecommendationsForUser( userId: string, @@ -220,7 +282,7 @@ export class RecipeService { if (userId) { const userAllergens = await allergenService.getUserAllergens(userId); if (userAllergens.length > 0) { - where.NOT = { allergens: { hasSome: userAllergens } }; + where.NOT = { allergens: { hasSome: expandAllergenQueryTerms(userAllergens) } }; } } @@ -237,13 +299,12 @@ export class RecipeService { return { recipes: rows.map(mapPrismaToRecipe), total, page, limit }; } - async searchRecipes( - query: string, - limit: number, - userId?: string, - ): Promise { + async searchRecipes(query: string, limit: number, userId?: string): Promise { + const userAllergens = userId ? await allergenService.getUserAllergens(userId) : []; + const intolerances = spoonacularIntolerances(userAllergens); + // Check Redis cache - const cacheKey = `food:search:${Buffer.from(`${query}:${limit}`).toString("base64url")}`; + const cacheKey = `food:search:${Buffer.from(`${query}:${limit}:${intolerances ?? ""}`).toString("base64url")}`; try { const cached = await redis.get(cacheKey); if (cached) { @@ -259,7 +320,10 @@ export class RecipeService { } // Fetch from Spoonacular - const results = await spoonacularService.searchRecipes(query, { number: limit }); + const results = await spoonacularService.searchRecipes(query, { + number: limit, + intolerances, + }); // Hydrate details and cache in PostgreSQL concurrently; one slow or // failing recipe must not serialize or sink the whole search. @@ -281,10 +345,7 @@ export class RecipeService { if (outcome.status === "fulfilled") { recipes.push(outcome.value); } else { - logger.warn( - { error: outcome.reason, recipeId: results[i]?.id }, - "Failed to cache recipe", - ); + logger.warn({ error: outcome.reason, recipeId: results[i]?.id }, "Failed to cache recipe"); } }); @@ -322,15 +383,9 @@ export class RecipeService { } // If expired and has spoonacularId, refresh from API - if ( - dbRecipe.expiresAt && - dbRecipe.expiresAt < new Date() && - dbRecipe.spoonacularId - ) { + if (dbRecipe.expiresAt && dbRecipe.expiresAt < new Date() && dbRecipe.spoonacularId) { try { - const detail = await spoonacularService.getRecipeDetails( - dbRecipe.spoonacularId, - ); + const detail = await spoonacularService.getRecipeDetails(dbRecipe.spoonacularId); const recipeData = mapSpoonacularToRecipeData(detail); const updated = await prisma.recipe.update({ where: { id }, @@ -362,12 +417,14 @@ export class RecipeService { return recipe; } - async getCachedRecipes(options: { - diet?: string; - maxReadyInMinutes?: number; - limit?: number; - excludeIds?: string[]; - } = {}): Promise { + async getCachedRecipes( + options: { + diet?: string; + maxReadyInMinutes?: number; + limit?: number; + excludeIds?: string[]; + } = {}, + ): Promise { const where: Record = {}; if (options.diet) { diff --git a/apps/backend/tests/auth.test.ts b/apps/backend/tests/auth.test.ts index 247e807..05ed28b 100644 --- a/apps/backend/tests/auth.test.ts +++ b/apps/backend/tests/auth.test.ts @@ -157,4 +157,45 @@ describe("AuthService", () => { expect(prisma.user.upsert).not.toHaveBeenCalled(); }); }); + + describe("refreshSession", () => { + it("self-heals a missing app user row during token refresh", async () => { + vi.mocked(supabaseAdmin.auth.refreshSession).mockResolvedValue({ + data: { + user: { + id: USER_ID, + email: "refresh@snacktrack.dev", + user_metadata: { display_name: "Refresh User" }, + }, + session: { + access_token: "new-access-token", + refresh_token: "new-refresh-token", + expires_in: 3600, + expires_at: 1_735_689_600, + }, + }, + error: null, + } as never); + vi.mocked(prisma.user.upsert).mockResolvedValue({} as never); + + const result = await authService.refreshSession({ + refreshToken: "old-refresh-token", + }); + + expect(prisma.user.upsert).toHaveBeenCalledWith({ + where: { id: USER_ID }, + create: { + id: USER_ID, + email: "refresh@snacktrack.dev", + displayName: "Refresh User", + }, + update: { email: "refresh@snacktrack.dev" }, + }); + expect(result.user).toEqual({ + id: USER_ID, + email: "refresh@snacktrack.dev", + }); + expect(result.tokens.accessToken).toBe("new-access-token"); + }); + }); }); diff --git a/apps/backend/tests/meal-log.test.ts b/apps/backend/tests/meal-log.test.ts index 1ffcc49..73d1535 100644 --- a/apps/backend/tests/meal-log.test.ts +++ b/apps/backend/tests/meal-log.test.ts @@ -15,7 +15,7 @@ vi.mock("../src/config/database.js", () => ({ vi.mock("../src/config/redis.js", () => ({ isRedisHealthy: vi.fn().mockResolvedValue(true), - redis: { on: vi.fn() }, + redis: { on: vi.fn(), del: vi.fn().mockResolvedValue(1) }, })); vi.mock("../src/config/supabase.js", () => ({ @@ -34,6 +34,7 @@ vi.mock("../src/utils/logger.js", () => ({ import { createApp } from "../src/app.js"; import { prisma } from "../src/config/database.js"; +import { redis } from "../src/config/redis.js"; import { supabaseAdmin } from "../src/config/supabase.js"; const app = createApp(); @@ -90,6 +91,7 @@ describe("Meal Log endpoints", () => { expect(res.body.status).toBe("success"); expect(res.body.data.foodName).toBe("Grilled Chicken Salad"); expect(res.body.data.mealType).toBe("lunch"); + expect(redis.del).toHaveBeenCalledWith(`nutrition:daily:${USER_ID}:2025-06-15`); }); it("returns 422 for missing required fields", async () => { @@ -153,6 +155,7 @@ describe("Meal Log endpoints", () => { expect(res.status).toBe(200); expect(res.body.status).toBe("success"); + expect(redis.del).toHaveBeenCalledWith(`nutrition:daily:${USER_ID}:2025-06-15`); }); it("returns 404 for non-existent log", async () => { diff --git a/apps/backend/tests/recipe.service.test.ts b/apps/backend/tests/recipe.service.test.ts new file mode 100644 index 0000000..70be433 --- /dev/null +++ b/apps/backend/tests/recipe.service.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../src/config/database.js", () => ({ + prisma: { + $transaction: vi.fn((operations: Promise[]) => Promise.all(operations)), + recipe: { + findMany: vi.fn(), + count: vi.fn(), + upsert: vi.fn(), + }, + userAllergen: { + findMany: vi.fn(), + }, + }, +})); + +vi.mock("../src/config/redis.js", () => ({ + redis: { + get: vi.fn(), + setex: vi.fn(), + }, +})); + +vi.mock("../src/services/spoonacular.service.js", () => ({ + spoonacularService: { + searchRecipes: vi.fn(), + getRecipeDetails: vi.fn(), + }, +})); + +vi.mock("../src/services/ml.service.js", () => ({ + mlService: { + getRecommendations: vi.fn(), + }, +})); + +vi.mock("../src/config/sentry.js", () => ({ + captureMlFailure: vi.fn(), +})); + +vi.mock("../src/utils/logger.js", () => ({ + logger: { debug: vi.fn(), info: vi.fn(), error: vi.fn(), warn: vi.fn() }, +})); + +import { prisma } from "../src/config/database.js"; +import { redis } from "../src/config/redis.js"; +import { recipeService } from "../src/services/recipe.service.js"; +import { spoonacularService } from "../src/services/spoonacular.service.js"; + +const USER_ID = "550e8400-e29b-41d4-a716-446655440000"; + +const recipeRow = { + id: "7d9f1a00-1111-4222-8333-444455556666", + spoonacularId: 123, + title: "Safe Noodles", + imageUrl: null, + cloudinaryUrl: null, + readyInMinutes: 20, + servings: 2, + calories: 500, + proteinG: 20, + carbsG: 70, + fatG: 10, + sodiumMg: null, + fiberG: null, + sugarG: null, + ingredients: null, + allergens: [], + dietLabels: [], + cuisineTypes: [], + instructions: null, + cachedAt: new Date("2025-06-15T12:00:00Z"), + expiresAt: null, +}; + +describe("RecipeService allergen filtering", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("expands FDA allergen aliases in the database catalog filter", async () => { + vi.mocked(prisma.userAllergen.findMany).mockResolvedValue([ + { allergenType: "peanuts" }, + ] as never); + vi.mocked(prisma.recipe.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.recipe.count).mockResolvedValue(0 as never); + + const result = await recipeService.listRecipes({ page: 1, limit: 12 }, USER_ID); + + expect(result).toEqual({ recipes: [], total: 0, page: 1, limit: 12 }); + expect(prisma.recipe.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + NOT: { allergens: { hasSome: ["peanuts", "peanut"] } }, + }, + }), + ); + expect(prisma.recipe.count).toHaveBeenCalledWith({ + where: { + NOT: { allergens: { hasSome: ["peanuts", "peanut"] } }, + }, + }); + }); + + it("passes mapped user allergens as Spoonacular intolerances", async () => { + vi.mocked(prisma.userAllergen.findMany).mockResolvedValue([ + { allergenType: "peanuts" }, + { allergenType: "milk" }, + ] as never); + vi.mocked(redis.get).mockResolvedValue(null); + vi.mocked(redis.setex).mockResolvedValue("OK"); + vi.mocked(spoonacularService.searchRecipes).mockResolvedValue([ + { + id: 123, + title: "Safe Noodles", + image: "https://example.com/noodles.jpg", + readyInMinutes: 20, + servings: 2, + }, + ] as never); + vi.mocked(spoonacularService.getRecipeDetails).mockResolvedValue({ + id: 123, + title: "Safe Noodles", + image: "https://example.com/noodles.jpg", + readyInMinutes: 20, + servings: 2, + nutrition: { + nutrients: [ + { name: "Calories", amount: 500, unit: "kcal" }, + { name: "Protein", amount: 20, unit: "g" }, + { name: "Carbohydrates", amount: 70, unit: "g" }, + { name: "Fat", amount: 10, unit: "g" }, + ], + }, + extendedIngredients: [], + diets: [], + cuisines: [], + analyzedInstructions: [], + } as never); + vi.mocked(prisma.recipe.upsert).mockResolvedValue(recipeRow as never); + + const result = await recipeService.searchRecipes("noodles", 10, USER_ID); + + expect(spoonacularService.searchRecipes).toHaveBeenCalledWith("noodles", { + number: 10, + intolerances: "peanut,dairy", + }); + expect(result).toHaveLength(1); + expect(result[0]?.title).toBe("Safe Noodles"); + }); +});