feat(recipes): préférences de sources par foyer + distinction officielle/non-officielle
Répond à deux besoins : permettre à chaque foyer de choisir quelles sources apparaissent dans ses onglets de recettes, et distinguer les sources à API officielle des sources scrapées. - RecipeSourceAdapter.official (booléen, sans défaut — chaque adaptateur doit le déclarer explicitement) synchronisé sur Source.official par syncRecipeSources. - HouseSource : table de jointure opt-in (House <-> Source) — aucune ligne = source masquée. Un foyer nouvellement créé ne voit aucune source tant qu'il ne les active pas explicitement. - GET /reference/sources (catalogue des sources implémentées, avec le flag officiel). - GET/PATCH /house/current/sources (lecture/remplacement complet des sources activées par le foyer courant). - recipe.service.ts : sourceVisibilityWhere() filtre désormais TOUS les onglets (perso/foyer/publique/favoris) — une recette sans source reste toujours visible ; une recette importée ne l'est que si sa source est activée pour le foyer du viewer. Un viewer sans foyer ne voit aucune recette sourcée. Côté web : - Nouvelle étape /onboarding/sources dans le wizard d'inscription, atteinte uniquement si un foyer vient d'être créé/rejoint (sinon on saute direct aux allergènes) ; s'auto-saute aussi si aucune source n'est encore implémentée (catalogue vide aujourd'hui). - Nouvelle section « Sources de recettes » dans /parametres/foyer (masquée dans les mêmes conditions), avec sauvegarde à la volée (même pattern que les autres préférences hot-saved). - SourceSelect (features/house/), grille de cases à cocher avec badge officiel/non-officielle, sur le même principe qu'AllergySelect. 172 tests backend passent (dont 25 nouveaux). Build et lint propres. Vérifié manuellement en navigateur : le parcours d'onboarding saute bien l'étape sources (catalogue vide) et affiche « 4 sur 4 » quand un foyer a été créé ; la section paramètres reste invisible tant qu'aucune source n'existe. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
0b1c102418
commit
766d48eaa5
26 changed files with 879 additions and 24 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
|
/// Recipes whose author belonged to this household when they created
|
||||||
/// them — see `Recipe.authorHouseId`.
|
/// them — see `Recipe.authorHouseId`.
|
||||||
authoredRecipes Recipe[]
|
authoredRecipes Recipe[]
|
||||||
|
/// Which recipe sources this household sees in its recipe tabs — see `HouseSource`.
|
||||||
|
enabledSources HouseSource[]
|
||||||
|
|
||||||
@@map("house")
|
@@map("house")
|
||||||
}
|
}
|
||||||
|
|
@ -227,12 +229,40 @@ model Source {
|
||||||
key String @unique
|
key String @unique
|
||||||
name String
|
name String
|
||||||
url String?
|
url String?
|
||||||
|
/// Whether this is an official API (the site/publisher provides
|
||||||
|
/// structured recipe data itself) or unofficial web scraping (we parse
|
||||||
|
/// HTML the site never committed to a stable shape for) — mirrors
|
||||||
|
/// `RecipeSourceAdapter.official` (recipe-source-adapter.ts), synced the
|
||||||
|
/// same way as `key`/`name`. Surfaced to households picking which
|
||||||
|
/// sources to enable (see `HouseSource`) so scraped content is never
|
||||||
|
/// mistaken for an official feed.
|
||||||
|
official Boolean
|
||||||
|
|
||||||
recipes Recipe[]
|
recipes Recipe[]
|
||||||
|
enabledHouses HouseSource[]
|
||||||
|
|
||||||
@@map("sources")
|
@@map("sources")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which sources a household has chosen to see recipes from — opt-in: no
|
||||||
|
/// row means disabled. A newly created household starts with nothing
|
||||||
|
/// enabled (see the household-creation step in the signup wizard, and the
|
||||||
|
/// household settings page for changing this later); every recipe catalog
|
||||||
|
/// tab (`recipe.service.ts`'s `listRecipes`) filters out recipes whose
|
||||||
|
/// `sourceId` isn't in this list for the viewer's household — a
|
||||||
|
/// manually-authored recipe (`sourceId` `null`) is never affected, this
|
||||||
|
/// only ever hides recipes that came from an external source.
|
||||||
|
model HouseSource {
|
||||||
|
houseId Int @map("house_id")
|
||||||
|
sourceId Int @map("source_id")
|
||||||
|
|
||||||
|
house House @relation(fields: [houseId], references: [id], onDelete: Cascade)
|
||||||
|
source Source @relation(fields: [sourceId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([houseId, sourceId])
|
||||||
|
@@map("house_source")
|
||||||
|
}
|
||||||
|
|
||||||
/// Not in the original spec doc — who can *read* a recipe. Controls only
|
/// Not in the original spec doc — who can *read* a recipe. Controls only
|
||||||
/// visibility, never editing: a recipe can only ever be edited/deleted by
|
/// visibility, never editing: a recipe can only ever be edited/deleted by
|
||||||
/// its `author`, whatever this is set to (see `recipe.service.ts`).
|
/// its `author`, whatever this is set to (see `recipe.service.ts`).
|
||||||
|
|
@ -555,7 +585,7 @@ model Unit {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
key String @unique
|
key String @unique
|
||||||
type UnitType
|
type UnitType
|
||||||
toBaseFactor Decimal @default(1) @db.Decimal(12, 4) @map("to_base_factor")
|
toBaseFactor Decimal @default(1) @map("to_base_factor") @db.Decimal(12, 4)
|
||||||
|
|
||||||
recipeIngredients RecipeIngredient[]
|
recipeIngredients RecipeIngredient[]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,8 @@ export async function syncRecipeSources(prisma: PrismaClient): Promise<void> {
|
||||||
for (const adapter of listRecipeSources()) {
|
for (const adapter of listRecipeSources()) {
|
||||||
await prisma.source.upsert({
|
await prisma.source.upsert({
|
||||||
where: { key: adapter.key },
|
where: { key: adapter.key },
|
||||||
update: { name: adapter.name },
|
update: { name: adapter.name, official: adapter.official },
|
||||||
create: { key: adapter.key, name: adapter.name },
|
create: { key: adapter.key, name: adapter.name, official: adapter.official },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,7 @@ export interface ParsedRecipe {
|
||||||
* const myAdapter: RecipeSourceAdapter<{ html: string }> = {
|
* const myAdapter: RecipeSourceAdapter<{ html: string }> = {
|
||||||
* key: "someRecipeSite",
|
* key: "someRecipeSite",
|
||||||
* name: "Some Recipe Site",
|
* name: "Some Recipe Site",
|
||||||
|
* official: false,
|
||||||
* async list(params) { ... },
|
* async list(params) { ... },
|
||||||
* async fetchDetail(externalId) { ... },
|
* async fetchDetail(externalId) { ... },
|
||||||
* parse(raw) { ... },
|
* parse(raw) { ... },
|
||||||
|
|
@ -156,6 +157,17 @@ export interface RecipeSourceAdapter<TRawDetail = unknown> {
|
||||||
key: string;
|
key: string;
|
||||||
/** Human-readable name, for display in a source picker. */
|
/** Human-readable name, for display in a source picker. */
|
||||||
name: string;
|
name: string;
|
||||||
|
/**
|
||||||
|
* Whether this source is an official API (the site/publisher itself
|
||||||
|
* provides structured recipe data) versus unofficial web scraping (we
|
||||||
|
* parse HTML the site never committed to a stable shape for) — surfaced
|
||||||
|
* to households (`Source.official`, synced via `syncRecipeSources`) so
|
||||||
|
* they can tell the two apart when deciding which sources to enable
|
||||||
|
* (see `HouseSource`, schema.prisma). No default on purpose: every
|
||||||
|
* adapter author has to consciously pick one rather than silently
|
||||||
|
* inheriting a guess.
|
||||||
|
*/
|
||||||
|
official: boolean;
|
||||||
list(params: RecipeSourceListParams): Promise<RecipeSourceListResult>;
|
list(params: RecipeSourceListParams): Promise<RecipeSourceListResult>;
|
||||||
fetchDetail(externalId: string): Promise<TRawDetail>;
|
fetchDetail(externalId: string): Promise<TRawDetail>;
|
||||||
parse(raw: TRawDetail): ParsedRecipe;
|
parse(raw: TRawDetail): ParsedRecipe;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
createHouseSchema,
|
createHouseSchema,
|
||||||
joinHouseSchema,
|
joinHouseSchema,
|
||||||
renameHouseSchema,
|
renameHouseSchema,
|
||||||
|
updateHouseSourcesSchema,
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
||||||
|
|
@ -12,10 +13,12 @@ import {
|
||||||
createHouse,
|
createHouse,
|
||||||
deleteHouse,
|
deleteHouse,
|
||||||
getCurrentHouse,
|
getCurrentHouse,
|
||||||
|
getHouseSourceIds,
|
||||||
joinHouse,
|
joinHouse,
|
||||||
leaveCurrentHouse,
|
leaveCurrentHouse,
|
||||||
removeMember,
|
removeMember,
|
||||||
renameHouse,
|
renameHouse,
|
||||||
|
updateHouseSources,
|
||||||
} from "./house.service.js";
|
} from "./house.service.js";
|
||||||
|
|
||||||
/** Router mounted at `/house` in app.ts. Every route requires a session — a household is per-user (via their profile), never public. */
|
/** Router mounted at `/house` in app.ts. Every route requires a session — a household is per-user (via their profile), never public. */
|
||||||
|
|
@ -91,6 +94,27 @@ houseRouter.delete(
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Which recipe sources the household currently sees in its recipe tabs — the source step of the onboarding wizard and the `/parametres/foyer` settings page both call this. */
|
||||||
|
houseRouter.get(
|
||||||
|
"/current/sources",
|
||||||
|
requireAuth,
|
||||||
|
wrapAsyncHandler<unknown, AuthLocals>(async (_req, res) => {
|
||||||
|
const sourceIds = await getHouseSourceIds(res.locals.userProfile.houseId);
|
||||||
|
res.status(200).json(sourceIds);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Replaces the household's enabled-source set — same callers as the GET above. */
|
||||||
|
houseRouter.patch(
|
||||||
|
"/current/sources",
|
||||||
|
requireAuth,
|
||||||
|
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||||
|
const input = updateHouseSourcesSchema.parse(req.body);
|
||||||
|
const sourceIds = await updateHouseSources(res.locals.userProfile.houseId, input.sourceIds);
|
||||||
|
res.status(200).json(sourceIds);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
/** Removes one specific member from the caller's household. Admin-only, see `house.service.ts`. */
|
/** Removes one specific member from the caller's household. Admin-only, see `house.service.ts`. */
|
||||||
houseRouter.delete(
|
houseRouter.delete(
|
||||||
"/members/:memberId",
|
"/members/:memberId",
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,65 @@ export async function removeMember(
|
||||||
return getCurrentHouseOrThrow(house.id);
|
return getCurrentHouseOrThrow(house.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current enabled-source ids for a household — an empty array is normal
|
||||||
|
* and is this household's starting state (opt-in: see `HouseSource` in
|
||||||
|
* schema.prisma), not just "no preference set yet".
|
||||||
|
*
|
||||||
|
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
|
||||||
|
*/
|
||||||
|
export async function getHouseSourceIds(houseId: number | null): Promise<number[]> {
|
||||||
|
if (houseId === null) {
|
||||||
|
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||||
|
}
|
||||||
|
const rows = await prisma.houseSource.findMany({
|
||||||
|
where: { houseId },
|
||||||
|
select: { sourceId: true },
|
||||||
|
});
|
||||||
|
return rows.map((row) => row.sourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces a household's full set of enabled recipe sources (not a merge —
|
||||||
|
* same "replace, not merge" contract as `profile.service.ts`'s
|
||||||
|
* `updateAllergies`). Every recipe-catalog tab (`recipe.service.ts`'s
|
||||||
|
* `listRecipes`) filters against this set — a source left out here simply
|
||||||
|
* never shows its recipes to this household, in any tab.
|
||||||
|
*
|
||||||
|
* @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet.
|
||||||
|
* @throws {HttpError} `404 SOURCE_NOT_FOUND` if any `sourceId` doesn't match a reference `Source` row.
|
||||||
|
*/
|
||||||
|
export async function updateHouseSources(
|
||||||
|
houseId: number | null,
|
||||||
|
sourceIds: number[],
|
||||||
|
): Promise<number[]> {
|
||||||
|
if (houseId === null) {
|
||||||
|
throw new HttpError(404, ErrorCode.HOUSE_NOT_FOUND, "Profile has no household");
|
||||||
|
}
|
||||||
|
if (sourceIds.length > 0) {
|
||||||
|
const found = await prisma.source.findMany({
|
||||||
|
where: { id: { in: sourceIds } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const foundIds = new Set(found.map((source) => source.id));
|
||||||
|
const missing = sourceIds.filter((id) => !foundIds.has(id));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new HttpError(
|
||||||
|
404,
|
||||||
|
ErrorCode.SOURCE_NOT_FOUND,
|
||||||
|
`Unknown source id(s): ${missing.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.houseSource.deleteMany({ where: { houseId } }),
|
||||||
|
prisma.houseSource.createMany({ data: sourceIds.map((sourceId) => ({ houseId, sourceId })) }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return sourceIds;
|
||||||
|
}
|
||||||
|
|
||||||
/** Re-fetches a household by id (as a {@link HouseView}) once its id is already known to be valid — the common "reload after a mutation" step shared by several functions above. */
|
/** Re-fetches a household by id (as a {@link HouseView}) once its id is already known to be valid — the common "reload after a mutation" step shared by several functions above. */
|
||||||
async function getCurrentHouseOrThrow(houseId: number): Promise<HouseView> {
|
async function getCurrentHouseOrThrow(houseId: number): Promise<HouseView> {
|
||||||
return toHouseView(await findHouseOrThrow(houseId));
|
return toHouseView(await findHouseOrThrow(houseId));
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,27 @@ async function suitableForHouseholdWhere(houseId: number): Promise<Prisma.Recipe
|
||||||
return { AND: conditions };
|
return { AND: conditions };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `Recipe` rows a household is allowed to see given which sources it has
|
||||||
|
* enabled (`HouseSource`, schema.prisma — opt-in, no row means hidden).
|
||||||
|
* Applied unconditionally in {@link listRecipes}, across every tab: a
|
||||||
|
* manually-authored recipe (`sourceId` `null`) is always visible, this
|
||||||
|
* only ever hides a recipe that came from an external source the viewer's
|
||||||
|
* household hasn't turned on. A viewer with no household yet
|
||||||
|
* (`houseId === null`) has nothing enabled by construction (there's no
|
||||||
|
* household row for `HouseSource` to reference), so every sourced recipe
|
||||||
|
* is hidden for them until they join or create one and configure it.
|
||||||
|
*/
|
||||||
|
async function sourceVisibilityWhere(houseId: number | null): Promise<Prisma.RecipeWhereInput> {
|
||||||
|
const enabledSourceIds =
|
||||||
|
houseId === null
|
||||||
|
? []
|
||||||
|
: (await prisma.houseSource.findMany({ where: { houseId }, select: { sourceId: true } })).map(
|
||||||
|
(row) => row.sourceId,
|
||||||
|
);
|
||||||
|
return { OR: [{ sourceId: null }, { sourceId: { in: enabledSourceIds } }] };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional narrowing filters for {@link listRecipes}, on top of the
|
* Optional narrowing filters for {@link listRecipes}, on top of the
|
||||||
* mandatory `tab`/`viewerId`/`viewerHouseId` — grouped into one object
|
* mandatory `tab`/`viewerId`/`viewerHouseId` — grouped into one object
|
||||||
|
|
@ -236,7 +257,7 @@ export async function listRecipes(
|
||||||
filters: ListRecipesFilters = {},
|
filters: ListRecipesFilters = {},
|
||||||
): Promise<RecipeSummaryView[]> {
|
): Promise<RecipeSummaryView[]> {
|
||||||
const { search, suitableForHousehold, ingredientIds, dietIds } = filters;
|
const { search, suitableForHousehold, ingredientIds, dietIds } = filters;
|
||||||
const conditions: Prisma.RecipeWhereInput[] = [];
|
const conditions: Prisma.RecipeWhereInput[] = [await sourceVisibilityWhere(viewerHouseId)];
|
||||||
if (search) {
|
if (search) {
|
||||||
conditions.push({ name: { contains: search, mode: "insensitive" } });
|
conditions.push({ name: { contains: search, mode: "insensitive" } });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
getAllergies,
|
getAllergies,
|
||||||
getDiets,
|
getDiets,
|
||||||
getIngredients,
|
getIngredients,
|
||||||
|
getSources,
|
||||||
getTechSteps,
|
getTechSteps,
|
||||||
getUnits,
|
getUnits,
|
||||||
} from "./reference.service.js";
|
} from "./reference.service.js";
|
||||||
|
|
@ -53,3 +54,10 @@ referenceRouter.get(
|
||||||
res.status(200).json(await getTechSteps());
|
res.status(200).json(await getTechSteps());
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
referenceRouter.get(
|
||||||
|
"/sources",
|
||||||
|
wrapAsyncHandler(async (_req, res) => {
|
||||||
|
res.status(200).json(await getSources());
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type {
|
||||||
AllergyView,
|
AllergyView,
|
||||||
DietView,
|
DietView,
|
||||||
IngredientView,
|
IngredientView,
|
||||||
|
SourceView,
|
||||||
TechStepView,
|
TechStepView,
|
||||||
UnitView,
|
UnitView,
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
|
|
@ -64,6 +65,24 @@ export async function getTechSteps(): Promise<TechStepView[]> {
|
||||||
return prisma.techStep.findMany({ orderBy: { key: "asc" } });
|
return prisma.techStep.findMany({ orderBy: { key: "asc" } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every implemented recipe source, ordered by name (not `key` — unlike
|
||||||
|
* every other reference catalog, `name` here *is* the display string a
|
||||||
|
* household picks from, see {@link SourceView}, so alphabetical-by-name is
|
||||||
|
* what a real picker should show). Empty until a concrete adapter is
|
||||||
|
* registered (see `recipe-source-registry.ts`) and synced (see
|
||||||
|
* `recipe-source-sync.ts`'s `syncRecipeSources`).
|
||||||
|
*/
|
||||||
|
export async function getSources(): Promise<SourceView[]> {
|
||||||
|
// Explicit `select` — `url` exists on the `Source` row but isn't part of
|
||||||
|
// `SourceView` yet, so it must not leak into the response the way a bare
|
||||||
|
// `findMany()` would let it.
|
||||||
|
return prisma.source.findMany({
|
||||||
|
select: { id: true, key: true, name: true, official: true },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All reference ingredients, ordered by key (see {@link getDiets} for why),
|
* All reference ingredients, ordered by key (see {@link getDiets} for why),
|
||||||
* each resolved to its allergens (see `IngredientAllergy` in schema.prisma)
|
* each resolved to its allergens (see `IngredientAllergy` in schema.prisma)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ import type { Express } from "express";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import { createApp } from "../src/app.js";
|
import { createApp } from "../src/app.js";
|
||||||
import { prisma } from "../src/db/prisma.js";
|
import { prisma } from "../src/db/prisma.js";
|
||||||
|
import { syncRecipeSources } from "../src/db/recipe-source-sync.js";
|
||||||
|
import type { RecipeSourceAdapter } from "../src/lib/recipe-source-adapter.js";
|
||||||
|
import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js";
|
||||||
import { resetDatabase } from "../test-support/reset-db.js";
|
import { resetDatabase } from "../test-support/reset-db.js";
|
||||||
|
|
||||||
function buildSignupPayload(): SignupInput {
|
function buildSignupPayload(): SignupInput {
|
||||||
|
|
@ -18,6 +21,24 @@ function buildSignupPayload(): SignupInput {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A minimal `RecipeSourceAdapter` — only `key`/`name`/`official` matter for `syncRecipeSources` fixtures here. */
|
||||||
|
function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
name,
|
||||||
|
official: false,
|
||||||
|
async list() {
|
||||||
|
return { items: [], nextCursor: null };
|
||||||
|
},
|
||||||
|
async fetchDetail() {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
},
|
||||||
|
parse() {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Signs up a fresh profile on a brand new agent (its own cookie jar) and returns both. */
|
/** Signs up a fresh profile on a brand new agent (its own cookie jar) and returns both. */
|
||||||
async function signupAgent(app: Express) {
|
async function signupAgent(app: Express) {
|
||||||
const agent = request.agent(app);
|
const agent = request.agent(app);
|
||||||
|
|
@ -336,4 +357,80 @@ describe("Household", () => {
|
||||||
expect(memberProfile.houseId).to.equal(null);
|
expect(memberProfile.houseId).to.equal(null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("GET /house/current/sources + PATCH /house/current/sources", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearRecipeSources();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearRecipeSources();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => {
|
||||||
|
const getRes = await request(app).get("/house/current/sources");
|
||||||
|
const patchRes = await request(app).patch("/house/current/sources").send({ sourceIds: [] });
|
||||||
|
|
||||||
|
expect(getRes.status).to.equal(401);
|
||||||
|
expect(patchRes.status).to.equal(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects reading/writing with 404 HOUSE_NOT_FOUND when the profile has no household yet", async () => {
|
||||||
|
const { agent } = await signupAgent(app);
|
||||||
|
|
||||||
|
const getRes = await agent.get("/house/current/sources");
|
||||||
|
const patchRes = await agent.patch("/house/current/sources").send({ sourceIds: [] });
|
||||||
|
|
||||||
|
expect(getRes.status).to.equal(404);
|
||||||
|
expect(getRes.body.code).to.equal(ErrorCode.HOUSE_NOT_FOUND);
|
||||||
|
expect(patchRes.status).to.equal(404);
|
||||||
|
expect(patchRes.body.code).to.equal(ErrorCode.HOUSE_NOT_FOUND);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts with nothing enabled, opt-in — not just an empty array by coincidence", async () => {
|
||||||
|
const { agent } = await signupAgent(app);
|
||||||
|
await agent.post("/house").send({ name: "Chez Alice" });
|
||||||
|
|
||||||
|
const res = await agent.get("/house/current/sources");
|
||||||
|
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.body).to.deep.equal([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces (not merges) the enabled-source set, and it's readable back", async () => {
|
||||||
|
registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source"));
|
||||||
|
registerRecipeSource(buildFakeAdapter("otherSource", "Other Source"));
|
||||||
|
await syncRecipeSources(prisma);
|
||||||
|
const fakeSource = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } });
|
||||||
|
const otherSource = await prisma.source.findUniqueOrThrow({ where: { key: "otherSource" } });
|
||||||
|
|
||||||
|
const { agent } = await signupAgent(app);
|
||||||
|
await agent.post("/house").send({ name: "Chez Alice" });
|
||||||
|
|
||||||
|
const firstPatch = await agent
|
||||||
|
.patch("/house/current/sources")
|
||||||
|
.send({ sourceIds: [fakeSource.id, otherSource.id] });
|
||||||
|
expect(firstPatch.status).to.equal(200);
|
||||||
|
expect(firstPatch.body.sort()).to.deep.equal([fakeSource.id, otherSource.id].sort());
|
||||||
|
|
||||||
|
const secondPatch = await agent
|
||||||
|
.patch("/house/current/sources")
|
||||||
|
.send({ sourceIds: [fakeSource.id] });
|
||||||
|
expect(secondPatch.status).to.equal(200);
|
||||||
|
expect(secondPatch.body).to.deep.equal([fakeSource.id]);
|
||||||
|
|
||||||
|
const res = await agent.get("/house/current/sources");
|
||||||
|
expect(res.body).to.deep.equal([fakeSource.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown sourceId with 404 SOURCE_NOT_FOUND", async () => {
|
||||||
|
const { agent } = await signupAgent(app);
|
||||||
|
await agent.post("/house").send({ name: "Chez Alice" });
|
||||||
|
|
||||||
|
const res = await agent.patch("/house/current/sources").send({ sourceIds: [999999] });
|
||||||
|
|
||||||
|
expect(res.status).to.equal(404);
|
||||||
|
expect(res.body.code).to.equal(ErrorCode.SOURCE_NOT_FOUND);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,12 @@ function buildSignupPayload(): SignupInput {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A minimal `RecipeSourceAdapter` whose list/fetchDetail/parse are never actually called here — only `key`/`name` matter for exercising `syncRecipeSources`. */
|
/** A minimal `RecipeSourceAdapter` whose list/fetchDetail/parse are never actually called here — only `key`/`name`/`official` matter for exercising `syncRecipeSources`. */
|
||||||
function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter {
|
function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter {
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
name,
|
name,
|
||||||
|
official: false,
|
||||||
async list() {
|
async list() {
|
||||||
return { items: [], nextCursor: null };
|
return { items: [], nextCursor: null };
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ function buildFakeAdapter(key = "fakeSource"): RecipeSourceAdapter<FakeRawRecipe
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
name: "Fake Source",
|
name: "Fake Source",
|
||||||
|
official: false,
|
||||||
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
|
async list(params: RecipeSourceListParams): Promise<RecipeSourceListResult> {
|
||||||
const start = params.cursor ? Number(params.cursor) : 0;
|
const start = params.cursor ? Number(params.cursor) : 0;
|
||||||
const page = FAKE_CATALOG.slice(start, start + PAGE_SIZE);
|
const page = FAKE_CATALOG.slice(start, start + PAGE_SIZE);
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,29 @@ import { expect } from "chai";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import { createApp } from "../src/app.js";
|
import { createApp } from "../src/app.js";
|
||||||
import { prisma } from "../src/db/prisma.js";
|
import { prisma } from "../src/db/prisma.js";
|
||||||
|
import { syncRecipeSources } from "../src/db/recipe-source-sync.js";
|
||||||
|
import type { RecipeSourceAdapter } from "../src/lib/recipe-source-adapter.js";
|
||||||
|
import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js";
|
||||||
import { resetDatabase } from "../test-support/reset-db.js";
|
import { resetDatabase } from "../test-support/reset-db.js";
|
||||||
|
|
||||||
|
/** A minimal `RecipeSourceAdapter` — only `key`/`name`/`official` matter for `syncRecipeSources` fixtures here. */
|
||||||
|
function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
name,
|
||||||
|
official: false,
|
||||||
|
async list() {
|
||||||
|
return { items: [], nextCursor: null };
|
||||||
|
},
|
||||||
|
async fetchDetail() {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
},
|
||||||
|
parse() {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** See `auth.test.ts` — generated rather than hardcoded, no test fixture looks like a real person's data. */
|
/** See `auth.test.ts` — generated rather than hardcoded, no test fixture looks like a real person's data. */
|
||||||
function buildSignupPayload(): SignupInput {
|
function buildSignupPayload(): SignupInput {
|
||||||
const firstName = faker.person.firstName();
|
const firstName = faker.person.firstName();
|
||||||
|
|
@ -182,6 +203,107 @@ describe("Recipes", () => {
|
||||||
|
|
||||||
expect(res.body).to.deep.equal([]);
|
expect(res.body).to.deep.equal([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("source visibility", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
clearRecipeSources();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Registers+syncs a throwaway adapter and returns the `Source` row `syncRecipeSources` created for it. */
|
||||||
|
async function registerAndSyncSource(key: string) {
|
||||||
|
registerRecipeSource(buildFakeAdapter(key, key));
|
||||||
|
await syncRecipeSources(prisma);
|
||||||
|
return prisma.source.findUniqueOrThrow({ where: { key } });
|
||||||
|
}
|
||||||
|
|
||||||
|
it("always shows a manually-authored recipe, even for a household with nothing enabled", async () => {
|
||||||
|
const { agent, profileId } = await signup();
|
||||||
|
await agent.post("/house").send({ name: "Chez moi" });
|
||||||
|
await prisma.recipe.create({
|
||||||
|
data: { name: "Maison", authorId: profileId, visibility: "PUBLIC", portions: 4 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await agent.get("/recipes").query({ tab: "publique" });
|
||||||
|
expect(res.body.map((r: { name: string }) => r.name)).to.deep.equal(["Maison"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides a sourced recipe until the viewer's household enables that source, then shows it", async () => {
|
||||||
|
const { agent, profileId } = await signup();
|
||||||
|
await agent.post("/house").send({ name: "Chez moi" });
|
||||||
|
const source = await registerAndSyncSource("fakeSource");
|
||||||
|
await prisma.recipe.create({
|
||||||
|
data: {
|
||||||
|
name: "Importée",
|
||||||
|
authorId: profileId,
|
||||||
|
visibility: "PUBLIC",
|
||||||
|
portions: 4,
|
||||||
|
sourceId: source.id,
|
||||||
|
externalId: "1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const hidden = await agent.get("/recipes").query({ tab: "publique" });
|
||||||
|
expect(hidden.body).to.deep.equal([]);
|
||||||
|
|
||||||
|
const patchRes = await agent
|
||||||
|
.patch("/house/current/sources")
|
||||||
|
.send({ sourceIds: [source.id] });
|
||||||
|
expect(patchRes.status).to.equal(200);
|
||||||
|
|
||||||
|
const visible = await agent.get("/recipes").query({ tab: "publique" });
|
||||||
|
expect(visible.body.map((r: { name: string }) => r.name)).to.deep.equal(["Importée"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides a disabled-source recipe in every tab, including the viewer's own perso and favoris", async () => {
|
||||||
|
const { agent, profileId } = await signup();
|
||||||
|
await agent.post("/house").send({ name: "Chez moi" });
|
||||||
|
const source = await registerAndSyncSource("fakeSource");
|
||||||
|
const recipe = await prisma.recipe.create({
|
||||||
|
data: {
|
||||||
|
name: "Ma recette importée",
|
||||||
|
authorId: profileId,
|
||||||
|
portions: 4,
|
||||||
|
sourceId: source.id,
|
||||||
|
externalId: "1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.recipeFavorite.create({
|
||||||
|
data: { userProfileId: profileId, recipeId: recipe.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await agent.get("/recipes").query({ tab: "perso" })).body).to.deep.equal([]);
|
||||||
|
expect((await agent.get("/recipes").query({ tab: "favoris" })).body).to.deep.equal([]);
|
||||||
|
|
||||||
|
await agent.patch("/house/current/sources").send({ sourceIds: [source.id] });
|
||||||
|
|
||||||
|
const persoAfter = await agent.get("/recipes").query({ tab: "perso" });
|
||||||
|
expect(persoAfter.body.map((r: { name: string }) => r.name)).to.deep.equal([
|
||||||
|
"Ma recette importée",
|
||||||
|
]);
|
||||||
|
const favorisAfter = await agent.get("/recipes").query({ tab: "favoris" });
|
||||||
|
expect(favorisAfter.body.map((r: { name: string }) => r.name)).to.deep.equal([
|
||||||
|
"Ma recette importée",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides every sourced recipe for a viewer with no household at all", async () => {
|
||||||
|
const { agent, profileId } = await signup();
|
||||||
|
const source = await registerAndSyncSource("fakeSource");
|
||||||
|
await prisma.recipe.create({
|
||||||
|
data: {
|
||||||
|
name: "Importée",
|
||||||
|
authorId: profileId,
|
||||||
|
visibility: "PUBLIC",
|
||||||
|
portions: 4,
|
||||||
|
sourceId: source.id,
|
||||||
|
externalId: "1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await agent.get("/recipes").query({ tab: "publique" });
|
||||||
|
expect(res.body).to.deep.equal([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /recipes", () => {
|
describe("POST /recipes", () => {
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,30 @@ import { expect } from "chai";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import { createApp } from "../src/app.js";
|
import { createApp } from "../src/app.js";
|
||||||
import { prisma } from "../src/db/prisma.js";
|
import { prisma } from "../src/db/prisma.js";
|
||||||
|
import { syncRecipeSources } from "../src/db/recipe-source-sync.js";
|
||||||
import { seedReferenceData } from "../src/db/reference-seed-data.js";
|
import { seedReferenceData } from "../src/db/reference-seed-data.js";
|
||||||
|
import type { RecipeSourceAdapter } from "../src/lib/recipe-source-adapter.js";
|
||||||
|
import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js";
|
||||||
import { resetDatabase } from "../test-support/reset-db.js";
|
import { resetDatabase } from "../test-support/reset-db.js";
|
||||||
|
|
||||||
|
/** A minimal `RecipeSourceAdapter` — only `key`/`name`/`official` matter for `syncRecipeSources` fixtures here. */
|
||||||
|
function buildFakeAdapter(key: string, name: string, official: boolean): RecipeSourceAdapter {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
name,
|
||||||
|
official,
|
||||||
|
async list() {
|
||||||
|
return { items: [], nextCursor: null };
|
||||||
|
},
|
||||||
|
async fetchDetail() {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
},
|
||||||
|
parse() {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("Reference data", () => {
|
describe("Reference data", () => {
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
|
|
||||||
|
|
@ -125,4 +146,42 @@ describe("Reference data", () => {
|
||||||
expect(await prisma.techStepMapping.count()).to.equal(26);
|
expect(await prisma.techStepMapping.count()).to.equal(26);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("GET /reference/sources", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
clearRecipeSources();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is empty until a concrete adapter is registered", async () => {
|
||||||
|
const res = await request(app).get("/reference/sources");
|
||||||
|
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.body).to.deep.equal([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns every synced adapter, official flag included, no session required", async () => {
|
||||||
|
registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source", false));
|
||||||
|
registerRecipeSource(buildFakeAdapter("officialSource", "Official Source", true));
|
||||||
|
await syncRecipeSources(prisma);
|
||||||
|
|
||||||
|
const res = await request(app).get("/reference/sources");
|
||||||
|
|
||||||
|
expect(res.status).to.equal(200);
|
||||||
|
expect(res.body).to.have.length(2);
|
||||||
|
expect(res.body[0]).to.have.keys(["id", "key", "name", "official"]);
|
||||||
|
const byKey = (key: string) => res.body.find((s: { key: string }) => s.key === key);
|
||||||
|
expect(byKey("fakeSource").official).to.equal(false);
|
||||||
|
expect(byKey("officialSource").official).to.equal(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders sources alphabetically by name", async () => {
|
||||||
|
registerRecipeSource(buildFakeAdapter("bSource", "Bravo", false));
|
||||||
|
registerRecipeSource(buildFakeAdapter("aSource", "Alpha", false));
|
||||||
|
await syncRecipeSources(prisma);
|
||||||
|
|
||||||
|
const res = await request(app).get("/reference/sources");
|
||||||
|
|
||||||
|
expect(res.body.map((s: { name: string }) => s.name)).to.deep.equal(["Alpha", "Bravo"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { SignupPage } from "./pages/SignupPage";
|
||||||
import { OnboardingAllergensPage } from "./pages/onboarding/OnboardingAllergensPage";
|
import { OnboardingAllergensPage } from "./pages/onboarding/OnboardingAllergensPage";
|
||||||
import { OnboardingDietPage } from "./pages/onboarding/OnboardingDietPage";
|
import { OnboardingDietPage } from "./pages/onboarding/OnboardingDietPage";
|
||||||
import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdPage";
|
import { OnboardingHouseholdPage } from "./pages/onboarding/OnboardingHouseholdPage";
|
||||||
|
import { OnboardingSourcesPage } from "./pages/onboarding/OnboardingSourcesPage";
|
||||||
import { AccountSettingsPage } from "./pages/settings/AccountSettingsPage";
|
import { AccountSettingsPage } from "./pages/settings/AccountSettingsPage";
|
||||||
import { CreditsPage } from "./pages/settings/CreditsPage";
|
import { CreditsPage } from "./pages/settings/CreditsPage";
|
||||||
import { HouseholdSettingsPage } from "./pages/settings/HouseholdSettingsPage";
|
import { HouseholdSettingsPage } from "./pages/settings/HouseholdSettingsPage";
|
||||||
|
|
@ -32,11 +33,14 @@ import { UserPreferencesPage } from "./pages/settings/UserPreferencesPage";
|
||||||
* pre-split combined page's path; it now just redirects to
|
* pre-split combined page's path; it now just redirects to
|
||||||
* `/parametres/foyer` so an existing bookmark/link keeps working.
|
* `/parametres/foyer` so an existing bookmark/link keeps working.
|
||||||
*
|
*
|
||||||
* `/onboarding/*` (regime/foyer/allergens, in that order) is also
|
* `/onboarding/*` (regime/foyer/sources/allergens, in that order) is also
|
||||||
* `RequireAuth`-gated — reached right after signup, once a session already
|
* `RequireAuth`-gated — reached right after signup, once a session already
|
||||||
* exists — but deliberately its own top-level route group, *not* nested
|
* exists — but deliberately its own top-level route group, *not* nested
|
||||||
* under `AppLayout`: a focused, distraction-free wizard with no sidebar,
|
* under `AppLayout`: a focused, distraction-free wizard with no sidebar,
|
||||||
* same full-page-card language as `/login`/`/signup` (see `onboarding.scss`).
|
* same full-page-card language as `/login`/`/signup` (see `onboarding.scss`).
|
||||||
|
* `/onboarding/sources` is conditional — only reached when the `foyer` step
|
||||||
|
* created/joined a household (see `OnboardingHouseholdPage`'s `goToNextStep`);
|
||||||
|
* skipped otherwise, straight to `/onboarding/allergenes`.
|
||||||
*/
|
*/
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -81,6 +85,14 @@ export function App() {
|
||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/onboarding/sources"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<OnboardingSourcesPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/onboarding/allergenes"
|
path="/onboarding/allergenes"
|
||||||
element={
|
element={
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
type RecipeView,
|
type RecipeView,
|
||||||
type SafeUserProfile,
|
type SafeUserProfile,
|
||||||
type SignupInput,
|
type SignupInput,
|
||||||
|
type SourceView,
|
||||||
type ThemePreference,
|
type ThemePreference,
|
||||||
type UnitView,
|
type UnitView,
|
||||||
type UpdateRecipeInput,
|
type UpdateRecipeInput,
|
||||||
|
|
@ -97,7 +98,7 @@ export class ApiClient {
|
||||||
return response.json() as Promise<TResponseBody>;
|
return response.json() as Promise<TResponseBody>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Creates a profile (+ household) and starts a session. */
|
/** Creates a profile and starts a session — no household yet, that's an optional step of the onboarding wizard. */
|
||||||
public signup(input: SignupInput): Promise<SafeUserProfile> {
|
public signup(input: SignupInput): Promise<SafeUserProfile> {
|
||||||
return this.request("/auth/signup", { method: "POST", body: JSON.stringify(input) });
|
return this.request("/auth/signup", { method: "POST", body: JSON.stringify(input) });
|
||||||
}
|
}
|
||||||
|
|
@ -167,6 +168,11 @@ export class ApiClient {
|
||||||
return this.request("/reference/units");
|
return this.request("/reference/units");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reference list of implemented recipe sources (onboarding wizard's source step, `/parametres/foyer`) — empty until a concrete source is registered. Public — no session required. */
|
||||||
|
public getSources(): Promise<SourceView[]> {
|
||||||
|
return this.request("/reference/sources");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One catalog tab (favoris/perso/foyer/publique — see `RecipeTab`),
|
* One catalog tab (favoris/perso/foyer/publique — see `RecipeTab`),
|
||||||
* optionally narrowed further — `search` (name substring),
|
* optionally narrowed further — `search` (name substring),
|
||||||
|
|
@ -256,6 +262,19 @@ export class ApiClient {
|
||||||
return this.request(`/house/members/${memberId}`, { method: "DELETE" });
|
return this.request(`/house/members/${memberId}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fetches the current user's household's enabled recipe-source ids — which sources show up in its recipe tabs. Rejects with `HOUSE_NOT_FOUND` if they have no household yet. */
|
||||||
|
public getHouseSourceIds(): Promise<number[]> {
|
||||||
|
return this.request("/house/current/sources");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces the current user's household's full enabled-source selection (not a merge — send the complete list; empty hides every external source). Rejects with `HOUSE_NOT_FOUND`/`SOURCE_NOT_FOUND`. */
|
||||||
|
public updateHouseSourceIds(sourceIds: number[]): Promise<number[]> {
|
||||||
|
return this.request("/house/current/sources", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ sourceIds }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Sets (or clears, with `null`) the current user's dietary regime. */
|
/** Sets (or clears, with `null`) the current user's dietary regime. */
|
||||||
public updateDiet(dietId: number | null): Promise<SafeUserProfile> {
|
public updateDiet(dietId: number | null): Promise<SafeUserProfile> {
|
||||||
return this.request("/profile/diet", { method: "PATCH", body: JSON.stringify({ dietId }) });
|
return this.request("/profile/diet", { method: "PATCH", body: JSON.stringify({ dietId }) });
|
||||||
|
|
|
||||||
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",
|
"RECIPE_IN_USE": "Cette recette est encore utilisée dans un planning",
|
||||||
"INGREDIENT_NOT_FOUND": "Un des ingrédients sélectionnés n'existe pas",
|
"INGREDIENT_NOT_FOUND": "Un des ingrédients sélectionnés n'existe pas",
|
||||||
"UNIT_NOT_FOUND": "Une des unités sélectionnées n'existe pas",
|
"UNIT_NOT_FOUND": "Une des unités sélectionnées n'existe pas",
|
||||||
|
"SOURCE_NOT_FOUND": "Une des sources sélectionnées n'existe pas",
|
||||||
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
|
"INTERNAL_ERROR": "Une erreur est survenue, réessayez plus tard"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
|
|
@ -59,6 +60,11 @@
|
||||||
"skip": "Passer cette étape",
|
"skip": "Passer cette étape",
|
||||||
"alreadyHasHouse": "Vous faites déjà partie du foyer « {{name}} »."
|
"alreadyHasHouse": "Vous faites déjà partie du foyer « {{name}} »."
|
||||||
},
|
},
|
||||||
|
"sources": {
|
||||||
|
"title": "Sources de recettes",
|
||||||
|
"subtitle": "Choisissez les sources dont vous voulez voir les recettes dans le catalogue de votre foyer.",
|
||||||
|
"legend": "Sources disponibles"
|
||||||
|
},
|
||||||
"allergens": {
|
"allergens": {
|
||||||
"title": "Des allergies ou intolérances ?"
|
"title": "Des allergies ou intolérances ?"
|
||||||
}
|
}
|
||||||
|
|
@ -311,6 +317,13 @@
|
||||||
"youSuffix": " (vous)",
|
"youSuffix": " (vous)",
|
||||||
"removeButton": "Retirer",
|
"removeButton": "Retirer",
|
||||||
"leaveButton": "Quitter le foyer",
|
"leaveButton": "Quitter le foyer",
|
||||||
|
"sources": {
|
||||||
|
"title": "Sources de recettes",
|
||||||
|
"hint": "Les sources activées ci-dessous sont celles dont les recettes apparaissent dans le catalogue de votre foyer.",
|
||||||
|
"legend": "Sources disponibles",
|
||||||
|
"official": "Officielle",
|
||||||
|
"unofficial": "Non officielle"
|
||||||
|
},
|
||||||
"dangerZone": {
|
"dangerZone": {
|
||||||
"title": "Zone dangereuse",
|
"title": "Zone dangereuse",
|
||||||
"description": "Supprimer le foyer le supprime pour tous ses membres, ainsi que son planning.",
|
"description": "Supprimer le foyer le supprime pour tous ses membres, ainsi que son planning.",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,13 @@ import "./onboarding.scss";
|
||||||
* `GET /profile/allergies` round trip a "resume where I left off" flow
|
* `GET /profile/allergies` round trip a "resume where I left off" flow
|
||||||
* would require (the `/parametres/preferences` settings page is the
|
* would require (the `/parametres/preferences` settings page is the
|
||||||
* always-fetch source of truth for editing an existing selection later).
|
* always-fetch source of truth for editing an existing selection later).
|
||||||
|
*
|
||||||
|
* Also fetches the current household on mount, only to know whether the
|
||||||
|
* `/onboarding/sources` step ran before this one (household step created
|
||||||
|
* one and it had a source to configure) — the step count shown reflects
|
||||||
|
* whichever path the visitor actually took, "3 sur 3" or "4 sur 4"
|
||||||
|
* (there's no shared wizard state to read this from — see
|
||||||
|
* `OnboardingHouseholdPage`'s doc comment on why each step is independent).
|
||||||
*/
|
*/
|
||||||
export function OnboardingAllergensPage() {
|
export function OnboardingAllergensPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -22,16 +29,18 @@ export function OnboardingAllergensPage() {
|
||||||
|
|
||||||
const [allergies, setAllergies] = useState<AllergyView[]>([]);
|
const [allergies, setAllergies] = useState<AllergyView[]>([]);
|
||||||
const [allergyIds, setAllergyIds] = useState<number[]>([]);
|
const [allergyIds, setAllergyIds] = useState<number[]>([]);
|
||||||
|
const [totalSteps, setTotalSteps] = useState(3);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
apiClient
|
Promise.all([apiClient.getAllergies(), apiClient.getCurrentHouse()])
|
||||||
.getAllergies()
|
.then(([allergiesResult, house]) => {
|
||||||
.then((result) => {
|
if (cancelled) return;
|
||||||
if (!cancelled) setAllergies(result);
|
setAllergies(allergiesResult);
|
||||||
|
setTotalSteps(house !== null ? 4 : 3);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!cancelled) setIsLoading(false);
|
if (!cancelled) setIsLoading(false);
|
||||||
|
|
@ -60,7 +69,9 @@ export function OnboardingAllergensPage() {
|
||||||
return (
|
return (
|
||||||
<main className="onboarding-page">
|
<main className="onboarding-page">
|
||||||
<form className="onboarding-card" onSubmit={handleSubmit} noValidate>
|
<form className="onboarding-card" onSubmit={handleSubmit} noValidate>
|
||||||
<p className="onboarding-step">{t("onboarding.step", { current: 3, total: 3 })}</p>
|
<p className="onboarding-step">
|
||||||
|
{t("onboarding.step", { current: totalSteps, total: totalSteps })}
|
||||||
|
</p>
|
||||||
<h1>{t("onboarding.allergens.title")}</h1>
|
<h1>{t("onboarding.allergens.title")}</h1>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|
|
||||||
|
|
@ -44,8 +44,14 @@ export function OnboardingHouseholdPage() {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function goToNextStep() {
|
/**
|
||||||
navigate("/onboarding/allergenes");
|
* A household is what the next step (`/onboarding/sources`) needs to
|
||||||
|
* attach a preference to — created/joined/already-there goes on to it,
|
||||||
|
* skipped goes straight to `/onboarding/allergenes` instead (nothing to
|
||||||
|
* configure sources for without a household).
|
||||||
|
*/
|
||||||
|
function goToNextStep(hasHousehold: boolean) {
|
||||||
|
navigate(hasHousehold ? "/onboarding/sources" : "/onboarding/allergenes");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -59,16 +65,16 @@ export function OnboardingHouseholdPage() {
|
||||||
) : house !== null ? (
|
) : house !== null ? (
|
||||||
<>
|
<>
|
||||||
<p>{t("onboarding.household.alreadyHasHouse", { name: house.name })}</p>
|
<p>{t("onboarding.household.alreadyHasHouse", { name: house.name })}</p>
|
||||||
<button type="button" onClick={goToNextStep}>
|
<button type="button" onClick={() => goToNextStep(true)}>
|
||||||
{t("onboarding.continue")}
|
{t("onboarding.continue")}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="onboarding-subtitle">{t("onboarding.household.subtitle")}</p>
|
<p className="onboarding-subtitle">{t("onboarding.household.subtitle")}</p>
|
||||||
<CreateHouseholdForm onDone={goToNextStep} />
|
<CreateHouseholdForm onDone={() => goToNextStep(true)} />
|
||||||
<JoinHouseholdForm onDone={goToNextStep} />
|
<JoinHouseholdForm onDone={() => goToNextStep(true)} />
|
||||||
<SkipButton onSkip={goToNextStep} />
|
<SkipButton onSkip={() => goToNextStep(false)} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
97
apps/web/src/pages/onboarding/OnboardingSourcesPage.tsx
Normal file
97
apps/web/src/pages/onboarding/OnboardingSourcesPage.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import type { SourceView } from "@batch-cooking/shared";
|
||||||
|
import { ErrorCode } from "@batch-cooking/shared";
|
||||||
|
import { type FormEvent, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { ApiError, apiClient } from "../../api/client";
|
||||||
|
import { SourceSelect } from "../../features/house/SourceSelect";
|
||||||
|
import { errorMessageService } from "../../services/error-message.service";
|
||||||
|
import "./onboarding.scss";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Third step of the post-signup onboarding wizard, routed at
|
||||||
|
* `/onboarding/sources` — only reached when a household now exists (see
|
||||||
|
* `OnboardingHouseholdPage`'s `goToStep`, which skips straight to
|
||||||
|
* `/onboarding/allergenes` when the previous step was skipped): enabled
|
||||||
|
* sources are a per-household preference (`HouseSource`, schema.prisma),
|
||||||
|
* so there's nothing to attach a selection to without one.
|
||||||
|
*
|
||||||
|
* Starts from an empty selection — a newly created/joined household has
|
||||||
|
* nothing enabled yet by design (opt-in, see `house.service.ts`'s
|
||||||
|
* `getHouseSourceIds`). If no source is implemented yet (`getSources()`
|
||||||
|
* comes back empty — currently always the case), there's nothing to pick
|
||||||
|
* from either, so this step silently steps itself over rather than showing
|
||||||
|
* an empty screen.
|
||||||
|
*/
|
||||||
|
export function OnboardingSourcesPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [sources, setSources] = useState<SourceView[]>([]);
|
||||||
|
const [sourceIds, setSourceIds] = useState<number[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
apiClient
|
||||||
|
.getSources()
|
||||||
|
.then((result) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (result.length === 0) {
|
||||||
|
navigate("/onboarding/allergenes", { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSources(result);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setIsLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
async function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setFormError(null);
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await apiClient.updateHouseSourceIds(sourceIds);
|
||||||
|
navigate("/onboarding/allergenes");
|
||||||
|
} catch (err) {
|
||||||
|
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
||||||
|
setFormError(errorMessageService.getLabel(code));
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="onboarding-page">
|
||||||
|
<form className="onboarding-card" onSubmit={handleSubmit} noValidate>
|
||||||
|
<p className="onboarding-step">{t("onboarding.step", { current: 3, total: 4 })}</p>
|
||||||
|
<h1>{t("onboarding.sources.title")}</h1>
|
||||||
|
<p className="onboarding-subtitle">{t("onboarding.sources.subtitle")}</p>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p>{t("onboarding.loading")}</p>
|
||||||
|
) : (
|
||||||
|
<SourceSelect
|
||||||
|
legend={t("onboarding.sources.legend")}
|
||||||
|
sources={sources}
|
||||||
|
value={sourceIds}
|
||||||
|
onChange={setSourceIds}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formError && <p className="form-error">{formError}</p>}
|
||||||
|
|
||||||
|
<button type="submit" disabled={isSubmitting || isLoading}>
|
||||||
|
{t("onboarding.continue")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,14 @@
|
||||||
import { ErrorCode, type HouseView, renameHouseSchema } from "@batch-cooking/shared";
|
import {
|
||||||
|
ErrorCode,
|
||||||
|
type HouseView,
|
||||||
|
type SourceView,
|
||||||
|
renameHouseSchema,
|
||||||
|
} from "@batch-cooking/shared";
|
||||||
import { type FormEvent, useEffect, useRef, useState } from "react";
|
import { type FormEvent, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ApiError, apiClient } from "../../api/client";
|
import { ApiError, apiClient } from "../../api/client";
|
||||||
import { useAuth } from "../../features/auth/AuthContext";
|
import { useAuth } from "../../features/auth/AuthContext";
|
||||||
|
import { SourceSelect } from "../../features/house/SourceSelect";
|
||||||
import { HouseNameField } from "../../features/profile/HouseNameField";
|
import { HouseNameField } from "../../features/profile/HouseNameField";
|
||||||
import { fieldErrorsFrom } from "../../lib/zod-errors";
|
import { fieldErrorsFrom } from "../../lib/zod-errors";
|
||||||
import { errorMessageService } from "../../services/error-message.service";
|
import { errorMessageService } from "../../services/error-message.service";
|
||||||
|
|
@ -14,6 +20,9 @@ type SaveState = "idle" | "saving" | "saved" | "error";
|
||||||
/** Debounce for the household name field (typing) — long enough that saves don't fire on every keystroke. */
|
/** Debounce for the household name field (typing) — long enough that saves don't fire on every keystroke. */
|
||||||
const HOUSE_NAME_DEBOUNCE_MS = 600;
|
const HOUSE_NAME_DEBOUNCE_MS = 600;
|
||||||
|
|
||||||
|
/** Debounce for the enabled-sources checkboxes — same reasoning as `PreferencesPage`'s `ALLERGIES_DEBOUNCE_MS`: coalesces a quick burst of toggles into one request. */
|
||||||
|
const HOUSE_SOURCES_DEBOUNCE_MS = 500;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Household settings — routed at `/parametres/foyer`. Split out of what
|
* Household settings — routed at `/parametres/foyer`. Split out of what
|
||||||
* used to be `HouseholdPage` (regime/allergies moved to `PreferencesPage`,
|
* used to be `HouseholdPage` (regime/allergies moved to `PreferencesPage`,
|
||||||
|
|
@ -254,6 +263,8 @@ function HasHousehold({
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SourcesSection />
|
||||||
|
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<DeleteHouseholdSection onChanged={onChanged} />
|
<DeleteHouseholdSection onChanged={onChanged} />
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -263,6 +274,78 @@ function HasHousehold({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which recipe sources this household sees in its recipe tabs — any member
|
||||||
|
* can change this, not just the admin (same posture as renaming, unlike
|
||||||
|
* deleting the household or removing a member). Renders nothing while
|
||||||
|
* loading or once loaded if no source is implemented yet (`getSources()`
|
||||||
|
* empty — currently always the case): no point showing an empty picker in
|
||||||
|
* a settings page any more than in the onboarding wizard's own source step.
|
||||||
|
*/
|
||||||
|
function SourcesSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [sources, setSources] = useState<SourceView[]>([]);
|
||||||
|
const [sourceIds, setSourceIds] = useState<number[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [saveState, setSaveState] = useState<SaveState>("idle");
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
const saveTimeout = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
Promise.all([apiClient.getSources(), apiClient.getHouseSourceIds()])
|
||||||
|
.then(([sourcesResult, sourceIdsResult]) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setSources(sourcesResult);
|
||||||
|
setSourceIds(sourceIdsResult);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setIsLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearTimeout(saveTimeout.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function handleChange(newSourceIds: number[]) {
|
||||||
|
setSourceIds(newSourceIds);
|
||||||
|
window.clearTimeout(saveTimeout.current);
|
||||||
|
setSaveState("saving");
|
||||||
|
saveTimeout.current = window.setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await apiClient.updateHouseSourceIds(newSourceIds);
|
||||||
|
setSaveState("saved");
|
||||||
|
} catch (err) {
|
||||||
|
const code = err instanceof ApiError ? err.code : ErrorCode.INTERNAL_ERROR;
|
||||||
|
setSaveError(errorMessageService.getLabel(code));
|
||||||
|
setSaveState("error");
|
||||||
|
}
|
||||||
|
}, HOUSE_SOURCES_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading || sources.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-page__section">
|
||||||
|
<h2 className="settings-page__section-title">{t("household.sources.title")}</h2>
|
||||||
|
<p className="settings-page__hint">{t("household.sources.hint")}</p>
|
||||||
|
<SourceSelect
|
||||||
|
legend={t("household.sources.legend")}
|
||||||
|
sources={sources}
|
||||||
|
value={sourceIds}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
{saveState === "saving" && <p className="settings-page__saving">{t("common.saving")}</p>}
|
||||||
|
{saveState === "saved" && <p className="settings-page__saved">{t("common.saved")}</p>}
|
||||||
|
{saveState === "error" && <p className="field-error">{saveError}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Read-only invite code display with a one-click clipboard copy. */
|
/** Read-only invite code display with a one-click clipboard copy. */
|
||||||
function InviteCode({ code }: { code: string }) {
|
function InviteCode({ code }: { code: string }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,8 @@ export enum ErrorCode {
|
||||||
PLANNING_ITEM_NOT_FOUND = 4047,
|
PLANNING_ITEM_NOT_FOUND = 4047,
|
||||||
/** A recipe payload's `unitId` doesn't match any reference `Unit` row. */
|
/** A recipe payload's `unitId` doesn't match any reference `Unit` row. */
|
||||||
UNIT_NOT_FOUND = 4048,
|
UNIT_NOT_FOUND = 4048,
|
||||||
|
/** `PATCH /house/current/sources`'s `sourceIds` contains one that doesn't match any reference `Source` row. */
|
||||||
|
SOURCE_NOT_FOUND = 4049,
|
||||||
/** Unexpected/unhandled failure — the catch-all, always logged server-side. */
|
/** Unexpected/unhandled failure — the catch-all, always logged server-side. */
|
||||||
INTERNAL_ERROR = 5000,
|
INTERNAL_ERROR = 5000,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,3 +24,16 @@ export const joinHouseSchema = z.object({
|
||||||
});
|
});
|
||||||
/** Inferred TS type for {@link joinHouseSchema}'s validated output. */
|
/** Inferred TS type for {@link joinHouseSchema}'s validated output. */
|
||||||
export type JoinHouseInput = z.infer<typeof joinHouseSchema>;
|
export type JoinHouseInput = z.infer<typeof joinHouseSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload accepted by `PATCH /house/current/sources`. Replaces the
|
||||||
|
* household's full set of enabled recipe sources (same "replace, not
|
||||||
|
* merge" contract as `updateAllergiesSchema`) — an empty array disables
|
||||||
|
* every source, which is also this household's starting state (opt-in: no
|
||||||
|
* `HouseSource` row means hidden, see schema.prisma).
|
||||||
|
*/
|
||||||
|
export const updateHouseSourcesSchema = z.object({
|
||||||
|
sourceIds: z.array(z.number().int().positive()),
|
||||||
|
});
|
||||||
|
/** Inferred TS type for {@link updateHouseSourcesSchema}'s validated output. */
|
||||||
|
export type UpdateHouseSourcesInput = z.infer<typeof updateHouseSourcesSchema>;
|
||||||
|
|
|
||||||
|
|
@ -210,6 +210,29 @@ export interface TechStepView {
|
||||||
key: string;
|
key: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An implemented recipe source, as returned by `GET /reference/sources` —
|
||||||
|
* reference data (`Source`, kept in sync with the adapter registry by
|
||||||
|
* `syncRecipeSources`, `apps/api/src/db/recipe-source-sync.ts`), same
|
||||||
|
* static/non-administrable status as {@link DietView}/{@link UnitView} from
|
||||||
|
* a client's point of view (nothing here is ever created/edited through
|
||||||
|
* the API — only a real adapter being registered in code adds a row).
|
||||||
|
*
|
||||||
|
* Unlike `DietView`/`TechStepView`, `name` is the actual display string
|
||||||
|
* (e.g. `"Marmiton"`) rather than a `key` resolved through
|
||||||
|
* `catalog.<domain>.<key>` — a source's name is a proper noun/brand,
|
||||||
|
* nothing to translate. `official` distinguishes a source backed by an
|
||||||
|
* official API from one built by scraping HTML the site never committed to
|
||||||
|
* a stable shape — surfaced so households can make an informed choice when
|
||||||
|
* picking which sources to enable (see `HouseSource` in schema.prisma).
|
||||||
|
*/
|
||||||
|
export interface SourceView {
|
||||||
|
id: number;
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
official: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A selectable ingredient, as returned by `GET /reference/ingredients` —
|
* A selectable ingredient, as returned by `GET /reference/ingredients` —
|
||||||
* reference data (`Ingredient`, seeded via `apps/api/src/db/
|
* reference data (`Ingredient`, seeded via `apps/api/src/db/
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue