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>
This commit is contained in:
Nicolas 2026-08-20 09:22:54 +02:00
parent 0b1c102418
commit 766d48eaa5
26 changed files with 879 additions and 24 deletions

View file

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

View file

@ -30,6 +30,8 @@ model House {
/// Recipes whose author belonged to this household when they created /// Recipes whose author belonged to this household when they created
/// them — see `Recipe.authorHouseId`. /// them — see `Recipe.authorHouseId`.
authoredRecipes Recipe[] authoredRecipes Recipe[]
/// Which recipe sources this household sees in its recipe tabs — see `HouseSource`.
enabledSources HouseSource[]
@@map("house") @@map("house")
} }
@ -227,12 +229,40 @@ model Source {
key String @unique key String @unique
name String name String
url 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") @@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 /// 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 /// visibility, never editing: a recipe can only ever be edited/deleted by
/// its `author`, whatever this is set to (see `recipe.service.ts`). /// its `author`, whatever this is set to (see `recipe.service.ts`).
@ -555,7 +585,7 @@ model Unit {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
key String @unique key String @unique
type UnitType 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[] recipeIngredients RecipeIngredient[]

View file

@ -26,8 +26,8 @@ export async function syncRecipeSources(prisma: PrismaClient): Promise<void> {
for (const adapter of listRecipeSources()) { for (const adapter of listRecipeSources()) {
await prisma.source.upsert({ await prisma.source.upsert({
where: { key: adapter.key }, where: { key: adapter.key },
update: { name: adapter.name }, update: { name: adapter.name, official: adapter.official },
create: { key: adapter.key, name: adapter.name }, create: { key: adapter.key, name: adapter.name, official: adapter.official },
}); });
} }
} }

View file

@ -144,6 +144,7 @@ export interface ParsedRecipe {
* const myAdapter: RecipeSourceAdapter<{ html: string }> = { * const myAdapter: RecipeSourceAdapter<{ html: string }> = {
* key: "someRecipeSite", * key: "someRecipeSite",
* name: "Some Recipe Site", * name: "Some Recipe Site",
* official: false,
* async list(params) { ... }, * async list(params) { ... },
* async fetchDetail(externalId) { ... }, * async fetchDetail(externalId) { ... },
* parse(raw) { ... }, * parse(raw) { ... },
@ -156,6 +157,17 @@ export interface RecipeSourceAdapter<TRawDetail = unknown> {
key: string; key: string;
/** Human-readable name, for display in a source picker. */ /** Human-readable name, for display in a source picker. */
name: string; 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<RecipeSourceListResult>; list(params: RecipeSourceListParams): Promise<RecipeSourceListResult>;
fetchDetail(externalId: string): Promise<TRawDetail>; fetchDetail(externalId: string): Promise<TRawDetail>;
parse(raw: TRawDetail): ParsedRecipe; parse(raw: TRawDetail): ParsedRecipe;

View file

@ -5,6 +5,7 @@ import {
createHouseSchema, createHouseSchema,
joinHouseSchema, joinHouseSchema,
renameHouseSchema, renameHouseSchema,
updateHouseSourcesSchema,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
import { Router } from "express"; import { Router } from "express";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
@ -12,10 +13,12 @@ import {
createHouse, createHouse,
deleteHouse, deleteHouse,
getCurrentHouse, getCurrentHouse,
getHouseSourceIds,
joinHouse, joinHouse,
leaveCurrentHouse, leaveCurrentHouse,
removeMember, removeMember,
renameHouse, renameHouse,
updateHouseSources,
} from "./house.service.js"; } 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. */ /** 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<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`. */ /** Removes one specific member from the caller's household. Admin-only, see `house.service.ts`. */
houseRouter.delete( houseRouter.delete(
"/members/:memberId", "/members/:memberId",

View file

@ -241,6 +241,65 @@ export async function removeMember(
return getCurrentHouseOrThrow(house.id); 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<number[]> {
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<number[]> {
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. */ /** 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> { async function getCurrentHouseOrThrow(houseId: number): Promise<HouseView> {
return toHouseView(await findHouseOrThrow(houseId)); return toHouseView(await findHouseOrThrow(houseId));

View file

@ -202,6 +202,27 @@ async function suitableForHouseholdWhere(houseId: number): Promise<Prisma.Recipe
return { AND: conditions }; return { AND: conditions };
} }
/**
* `Recipe` rows a household is allowed to see given which sources it has
* enabled (`HouseSource`, schema.prisma opt-in, no row means hidden).
* Applied unconditionally in {@link listRecipes}, across every tab: a
* manually-authored recipe (`sourceId` `null`) is always visible, this
* only ever hides a recipe that came from an external source the viewer's
* household hasn't turned on. A viewer with no household yet
* (`houseId === null`) has nothing enabled by construction (there's no
* household row for `HouseSource` to reference), so every sourced recipe
* is hidden for them until they join or create one and configure it.
*/
async function sourceVisibilityWhere(houseId: number | null): Promise<Prisma.RecipeWhereInput> {
const enabledSourceIds =
houseId === null
? []
: (await prisma.houseSource.findMany({ where: { houseId }, select: { sourceId: true } })).map(
(row) => row.sourceId,
);
return { OR: [{ sourceId: null }, { sourceId: { in: enabledSourceIds } }] };
}
/** /**
* Optional narrowing filters for {@link listRecipes}, on top of the * Optional narrowing filters for {@link listRecipes}, on top of the
* mandatory `tab`/`viewerId`/`viewerHouseId` grouped into one object * mandatory `tab`/`viewerId`/`viewerHouseId` grouped into one object
@ -236,7 +257,7 @@ export async function listRecipes(
filters: ListRecipesFilters = {}, filters: ListRecipesFilters = {},
): Promise<RecipeSummaryView[]> { ): Promise<RecipeSummaryView[]> {
const { search, suitableForHousehold, ingredientIds, dietIds } = filters; const { search, suitableForHousehold, ingredientIds, dietIds } = filters;
const conditions: Prisma.RecipeWhereInput[] = []; const conditions: Prisma.RecipeWhereInput[] = [await sourceVisibilityWhere(viewerHouseId)];
if (search) { if (search) {
conditions.push({ name: { contains: search, mode: "insensitive" } }); conditions.push({ name: { contains: search, mode: "insensitive" } });
} }

View file

@ -4,6 +4,7 @@ import {
getAllergies, getAllergies,
getDiets, getDiets,
getIngredients, getIngredients,
getSources,
getTechSteps, getTechSteps,
getUnits, getUnits,
} from "./reference.service.js"; } from "./reference.service.js";
@ -53,3 +54,10 @@ referenceRouter.get(
res.status(200).json(await getTechSteps()); res.status(200).json(await getTechSteps());
}), }),
); );
referenceRouter.get(
"/sources",
wrapAsyncHandler(async (_req, res) => {
res.status(200).json(await getSources());
}),
);

View file

@ -2,6 +2,7 @@ import type {
AllergyView, AllergyView,
DietView, DietView,
IngredientView, IngredientView,
SourceView,
TechStepView, TechStepView,
UnitView, UnitView,
} from "@batch-cooking/shared"; } from "@batch-cooking/shared";
@ -64,6 +65,24 @@ export async function getTechSteps(): Promise<TechStepView[]> {
return prisma.techStep.findMany({ orderBy: { key: "asc" } }); return prisma.techStep.findMany({ orderBy: { key: "asc" } });
} }
/**
* Every implemented recipe source, ordered by name (not `key` unlike
* every other reference catalog, `name` here *is* the display string a
* household picks from, see {@link SourceView}, so alphabetical-by-name is
* what a real picker should show). Empty until a concrete adapter is
* registered (see `recipe-source-registry.ts`) and synced (see
* `recipe-source-sync.ts`'s `syncRecipeSources`).
*/
export async function getSources(): Promise<SourceView[]> {
// Explicit `select` — `url` exists on the `Source` row but isn't part of
// `SourceView` yet, so it must not leak into the response the way a bare
// `findMany()` would let it.
return prisma.source.findMany({
select: { id: true, key: true, name: true, official: true },
orderBy: { name: "asc" },
});
}
/** /**
* All reference ingredients, ordered by key (see {@link getDiets} for why), * All reference ingredients, ordered by key (see {@link getDiets} for why),
* each resolved to its allergens (see `IngredientAllergy` in schema.prisma) * each resolved to its allergens (see `IngredientAllergy` in schema.prisma)

View file

@ -5,6 +5,9 @@ import type { Express } from "express";
import request from "supertest"; import request from "supertest";
import { createApp } from "../src/app.js"; import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js"; import { prisma } from "../src/db/prisma.js";
import { syncRecipeSources } from "../src/db/recipe-source-sync.js";
import type { RecipeSourceAdapter } from "../src/lib/recipe-source-adapter.js";
import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js";
import { resetDatabase } from "../test-support/reset-db.js"; import { resetDatabase } from "../test-support/reset-db.js";
function buildSignupPayload(): SignupInput { function buildSignupPayload(): SignupInput {
@ -18,6 +21,24 @@ function buildSignupPayload(): SignupInput {
}; };
} }
/** A minimal `RecipeSourceAdapter` — only `key`/`name`/`official` matter for `syncRecipeSources` fixtures here. */
function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter {
return {
key,
name,
official: false,
async list() {
return { items: [], nextCursor: null };
},
async fetchDetail() {
throw new Error("not implemented");
},
parse() {
throw new Error("not implemented");
},
};
}
/** Signs up a fresh profile on a brand new agent (its own cookie jar) and returns both. */ /** Signs up a fresh profile on a brand new agent (its own cookie jar) and returns both. */
async function signupAgent(app: Express) { async function signupAgent(app: Express) {
const agent = request.agent(app); const agent = request.agent(app);
@ -336,4 +357,80 @@ describe("Household", () => {
expect(memberProfile.houseId).to.equal(null); expect(memberProfile.houseId).to.equal(null);
}); });
}); });
describe("GET /house/current/sources + PATCH /house/current/sources", () => {
beforeEach(() => {
clearRecipeSources();
});
afterEach(() => {
clearRecipeSources();
});
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
const getRes = await request(app).get("/house/current/sources");
const patchRes = await request(app).patch("/house/current/sources").send({ sourceIds: [] });
expect(getRes.status).to.equal(401);
expect(patchRes.status).to.equal(401);
});
it("rejects reading/writing with 404 HOUSE_NOT_FOUND when the profile has no household yet", async () => {
const { agent } = await signupAgent(app);
const getRes = await agent.get("/house/current/sources");
const patchRes = await agent.patch("/house/current/sources").send({ sourceIds: [] });
expect(getRes.status).to.equal(404);
expect(getRes.body.code).to.equal(ErrorCode.HOUSE_NOT_FOUND);
expect(patchRes.status).to.equal(404);
expect(patchRes.body.code).to.equal(ErrorCode.HOUSE_NOT_FOUND);
});
it("starts with nothing enabled, opt-in — not just an empty array by coincidence", async () => {
const { agent } = await signupAgent(app);
await agent.post("/house").send({ name: "Chez Alice" });
const res = await agent.get("/house/current/sources");
expect(res.status).to.equal(200);
expect(res.body).to.deep.equal([]);
});
it("replaces (not merges) the enabled-source set, and it's readable back", async () => {
registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source"));
registerRecipeSource(buildFakeAdapter("otherSource", "Other Source"));
await syncRecipeSources(prisma);
const fakeSource = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
const otherSource = await prisma.source.findUniqueOrThrow({ where: { key: "otherSource" } });
const { agent } = await signupAgent(app);
await agent.post("/house").send({ name: "Chez Alice" });
const firstPatch = await agent
.patch("/house/current/sources")
.send({ sourceIds: [fakeSource.id, otherSource.id] });
expect(firstPatch.status).to.equal(200);
expect(firstPatch.body.sort()).to.deep.equal([fakeSource.id, otherSource.id].sort());
const secondPatch = await agent
.patch("/house/current/sources")
.send({ sourceIds: [fakeSource.id] });
expect(secondPatch.status).to.equal(200);
expect(secondPatch.body).to.deep.equal([fakeSource.id]);
const res = await agent.get("/house/current/sources");
expect(res.body).to.deep.equal([fakeSource.id]);
});
it("rejects an unknown sourceId with 404 SOURCE_NOT_FOUND", async () => {
const { agent } = await signupAgent(app);
await agent.post("/house").send({ name: "Chez Alice" });
const res = await agent.patch("/house/current/sources").send({ sourceIds: [999999] });
expect(res.status).to.equal(404);
expect(res.body.code).to.equal(ErrorCode.SOURCE_NOT_FOUND);
});
});
}); });

