diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index a1161e3..b57a5ad 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -10,6 +10,7 @@ import { preferencesRouter } from "./modules/preferences/preferences.routes.js"; import { profileRouter } from "./modules/profile/profile.routes.js"; import { recipeRouter } from "./modules/recipe/recipe.routes.js"; import { referenceRouter } from "./modules/reference/reference.routes.js"; +import { sourcesRouter } from "./modules/sources/sources.routes.js"; /** * Builds the API's `ExpressServer`: standard middleware, routes, and the @@ -34,6 +35,7 @@ export function createServer(): ExpressServer { server.mountRouter("/profile", profileRouter); server.mountRouter("/recipes", recipeRouter); server.mountRouter("/reference", referenceRouter); + server.mountRouter("/sources", sourcesRouter); // Serves the built frontend (production Docker image only — see // FRONTEND_DIST_DIR's doc comment in config/env.ts). Must come after diff --git a/apps/api/src/db/recipe-source-sync.ts b/apps/api/src/db/recipe-source-sync.ts index f6ae4aa..8ad5a55 100644 --- a/apps/api/src/db/recipe-source-sync.ts +++ b/apps/api/src/db/recipe-source-sync.ts @@ -41,28 +41,33 @@ export async function syncRecipeSources(prisma: PrismaClient): Promise { /** * Which of `externalIds` already have a `Recipe` imported from the source - * registered under `sourceKey` — the DB-touching counterpart to - * `markAlreadyImported` (recipe-source-adapter.ts), which stays pure and - * takes this set as a plain argument rather than querying itself. Returns - * an empty set (not an error) for a `sourceKey` with no matching `Source` - * row — nothing can have been imported from a source we don't even have a - * catalog entry for. + * registered under `sourceKey`, mapped to that `Recipe`'s id — the + * DB-touching counterpart to `markAlreadyImported` (recipe-source-adapter.ts), + * which stays pure and takes a plain `ReadonlySet` (this map's + * `.keys()`) rather than querying itself. The id (not just membership) is + * what `sources.service.ts`'s browse endpoint needs to link an + * already-imported item straight to its real `Recipe`, instead of a + * caller having to look it up again. Returns an empty map (not an error) + * for a `sourceKey` with no matching `Source` row — nothing can have been + * imported from a source we don't even have a catalog entry for. */ -export async function findImportedExternalIds( +export async function findImportedRecipeIds( prisma: PrismaClient, sourceKey: string, externalIds: string[], -): Promise> { - if (externalIds.length === 0) return new Set(); +): Promise> { + if (externalIds.length === 0) return new Map(); const source = await prisma.source.findUnique({ where: { key: sourceKey } }); - if (!source) return new Set(); + if (!source) return new Map(); const imported = await prisma.recipe.findMany({ where: { sourceId: source.id, externalId: { in: externalIds } }, - select: { externalId: true }, + select: { id: true, externalId: true }, }); - return new Set( - imported.flatMap((recipe) => (recipe.externalId !== null ? [recipe.externalId] : [])), + return new Map( + imported.flatMap((recipe) => + recipe.externalId !== null ? [[recipe.externalId, recipe.id]] : [], + ), ); } diff --git a/apps/api/src/lib/recipe-source-adapter.ts b/apps/api/src/lib/recipe-source-adapter.ts index 2db6816..d663855 100644 --- a/apps/api/src/lib/recipe-source-adapter.ts +++ b/apps/api/src/lib/recipe-source-adapter.ts @@ -146,6 +146,7 @@ export interface ParsedRecipe { * name: "Some Recipe Site", * official: false, * iconUrl: "https://somerecipesite.example/favicon.svg", + * locale: "fr", * async list(params) { ... }, * async fetchDetail(externalId) { ... }, * parse(raw) { ... }, @@ -171,6 +172,16 @@ export interface RecipeSourceAdapter { official: boolean; /** URL of the source's own logo/favicon, for `SourceSelect` (apps/web) to display next to its name — `null` if the source has none worth showing. Synced to `Source.iconUrl` the same way as `name`/`official`. */ iconUrl: string | null; + /** + * Language of the text this source produces (`ParsedRecipe.description`/ + * `steps[].description`/`ingredients[].name`) — e.g. `"en"` for + * TheMealDB. Not a user preference: the language the source's own + * content is actually written in, regardless of who's browsing it. + * Determines which `TechStepMapping`/ingredient-label locale + * `translateRecipe` (`recipe-translation.ts`) resolves this source's + * recipes against when previewing/importing one. + */ + locale: string; list(params: RecipeSourceListParams): Promise; fetchDetail(externalId: string): Promise; parse(raw: TRawDetail): ParsedRecipe; diff --git a/apps/api/src/modules/sources/sources.routes.ts b/apps/api/src/modules/sources/sources.routes.ts new file mode 100644 index 0000000..1b60a92 --- /dev/null +++ b/apps/api/src/modules/sources/sources.routes.ts @@ -0,0 +1,51 @@ +import { HttpError } from "@batch-cooking/error-tools"; +import { wrapAsyncHandler } from "@batch-cooking/express-tools"; +import { ErrorCode, browseSourceSchema } from "@batch-cooking/shared"; +import { Router } from "express"; +import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js"; +import { browseSource, previewSourceItem } from "./sources.service.js"; + +/** + * Router mounted at `/sources` in app.ts — browsing/previewing a + * household's *enabled* external recipe sources (see `sources.service.ts`). + * Every route requires a session, same posture as `/recipes`/`/house`: this + * is app content scoped to the viewer's household, not signup-time + * reference data (contrast `/reference/sources`, which just lists what + * exists, public, no auth needed). + */ +export const sourcesRouter = Router(); + +/** Route params are typed `string | undefined` by Express even for a segment that always matches when the route does — this just satisfies TS, the branch is unreachable in practice. */ +function requireParam(value: string | undefined): string { + if (value === undefined) { + throw new HttpError(400, ErrorCode.VALIDATION_ERROR, "Missing route parameter"); + } + return value; +} + +sourcesRouter.get( + "/:sourceKey/browse", + requireAuth, + wrapAsyncHandler(async (req, res) => { + const input = browseSourceSchema.parse(req.query); + const { houseId } = res.locals.userProfile; + res.status(200).json(await browseSource(requireParam(req.params.sourceKey), houseId, input)); + }), +); + +sourcesRouter.get( + "/:sourceKey/preview/:externalId", + requireAuth, + wrapAsyncHandler(async (req, res) => { + const { houseId } = res.locals.userProfile; + res + .status(200) + .json( + await previewSourceItem( + requireParam(req.params.sourceKey), + requireParam(req.params.externalId), + houseId, + ), + ); + }), +); diff --git a/apps/api/src/modules/sources/sources.service.ts b/apps/api/src/modules/sources/sources.service.ts new file mode 100644 index 0000000..68b5c3e --- /dev/null +++ b/apps/api/src/modules/sources/sources.service.ts @@ -0,0 +1,191 @@ +import { HttpError } from "@batch-cooking/error-tools"; +import { + type BrowsableSourceItemView, + type DraftRecipeIngredientView, + type DraftRecipeStepView, + ErrorCode, + type RecipeImportDraftView, +} from "@batch-cooking/shared"; +import { prisma } from "../../db/prisma.js"; +import { findImportedRecipeIds } from "../../db/recipe-source-sync.js"; +import { + type IngredientMatchEntry, + type UnitMatchEntry, + loadIngredientCatalog, + loadUnitCatalog, +} from "../../lib/ingredient-matcher.js"; +import { type RecipeSourceAdapter, markAlreadyImported } from "../../lib/recipe-source-adapter.js"; +import { RecipeSourceError } from "../../lib/recipe-source-errors.js"; +import { getRecipeSource } from "../../lib/recipe-source-registry.js"; +import { translateRecipeIngredients } from "../../lib/recipe-translation.js"; +import { loadTechStepMappingRules, matchTechStepSpans } from "../../lib/tech-step-matcher.js"; +import { getHouseSourceIds } from "../house/house.service.js"; +import { getIngredients, getUnits } from "../reference/reference.service.js"; + +/** + * Browsing and previewing a household's *enabled* external recipe sources + * (`HouseSource`) — the read-only half of the "onglet Sources" feature + * (see the project plan). Neither function here saves anything: browsing + * lists what a source offers (`RecipeSourceAdapter.list()`), previewing + * fully translates one item (`translateRecipeIngredients`, + * `matchTechStepSpans` — same building blocks `recipe.service.ts` uses at + * actual save time) without persisting it. Turning a preview into a real + * `Recipe` (with unresolved ingredients reviewed/fixed up first) is a + * later stage of the same plan, not built here. + */ + +/** + * `sourceKey` must both exist as a `Source` (household-enabled, via + * `HouseSource`) *and* still be a registered adapter (`recipe-source-registry.ts`) + * — the two can drift apart (a `Source` row outlives its adapter being + * unregistered, exactly what `jsonLdRecipe` was cleaned up from — see + * `sources/index.ts`), so both are checked. Either failure looks like "this + * source doesn't exist" to the caller (404 `SOURCE_NOT_FOUND`), same + * "don't distinguish not-found from not-visible" posture `recipe.service.ts` + * takes for a recipe the viewer can't see. + * + * @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet. + * @throws {HttpError} `404 SOURCE_NOT_FOUND` if `sourceKey` doesn't match a source enabled for this household. + */ +async function assertSourceEnabled( + houseId: number | null, + sourceKey: string, +): Promise { + const enabledSourceIds = await getHouseSourceIds(houseId); + const source = await prisma.source.findUnique({ where: { key: sourceKey } }); + if (!source || !enabledSourceIds.includes(source.id)) { + throw new HttpError( + 404, + ErrorCode.SOURCE_NOT_FOUND, + `Source "${sourceKey}" is not enabled for this household`, + ); + } + const adapter = getRecipeSource(sourceKey); + if (!adapter) { + throw new HttpError( + 404, + ErrorCode.SOURCE_NOT_FOUND, + `Source "${sourceKey}" has no registered adapter`, + ); + } + return adapter; +} + +/** + * One page of `sourceKey`'s own catalog, each item flagged with whether + * it's already been imported (and, if so, its real `Recipe` id — see + * `findImportedRecipeIds`). + * + * @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet. + * @throws {HttpError} `404 SOURCE_NOT_FOUND` if `sourceKey` doesn't match a source enabled for this household. + */ +export async function browseSource( + sourceKey: string, + houseId: number | null, + params: { query?: string; cursor?: string }, +): Promise<{ items: BrowsableSourceItemView[]; nextCursor: string | null }> { + const adapter = await assertSourceEnabled(houseId, sourceKey); + const result = await adapter.list({ query: params.query, cursor: params.cursor }); + + const importedRecipeIds = await findImportedRecipeIds( + prisma, + sourceKey, + result.items.map((item) => item.externalId), + ); + const marked = markAlreadyImported(result.items, new Set(importedRecipeIds.keys())); + + return { + items: marked.map((item) => ({ + externalId: item.externalId, + title: item.title, + picture: item.picture, + url: item.url, + alreadyImported: item.alreadyImported, + recipeId: importedRecipeIds.get(item.externalId) ?? null, + })), + nextCursor: result.nextCursor, + }; +} + +/** + * Fully translates one source item into an unsaved {@link RecipeImportDraftView} + * — fetches + parses it (`fetchDetail`/`parse`), then resolves its + * ingredients/units (`translateRecipeIngredients`) and detects each step's + * techniques with their exact matched span (`matchTechStepSpans`, the same + * function `recipe.service.ts` uses at real save time — see its doc + * comment), all against `adapter.locale`'s catalogs. Ingredient/unit + * matching itself only has English data today (see `ingredient-matcher.ts`); + * a non-English-locale source simply gets `ingredient`/`unit: null` on + * every line, the same graceful "no matching-language data" degradation + * `translateRecipe` already has. + * + * @throws {HttpError} `404 HOUSE_NOT_FOUND` if the profile has no household yet. + * @throws {HttpError} `404 SOURCE_NOT_FOUND` if `sourceKey` doesn't match a source enabled for this household. + * @throws {HttpError} `404 RECIPE_NOT_FOUND` if `externalId` couldn't be fetched or parsed into a usable recipe (a `RecipeSourceError` — `recipe-source-errors.ts` — from the adapter). + */ +export async function previewSourceItem( + sourceKey: string, + externalId: string, + houseId: number | null, +): Promise { + const adapter = await assertSourceEnabled(houseId, sourceKey); + + let parsed: ReturnType; + try { + const raw = await adapter.fetchDetail(externalId); + parsed = adapter.parse(raw); + } catch (err) { + if (err instanceof RecipeSourceError) { + throw new HttpError(404, ErrorCode.RECIPE_NOT_FOUND, err.message); + } + throw err; + } + + const [techStepMappings, ingredientCatalog, unitCatalog, techStepsByKey] = await Promise.all([ + loadTechStepMappingRules(adapter.locale), + adapter.locale === "en" ? loadIngredientCatalog() : Promise.resolve([]), + adapter.locale === "en" ? loadUnitCatalog() : Promise.resolve([]), + prisma.techStep.findMany({ select: { id: true, key: true } }), + ]); + const techStepById = new Map(techStepsByKey.map((techStep) => [techStep.id, techStep])); + + const translatedIngredients = translateRecipeIngredients( + parsed.ingredients, + ingredientCatalog, + unitCatalog, + ); + const [ingredientViews, unitViews] = await Promise.all([getIngredients(), getUnits()]); + const ingredientById = new Map(ingredientViews.map((view) => [view.id, view])); + const unitById = new Map(unitViews.map((view) => [view.id, view])); + + const ingredients: DraftRecipeIngredientView[] = translatedIngredients.map((ingredient) => ({ + rawText: ingredient.rawText, + quantity: ingredient.quantity, + ingredient: + ingredient.ingredientId !== null + ? (ingredientById.get(ingredient.ingredientId) ?? null) + : null, + unit: ingredient.unitId !== null ? (unitById.get(ingredient.unitId) ?? null) : null, + })); + + const steps: DraftRecipeStepView[] = parsed.steps.map((step) => ({ + description: step.description, + picture: step.picture, + techSteps: matchTechStepSpans(step.description, techStepMappings).flatMap((match) => { + const techStep = techStepById.get(match.techStepId); + return techStep ? [{ techStep, start: match.start, end: match.end }] : []; + }), + })); + + return { + sourceKey, + externalId, + name: parsed.name, + description: parsed.description, + picture: parsed.picture, + portions: parsed.portions, + sourceUrl: parsed.sourceUrl, + ingredients, + steps, + }; +} diff --git a/apps/api/src/sources/json-ld-recipe.ts b/apps/api/src/sources/json-ld-recipe.ts index 3023027..10971a2 100644 --- a/apps/api/src/sources/json-ld-recipe.ts +++ b/apps/api/src/sources/json-ld-recipe.ts @@ -170,6 +170,13 @@ export const jsonLdRecipeAdapter: RecipeSourceAdapter<{ html: string; url: strin name: "Import générique (JSON-LD)", official: false, iconUrl: null, + // Genuinely varies per scraped site (this adapter has no fixed content + // language of its own) — "fr" as a placeholder since it's never actually + // consulted: this adapter isn't registered into the source registry (see + // sources/index.ts), so nothing calls translateRecipe against it today. + // A concrete per-site adapter built on top of this one would declare its + // own real locale. + locale: "fr", async list() { return { items: [], nextCursor: null }; diff --git a/apps/api/src/sources/the-meal-db.ts b/apps/api/src/sources/the-meal-db.ts index b43d6d6..4789c4f 100644 --- a/apps/api/src/sources/the-meal-db.ts +++ b/apps/api/src/sources/the-meal-db.ts @@ -76,6 +76,10 @@ export const theMealDbAdapter: RecipeSourceAdapter = { name: "TheMealDB", official: true, iconUrl: "https://www.themealdb.com/images/logo.svg", + // TheMealDB's content (names, ingredients, instructions) is English — + // determines which locale translateRecipe (recipe-translation.ts) + // resolves this source's recipes against when previewing/importing one. + locale: "en", async list(params: RecipeSourceListParams): Promise { const query = params.query ?? ""; diff --git a/apps/api/test/house.test.ts b/apps/api/test/house.test.ts index f25fa60..f621b56 100644 --- a/apps/api/test/house.test.ts +++ b/apps/api/test/house.test.ts @@ -28,6 +28,7 @@ function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter { name, official: false, iconUrl: null, + locale: "fr", async list() { return { items: [], nextCursor: null }; }, diff --git a/apps/api/test/recipe-source-sync.test.ts b/apps/api/test/recipe-source-sync.test.ts index 05f9c4e..e366af4 100644 --- a/apps/api/test/recipe-source-sync.test.ts +++ b/apps/api/test/recipe-source-sync.test.ts @@ -4,7 +4,7 @@ import { expect } from "chai"; import request from "supertest"; import { createApp } from "../src/app.js"; import { prisma } from "../src/db/prisma.js"; -import { findImportedExternalIds, syncRecipeSources } from "../src/db/recipe-source-sync.js"; +import { findImportedRecipeIds, 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"; @@ -28,6 +28,7 @@ function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter { name, official: false, iconUrl: null, + locale: "fr", async list() { return { items: [], nextCursor: null }; }, @@ -108,22 +109,22 @@ describe("recipe-source-sync", () => { }); }); - describe("findImportedExternalIds", () => { - it("returns an empty set for a sourceKey with no matching Source row", async () => { - expect(await findImportedExternalIds(prisma, "unknown", ["1", "2"])).to.deep.equal(new Set()); + describe("findImportedRecipeIds", () => { + it("returns an empty map for a sourceKey with no matching Source row", async () => { + expect(await findImportedRecipeIds(prisma, "unknown", ["1", "2"])).to.deep.equal(new Map()); }); - it("returns an empty set for an empty externalIds list", async () => { - expect(await findImportedExternalIds(prisma, "fakeSource", [])).to.deep.equal(new Set()); + it("returns an empty map for an empty externalIds list", async () => { + expect(await findImportedRecipeIds(prisma, "fakeSource", [])).to.deep.equal(new Map()); }); - it("returns exactly the externalIds already imported from that source", async () => { + it("returns exactly the externalIds already imported from that source, mapped to their Recipe id", async () => { const { profileId } = await signup(); registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source")); await syncRecipeSources(prisma); const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } }); - await prisma.recipe.create({ + const imported = await prisma.recipe.create({ data: { name: "Tarte", authorId: profileId, @@ -135,8 +136,8 @@ describe("recipe-source-sync", () => { // Manually-authored, not tied to any source — shouldn't ever show up as "imported". await prisma.recipe.create({ data: { name: "Salade", authorId: profileId, portions: 2 } }); - const result = await findImportedExternalIds(prisma, "fakeSource", ["1", "2", "3"]); - expect(result).to.deep.equal(new Set(["1"])); + const result = await findImportedRecipeIds(prisma, "fakeSource", ["1", "2", "3"]); + expect(result).to.deep.equal(new Map([["1", imported.id]])); }); it("scopes matches to the given source — the same externalId from a different source doesn't count", async () => { @@ -156,7 +157,7 @@ describe("recipe-source-sync", () => { }, }); - expect(await findImportedExternalIds(prisma, "fakeSource", ["1"])).to.deep.equal(new Set()); + expect(await findImportedRecipeIds(prisma, "fakeSource", ["1"])).to.deep.equal(new Map()); }); }); diff --git a/apps/api/test/recipe-source.test.ts b/apps/api/test/recipe-source.test.ts index 45d0c36..e10b3c1 100644 --- a/apps/api/test/recipe-source.test.ts +++ b/apps/api/test/recipe-source.test.ts @@ -60,6 +60,7 @@ function buildFakeAdapter(key = "fakeSource"): RecipeSourceAdapter { const start = params.cursor ? Number(params.cursor) : 0; const page = FAKE_CATALOG.slice(start, start + PAGE_SIZE); diff --git a/apps/api/test/recipe.test.ts b/apps/api/test/recipe.test.ts index 1ad66cb..d35c720 100644 --- a/apps/api/test/recipe.test.ts +++ b/apps/api/test/recipe.test.ts @@ -17,6 +17,7 @@ function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter { name, official: false, iconUrl: null, + locale: "fr", async list() { return { items: [], nextCursor: null }; }, diff --git a/apps/api/test/reference.test.ts b/apps/api/test/reference.test.ts index 128c949..77ed7ba 100644 --- a/apps/api/test/reference.test.ts +++ b/apps/api/test/reference.test.ts @@ -20,6 +20,7 @@ function buildFakeAdapter( name, official, iconUrl, + locale: "fr", async list() { return { items: [], nextCursor: null }; }, diff --git a/apps/api/test/sources.test.ts b/apps/api/test/sources.test.ts new file mode 100644 index 0000000..4b496ac --- /dev/null +++ b/apps/api/test/sources.test.ts @@ -0,0 +1,254 @@ +import type { SignupInput } from "@batch-cooking/shared"; +import { ErrorCode } from "@batch-cooking/shared"; +import { faker } from "@faker-js/faker"; +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 { + ParsedRecipe, + RecipeSourceAdapter, + RecipeSourceListParams, + RecipeSourceListResult, +} from "../src/lib/recipe-source-adapter.js"; +import { RecipeSourceFetchError } from "../src/lib/recipe-source-errors.js"; +import { clearRecipeSources, registerRecipeSource } from "../src/lib/recipe-source-registry.js"; +import { resetDatabase } from "../test-support/reset-db.js"; + +/** See `recipe.test.ts` — generated rather than hardcoded, no test fixture looks like a real person's data. */ +function buildSignupPayload(): SignupInput { + const firstName = faker.person.firstName(); + const lastName = faker.person.lastName(); + return { + firstName, + lastName, + email: faker.internet.email({ firstName, lastName }).toLowerCase(), + password: faker.internet.password({ length: 16 }), + }; +} + +/** + * A minimal, real English-content fake adapter — `parse()` deliberately + * mixes one ingredient that resolves against the real seeded catalog + * ("onion") with one that doesn't ("mystery paste"), and a step whose + * text matches a real seeded English tech-step mapping ("chop") — same + * "exercise the real catalog, not a mock of it" approach the ingredient/ + * tech-step matcher tests already use. + */ +function buildFakeAdapter(key = "fakeSource"): RecipeSourceAdapter<{ externalId: string }> { + return { + key, + name: "Fake Source", + official: true, + iconUrl: null, + locale: "en", + async list(_params: RecipeSourceListParams): Promise { + return { + items: [ + { externalId: "1", title: "Onion soup", picture: null, url: "https://fake.test/1" }, + { externalId: "2", title: "Mystery stew", picture: null, url: "https://fake.test/2" }, + ], + nextCursor: null, + }; + }, + async fetchDetail(externalId: string): Promise<{ externalId: string }> { + if (externalId === "missing") { + throw new RecipeSourceFetchError(key, `No item found for id "${externalId}"`); + } + return { externalId }; + }, + parse(raw: { externalId: string }): ParsedRecipe { + return { + name: `Fake recipe ${raw.externalId}`, + description: null, + picture: null, + portions: 4, + sourceUrl: `https://fake.test/${raw.externalId}`, + ingredients: [ + { rawText: "1 onion", quantity: null, unit: null, name: "onion" }, + // No leading number and no recognizable unit word — exercises + // quantity/unit staying null alongside the ingredient itself not + // resolving, not just the ingredient. + { rawText: "some mystery paste", quantity: null, unit: null, name: "mystery paste" }, + ], + steps: [{ description: "Chop the onions finely", picture: null }], + }; + }, + }; +} + +describe("Sources", () => { + const app = createApp(); + + /** Signs up a fresh profile, creates a household for it, and returns the session `agent` alongside the household id. */ + async function signupWithHouse(): Promise<{ + agent: ReturnType; + houseId: number; + }> { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + const houseRes = await agent.post("/house").send({ name: "Chez moi" }); + return { agent, houseId: houseRes.body.id }; + } + + beforeEach(async () => { + await resetDatabase(); + }); + + afterEach(() => { + clearRecipeSources(); + }); + + after(async () => { + await prisma.$disconnect(); + }); + + describe("GET /sources/:sourceKey/browse", () => { + it("rejects requests without a session cookie with 401 NOT_AUTHENTICATED", async () => { + const res = await request(app).get("/sources/fakeSource/browse"); + expect(res.status).to.equal(401); + expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED); + }); + + it("rejects a profile with no household with 404 HOUSE_NOT_FOUND", async () => { + const agent = request.agent(app); + await agent.post("/auth/signup").send(buildSignupPayload()); + + const res = await agent.get("/sources/fakeSource/browse"); + + expect(res.status).to.equal(404); + expect(res.body.code).to.equal(ErrorCode.HOUSE_NOT_FOUND); + }); + + it("rejects an unknown sourceKey with 404 SOURCE_NOT_FOUND", async () => { + const { agent } = await signupWithHouse(); + + const res = await agent.get("/sources/unknown/browse"); + + expect(res.status).to.equal(404); + expect(res.body.code).to.equal(ErrorCode.SOURCE_NOT_FOUND); + }); + + it("rejects a real source the household hasn't enabled with 404 SOURCE_NOT_FOUND", async () => { + const { agent } = await signupWithHouse(); + registerRecipeSource(buildFakeAdapter()); + await syncRecipeSources(prisma); + + const res = await agent.get("/sources/fakeSource/browse"); + + expect(res.status).to.equal(404); + expect(res.body.code).to.equal(ErrorCode.SOURCE_NOT_FOUND); + }); + + it("returns each item flagged with alreadyImported/recipeId once the source is enabled", async () => { + const { agent, houseId } = await signupWithHouse(); + registerRecipeSource(buildFakeAdapter()); + await syncRecipeSources(prisma); + const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } }); + await agent.patch("/house/current/sources").send({ sourceIds: [source.id] }); + + const importedRecipe = await prisma.recipe.create({ + data: { + name: "Already imported", + authorId: (await prisma.userProfile.findFirstOrThrow({ where: { houseId } })).id, + portions: 4, + sourceId: source.id, + externalId: "1", + }, + }); + + const res = await agent.get("/sources/fakeSource/browse"); + + expect(res.status).to.equal(200); + expect(res.body.nextCursor).to.equal(null); + expect(res.body.items).to.deep.equal([ + { + externalId: "1", + title: "Onion soup", + picture: null, + url: "https://fake.test/1", + alreadyImported: true, + recipeId: importedRecipe.id, + }, + { + externalId: "2", + title: "Mystery stew", + picture: null, + url: "https://fake.test/2", + alreadyImported: false, + recipeId: null, + }, + ]); + }); + }); + + describe("GET /sources/:sourceKey/preview/:externalId", () => { + async function enableFakeSource(): Promise<{ + agent: ReturnType; + }> { + const { agent } = await signupWithHouse(); + registerRecipeSource(buildFakeAdapter()); + await syncRecipeSources(prisma); + const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } }); + await agent.patch("/house/current/sources").send({ sourceIds: [source.id] }); + return { agent }; + } + + it("rejects a source the household hasn't enabled with 404 SOURCE_NOT_FOUND", async () => { + const { agent } = await signupWithHouse(); + registerRecipeSource(buildFakeAdapter()); + await syncRecipeSources(prisma); + + const res = await agent.get("/sources/fakeSource/preview/1"); + + expect(res.status).to.equal(404); + expect(res.body.code).to.equal(ErrorCode.SOURCE_NOT_FOUND); + }); + + it("translates the item against the real catalog: resolves what it can, leaves the rest null", async () => { + const { agent } = await enableFakeSource(); + const onion = await prisma.ingredient.findFirstOrThrow({ where: { key: "onion" } }); + const chop = await prisma.techStep.findFirstOrThrow({ where: { key: "chop" } }); + + const res = await agent.get("/sources/fakeSource/preview/1"); + + expect(res.status).to.equal(200); + expect(res.body).to.deep.include({ + sourceKey: "fakeSource", + externalId: "1", + name: "Fake recipe 1", + description: null, + picture: null, + portions: 4, + sourceUrl: "https://fake.test/1", + }); + + const [resolved, unresolved] = res.body.ingredients; + expect(resolved.rawText).to.equal("1 onion"); + expect(resolved.ingredient).to.deep.include({ id: onion.id, key: "onion" }); + expect(unresolved.rawText).to.equal("some mystery paste"); + expect(unresolved.ingredient).to.equal(null); + expect(unresolved.unit).to.equal(null); + expect(unresolved.quantity).to.equal(null); + + expect(res.body.steps).to.have.length(1); + const [step] = res.body.steps; + expect(step.description).to.equal("Chop the onions finely"); + expect(step.techSteps).to.have.length(1); + expect(step.techSteps[0].techStep).to.deep.equal({ id: chop.id, key: "chop" }); + expect( + step.description.slice(step.techSteps[0].start, step.techSteps[0].end).toLowerCase(), + ).to.equal("chop"); + }); + + it("returns 404 RECIPE_NOT_FOUND when the adapter can't fetch the item", async () => { + const { agent } = await enableFakeSource(); + + const res = await agent.get("/sources/fakeSource/preview/missing"); + + expect(res.status).to.equal(404); + expect(res.body.code).to.equal(ErrorCode.RECIPE_NOT_FOUND); + }); + }); +}); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1f6beed..bce1750 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -12,10 +12,12 @@ export * from "./schemas/planning.js"; export * from "./schemas/preferences.js"; export * from "./schemas/profile.js"; export * from "./schemas/recipe.js"; +export * from "./schemas/sources.js"; export * from "./tools/assert-is-never.js"; export * from "./types/household.js"; export * from "./types/planning.js"; export * from "./types/preferences.js"; export * from "./types/recipe.js"; export * from "./types/reference.js"; +export * from "./types/sources.js"; export * from "./types/user-profile.js"; diff --git a/packages/shared/src/schemas/sources.ts b/packages/shared/src/schemas/sources.ts new file mode 100644 index 0000000..81324ca --- /dev/null +++ b/packages/shared/src/schemas/sources.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +/** Payload accepted by `GET /sources/:sourceKey/browse`'s query params — `query` free-text searches the source's own catalog (support varies by adapter, see `RecipeSourceListParams`), `cursor` continues a previous page (`RecipeSourceListResult.nextCursor`), omitted starts from the first page. */ +export const browseSourceSchema = z.object({ + query: z.string().trim().min(1).optional(), + cursor: z.string().trim().min(1).optional(), +}); +/** Inferred TS type for {@link browseSourceSchema}'s validated output. */ +export type BrowseSourceInput = z.infer; diff --git a/packages/shared/src/types/sources.ts b/packages/shared/src/types/sources.ts new file mode 100644 index 0000000..aab1bed --- /dev/null +++ b/packages/shared/src/types/sources.ts @@ -0,0 +1,63 @@ +import type { StepTechStepView } from "./recipe.js"; +import type { IngredientView, UnitView } from "./reference.js"; + +/** + * One item from a source's own catalog (`RecipeSourceAdapter.list()`), + * browsable regardless of whether it's already been imported — + * `GET /sources/:sourceKey/browse`. Mirrors `BrowsableRecipeItem` + * (apps/api's `recipe-source-adapter.ts`), plus `recipeId`: the already- + * imported `Recipe`'s id when `alreadyImported` is true, so a caller (the + * browse UI) can navigate straight to it without a second lookup — `null` + * otherwise. + */ +export interface BrowsableSourceItemView { + externalId: string; + title: string; + picture: string | null; + url: string; + alreadyImported: boolean; + recipeId: number | null; +} + +/** + * One ingredient line of an unsaved import draft (`RecipeImportDraftView`) + * — same spirit as `RecipeIngredientView`, but `ingredient`/`unit` can be + * `null` (nothing in the catalog matched — see `ingredient-matcher.ts`) and + * `quantity` can be missing entirely, since this hasn't been reviewed/fixed + * up by a person yet. + */ +export interface DraftRecipeIngredientView { + /** Exactly what the source wrote for this line — kept even once ingredient/unit resolve, so a review screen can show what the match was made from. */ + rawText: string; + quantity: number | null; + ingredient: IngredientView | null; + unit: UnitView | null; +} + +/** One step of an unsaved import draft — `techSteps` is detected the same way a real save computes it (`matchTechStepSpans`), just not persisted yet. */ +export interface DraftRecipeStepView { + description: string; + picture: string | null; + techSteps: StepTechStepView[]; +} + +/** + * An unsaved preview of one source item, fully translated (ingredients/ + * units/techniques resolved against our catalogs where possible) — + * `GET /sources/:sourceKey/preview/:externalId`. What a future import + * review screen pre-fills its form from. Deliberately distinct from + * `RecipeView`: nothing here has an id (nothing is saved), and ingredient/ + * unit resolution can be incomplete — nothing about this type assumes it's + * ready to persist as-is. + */ +export interface RecipeImportDraftView { + sourceKey: string; + externalId: string; + name: string; + description: string | null; + picture: string | null; + portions: number | null; + sourceUrl: string; + ingredients: DraftRecipeIngredientView[]; + steps: DraftRecipeStepView[]; +}