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:
parent
1248461125
commit
7d5a6c05bb
4 changed files with 357 additions and 16 deletions
|
|
@ -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;
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue