Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 48 additions & 23 deletions apps/backend/src/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<AuthResult> {
const { data, error } = await supabaseAdmin.auth.signUp({
email: input.email,
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions apps/backend/src/services/meal-log.service.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -97,6 +98,8 @@ export class MealLogService {
},
});

await nutritionService.invalidateDailySummary(userId, log.loggedAt);

return mapMealLog(log);
}

Expand Down Expand Up @@ -139,6 +142,7 @@ export class MealLogService {
}

await prisma.mealLog.delete({ where: { id: logId } });
await nutritionService.invalidateDailySummary(userId, log.loggedAt);
}
}

Expand Down
28 changes: 19 additions & 9 deletions apps/backend/src/services/nutrition.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DailyNutritionSummary> {
async getDailySummary(userId: string, date: string): Promise<DailyNutritionSummary> {
// 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;
Expand Down Expand Up @@ -96,10 +101,15 @@ export class NutritionService {
return summary;
}

async getWeeklySummary(
userId: string,
weekStart: string,
): Promise<WeeklyNutritionSummary> {
async invalidateDailySummary(userId: string, loggedAt: Date): Promise<void> {
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<WeeklyNutritionSummary> {
const startDate = new Date(weekStart);
startDate.setHours(0, 0, 0, 0);
// endDate is the exclusive query bound (start + 7d); the reported
Expand Down
139 changes: 98 additions & 41 deletions apps/backend/src/services/recipe.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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),
};
}
Expand Down Expand Up @@ -103,6 +104,67 @@ function mapPrismaToRecipe(r: {
};
}

const ALLERGEN_QUERY_ALIASES: Record<string, string[]> = {
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<string, string> = {
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,
Expand Down Expand Up @@ -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) } };
}
}

Expand All @@ -237,13 +299,12 @@ export class RecipeService {
return { recipes: rows.map(mapPrismaToRecipe), total, page, limit };
}

async searchRecipes(
query: string,
limit: number,
userId?: string,
): Promise<Recipe[]> {
async searchRecipes(query: string, limit: number, userId?: string): Promise<Recipe[]> {
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) {
Expand All @@ -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.
Expand All @@ -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");
}
});

Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -362,12 +417,14 @@ export class RecipeService {
return recipe;
}

async getCachedRecipes(options: {
diet?: string;
maxReadyInMinutes?: number;
limit?: number;
excludeIds?: string[];
} = {}): Promise<Recipe[]> {
async getCachedRecipes(
options: {
diet?: string;
maxReadyInMinutes?: number;
limit?: number;
excludeIds?: string[];
} = {},
): Promise<Recipe[]> {
const where: Record<string, unknown> = {};

if (options.diet) {
Expand Down
Loading
Loading