From 766d48eaa5aeb5c5760eb7bc59fd4a10a5c01a12 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Thu, 20 Aug 2026 09:22:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(recipes):=20pr=C3=A9f=C3=A9rences=20de=20s?= =?UTF-8?q?ources=20par=20foyer=20+=20distinction=20officielle/non-officie?= =?UTF-8?q?lle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../migration.sql | 23 ++++ apps/api/prisma/schema.prisma | 42 +++++- apps/api/src/db/recipe-source-sync.ts | 4 +- apps/api/src/lib/recipe-source-adapter.ts | 12 ++ apps/api/src/modules/house/house.routes.ts | 24 ++++ apps/api/src/modules/house/house.service.ts | 59 +++++++++ apps/api/src/modules/recipe/recipe.service.ts | 23 +++- .../src/modules/reference/reference.routes.ts | 8 ++ .../modules/reference/reference.service.ts | 19 +++ apps/api/test/house.test.ts | 97 ++++++++++++++ apps/api/test/recipe-source-sync.test.ts | 3 +- apps/api/test/recipe-source.test.ts | 1 + apps/api/test/recipe.test.ts | 122 ++++++++++++++++++ apps/api/test/reference.test.ts | 59 +++++++++ apps/web/src/App.tsx | 14 +- apps/web/src/api/client.ts | 21 ++- apps/web/src/features/house/SourceSelect.tsx | 57 ++++++++ apps/web/src/features/house/house-forms.scss | 43 ++++++ apps/web/src/locales/fr/translation.json | 13 ++ .../onboarding/OnboardingAllergensPage.tsx | 21 ++- .../onboarding/OnboardingHouseholdPage.tsx | 18 ++- .../onboarding/OnboardingSourcesPage.tsx | 97 ++++++++++++++ .../pages/settings/HouseholdSettingsPage.tsx | 85 +++++++++++- packages/shared/src/errors/error-codes.ts | 2 + packages/shared/src/schemas/household.ts | 13 ++ packages/shared/src/types/reference.ts | 23 ++++ 26 files changed, 879 insertions(+), 24 deletions(-) create mode 100644 apps/api/prisma/migrations/20260820120000_house_source_preferences/migration.sql create mode 100644 apps/web/src/features/house/SourceSelect.tsx create mode 100644 apps/web/src/features/house/house-forms.scss create mode 100644 apps/web/src/pages/onboarding/OnboardingSourcesPage.tsx diff --git a/apps/api/prisma/migrations/20260820120000_house_source_preferences/migration.sql b/apps/api/prisma/migrations/20260820120000_house_source_preferences/migration.sql new file mode 100644 index 0000000..cc4513b --- /dev/null +++ b/apps/api/prisma/migrations/20260820120000_house_source_preferences/migration.sql @@ -0,0 +1,23 @@ +-- Adds `Source.official` (Boolean, no default — every source must state +-- it explicitly, mirrors `RecipeSourceAdapter.official`) and `house_source`, +-- an opt-in join table: a household sees recipes from a source only once a +-- row exists for it (no row = hidden). `sources` has never been seeded +-- (no adapter registered yet), so a plain NOT NULL column with no backfill +-- is safe. + +-- AlterTable +ALTER TABLE "sources" ADD COLUMN "official" BOOLEAN NOT NULL; + +-- CreateTable +CREATE TABLE "house_source" ( + "house_id" INTEGER NOT NULL, + "source_id" INTEGER NOT NULL, + + CONSTRAINT "house_source_pkey" PRIMARY KEY ("house_id", "source_id") +); + +-- AddForeignKey +ALTER TABLE "house_source" ADD CONSTRAINT "house_source_house_id_fkey" FOREIGN KEY ("house_id") REFERENCES "house"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "house_source" ADD CONSTRAINT "house_source_source_id_fkey" FOREIGN KEY ("source_id") REFERENCES "sources"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 12849fa..cf35cfb 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -30,6 +30,8 @@ model House { /// Recipes whose author belonged to this household when they created /// them — see `Recipe.authorHouseId`. authoredRecipes Recipe[] + /// Which recipe sources this household sees in its recipe tabs — see `HouseSource`. + enabledSources HouseSource[] @@map("house") } @@ -223,16 +225,44 @@ model PlanningItem { /// Empty until a concrete adapter is registered (none exists yet, see /// recipe-source-adapter.ts). model Source { - id Int @id @default(autoincrement()) - key String @unique - name String - url String? + id Int @id @default(autoincrement()) + key String @unique + name String + url String? + /// Whether this is an official API (the site/publisher provides + /// structured recipe data itself) or unofficial web scraping (we parse + /// HTML the site never committed to a stable shape for) — mirrors + /// `RecipeSourceAdapter.official` (recipe-source-adapter.ts), synced the + /// same way as `key`/`name`. Surfaced to households picking which + /// sources to enable (see `HouseSource`) so scraped content is never + /// mistaken for an official feed. + official Boolean - recipes Recipe[] + recipes Recipe[] + enabledHouses HouseSource[] @@map("sources") } +/// Which sources a household has chosen to see recipes from — opt-in: no +/// row means disabled. A newly created household starts with nothing +/// enabled (see the household-creation step in the signup wizard, and the +/// household settings page for changing this later); every recipe catalog +/// tab (`recipe.service.ts`'s `listRecipes`) filters out recipes whose +/// `sourceId` isn't in this list for the viewer's household — a +/// manually-authored recipe (`sourceId` `null`) is never affected, this +/// only ever hides recipes that came from an external source. +model HouseSource { + houseId Int @map("house_id") + sourceId Int @map("source_id") + + house House @relation(fields: [houseId], references: [id], onDelete: Cascade) + source Source @relation(fields: [sourceId], references: [id], onDelete: Cascade) + + @@id([houseId, sourceId]) + @@map("house_source") +} + /// Not in the original spec doc — who can *read* a recipe. Controls only /// visibility, never editing: a recipe can only ever be edited/deleted by /// its `author`, whatever this is set to (see `recipe.service.ts`). @@ -555,7 +585,7 @@ model Unit { id Int @id @default(autoincrement()) key String @unique type UnitType - toBaseFactor Decimal @default(1) @db.Decimal(12, 4) @map("to_base_factor") + toBaseFactor Decimal @default(1) @map("to_base_factor") @db.Decimal(12, 4) recipeIngredients RecipeIngredient[] diff --git a/apps/api/src/db/recipe-source-sync.ts b/apps/api/src/db/recipe-source-sync.ts index 3ff0eb2..1d8f57a 100644 --- a/apps/api/src/db/recipe-source-sync.ts +++ b/apps/api/src/db/recipe-source-sync.ts @@ -26,8 +26,8 @@ export async function syncRecipeSources(prisma: PrismaClient): Promise { for (const adapter of listRecipeSources()) { await prisma.source.upsert({ where: { key: adapter.key }, - update: { name: adapter.name }, - create: { key: adapter.key, name: adapter.name }, + update: { name: adapter.name, official: adapter.official }, + create: { key: adapter.key, name: adapter.name, official: adapter.official }, }); } } diff --git a/apps/api/src/lib/recipe-source-adapter.ts b/apps/api/src/lib/recipe-source-adapter.ts index 2475ce8..d903fa9 100644 --- a/apps/api/src/lib/recipe-source-adapter.ts +++ b/apps/api/src/lib/recipe-source-adapter.ts @@ -144,6 +144,7 @@ export interface ParsedRecipe { * const myAdapter: RecipeSourceAdapter<{ html: string }> = { * key: "someRecipeSite", * name: "Some Recipe Site", + * official: false, * async list(params) { ... }, * async fetchDetail(externalId) { ... }, * parse(raw) { ... }, @@ -156,6 +157,17 @@ export interface RecipeSourceAdapter { key: string; /** Human-readable name, for display in a source picker. */ name: string; + /** + * Whether this source is an official API (the site/publisher itself + * provides structured recipe data) versus unofficial web scraping (we + * parse HTML the site never committed to a stable shape for) — surfaced + * to households (`Source.official`, synced via `syncRecipeSources`) so + * they can tell the two apart when deciding which sources to enable + * (see `HouseSource`, schema.prisma). No default on purpose: every + * adapter author has to consciously pick one rather than silently + * inheriting a guess. + */ + official: boolean; list(params: RecipeSourceListParams): Promise; fetchDetail(externalId: string): Promise; parse(raw: TRawDetail): ParsedRecipe; diff --git a/apps/api/src/modules/house/house.routes.ts b/apps/api/src/modules/house/house.routes.ts index ec9fde9..1ffd8b3 100644 --- a/apps/api/src/modules/house/house.routes.ts +++ b/apps/api/src/modules/house/house.routes.ts @@ -5,6 +5,7 @@ import { createHouseSchema, joinHouseSchema, renameHouseSchema, + updateHouseSourcesSchema, } from "@batch-cooking/shared"; import { Router } from "express"; import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; @@ -12,10 +13,12 @@ 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. */ @@ -91,6 +94,27 @@ houseRouter.delete( }), ); +/** 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(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(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", diff --git a/apps/api/src/modules/house/house.service.ts b/apps/api/src/modules/house/house.service.ts index 05c30a2..5f8b2dc 100644 --- a/apps/api/src/modules/house/house.service.ts +++ b/apps/api/src/modules/house/house.service.ts @@ -241,6 +241,65 @@ export async function removeMember( 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)); diff --git a/apps/api/src/modules/recipe/recipe.service.ts b/apps/api/src/modules/recipe/recipe.service.ts index af5031f..6e44e07 100644 --- a/apps/api/src/modules/recipe/recipe.service.ts +++ b/apps/api/src/modules/recipe/recipe.service.ts @@ -202,6 +202,27 @@ async function suitableForHouseholdWhere(houseId: number): Promise