View file

@ -21,11 +21,12 @@ function buildSignupPayload(): SignupInput {
}; };
} }
/** A minimal `RecipeSourceAdapter` whose list/fetchDetail/parse are never actually called here — only `key`/`name` matter for exercising `syncRecipeSources`. */ /** A minimal `RecipeSourceAdapter` whose list/fetchDetail/parse are never actually called here — only `key`/`name`/`official` matter for exercising `syncRecipeSources`. */
function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter { function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter {
return { return {
key, key,
name, name,
official: false,
async list() { async list() {
return { items: [], nextCursor: null }; return { items: [], nextCursor: null };
}, },

View file

@ -58,6 +58,7 @@ function buildFakeAdapter(key = "fakeSource"): RecipeSourceAdapter<FakeRawRecipe
return { return {
key, key,
name: "Fake Source", name: "Fake Source",
official: false,
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> { async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
const start = params.cursor ? Number(params.cursor) : 0; const start = params.cursor ? Number(params.cursor) : 0;
const page = FAKE_CATALOG.slice(start, start + PAGE_SIZE); const page = FAKE_CATALOG.slice(start, start + PAGE_SIZE);

View file

@ -5,8 +5,29 @@ import { expect } from "chai";
import request from "supertest"; import request from "supertest";
import { createApp } from "../src/app.js"; import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js"; import { prisma } from "../src/db/prisma.js";
import { syncRecipeSources } from "../src/db/recipe-source-sync.js";
import type { RecipeSourceAdapter } from "../src/lib/recipe-source-adapter.js";
import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js";
import { resetDatabase } from "../test-support/reset-db.js"; import { resetDatabase } from "../test-support/reset-db.js";
/** A minimal `RecipeSourceAdapter` — only `key`/`name`/`official` matter for `syncRecipeSources` fixtures here. */
function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter {
return {
key,
name,
official: false,
async list() {
return { items: [], nextCursor: null };
},
async fetchDetail() {
throw new Error("not implemented");
},
parse() {
throw new Error("not implemented");
},
};
}
/** See `auth.test.ts` — generated rather than hardcoded, no test fixture looks like a real person's data. */ /** See `auth.test.ts` — generated rather than hardcoded, no test fixture looks like a real person's data. */
function buildSignupPayload(): SignupInput { function buildSignupPayload(): SignupInput {
const firstName = faker.person.firstName(); const firstName = faker.person.firstName();
@ -182,6 +203,107 @@ describe("Recipes", () => {
expect(res.body).to.deep.equal([]); expect(res.body).to.deep.equal([]);
}); });
describe("source visibility", () => {
afterEach(() => {
clearRecipeSources();
});
/** Registers+syncs a throwaway adapter and returns the `Source` row `syncRecipeSources` created for it. */
async function registerAndSyncSource(key: string) {
registerRecipeSource(buildFakeAdapter(key, key));
await syncRecipeSources(prisma);
return prisma.source.findUniqueOrThrow({ where: { key } });
}
it("always shows a manually-authored recipe, even for a household with nothing enabled", async () => {
const { agent, profileId } = await signup();
await agent.post("/house").send({ name: "Chez moi" });
await prisma.recipe.create({
data: { name: "Maison", authorId: profileId, visibility: "PUBLIC", portions: 4 },
});
const res = await agent.get("/recipes").query({ tab: "publique" });
expect(res.body.map((r: { name: string }) => r.name)).to.deep.equal(["Maison"]);
});
it("hides a sourced recipe until the viewer's household enables that source, then shows it", async () => {
const { agent, profileId } = await signup();
await agent.post("/house").send({ name: "Chez moi" });
const source = await registerAndSyncSource("fakeSource");
await prisma.recipe.create({
data: {
name: "Importée",
authorId: profileId,
visibility: "PUBLIC",
portions: 4,
sourceId: source.id,
externalId: "1",
},
});
const hidden = await agent.get("/recipes").query({ tab: "publique" });
expect(hidden.body).to.deep.equal([]);
const patchRes = await agent
.patch("/house/current/sources")
.send({ sourceIds: [source.id] });
expect(patchRes.status).to.equal(200);
const visible = await agent.get("/recipes").query({ tab: "publique" });
expect(visible.body.map((r: { name: string }) => r.name)).to.deep.equal(["Importée"]);
});
it("hides a disabled-source recipe in every tab, including the viewer's own perso and favoris", async () => {
const { agent, profileId } = await signup();
await agent.post("/house").send({ name: "Chez moi" });
const source = await registerAndSyncSource("fakeSource");
const recipe = await prisma.recipe.create({
data: {
name: "Ma recette importée",
authorId: profileId,
portions: 4,
sourceId: source.id,
externalId: "1",
},
});
await prisma.recipeFavorite.create({
data: { userProfileId: profileId, recipeId: recipe.id },
});
expect((await agent.get("/recipes").query({ tab: "perso" })).body).to.deep.equal([]);
expect((await agent.get("/recipes").query({ tab: "favoris" })).body).to.deep.equal([]);
await agent.patch("/house/current/sources").send({ sourceIds: [source.id] });
const persoAfter = await agent.get("/recipes").query({ tab: "perso" });
expect(persoAfter.body.map((r: { name: string }) => r.name)).to.deep.equal([
"Ma recette importée",
]);
const favorisAfter = await agent.get("/recipes").query({ tab: "favoris" });
expect(favorisAfter.body.map((r: { name: string }) => r.name)).to.deep.equal([
"Ma recette importée",
]);
});
it("hides every sourced recipe for a viewer with no household at all", async () => {
const { agent, profileId } = await signup();
const source = await registerAndSyncSource("fakeSource");
await prisma.recipe.create({
data: {
name: "Importée",
authorId: profileId,
visibility: "PUBLIC",
portions: 4,
sourceId: source.id,
externalId: "1",
},
});
const res = await agent.get("/recipes").query({ tab: "publique" });
expect(res.body).to.deep.equal([]);
});
});
}); });
describe("POST /recipes", () => { describe("POST /recipes", () => {

View file

@ -2,9 +2,30 @@ import { expect } from "chai";
import request from "supertest"; import request from "supertest";
import { createApp } from "../src/app.js"; import { createApp } from "../src/app.js";
import { prisma } from "../src/db/prisma.js"; import { prisma } from "../src/db/prisma.js";
import { syncRecipeSources } from "../src/db/recipe-source-sync.js";
import { seedReferenceData } from "../src/db/reference-seed-data.js"; import { seedReferenceData } from "../src/db/reference-seed-data.js";
import type { RecipeSourceAdapter } from "../src/lib/recipe-source-adapter.js";
import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js";
import { resetDatabase } from "../test-support/reset-db.js"; import { resetDatabase } from "../test-support/reset-db.js";
/** A minimal `RecipeSourceAdapter` — only `key`/`name`/`official` matter for `syncRecipeSources` fixtures here. */
function buildFakeAdapter(key: string, name: string, official: boolean): RecipeSourceAdapter {
return {
key,
name,
official,
async list() {
return { items: [], nextCursor: null };
},
async fetchDetail() {
throw new Error("not implemented");
},
parse() {
throw new Error("not implemented");
},
};
}
describe("Reference data", () => { describe("Reference data", () => {
const app = createApp(); const app = createApp();
@ -125,4 +146,42 @@ describe("Reference data", () => {
expect(await prisma.techStepMapping.count()).to.equal(26); expect(await prisma.techStepMapping.count()).to.equal(26);
}); });
}); });
describe("GET /reference/sources", () => {
afterEach(() => {
clearRecipeSources();
});
it("is empty until a concrete adapter is registered", async () => {
const res = await request(app).get("/reference/sources");
expect(res.status).to.equal(200);
expect(res.body).to.deep.equal([]);
});
it("returns every synced adapter, official flag included, no session required", async () => {
registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source", false));
registerRecipeSource(buildFakeAdapter("officialSource", "Official Source", true));
await syncRecipeSources(prisma);
const res = await request(app).get("/reference/sources");
expect(res.status).to.equal(200);
expect(res.body).to.have.length(2);
expect(res.body[0]).to.have.keys(["id", "key", "name", "official"]);
const byKey = (key: string) => res.body.find((s: { key: string }) => s.key === key);
expect(byKey("fakeSource").official).to.equal(false);
expect(byKey("officialSource").official).to.equal(true);
});
it("orders sources alphabetically by name", async () => {
registerRecipeSource(buildFakeAdapter("bSource", "Bravo", false));
registerRecipeSource(buildFakeAdapter("aSource", "Alpha", false));
await syncRecipeSources(prisma);
const res = await request(app).get("/reference/sources");
expect(res.body.map((s: { name: string }) => s.name)).to.deep.equal(["Alpha", "Bravo"]);
});
});
}); });

View file

@ -11,6 +11,7 @@ import { SignupPage } from "./pages/SignupPage";
import { OnboardingAllergensPage } from "./pages/onboarding/OnboardingAllergensPage"; import { OnboardingAllergensPage } from "./pages/onboarding/OnboardingAllergensPage";
import { OnboardingDietPage } from "./pages/onboarding/OnboardingDietPage"; import { OnboardingDietPage } from "./pages/onboarding/OnboardingDietPage";
import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdPage"; import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdPage";
import { OnboardingSourcesPage } from "./pages/onboarding/OnboardingSourcesPage";
import { AccountSettingsPage } from "./pages/settings/AccountSettingsPage"; import { AccountSettingsPage } from "./pages/settings/AccountSettingsPage";
import { CreditsPage } from "./pages/settings/CreditsPage"; import { CreditsPage } from "./pages/settings/CreditsPage";
import { HouseholdSettingsPage } from "./pages/settings/HouseholdSettingsPage"; import { HouseholdSettingsPage } from "./pages/settings/HouseholdSettingsPage";
@ -32,11 +33,14 @@ import { UserPreferencesPage } from "./pages/settings/UserPreferencesPage";
* pre-split combined page's path; it now just redirects to * pre-split combined page's path; it now just redirects to
* `/parametres/foyer` so an existing bookmark/link keeps working. * `/parametres/foyer` so an existing bookmark/link keeps working.
* *
* `/onboarding/*` (regime/foyer/allergens, in that order) is also * `/onboarding/*` (regime/foyer/sources/allergens, in that order) is also
* `RequireAuth`-gated reached right after signup, once a session already * `RequireAuth`-gated reached right after signup, once a session already
* exists but deliberately its own top-level route group, *not* nested * exists but deliberately its own top-level route group, *not* nested
* under `AppLayout`: a focused, distraction-free wizard with no sidebar, * under `AppLayout`: a focused, distraction-free wizard with no sidebar,
* same full-page-card language as `/login`/`/signup` (see `onboarding.scss`). * same full-page-card language as `/login`/`/signup` (see `onboarding.scss`).
* `/onboarding/sources` is conditional only reached when the `foyer` step
* created/joined a household (see `OnboardingHouseholdPage`'s `goToNextStep`);
* skipped otherwise, straight to `/onboarding/allergenes`.
*/ */
export function App() { export function App() {
return ( return (
@ -81,6 +85,14 @@ export function App() {
</RequireAuth> </RequireAuth>
} }
/> />
<Route
path="/onboarding/sources"
element={
<RequireAuth>
<OnboardingSourcesPage />
</RequireAuth>
}
/>
<Route <Route
path="/onboarding/allergenes" path="/onboarding/allergenes"
element={ element={

View file

@ -16,6 +16,7 @@ import {
type RecipeView, type RecipeView,
type SafeUserProfile, type SafeUserProfile,
type SignupInput, type SignupInput,
type SourceView,
type ThemePreference, type ThemePreference,
type UnitView, type UnitView,
type UpdateRecipeInput, type UpdateRecipeInput,
@ -97,7 +98,7 @@ export class ApiClient {
return response.json() as Promise<TResponseBody>; return response.json() as Promise<TResponseBody>;
} }
/** Creates a profile (+ household) and starts a session. */ /** Creates a profile and starts a session — no household yet, that's an optional step of the onboarding wizard. */
public signup(input: SignupInput): Promise<SafeUserProfile> { public signup(input: SignupInput): Promise<SafeUserProfile> {
return this.request("/auth/signup", { method: "POST", body: JSON.stringify(input) }); return this.request("/auth/signup", { method: "POST", body: JSON.stringify(input) });
} }
@ -167,6 +168,11 @@ export class ApiClient {
return this.request("/reference/units"); return this.request("/reference/units");
} }
/** Reference list of implemented recipe sources (onboarding wizard's source step, `/parametres/foyer`) — empty until a concrete source is registered. Public — no session required. */
public getSources(): Promise<SourceView[]> {
return this.request("/reference/sources");
}
/** /**
* One catalog tab (favoris/perso/foyer/publique see `RecipeTab`), * One catalog tab (favoris/perso/foyer/publique see `RecipeTab`),
* optionally narrowed further `search` (name substring), * optionally narrowed further `search` (name substring),
@ -256,6 +262,19 @@ export class ApiClient {
return this.request(`/house/members/${memberId}`, { method: "DELETE" }); return this.request(`/house/members/${memberId}`, { method: "DELETE" });
} }
/** Fetches the current user's household's enabled recipe-source ids — which sources show up in its recipe tabs. Rejects with `HOUSE_NOT_FOUND` if they have no household yet. */
public getHouseSourceIds(): Promise<number[]> {
return this.request("/house/current/sources");
}
/** Replaces the current user's household's full enabled-source selection (not a merge — send the complete list; empty hides every external source). Rejects with `HOUSE_NOT_FOUND`/`SOURCE_NOT_FOUND`. */
public updateHouseSourceIds(sourceIds: number[]): Promise<number[]> {
return this.request("/house/current/sources", {
method: "PATCH",
body: JSON.stringify({ sourceIds }),
});
}
/** Sets (or clears, with `null`) the current user's dietary regime. */ /** Sets (or clears, with `null`) the current user's dietary regime. */
public updateDiet(dietId: number | null): Promise<SafeUserProfile> { public updateDiet(dietId: number | null): Promise<SafeUserProfile> {
return this.request("/profile/diet", { method: "PATCH", body: JSON.stringify({ dietId }) }); return this.request("/profile/diet", { method: "PATCH", body: JSON.stringify({ dietId }) });

View file

@ -0,0 +1,57 @@
import type { SourceView } from "@batch-cooking/shared";
import { useTranslation } from "react-i18next";
import { CheckboxOption } from "../../components/ui/Checkbox";
import "./house-forms.scss";
interface SourceSelectProps {
legend: string;
sources: SourceView[];
value: number[];
onChange: (sourceIds: number[]) => void;
}
/**
* Multi-select (checkbox grid) for which recipe sources a household sees
* in its recipe tabs same "grid of `CheckboxOption`s" shape as
* `AllergySelect` (`features/profile/`), but household-scoped rather than
* per-user, hence its own `features/house/` home. Used both by the
* onboarding wizard's source step and the `/parametres/foyer` settings
* page. An empty `value` is this household's normal starting state
* (opt-in see `HouseSource` in schema.prisma), not an incomplete one.
*
* Unlike `AllergySelect`, a source's display text is its own `name`
* (`SourceView.name` a proper noun like "Marmiton"), not resolved
* through `catalog.<domain>.<key>` i18n nothing to translate. The
* `official`/`unofficial` badge next to it is what *is* translated, so a
* household can tell an official API apart from a scraped site before
* deciding whether to trust it.
*/
export function SourceSelect({ legend, sources, value, onChange }: SourceSelectProps) {
const { t } = useTranslation();
function toggle(id: number) {
onChange(value.includes(id) ? value.filter((existing) => existing !== id) : [...value, id]);
}
return (
<fieldset className="source-select">
<legend>{legend}</legend>
{sources.map((source) => {
const checked = value.includes(source.id);
return (
<CheckboxOption
key={source.id}
checked={checked}
onChange={() => toggle(source.id)}
className="source-select__option"
>
{source.name}
<span className={`source-select__badge ${source.official ? "is-official" : ""}`}>
{t(source.official ? "household.sources.official" : "household.sources.unofficial")}
</span>
</CheckboxOption>
);
})}
</fieldset>
);
}

View file

@ -0,0 +1,43 @@
// Same grid-of-checkbox-cards shape as `features/profile/profile-forms.scss`'s
// `.allergy-select` kept as its own file/class rather than reused directly
// since this is household-scoped, not profile-scoped (see `SourceSelect.tsx`).
.source-select {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
gap: var(--space-xs) var(--space-md);
margin: var(--space-sm) 0 0;
padding: 0;
border: none;
legend {
grid-column: 1 / -1;
padding: 0;
font-size: var(--font-size-sm);
font-weight: 600;
}
&__option {
display: flex;
align-items: center;
gap: var(--space-xs);
margin-top: 0;
font-weight: 400;
font-size: var(--font-size-base);
}
// Official/unofficial marker same "small pill" language as
// `settings-pages.scss`'s `.settings-page__member-badge`, neutral grey by
// default (unofficial/scraped) and tinted primary once official.
&__badge {
padding: 0.1rem 0.4rem;
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--color-text-muted);
background: var(--color-surface-alt);
border-radius: var(--radius-base);
&.is-official {
color: var(--color-primary);
}
}
}

View file

@ -21,6 +21,7 @@
"RECIPE_IN_USE": "Cette recette est encore utilisée dans un planning", "RECIPE_IN_USE": "Cette recette est encore utilisée dans un planning",
"INGREDIENT_NOT_FOUND": "Un des ingrédients sélectionnés n'existe pas", "INGREDIENT_NOT_FOUND": "Un des ingrédients sélectionnés n'existe pas",
"UNIT_NOT_FOUND": "Une des unités sélectionnées n'existe pas", "UNIT_NOT_FOUND": "Une des unités sélectionnées n'existe pas",
"SOURCE_NOT_FOUND": "Une des sources sélectionnées n'existe pas",
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard" "INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
}, },
"auth": { "auth": {
@ -59,6 +60,11 @@
"skip": "Passer cette étape", "skip": "Passer cette étape",
"alreadyHasHouse": "Vous faites déjà partie du foyer « {{name}} »." "alreadyHasHouse": "Vous faites déjà partie du foyer « {{name}} »."
}, },
"sources": {
"title": "Sources de recettes",
"subtitle": "Choisissez les sources dont vous voulez voir les recettes dans le catalogue de votre foyer.",
"legend": "Sources disponibles"
},
"allergens": { "allergens": {
"title": "Des allergies ou intolérances ?" "title": "Des allergies ou intolérances ?"
} }
@ -311,6 +317,13 @@
"youSuffix": " (vous)", "youSuffix": " (vous)",
"removeButton": "Retirer", "removeButton": "Retirer",
"leaveButton": "Quitter le foyer", "leaveButton": "Quitter le foyer",
"sources": {
"title": "Sources de recettes",
"hint": "Les sources activées ci-dessous sont celles dont les recettes apparaissent dans le catalogue de votre foyer.",
"legend": "Sources disponibles",
"official": "Officielle",
"unofficial": "Non officielle"
},
"dangerZone": { "dangerZone": {
"title": "Zone dangereuse", "title": "Zone dangereuse",
"description": "Supprimer le foyer le supprime pour tous ses membres, ainsi que son planning.", "description": "Supprimer le foyer le supprime pour tous ses membres, ainsi que son planning.",

View file

@ -15,6 +15,13 @@ import "./onboarding.scss";
* `GET /profile/allergies` round trip a "resume where I left off" flow * `GET /profile/allergies` round trip a "resume where I left off" flow
* would require (the `/parametres/preferences` settings page is the * would require (the `/parametres/preferences` settings page is the
* always-fetch source of truth for editing an existing selection later). * always-fetch source of truth for editing an existing selection later).
*
* Also fetches the current household on mount, only to know whether the
* `/onboarding/sources` step ran before this one (household step created
* one and it had a source to configure) the step count shown reflects
* whichever path the visitor actually took, "3 sur 3" or "4 sur 4"
* (there's no shared wizard state to read this from see
* `OnboardingHouseholdPage`'s doc comment on why each step is independent).
*/ */
export function OnboardingAllergensPage() { export function OnboardingAllergensPage() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -22,16 +29,18 @@ export function OnboardingAllergensPage() {
const [allergies, setAllergies] = useState<AllergyView[]>([]); const [allergies, setAllergies] = useState<AllergyView[]>([]);
const [allergyIds, setAllergyIds] = useState<number[]>([]); const [allergyIds, setAllergyIds] = useState<number[]>([]);
const [totalSteps, setTotalSteps] = useState(3);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
apiClient Promise.all([apiClient.getAllergies(), apiClient.getCurrentHouse()])
.getAllergies() .then(([allergiesResult, house]) => {
.then((result) => { if (cancelled) return;
if (!cancelled) setAllergies(result); setAllergies(allergiesResult);
setTotalSteps(house !== null ? 4 : 3);
}) })
.finally(() => { .finally(() => {
if (!cancelled) setIsLoading(false); if (!cancelled) setIsLoading(false);
@ -60,7 +69,9 @@ export function OnboardingAllergensPage() {
return ( return (
<main className="onboarding-page"> <main className="onboarding-page">
<form className="onboarding-card" onSubmit={handleSubmit} noValidate> <form className="onboarding-card" onSubmit={handleSubmit} noValidate>
<p className="onboarding-step">{t("onboarding.step", { current: 3, total: 3 })}</p> <p className="onboarding-step">
{t("onboarding.step", { current: totalSteps, total: totalSteps })}
</p>
<h1>{t("onboarding.allergens.title")}</h1> <h1>{t("onboarding.allergens.title")}</h1>
{isLoading ? ( {isLoading ? (

View file

@ -44,8 +44,14 @@ export function OnboardingHouseholdPage() {
}; };
}, []); }, []);
function goToNextStep() { /**
navigate("/onboarding/allergenes"); * A household is what the next step (`/onboarding/sources`) needs to
* attach a preference to created/joined/already-there goes on to it,
* skipped goes straight to `/onboarding/allergenes` instead (nothing to
* configure sources for without a household).
*/
function goToNextStep(hasHousehold: boolean) {
navigate(hasHousehold ? "/onboarding/sources" : "/onboarding/allergenes");
} }
return ( return (
@ -59,16 +65,16 @@ export function OnboardingHouseholdPage() {
) : house !== null ? ( ) : house !== null ? (
<> <>
<p>{t("onboarding.household.alreadyHasHouse", { name: house.name })}</p> <p>{t("onboarding.household.alreadyHasHouse", { name: house.name })}</p>
<button type="button" onClick={goToNextStep}> <button type="button" onClick={() => goToNextStep(true)}>
{t("onboarding.continue")} {t("onboarding.continue")}
</button> </button>
</> </>
) : ( ) : (
<> <>
<p className="onboarding-subtitle">{t("onboarding.household.subtitle")}</p> <p className="onboarding-subtitle">{t("onboarding.household.subtitle")}</p>
<CreateHouseholdForm onDone={goToNextStep} /> <CreateHouseholdForm onDone={() => goToNextStep(true)} />
<JoinHouseholdForm onDone={goToNextStep} /> <JoinHouseholdForm onDone={() => goToNextStep(true)} />
<SkipButton onSkip={goToNextStep} /> <SkipButton onSkip={() => goToNextStep(false)} />
</> </>
)} )}
</div> </div>

View file

@ -0,0 +1,97 @@
import type { SourceView } from "@batch-cooking/shared";
import { ErrorCode } from "@batch-cooking/shared";
import { type FormEvent, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { ApiError, apiClient } from "../../api/client";
import { SourceSelect } from "../../features/house/SourceSelect";
import { errorMessageService } from "../../services/error-message.service";
import "./onboarding.scss";
/**
* Third step of the post-signup onboarding wizard, routed at
* `/onboarding/sources` only reached when a household now exists (see
* `OnboardingHouseholdPage`'s `goToStep`, which skips straight to
* `/onboarding/allergenes` when the previous step was skipped): enabled
* sources are a per-household preference (`HouseSource`, schema.prisma),
* so there's nothing to attach a selection to without one.
*
* Starts from an empty selection a newly created/joined household has
* nothing enabled yet by design (opt-in, see `house.service.ts`'s
* `getHouseSourceIds`). If no source is implemented yet (`getSources()`
* comes back empty currently always the case), there's nothing to pick
* from either, so this step silently steps itself over rather than showing
* an empty screen.
*/
export function OnboardingSourcesPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const [sources, setSources] = useState<SourceView[]>([]);
const [sourceIds, setSourceIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [formError, setFormError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
let cancelled = false;
apiClient
.getSources()
.then((result) => {
if (cancelled) return;
if (result.length === 0) {
navigate("/onboarding/allergenes", { replace: true });
return;
}
setSources(result);
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, [navigate]);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setFormError(null);
setIsSubmitting(true);
try {
await apiClient.updateHouseSourceIds(sourceIds);
navigate("/onboarding/allergenes");
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setFormError(errorMessageService.getLabel(code));
} finally {
setIsSubmitting(false);
}
}
return (
<main className="onboarding-page">
<form className="onboarding-card" onSubmit={handleSubmit} noValidate>
<p className="onboarding-step">{t("onboarding.step", { current: 3, total: 4 })}</p>
<h1>{t("onboarding.sources.title")}</h1>
<p className="onboarding-subtitle">{t("onboarding.sources.subtitle")}</p>
{isLoading ? (
<p>{t("onboarding.loading")}</p>
) : (
<SourceSelect
legend={t("onboarding.sources.legend")}
sources={sources}
value={sourceIds}
onChange={setSourceIds}
/>
)}
{formError && <p className="form-error">{formError}</p>}
<button type="submit" disabled={isSubmitting || isLoading}>
{t("onboarding.continue")}
</button>
</form>
</main>
);
}

View file

@ -1,8 +1,14 @@
import { ErrorCode, type HouseView, renameHouseSchema } from "@batch-cooking/shared"; import {
ErrorCode,
type HouseView,
type SourceView,
renameHouseSchema,
} from "@batch-cooking/shared";
import { type FormEvent, useEffect, useRef, useState } from "react"; import { type FormEvent, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ApiError, apiClient } from "../../api/client"; import { ApiError, apiClient } from "../../api/client";
import { useAuth } from "../../features/auth/AuthContext"; import { useAuth } from "../../features/auth/AuthContext";
import { SourceSelect } from "../../features/house/SourceSelect";
import { HouseNameField } from "../../features/profile/HouseNameField"; import { HouseNameField } from "../../features/profile/HouseNameField";
import { fieldErrorsFrom } from "../../lib/zod-errors"; import { fieldErrorsFrom } from "../../lib/zod-errors";
import { errorMessageService } from "../../services/error-message.service"; import { errorMessageService } from "../../services/error-message.service";
@ -14,6 +20,9 @@ type SaveState = "idle" | "saving" | "saved" | "error";
/** Debounce for the household name field (typing) — long enough that saves don't fire on every keystroke. */ /** Debounce for the household name field (typing) — long enough that saves don't fire on every keystroke. */
const HOUSE_NAME_DEBOUNCE_MS = 600; const HOUSE_NAME_DEBOUNCE_MS = 600;
/** Debounce for the enabled-sources checkboxes — same reasoning as `PreferencesPage`'s `ALLERGIES_DEBOUNCE_MS`: coalesces a quick burst of toggles into one request. */
const HOUSE_SOURCES_DEBOUNCE_MS = 500;
/** /**
* Household settings routed at `/parametres/foyer`. Split out of what * Household settings routed at `/parametres/foyer`. Split out of what
* used to be `HouseholdPage` (regime/allergies moved to `PreferencesPage`, * used to be `HouseholdPage` (regime/allergies moved to `PreferencesPage`,
@ -254,6 +263,8 @@ function HasHousehold({
</ul> </ul>
</div> </div>
<SourcesSection />
{isAdmin ? ( {isAdmin ? (
<DeleteHouseholdSection onChanged={onChanged} /> <DeleteHouseholdSection onChanged={onChanged} />
) : ( ) : (
@ -263,6 +274,78 @@ function HasHousehold({
); );
} }
/**
* Which recipe sources this household sees in its recipe tabs any member
* can change this, not just the admin (same posture as renaming, unlike
* deleting the household or removing a member). Renders nothing while
* loading or once loaded if no source is implemented yet (`getSources()`
* empty currently always the case): no point showing an empty picker in
* a settings page any more than in the onboarding wizard's own source step.
*/
function SourcesSection() {
const { t } = useTranslation();
const [sources, setSources] = useState<SourceView[]>([]);
const [sourceIds, setSourceIds] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [saveState, setSaveState] = useState<SaveState>("idle");
const [saveError, setSaveError] = useState<string | null>(null);
const saveTimeout = useRef<number | undefined>(undefined);
useEffect(() => {
let cancelled = false;
Promise.all([apiClient.getSources(), apiClient.getHouseSourceIds()])
.then(([sourcesResult, sourceIdsResult]) => {
if (cancelled) return;
setSources(sourcesResult);
setSourceIds(sourceIdsResult);
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
window.clearTimeout(saveTimeout.current);
};
}, []);
function handleChange(newSourceIds: number[]) {
setSourceIds(newSourceIds);
window.clearTimeout(saveTimeout.current);
setSaveState("saving");
saveTimeout.current = window.setTimeout(async () => {
try {
await apiClient.updateHouseSourceIds(newSourceIds);
setSaveState("saved");
} catch (err) {
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
setSaveError(errorMessageService.getLabel(code));
setSaveState("error");
}
}, HOUSE_SOURCES_DEBOUNCE_MS);
}
if (isLoading || sources.length === 0) {
return null;
}
return (
<div className="settings-page__section">
<h2 className="settings-page__section-title">{t("household.sources.title")}</h2>
<p className="settings-page__hint">{t("household.sources.hint")}</p>
<SourceSelect
legend={t("household.sources.legend")}
sources={sources}
value={sourceIds}
onChange={handleChange}
/>
{saveState === "saving" && <p className="settings-page__saving">{t("common.saving")}</p>}
{saveState === "saved" && <p className="settings-page__saved">{t("common.saved")}</p>}
{saveState === "error" && <p className="field-error">{saveError}</p>}
</div>
);
}
/** Read-only invite code display with a one-click clipboard copy. */ /** Read-only invite code display with a one-click clipboard copy. */
function InviteCode({ code }: { code: string }) { function InviteCode({ code }: { code: string }) {
const { t } = useTranslation(); const { t } = useTranslation();

View file

@ -60,6 +60,8 @@ export enum ErrorCode {
PLANNING_ITEM_NOT_FOUND = 4047, PLANNING_ITEM_NOT_FOUND = 4047,
/** A recipe payload's `unitId` doesn't match any reference `Unit` row. */ /** A recipe payload's `unitId` doesn't match any reference `Unit` row. */
UNIT_NOT_FOUND = 4048, UNIT_NOT_FOUND = 4048,
/** `PATCH /house/current/sources`'s `sourceIds` contains one that doesn't match any reference `Source` row. */
SOURCE_NOT_FOUND = 4049,
/** Unexpected/unhandled failure — the catch-all, always logged server-side. */ /** Unexpected/unhandled failure — the catch-all, always logged server-side. */
INTERNAL_ERROR = 5000, INTERNAL_ERROR = 5000,
} }

View file

@ -24,3 +24,16 @@ export const joinHouseSchema = z.object({
}); });
/** Inferred TS type for {@link joinHouseSchema}'s validated output. */ /** Inferred TS type for {@link joinHouseSchema}'s validated output. */
export type JoinHouseInput = z.infer<typeof joinHouseSchema>; export type JoinHouseInput = z.infer<typeof joinHouseSchema>;
/**
* Payload accepted by `PATCH /house/current/sources`. Replaces the
* household's full set of enabled recipe sources (same "replace, not
* merge" contract as `updateAllergiesSchema`) an empty array disables
* every source, which is also this household's starting state (opt-in: no
* `HouseSource` row means hidden, see schema.prisma).
*/
export const updateHouseSourcesSchema = z.object({
sourceIds: z.array(z.number().int().positive()),
});
/** Inferred TS type for {@link updateHouseSourcesSchema}'s validated output. */
export type UpdateHouseSourcesInput = z.infer<typeof updateHouseSourcesSchema>;

View file

@ -210,6 +210,29 @@ export interface TechStepView {
key: string; key: string;
} }
/**
* An implemented recipe source, as returned by `GET /reference/sources`
* reference data (`Source`, kept in sync with the adapter registry by
* `syncRecipeSources`, `apps/api/src/db/recipe-source-sync.ts`), same
* static/non-administrable status as {@link DietView}/{@link UnitView} from
* a client's point of view (nothing here is ever created/edited through
* the API only a real adapter being registered in code adds a row).
*
* Unlike `DietView`/`TechStepView`, `name` is the actual display string
* (e.g. `"Marmiton"`) rather than a `key` resolved through
* `catalog.<domain>.<key>` a source's name is a proper noun/brand,
* nothing to translate. `official` distinguishes a source backed by an
* official API from one built by scraping HTML the site never committed to
* a stable shape surfaced so households can make an informed choice when
* picking which sources to enable (see `HouseSource` in schema.prisma).
*/
export interface SourceView {
id: number;
key: string;
name: string;
official: boolean;
}
/** /**
* A selectable ingredient, as returned by `GET /reference/ingredients` * A selectable ingredient, as returned by `GET /reference/ingredients`
* reference data (`Ingredient`, seeded via `apps/api/src/db/ * reference data (`Ingredient`, seeded via `apps/api/src/db/