From d98f3450c04d123b8e50d4d6d0d587b8686b7971 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Fri, 21 Aug 2026 12:00:39 +0200 Subject: [PATCH] feat(convention): impose try/catch autour de chaque await/corps async MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nouvelle règle de dev : aucun await nu, et un corps de fonction/méthode async doit intégralement vivre dans un try/catch (pas seulement la ou les lignes qui awaitent). Documentée dans specs/dev-conventions.md avec son périmètre (code applicatif — services/hooks/composants/middlewares — routes *.routes.ts exemptées car déjà couvertes par wrapAsyncHandler ; tests et scripts one-off exemptés aussi). Appliqué rétroactivement à tout le code applicatif qui ne l'était pas déjà : - api : auth/house/profile/preferences/planning/reference/recipe/ sources .service.ts, recipe-source-sync.ts, recipe-translation.ts, ingredient-matcher.ts, tech-step-matcher.ts, json-ld-recipe.ts, the-meal-db.ts — un try/catch par fonction async, rethrow simple (le middleware d'erreur logge déjà tout centralement, voir error-logger.ts) sauf quand un catch avait déjà une logique propre (ex. le retry de createHouse). - web : api/client.ts (_request), AuthContext.tsx, ThemeContext.tsx, AppLayout.tsx (handleLogout), HouseholdSettingsPage.tsx (handleCopy/ handleRemove/handleDelete/handleLeave) — la plupart des handlers de formulaire avaient déjà ce pattern, seuls ceux qui laissaient un await nu ont été corrigés. lint/complexity/noUselessCatch désactivé dans biome.json (interdisait justement le catch-qui-rethrow que cette convention impose). Vérifié : tsc --noEmit (api+web), biome check (0 erreur, repo entier), build complet, 303 tests API, vérification live navigateur (thème, déconnexion, copie du code d'invitation). Co-Authored-By: Claude Sonnet 5 --- apps/api/src/db/recipe-source-sync.ts | 63 +- .../lib/recipe-matching/ingredient-matcher.ts | 50 +- .../lib/recipe-matching/recipe-translation.ts | 35 +- .../lib/recipe-matching/tech-step-matcher.ts | 28 +- apps/api/src/modules/auth/auth.service.ts | 96 ++- apps/api/src/modules/house/house.service.ts | 370 ++++++----- .../src/modules/planning/planning.service.ts | 180 +++--- .../preferences/preferences.service.ts | 30 +- .../src/modules/profile/profile.service.ts | 157 +++-- apps/api/src/modules/recipe/recipe.service.ts | 587 +++++++++++------- .../modules/reference/reference.service.ts | 129 ++-- .../src/modules/sources/sources.service.ts | 257 ++++---- apps/api/src/sources/json-ld-recipe.ts | 37 +- apps/api/src/sources/the-meal-db.ts | 83 ++- apps/web/src/api/client.ts | 49 +- apps/web/src/features/auth/AuthContext.tsx | 38 +- apps/web/src/features/theme/ThemeContext.tsx | 14 +- apps/web/src/layouts/AppLayout.tsx | 11 +- .../pages/settings/HouseholdSettingsPage.tsx | 20 +- biome.json | 3 + specs/dev-conventions.md | 36 ++ 21 files changed, 1415 insertions(+), 858 deletions(-) diff --git a/apps/api/src/db/recipe-source-sync.ts b/apps/api/src/db/recipe-source-sync.ts index 1ffe1be..a18f015 100644 --- a/apps/api/src/db/recipe-source-sync.ts +++ b/apps/api/src/db/recipe-source-sync.ts @@ -25,17 +25,28 @@ import { listRecipeSources } from "../lib/recipe-sources/recipe-source-registry. * themselves at startup). */ export async function syncRecipeSources(prisma: PrismaClient): Promise { - for (const adapter of listRecipeSources()) { - await prisma.source.upsert({ - where: { key: adapter.key }, - update: { name: adapter.name, official: adapter.official, iconUrl: adapter.iconUrl }, - create: { - key: adapter.key, - name: adapter.name, - official: adapter.official, - iconUrl: adapter.iconUrl, - }, - }); + try { + for (const adapter of listRecipeSources()) { + await prisma.source.upsert({ + where: { key: adapter.key }, + update: { + name: adapter.name, + official: adapter.official, + iconUrl: adapter.iconUrl, + }, + create: { + key: adapter.key, + name: adapter.name, + official: adapter.official, + iconUrl: adapter.iconUrl, + }, + }); + } + } catch (err) { + // Rethrown as-is — callers (app startup, `sources.service.ts` via test + // setup) already handle/log failures centrally; this function just + // isn't allowed a bare `await` per the repo's async/try-catch convention. + throw err; } } @@ -56,18 +67,24 @@ export async function findImportedRecipeIds( sourceKey: string, externalIds: string[], ): Promise> { - if (externalIds.length === 0) return new Map(); + try { + if (externalIds.length === 0) return new Map(); - const source = await prisma.source.findUnique({ where: { key: sourceKey } }); - if (!source) return new Map(); + const source = await prisma.source.findUnique({ + where: { key: sourceKey }, + }); + if (!source) return new Map(); - const imported = await prisma.recipe.findMany({ - where: { sourceId: source.id, externalId: { in: externalIds } }, - select: { id: true, externalId: true }, - }); - return new Map( - imported.flatMap((recipe) => - recipe.externalId !== null ? [[recipe.externalId, recipe.id]] : [], - ), - ); + const imported = await prisma.recipe.findMany({ + where: { sourceId: source.id, externalId: { in: externalIds } }, + select: { id: true, externalId: true }, + }); + return new Map( + imported.flatMap((recipe) => + recipe.externalId !== null ? [[recipe.externalId, recipe.id]] : [], + ), + ); + } catch (err) { + throw err; // see syncRecipeSources()'s catch comment above + } } diff --git a/apps/api/src/lib/recipe-matching/ingredient-matcher.ts b/apps/api/src/lib/recipe-matching/ingredient-matcher.ts index a483879..61e7327 100644 --- a/apps/api/src/lib/recipe-matching/ingredient-matcher.ts +++ b/apps/api/src/lib/recipe-matching/ingredient-matcher.ts @@ -103,7 +103,10 @@ export function matchIngredientName(name: string, catalog: IngredientMatchEntry[ labelTokens.length > best.tokenCount || (labelTokens.length === best.tokenCount && entry.ingredientId < best.ingredientId) ) { - best = { ingredientId: entry.ingredientId, tokenCount: labelTokens.length }; + best = { + ingredientId: entry.ingredientId, + tokenCount: labelTokens.length, + }; } } return best?.ingredientId ?? null; @@ -174,26 +177,41 @@ export function extractQuantity(rawText: string): ExtractedQuantity { /** Loads the full `Ingredient` catalog as {@link IngredientMatchEntry}s — one entry per key with an authored English label (see `INGREDIENT_LABELS_EN`), plus one extra entry per alternate wording (`INGREDIENT_LABEL_SYNONYMS_EN`, e.g. "Vanilla pod" alongside "Vanilla bean" — see issue #54) sharing the same `ingredientId`; `matchIngredientName` doesn't need to know synonyms exist, it just sees more candidate labels for the same ingredient. An ingredient with no English label yet is silently skipped, never a matching target. Meant to be fetched once per request and reused across every ingredient line, not re-queried per line. */ export async function loadIngredientCatalog(): Promise { - const ingredients = await prisma.ingredient.findMany({ select: { id: true, key: true } }); - const catalog: IngredientMatchEntry[] = []; - for (const ingredient of ingredients) { - const label = INGREDIENT_LABELS_EN[ingredient.key]; - if (label === undefined) continue; - catalog.push({ ingredientId: ingredient.id, label }); - for (const synonym of INGREDIENT_LABEL_SYNONYMS_EN[ingredient.key] ?? []) { - catalog.push({ ingredientId: ingredient.id, label: synonym }); + try { + const ingredients = await prisma.ingredient.findMany({ + select: { id: true, key: true }, + }); + const catalog: IngredientMatchEntry[] = []; + for (const ingredient of ingredients) { + const label = INGREDIENT_LABELS_EN[ingredient.key]; + if (label === undefined) continue; + catalog.push({ ingredientId: ingredient.id, label }); + for (const synonym of INGREDIENT_LABEL_SYNONYMS_EN[ingredient.key] ?? []) { + catalog.push({ ingredientId: ingredient.id, label: synonym }); + } } + return catalog; + } catch (err) { + // Rethrown as-is — the caller (`sources.service.ts`/`recipe-translation.ts`) + // already handles/logs failures centrally; this function just isn't + // allowed a bare `await` per the repo's async/try-catch convention. + throw err; } - return catalog; } /** Loads the full `Unit` catalog as {@link UnitMatchEntry}s — one entry per key with authored English synonyms (see `UNIT_LABELS_EN`); a unit with none yet is silently skipped. Meant to be fetched once per request, same reasoning as {@link loadIngredientCatalog}. */ export async function loadUnitCatalog(): Promise { - const units = await prisma.unit.findMany({ select: { id: true, key: true } }); - const catalog: UnitMatchEntry[] = []; - for (const unit of units) { - const synonyms = UNIT_LABELS_EN[unit.key]; - if (synonyms !== undefined) catalog.push({ unitId: unit.id, synonyms }); + try { + const units = await prisma.unit.findMany({ + select: { id: true, key: true }, + }); + const catalog: UnitMatchEntry[] = []; + for (const unit of units) { + const synonyms = UNIT_LABELS_EN[unit.key]; + if (synonyms !== undefined) catalog.push({ unitId: unit.id, synonyms }); + } + return catalog; + } catch (err) { + throw err; // see loadIngredientCatalog()'s catch comment above } - return catalog; } diff --git a/apps/api/src/lib/recipe-matching/recipe-translation.ts b/apps/api/src/lib/recipe-matching/recipe-translation.ts index 7211c6d..adcf398 100644 --- a/apps/api/src/lib/recipe-matching/recipe-translation.ts +++ b/apps/api/src/lib/recipe-matching/recipe-translation.ts @@ -175,7 +175,11 @@ function combineIngredientLines( return null; } if (a.unitId === b.unitId) { - return { ...a, quantity: a.quantity + b.quantity, rawText: `${a.rawText} + ${b.rawText}` }; + return { + ...a, + quantity: a.quantity + b.quantity, + rawText: `${a.rawText} + ${b.rawText}`, + }; } const unitA = unitById.get(a.unitId); @@ -274,17 +278,24 @@ export async function translateRecipe( recipe: ParsedRecipe, locale: string, ): Promise { - const techStepMappings = await loadTechStepMappingRules(locale); - const translated = translateRecipeSteps(recipe, techStepMappings); + try { + const techStepMappings = await loadTechStepMappingRules(locale); + const translated = translateRecipeSteps(recipe, techStepMappings); - if (locale !== "en") return translated; + if (locale !== "en") return translated; - const [ingredientCatalog, unitCatalog] = await Promise.all([ - loadIngredientCatalog(), - loadUnitCatalog(), - ]); - return { - ...translated, - ingredients: translateRecipeIngredients(recipe.ingredients, ingredientCatalog, unitCatalog), - }; + const [ingredientCatalog, unitCatalog] = await Promise.all([ + loadIngredientCatalog(), + loadUnitCatalog(), + ]); + return { + ...translated, + ingredients: translateRecipeIngredients(recipe.ingredients, ingredientCatalog, unitCatalog), + }; + } catch (err) { + // Rethrown as-is — the caller (`sources.service.ts`) already + // handles/logs failures centrally; this function just isn't allowed a + // bare `await` per the repo's async/try-catch convention. + throw err; + } } diff --git a/apps/api/src/lib/recipe-matching/tech-step-matcher.ts b/apps/api/src/lib/recipe-matching/tech-step-matcher.ts index 421e559..b37dd2a 100644 --- a/apps/api/src/lib/recipe-matching/tech-step-matcher.ts +++ b/apps/api/src/lib/recipe-matching/tech-step-matcher.ts @@ -124,7 +124,11 @@ export function matchTechStepSpans( const pattern = new RegExp(normalizeText(mapping.expression), "i"); const match = pattern.exec(normalizedDescription); if (match === null) continue; - candidates.push({ ...mapping, start: match.index, end: match.index + match[0].length }); + candidates.push({ + ...mapping, + start: match.index, + end: match.index + match[0].length, + }); } // Step 2: one best candidate per techStepId. @@ -152,7 +156,11 @@ export function matchTechStepSpans( // Step 4: reading order. accepted.sort((a, b) => a.start - b.start || a.techStepId - b.techStepId); - return accepted.map(({ techStepId, start, end }) => ({ techStepId, start, end })); + return accepted.map(({ techStepId, start, end }) => ({ + techStepId, + start, + end, + })); } /** @@ -179,8 +187,16 @@ export function matchTechSteps(description: string, mappings: TechStepMappingRul * module. */ export async function loadTechStepMappingRules(locale: string): Promise { - return prisma.techStepMapping.findMany({ - where: { locale }, - select: { techStepId: true, expression: true, weight: true }, - }); + try { + return await prisma.techStepMapping.findMany({ + where: { locale }, + select: { techStepId: true, expression: true, weight: true }, + }); + } catch (err) { + // Rethrown as-is — the caller (`recipe.service.ts`/`sources.service.ts`) + // already handles/logs failures centrally; this function just isn't + // allowed a bare `async` body without a try/catch per the repo's + // convention. + throw err; + } } diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts index e0bc542..85d0c76 100644 --- a/apps/api/src/modules/auth/auth.service.ts +++ b/apps/api/src/modules/auth/auth.service.ts @@ -35,28 +35,41 @@ const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined; * @throws {HttpError} `409 EMAIL_ALREADY_IN_USE` if the email is already taken. */ export async function signup(input: SignupInput): Promise { - const existing = await prisma.userProfile.findUnique({ where: { email: input.email } }); - if (existing) { - throw new HttpError(409, ErrorCode.EMAIL_ALREADY_IN_USE, "Email already in use"); + try { + const existing = await prisma.userProfile.findUnique({ + where: { email: input.email }, + }); + if (existing) { + throw new HttpError(409, ErrorCode.EMAIL_ALREADY_IN_USE, "Email already in use"); + } + + const passwordHash = await argon2.hash(input.password, hashOptions); + + // No household is created here — it's now an optional step of the + // onboarding wizard (create or join one, or skip — see + // `house.service.ts`'s `createHouse`/`joinHouse`), not an implicit side + // effect of signing up. `houseId` starts out `null`, same as `dietId`. + const profile = await prisma.userProfile.create({ + data: { + firstName: input.firstName, + lastName: input.lastName, + email: input.email, + passwordHash, + }, + }); + + const token = signAuthToken({ + userProfileId: profile.id, + tokenVersion: profile.tokenVersion, + }); + return { profile: toSafeProfile(profile), token }; + } catch (err) { + // Rethrown as-is — `wrapAsyncHandler`/the error middleware (which already + // logs it, see `error-logger.ts`) is what actually handles it, this + // service layer just isn't allowed a bare `await` per the repo's + // async/try-catch convention. + throw err; } - - const passwordHash = await argon2.hash(input.password, hashOptions); - - // No household is created here — it's now an optional step of the - // onboarding wizard (create or join one, or skip — see - // `house.service.ts`'s `createHouse`/`joinHouse`), not an implicit side - // effect of signing up. `houseId` starts out `null`, same as `dietId`. - const profile = await prisma.userProfile.create({ - data: { - firstName: input.firstName, - lastName: input.lastName, - email: input.email, - passwordHash, - }, - }); - - const token = signAuthToken({ userProfileId: profile.id, tokenVersion: profile.tokenVersion }); - return { profile: toSafeProfile(profile), token }; } /** @@ -74,15 +87,21 @@ export async function signup(input: SignupInput): Promise { * @throws {HttpError} `401 INVALID_CREDENTIALS` if the password is wrong. */ export async function deleteAccount(profileId: number, password: string): Promise { - const profile = await prisma.userProfile.findUnique({ where: { id: profileId } }); - if (!profile || !(await argon2.verify(profile.passwordHash, password))) { - throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid password"); - } + try { + const profile = await prisma.userProfile.findUnique({ + where: { id: profileId }, + }); + if (!profile || !(await argon2.verify(profile.passwordHash, password))) { + throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid password"); + } - if (profile.houseId !== null) { - await leaveCurrentHouse(profile.id, profile.houseId); + if (profile.houseId !== null) { + await leaveCurrentHouse(profile.id, profile.houseId); + } + await prisma.userProfile.delete({ where: { id: profile.id } }); + } catch (err) { + throw err; // see signup()'s catch comment above } - await prisma.userProfile.delete({ where: { id: profile.id } }); } /** @@ -93,12 +112,21 @@ export async function deleteAccount(profileId: number, password: string): Promis * caller can never learn whether a given email has an account. */ export async function login(input: LoginInput): Promise { - const profile = await prisma.userProfile.findUnique({ where: { email: input.email } }); + try { + const profile = await prisma.userProfile.findUnique({ + where: { email: input.email }, + }); - if (!profile || !(await argon2.verify(profile.passwordHash, input.password))) { - throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid email or password"); + if (!profile || !(await argon2.verify(profile.passwordHash, input.password))) { + throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid email or password"); + } + + const token = signAuthToken({ + userProfileId: profile.id, + tokenVersion: profile.tokenVersion, + }); + return { profile: toSafeProfile(profile), token }; + } catch (err) { + throw err; // see signup()'s catch comment above } - - const token = signAuthToken({ userProfileId: profile.id, tokenVersion: profile.tokenVersion }); - return { profile: toSafeProfile(profile), token }; } diff --git a/apps/api/src/modules/house/house.service.ts b/apps/api/src/modules/house/house.service.ts index 5f8b2dc..b31cdf8 100644 --- a/apps/api/src/modules/house/house.service.ts +++ b/apps/api/src/modules/house/house.service.ts @@ -44,10 +44,18 @@ const houseWithMembers = { /** Returns the profile's household (with its member list), or `null` if the profile has none yet (`houseId` is `null` — see `SafeUserProfile`). */ export async function getCurrentHouse(houseId: number | null): Promise { - if (houseId === null) { - return null; + try { + if (houseId === null) { + return null; + } + return toHouseView(await findHouseOrThrow(houseId)); + } catch (err) { + // Rethrown as-is — `wrapAsyncHandler`/the error middleware (which + // already logs it, see `error-logger.ts`) is what actually handles it, + // this service layer just isn't allowed a bare `await` per the repo's + // async/try-catch convention. + throw err; } - return toHouseView(await findHouseOrThrow(houseId)); } /** @@ -58,16 +66,20 @@ export async function getCurrentHouse(houseId: number | null): Promise { - if (houseId === null) { - throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household"); + try { + if (houseId === null) { + throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household"); + } + await findHouseOrThrow(houseId); + const house = await prisma.house.update({ + where: { id: houseId }, + data: { name }, + include: houseWithMembers, + }); + return toHouseView(house); + } catch (err) { + throw err; // see getCurrentHouse()'s catch comment above } - await findHouseOrThrow(houseId); - const house = await prisma.house.update({ - where: { id: houseId }, - data: { name }, - include: houseWithMembers, - }); - return toHouseView(house); } /** @@ -81,30 +93,45 @@ export async function createHouse( houseId: number | null, name: string, ): Promise { - if (houseId !== null) { - throw new HttpError(409, ErrorCode.ALREADY_HAS_HOUSE, "Profile already belongs to a household"); - } - - // Astronomically unlikely to collide (33^8 possibilities), but retried - // rather than assumed — a `@unique` constraint failure is the only fully - // reliable way to detect it. - const maxAttempts = 5; - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const house = await prisma.$transaction(async (tx) => { - const created = await tx.house.create({ - data: { name, adminId: profileId, inviteCode: generateInviteCode() }, - }); - await tx.userProfile.update({ where: { id: profileId }, data: { houseId: created.id } }); - return created; - }); - return getCurrentHouseOrThrow(house.id); - } catch (err) { - if (isUniqueInviteCodeViolation(err) && attempt < maxAttempts) continue; - throw err; + try { + if (houseId !== null) { + throw new HttpError( + 409, + ErrorCode.ALREADY_HAS_HOUSE, + "Profile already belongs to a household", + ); } + + // Astronomically unlikely to collide (33^8 possibilities), but retried + // rather than assumed — a `@unique` constraint failure is the only fully + // reliable way to detect it. + const maxAttempts = 5; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const house = await prisma.$transaction(async (tx) => { + const created = await tx.house.create({ + data: { + name, + adminId: profileId, + inviteCode: generateInviteCode(), + }, + }); + await tx.userProfile.update({ + where: { id: profileId }, + data: { houseId: created.id }, + }); + return created; + }); + return await getCurrentHouseOrThrow(house.id); + } catch (err) { + if (isUniqueInviteCodeViolation(err) && attempt < maxAttempts) continue; + throw err; + } + } + throw new Error("Failed to generate a unique invite code after several attempts"); + } catch (err) { + throw err; // see getCurrentHouse()'s catch comment above } - throw new Error("Failed to generate a unique invite code after several attempts"); } /** @@ -118,21 +145,32 @@ export async function joinHouse( houseId: number | null, inviteCode: string, ): Promise { - if (houseId !== null) { - throw new HttpError(409, ErrorCode.ALREADY_HAS_HOUSE, "Profile already belongs to a household"); - } + try { + if (houseId !== null) { + throw new HttpError( + 409, + ErrorCode.ALREADY_HAS_HOUSE, + "Profile already belongs to a household", + ); + } - const house = await prisma.house.findUnique({ where: { inviteCode } }); - if (!house) { - throw new HttpError( - 404, - ErrorCode.INVITE_CODE_NOT_FOUND, - "No household matches this invite code", - ); - } + const house = await prisma.house.findUnique({ where: { inviteCode } }); + if (!house) { + throw new HttpError( + 404, + ErrorCode.INVITE_CODE_NOT_FOUND, + "No household matches this invite code", + ); + } - await prisma.userProfile.update({ where: { id: profileId }, data: { houseId: house.id } }); - return getCurrentHouseOrThrow(house.id); + await prisma.userProfile.update({ + where: { id: profileId }, + data: { houseId: house.id }, + }); + return await getCurrentHouseOrThrow(house.id); + } catch (err) { + throw err; // see getCurrentHouse()'s catch comment above + } } /** @@ -149,28 +187,38 @@ export async function joinHouse( * @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household. */ export async function leaveCurrentHouse(profileId: number, houseId: number | null): Promise { - if (houseId === null) { - throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household"); + try { + if (houseId === null) { + throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household"); + } + + const house = await findHouseOrThrow(houseId); + const remainingMembers = house.members.filter((member) => member.id !== profileId); + + await prisma.$transaction(async (tx) => { + await tx.userProfile.update({ + where: { id: profileId }, + data: { houseId: null }, + }); + + if (house.adminId !== profileId) { + return; + } + if (remainingMembers.length === 0) { + await tx.house.delete({ where: { id: house.id } }); + return; + } + const nextAdmin = remainingMembers.reduce((oldest, member) => + member.id < oldest.id ? member : oldest, + ); + await tx.house.update({ + where: { id: house.id }, + data: { adminId: nextAdmin.id }, + }); + }); + } catch (err) { + throw err; // see getCurrentHouse()'s catch comment above } - - const house = await findHouseOrThrow(houseId); - const remainingMembers = house.members.filter((member) => member.id !== profileId); - - await prisma.$transaction(async (tx) => { - await tx.userProfile.update({ where: { id: profileId }, data: { houseId: null } }); - - if (house.adminId !== profileId) { - return; - } - if (remainingMembers.length === 0) { - await tx.house.delete({ where: { id: house.id } }); - return; - } - const nextAdmin = remainingMembers.reduce((oldest, member) => - member.id < oldest.id ? member : oldest, - ); - await tx.house.update({ where: { id: house.id }, data: { adminId: nextAdmin.id } }); - }); } /** @@ -183,21 +231,32 @@ export async function leaveCurrentHouse(profileId: number, houseId: number | nul * @throws {HttpError} `403 NOT_HOUSE_ADMIN` if the profile isn't this household's admin. */ export async function deleteHouse(profileId: number, houseId: number | null): Promise { - if (houseId === null) { - throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household"); - } - const house = await findHouseOrThrow(houseId); - if (house.adminId !== profileId) { - throw new HttpError(403, ErrorCode.NOT_HOUSE_ADMIN, "Only the household's admin can delete it"); - } + try { + if (houseId === null) { + throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household"); + } + const house = await findHouseOrThrow(houseId); + if (house.adminId !== profileId) { + throw new HttpError( + 403, + ErrorCode.NOT_HOUSE_ADMIN, + "Only the household's admin can delete it", + ); + } - // Members' houseId also cascades to null via the FK's onDelete: SetNull, - // but clearing it explicitly first keeps the outcome obvious without - // relying on that FK behavior being read alongside this function. - await prisma.$transaction([ - prisma.userProfile.updateMany({ where: { houseId: house.id }, data: { houseId: null } }), - prisma.house.delete({ where: { id: house.id } }), - ]); + // Members' houseId also cascades to null via the FK's onDelete: SetNull, + // but clearing it explicitly first keeps the outcome obvious without + // relying on that FK behavior being read alongside this function. + await prisma.$transaction([ + prisma.userProfile.updateMany({ + where: { houseId: house.id }, + data: { houseId: null }, + }), + prisma.house.delete({ where: { id: house.id } }), + ]); + } catch (err) { + throw err; // see getCurrentHouse()'s catch comment above + } } /** @@ -215,30 +274,37 @@ export async function removeMember( houseId: number | null, targetMemberId: number, ): Promise { - if (houseId === null) { - throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household"); - } - const house = await findHouseOrThrow(houseId); - if (house.adminId !== profileId) { - throw new HttpError( - 403, - ErrorCode.NOT_HOUSE_ADMIN, - "Only the household's admin can remove a member", - ); - } - if (targetMemberId === profileId) { - throw new HttpError( - 400, - ErrorCode.VALIDATION_ERROR, - "Use POST /house/leave to remove yourself", - ); - } - if (!house.members.some((member) => member.id === targetMemberId)) { - throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "Not a member of this household"); - } + try { + if (houseId === null) { + throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household"); + } + const house = await findHouseOrThrow(houseId); + if (house.adminId !== profileId) { + throw new HttpError( + 403, + ErrorCode.NOT_HOUSE_ADMIN, + "Only the household's admin can remove a member", + ); + } + if (targetMemberId === profileId) { + throw new HttpError( + 400, + ErrorCode.VALIDATION_ERROR, + "Use POST /house/leave to remove yourself", + ); + } + if (!house.members.some((member) => member.id === targetMemberId)) { + throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "Not a member of this household"); + } - await prisma.userProfile.update({ where: { id: targetMemberId }, data: { houseId: null } }); - return getCurrentHouseOrThrow(house.id); + await prisma.userProfile.update({ + where: { id: targetMemberId }, + data: { houseId: null }, + }); + return await getCurrentHouseOrThrow(house.id); + } catch (err) { + throw err; // see getCurrentHouse()'s catch comment above + } } /** @@ -249,14 +315,18 @@ export async function removeMember( * @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet. */ export async function getHouseSourceIds(houseId: number | null): Promise { - if (houseId === null) { - throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household"); + try { + if (houseId === null) { + throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household"); + } + const rows = await prisma.houseSource.findMany({ + where: { houseId }, + select: { sourceId: true }, + }); + return rows.map((row) => row.sourceId); + } catch (err) { + throw err; // see getCurrentHouse()'s catch comment above } - const rows = await prisma.houseSource.findMany({ - where: { houseId }, - select: { sourceId: true }, - }); - return rows.map((row) => row.sourceId); } /** @@ -273,36 +343,46 @@ export async function updateHouseSources( houseId: number | null, sourceIds: number[], ): Promise { - if (houseId === null) { - throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household"); - } - if (sourceIds.length > 0) { - const found = await prisma.source.findMany({ - where: { id: { in: sourceIds } }, - select: { id: true }, - }); - const foundIds = new Set(found.map((source) => source.id)); - const missing = sourceIds.filter((id) => !foundIds.has(id)); - if (missing.length > 0) { - throw new HttpError( - 404, - ErrorCode.SOURCE_NOT_FOUND, - `Unknown source id(s): ${missing.join(", ")}`, - ); + try { + if (houseId === null) { + throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household"); } + if (sourceIds.length > 0) { + const found = await prisma.source.findMany({ + where: { id: { in: sourceIds } }, + select: { id: true }, + }); + const foundIds = new Set(found.map((source) => source.id)); + const missing = sourceIds.filter((id) => !foundIds.has(id)); + if (missing.length > 0) { + throw new HttpError( + 404, + ErrorCode.SOURCE_NOT_FOUND, + `Unknown source id(s): ${missing.join(", ")}`, + ); + } + } + + await prisma.$transaction([ + prisma.houseSource.deleteMany({ where: { houseId } }), + prisma.houseSource.createMany({ + data: sourceIds.map((sourceId) => ({ houseId, sourceId })), + }), + ]); + + return sourceIds; + } catch (err) { + throw err; // see getCurrentHouse()'s catch comment above } - - await prisma.$transaction([ - prisma.houseSource.deleteMany({ where: { houseId } }), - prisma.houseSource.createMany({ data: sourceIds.map((sourceId) => ({ houseId, sourceId })) }), - ]); - - return sourceIds; } /** Re-fetches a household by id (as a {@link HouseView}) once its id is already known to be valid — the common "reload after a mutation" step shared by several functions above. */ async function getCurrentHouseOrThrow(houseId: number): Promise { - return toHouseView(await findHouseOrThrow(houseId)); + try { + return toHouseView(await findHouseOrThrow(houseId)); + } catch (err) { + throw err; // see getCurrentHouse()'s catch comment above + } } /** True if `err` is Prisma's unique-constraint violation (`P2002`) on `invite_code` — the only expected cause of a collision retry in {@link createHouse}. */ @@ -324,12 +404,16 @@ function isUniqueInviteCodeViolation(err: unknown): boolean { * `HOUSE_NOT_FOUND` HttpError. */ async function findHouseOrThrow(houseId: number) { - const house = await prisma.house.findUnique({ - where: { id: houseId }, - include: houseWithMembers, - }); - if (!house) { - throw new Error(`House ${houseId} referenced by a profile but not found`); + try { + const house = await prisma.house.findUnique({ + where: { id: houseId }, + include: houseWithMembers, + }); + if (!house) { + throw new Error(`House ${houseId} referenced by a profile but not found`); + } + return house; + } catch (err) { + throw err; // see getCurrentHouse()'s catch comment above } - return house; } diff --git a/apps/api/src/modules/planning/planning.service.ts b/apps/api/src/modules/planning/planning.service.ts index e548142..23146e1 100644 --- a/apps/api/src/modules/planning/planning.service.ts +++ b/apps/api/src/modules/planning/planning.service.ts @@ -27,49 +27,57 @@ export async function getPlanningForDate( houseId: number | null, date: DateTime, ): Promise { - if (houseId === null) { - return null; - } + try { + if (houseId === null) { + return null; + } - // `startDate`/`finishDate` are `@db.Date` columns (no time-of-day - // component) — comparing against a UTC-midnight JS `Date` lines up with - // how Postgres stores/returns them, regardless of the server's local - // timezone. - const dateOnly = toDateOnly(date).toJSDate(); + // `startDate`/`finishDate` are `@db.Date` columns (no time-of-day + // component) — comparing against a UTC-midnight JS `Date` lines up with + // how Postgres stores/returns them, regardless of the server's local + // timezone. + const dateOnly = toDateOnly(date).toJSDate(); - const planning = await prisma.planning.findFirst({ - where: { - houseId, - startDate: { lte: dateOnly }, - finishDate: { gte: dateOnly }, - }, - // A household should never have two plannings covering the same day, - // but nothing in the schema enforces that yet — pick the most recently - // started one rather than letting the query fail if it ever happens. - orderBy: { startDate: "desc" }, - include: { - items: { - include: { recipe: { select: { id: true, name: true } } }, + const planning = await prisma.planning.findFirst({ + where: { + houseId, + startDate: { lte: dateOnly }, + finishDate: { gte: dateOnly }, }, - }, - }); + // A household should never have two plannings covering the same day, + // but nothing in the schema enforces that yet — pick the most recently + // started one rather than letting the query fail if it ever happens. + orderBy: { startDate: "desc" }, + include: { + items: { + include: { recipe: { select: { id: true, name: true } } }, + }, + }, + }); - if (!planning) { - return null; + if (!planning) { + return null; + } + + return { + id: planning.id, + startDate: planning.startDate.toISOString(), + finishDate: planning.finishDate.toISOString(), + items: planning.items.map((item) => ({ + id: item.id, + weekDay: item.weekDay, + meal: item.meal, + portions: item.portions, + recipe: item.recipe, + })), + }; + } catch (err) { + // Rethrown as-is — `wrapAsyncHandler`/the error middleware (which + // already logs it, see `error-logger.ts`) is what actually handles it, + // this service layer just isn't allowed a bare `await` per the repo's + // async/try-catch convention. + throw err; } - - return { - id: planning.id, - startDate: planning.startDate.toISOString(), - finishDate: planning.finishDate.toISOString(), - items: planning.items.map((item) => ({ - id: item.id, - weekDay: item.weekDay, - meal: item.meal, - portions: item.portions, - recipe: item.recipe, - })), - }; } /** @@ -88,14 +96,24 @@ export async function getPlanningForDate( * scale rather than adding a migration + retry-on-conflict loop for it. */ async function findOrCreatePlanningForWeek(houseId: number, weekStart: DateTime) { - const startDate = weekStart.toJSDate(); - const existing = await prisma.planning.findFirst({ where: { houseId, startDate } }); - if (existing) { - return existing; + try { + const startDate = weekStart.toJSDate(); + const existing = await prisma.planning.findFirst({ + where: { houseId, startDate }, + }); + if (existing) { + return existing; + } + return await prisma.planning.create({ + data: { + houseId, + startDate, + finishDate: weekStart.plus({ days: 6 }).toJSDate(), + }, + }); + } catch (err) { + throw err; // see getPlanningForDate()'s catch comment above } - return prisma.planning.create({ - data: { houseId, startDate, finishDate: weekStart.plus({ days: 6 }).toJSDate() }, - }); } /** @@ -119,32 +137,36 @@ export async function addPlanningItem( date: DateTime, input: AddPlanningItemInput, ): Promise { - if (houseId === null) { - throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household"); + try { + if (houseId === null) { + throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household"); + } + await assertRecipeVisible(input.recipeId, viewerId, viewerHouseId); + + const weekStart = getWeekStart(toDateOnly(date)); + const planning = await findOrCreatePlanningForWeek(houseId, weekStart); + + const item = await prisma.planningItem.create({ + data: { + planningId: planning.id, + weekDay: input.weekDay, + meal: input.meal, + recipeId: input.recipeId, + portions: input.portions, + }, + include: { recipe: { select: { id: true, name: true } } }, + }); + + return { + id: item.id, + weekDay: item.weekDay, + meal: item.meal, + portions: item.portions, + recipe: item.recipe, + }; + } catch (err) { + throw err; // see getPlanningForDate()'s catch comment above } - await assertRecipeVisible(input.recipeId, viewerId, viewerHouseId); - - const weekStart = getWeekStart(toDateOnly(date)); - const planning = await findOrCreatePlanningForWeek(houseId, weekStart); - - const item = await prisma.planningItem.create({ - data: { - planningId: planning.id, - weekDay: input.weekDay, - meal: input.meal, - recipeId: input.recipeId, - portions: input.portions, - }, - include: { recipe: { select: { id: true, name: true } } }, - }); - - return { - id: item.id, - weekDay: item.weekDay, - meal: item.meal, - portions: item.portions, - recipe: item.recipe, - }; } /** @@ -156,12 +178,16 @@ export async function addPlanningItem( * @throws {HttpError} `404 PLANNING_ITEM_NOT_FOUND` if `id` doesn't match any planning item, or does but belongs to a planning outside `houseId` — never `403`, same "don't confirm what exists" reasoning as `RECIPE_NOT_FOUND` elsewhere. */ export async function removePlanningItem(id: number, houseId: number | null): Promise { - const item = await prisma.planningItem.findUnique({ - where: { id }, - include: { planning: true }, - }); - if (!item || houseId === null || item.planning.houseId !== houseId) { - throw new HttpError(404, ErrorCode.PLANNING_ITEM_NOT_FOUND, `Planning item ${id} not found`); + try { + const item = await prisma.planningItem.findUnique({ + where: { id }, + include: { planning: true }, + }); + if (!item || houseId === null || item.planning.houseId !== houseId) { + throw new HttpError(404, ErrorCode.PLANNING_ITEM_NOT_FOUND, `Planning item ${id} not found`); + } + await prisma.planningItem.delete({ where: { id } }); + } catch (err) { + throw err; // see getPlanningForDate()'s catch comment above } - await prisma.planningItem.delete({ where: { id } }); } diff --git a/apps/api/src/modules/preferences/preferences.service.ts b/apps/api/src/modules/preferences/preferences.service.ts index 6e6d2a0..4ad70b5 100644 --- a/apps/api/src/modules/preferences/preferences.service.ts +++ b/apps/api/src/modules/preferences/preferences.service.ts @@ -9,8 +9,18 @@ import { prisma } from "../../db/prisma.js"; * just to read it. */ export async function getPreferences(userProfileId: number): Promise { - const preferences = await prisma.userPreference.findUnique({ where: { userProfileId } }); - return { theme: preferences?.theme ?? "SYSTEM" }; + try { + const preferences = await prisma.userPreference.findUnique({ + where: { userProfileId }, + }); + return { theme: preferences?.theme ?? "SYSTEM" }; + } catch (err) { + // Rethrown as-is — `wrapAsyncHandler`/the error middleware (which + // already logs it, see `error-logger.ts`) is what actually handles it, + // this service layer just isn't allowed a bare `await` per the repo's + // async/try-catch convention. + throw err; + } } /** @@ -22,10 +32,14 @@ export async function updatePreferences( userProfileId: number, theme: ThemePreference, ): Promise { - const preferences = await prisma.userPreference.upsert({ - where: { userProfileId }, - create: { userProfileId, theme }, - update: { theme }, - }); - return { theme: preferences.theme }; + try { + const preferences = await prisma.userPreference.upsert({ + where: { userProfileId }, + create: { userProfileId, theme }, + update: { theme }, + }); + return { theme: preferences.theme }; + } catch (err) { + throw err; // see getPreferences()'s catch comment above + } } diff --git a/apps/api/src/modules/profile/profile.service.ts b/apps/api/src/modules/profile/profile.service.ts index 39699bf..1b0197d 100644 --- a/apps/api/src/modules/profile/profile.service.ts +++ b/apps/api/src/modules/profile/profile.service.ts @@ -14,27 +14,39 @@ export async function updateDiet( userProfileId: number, dietId: number | null, ): Promise { - if (dietId !== null) { - const diet = await prisma.diet.findUnique({ where: { id: dietId } }); - if (!diet) { - throw new HttpError(404, ErrorCode.DIET_NOT_FOUND, `No diet with id ${dietId}`); + try { + if (dietId !== null) { + const diet = await prisma.diet.findUnique({ where: { id: dietId } }); + if (!diet) { + throw new HttpError(404, ErrorCode.DIET_NOT_FOUND, `No diet with id ${dietId}`); + } } - } - const profile = await prisma.userProfile.update({ - where: { id: userProfileId }, - data: { dietId }, - }); - return toSafeProfile(profile); + const profile = await prisma.userProfile.update({ + where: { id: userProfileId }, + data: { dietId }, + }); + return toSafeProfile(profile); + } catch (err) { + // Rethrown as-is — `wrapAsyncHandler`/the error middleware (which + // already logs it, see `error-logger.ts`) is what actually handles it, + // this service layer just isn't allowed a bare `await` per the repo's + // async/try-catch convention. + throw err; + } } /** Current allergen ids for a profile — an empty array is normal (no allergies declared, or the step was skipped). */ export async function getAllergyIds(userProfileId: number): Promise { - const rows = await prisma.userProfileAllergy.findMany({ - where: { userProfileId }, - select: { allergyId: true }, - }); - return rows.map((row) => row.allergyId); + try { + const rows = await prisma.userProfileAllergy.findMany({ + where: { userProfileId }, + select: { allergyId: true }, + }); + return rows.map((row) => row.allergyId); + } catch (err) { + throw err; // see updateDiet()'s catch comment above + } } /** @@ -49,39 +61,47 @@ export async function updateAllergies( userProfileId: number, allergyIds: number[], ): Promise { - if (allergyIds.length > 0) { - const found = await prisma.allergy.findMany({ - where: { id: { in: allergyIds } }, - select: { id: true }, - }); - const foundIds = new Set(found.map((allergy) => allergy.id)); - const missing = allergyIds.filter((id) => !foundIds.has(id)); - if (missing.length > 0) { - throw new HttpError( - 404, - ErrorCode.ALLERGY_NOT_FOUND, - `Unknown allergy id(s): ${missing.join(", ")}`, - ); + try { + if (allergyIds.length > 0) { + const found = await prisma.allergy.findMany({ + where: { id: { in: allergyIds } }, + select: { id: true }, + }); + const foundIds = new Set(found.map((allergy) => allergy.id)); + const missing = allergyIds.filter((id) => !foundIds.has(id)); + if (missing.length > 0) { + throw new HttpError( + 404, + ErrorCode.ALLERGY_NOT_FOUND, + `Unknown allergy id(s): ${missing.join(", ")}`, + ); + } } + + await prisma.$transaction([ + prisma.userProfileAllergy.deleteMany({ where: { userProfileId } }), + prisma.userProfileAllergy.createMany({ + data: allergyIds.map((allergyId) => ({ userProfileId, allergyId })), + }), + ]); + + return allergyIds; + } catch (err) { + throw err; // see updateDiet()'s catch comment above } - - await prisma.$transaction([ - prisma.userProfileAllergy.deleteMany({ where: { userProfileId } }), - prisma.userProfileAllergy.createMany({ - data: allergyIds.map((allergyId) => ({ userProfileId, allergyId })), - }), - ]); - - return allergyIds; } /** Current disliked-ingredient ids for a profile — an empty array is normal (no dislikes declared). A taste preference, not a medical restriction — see {@link getAllergyIds} for that distinct list. */ export async function getDislikedIngredientIds(userProfileId: number): Promise { - const rows = await prisma.userProfileDislikedIngredient.findMany({ - where: { userProfileId }, - select: { ingredientId: true }, - }); - return rows.map((row) => row.ingredientId); + try { + const rows = await prisma.userProfileDislikedIngredient.findMany({ + where: { userProfileId }, + select: { ingredientId: true }, + }); + return rows.map((row) => row.ingredientId); + } catch (err) { + throw err; // see updateDiet()'s catch comment above + } } /** @@ -95,28 +115,37 @@ export async function updateDislikedIngredients( userProfileId: number, dislikedIngredientIds: number[], ): Promise { - if (dislikedIngredientIds.length > 0) { - const found = await prisma.ingredient.findMany({ - where: { id: { in: dislikedIngredientIds } }, - select: { id: true }, - }); - const foundIds = new Set(found.map((ingredient) => ingredient.id)); - const missing = dislikedIngredientIds.filter((id) => !foundIds.has(id)); - if (missing.length > 0) { - throw new HttpError( - 404, - ErrorCode.INGREDIENT_NOT_FOUND, - `Unknown ingredient id(s): ${missing.join(", ")}`, - ); + try { + if (dislikedIngredientIds.length > 0) { + const found = await prisma.ingredient.findMany({ + where: { id: { in: dislikedIngredientIds } }, + select: { id: true }, + }); + const foundIds = new Set(found.map((ingredient) => ingredient.id)); + const missing = dislikedIngredientIds.filter((id) => !foundIds.has(id)); + if (missing.length > 0) { + throw new HttpError( + 404, + ErrorCode.INGREDIENT_NOT_FOUND, + `Unknown ingredient id(s): ${missing.join(", ")}`, + ); + } } + + await prisma.$transaction([ + prisma.userProfileDislikedIngredient.deleteMany({ + where: { userProfileId }, + }), + prisma.userProfileDislikedIngredient.createMany({ + data: dislikedIngredientIds.map((ingredientId) => ({ + userProfileId, + ingredientId, + })), + }), + ]); + + return dislikedIngredientIds; + } catch (err) { + throw err; // see updateDiet()'s catch comment above } - - await prisma.$transaction([ - prisma.userProfileDislikedIngredient.deleteMany({ where: { userProfileId } }), - prisma.userProfileDislikedIngredient.createMany({ - data: dislikedIngredientIds.map((ingredientId) => ({ userProfileId, ingredientId })), - }), - ]); - - return dislikedIngredientIds; } diff --git a/apps/api/src/modules/recipe/recipe.service.ts b/apps/api/src/modules/recipe/recipe.service.ts index 10801e7..64a2e1b 100644 --- a/apps/api/src/modules/recipe/recipe.service.ts +++ b/apps/api/src/modules/recipe/recipe.service.ts @@ -33,7 +33,9 @@ function recipeInclude(viewerId: number) { include: { ingredient: { include: { - allergies: { include: { allergy: { include: { category: true } } } }, + allergies: { + include: { allergy: { include: { category: true } } }, + }, diets: { include: { diet: true } }, }, }, @@ -42,20 +44,29 @@ function recipeInclude(viewerId: number) { }, steps: { orderBy: { order: "asc" }, - include: { techSteps: { orderBy: { order: "asc" }, include: { techStep: true } } }, + include: { + techSteps: { orderBy: { order: "asc" }, include: { techStep: true } }, + }, }, diets: { include: { diet: true } }, favoritedBy: { where: { userProfileId: viewerId } }, } satisfies Prisma.RecipeInclude; } -type RecipeWithDetails = Prisma.RecipeGetPayload<{ include: ReturnType }>; +type RecipeWithDetails = Prisma.RecipeGetPayload<{ + include: ReturnType; +}>; type IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"]; type UnitWithDetails = RecipeWithDetails["ingredients"][number]["unit"]; /** Shapes a Prisma `Unit` row into the public {@link UnitView} — same "Decimal → number" conversion `reference.service.ts`'s `getUnits` does. */ function toUnitView(unit: UnitWithDetails): UnitView { - return { id: unit.id, key: unit.key, type: unit.type, toBaseFactor: Number(unit.toBaseFactor) }; + return { + id: unit.id, + key: unit.key, + type: unit.type, + toBaseFactor: Number(unit.toBaseFactor), + }; } /** Shapes a Prisma `Ingredient` (with its `allergies`/`diets` relations included) into the public {@link IngredientView} — same aplattening as `reference.service.ts`'s `getIngredients`. */ @@ -124,7 +135,10 @@ function toStepTechStepViews( for (const stepTechStep of techSteps) { if (stepTechStep.start === null || stepTechStep.end === null) continue; views.push({ - techStep: { id: stepTechStep.techStep.id, key: stepTechStep.techStep.key }, + techStep: { + id: stepTechStep.techStep.id, + key: stepTechStep.techStep.key, + }, start: stepTechStep.start, end: stepTechStep.end, }); @@ -160,7 +174,11 @@ function toRecipeView(recipe: RecipeWithDetails): RecipeView { * `RecipeVisibility` in schema.prisma. */ function canView( - recipe: { authorId: number; authorHouseId: number | null; visibility: string }, + recipe: { + authorId: number; + authorHouseId: number | null; + visibility: string; + }, viewerId: number, viewerHouseId: number | null, ): boolean { @@ -204,32 +222,46 @@ function visibleToViewerWhere( * belong in a filter framed around what's safe/appropriate to serve. */ async function suitableForHouseholdWhere(houseId: number): Promise { - const members = await prisma.userProfile.findMany({ - where: { houseId }, - select: { dietId: true, allergies: { select: { allergyId: true } } }, - }); - const requiredDietIds = [ - ...new Set(members.map((m) => m.dietId).filter((id): id is number => id !== null)), - ]; - const excludedAllergyIds = [ - ...new Set(members.flatMap((m) => m.allergies.map((a) => a.allergyId))), - ]; - - const conditions: Prisma.RecipeWhereInput[] = []; - if (requiredDietIds.length > 0) { - // Every diet declared by a member must be among this recipe's tags — - // not "at least one", since a recipe suiting a vegetarian member - // doesn't automatically suit a gluten-free one too. - conditions.push({ AND: requiredDietIds.map((dietId) => ({ diets: { some: { dietId } } })) }); - } - if (excludedAllergyIds.length > 0) { - conditions.push({ - ingredients: { - none: { ingredient: { allergies: { some: { allergyId: { in: excludedAllergyIds } } } } }, - }, + try { + const members = await prisma.userProfile.findMany({ + where: { houseId }, + select: { dietId: true, allergies: { select: { allergyId: true } } }, }); + const requiredDietIds = [ + ...new Set(members.map((m) => m.dietId).filter((id): id is number => id !== null)), + ]; + const excludedAllergyIds = [ + ...new Set(members.flatMap((m) => m.allergies.map((a) => a.allergyId))), + ]; + + const conditions: Prisma.RecipeWhereInput[] = []; + if (requiredDietIds.length > 0) { + // Every diet declared by a member must be among this recipe's tags — + // not "at least one", since a recipe suiting a vegetarian member + // doesn't automatically suit a gluten-free one too. + conditions.push({ + AND: requiredDietIds.map((dietId) => ({ diets: { some: { dietId } } })), + }); + } + if (excludedAllergyIds.length > 0) { + conditions.push({ + ingredients: { + none: { + ingredient: { + allergies: { some: { allergyId: { in: excludedAllergyIds } } }, + }, + }, + }, + }); + } + return { AND: conditions }; + } catch (err) { + // Rethrown as-is — `wrapAsyncHandler`/the error middleware (which + // already logs it, see `error-logger.ts`) is what actually handles it, + // this service layer just isn't allowed a bare `await`/`async` body + // without a try/catch per the repo's convention. + throw err; } - return { AND: conditions }; } /** @@ -244,13 +276,20 @@ async function suitableForHouseholdWhere(houseId: number): Promise