diff --git a/apps/api/prisma/migrations/20260817093000_add_house_admin_invite_code/migration.sql b/apps/api/prisma/migrations/20260817093000_add_house_admin_invite_code/migration.sql new file mode 100644 index 0000000..e355307 --- /dev/null +++ b/apps/api/prisma/migrations/20260817093000_add_house_admin_invite_code/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "house" ADD COLUMN "admin_id" INTEGER NOT NULL, +ADD COLUMN "invite_code" TEXT NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "house_invite_code_key" ON "house"("invite_code"); + +-- AddForeignKey +ALTER TABLE "house" ADD CONSTRAINT "house_admin_id_fkey" FOREIGN KEY ("admin_id") REFERENCES "user_profiles"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index ebe8716..769943d 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -13,10 +13,19 @@ datasource db { // ----------------------------------------------------------------------------- model House { - id Int @id @default(autoincrement()) - name String + id Int @id @default(autoincrement()) + name String + /// The member who administers this household — created it, or inherited + /// adminship when the previous admin left/deleted their account (see + /// `house.service.ts`'s `leaveCurrentHouse`). Always set: a house is + /// deleted outright once it would otherwise have no admin left. + adminId Int @map("admin_id") + /// Shareable code another user enters via `POST /house/join` to become a + /// member — see `house.service.ts`'s generator for the charset/length. + inviteCode String @unique @map("invite_code") - members UserProfile[] + admin UserProfile @relation("HouseAdmin", fields: [adminId], references: [id]) + members UserProfile[] @relation("HouseMember") plannings Planning[] @@map("house") @@ -81,9 +90,14 @@ model UserProfile { houseId Int? @map("house_id") dietId Int? @map("diet_id") - house House? @relation(fields: [houseId], references: [id], onDelete: SetNull) - diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull) - allergies UserProfileAllergy[] + house House? @relation("HouseMember", fields: [houseId], references: [id], onDelete: SetNull) + diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull) + allergies UserProfileAllergy[] + /// Households this profile administers. In practice at most one — a + /// profile can only ever belong to (and thus admin) a single household at + /// a time — but Prisma models the admin side of a one-to-many FK as a + /// list regardless of that real-world cardinality. + administeredHouses House[] @relation("HouseAdmin") @@map("user_profiles") } diff --git a/apps/api/src/modules/house/house.routes.ts b/apps/api/src/modules/house/house.routes.ts index ec9c67d..ec9fde9 100644 --- a/apps/api/src/modules/house/house.routes.ts +++ b/apps/api/src/modules/house/house.routes.ts @@ -1,10 +1,24 @@ +import { HttpError } from "@batch-cooking/error-tools"; import { wrapAsyncHandler } from "@batch-cooking/express-tools"; -import { renameHouseSchema } from "@batch-cooking/shared"; +import { + ErrorCode, + createHouseSchema, + joinHouseSchema, + renameHouseSchema, +} from "@batch-cooking/shared"; import { Router } from "express"; import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; -import { getCurrentHouse, renameHouse } from "./house.service.js"; +import { + createHouse, + deleteHouse, + getCurrentHouse, + joinHouse, + leaveCurrentHouse, + removeMember, + renameHouse, +} from "./house.service.js"; -/** Router mounted at `/house` in app.ts. Both routes require a session — a household is per-user (via their profile), never public. */ +/** Router mounted at `/house` in app.ts. Every route requires a session — a household is per-user (via their profile), never public. */ export const houseRouter = Router(); houseRouter.get( @@ -16,7 +30,7 @@ houseRouter.get( }), ); -/** The household step of the profile journey (signup wizard and the `/foyer` settings page both call this). */ +/** The household step of the profile journey (onboarding wizard and the `/parametres/foyer` settings page both call this) — renaming, open to any member. */ houseRouter.patch( "/current", requireAuth, @@ -26,3 +40,71 @@ houseRouter.patch( res.status(200).json(house); }), ); + +/** Creates a new household for a profile that doesn't have one yet — the "create" half of the optional household step. */ +houseRouter.post( + "/", + requireAuth, + wrapAsyncHandler(async (req, res) => { + const input = createHouseSchema.parse(req.body); + const house = await createHouse( + res.locals.userProfile.id, + res.locals.userProfile.houseId, + input.name, + ); + res.status(201).json(house); + }), +); + +/** Joins an existing household by invite code — the "join" half of the optional household step. */ +houseRouter.post( + "/join", + requireAuth, + wrapAsyncHandler(async (req, res) => { + const input = joinHouseSchema.parse(req.body); + const house = await joinHouse( + res.locals.userProfile.id, + res.locals.userProfile.houseId, + input.inviteCode, + ); + res.status(200).json(house); + }), +); + +/** Removes the caller from their own household — see `deleteHouse` below for removing the household itself. */ +houseRouter.post( + "/leave", + requireAuth, + wrapAsyncHandler(async (_req, res) => { + await leaveCurrentHouse(res.locals.userProfile.id, res.locals.userProfile.houseId); + res.status(204).end(); + }), +); + +/** Deletes the household entirely — every member loses it. Admin-only, see `house.service.ts`. */ +houseRouter.delete( + "/current", + requireAuth, + wrapAsyncHandler(async (_req, res) => { + await deleteHouse(res.locals.userProfile.id, res.locals.userProfile.houseId); + res.status(204).end(); + }), +); + +/** Removes one specific member from the caller's household. Admin-only, see `house.service.ts`. */ +houseRouter.delete( + "/members/:memberId", + requireAuth, + wrapAsyncHandler(async (req, res) => { + const memberId = Number(req.params.memberId); + if (!Number.isInteger(memberId)) { + throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "memberId must be an integer"); + } + const house = await removeMember( + res.locals.userProfile.id, + res.locals.userProfile.houseId, + memberId, + ); + res.status(200).json(house); + }), +); diff --git a/apps/api/src/modules/house/house.service.ts b/apps/api/src/modules/house/house.service.ts index 6a34438..05c30a2 100644 --- a/apps/api/src/modules/house/house.service.ts +++ b/apps/api/src/modules/house/house.service.ts @@ -1,17 +1,59 @@ +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"; -/** Returns the profile's household, or `null` if the profile has none yet (`houseId` is `null` — see `SafeUserProfile`). */ +/** + * 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 findHouseOrThrow(houseId); + return toHouseView(await findHouseOrThrow(houseId)); } /** - * Renames the profile's household. + * 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. */ @@ -20,7 +62,198 @@ export async function renameHouse(houseId: number | null, name: string): Promise throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household"); } await findHouseOrThrow(houseId); - return prisma.house.update({ where: { id: houseId }, data: { name } }); + 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); +} + +/** 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" + ); } /** @@ -31,8 +264,11 @@ export async function renameHouse(houseId: number | null, name: string): Promise * through the API, hence a plain `Error` (500) rather than a * `HOUSE_NOT_FOUND` HttpError. */ -async function findHouseOrThrow(houseId: number): Promise { - const house = await prisma.house.findUnique({ where: { id: houseId } }); +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`); }