API: administrateur de foyer, code d'invitation, créer/rejoindre/quitter/supprimer (step 2/8)

- House.adminId / House.inviteCode (migration Prisma)
- house.service: createHouse, joinHouse, leaveCurrentHouse (transfert
  d'admin ou suppression du foyer si dernier membre), deleteHouse
  (admin-only), removeMember (admin-only)
- house.routes: POST /house, POST /house/join, POST /house/leave,
  DELETE /house/current, DELETE /house/members/:memberId
- GET/PATCH /house/current renvoient désormais membres + admin + code
This commit is contained in:
Nicolas 2026-08-17 10:38:16 +02:00
parent 1248461125
commit 7d5a6c05bb
4 changed files with 357 additions and 16 deletions

View file

@ -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;

View file

@ -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")
}

View file

@ -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<unknown, AuthLocals>(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<unknown, AuthLocals>(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<unknown, AuthLocals>(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<unknown, AuthLocals>(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<unknown, AuthLocals>(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);
}),
);

View file

@ -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<HouseView | null> {
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<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);
}
/** 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"
);
}
/**
@ -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<HouseView> {
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`);
}