Merge pull request #37 from kyuno053/feat/house-source-preferences

feat(recipes): préférences de sources par foyer + distinction officielle/non-officielle
This commit is contained in:
kyuno053 2026-08-20 10:02:51 +02:00 committed by GitHub
commit b060fc47c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 897 additions and 26 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
/// 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[]

View file

@ -26,8 +26,8 @@ export async function syncRecipeSources(prisma: PrismaClient): Promise<void> {
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 },
});
}
}

View file

@ -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<TRawDetail = unknown> {
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<RecipeSourceListResult>;
fetchDetail(externalId: string): Promise<TRawDetail>;
parse(raw: TRawDetail): ParsedRecipe;

View file

@ -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<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",

View file

@ -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<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. */
async function getCurrentHouseOrThrow(houseId: number): Promise<HouseView> {
return toHouseView(await findHouseOrThrow(houseId));

View file

@ -202,6 +202,27 @@ async function suitableForHouseholdWhere(houseId: number): Promise<Prisma.Recipe
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
* mandatory `tab`/`viewerId`/`viewerHouseId` grouped into one object
@ -236,7 +257,7 @@ export async function listRecipes(
filters: ListRecipesFilters = {},
): Promise<RecipeSummaryView[]> {
const { search, suitableForHousehold, ingredientIds, dietIds } = filters;
const conditions: Prisma.RecipeWhereInput[] = [];
const conditions: Prisma.RecipeWhereInput[] = [await sourceVisibilityWhere(viewerHouseId)];
if (search) {
conditions.push({ name: { contains: search, mode: "insensitive" } });
}

View file

@ -4,6 +4,7 @@ import {
getAllergies,
getDiets,
getIngredients,
getSources,
getTechSteps,
getUnits,
} from "./reference.service.js";
@ -53,3 +54,10 @@ referenceRouter.get(
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,
DietView,
IngredientView,
SourceView,
TechStepView,
UnitView,
} from "@batch-cooking/shared";
@ -64,6 +65,24 @@ export async function getTechSteps(): Promise<TechStepView[]> {
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),
* 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 { createApp } from "../src/app.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";
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. */
async function signupAgent(app: Express) {
const agent = request.agent(app);
@ -336,4 +357,80 @@ describe("Household", () => {
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 {
return {
key,
name,
official: false,
async list() {
return { items: [], nextCursor: null };
},

View file

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

View file

@ -5,8 +5,29 @@ import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.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";
/** 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. */
function buildSignupPayload(): SignupInput {
const firstName = faker.person.firstName();
@ -182,6 +203,107 @@ describe("Recipes", () => {
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", () => {

View file

@ -2,9 +2,30 @@ import { expect } from "chai";
import request from "supertest";
import { createApp } from "../src/app.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 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";
/** 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", () => {
const app = createApp();
@ -125,4 +146,42 @@ describe("Reference data", () => {
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

@ -6,11 +6,12 @@ Feature: Onboarding wizard
Background:
Given the planning request returns nothing
Scenario: Walks through all three steps, creating a household on the way, and lands on the home page
Scenario: Walks through the wizard, creating a household on the way (which surfaces the sources step), and lands on the home page
Given the diets reference list has options
And selecting the diet will succeed
And the household request returns no household
And creating a household will succeed
And the sources reference list is empty
And the allergies reference list has options
And updating allergies will succeed
And I have signed up
@ -25,7 +26,7 @@ Feature: Onboarding wizard
And I click the button "Créer"
Then the household creation request should have been made with name "Chez Alice"
And the URL should include "/onboarding/allergenes"
And I should see "Étape 3 sur 3"
And I should see "Étape 4 sur 4"
And I should see the section "Allergies"
And I should see the section "Intolérances"
When I check the checkbox "Arachides"
@ -56,6 +57,7 @@ Feature: Onboarding wizard
And selecting the diet will succeed
And the household request returns no household
And joining a household will succeed
And the sources reference list is empty
And the allergies reference list is empty
And updating allergies will succeed
And I have signed up

View file

@ -31,3 +31,13 @@ Given("the allergies reference list has options", () => {
Given("the allergies reference list is empty", () => {
cy.intercept("GET", "**/reference/allergies", { statusCode: 200, body: [] });
});
// Every onboarding scenario that reaches the household step also reaches
// `/onboarding/sources` right after (when a household got created/joined —
// see `OnboardingHouseholdPage`'s `goToNextStep`), which reads this before
// self-skipping to `/onboarding/allergenes`. No "has options" counterpart
// yet — no source is implemented in the app itself, so there's nothing
// real to mock a populated catalog with.
Given("the sources reference list is empty", () => {
cy.intercept("GET", "**/reference/sources", { statusCode: 200, body: [] });
});

View file

@ -11,6 +11,7 @@ import { SignupPage } from "./pages/SignupPage";
import { OnboardingAllergensPage } from "./pages/onboarding/OnboardingAllergensPage";
import { OnboardingDietPage } from "./pages/onboarding/OnboardingDietPage";
import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdPage";
import { OnboardingSourcesPage } from "./pages/onboarding/OnboardingSourcesPage";
import { AccountSettingsPage } from "./pages/settings/AccountSettingsPage";
import { CreditsPage } from "./pages/settings/CreditsPage";
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
* `/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
* exists but deliberately its own top-level route group, *not* nested
* under `AppLayout`: a focused, distraction-free wizard with no sidebar,
* 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() {
return (
@ -81,6 +85,14 @@ export function App() {
</RequireAuth>
}
/>
<Route
path="/onboarding/sources"
element={
<RequireAuth>
<OnboardingSourcesPage />
</RequireAuth>
}
/>
<Route
path="/onboarding/allergenes"
element={

View file

@ -16,6 +16,7 @@ import {
type RecipeView,
type SafeUserProfile,
type SignupInput,
type SourceView,
type ThemePreference,
type UnitView,
type UpdateRecipeInput,
@ -97,7 +98,7 @@ export class ApiClient {
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> {
return this.request("/auth/signup", { method: "POST", body: JSON.stringify(input) });
}
@ -167,6 +168,11 @@ export class ApiClient {
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`),
* optionally narrowed further `search` (name substring),
@ -256,6 +262,19 @@ export class ApiClient {
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. */
public updateDiet(dietId: number | null): Promise<SafeUserProfile> {
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",
"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",
"SOURCE_NOT_FOUND": "Une des sources sélectionnées n'existe pas",
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
},
"auth": {
@ -59,6 +60,11 @@
"skip": "Passer cette étape",
"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": {
"title": "Des allergies ou intolérances ?"
}
@ -311,6 +317,13 @@
"youSuffix": " (vous)",
"removeButton": "Retirer",
"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": {
"title": "Zone dangereuse",
"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
* would require (the `/parametres/preferences` settings page is the
* 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() {
const { t } = useTranslation();
@ -22,16 +29,18 @@ export function OnboardingAllergensPage() {
const [allergies, setAllergies] = useState<AllergyView[]>([]);
const [allergyIds, setAllergyIds] = useState<number[]>([]);
const [totalSteps, setTotalSteps] = useState(3);
const [isLoading, setIsLoading] = useState(true);
const [formError, setFormError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
let cancelled = false;
apiClient
.getAllergies()
.then((result) => {
if (!cancelled) setAllergies(result);
Promise.all([apiClient.getAllergies(), apiClient.getCurrentHouse()])
.then(([allergiesResult, house]) => {
if (cancelled) return;
setAllergies(allergiesResult);
setTotalSteps(house !== null ? 4 : 3);
})
.finally(() => {
if (!cancelled) setIsLoading(false);
@ -60,7 +69,9 @@ export function OnboardingAllergensPage() {
return (
<main className="onboarding-page">
<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>
{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 (
@ -59,16 +65,16 @@ export function OnboardingHouseholdPage() {
) : house !== null ? (
<>
<p>{t("onboarding.household.alreadyHasHouse", { name: house.name })}</p>
<button type="button" onClick={goToNextStep}>
<button type="button" onClick={() => goToNextStep(true)}>
{t("onboarding.continue")}
</button>
</>
) : (
<>
<p className="onboarding-subtitle">{t("onboarding.household.subtitle")}</p>
<CreateHouseholdForm onDone={goToNextStep} />
<JoinHouseholdForm onDone={goToNextStep} />
<SkipButton onSkip={goToNextStep} />
<CreateHouseholdForm onDone={() => goToNextStep(true)} />
<JoinHouseholdForm onDone={() => goToNextStep(true)} />
<SkipButton onSkip={() => goToNextStep(false)} />
</>
)}
</div>

View file

@ -0,0 +1,101 @@
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);
setIsLoading(false);
})
.catch(() => {
// Nothing to configure sources for if we can't even list them — the
// wizard shouldn't strand the visitor here over a transient failure
// fetching an optional step's own data.
if (!cancelled) navigate("/onboarding/allergenes", { replace: true });
});
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 { useTranslation } from "react-i18next";
import { ApiError, apiClient } from "../../api/client";
import { useAuth } from "../../features/auth/AuthContext";
import { SourceSelect } from "../../features/house/SourceSelect";
import { HouseNameField } from "../../features/profile/HouseNameField";
import { fieldErrorsFrom } from "../../lib/zod-errors";
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. */
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
* used to be `HouseholdPage` (regime/allergies moved to `PreferencesPage`,
@ -254,6 +263,8 @@ function HasHousehold({
</ul>
</div>
<SourcesSection />
{isAdmin ? (
<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. */
function InviteCode({ code }: { code: string }) {
const { t } = useTranslation();

View file

@ -60,6 +60,8 @@ export enum ErrorCode {
PLANNING_ITEM_NOT_FOUND = 4047,
/** A recipe payload's `unitId` doesn't match any reference `Unit` row. */
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. */
INTERNAL_ERROR = 5000,
}

View file

@ -24,3 +24,16 @@ export const joinHouseSchema = z.object({
});
/** Inferred TS type for {@link joinHouseSchema}'s validated output. */
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;
}
/**
* 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`
* reference data (`Ingredient`, seeded via `apps/api/src/db/