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:
commit
b060fc47c3
28 changed files with 897 additions and 26 deletions
|
|
@ -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;
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -227,12 +229,40 @@ model Source {
|
|||
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[]
|
||||
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[]
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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" } });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: [] });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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 }) });
|
||||
|
|
|
|||
57
apps/web/src/features/house/SourceSelect.tsx
Normal file
57
apps/web/src/features/house/SourceSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
apps/web/src/features/house/house-forms.scss
Normal file
43
apps/web/src/features/house/house-forms.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
101
apps/web/src/pages/onboarding/OnboardingSourcesPage.tsx
Normal file
101
apps/web/src/pages/onboarding/OnboardingSourcesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
Loading…
Reference in a new issue