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:
parent
82c09331dc
commit
d98f3450c0
21 changed files with 1415 additions and 858 deletions
|
|
@ -25,17 +25,28 @@ import { listRecipeSources } from "../lib/recipe-sources/recipe-source-registry.
|
||||||
* themselves at startup).
|
* themselves at startup).
|
||||||
*/
|
*/
|
||||||
export async function syncRecipeSources(prisma: PrismaClient): Promise<void> {
|
export async function syncRecipeSources(prisma: PrismaClient): Promise<void> {
|
||||||
for (const adapter of listRecipeSources()) {
|
try {
|
||||||
await prisma.source.upsert({
|
for (const adapter of listRecipeSources()) {
|
||||||
where: { key: adapter.key },
|
await prisma.source.upsert({
|
||||||
update: { name: adapter.name, official: adapter.official, iconUrl: adapter.iconUrl },
|
where: { key: adapter.key },
|
||||||
create: {
|
update: {
|
||||||
key: adapter.key,
|
name: adapter.name,
|
||||||
name: adapter.name,
|
official: adapter.official,
|
||||||
official: adapter.official,
|
iconUrl: adapter.iconUrl,
|
||||||
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,
|
sourceKey: string,
|
||||||
externalIds: string[],
|
externalIds: string[],
|
||||||
): Promise<Map<string, number>> {
|
): Promise<Map<string, number>> {
|
||||||
if (externalIds.length === 0) return new Map();
|
try {
|
||||||
|
if (externalIds.length === 0) return new Map();
|
||||||
|
|
||||||
const source = await prisma.source.findUnique({ where: { key: sourceKey } });
|
const source = await prisma.source.findUnique({
|
||||||
if (!source) return new Map();
|
where: { key: sourceKey },
|
||||||
|
});
|
||||||
|
if (!source) return new Map();
|
||||||
|
|
||||||
const imported = await prisma.recipe.findMany({
|
const imported = await prisma.recipe.findMany({
|
||||||
where: { sourceId: source.id, externalId: { in: externalIds } },
|
where: { sourceId: source.id, externalId: { in: externalIds } },
|
||||||
select: { id: true, externalId: true },
|
select: { id: true, externalId: true },
|
||||||
});
|
});
|
||||||
return new Map(
|
return new Map(
|
||||||
imported.flatMap((recipe) =>
|
imported.flatMap((recipe) =>
|
||||||
recipe.externalId !== null ? [[recipe.externalId, recipe.id]] : [],
|
recipe.externalId !== null ? [[recipe.externalId, recipe.id]] : [],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see syncRecipeSources()'s catch comment above
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,10 @@ export function matchIngredientName(name: string, catalog: IngredientMatchEntry[
|
||||||
labelTokens.length > best.tokenCount ||
|
labelTokens.length > best.tokenCount ||
|
||||||
(labelTokens.length === best.tokenCount && entry.ingredientId < best.ingredientId)
|
(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;
|
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. */
|
/** 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[]> {
|
export async function loadIngredientCatalog(): Promise<IngredientMatchEntry[]> {
|
||||||
const ingredients = await prisma.ingredient.findMany({ select: { id: true, key: true } });
|
try {
|
||||||
const catalog: IngredientMatchEntry[] = [];
|
const ingredients = await prisma.ingredient.findMany({
|
||||||
for (const ingredient of ingredients) {
|
select: { id: true, key: true },
|
||||||
const label = INGREDIENT_LABELS_EN[ingredient.key];
|
});
|
||||||
if (label === undefined) continue;
|
const catalog: IngredientMatchEntry[] = [];
|
||||||
catalog.push({ ingredientId: ingredient.id, label });
|
for (const ingredient of ingredients) {
|
||||||
for (const synonym of INGREDIENT_LABEL_SYNONYMS_EN[ingredient.key] ?? []) {
|
const label = INGREDIENT_LABELS_EN[ingredient.key];
|
||||||
catalog.push({ ingredientId: ingredient.id, label: synonym });
|
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}. */
|
/** 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[]> {
|
export async function loadUnitCatalog(): Promise<UnitMatchEntry[]> {
|
||||||
const units = await prisma.unit.findMany({ select: { id: true, key: true } });
|
try {
|
||||||
const catalog: UnitMatchEntry[] = [];
|
const units = await prisma.unit.findMany({
|
||||||
for (const unit of units) {
|
select: { id: true, key: true },
|
||||||
const synonyms = UNIT_LABELS_EN[unit.key];
|
});
|
||||||
if (synonyms !== undefined) catalog.push({ unitId: unit.id, synonyms });
|
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;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -175,7 +175,11 @@ function combineIngredientLines(
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (a.unitId === b.unitId) {
|
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);
|
const unitA = unitById.get(a.unitId);
|
||||||
|
|
@ -274,17 +278,24 @@ export async function translateRecipe(
|
||||||
recipe: ParsedRecipe,
|
recipe: ParsedRecipe,
|
||||||
locale: string,
|
locale: string,
|
||||||
): Promise<TranslatedRecipe> {
|
): Promise<TranslatedRecipe> {
|
||||||
const techStepMappings = await loadTechStepMappingRules(locale);
|
try {
|
||||||
const translated = translateRecipeSteps(recipe, techStepMappings);
|
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([
|
const [ingredientCatalog, unitCatalog] = await Promise.all([
|
||||||
loadIngredientCatalog(),
|
loadIngredientCatalog(),
|
||||||
loadUnitCatalog(),
|
loadUnitCatalog(),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
...translated,
|
...translated,
|
||||||
ingredients: translateRecipeIngredients(recipe.ingredients, ingredientCatalog, unitCatalog),
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,11 @@ export function matchTechStepSpans(
|
||||||
const pattern = new RegExp(normalizeText(mapping.expression), "i");
|
const pattern = new RegExp(normalizeText(mapping.expression), "i");
|
||||||
const match = pattern.exec(normalizedDescription);
|
const match = pattern.exec(normalizedDescription);
|
||||||
if (match === null) continue;
|
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.
|
// Step 2: one best candidate per techStepId.
|
||||||
|
|
@ -152,7 +156,11 @@ export function matchTechStepSpans(
|
||||||
|
|
||||||
// Step 4: reading order.
|
// Step 4: reading order.
|
||||||
accepted.sort((a, b) => a.start - b.start || a.techStepId - b.techStepId);
|
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.
|
* module.
|
||||||
*/
|
*/
|
||||||
export async function loadTechStepMappingRules(locale: string): Promise<TechStepMappingRule[]> {
|
export async function loadTechStepMappingRules(locale: string): Promise<TechStepMappingRule[]> {
|
||||||
return prisma.techStepMapping.findMany({
|
try {
|
||||||
where: { locale },
|
return await prisma.techStepMapping.findMany({
|
||||||
select: { techStepId: true, expression: true, weight: true },
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
* @throws {HttpError} `409 EMAIL_ALREADY_IN_USE` if the email is already taken.
|
||||||
*/
|
*/
|
||||||
export async function signup(input: SignupInput): Promise<AuthResult> {
|
export async function signup(input: SignupInput): Promise<AuthResult> {
|
||||||
const existing = await prisma.userProfile.findUnique({ where: { email: input.email } });
|
try {
|
||||||
if (existing) {
|
const existing = await prisma.userProfile.findUnique({
|
||||||
throw new HttpError(409, ErrorCode.EMAIL_ALREADY_IN_USE, "Email already in use");
|
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<AuthResult> {
|
||||||
* @throws {HttpError} `401 INVALID_CREDENTIALS` if the password is wrong.
|
* @throws {HttpError} `401 INVALID_CREDENTIALS` if the password is wrong.
|
||||||
*/
|
*/
|
||||||
export async function deleteAccount(profileId: number, password: string): Promise<void> {
|
export async function deleteAccount(profileId: number, password: string): Promise<void> {
|
||||||
const profile = await prisma.userProfile.findUnique({ where: { id: profileId } });
|
try {
|
||||||
if (!profile || !(await argon2.verify(profile.passwordHash, password))) {
|
const profile = await prisma.userProfile.findUnique({
|
||||||
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid password");
|
where: { id: profileId },
|
||||||
}
|
});
|
||||||
|
if (!profile || !(await argon2.verify(profile.passwordHash, password))) {
|
||||||
|
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid password");
|
||||||
|
}
|
||||||
|
|
||||||
if (profile.houseId !== null) {
|
if (profile.houseId !== null) {
|
||||||
await leaveCurrentHouse(profile.id, profile.houseId);
|
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.
|
* caller can never learn whether a given email has an account.
|
||||||
*/
|
*/
|
||||||
export async function login(input: LoginInput): Promise<AuthResult> {
|
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))) {
|
if (!profile || !(await argon2.verify(profile.passwordHash, input.password))) {
|
||||||
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid email or 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 };
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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`). */
|
/** 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> {
|
export async function getCurrentHouse(houseId: number | null): Promise<HouseView | null> {
|
||||||
if (houseId === null) {
|
try {
|
||||||
return null;
|
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<HouseView
|
||||||
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
|
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
|
||||||
*/
|
*/
|
||||||
export async function renameHouse(houseId: number | null, name: string): Promise<HouseView> {
|
export async function renameHouse(houseId: number | null, name: string): Promise<HouseView> {
|
||||||
if (houseId === null) {
|
try {
|
||||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
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,
|
houseId: number | null,
|
||||||
name: string,
|
name: string,
|
||||||
): Promise<HouseView> {
|
): Promise<HouseView> {
|
||||||
if (houseId !== null) {
|
try {
|
||||||
throw new HttpError(409, ErrorCode.ALREADY_HAS_HOUSE, "Profile already belongs to a household");
|
if (houseId !== null) {
|
||||||
}
|
throw new HttpError(
|
||||||
|
409,
|
||||||
// Astronomically unlikely to collide (33^8 possibilities), but retried
|
ErrorCode.ALREADY_HAS_HOUSE,
|
||||||
// rather than assumed — a `@unique` constraint failure is the only fully
|
"Profile already belongs to a household",
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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,
|
houseId: number | null,
|
||||||
inviteCode: string,
|
inviteCode: string,
|
||||||
): Promise<HouseView> {
|
): Promise<HouseView> {
|
||||||
if (houseId !== null) {
|
try {
|
||||||
throw new HttpError(409, ErrorCode.ALREADY_HAS_HOUSE, "Profile already belongs to a household");
|
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 } });
|
const house = await prisma.house.findUnique({ where: { inviteCode } });
|
||||||
if (!house) {
|
if (!house) {
|
||||||
throw new HttpError(
|
throw new HttpError(
|
||||||
404,
|
404,
|
||||||
ErrorCode.INVITE_CODE_NOT_FOUND,
|
ErrorCode.INVITE_CODE_NOT_FOUND,
|
||||||
"No household matches this invite code",
|
"No household matches this invite code",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.userProfile.update({ where: { id: profileId }, data: { houseId: house.id } });
|
await prisma.userProfile.update({
|
||||||
return getCurrentHouseOrThrow(house.id);
|
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.
|
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household.
|
||||||
*/
|
*/
|
||||||
export async function leaveCurrentHouse(profileId: number, houseId: number | null): Promise<void> {
|
export async function leaveCurrentHouse(profileId: number, houseId: number | null): Promise<void> {
|
||||||
if (houseId === null) {
|
try {
|
||||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
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.
|
* @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> {
|
export async function deleteHouse(profileId: number, houseId: number | null): Promise<void> {
|
||||||
if (houseId === null) {
|
try {
|
||||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
if (houseId === null) {
|
||||||
}
|
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||||
const house = await findHouseOrThrow(houseId);
|
}
|
||||||
if (house.adminId !== profileId) {
|
const house = await findHouseOrThrow(houseId);
|
||||||
throw new HttpError(403, ErrorCode.NOT_HOUSE_ADMIN, "Only the household's admin can delete it");
|
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,
|
// Members' houseId also cascades to null via the FK's onDelete: SetNull,
|
||||||
// but clearing it explicitly first keeps the outcome obvious without
|
// but clearing it explicitly first keeps the outcome obvious without
|
||||||
// relying on that FK behavior being read alongside this function.
|
// relying on that FK behavior being read alongside this function.
|
||||||
await prisma.$transaction([
|
await prisma.$transaction([
|
||||||
prisma.userProfile.updateMany({ where: { houseId: house.id }, data: { houseId: null } }),
|
prisma.userProfile.updateMany({
|
||||||
prisma.house.delete({ where: { id: house.id } }),
|
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,
|
houseId: number | null,
|
||||||
targetMemberId: number,
|
targetMemberId: number,
|
||||||
): Promise<HouseView> {
|
): Promise<HouseView> {
|
||||||
if (houseId === null) {
|
try {
|
||||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
if (houseId === null) {
|
||||||
}
|
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||||
const house = await findHouseOrThrow(houseId);
|
}
|
||||||
if (house.adminId !== profileId) {
|
const house = await findHouseOrThrow(houseId);
|
||||||
throw new HttpError(
|
if (house.adminId !== profileId) {
|
||||||
403,
|
throw new HttpError(
|
||||||
ErrorCode.NOT_HOUSE_ADMIN,
|
403,
|
||||||
"Only the household's admin can remove a member",
|
ErrorCode.NOT_HOUSE_ADMIN,
|
||||||
);
|
"Only the household's admin can remove a member",
|
||||||
}
|
);
|
||||||
if (targetMemberId === profileId) {
|
}
|
||||||
throw new HttpError(
|
if (targetMemberId === profileId) {
|
||||||
400,
|
throw new HttpError(
|
||||||
ErrorCode.VALIDATION_ERROR,
|
400,
|
||||||
"Use POST /house/leave to remove yourself",
|
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");
|
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 } });
|
await prisma.userProfile.update({
|
||||||
return getCurrentHouseOrThrow(house.id);
|
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.
|
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
|
||||||
*/
|
*/
|
||||||
export async function getHouseSourceIds(houseId: number | null): Promise<number[]> {
|
export async function getHouseSourceIds(houseId: number | null): Promise<number[]> {
|
||||||
if (houseId === null) {
|
try {
|
||||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
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,
|
houseId: number | null,
|
||||||
sourceIds: number[],
|
sourceIds: number[],
|
||||||
): Promise<number[]> {
|
): Promise<number[]> {
|
||||||
if (houseId === null) {
|
try {
|
||||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
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(", ")}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
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. */
|
/** 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> {
|
async function getCurrentHouseOrThrow(houseId: number): Promise<HouseView> {
|
||||||
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}. */
|
/** 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.
|
* `HOUSE_NOT_FOUND` HttpError.
|
||||||
*/
|
*/
|
||||||
async function findHouseOrThrow(houseId: number) {
|
async function findHouseOrThrow(houseId: number) {
|
||||||
const house = await prisma.house.findUnique({
|
try {
|
||||||
where: { id: houseId },
|
const house = await prisma.house.findUnique({
|
||||||
include: houseWithMembers,
|
where: { id: houseId },
|
||||||
});
|
include: houseWithMembers,
|
||||||
if (!house) {
|
});
|
||||||
throw new Error(`House ${houseId} referenced by a profile but not found`);
|
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;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,49 +27,57 @@ export async function getPlanningForDate(
|
||||||
houseId: number | null,
|
houseId: number | null,
|
||||||
date: DateTime,
|
date: DateTime,
|
||||||
): Promise<PlanningView | null> {
|
): Promise<PlanningView | null> {
|
||||||
if (houseId === null) {
|
try {
|
||||||
return null;
|
if (houseId === null) {
|
||||||
}
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// `startDate`/`finishDate` are `@db.Date` columns (no time-of-day
|
// `startDate`/`finishDate` are `@db.Date` columns (no time-of-day
|
||||||
// component) — comparing against a UTC-midnight JS `Date` lines up with
|
// component) — comparing against a UTC-midnight JS `Date` lines up with
|
||||||
// how Postgres stores/returns them, regardless of the server's local
|
// how Postgres stores/returns them, regardless of the server's local
|
||||||
// timezone.
|
// timezone.
|
||||||
const dateOnly = toDateOnly(date).toJSDate();
|
const dateOnly = toDateOnly(date).toJSDate();
|
||||||
|
|
||||||
const planning = await prisma.planning.findFirst({
|
const planning = await prisma.planning.findFirst({
|
||||||
where: {
|
where: {
|
||||||
houseId,
|
houseId,
|
||||||
startDate: { lte: dateOnly },
|
startDate: { lte: dateOnly },
|
||||||
finishDate: { gte: 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 } } },
|
|
||||||
},
|
},
|
||||||
},
|
// 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) {
|
if (!planning) {
|
||||||
return null;
|
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.
|
* scale rather than adding a migration + retry-on-conflict loop for it.
|
||||||
*/
|
*/
|
||||||
async function findOrCreatePlanningForWeek(houseId: number, weekStart: DateTime) {
|
async function findOrCreatePlanningForWeek(houseId: number, weekStart: DateTime) {
|
||||||
const startDate = weekStart.toJSDate();
|
try {
|
||||||
const existing = await prisma.planning.findFirst({ where: { houseId, startDate } });
|
const startDate = weekStart.toJSDate();
|
||||||
if (existing) {
|
const existing = await prisma.planning.findFirst({
|
||||||
return existing;
|
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,
|
date: DateTime,
|
||||||
input: AddPlanningItemInput,
|
input: AddPlanningItemInput,
|
||||||
): Promise<PlanningItemView> {
|
): Promise<PlanningItemView> {
|
||||||
if (houseId === null) {
|
try {
|
||||||
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
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.
|
* @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> {
|
export async function removePlanningItem(id: number, houseId: number | null): Promise<void> {
|
||||||
const item = await prisma.planningItem.findUnique({
|
try {
|
||||||
where: { id },
|
const item = await prisma.planningItem.findUnique({
|
||||||
include: { planning: true },
|
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`);
|
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 } });
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,18 @@ import { prisma } from "../../db/prisma.js";
|
||||||
* just to read it.
|
* just to read it.
|
||||||
*/
|
*/
|
||||||
export async function getPreferences(userProfileId: number): Promise<PreferencesView> {
|
export async function getPreferences(userProfileId: number): Promise<PreferencesView> {
|
||||||
const preferences = await prisma.userPreference.findUnique({ where: { userProfileId } });
|
try {
|
||||||
return { theme: preferences?.theme ?? "SYSTEM" };
|
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,
|
userProfileId: number,
|
||||||
theme: ThemePreference,
|
theme: ThemePreference,
|
||||||
): Promise<PreferencesView> {
|
): Promise<PreferencesView> {
|
||||||
const preferences = await prisma.userPreference.upsert({
|
try {
|
||||||
where: { userProfileId },
|
const preferences = await prisma.userPreference.upsert({
|
||||||
create: { userProfileId, theme },
|
where: { userProfileId },
|
||||||
update: { theme },
|
create: { userProfileId, theme },
|
||||||
});
|
update: { theme },
|
||||||
return { theme: preferences.theme };
|
});
|
||||||
|
return { theme: preferences.theme };
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see getPreferences()'s catch comment above
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,27 +14,39 @@ export async function updateDiet(
|
||||||
userProfileId: number,
|
userProfileId: number,
|
||||||
dietId: number | null,
|
dietId: number | null,
|
||||||
): Promise<SafeUserProfile> {
|
): Promise<SafeUserProfile> {
|
||||||
if (dietId !== null) {
|
try {
|
||||||
const diet = await prisma.diet.findUnique({ where: { id: dietId } });
|
if (dietId !== null) {
|
||||||
if (!diet) {
|
const diet = await prisma.diet.findUnique({ where: { id: dietId } });
|
||||||
throw new HttpError(404, ErrorCode.DIET_NOT_FOUND, `No diet with id ${dietId}`);
|
if (!diet) {
|
||||||
|
throw new HttpError(404, ErrorCode.DIET_NOT_FOUND, `No diet with id ${dietId}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const profile = await prisma.userProfile.update({
|
const profile = await prisma.userProfile.update({
|
||||||
where: { id: userProfileId },
|
where: { id: userProfileId },
|
||||||
data: { dietId },
|
data: { dietId },
|
||||||
});
|
});
|
||||||
return toSafeProfile(profile);
|
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). */
|
/** 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[]> {
|
export async function getAllergyIds(userProfileId: number): Promise<number[]> {
|
||||||
const rows = await prisma.userProfileAllergy.findMany({
|
try {
|
||||||
where: { userProfileId },
|
const rows = await prisma.userProfileAllergy.findMany({
|
||||||
select: { allergyId: true },
|
where: { userProfileId },
|
||||||
});
|
select: { allergyId: true },
|
||||||
return rows.map((row) => row.allergyId);
|
});
|
||||||
|
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,
|
userProfileId: number,
|
||||||
allergyIds: number[],
|
allergyIds: number[],
|
||||||
): Promise<number[]> {
|
): Promise<number[]> {
|
||||||
if (allergyIds.length > 0) {
|
try {
|
||||||
const found = await prisma.allergy.findMany({
|
if (allergyIds.length > 0) {
|
||||||
where: { id: { in: allergyIds } },
|
const found = await prisma.allergy.findMany({
|
||||||
select: { id: true },
|
where: { id: { in: allergyIds } },
|
||||||
});
|
select: { id: true },
|
||||||
const foundIds = new Set(found.map((allergy) => allergy.id));
|
});
|
||||||
const missing = allergyIds.filter((id) => !foundIds.has(id));
|
const foundIds = new Set(found.map((allergy) => allergy.id));
|
||||||
if (missing.length > 0) {
|
const missing = allergyIds.filter((id) => !foundIds.has(id));
|
||||||
throw new HttpError(
|
if (missing.length > 0) {
|
||||||
404,
|
throw new HttpError(
|
||||||
ErrorCode.ALLERGY_NOT_FOUND,
|
404,
|
||||||
`Unknown allergy id(s): ${missing.join(", ")}`,
|
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. */
|
/** 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[]> {
|
export async function getDislikedIngredientIds(userProfileId: number): Promise<number[]> {
|
||||||
const rows = await prisma.userProfileDislikedIngredient.findMany({
|
try {
|
||||||
where: { userProfileId },
|
const rows = await prisma.userProfileDislikedIngredient.findMany({
|
||||||
select: { ingredientId: true },
|
where: { userProfileId },
|
||||||
});
|
select: { ingredientId: true },
|
||||||
return rows.map((row) => row.ingredientId);
|
});
|
||||||
|
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,
|
userProfileId: number,
|
||||||
dislikedIngredientIds: number[],
|
dislikedIngredientIds: number[],
|
||||||
): Promise<number[]> {
|
): Promise<number[]> {
|
||||||
if (dislikedIngredientIds.length > 0) {
|
try {
|
||||||
const found = await prisma.ingredient.findMany({
|
if (dislikedIngredientIds.length > 0) {
|
||||||
where: { id: { in: dislikedIngredientIds } },
|
const found = await prisma.ingredient.findMany({
|
||||||
select: { id: true },
|
where: { id: { in: dislikedIngredientIds } },
|
||||||
});
|
select: { id: true },
|
||||||
const foundIds = new Set(found.map((ingredient) => ingredient.id));
|
});
|
||||||
const missing = dislikedIngredientIds.filter((id) => !foundIds.has(id));
|
const foundIds = new Set(found.map((ingredient) => ingredient.id));
|
||||||
if (missing.length > 0) {
|
const missing = dislikedIngredientIds.filter((id) => !foundIds.has(id));
|
||||||
throw new HttpError(
|
if (missing.length > 0) {
|
||||||
404,
|
throw new HttpError(
|
||||||
ErrorCode.INGREDIENT_NOT_FOUND,
|
404,
|
||||||
`Unknown ingredient id(s): ${missing.join(", ")}`,
|
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;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,9 @@ function recipeInclude(viewerId: number) {
|
||||||
include: {
|
include: {
|
||||||
ingredient: {
|
ingredient: {
|
||||||
include: {
|
include: {
|
||||||
allergies: { include: { allergy: { include: { category: true } } } },
|
allergies: {
|
||||||
|
include: { allergy: { include: { category: true } } },
|
||||||
|
},
|
||||||
diets: { include: { diet: true } },
|
diets: { include: { diet: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -42,20 +44,29 @@ function recipeInclude(viewerId: number) {
|
||||||
},
|
},
|
||||||
steps: {
|
steps: {
|
||||||
orderBy: { order: "asc" },
|
orderBy: { order: "asc" },
|
||||||
include: { techSteps: { orderBy: { order: "asc" }, include: { techStep: true } } },
|
include: {
|
||||||
|
techSteps: { orderBy: { order: "asc" }, include: { techStep: true } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
diets: { include: { diet: true } },
|
diets: { include: { diet: true } },
|
||||||
favoritedBy: { where: { userProfileId: viewerId } },
|
favoritedBy: { where: { userProfileId: viewerId } },
|
||||||
} satisfies Prisma.RecipeInclude;
|
} 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 IngredientWithDetails = RecipeWithDetails["ingredients"][number]["ingredient"];
|
||||||
type UnitWithDetails = RecipeWithDetails["ingredients"][number]["unit"];
|
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. */
|
/** 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 {
|
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`. */
|
/** 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) {
|
for (const stepTechStep of techSteps) {
|
||||||
if (stepTechStep.start === null || stepTechStep.end === null) continue;
|
if (stepTechStep.start === null || stepTechStep.end === null) continue;
|
||||||
views.push({
|
views.push({
|
||||||
techStep: { id: stepTechStep.techStep.id, key: stepTechStep.techStep.key },
|
techStep: {
|
||||||
|
id: stepTechStep.techStep.id,
|
||||||
|
key: stepTechStep.techStep.key,
|
||||||
|
},
|
||||||
start: stepTechStep.start,
|
start: stepTechStep.start,
|
||||||
end: stepTechStep.end,
|
end: stepTechStep.end,
|
||||||
});
|
});
|
||||||
|
|
@ -160,7 +174,11 @@ function toRecipeView(recipe: RecipeWithDetails): RecipeView {
|
||||||
* `RecipeVisibility` in schema.prisma.
|
* `RecipeVisibility` in schema.prisma.
|
||||||
*/
|
*/
|
||||||
function canView(
|
function canView(
|
||||||
recipe: { authorId: number; authorHouseId: number | null; visibility: string },
|
recipe: {
|
||||||
|
authorId: number;
|
||||||
|
authorHouseId: number | null;
|
||||||
|
visibility: string;
|
||||||
|
},
|
||||||
viewerId: number,
|
viewerId: number,
|
||||||
viewerHouseId: number | null,
|
viewerHouseId: number | null,
|
||||||
): boolean {
|
): boolean {
|
||||||
|
|
@ -204,32 +222,46 @@ function visibleToViewerWhere(
|
||||||
* belong in a filter framed around what's safe/appropriate to serve.
|
* belong in a filter framed around what's safe/appropriate to serve.
|
||||||
*/
|
*/
|
||||||
async function suitableForHouseholdWhere(houseId: number): Promise<Prisma.RecipeWhereInput> {
|
async function suitableForHouseholdWhere(houseId: number): Promise<Prisma.RecipeWhereInput> {
|
||||||
const members = await prisma.userProfile.findMany({
|
try {
|
||||||
where: { houseId },
|
const members = await prisma.userProfile.findMany({
|
||||||
select: { dietId: true, allergies: { select: { allergyId: true } } },
|
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 } } } } },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
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<Prisma.Recipe
|
||||||
* is hidden for them until they join or create one and configure it.
|
* is hidden for them until they join or create one and configure it.
|
||||||
*/
|
*/
|
||||||
async function sourceVisibilityWhere(houseId: number | null): Promise<Prisma.RecipeWhereInput> {
|
async function sourceVisibilityWhere(houseId: number | null): Promise<Prisma.RecipeWhereInput> {
|
||||||
const enabledSourceIds =
|
try {
|
||||||
houseId === null
|
const enabledSourceIds =
|
||||||
? []
|
houseId === null
|
||||||
: (await prisma.houseSource.findMany({ where: { houseId }, select: { sourceId: true } })).map(
|
? []
|
||||||
(row) => row.sourceId,
|
: (
|
||||||
);
|
await prisma.houseSource.findMany({
|
||||||
return { OR: [{ sourceId: null }, { sourceId: { in: enabledSourceIds } }] };
|
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,52 +325,60 @@ export async function listRecipes(
|
||||||
tab: RecipeTab,
|
tab: RecipeTab,
|
||||||
filters: ListRecipesFilters = {},
|
filters: ListRecipesFilters = {},
|
||||||
): Promise<RecipeSummaryView[]> {
|
): Promise<RecipeSummaryView[]> {
|
||||||
const { search, suitableForHousehold, ingredientIds, dietIds } = filters;
|
try {
|
||||||
const conditions: Prisma.RecipeWhereInput[] = [await sourceVisibilityWhere(viewerHouseId)];
|
const { search, suitableForHousehold, ingredientIds, dietIds } = filters;
|
||||||
if (search) {
|
const conditions: Prisma.RecipeWhereInput[] = [await sourceVisibilityWhere(viewerHouseId)];
|
||||||
conditions.push({ name: { contains: search, mode: "insensitive" } });
|
if (search) {
|
||||||
}
|
conditions.push({ name: { contains: search, mode: "insensitive" } });
|
||||||
// No-op without a household — nothing to filter against, same posture as
|
}
|
||||||
// the `foyer` tab returning everything it can rather than throwing.
|
// No-op without a household — nothing to filter against, same posture as
|
||||||
if (suitableForHousehold && viewerHouseId !== null) {
|
// the `foyer` tab returning everything it can rather than throwing.
|
||||||
conditions.push(await suitableForHouseholdWhere(viewerHouseId));
|
if (suitableForHousehold && viewerHouseId !== null) {
|
||||||
}
|
conditions.push(await suitableForHouseholdWhere(viewerHouseId));
|
||||||
if (ingredientIds && ingredientIds.length > 0) {
|
}
|
||||||
// One condition per required id (AND) — a recipe must carry all of
|
if (ingredientIds && ingredientIds.length > 0) {
|
||||||
// them, not just one, same "every one, not any one" posture as
|
// One condition per required id (AND) — a recipe must carry all of
|
||||||
// suitableForHouseholdWhere's requiredDietIds.
|
// them, not just one, same "every one, not any one" posture as
|
||||||
conditions.push({
|
// suitableForHouseholdWhere's requiredDietIds.
|
||||||
AND: ingredientIds.map((ingredientId) => ({ ingredients: { some: { ingredientId } } })),
|
conditions.push({
|
||||||
|
AND: ingredientIds.map((ingredientId) => ({
|
||||||
|
ingredients: { some: { ingredientId } },
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (dietIds && dietIds.length > 0) {
|
||||||
|
conditions.push({
|
||||||
|
AND: dietIds.map((dietId) => ({ diets: { some: { dietId } } })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (tab) {
|
||||||
|
case "favoris":
|
||||||
|
conditions.push({ favoritedBy: { some: { userProfileId: viewerId } } });
|
||||||
|
conditions.push(visibleToViewerWhere(viewerId, viewerHouseId));
|
||||||
|
break;
|
||||||
|
case "perso":
|
||||||
|
conditions.push({ visibility: "PERSONAL", authorId: viewerId });
|
||||||
|
break;
|
||||||
|
case "foyer":
|
||||||
|
// No household — nothing can carry this viewer's authorHouseId.
|
||||||
|
if (viewerHouseId === null) return [];
|
||||||
|
conditions.push({ visibility: "HOUSE", authorHouseId: viewerHouseId });
|
||||||
|
break;
|
||||||
|
case "publique":
|
||||||
|
conditions.push({ visibility: "PUBLIC" });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipes = await prisma.recipe.findMany({
|
||||||
|
where: { AND: conditions },
|
||||||
|
include: recipeInclude(viewerId),
|
||||||
|
orderBy: { name: "asc" },
|
||||||
});
|
});
|
||||||
|
return recipes.map(toRecipeSummaryView);
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||||
}
|
}
|
||||||
if (dietIds && dietIds.length > 0) {
|
|
||||||
conditions.push({ AND: dietIds.map((dietId) => ({ diets: { some: { dietId } } })) });
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (tab) {
|
|
||||||
case "favoris":
|
|
||||||
conditions.push({ favoritedBy: { some: { userProfileId: viewerId } } });
|
|
||||||
conditions.push(visibleToViewerWhere(viewerId, viewerHouseId));
|
|
||||||
break;
|
|
||||||
case "perso":
|
|
||||||
conditions.push({ visibility: "PERSONAL", authorId: viewerId });
|
|
||||||
break;
|
|
||||||
case "foyer":
|
|
||||||
// No household — nothing can carry this viewer's authorHouseId.
|
|
||||||
if (viewerHouseId === null) return [];
|
|
||||||
conditions.push({ visibility: "HOUSE", authorHouseId: viewerHouseId });
|
|
||||||
break;
|
|
||||||
case "publique":
|
|
||||||
conditions.push({ visibility: "PUBLIC" });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const recipes = await prisma.recipe.findMany({
|
|
||||||
where: { AND: conditions },
|
|
||||||
include: recipeInclude(viewerId),
|
|
||||||
orderBy: { name: "asc" },
|
|
||||||
});
|
|
||||||
return recipes.map(toRecipeSummaryView);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -344,11 +391,15 @@ export async function getRecipe(
|
||||||
viewerId: number,
|
viewerId: number,
|
||||||
viewerHouseId: number | null,
|
viewerHouseId: number | null,
|
||||||
): Promise<RecipeView> {
|
): Promise<RecipeView> {
|
||||||
const recipe = await findRecipeOrThrow(id, viewerId);
|
try {
|
||||||
if (!canView(recipe, viewerId, viewerHouseId)) {
|
const recipe = await findRecipeOrThrow(id, viewerId);
|
||||||
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
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
|
||||||
}
|
}
|
||||||
return toRecipeView(recipe);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -368,7 +419,11 @@ export async function createRecipe(
|
||||||
authorId: number,
|
authorId: number,
|
||||||
authorHouseId: number | null,
|
authorHouseId: number | null,
|
||||||
): Promise<RecipeView> {
|
): 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,
|
authorHouseId: number | null,
|
||||||
source: { sourceId: number; externalId: string; locale: string },
|
source: { sourceId: number; externalId: string; locale: string },
|
||||||
): Promise<RecipeView> {
|
): 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(
|
async function createRecipeInternal(
|
||||||
|
|
@ -401,90 +460,25 @@ async function createRecipeInternal(
|
||||||
authorHouseId: number | null,
|
authorHouseId: number | null,
|
||||||
source: { sourceId: number; externalId: string; locale: string } | null,
|
source: { sourceId: number; externalId: string; locale: string } | null,
|
||||||
): Promise<RecipeView> {
|
): Promise<RecipeView> {
|
||||||
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
|
try {
|
||||||
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
|
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
|
||||||
await assertDietsExist(input.dietIds);
|
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
|
||||||
const techStepMappings = await loadTechStepMappingRules(
|
await assertDietsExist(input.dietIds);
|
||||||
source?.locale ?? DEFAULT_TECH_STEP_LOCALE,
|
const techStepMappings = await loadTechStepMappingRules(
|
||||||
);
|
source?.locale ?? DEFAULT_TECH_STEP_LOCALE,
|
||||||
|
);
|
||||||
|
|
||||||
const created = await prisma.recipe.create({
|
const created = await prisma.recipe.create({
|
||||||
data: {
|
|
||||||
name: input.name,
|
|
||||||
description: input.description ?? null,
|
|
||||||
picture: input.picture ?? null,
|
|
||||||
portions: input.portions,
|
|
||||||
authorId,
|
|
||||||
authorHouseId,
|
|
||||||
visibility: input.visibility,
|
|
||||||
sourceId: source?.sourceId ?? null,
|
|
||||||
externalId: source?.externalId ?? null,
|
|
||||||
ingredients: {
|
|
||||||
create: input.ingredients.map((ingredient) => ({
|
|
||||||
ingredientId: ingredient.ingredientId,
|
|
||||||
quantity: ingredient.quantity,
|
|
||||||
unitId: ingredient.unitId,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
steps: {
|
|
||||||
create: input.steps.map((step, index) => ({
|
|
||||||
description: step.description,
|
|
||||||
picture: step.picture ?? null,
|
|
||||||
order: index,
|
|
||||||
techSteps: {
|
|
||||||
create: matchTechStepSpans(step.description, techStepMappings).map((match, order) => ({
|
|
||||||
techStepId: match.techStepId,
|
|
||||||
start: match.start,
|
|
||||||
end: match.end,
|
|
||||||
order,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
|
|
||||||
},
|
|
||||||
include: recipeInclude(authorId),
|
|
||||||
});
|
|
||||||
return toRecipeView(created);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replaces a recipe's whole content — name/description/picture/visibility
|
|
||||||
* and the complete ingredient/step/diet lists (not a partial merge: a line
|
|
||||||
* missing from `input` is removed, same contract as `PATCH
|
|
||||||
* /profile/allergies`). `authorId`/`authorHouseId` are untouched — editing
|
|
||||||
* never transfers ownership.
|
|
||||||
*
|
|
||||||
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe visible to `viewerId`.
|
|
||||||
* @throws {HttpError} `403 NOT_RECIPE_AUTHOR` if `viewerId` isn't this recipe's author.
|
|
||||||
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
|
|
||||||
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
|
|
||||||
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
|
|
||||||
*/
|
|
||||||
export async function updateRecipe(
|
|
||||||
id: number,
|
|
||||||
input: UpdateRecipeInput,
|
|
||||||
viewerId: number,
|
|
||||||
viewerHouseId: number | null,
|
|
||||||
): Promise<RecipeView> {
|
|
||||||
await assertIsAuthor(id, viewerId, viewerHouseId);
|
|
||||||
await assertIngredientsExist(input.ingredients.map((i) => i.ingredientId));
|
|
||||||
await assertUnitsExist(input.ingredients.map((i) => i.unitId));
|
|
||||||
await assertDietsExist(input.dietIds);
|
|
||||||
const techStepMappings = await loadTechStepMappingRules(DEFAULT_TECH_STEP_LOCALE);
|
|
||||||
|
|
||||||
await prisma.$transaction([
|
|
||||||
prisma.recipeIngredient.deleteMany({ where: { recipeId: id } }),
|
|
||||||
prisma.step.deleteMany({ where: { recipeId: id } }),
|
|
||||||
prisma.recipeDiet.deleteMany({ where: { recipeId: id } }),
|
|
||||||
prisma.recipe.update({
|
|
||||||
where: { id },
|
|
||||||
data: {
|
data: {
|
||||||
name: input.name,
|
name: input.name,
|
||||||
description: input.description ?? null,
|
description: input.description ?? null,
|
||||||
picture: input.picture ?? null,
|
picture: input.picture ?? null,
|
||||||
portions: input.portions,
|
portions: input.portions,
|
||||||
|
authorId,
|
||||||
|
authorHouseId,
|
||||||
visibility: input.visibility,
|
visibility: input.visibility,
|
||||||
|
sourceId: source?.sourceId ?? null,
|
||||||
|
externalId: source?.externalId ?? null,
|
||||||
ingredients: {
|
ingredients: {
|
||||||
create: input.ingredients.map((ingredient) => ({
|
create: input.ingredients.map((ingredient) => ({
|
||||||
ingredientId: ingredient.ingredientId,
|
ingredientId: ingredient.ingredientId,
|
||||||
|
|
@ -511,10 +505,85 @@ export async function updateRecipe(
|
||||||
},
|
},
|
||||||
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
|
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
|
||||||
},
|
},
|
||||||
}),
|
include: recipeInclude(authorId),
|
||||||
]);
|
});
|
||||||
|
return toRecipeView(created);
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return toRecipeView(await findRecipeOrThrow(id, viewerId));
|
/**
|
||||||
|
* Replaces a recipe's whole content — name/description/picture/visibility
|
||||||
|
* and the complete ingredient/step/diet lists (not a partial merge: a line
|
||||||
|
* missing from `input` is removed, same contract as `PATCH
|
||||||
|
* /profile/allergies`). `authorId`/`authorHouseId` are untouched — editing
|
||||||
|
* never transfers ownership.
|
||||||
|
*
|
||||||
|
* @throws {HttpError} `404 RECIPE_NOT_FOUND` if `id` doesn't match any recipe visible to `viewerId`.
|
||||||
|
* @throws {HttpError} `403 NOT_RECIPE_AUTHOR` if `viewerId` isn't this recipe's author.
|
||||||
|
* @throws {HttpError} `404 INGREDIENT_NOT_FOUND` if any `ingredientId` doesn't match a reference ingredient.
|
||||||
|
* @throws {HttpError} `404 UNIT_NOT_FOUND` if any `unitId` doesn't match a reference unit.
|
||||||
|
* @throws {HttpError} `404 DIET_NOT_FOUND` if any `dietId` doesn't match a reference diet.
|
||||||
|
*/
|
||||||
|
export async function updateRecipe(
|
||||||
|
id: number,
|
||||||
|
input: UpdateRecipeInput,
|
||||||
|
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));
|
||||||
|
await assertDietsExist(input.dietIds);
|
||||||
|
const techStepMappings = await loadTechStepMappingRules(DEFAULT_TECH_STEP_LOCALE);
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.recipeIngredient.deleteMany({ where: { recipeId: id } }),
|
||||||
|
prisma.step.deleteMany({ where: { recipeId: id } }),
|
||||||
|
prisma.recipeDiet.deleteMany({ where: { recipeId: id } }),
|
||||||
|
prisma.recipe.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name: input.name,
|
||||||
|
description: input.description ?? null,
|
||||||
|
picture: input.picture ?? null,
|
||||||
|
portions: input.portions,
|
||||||
|
visibility: input.visibility,
|
||||||
|
ingredients: {
|
||||||
|
create: input.ingredients.map((ingredient) => ({
|
||||||
|
ingredientId: ingredient.ingredientId,
|
||||||
|
quantity: ingredient.quantity,
|
||||||
|
unitId: ingredient.unitId,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
steps: {
|
||||||
|
create: input.steps.map((step, index) => ({
|
||||||
|
description: step.description,
|
||||||
|
picture: step.picture ?? null,
|
||||||
|
order: index,
|
||||||
|
techSteps: {
|
||||||
|
create: matchTechStepSpans(step.description, techStepMappings).map(
|
||||||
|
(match, order) => ({
|
||||||
|
techStepId: match.techStepId,
|
||||||
|
start: match.start,
|
||||||
|
end: match.end,
|
||||||
|
order,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
diets: { create: input.dietIds.map((dietId) => ({ dietId })) },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return toRecipeView(await findRecipeOrThrow(id, viewerId));
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -530,18 +599,24 @@ export async function deleteRecipe(
|
||||||
viewerId: number,
|
viewerId: number,
|
||||||
viewerHouseId: number | null,
|
viewerHouseId: number | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await assertIsAuthor(id, viewerId, viewerHouseId);
|
try {
|
||||||
|
await assertIsAuthor(id, viewerId, viewerHouseId);
|
||||||
|
|
||||||
const usedInPlanning = await prisma.planningItem.findFirst({ where: { recipeId: id } });
|
const usedInPlanning = await prisma.planningItem.findFirst({
|
||||||
if (usedInPlanning) {
|
where: { recipeId: id },
|
||||||
throw new HttpError(
|
});
|
||||||
409,
|
if (usedInPlanning) {
|
||||||
ErrorCode.RECIPE_IN_USE,
|
throw new HttpError(
|
||||||
"Recipe is still used by at least one planning item",
|
409,
|
||||||
);
|
ErrorCode.RECIPE_IN_USE,
|
||||||
|
"Recipe is still used by at least one planning item",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.recipe.delete({ where: { id } });
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.recipe.delete({ where: { id } });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -555,20 +630,32 @@ export async function addFavorite(
|
||||||
viewerId: number,
|
viewerId: number,
|
||||||
viewerHouseId: number | null,
|
viewerHouseId: number | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const recipe = await findRecipeOrThrow(id, viewerId);
|
try {
|
||||||
if (!canView(recipe, viewerId, viewerHouseId)) {
|
const recipe = await findRecipeOrThrow(id, viewerId);
|
||||||
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
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 },
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
create: { userProfileId: viewerId, recipeId: id },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||||
}
|
}
|
||||||
await prisma.recipeFavorite.upsert({
|
|
||||||
where: { userProfileId_recipeId: { userProfileId: viewerId, recipeId: id } },
|
|
||||||
update: {},
|
|
||||||
create: { userProfileId: viewerId, recipeId: id },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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). */
|
/** 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> {
|
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,22 +671,30 @@ export async function assertRecipeVisible(
|
||||||
viewerId: number,
|
viewerId: number,
|
||||||
viewerHouseId: number | null,
|
viewerHouseId: number | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const recipe = await findRecipeOrThrow(id, viewerId);
|
try {
|
||||||
if (!canView(recipe, viewerId, viewerHouseId)) {
|
const recipe = await findRecipeOrThrow(id, viewerId);
|
||||||
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
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). */
|
/** 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> {
|
async function findRecipeOrThrow(id: number, viewerId: number): Promise<RecipeWithDetails> {
|
||||||
const recipe = await prisma.recipe.findUnique({
|
try {
|
||||||
where: { id },
|
const recipe = await prisma.recipe.findUnique({
|
||||||
include: recipeInclude(viewerId),
|
where: { id },
|
||||||
});
|
include: recipeInclude(viewerId),
|
||||||
if (!recipe) {
|
});
|
||||||
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
if (!recipe) {
|
||||||
|
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
||||||
|
}
|
||||||
|
return recipe;
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||||
}
|
}
|
||||||
return recipe;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shared "load, check visible, check authored by viewer" guard for the write paths (`updateRecipe`/`deleteRecipe`). */
|
/** Shared "load, check visible, check authored by viewer" guard for the write paths (`updateRecipe`/`deleteRecipe`). */
|
||||||
|
|
@ -608,58 +703,82 @@ async function assertIsAuthor(
|
||||||
viewerId: number,
|
viewerId: number,
|
||||||
viewerHouseId: number | null,
|
viewerHouseId: number | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const recipe = await findRecipeOrThrow(id, viewerId);
|
try {
|
||||||
if (!canView(recipe, viewerId, viewerHouseId)) {
|
const recipe = await findRecipeOrThrow(id, viewerId);
|
||||||
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
if (!canView(recipe, viewerId, viewerHouseId)) {
|
||||||
}
|
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, `Recipe ${id} not found`);
|
||||||
if (recipe.authorId !== viewerId) {
|
}
|
||||||
throw new HttpError(403, ErrorCode.NOT_RECIPE_AUTHOR, "Only the recipe's author can do this");
|
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. */
|
/** Throws `404 INGREDIENT_NOT_FOUND` if any of `ingredientIds` doesn't match a reference `Ingredient` row. */
|
||||||
async function assertIngredientsExist(ingredientIds: number[]): Promise<void> {
|
async function assertIngredientsExist(ingredientIds: number[]): Promise<void> {
|
||||||
const uniqueIds = [...new Set(ingredientIds)];
|
try {
|
||||||
const found = await prisma.ingredient.findMany({
|
const uniqueIds = [...new Set(ingredientIds)];
|
||||||
where: { id: { in: uniqueIds } },
|
const found = await prisma.ingredient.findMany({
|
||||||
select: { id: true },
|
where: { id: { in: uniqueIds } },
|
||||||
});
|
select: { id: true },
|
||||||
if (found.length !== uniqueIds.length) {
|
});
|
||||||
const foundIds = new Set(found.map((ingredient) => ingredient.id));
|
if (found.length !== uniqueIds.length) {
|
||||||
const missing = uniqueIds.filter((id) => !foundIds.has(id));
|
const foundIds = new Set(found.map((ingredient) => ingredient.id));
|
||||||
throw new HttpError(
|
const missing = uniqueIds.filter((id) => !foundIds.has(id));
|
||||||
404,
|
throw new HttpError(
|
||||||
ErrorCode.INGREDIENT_NOT_FOUND,
|
404,
|
||||||
`Ingredient(s) not found: ${missing.join(", ")}`,
|
ErrorCode.INGREDIENT_NOT_FOUND,
|
||||||
);
|
`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. */
|
/** Throws `404 UNIT_NOT_FOUND` if any of `unitIds` doesn't match a reference `Unit` row. */
|
||||||
async function assertUnitsExist(unitIds: number[]): Promise<void> {
|
async function assertUnitsExist(unitIds: number[]): Promise<void> {
|
||||||
const uniqueIds = [...new Set(unitIds)];
|
try {
|
||||||
const found = await prisma.unit.findMany({
|
const uniqueIds = [...new Set(unitIds)];
|
||||||
where: { id: { in: uniqueIds } },
|
const found = await prisma.unit.findMany({
|
||||||
select: { id: true },
|
where: { id: { in: uniqueIds } },
|
||||||
});
|
select: { id: true },
|
||||||
if (found.length !== uniqueIds.length) {
|
});
|
||||||
const foundIds = new Set(found.map((unit) => unit.id));
|
if (found.length !== uniqueIds.length) {
|
||||||
const missing = uniqueIds.filter((id) => !foundIds.has(id));
|
const foundIds = new Set(found.map((unit) => unit.id));
|
||||||
throw new HttpError(404, ErrorCode.UNIT_NOT_FOUND, `Unit(s) not found: ${missing.join(", ")}`);
|
const missing = uniqueIds.filter((id) => !foundIds.has(id));
|
||||||
|
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. */
|
/** Throws `404 DIET_NOT_FOUND` if any of `dietIds` doesn't match a reference `Diet` row. */
|
||||||
async function assertDietsExist(dietIds: number[]): Promise<void> {
|
async function assertDietsExist(dietIds: number[]): Promise<void> {
|
||||||
const uniqueIds = [...new Set(dietIds)];
|
try {
|
||||||
if (uniqueIds.length === 0) return;
|
const uniqueIds = [...new Set(dietIds)];
|
||||||
const found = await prisma.diet.findMany({
|
if (uniqueIds.length === 0) return;
|
||||||
where: { id: { in: uniqueIds } },
|
const found = await prisma.diet.findMany({
|
||||||
select: { id: true },
|
where: { id: { in: uniqueIds } },
|
||||||
});
|
select: { id: true },
|
||||||
if (found.length !== uniqueIds.length) {
|
});
|
||||||
const foundIds = new Set(found.map((diet) => diet.id));
|
if (found.length !== uniqueIds.length) {
|
||||||
const missing = uniqueIds.filter((id) => !foundIds.has(id));
|
const foundIds = new Set(found.map((diet) => diet.id));
|
||||||
throw new HttpError(404, ErrorCode.DIET_NOT_FOUND, `Diet(s) not found: ${missing.join(", ")}`);
|
const missing = uniqueIds.filter((id) => !foundIds.has(id));
|
||||||
|
throw new HttpError(
|
||||||
|
404,
|
||||||
|
ErrorCode.DIET_NOT_FOUND,
|
||||||
|
`Diet(s) not found: ${missing.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see suitableForHouseholdWhere()'s catch comment above
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,15 @@ import { prisma } from "../../db/prisma.js";
|
||||||
* client-side (`apps/web`'s `locales/fr/translation.json`).
|
* client-side (`apps/web`'s `locales/fr/translation.json`).
|
||||||
*/
|
*/
|
||||||
export async function getDiets(): Promise<DietView[]> {
|
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,15 +36,19 @@ export async function getDiets(): Promise<DietView[]> {
|
||||||
* callers.
|
* callers.
|
||||||
*/
|
*/
|
||||||
export async function getAllergies(): Promise<AllergyView[]> {
|
export async function getAllergies(): Promise<AllergyView[]> {
|
||||||
const allergies = await prisma.allergy.findMany({
|
try {
|
||||||
include: { category: { select: { key: true, kind: true } } },
|
const allergies = await prisma.allergy.findMany({
|
||||||
orderBy: { category: { key: "asc" } },
|
include: { category: { select: { key: true, kind: true } } },
|
||||||
});
|
orderBy: { category: { key: "asc" } },
|
||||||
return allergies.map((allergy) => ({
|
});
|
||||||
id: allergy.id,
|
return allergies.map((allergy) => ({
|
||||||
key: allergy.category.key,
|
id: allergy.id,
|
||||||
kind: allergy.category.kind,
|
key: allergy.category.key,
|
||||||
}));
|
kind: allergy.category.kind,
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see getDiets()'s catch comment above
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -47,13 +59,17 @@ export async function getAllergies(): Promise<AllergyView[]> {
|
||||||
* `RecipeIngredient.quantity`.
|
* `RecipeIngredient.quantity`.
|
||||||
*/
|
*/
|
||||||
export async function getUnits(): Promise<UnitView[]> {
|
export async function getUnits(): Promise<UnitView[]> {
|
||||||
const units = await prisma.unit.findMany({ orderBy: { key: "asc" } });
|
try {
|
||||||
return units.map((unit) => ({
|
const units = await prisma.unit.findMany({ orderBy: { key: "asc" } });
|
||||||
id: unit.id,
|
return units.map((unit) => ({
|
||||||
key: unit.key,
|
id: unit.id,
|
||||||
type: unit.type,
|
key: unit.key,
|
||||||
toBaseFactor: Number(unit.toBaseFactor),
|
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}.
|
* `TECH_STEPS`). Not consumed by the recipe UI yet — see {@link TechStepView}.
|
||||||
*/
|
*/
|
||||||
export async function getTechSteps(): Promise<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`).
|
* `recipe-source-sync.ts`'s `syncRecipeSources`).
|
||||||
*/
|
*/
|
||||||
export async function getSources(): Promise<SourceView[]> {
|
export async function getSources(): Promise<SourceView[]> {
|
||||||
// Explicit `select` — `url` exists on the `Source` row but isn't part of
|
try {
|
||||||
// `SourceView` yet, so it must not leak into the response the way a bare
|
// Explicit `select` — `url` exists on the `Source` row but isn't part of
|
||||||
// `findMany()` would let it.
|
// `SourceView` yet, so it must not leak into the response the way a bare
|
||||||
return prisma.source.findMany({
|
// `findMany()` would let it.
|
||||||
select: { id: true, key: true, name: true, official: true, iconUrl: true },
|
return await prisma.source.findMany({
|
||||||
orderBy: { name: "asc" },
|
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,25 +121,32 @@ export async function getSources(): Promise<SourceView[]> {
|
||||||
* allergen/diet come back with `allergens: []`/`diets: []`.
|
* allergen/diet come back with `allergens: []`/`diets: []`.
|
||||||
*/
|
*/
|
||||||
export async function getIngredients(): Promise<IngredientView[]> {
|
export async function getIngredients(): Promise<IngredientView[]> {
|
||||||
const ingredients = await prisma.ingredient.findMany({
|
try {
|
||||||
include: {
|
const ingredients = await prisma.ingredient.findMany({
|
||||||
allergies: { include: { allergy: { include: { category: true } } } },
|
include: {
|
||||||
diets: { include: { diet: true } },
|
allergies: { include: { allergy: { include: { category: true } } } },
|
||||||
},
|
diets: { include: { diet: true } },
|
||||||
orderBy: { key: "asc" },
|
},
|
||||||
});
|
orderBy: { key: "asc" },
|
||||||
return ingredients.map((ingredient) => ({
|
});
|
||||||
id: ingredient.id,
|
return ingredients.map((ingredient) => ({
|
||||||
key: ingredient.key,
|
id: ingredient.id,
|
||||||
icon: ingredient.icon,
|
key: ingredient.key,
|
||||||
category: ingredient.category,
|
icon: ingredient.icon,
|
||||||
subcategory: ingredient.subcategory,
|
category: ingredient.category,
|
||||||
reproducible: ingredient.reproducible,
|
subcategory: ingredient.subcategory,
|
||||||
allergens: ingredient.allergies.map(({ allergy }) => ({
|
reproducible: ingredient.reproducible,
|
||||||
id: allergy.id,
|
allergens: ingredient.allergies.map(({ allergy }) => ({
|
||||||
key: allergy.category.key,
|
id: allergy.id,
|
||||||
kind: allergy.category.kind,
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,24 +64,34 @@ async function assertSourceEnabled(
|
||||||
houseId: number | null,
|
houseId: number | null,
|
||||||
sourceKey: string,
|
sourceKey: string,
|
||||||
): Promise<{ adapter: RecipeSourceAdapter; sourceId: number }> {
|
): Promise<{ adapter: RecipeSourceAdapter; sourceId: number }> {
|
||||||
const enabledSourceIds = await getHouseSourceIds(houseId);
|
try {
|
||||||
const source = await prisma.source.findUnique({ where: { key: sourceKey } });
|
const enabledSourceIds = await getHouseSourceIds(houseId);
|
||||||
if (!source || !enabledSourceIds.includes(source.id)) {
|
const source = await prisma.source.findUnique({
|
||||||
throw new HttpError(
|
where: { key: sourceKey },
|
||||||
404,
|
});
|
||||||
ErrorCode.SOURCE_NOT_FOUND,
|
if (!source || !enabledSourceIds.includes(source.id)) {
|
||||||
`Source "${sourceKey}" is not enabled for this household`,
|
throw new HttpError(
|
||||||
);
|
404,
|
||||||
|
ErrorCode.SOURCE_NOT_FOUND,
|
||||||
|
`Source "${sourceKey}" is not enabled for this household`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const adapter = getRecipeSource(sourceKey);
|
||||||
|
if (!adapter) {
|
||||||
|
throw new HttpError(
|
||||||
|
404,
|
||||||
|
ErrorCode.SOURCE_NOT_FOUND,
|
||||||
|
`Source "${sourceKey}" has no registered adapter`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
const adapter = getRecipeSource(sourceKey);
|
|
||||||
if (!adapter) {
|
|
||||||
throw new HttpError(
|
|
||||||
404,
|
|
||||||
ErrorCode.SOURCE_NOT_FOUND,
|
|
||||||
`Source "${sourceKey}" has no registered adapter`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { adapter, sourceId: source.id };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -97,27 +107,34 @@ export async function browseSource(
|
||||||
houseId: number | null,
|
houseId: number | null,
|
||||||
params: { query?: string; cursor?: string },
|
params: { query?: string; cursor?: string },
|
||||||
): Promise<{ items: BrowsableSourceItemView[]; nextCursor: string | null }> {
|
): Promise<{ items: BrowsableSourceItemView[]; nextCursor: string | null }> {
|
||||||
const { adapter } = await assertSourceEnabled(houseId, sourceKey);
|
try {
|
||||||
const result = await adapter.list({ query: params.query, cursor: params.cursor });
|
const { adapter } = await assertSourceEnabled(houseId, sourceKey);
|
||||||
|
const result = await adapter.list({
|
||||||
|
query: params.query,
|
||||||
|
cursor: params.cursor,
|
||||||
|
});
|
||||||
|
|
||||||
const importedRecipeIds = await findImportedRecipeIds(
|
const importedRecipeIds = await findImportedRecipeIds(
|
||||||
prisma,
|
prisma,
|
||||||
sourceKey,
|
sourceKey,
|
||||||
result.items.map((item) => item.externalId),
|
result.items.map((item) => item.externalId),
|
||||||
);
|
);
|
||||||
const marked = markAlreadyImported(result.items, new Set(importedRecipeIds.keys()));
|
const marked = markAlreadyImported(result.items, new Set(importedRecipeIds.keys()));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: marked.map((item) => ({
|
items: marked.map((item) => ({
|
||||||
externalId: item.externalId,
|
externalId: item.externalId,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
picture: item.picture,
|
picture: item.picture,
|
||||||
url: item.url,
|
url: item.url,
|
||||||
alreadyImported: item.alreadyImported,
|
alreadyImported: item.alreadyImported,
|
||||||
recipeId: importedRecipeIds.get(item.externalId) ?? null,
|
recipeId: importedRecipeIds.get(item.externalId) ?? null,
|
||||||
})),
|
})),
|
||||||
nextCursor: result.nextCursor,
|
nextCursor: result.nextCursor,
|
||||||
};
|
};
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see assertSourceEnabled()'s catch comment above
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -141,74 +158,80 @@ export async function previewSourceItem(
|
||||||
externalId: string,
|
externalId: string,
|
||||||
houseId: number | null,
|
houseId: number | null,
|
||||||
): Promise<RecipeImportDraftView> {
|
): Promise<RecipeImportDraftView> {
|
||||||
const { adapter } = await assertSourceEnabled(houseId, sourceKey);
|
|
||||||
|
|
||||||
let parsed: ReturnType<typeof adapter.parse>;
|
|
||||||
try {
|
try {
|
||||||
const raw = await adapter.fetchDetail(externalId);
|
const { adapter } = await assertSourceEnabled(houseId, sourceKey);
|
||||||
parsed = adapter.parse(raw);
|
|
||||||
} catch (err) {
|
let parsed: ReturnType<typeof adapter.parse>;
|
||||||
if (err instanceof RecipeSourceError) {
|
try {
|
||||||
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, err.message);
|
const raw = await adapter.fetchDetail(externalId);
|
||||||
|
parsed = adapter.parse(raw);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof RecipeSourceError) {
|
||||||
|
throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, err.message);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
throw err;
|
|
||||||
|
const [techStepMappings, ingredientCatalog, unitCatalog, techStepsByKey] = await Promise.all([
|
||||||
|
loadTechStepMappingRules(adapter.locale),
|
||||||
|
adapter.locale === "en"
|
||||||
|
? loadIngredientCatalog()
|
||||||
|
: Promise.resolve<IngredientMatchEntry[]>([]),
|
||||||
|
adapter.locale === "en" ? loadUnitCatalog() : Promise.resolve<UnitMatchEntry[]>([]),
|
||||||
|
prisma.techStep.findMany({ select: { id: true, key: true } }),
|
||||||
|
]);
|
||||||
|
const techStepById = new Map(techStepsByKey.map((techStep) => [techStep.id, techStep]));
|
||||||
|
|
||||||
|
const translatedIngredients = translateRecipeIngredients(
|
||||||
|
parsed.ingredients,
|
||||||
|
ingredientCatalog,
|
||||||
|
unitCatalog,
|
||||||
|
);
|
||||||
|
const [ingredientViews, unitViews] = await Promise.all([getIngredients(), getUnits()]);
|
||||||
|
const ingredientById = new Map(ingredientViews.map((view) => [view.id, view]));
|
||||||
|
const unitById = new Map(unitViews.map((view) => [view.id, view]));
|
||||||
|
|
||||||
|
// A source's raw ingredient lines aren't deduplicated by the matcher —
|
||||||
|
// two different lines (e.g. "Egg Yolks"/"Eggs") can resolve to the same
|
||||||
|
// catalog ingredient. Folded into one line per ingredient (quantities
|
||||||
|
// summed where that's safe) before the draft ever reaches the review
|
||||||
|
// screen, rather than surfacing the recipe with two rows for "Œuf" and
|
||||||
|
// making the person sort it out — see issue #53's follow-up.
|
||||||
|
const mergedIngredients = mergeDuplicateIngredients(translatedIngredients, unitViews);
|
||||||
|
|
||||||
|
const ingredients: DraftRecipeIngredientView[] = mergedIngredients.map((ingredient) => ({
|
||||||
|
rawText: ingredient.rawText,
|
||||||
|
quantity: ingredient.quantity,
|
||||||
|
ingredient:
|
||||||
|
ingredient.ingredientId !== null
|
||||||
|
? (ingredientById.get(ingredient.ingredientId) ?? null)
|
||||||
|
: null,
|
||||||
|
unit: ingredient.unitId !== null ? (unitById.get(ingredient.unitId) ?? null) : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const steps: DraftRecipeStepView[] = parsed.steps.map((step) => ({
|
||||||
|
description: step.description,
|
||||||
|
picture: step.picture,
|
||||||
|
techSteps: matchTechStepSpans(step.description, techStepMappings).flatMap((match) => {
|
||||||
|
const techStep = techStepById.get(match.techStepId);
|
||||||
|
return techStep ? [{ techStep, start: match.start, end: match.end }] : [];
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceKey,
|
||||||
|
externalId,
|
||||||
|
name: parsed.name,
|
||||||
|
description: parsed.description,
|
||||||
|
picture: parsed.picture,
|
||||||
|
portions: parsed.portions,
|
||||||
|
sourceUrl: parsed.sourceUrl,
|
||||||
|
ingredients,
|
||||||
|
steps,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see assertSourceEnabled()'s catch comment above
|
||||||
}
|
}
|
||||||
|
|
||||||
const [techStepMappings, ingredientCatalog, unitCatalog, techStepsByKey] = await Promise.all([
|
|
||||||
loadTechStepMappingRules(adapter.locale),
|
|
||||||
adapter.locale === "en" ? loadIngredientCatalog() : Promise.resolve<IngredientMatchEntry[]>([]),
|
|
||||||
adapter.locale === "en" ? loadUnitCatalog() : Promise.resolve<UnitMatchEntry[]>([]),
|
|
||||||
prisma.techStep.findMany({ select: { id: true, key: true } }),
|
|
||||||
]);
|
|
||||||
const techStepById = new Map(techStepsByKey.map((techStep) => [techStep.id, techStep]));
|
|
||||||
|
|
||||||
const translatedIngredients = translateRecipeIngredients(
|
|
||||||
parsed.ingredients,
|
|
||||||
ingredientCatalog,
|
|
||||||
unitCatalog,
|
|
||||||
);
|
|
||||||
const [ingredientViews, unitViews] = await Promise.all([getIngredients(), getUnits()]);
|
|
||||||
const ingredientById = new Map(ingredientViews.map((view) => [view.id, view]));
|
|
||||||
const unitById = new Map(unitViews.map((view) => [view.id, view]));
|
|
||||||
|
|
||||||
// A source's raw ingredient lines aren't deduplicated by the matcher —
|
|
||||||
// two different lines (e.g. "Egg Yolks"/"Eggs") can resolve to the same
|
|
||||||
// catalog ingredient. Folded into one line per ingredient (quantities
|
|
||||||
// summed where that's safe) before the draft ever reaches the review
|
|
||||||
// screen, rather than surfacing the recipe with two rows for "Œuf" and
|
|
||||||
// making the person sort it out — see issue #53's follow-up.
|
|
||||||
const mergedIngredients = mergeDuplicateIngredients(translatedIngredients, unitViews);
|
|
||||||
|
|
||||||
const ingredients: DraftRecipeIngredientView[] = mergedIngredients.map((ingredient) => ({
|
|
||||||
rawText: ingredient.rawText,
|
|
||||||
quantity: ingredient.quantity,
|
|
||||||
ingredient:
|
|
||||||
ingredient.ingredientId !== null
|
|
||||||
? (ingredientById.get(ingredient.ingredientId) ?? null)
|
|
||||||
: null,
|
|
||||||
unit: ingredient.unitId !== null ? (unitById.get(ingredient.unitId) ?? null) : null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const steps: DraftRecipeStepView[] = parsed.steps.map((step) => ({
|
|
||||||
description: step.description,
|
|
||||||
picture: step.picture,
|
|
||||||
techSteps: matchTechStepSpans(step.description, techStepMappings).flatMap((match) => {
|
|
||||||
const techStep = techStepById.get(match.techStepId);
|
|
||||||
return techStep ? [{ techStep, start: match.start, end: match.end }] : [];
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
sourceKey,
|
|
||||||
externalId,
|
|
||||||
name: parsed.name,
|
|
||||||
description: parsed.description,
|
|
||||||
picture: parsed.picture,
|
|
||||||
portions: parsed.portions,
|
|
||||||
sourceUrl: parsed.sourceUrl,
|
|
||||||
ingredients,
|
|
||||||
steps,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -238,20 +261,24 @@ export async function importSourceItem(
|
||||||
authorId: number,
|
authorId: number,
|
||||||
authorHouseId: number | null,
|
authorHouseId: number | null,
|
||||||
): Promise<RecipeView> {
|
): Promise<RecipeView> {
|
||||||
const { adapter, sourceId } = await assertSourceEnabled(authorHouseId, sourceKey);
|
try {
|
||||||
|
const { adapter, sourceId } = await assertSourceEnabled(authorHouseId, sourceKey);
|
||||||
|
|
||||||
const alreadyImported = await findImportedRecipeIds(prisma, sourceKey, [externalId]);
|
const alreadyImported = await findImportedRecipeIds(prisma, sourceKey, [externalId]);
|
||||||
if (alreadyImported.has(externalId)) {
|
if (alreadyImported.has(externalId)) {
|
||||||
throw new HttpError(
|
throw new HttpError(
|
||||||
409,
|
409,
|
||||||
ErrorCode.RECIPE_ALREADY_IMPORTED,
|
ErrorCode.RECIPE_ALREADY_IMPORTED,
|
||||||
`"${externalId}" from source "${sourceKey}" is already imported`,
|
`"${externalId}" from source "${sourceKey}" is already imported`,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await createImportedRecipe(input, authorId, authorHouseId, {
|
||||||
|
sourceId,
|
||||||
|
externalId,
|
||||||
|
locale: adapter.locale,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see assertSourceEnabled()'s catch comment above
|
||||||
}
|
}
|
||||||
|
|
||||||
return createImportedRecipe(input, authorId, authorHouseId, {
|
|
||||||
sourceId,
|
|
||||||
externalId,
|
|
||||||
locale: adapter.locale,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -168,7 +168,10 @@ function flattenInstructions(instructions: SchemaOrgRecipe["recipeInstructions"]
|
||||||
* from a prior `list()` call. A future "import from URL" flow would call
|
* from a prior `list()` call. A future "import from URL" flow would call
|
||||||
* `fetchDetail(pastedUrl)` directly.
|
* `fetchDetail(pastedUrl)` directly.
|
||||||
*/
|
*/
|
||||||
export const jsonLdRecipeAdapter: RecipeSourceAdapter<{ html: string; url: string }> = {
|
export const jsonLdRecipeAdapter: RecipeSourceAdapter<{
|
||||||
|
html: string;
|
||||||
|
url: string;
|
||||||
|
}> = {
|
||||||
key: SOURCE_KEY,
|
key: SOURCE_KEY,
|
||||||
name: "Import générique (JSON-LD)",
|
name: "Import générique (JSON-LD)",
|
||||||
official: false,
|
official: false,
|
||||||
|
|
@ -182,21 +185,33 @@ export const jsonLdRecipeAdapter: RecipeSourceAdapter<{ html: string; url: strin
|
||||||
locale: "fr",
|
locale: "fr",
|
||||||
|
|
||||||
async list() {
|
async list() {
|
||||||
return { items: [], nextCursor: null };
|
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 }> {
|
async fetchDetail(url: string): Promise<{ html: string; url: string }> {
|
||||||
let response: Response;
|
|
||||||
try {
|
try {
|
||||||
response = await fetch(url);
|
let response: Response;
|
||||||
} catch (cause) {
|
try {
|
||||||
throw new RecipeSourceFetchError(SOURCE_KEY, `Network error fetching ${url}`, { cause });
|
response = await fetch(url);
|
||||||
|
} catch (cause) {
|
||||||
|
throw new RecipeSourceFetchError(SOURCE_KEY, `Network error fetching ${url}`, { cause });
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new RecipeSourceFetchError(SOURCE_KEY, `${url} responded ${response.status}`);
|
||||||
|
}
|
||||||
|
const html = await response.text();
|
||||||
|
return { html, url };
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see list()'s catch comment above
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
|
||||||
throw new RecipeSourceFetchError(SOURCE_KEY, `${url} responded ${response.status}`);
|
|
||||||
}
|
|
||||||
const html = await response.text();
|
|
||||||
return { html, url };
|
|
||||||
},
|
},
|
||||||
|
|
||||||
parse({ html, url }): ParsedRecipe {
|
parse({ html, url }): ParsedRecipe {
|
||||||
|
|
|
||||||
|
|
@ -38,21 +38,28 @@ interface TheMealDbMealsResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchTheMealDb<T>(path: string): Promise<T> {
|
async function fetchTheMealDb<T>(path: string): Promise<T> {
|
||||||
let response: Response;
|
|
||||||
try {
|
try {
|
||||||
response = await fetch(`${API_BASE}${path}`);
|
let response: Response;
|
||||||
} catch (cause) {
|
try {
|
||||||
throw new RecipeSourceFetchError(SOURCE_KEY, `Network error calling TheMealDB (${path})`, {
|
response = await fetch(`${API_BASE}${path}`);
|
||||||
cause,
|
} catch (cause) {
|
||||||
});
|
throw new RecipeSourceFetchError(SOURCE_KEY, `Network error calling TheMealDB (${path})`, {
|
||||||
|
cause,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new RecipeSourceFetchError(
|
||||||
|
SOURCE_KEY,
|
||||||
|
`TheMealDB responded ${response.status} (${path})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
|
||||||
throw new RecipeSourceFetchError(
|
|
||||||
SOURCE_KEY,
|
|
||||||
`TheMealDB responded ${response.status} (${path})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return response.json() as Promise<T>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function detailUrl(idMeal: string): string {
|
function detailUrl(idMeal: string): string {
|
||||||
|
|
@ -85,31 +92,39 @@ export const theMealDbAdapter: RecipeSourceAdapter<TheMealDbMeal> = {
|
||||||
locale: "en",
|
locale: "en",
|
||||||
|
|
||||||
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
|
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
|
||||||
const query = params.query ?? "";
|
try {
|
||||||
const data = await fetchTheMealDb<TheMealDbMealsResponse>(
|
const query = params.query ?? "";
|
||||||
`/search.php?s=${encodeURIComponent(query)}`,
|
const data = await fetchTheMealDb<TheMealDbMealsResponse>(
|
||||||
);
|
`/search.php?s=${encodeURIComponent(query)}`,
|
||||||
const meals = data.meals ?? [];
|
);
|
||||||
return {
|
const meals = data.meals ?? [];
|
||||||
items: meals
|
return {
|
||||||
.filter((meal): meal is TheMealDbMeal & { strMeal: string } => Boolean(meal.strMeal))
|
items: meals
|
||||||
.map((meal) => ({
|
.filter((meal): meal is TheMealDbMeal & { strMeal: string } => Boolean(meal.strMeal))
|
||||||
externalId: meal.idMeal,
|
.map((meal) => ({
|
||||||
title: meal.strMeal,
|
externalId: meal.idMeal,
|
||||||
picture: meal.strMealThumb,
|
title: meal.strMeal,
|
||||||
url: detailUrl(meal.idMeal),
|
picture: meal.strMealThumb,
|
||||||
})),
|
url: detailUrl(meal.idMeal),
|
||||||
nextCursor: null,
|
})),
|
||||||
};
|
nextCursor: null,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see fetchTheMealDb()'s catch comment above
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchDetail(externalId: string): Promise<TheMealDbMeal> {
|
async fetchDetail(externalId: string): Promise<TheMealDbMeal> {
|
||||||
const data = await fetchTheMealDb<TheMealDbMealsResponse>(`/lookup.php?i=${externalId}`);
|
try {
|
||||||
const meal = data.meals?.[0];
|
const data = await fetchTheMealDb<TheMealDbMealsResponse>(`/lookup.php?i=${externalId}`);
|
||||||
if (!meal) {
|
const meal = data.meals?.[0];
|
||||||
throw new RecipeSourceFetchError(SOURCE_KEY, `No meal found for id "${externalId}"`);
|
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
|
||||||
}
|
}
|
||||||
return meal;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
parse(meal: TheMealDbMeal): ParsedRecipe {
|
parse(meal: TheMealDbMeal): ParsedRecipe {
|
||||||
|
|
|
||||||
|
|
@ -75,29 +75,38 @@ export class ApiClient {
|
||||||
path: string,
|
path: string,
|
||||||
options: RequestInit = {},
|
options: RequestInit = {},
|
||||||
): Promise<TResponseBody> {
|
): Promise<TResponseBody> {
|
||||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
try {
|
||||||
...options,
|
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
// Required for the httpOnly session cookie to be sent/received — the
|
...options,
|
||||||
// API and the web app run on different origins.
|
// Required for the httpOnly session cookie to be sent/received — the
|
||||||
credentials: "include",
|
// API and the web app run on different origins.
|
||||||
headers: { "Content-Type": "application/json", ...options.headers },
|
credentials: "include",
|
||||||
});
|
headers: { "Content-Type": "application/json", ...options.headers },
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = (await response.json().catch(() => null)) as ApiErrorResponse | null;
|
const body = (await response.json().catch(() => null)) as ApiErrorResponse | null;
|
||||||
// Fallback for a response that couldn't even be parsed as JSON — no
|
// Fallback for a response that couldn't even be parsed as JSON — no
|
||||||
// hardcoded string, always the real enum member.
|
// hardcoded string, always the real enum member.
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
response.status,
|
response.status,
|
||||||
body ?? { code: ErrorCode.INTERNAL_ERROR, message: "Something went wrong" },
|
body ?? { code: ErrorCode.INTERNAL_ERROR, message: "Something went wrong" },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 204 No Content (e.g. logout) has no body to parse.
|
// 204 No Content (e.g. logout) has no body to parse.
|
||||||
if (response.status === 204) {
|
if (response.status === 204) {
|
||||||
return undefined as TResponseBody;
|
return undefined as 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;
|
||||||
}
|
}
|
||||||
return response.json() as Promise<TResponseBody>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Creates a profile and starts a session — no household yet, that's an optional step of the onboarding wizard. */
|
/** Creates a profile and starts a session — no household yet, that's an optional step of the onboarding wizard. */
|
||||||
|
|
|
||||||
|
|
@ -49,25 +49,49 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const signup = useCallback(async (input: SignupInput) => {
|
const signup = useCallback(async (input: SignupInput) => {
|
||||||
setUser(await apiClient.signup(input));
|
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) => {
|
const login = useCallback(async (input: LoginInput) => {
|
||||||
setUser(await apiClient.login(input));
|
try {
|
||||||
|
setUser(await apiClient.login(input));
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see signup()'s catch comment above
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
await apiClient.logout();
|
try {
|
||||||
setUser(null);
|
await apiClient.logout();
|
||||||
|
setUser(null);
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see signup()'s catch comment above
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const deleteAccount = useCallback(async (password: string) => {
|
const deleteAccount = useCallback(async (password: string) => {
|
||||||
await apiClient.deleteAccount(password);
|
try {
|
||||||
setUser(null);
|
await apiClient.deleteAccount(password);
|
||||||
|
setUser(null);
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see signup()'s catch comment above
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const refreshUser = useCallback(async () => {
|
const refreshUser = useCallback(async () => {
|
||||||
setUser(await apiClient.me());
|
try {
|
||||||
|
setUser(await apiClient.me());
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see signup()'s catch comment above
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -71,9 +71,17 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
}, [user?.id]);
|
}, [user?.id]);
|
||||||
|
|
||||||
const setTheme = useCallback(async (newTheme: ThemePreference) => {
|
const setTheme = useCallback(async (newTheme: ThemePreference) => {
|
||||||
await apiClient.updatePreferences(newTheme);
|
try {
|
||||||
setThemeState(newTheme);
|
await apiClient.updatePreferences(newTheme);
|
||||||
applyTheme(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>;
|
return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>;
|
||||||
|
|
|
||||||
|
|
@ -194,8 +194,15 @@ function AccountMenu() {
|
||||||
/** Ends the session and returns to the login page. */
|
/** Ends the session and returns to the login page. */
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
await logout();
|
try {
|
||||||
void navigate("/login");
|
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() ?? "";
|
const initial = user?.firstName?.charAt(0).toUpperCase() ?? "";
|
||||||
|
|
|
||||||
|
|
@ -365,9 +365,17 @@ function InviteCode({ code }: { code: string }) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
async function handleCopy() {
|
async function handleCopy() {
|
||||||
await navigator.clipboard.writeText(code);
|
try {
|
||||||
setCopied(true);
|
await navigator.clipboard.writeText(code);
|
||||||
window.setTimeout(() => setCopied(false), 2000);
|
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 (
|
return (
|
||||||
|
|
@ -390,6 +398,8 @@ function RemoveMemberButton({ memberId, onChanged }: { memberId: number; onChang
|
||||||
try {
|
try {
|
||||||
await apiClient.removeHouseMember(memberId);
|
await apiClient.removeHouseMember(memberId);
|
||||||
onChanged();
|
onChanged();
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see InviteCode's handleCopy() catch comment above
|
||||||
} finally {
|
} finally {
|
||||||
setIsRemoving(false);
|
setIsRemoving(false);
|
||||||
}
|
}
|
||||||
|
|
@ -413,6 +423,8 @@ function DeleteHouseholdSection({ onChanged }: { onChanged: () => void }) {
|
||||||
try {
|
try {
|
||||||
await apiClient.deleteHouse();
|
await apiClient.deleteHouse();
|
||||||
onChanged();
|
onChanged();
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see InviteCode's handleCopy() catch comment above
|
||||||
} finally {
|
} finally {
|
||||||
setIsDeleting(false);
|
setIsDeleting(false);
|
||||||
}
|
}
|
||||||
|
|
@ -461,6 +473,8 @@ function LeaveHouseholdSection({ onChanged }: { onChanged: () => void }) {
|
||||||
try {
|
try {
|
||||||
await apiClient.leaveHouse();
|
await apiClient.leaveHouse();
|
||||||
onChanged();
|
onChanged();
|
||||||
|
} catch (err) {
|
||||||
|
throw err; // see InviteCode's handleCopy() catch comment above
|
||||||
} finally {
|
} finally {
|
||||||
setIsLeaving(false);
|
setIsLeaving(false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"preset": "recommended",
|
"preset": "recommended",
|
||||||
|
"complexity": {
|
||||||
|
"noUselessCatch": "off"
|
||||||
|
},
|
||||||
"nursery": {
|
"nursery": {
|
||||||
"noFloatingPromises": "error"
|
"noFloatingPromises": "error"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,42 @@ nouveau code) — voir `LoggerService._emit`/`_minSeverity`,
|
||||||
`ApiClient._request`, `ErrorHandlerService._fromZodError`/`_fromHttpError`/
|
`ApiClient._request`, `ErrorHandlerService._fromZodError`/`_fromHttpError`/
|
||||||
`_fromUnknownError`, `ExpressServer._app`/`_registeredRoutes`.
|
`_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é
|
### Élégance avant rapidité
|
||||||
|
|
||||||
Préférer une solution propre, bien structurée et délibérée à une solution
|
Préférer une solution propre, bien structurée et délibérée à une solution
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue