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>
335 lines
12 KiB
TypeScript
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;
|
|
}
|