feat(convention): impose try/catch autour de chaque await/corps async

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 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-08-21 12:00:39 +02:00
parent 82c09331dc
commit d98f3450c0
21 changed files with 1415 additions and 858 deletions

View file

@ -25,10 +25,15 @@ import { listRecipeSources } from "../lib/recipe-sources/recipe-source-registry.
* themselves at startup).
*/
export async function syncRecipeSources(prisma: PrismaClient): Promise<void> {
try {
for (const adapter of listRecipeSources()) {
await prisma.source.upsert({
where: { key: adapter.key },
update: { name: adapter.name, official: adapter.official, iconUrl: adapter.iconUrl },
update: {
name: adapter.name,
official: adapter.official,
iconUrl: adapter.iconUrl,
},
create: {
key: adapter.key,
name: adapter.name,
@ -37,6 +42,12 @@ export async function syncRecipeSources(prisma: PrismaClient): Promise<void> {
},
});
}
} 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,9 +67,12 @@ export async function findImportedRecipeIds(
sourceKey: string,
externalIds: string[],
): Promise<Map<string, number>> {
try {
if (externalIds.length === 0) return new Map();
const source = await prisma.source.findUnique({ where: { key: sourceKey } });
const source = await prisma.source.findUnique({
where: { key: sourceKey },
});
if (!source) return new Map();
const imported = await prisma.recipe.findMany({
@ -70,4 +84,7 @@ export async function findImportedRecipeIds(
recipe.externalId !== null ? [[recipe.externalId, recipe.id]] : [],
),
);
} catch (err) {
throw err; // see syncRecipeSources()'s catch comment above
}
}

View file

@ -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,7 +177,10 @@ 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<IngredientMatchEntry[]> {
const ingredients = await prisma.ingredient.findMany({ select: { id: true, key: true } });
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];
@ -185,15 +191,27 @@ export async function loadIngredientCatalog(): Promise<IngredientMatchEntry[]> {
}
}
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;
}
}
/** 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<UnitMatchEntry[]> {
const units = await prisma.unit.findMany({ select: { id: true, key: true } });
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
}
}

View file

@ -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,6 +278,7 @@ export async function translateRecipe(
recipe: ParsedRecipe,
locale: string,
): Promise<TranslatedRecipe> {
try {
const techStepMappings = await loadTechStepMappingRules(locale);
const translated = translateRecipeSteps(recipe, techStepMappings);
@ -287,4 +292,10 @@ export async function translateRecipe(
...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;
}
}

View file

@ -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<TechStepMappingRule[]> {
return prisma.techStepMapping.findMany({
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;
}
}

View file

@ -35,7 +35,10 @@ 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<AuthResult> {
const existing = await prisma.userProfile.findUnique({ where: { email: input.email } });
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");
}
@ -55,8 +58,18 @@ export async function signup(input: SignupInput): Promise<AuthResult> {
},
});
const token = signAuthToken({ userProfileId: profile.id, tokenVersion: profile.tokenVersion });
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;
}
}
/**
@ -74,7 +87,10 @@ export async function signup(input: SignupInput): Promise<AuthResult> {
* @throws {HttpError} `401 INVALID_CREDENTIALS` if the password is wrong.
*/
export async function deleteAccount(profileId: number, password: string): Promise<void> {
const profile = await prisma.userProfile.findUnique({ where: { id: profileId } });
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");
}
@ -83,6 +99,9 @@ export async function deleteAccount(profileId: number, password: string): Promis
await leaveCurrentHouse(profile.id, profile.houseId);
}
await prisma.userProfile.delete({ where: { id: profile.id } });
} catch (err) {
throw err; // see signup()'s catch comment above
}
}
/**
@ -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<AuthResult> {
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");
}
const token = signAuthToken({ userProfileId: profile.id, tokenVersion: profile.tokenVersion });
const token = signAuthToken({
userProfileId: profile.id,
tokenVersion: profile.tokenVersion,
});
return { profile: toSafeProfile(profile), token };
} catch (err) {
throw err; // see signup()'s catch comment above
}
}

View file

@ -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<HouseView | 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;
}
}
/**
@ -58,6 +66,7 @@ export async function getCurrentHouse(houseId: number | null): Promise<HouseView
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
*/
export async function renameHouse(houseId: number | null, name: string): Promise<HouseView> {
try {
if (houseId === null) {
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
}
@ -68,6 +77,9 @@ export async function renameHouse(houseId: number | null, name: string): Promise
include: houseWithMembers,
});
return toHouseView(house);
} catch (err) {
throw err; // see getCurrentHouse()'s catch comment above
}
}
/**
@ -81,8 +93,13 @@ export async function createHouse(
houseId: number | null,
name: string,
): Promise<HouseView> {
try {
if (houseId !== null) {
throw new HttpError(409, ErrorCode.ALREADY_HAS_HOUSE, "Profile already belongs to a household");
throw new HttpError(
409,
ErrorCode.ALREADY_HAS_HOUSE,
"Profile already belongs to a household",
);
}
// Astronomically unlikely to collide (33^8 possibilities), but retried
@ -93,18 +110,28 @@ export async function createHouse(
try {
const house = await prisma.$transaction(async (tx) => {
const created = await tx.house.create({
data: { name, adminId: profileId, inviteCode: generateInviteCode() },
data: {
name,
adminId: profileId,
inviteCode: generateInviteCode(),
},
});
await tx.userProfile.update({
where: { id: profileId },
data: { houseId: created.id },
});
await tx.userProfile.update({ where: { id: profileId }, data: { houseId: created.id } });
return created;
});
return getCurrentHouseOrThrow(house.id);
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
}
}
/**
@ -118,8 +145,13 @@ export async function joinHouse(
houseId: number | null,
inviteCode: string,
): Promise<HouseView> {
try {
if (houseId !== null) {
throw new HttpError(409, ErrorCode.ALREADY_HAS_HOUSE, "Profile already belongs to a household");
throw new HttpError(
409,
ErrorCode.ALREADY_HAS_HOUSE,
"Profile already belongs to a household",
);
}
const house = await prisma.house.findUnique({ where: { inviteCode } });
@ -131,8 +163,14 @@ export async function joinHouse(
);
}
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,6 +187,7 @@ 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<void> {
try {
if (houseId === null) {
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
}
@ -157,7 +196,10 @@ export async function leaveCurrentHouse(profileId: number, houseId: number | nul
const remainingMembers = house.members.filter((member) => member.id !== profileId);
await prisma.$transaction(async (tx) => {
await tx.userProfile.update({ where: { id: profileId }, data: { houseId: null } });
await tx.userProfile.update({
where: { id: profileId },
data: { houseId: null },
});
if (house.adminId !== profileId) {
return;
@ -169,8 +211,14 @@ export async function leaveCurrentHouse(profileId: number, houseId: number | nul
const nextAdmin = remainingMembers.reduce((oldest, member) =>
member.id < oldest.id ? member : oldest,
);
await tx.house.update({ where: { id: house.id }, data: { adminId: nextAdmin.id } });
await tx.house.update({
where: { id: house.id },
data: { adminId: nextAdmin.id },
});
});
} catch (err) {
throw err; // see getCurrentHouse()'s catch comment above
}
}
/**
@ -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<void> {
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");
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.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,6 +274,7 @@ export async function removeMember(
houseId: number | null,
targetMemberId: number,
): Promise<HouseView> {
try {
if (houseId === null) {
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
}
@ -237,8 +297,14 @@ export async function removeMember(
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,6 +315,7 @@ 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<number[]> {
try {
if (houseId === null) {
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
}
@ -257,6 +324,9 @@ export async function getHouseSourceIds(houseId: number | null): Promise<number[
select: { sourceId: true },
});
return rows.map((row) => row.sourceId);
} catch (err) {
throw err; // see getCurrentHouse()'s catch comment above
}
}
/**
@ -273,6 +343,7 @@ export async function updateHouseSources(
houseId: number | null,
sourceIds: number[],
): Promise<number[]> {
try {
if (houseId === null) {
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
}
@ -294,15 +365,24 @@ export async function updateHouseSources(
await prisma.$transaction([
prisma.houseSource.deleteMany({ where: { houseId } }),
prisma.houseSource.createMany({ data: sourceIds.map((sourceId) => ({ houseId, sourceId })) }),
prisma.houseSource.createMany({
data: sourceIds.map((sourceId) => ({ houseId, sourceId })),
}),
]);
return sourceIds;
} catch (err) {
throw err; // see getCurrentHouse()'s catch comment above
}
}
/** 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<HouseView> {
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,6 +404,7 @@ function isUniqueInviteCodeViolation(err: unknown): boolean {
* `HOUSE_NOT_FOUND` HttpError.
*/
async function findHouseOrThrow(houseId: number) {
try {
const house = await prisma.house.findUnique({
where: { id: houseId },
include: houseWithMembers,
@ -332,4 +413,7 @@ async function findHouseOrThrow(houseId: number) {
throw new Error(`House ${houseId} referenced by a profile but not found`);
}
return house;
} catch (err) {
throw err; // see getCurrentHouse()'s catch comment above
}
}

View file

@ -27,6 +27,7 @@ export async function getPlanningForDate(
houseId: number | null,
date: DateTime,
): Promise<PlanningView | null> {
try {
if (houseId === null) {
return null;
}
@ -70,6 +71,13 @@ export async function getPlanningForDate(
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;
}
}
/**
@ -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) {
try {
const startDate = weekStart.toJSDate();
const existing = await prisma.planning.findFirst({ where: { houseId, startDate } });
const existing = await prisma.planning.findFirst({
where: { houseId, startDate },
});
if (existing) {
return existing;
}
return prisma.planning.create({
data: { houseId, startDate, finishDate: weekStart.plus({ days: 6 }).toJSDate() },
return await prisma.planning.create({
data: {
houseId,
startDate,
finishDate: weekStart.plus({ days: 6 }).toJSDate(),
},
});
} catch (err) {
throw err; // see getPlanningForDate()'s catch comment above
}
}
/**
@ -119,6 +137,7 @@ export async function addPlanningItem(
date: DateTime,
input: AddPlanningItemInput,
): Promise<PlanningItemView> {
try {
if (houseId === null) {
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
}
@ -145,6 +164,9 @@ export async function addPlanningItem(
portions: item.portions,
recipe: item.recipe,
};
} catch (err) {
throw err; // see getPlanningForDate()'s catch comment above
}
}
/**
@ -156,6 +178,7 @@ 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<void> {
try {
const item = await prisma.planningItem.findUnique({
where: { id },
include: { planning: true },
@ -164,4 +187,7 @@ export async function removePlanningItem(id: number, houseId: number | null): Pr
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
}
}

View file

@ -9,8 +9,18 @@ import { prisma } from "../../db/prisma.js";
* just to read it.
*/
export async function getPreferences(userProfileId: number): Promise<PreferencesView> {
const preferences = await prisma.userPreference.findUnique({ where: { userProfileId } });
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<PreferencesView> {
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
}
}

View file

@ -14,6 +14,7 @@ export async function updateDiet(
userProfileId: number,
dietId: number | null,
): Promise<SafeUserProfile> {
try {
if (dietId !== null) {
const diet = await prisma.diet.findUnique({ where: { id: dietId } });
if (!diet) {
@ -26,15 +27,26 @@ export async function updateDiet(
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<number[]> {
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,6 +61,7 @@ export async function updateAllergies(
userProfileId: number,
allergyIds: number[],
): Promise<number[]> {
try {
if (allergyIds.length > 0) {
const found = await prisma.allergy.findMany({
where: { id: { in: allergyIds } },
@ -73,15 +86,22 @@ export async function updateAllergies(
]);
return allergyIds;
} catch (err) {
throw err; // see updateDiet()'s catch comment above
}
}
/** 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<number[]> {
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,6 +115,7 @@ export async function updateDislikedIngredients(
userProfileId: number,
dislikedIngredientIds: number[],
): Promise<number[]> {
try {
if (dislikedIngredientIds.length > 0) {
const found = await prisma.ingredient.findMany({
where: { id: { in: dislikedIngredientIds } },
@ -112,11 +133,19 @@ export async function updateDislikedIngredients(
}
await prisma.$transaction([
prisma.userProfileDislikedIngredient.deleteMany({ where: { userProfileId } }),
prisma.userProfileDislikedIngredient.deleteMany({
where: { userProfileId },
}),
prisma.userProfileDislikedIngredient.createMany({
data: dislikedIngredientIds.map((ingredientId) => ({ userProfileId, ingredientId })),
data: dislikedIngredientIds.map((ingredientId) => ({
userProfileId,
ingredientId,
})),
}),
]);
return dislikedIngredientIds;
} catch (err) {
throw err; // see updateDiet()'s catch comment above
}
}

View file

@ -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<typeof recipeInclude> }>;
type RecipeWithDetails = Prisma.RecipeGetPayload<{
include: ReturnType<typeof recipeInclude>;
}>;
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,6 +222,7 @@ function visibleToViewerWhere(
* belong in a filter framed around what's safe/appropriate to serve.
*/
async function suitableForHouseholdWhere(houseId: number): Promise<Prisma.RecipeWhereInput> {
try {
const members = await prisma.userProfile.findMany({
where: { houseId },
select: { dietId: true, allergies: { select: { allergyId: true } } },
@ -220,16 +239,29 @@ async function suitableForHouseholdWhere(houseId: number): Promise<Prisma.Recipe
// 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 } } })) });
conditions.push({
AND: requiredDietIds.map((dietId) => ({ diets: { some: { dietId } } })),
});
}
if (excludedAllergyIds.length > 0) {
conditions.push({
ingredients: {
none: { ingredient: { allergies: { some: { allergyId: { in: excludedAllergyIds } } } } },
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;
}
}
/**
@ -244,13 +276,20 @@ async function suitableForHouseholdWhere(houseId: number): Promise<Prisma.Recipe
* is hidden for them until they join or create one and configure it.
*/
async function sourceVisibilityWhere(houseId: number | null): Promise<Prisma.RecipeWhereInput> {
try {
const enabledSourceIds =
houseId === null
? []
: (await prisma.houseSource.findMany({ where: { houseId }, select: { sourceId: true } })).map(
(row) => row.sourceId,
);
: (
await prisma.houseSource.findMany({
where: { houseId },
select: { sourceId: true },
})
).map((row) => row.sourceId);
return { OR: [{ sourceId: null }, { sourceId: { in: enabledSourceIds } }] };
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
/**
@ -286,6 +325,7 @@ export async function listRecipes(
tab: RecipeTab,
filters: ListRecipesFilters = {},
): Promise<RecipeSummaryView[]> {
try {
const { search, suitableForHousehold, ingredientIds, dietIds } = filters;
const conditions: Prisma.RecipeWhereInput[] = [await sourceVisibilityWhere(viewerHouseId)];
if (search) {
@ -301,11 +341,15 @@ export async function listRecipes(
// them, not just one, same "every one, not any one" posture as
// suitableForHouseholdWhere's requiredDietIds.
conditions.push({
AND: ingredientIds.map((ingredientId) => ({ ingredients: { some: { ingredientId } } })),
AND: ingredientIds.map((ingredientId) => ({
ingredients: { some: { ingredientId } },
})),
});
}
if (dietIds && dietIds.length > 0) {
conditions.push({ AND: dietIds.map((dietId) => ({ diets: { some: { dietId } } })) });
conditions.push({
AND: dietIds.map((dietId) => ({ diets: { some: { dietId } } })),
});
}
switch (tab) {
@ -332,6 +376,9 @@ export async function listRecipes(
orderBy: { name: "asc" },
});
return recipes.map(toRecipeSummaryView);
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
/**
@ -344,11 +391,15 @@ export async function getRecipe(
viewerId: number,
viewerHouseId: number | null,
): Promise<RecipeView> {
try {
const recipe = await findRecipeOrThrow(id, viewerId);
if (!canView(recipe, viewerId, viewerHouseId)) {
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
}
return toRecipeView(recipe);
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
/**
@ -368,7 +419,11 @@ export async function createRecipe(
authorId: number,
authorHouseId: number | null,
): Promise<RecipeView> {
return createRecipeInternal(input, authorId, authorHouseId, null);
try {
return await createRecipeInternal(input, authorId, authorHouseId, null);
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
/**
@ -392,7 +447,11 @@ export async function createImportedRecipe(
authorHouseId: number | null,
source: { sourceId: number; externalId: string; locale: string },
): Promise<RecipeView> {
return createRecipeInternal(input, authorId, authorHouseId, source);
try {
return await createRecipeInternal(input, authorId, authorHouseId, source);
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
async function createRecipeInternal(
@ -401,6 +460,7 @@ async function createRecipeInternal(
authorHouseId: number | null,
source: { sourceId: number; externalId: string; locale: string } | null,
): Promise<RecipeView> {
try {
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
await assertDietsExist(input.dietIds);
@ -432,12 +492,14 @@ async function createRecipeInternal(
picture: step.picture ?? null,
order: index,
techSteps: {
create: matchTechStepSpans(step.description, techStepMappings).map((match, order) => ({
create: matchTechStepSpans(step.description, techStepMappings).map(
(match, order) => ({
techStepId: match.techStepId,
start: match.start,
end: match.end,
order,
})),
}),
),
},
})),
},
@ -446,6 +508,9 @@ async function createRecipeInternal(
include: recipeInclude(authorId),
});
return toRecipeView(created);
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
/**
@ -467,6 +532,7 @@ export async function updateRecipe(
viewerId: number,
viewerHouseId: number | null,
): Promise<RecipeView> {
try {
await assertIsAuthor(id, viewerId, viewerHouseId);
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
@ -515,6 +581,9 @@ export async function updateRecipe(
]);
return toRecipeView(await findRecipeOrThrow(id, viewerId));
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
/**
@ -530,9 +599,12 @@ export async function deleteRecipe(
viewerId: number,
viewerHouseId: number | null,
): Promise<void> {
try {
await assertIsAuthor(id, viewerId, viewerHouseId);
const usedInPlanning = await prisma.planningItem.findFirst({ where: { recipeId: id } });
const usedInPlanning = await prisma.planningItem.findFirst({
where: { recipeId: id },
});
if (usedInPlanning) {
throw new HttpError(
409,
@ -542,6 +614,9 @@ export async function deleteRecipe(
}
await prisma.recipe.delete({ where: { id } });
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
/**
@ -555,20 +630,32 @@ export async function addFavorite(
viewerId: number,
viewerHouseId: number | null,
): Promise<void> {
try {
const recipe = await findRecipeOrThrow(id, viewerId);
if (!canView(recipe, viewerId, viewerHouseId)) {
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
}
await prisma.recipeFavorite.upsert({
where: { userProfileId_recipeId: { userProfileId: viewerId, recipeId: id } },
where: {
userProfileId_recipeId: { userProfileId: viewerId, recipeId: id },
},
update: {},
create: { userProfileId: viewerId, recipeId: id },
});
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
/** Unfavorites a recipe for `viewerId` — idempotent, no error if it wasn't favorited (or doesn't exist/isn't visible: unfavoriting is always safe, nothing to leak). */
export async function removeFavorite(id: number, viewerId: number): Promise<void> {
await prisma.recipeFavorite.deleteMany({ where: { userProfileId: viewerId, recipeId: id } });
try {
await prisma.recipeFavorite.deleteMany({
where: { userProfileId: viewerId, recipeId: id },
});
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
/**
@ -584,14 +671,19 @@ export async function assertRecipeVisible(
viewerId: number,
viewerHouseId: number | null,
): Promise<void> {
try {
const recipe = await findRecipeOrThrow(id, viewerId);
if (!canView(recipe, viewerId, viewerHouseId)) {
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
}
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
/** Re-fetches a recipe by id (with {@link recipeInclude}), or throws `404 RECIPE_NOT_FOUND` — the shared "load or reject" step for every recipe endpoint. Does *not* check visibility on its own — callers combine it with {@link canView} (read paths) or {@link assertIsAuthor} (write paths). */
async function findRecipeOrThrow(id: number, viewerId: number): Promise<RecipeWithDetails> {
try {
const recipe = await prisma.recipe.findUnique({
where: { id },
include: recipeInclude(viewerId),
@ -600,6 +692,9 @@ async function findRecipeOrThrow(id: number, viewerId: number): Promise<RecipeWi
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
}
return recipe;
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
/** Shared "load, check visible, check authored by viewer" guard for the write paths (`updateRecipe`/`deleteRecipe`). */
@ -608,6 +703,7 @@ async function assertIsAuthor(
viewerId: number,
viewerHouseId: number | null,
): Promise<void> {
try {
const recipe = await findRecipeOrThrow(id, viewerId);
if (!canView(recipe, viewerId, viewerHouseId)) {
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
@ -615,10 +711,14 @@ async function assertIsAuthor(
if (recipe.authorId !== viewerId) {
throw new HttpError(403, ErrorCode.NOT_RECIPE_AUTHOR, "Only the recipe's author can do this");
}
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
/** Throws `404 INGREDIENT_NOT_FOUND` if any of `ingredientIds` doesn't match a reference `Ingredient` row. */
async function assertIngredientsExist(ingredientIds: number[]): Promise<void> {
try {
const uniqueIds = [...new Set(ingredientIds)];
const found = await prisma.ingredient.findMany({
where: { id: { in: uniqueIds } },
@ -633,10 +733,14 @@ async function assertIngredientsExist(ingredientIds: number[]): Promise<void> {
`Ingredient(s) not found: ${missing.join(", ")}`,
);
}
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
/** Throws `404 UNIT_NOT_FOUND` if any of `unitIds` doesn't match a reference `Unit` row. */
async function assertUnitsExist(unitIds: number[]): Promise<void> {
try {
const uniqueIds = [...new Set(unitIds)];
const found = await prisma.unit.findMany({
where: { id: { in: uniqueIds } },
@ -645,12 +749,20 @@ async function assertUnitsExist(unitIds: number[]): Promise<void> {
if (found.length !== uniqueIds.length) {
const foundIds = new Set(found.map((unit) => unit.id));
const missing = uniqueIds.filter((id) => !foundIds.has(id));
throw new HttpError(404, ErrorCode.UNIT_NOT_FOUND, `Unit(s) not found: ${missing.join(", ")}`);
throw new HttpError(
404,
ErrorCode.UNIT_NOT_FOUND,
`Unit(s) not found: ${missing.join(", ")}`,
);
}
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}
/** Throws `404 DIET_NOT_FOUND` if any of `dietIds` doesn't match a reference `Diet` row. */
async function assertDietsExist(dietIds: number[]): Promise<void> {
try {
const uniqueIds = [...new Set(dietIds)];
if (uniqueIds.length === 0) return;
const found = await prisma.diet.findMany({
@ -660,6 +772,13 @@ async function assertDietsExist(dietIds: number[]): Promise<void> {
if (found.length !== uniqueIds.length) {
const foundIds = new Set(found.map((diet) => diet.id));
const missing = uniqueIds.filter((id) => !foundIds.has(id));
throw new HttpError(404, ErrorCode.DIET_NOT_FOUND, `Diet(s) not found: ${missing.join(", ")}`);
throw new HttpError(
404,
ErrorCode.DIET_NOT_FOUND,
`Diet(s) not found: ${missing.join(", ")}`,
);
}
} catch (err) {
throw err; // see suitableForHouseholdWhere()'s catch comment above
}
}

View file

@ -17,7 +17,15 @@ import { prisma } from "../../db/prisma.js";
* client-side (`apps/web`'s `locales/fr/translation.json`).
*/
export async function getDiets(): Promise<DietView[]> {
return prisma.diet.findMany({ orderBy: { key: "asc" } });
try {
return await prisma.diet.findMany({ orderBy: { key: "asc" } });
} 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 `async` body without a
// try/catch per the repo's convention.
throw err;
}
}
/**
@ -28,6 +36,7 @@ export async function getDiets(): Promise<DietView[]> {
* callers.
*/
export async function getAllergies(): Promise<AllergyView[]> {
try {
const allergies = await prisma.allergy.findMany({
include: { category: { select: { key: true, kind: true } } },
orderBy: { category: { key: "asc" } },
@ -37,6 +46,9 @@ export async function getAllergies(): Promise<AllergyView[]> {
key: allergy.category.key,
kind: allergy.category.kind,
}));
} catch (err) {
throw err; // see getDiets()'s catch comment above
}
}
/**
@ -47,6 +59,7 @@ export async function getAllergies(): Promise<AllergyView[]> {
* `RecipeIngredient.quantity`.
*/
export async function getUnits(): Promise<UnitView[]> {
try {
const units = await prisma.unit.findMany({ orderBy: { key: "asc" } });
return units.map((unit) => ({
id: unit.id,
@ -54,6 +67,9 @@ export async function getUnits(): Promise<UnitView[]> {
type: unit.type,
toBaseFactor: Number(unit.toBaseFactor),
}));
} catch (err) {
throw err; // see getDiets()'s catch comment above
}
}
/**
@ -62,7 +78,11 @@ export async function getUnits(): Promise<UnitView[]> {
* `TECH_STEPS`). Not consumed by the recipe UI yet see {@link TechStepView}.
*/
export async function getTechSteps(): Promise<TechStepView[]> {
return prisma.techStep.findMany({ orderBy: { key: "asc" } });
try {
return await prisma.techStep.findMany({ orderBy: { key: "asc" } });
} catch (err) {
throw err; // see getDiets()'s catch comment above
}
}
/**
@ -74,13 +94,23 @@ export async function getTechSteps(): Promise<TechStepView[]> {
* `recipe-source-sync.ts`'s `syncRecipeSources`).
*/
export async function getSources(): Promise<SourceView[]> {
try {
// Explicit `select` — `url` exists on the `Source` row but isn't part of
// `SourceView` yet, so it must not leak into the response the way a bare
// `findMany()` would let it.
return prisma.source.findMany({
select: { id: true, key: true, name: true, official: true, iconUrl: true },
return await prisma.source.findMany({
select: {
id: true,
key: true,
name: true,
official: true,
iconUrl: true,
},
orderBy: { name: "asc" },
});
} catch (err) {
throw err; // see getDiets()'s catch comment above
}
}
/**
@ -91,6 +121,7 @@ export async function getSources(): Promise<SourceView[]> {
* allergen/diet come back with `allergens: []`/`diets: []`.
*/
export async function getIngredients(): Promise<IngredientView[]> {
try {
const ingredients = await prisma.ingredient.findMany({
include: {
allergies: { include: { allergy: { include: { category: true } } } },
@ -110,6 +141,12 @@ export async function getIngredients(): Promise<IngredientView[]> {
key: allergy.category.key,
kind: allergy.category.kind,
})),
diets: ingredient.diets.map(({ diet }) => ({ id: diet.id, key: diet.key })),
diets: ingredient.diets.map(({ diet }) => ({
id: diet.id,
key: diet.key,
})),
}));
} catch (err) {
throw err; // see getDiets()'s catch comment above
}
}

View file

@ -64,8 +64,11 @@ async function assertSourceEnabled(
houseId: number | null,
sourceKey: string,
): Promise<{ adapter: RecipeSourceAdapter; sourceId: number }> {
try {
const enabledSourceIds = await getHouseSourceIds(houseId);
const source = await prisma.source.findUnique({ where: { key: sourceKey } });
const source = await prisma.source.findUnique({
where: { key: sourceKey },
});
if (!source || !enabledSourceIds.includes(source.id)) {
throw new HttpError(
404,
@ -82,6 +85,13 @@ async function assertSourceEnabled(
);
}
return { adapter, sourceId: source.id };
} 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;
}
}
/**
@ -97,8 +107,12 @@ export async function browseSource(
houseId: number | null,
params: { query?: string; cursor?: string },
): Promise<{ items: BrowsableSourceItemView[]; nextCursor: string | null }> {
try {
const { adapter } = await assertSourceEnabled(houseId, sourceKey);
const result = await adapter.list({ query: params.query, cursor: params.cursor });
const result = await adapter.list({
query: params.query,
cursor: params.cursor,
});
const importedRecipeIds = await findImportedRecipeIds(
prisma,
@ -118,6 +132,9 @@ export async function browseSource(
})),
nextCursor: result.nextCursor,
};
} catch (err) {
throw err; // see assertSourceEnabled()'s catch comment above
}
}
/**
@ -141,6 +158,7 @@ export async function previewSourceItem(
externalId: string,
houseId: number | null,
): Promise<RecipeImportDraftView> {
try {
const { adapter } = await assertSourceEnabled(houseId, sourceKey);
let parsed: ReturnType<typeof adapter.parse>;
@ -156,7 +174,9 @@ export async function previewSourceItem(
const [techStepMappings, ingredientCatalog, unitCatalog, techStepsByKey] = await Promise.all([
loadTechStepMappingRules(adapter.locale),
adapter.locale === "en" ? loadIngredientCatalog() : Promise.resolve<IngredientMatchEntry[]>([]),
adapter.locale === "en"
? loadIngredientCatalog()
: Promise.resolve<IngredientMatchEntry[]>([]),
adapter.locale === "en" ? loadUnitCatalog() : Promise.resolve<UnitMatchEntry[]>([]),
prisma.techStep.findMany({ select: { id: true, key: true } }),
]);
@ -209,6 +229,9 @@ export async function previewSourceItem(
ingredients,
steps,
};
} catch (err) {
throw err; // see assertSourceEnabled()'s catch comment above
}
}
/**
@ -238,6 +261,7 @@ export async function importSourceItem(
authorId: number,
authorHouseId: number | null,
): Promise<RecipeView> {
try {
const { adapter, sourceId } = await assertSourceEnabled(authorHouseId, sourceKey);
const alreadyImported = await findImportedRecipeIds(prisma, sourceKey, [externalId]);
@ -249,9 +273,12 @@ export async function importSourceItem(
);
}
return createImportedRecipe(input, authorId, authorHouseId, {
return await createImportedRecipe(input, authorId, authorHouseId, {
sourceId,
externalId,
locale: adapter.locale,
});
} catch (err) {
throw err; // see assertSourceEnabled()'s catch comment above
}
}

View file

@ -168,7 +168,10 @@ function flattenInstructions(instructions: SchemaOrgRecipe["recipeInstructions"]
* from a prior `list()` call. A future "import from URL" flow would call
* `fetchDetail(pastedUrl)` directly.
*/
export const jsonLdRecipeAdapter: RecipeSourceAdapter<{ html: string; url: string }> = {
export const jsonLdRecipeAdapter: RecipeSourceAdapter<{
html: string;
url: string;
}> = {
key: SOURCE_KEY,
name: "Import générique (JSON-LD)",
official: false,
@ -182,10 +185,19 @@ export const jsonLdRecipeAdapter: RecipeSourceAdapter<{ html: string; url: strin
locale: "fr",
async list() {
try {
return { items: [], nextCursor: null };
} catch (err) {
// Rethrown as-is — this adapter's only caller (`sources.service.ts`)
// already handles/logs failures centrally; this method just isn't
// allowed a bare `async` body without a try/catch per the repo's
// convention.
throw err;
}
},
async fetchDetail(url: string): Promise<{ html: string; url: string }> {
try {
let response: Response;
try {
response = await fetch(url);
@ -197,6 +209,9 @@ export const jsonLdRecipeAdapter: RecipeSourceAdapter<{ html: string; url: strin
}
const html = await response.text();
return { html, url };
} catch (err) {
throw err; // see list()'s catch comment above
}
},
parse({ html, url }): ParsedRecipe {

View file

@ -38,6 +38,7 @@ interface TheMealDbMealsResponse {
}
async function fetchTheMealDb<T>(path: string): Promise<T> {
try {
let response: Response;
try {
response = await fetch(`${API_BASE}${path}`);
@ -52,7 +53,13 @@ async function fetchTheMealDb<T>(path: string): Promise<T> {
`TheMealDB responded ${response.status} (${path})`,
);
}
return response.json() as Promise<T>;
return (await response.json()) as T;
} catch (err) {
// Rethrown as-is — this adapter's only caller (`sources.service.ts`)
// already handles/logs failures centrally; this method just isn't
// allowed a bare `await` per the repo's async/try-catch convention.
throw err;
}
}
function detailUrl(idMeal: string): string {
@ -85,6 +92,7 @@ export const theMealDbAdapter: RecipeSourceAdapter<TheMealDbMeal> = {
locale: "en",
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
try {
const query = params.query ?? "";
const data = await fetchTheMealDb<TheMealDbMealsResponse>(
`/search.php?s=${encodeURIComponent(query)}`,
@ -101,15 +109,22 @@ export const theMealDbAdapter: RecipeSourceAdapter<TheMealDbMeal> = {
})),
nextCursor: null,
};
} catch (err) {
throw err; // see fetchTheMealDb()'s catch comment above
}
},
async fetchDetail(externalId: string): Promise<TheMealDbMeal> {
try {
const data = await fetchTheMealDb<TheMealDbMealsResponse>(`/lookup.php?i=${externalId}`);
const meal = data.meals?.[0];
if (!meal) {
throw new RecipeSourceFetchError(SOURCE_KEY, `No meal found for id "${externalId}"`);
}
return meal;
} catch (err) {
throw err; // see fetchTheMealDb()'s catch comment above
}
},
parse(meal: TheMealDbMeal): ParsedRecipe {

View file

@ -75,6 +75,7 @@ export class ApiClient {
path: string,
options: RequestInit = {},
): Promise<TResponseBody> {
try {
const response = await fetch(`${API_BASE_URL}${path}`, {
...options,
// Required for the httpOnly session cookie to be sent/received — the
@ -97,7 +98,15 @@ export class ApiClient {
if (response.status === 204) {
return undefined as TResponseBody;
}
return response.json() as Promise<TResponseBody>;
return (await response.json()) as TResponseBody;
} catch (err) {
// Rethrown as-is — every caller already handles/surfaces API failures
// its own way (an `ApiError` catch, a `.catch()` chain — see
// `error-message.service.ts`), this is just the one place the
// fetch/`await` itself has to sit inside a try/catch per the repo's
// convention.
throw err;
}
}
/** Creates a profile and starts a session — no household yet, that's an optional step of the onboarding wizard. */

View file

@ -49,25 +49,49 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []);
const signup = useCallback(async (input: SignupInput) => {
try {
setUser(await apiClient.signup(input));
} catch (err) {
// Rethrown as-is — the caller (`SignupPage`'s `handleSubmit`) is what
// actually catches and displays this (see this context value's doc
// comment, "Throws ApiError on failure"); this callback just isn't
// allowed a bare `await` per the repo's async/try-catch convention.
throw err;
}
}, []);
const login = useCallback(async (input: LoginInput) => {
try {
setUser(await apiClient.login(input));
} catch (err) {
throw err; // see signup()'s catch comment above
}
}, []);
const logout = useCallback(async () => {
try {
await apiClient.logout();
setUser(null);
} catch (err) {
throw err; // see signup()'s catch comment above
}
}, []);
const deleteAccount = useCallback(async (password: string) => {
try {
await apiClient.deleteAccount(password);
setUser(null);
} catch (err) {
throw err; // see signup()'s catch comment above
}
}, []);
const refreshUser = useCallback(async () => {
try {
setUser(await apiClient.me());
} catch (err) {
throw err; // see signup()'s catch comment above
}
}, []);
return (

View file

@ -71,9 +71,17 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
}, [user?.id]);
const setTheme = useCallback(async (newTheme: ThemePreference) => {
try {
await apiClient.updatePreferences(newTheme);
setThemeState(newTheme);
applyTheme(newTheme);
} catch (err) {
// Rethrown as-is — the caller is what actually handles this (see this
// context value's doc comment, "Throws ApiError on failure"); this
// callback just isn't allowed a bare `await` per the repo's
// async/try-catch convention.
throw err;
}
}, []);
return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>;

View file

@ -194,8 +194,15 @@ function AccountMenu() {
/** Ends the session and returns to the login page. */
async function handleLogout() {
setIsOpen(false);
try {
await logout();
void navigate("/login");
} catch (err) {
// Rethrown as-is — `AuthContext`'s `logout` already documents itself
// as throwing `ApiError` on failure; this handler just isn't allowed
// a bare `await` per the repo's async/try-catch convention.
throw err;
}
}
const initial = user?.firstName?.charAt(0).toUpperCase() ?? "";

View file

@ -365,9 +365,17 @@ function InviteCode({ code }: { code: string }) {
const [copied, setCopied] = useState(false);
async function handleCopy() {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch (err) {
// Rethrown as-is — nothing meaningful to show inline for a clipboard
// failure here (no error state on this small button); this handler
// just isn't allowed a bare `await` per the repo's async/try-catch
// convention.
throw err;
}
}
return (
@ -390,6 +398,8 @@ function RemoveMemberButton({ memberId, onChanged }: { memberId: number; onChang
try {
await apiClient.removeHouseMember(memberId);
onChanged();
} catch (err) {
throw err; // see InviteCode's handleCopy() catch comment above
} finally {
setIsRemoving(false);
}
@ -413,6 +423,8 @@ function DeleteHouseholdSection({ onChanged }: { onChanged: () => void }) {
try {
await apiClient.deleteHouse();
onChanged();
} catch (err) {
throw err; // see InviteCode's handleCopy() catch comment above
} finally {
setIsDeleting(false);
}
@ -461,6 +473,8 @@ function LeaveHouseholdSection({ onChanged }: { onChanged: () => void }) {
try {
await apiClient.leaveHouse();
onChanged();
} catch (err) {
throw err; // see InviteCode's handleCopy() catch comment above
} finally {
setIsLeaving(false);
}

View file

@ -28,6 +28,9 @@
"enabled": true,
"rules": {
"preset": "recommended",
"complexity": {
"noUselessCatch": "off"
},
"nursery": {
"noFloatingPromises": "error"
},

View file

@ -67,6 +67,42 @@ nouveau code) — voir `LoggerService._emit`/`_minSeverity`,
`ApiClient._request`, `ErrorHandlerService._fromZodError`/`_fromHttpError`/
`_fromUnknownError`, `ExpressServer._app`/`_registeredRoutes`.
### `await` toujours encapsulé dans `try`/`catch`
Aucun `await` nu (non encapsulé) : chaque appel `await` vit dans un bloc
`try`/`catch`. Conséquence directe — une fonction/méthode `async` contient
forcément au moins un `await`, donc **tout son corps** vit dans un
`try`/`catch`, pas seulement la ou les lignes qui awaitent. L'erreur
attrapée doit être traitée de façon utile pour ce point d'appel (log via
`LoggerService`, `throw`/retour d'une erreur typée, dégradation
gracieuse) — jamais avalée silencieusement — en cohérence avec le
traitement d'erreur déjà en place ailleurs dans le fichier (ex. les
chaînes `.catch()` du front). Rétroactif, même logique que les deux règles
ci-dessus.
Périmètre : code applicatif uniquement (routes, services, composants,
hooks, middlewares). Les fichiers de test (`apps/api/test/**`) et les
scripts one-off (`prisma/seed.ts`, `apps/api/src/scripts/seed-runtime.ts`,
`cypress.config.ts`) en sont exclus — un test s'appuie sur la propagation
du rejet d'un `await` non encapsulé pour faire échouer le test
(idiome chai/mocha) ; l'encapsuler forcerait soit un `re-throw` inutile,
soit risquerait d'avaler un vrai échec de test.
Deuxième exception : les handlers de route Express (`*.routes.ts`) passés à
`wrapAsyncHandler` (`packages/express-tools/src/async-handler.ts`) — son
rôle documenté est justement de transmettre une erreur/rejet à `next(err)`
pour qu'elle atteigne le middleware d'erreur centralisé, sans try/catch
répété dans chaque route. La règle vise le code service/hook/composant
qui n'est pas déjà filtré par ce mécanisme.
`lint/complexity/noUselessCatch` est désactivé dans `biome.json` pour
cette raison précise : cette règle Biome interdit un `catch` qui ne fait
que `throw err;`, exactement la forme que prend un `try`/`catch` ajouté
uniquement pour respecter la convention ci-dessus quand rien de plus
utile n'est à faire au niveau de cet appel (le middleware d'erreur logge
déjà tout centralement — voir plus bas). Les deux règles sont
mutuellement exclusives ; la convention du repo l'emporte.
### Élégance avant rapidité
Préférer une solution propre, bien structurée et délibérée à une solution