batchCooking/apps/api/src/modules/house/house.routes.ts
Nicolas 766d48eaa5 feat(recipes): préférences de sources par foyer + distinction officielle/non-officielle
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>
2026-08-20 09:22:54 +02:00

134 lines
4.3 KiB
TypeScript

import { HttpError } from "@batch-cooking/error-tools";
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import {
ErrorCode,
createHouseSchema,
joinHouseSchema,
renameHouseSchema,
updateHouseSourcesSchema,
} from "@batch-cooking/shared";
import { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import {
createHouse,
deleteHouse,
getCurrentHouse,
getHouseSourceIds,
joinHouse,
leaveCurrentHouse,
removeMember,
renameHouse,
updateHouseSources,
} from "./house.service.js";
/** 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(
"/current",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (_req, res) => {
const house = await getCurrentHouse(res.locals.userProfile.houseId);
res.status(200).json(house);
}),
);
/** 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,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const input = renameHouseSchema.parse(req.body);
const house = await renameHouse(res.locals.userProfile.houseId, input.name);
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();
}),
);
/** Which recipe sources the household currently sees in its recipe tabs — the source step of the onboarding wizard and the `/parametres/foyer` settings page both call this. */
houseRouter.get(
"/current/sources",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (_req, res) => {
const sourceIds = await getHouseSourceIds(res.locals.userProfile.houseId);
res.status(200).json(sourceIds);
}),
);
/** Replaces the household's enabled-source set — same callers as the GET above. */
houseRouter.patch(
"/current/sources",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const input = updateHouseSourcesSchema.parse(req.body);
const sourceIds = await updateHouseSources(res.locals.userProfile.houseId, input.sourceIds);
res.status(200).json(sourceIds);
}),
);
/** 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);
}),
);