batchCooking/apps/api/src/modules/house/house.service.ts
Nicolas 766d48eaa5 feat(recipes): préférences de sources par foyer + distinction officielle/non-officielle
Répond à deux besoins : permettre à chaque foyer de choisir quelles
sources apparaissent dans ses onglets de recettes, et distinguer les
sources à API officielle des sources scrapées.

- RecipeSourceAdapter.official (booléen, sans défaut — chaque
  adaptateur doit le déclarer explicitement) synchronisé sur
  Source.official par syncRecipeSources.
- HouseSource : table de jointure opt-in (House <-> Source) — aucune
  ligne = source masquée. Un foyer nouvellement créé ne voit aucune
  source tant qu'il ne les active pas explicitement.
- GET /reference/sources (catalogue des sources implémentées, avec le
  flag officiel).
- GET/PATCH /house/current/sources (lecture/remplacement complet des
  sources activées par le foyer courant).
- recipe.service.ts : sourceVisibilityWhere() filtre désormais TOUS
  les onglets (perso/foyer/publique/favoris) — une recette sans
  source reste toujours visible ; une recette importée ne l'est que
  si sa source est activée pour le foyer du viewer. Un viewer sans
  foyer ne voit aucune recette sourcée.

Côté web :
- Nouvelle étape /onboarding/sources dans le wizard d'inscription,
  atteinte uniquement si un foyer vient d'être créé/rejoint (sinon on
  saute direct aux allergènes) ; s'auto-saute aussi si aucune source
  n'est encore implémentée (catalogue vide aujourd'hui).
- Nouvelle section « Sources de recettes » dans /parametres/foyer
  (masquée dans les mêmes conditions), avec sauvegarde à la volée
  (même pattern que les autres préférences hot-saved).
- SourceSelect (features/house/), grille de cases à cocher avec badge
  officiel/non-officielle, sur le même principe qu'AllergySelect.

172 tests backend passent (dont 25 nouveaux). Build et lint propres.
Vérifié manuellement en navigateur : le parcours d'onboarding saute
bien l'étape sources (catalogue vide) et affiche « 4 sur 4 » quand un
foyer a été créé ; la section paramètres reste invisible tant
qu'aucune source n'existe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 09:22:54 +02:00

335 lines
12 KiB
TypeScript

