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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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; }