import { randomInt } from "node:crypto";
import { HttpError } from "@batch-cooking/error-tools";
import { ErrorCode, type HouseView } from "@batch-cooking/shared";
import { prisma } from "../../db/prisma.js";
/**
* Charset for {@link generateInviteCode} — uppercase letters/digits only,
* minus the visually-ambiguous `0`/`O`/`1`/`I` (this code is meant to be
* read off one screen and typed into another).
*/
const INVITE_CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const INVITE_CODE_LENGTH = 8;
/** Generates one candidate invite code. Collisions are handled by the caller (retry on the DB's unique-constraint failure), not here. */
function generateInviteCode(): string {
let code = "";
for (let i = 0; i < INVITE_CODE_LENGTH; i++) {
code += INVITE_CODE_CHARS[randomInt(INVITE_CODE_CHARS.length)];
}
return code;
}
/** Shapes a Prisma `House` (with its `members` relation included) into the public {@link HouseView}. */
function toHouseView(house: {
id: number;
name: string;
adminId: number;
inviteCode: string;
members: { id: number; firstName: string; lastName: string }[];
}): HouseView {
return {
id: house.id,
name: house.name,
adminId: house.adminId,
inviteCode: house.inviteCode,
members: house.members,
};
}
/** Shared `include` for every query that needs to return a full {@link HouseView}. */
const houseWithMembers = {
members: { select: { id: true, firstName: true, lastName: true } },
} as const;
/** 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> {
if (houseId === null) {
return null;
}
return toHouseView(await findHouseOrThrow(houseId));
}
/**
* Renames the profile's household. Open to any member, not just the admin —
* unlike deleting the household or removing a member, renaming isn't
* destructive enough to gate behind adminship.
*
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
*/
export async function renameHouse(houseId: number | null, name: string): Promise<HouseView> {
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);
}
/**
* Creates a new household for a profile that doesn't have one yet, with the
* creating profile as its admin.
*
* @throws {HttpError} `409 ALREADY_HAS_HOUSE` if the profile already belongs to a household.
*/
export async function createHouse(
profileId: number,
houseId: number | null,
name: string,
): Promise<HouseView> {
if (houseId !== null) {
throw new HttpError(409, ErrorCode.ALREADY_HAS_HOUSE, "Profile already belongs to a household");
}
// Astronomically unlikely to collide (33^8 possibilities), but retried
// rather than assumed — a `@unique` constraint failure is the only fully
// reliable way to detect it.
const maxAttempts = 5;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const house = await prisma.$transaction(async (tx) => {
const created = await tx.house.create({
data: { name, adminId: profileId, inviteCode: generateInviteCode() },
});
await tx.userProfile.update({ where: { id: profileId }, data: { houseId: created.id } });
return created;
});
return getCurrentHouseOrThrow(house.id);
} catch (err) {
if (isUniqueInviteCodeViolation(err) && attempt < maxAttempts) continue;
throw err;
}
}
throw new Error("Failed to generate a unique invite code after several attempts");
}
/**
* Joins an existing household by invite code.
*
* @throws {HttpError} `409 ALREADY_HAS_HOUSE` if the profile already belongs to a household.
* @throws {HttpError} `404 INVITE_CODE_NOT_FOUND` if no household matches the code.
*/
export async function joinHouse(
profileId: number,
houseId: number | null,
inviteCode: string,
): Promise<HouseView> {
if (houseId !== null) {
throw new HttpError(409, ErrorCode.ALREADY_HAS_HOUSE, "Profile already belongs to a household");
}
const house = await prisma.house.findUnique({ where: { inviteCode } });
if (!house) {
throw new HttpError(
404,
ErrorCode.INVITE_CODE_NOT_FOUND,
"No household matches this invite code",
);
}
await prisma.userProfile.update({ where: { id: profileId }, data: { houseId: house.id } });
return getCurrentHouseOrThrow(house.id);
}
/**
* Removes a profile from its current household — used both by
* `POST /house/leave` (a member removing themselves) and by account
* deletion (`auth.service.ts`'s `deleteAccount`, before the profile row
* itself is deleted).
*
* If the leaving profile was the household's admin: adminship transfers to
* the longest-standing remaining member (lowest id) if there is one,
* otherwise the household itself is deleted (cascading its plannings) since
* a household can never be left without an admin.
*
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household.
*/
export async function leaveCurrentHouse(profileId: number, houseId: number | null): Promise<void> {
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 } });
});
}
/**
* Deletes a household outright — every member (not just the caller) loses
* it, and its plannings are cascaded away. Only the household's admin may
* do this; a non-admin member wanting out should call
* {@link leaveCurrentHouse} (`POST /house/leave`) instead.
*
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household.
* @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> {
if (houseId === null) {
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
}
const house = await findHouseOrThrow(houseId);
if (house.adminId !== profileId) {
throw new HttpError(403, ErrorCode.NOT_HOUSE_ADMIN, "Only the household's admin can delete it");
}
// Members' houseId also cascades to null via the FK's onDelete: SetNull,
// but clearing it explicitly first keeps the outcome obvious without
// relying on that FK behavior being read alongside this function.
await prisma.$transaction([
prisma.userProfile.updateMany({ where: { houseId: house.id }, data: { houseId: null } }),
prisma.house.delete({ where: { id: house.id } }),
]);
}
/**
* Removes one specific member from the caller's household — the admin
* acting on someone else. The admin can't remove themselves this way (no
* adminship to hand off here); they use {@link leaveCurrentHouse} instead,
* same as any other member leaving voluntarily.
*
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household.
* @throws {HttpError} `403 NOT_HOUSE_ADMIN` if the profile isn't this household's admin.
* @throws {HttpError} `400 VALIDATION_ERROR` if `targetMemberId` is the caller, or isn't a member of this household.
*/
export async function removeMember(
profileId: number,
houseId: number | null,
targetMemberId: number,
): Promise<HouseView> {
if (houseId === null) {
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
}
const house = await findHouseOrThrow(houseId);
if (house.adminId !== profileId) {
throw new HttpError(
403,
ErrorCode.NOT_HOUSE_ADMIN,
"Only the household's admin can remove a member",
);
}
if (targetMemberId === profileId) {
throw new HttpError(
400,
ErrorCode.VALIDATION_ERROR,
"Use POST /house/leave to remove yourself",
);
}
if (!house.members.some((member) => member.id === targetMemberId)) {
throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "Not a member of this household");
}
await prisma.userProfile.update({ where: { id: targetMemberId }, data: { houseId: null } });
return getCurrentHouseOrThrow(house.id);
}
/**
* Current enabled-source ids for a household — an empty array is normal
* and is this household's starting state (opt-in: see `HouseSource` in
* schema.prisma), not just "no preference set yet".
*
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
*/
export async function getHouseSourceIds(houseId: number | null): Promise<number[]> {
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);
}
/**
* Replaces a household's full set of enabled recipe sources (not a merge —
* same "replace, not merge" contract as `profile.service.ts`'s
* `updateAllergies`). Every recipe-catalog tab (`recipe.service.ts`'s
* `listRecipes`) filters against this set — a source left out here simply
* never shows its recipes to this household, in any tab.
*
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
* @throws {HttpError} `404 SOURCE_NOT_FOUND` if any `sourceId` doesn't match a reference `Source` row.
*/
export async function updateHouseSources(
houseId: number | null,
sourceIds: number[],
): Promise<number[]> {
if (houseId === null) {
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
}
if (sourceIds.length > 0) {
const found = await prisma.source.findMany({
where: { id: { in: sourceIds } },
select: { id: true },
});
const foundIds = new Set(found.map((source) => source.id));
const missing = sourceIds.filter((id) => !foundIds.has(id));
if (missing.length > 0) {
throw new HttpError(
404,
ErrorCode.SOURCE_NOT_FOUND,
`Unknown source id(s): ${missing.join(", ")}`,
);
}
}
await prisma.$transaction([
prisma.houseSource.deleteMany({ where: { houseId } }),
prisma.houseSource.createMany({ data: sourceIds.map((sourceId) => ({ houseId, sourceId })) }),
]);
return sourceIds;
}
/** 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> {
return toHouseView(await findHouseOrThrow(houseId));
}
/** True if `err` is Prisma's unique-constraint violation (`P2002`) on `invite_code` — the only expected cause of a collision retry in {@link createHouse}. */
function isUniqueInviteCodeViolation(err: unknown): boolean {
return (
typeof err === "object" &&
err !== null &&
"code" in err &&
(err as { code: unknown }).code === "P2002"
);
}
/**
* A profile's `houseId` is only ever set to a real house (foreign key,
* never assigned by hand) — a lookup miss here means the referenced row
* was deleted out from under a still-linked profile, an internal
* inconsistency rather than a normal "not found" a client could hit
* through the API, hence a plain `Error` (500) rather than a
* `HOUSE_NOT_FOUND` HttpError.
*/
async function findHouseOrThrow(houseId: number) {
const house = await prisma.house.findUnique({
where: { id: houseId },
include: houseWithMembers,
});
if (!house) {
throw new Error(`House ${houseId} referenced by a profile but not found`);
}
return house;
}