diff --git a/apps/api/prisma/migrations/20260820100000_recipe_source_linking/migration.sql b/apps/api/prisma/migrations/20260820100000_recipe_source_linking/migration.sql new file mode 100644 index 0000000..877c7a4 --- /dev/null +++ b/apps/api/prisma/migrations/20260820100000_recipe_source_linking/migration.sql @@ -0,0 +1,24 @@ +-- Adds `Source.key` (`key String @unique`) — the catalog of implemented +-- recipe sources is now kept in sync with the adapter registry +-- (recipe-source-registry.ts) by key, same "stable English camelCase uid" +-- convention as Diet/Unit/TechStep, rather than hand-maintained. `sources` +-- has never been seeded (no rows exist pre-launch), so a plain NOT NULL +-- column with no backfill is safe. +-- +-- Adds `Recipe.external_id` — the item's identifier on `source`, `null` +-- for a manually-authored recipe. `@@unique([sourceId, externalId])` +-- prevents importing the same source recipe twice; Postgres treats each +-- NULL as distinct, so manually-authored recipes (both columns null) never +-- collide with each other or with one another here. + +-- AlterTable +ALTER TABLE "sources" ADD COLUMN "key" TEXT NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "sources_key_key" ON "sources"("key"); + +-- AlterTable +ALTER TABLE "recipe" ADD COLUMN "external_id" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "recipe_source_id_external_id_key" ON "recipe"("source_id", "external_id"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 23bfef3..91b1a52 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -211,8 +211,20 @@ model PlanningItem { // Recipes // ----------------------------------------------------------------------------- +/// Catalog of implemented recipe sources (specific websites/APIs the +/// import pipeline knows how to talk to) — one row per adapter registered +/// in `apps/api/src/lib/recipe-source-registry.ts`, kept in sync by +/// `syncRecipeSources` (`apps/api/src/db/recipe-source-sync.ts`) rather +/// than hand-maintained like `DIETS`/`UNITS` (`reference-seed-data.ts`): +/// the adapter registry is the actual source of truth for "which sources +/// exist", this table just mirrors it so `Recipe.sourceId` has something +/// to point at. `key` matches `RecipeSourceAdapter.key` — same stable +/// English camelCase uid convention as `Diet.key`/`Unit.key`/`TechStep.key`. +/// Empty until a concrete adapter is registered (none exists yet, see +/// recipe-source-adapter.ts). model Source { id Int @id @default(autoincrement()) + key String @unique name String url String? @@ -239,6 +251,17 @@ model Recipe { id Int @id @default(autoincrement()) name String sourceId Int? @map("source_id") + /// The item's identifier on `source` (`RecipeSourceListItem.externalId`, + /// recipe-source-adapter.ts) — `null` for a manually-authored recipe, + /// alongside `sourceId` being `null`. Together with `sourceId`, this is + /// what `findImportedExternalIds` (recipe-source-sync.ts) checks against + /// to tell an already-imported source item apart from a new one when + /// browsing (see `markAlreadyImported`, recipe-source-adapter.ts) — the + /// `@@unique([sourceId, externalId])` below is what actually prevents + /// importing the same source recipe twice (Postgres treats each `NULL` + /// as distinct, so manually-authored recipes never collide with each + /// other here). + externalId String? @map("external_id") description String? picture String? /// How many portions this recipe yields as written (its ingredient @@ -267,6 +290,7 @@ model Recipe { favoritedBy RecipeFavorite[] diets RecipeDiet[] + @@unique([sourceId, externalId]) @@map("recipe") } diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 87534e6..6bc8ea6 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -1,4 +1,5 @@ import { PrismaClient } from "@prisma/client"; +import { syncRecipeSources } from "../src/db/recipe-source-sync.js"; import { seedReferenceData } from "../src/db/reference-seed-data.js"; // Standalone CLI entry point (not `src/db/prisma.ts` — that module pulls in @@ -10,6 +11,7 @@ import { seedReferenceData } from "../src/db/reference-seed-data.js"; const prisma = new PrismaClient(); seedReferenceData(prisma) + .then(() => syncRecipeSources(prisma)) .then(() => prisma.$disconnect()) .catch(async (err) => { console.error(err); diff --git a/apps/api/src/db/recipe-source-sync.ts b/apps/api/src/db/recipe-source-sync.ts new file mode 100644 index 0000000..3ff0eb2 --- /dev/null +++ b/apps/api/src/db/recipe-source-sync.ts @@ -0,0 +1,61 @@ +import type { PrismaClient } from "@prisma/client"; +import { listRecipeSources } from "../lib/recipe-source-registry.js"; + +/** + * Upserts one `Source` row (schema.prisma) per adapter currently in + * `recipe-source-registry.ts`, keyed by `adapter.key` — keeps the + * `sources` catalog an exact mirror of "which sources are actually + * implemented in code", rather than a hand-maintained list that can drift + * out of sync the way `DIETS`/`UNITS` (`reference-seed-data.ts`) would if + * copy-pasted here. Call once at startup (`prisma/seed.ts`) and in test + * setup (`test-support/reset-db.ts`), the same place `seedReferenceData` + * runs — kept as its own function rather than folded into that one, since + * it reads from the adapter registry instead of a static array. + * + * Never deletes a `Source` row whose key fell out of the registry (e.g. an + * adapter temporarily removed from code) — a recipe already imported from + * it should keep citing it rather than having `sourceId` silently nulled + * out from under it (see `onDelete: SetNull` on `Recipe.source` in + * schema.prisma, which is what *would* happen on an actual delete). + * + * Safe to call with an empty registry — currently always the case, since + * no concrete adapter exists yet (see recipe-source-adapter.ts) — leaves + * the `sources` table untouched. + */ +export async function syncRecipeSources(prisma: PrismaClient): Promise { + for (const adapter of listRecipeSources()) { + await prisma.source.upsert({ + where: { key: adapter.key }, + update: { name: adapter.name }, + create: { key: adapter.key, name: adapter.name }, + }); + } +} + +/** + * 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. + */ +export async function findImportedExternalIds( + prisma: PrismaClient, + sourceKey: string, + externalIds: string[], +): Promise> { + if (externalIds.length === 0) return new Set(); + + const source = await prisma.source.findUnique({ where: { key: sourceKey } }); + if (!source) return new Set(); + + const imported = await prisma.recipe.findMany({ + where: { sourceId: source.id, externalId: { in: externalIds } }, + select: { externalId: true }, + }); + return new Set( + imported.flatMap((recipe) => (recipe.externalId !== null ? [recipe.externalId] : [])), + ); +} diff --git a/apps/api/src/lib/recipe-source-registry.ts b/apps/api/src/lib/recipe-source-registry.ts index aca3b42..f12e682 100644 --- a/apps/api/src/lib/recipe-source-registry.ts +++ b/apps/api/src/lib/recipe-source-registry.ts @@ -4,13 +4,12 @@ import type { RecipeSourceAdapter } from "./recipe-source-adapter.js"; * In-memory registry of every {@link RecipeSourceAdapter} (recipe-source-adapter.ts) * this process knows about, keyed by `adapter.key`. Deliberately not * DB-backed — an adapter *is* code (a website's fetch/parse logic can't - * live in a database row), unlike the `Source` table in schema.prisma, - * which records *where a saved recipe came from* (a name/url pair) once - * the import pipeline actually persists one. The two are related but - * distinct: this registry is "which sources can we import from right now", - * `Source` rows are "which sources a saved recipe cites" — wiring the two - * together is future work for whichever module ends up saving imported - * recipes. + * live in a database row) — but the `Source` table (schema.prisma) is kept + * in sync with it (see `syncRecipeSources`, recipe-source-sync.ts) so a + * saved `Recipe.sourceId` has a row to point at. Actually saving an + * imported recipe (setting `Recipe.sourceId`/`externalId`) is still future + * work for whichever module ends up driving the import pipeline — this + * registry only answers "which sources can we import from right now". * * No adapter is registered here yet — this file only provides the * mechanism; `registerRecipeSource` is meant to be called once per adapter diff --git a/apps/api/test-support/reset-db.ts b/apps/api/test-support/reset-db.ts index 3d6c793..52b264b 100644 --- a/apps/api/test-support/reset-db.ts +++ b/apps/api/test-support/reset-db.ts @@ -1,4 +1,5 @@ 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"; // Single TRUNCATE ... CASCADE covers FK ordering and resets identity @@ -7,6 +8,9 @@ import { seedReferenceData } from "../src/db/reference-seed-data.js"; // truncating it, so every test starts from the same realistic reference // data the real app seeds (`prisma/seed.ts`) rather than empty tables — // tests exercising dietId/allergyIds/unitId need real rows to reference. +// `syncRecipeSources` runs last, for the same reason: `sources` should +// reflect whatever adapters this test run happens to have registered +// (usually none — see recipe-source-registry.ts). export async function resetDatabase() { await prisma.$executeRawUnsafe(` TRUNCATE TABLE @@ -18,4 +22,5 @@ export async function resetDatabase() { RESTART IDENTITY CASCADE; `); await seedReferenceData(prisma); + await syncRecipeSources(prisma); } diff --git a/apps/api/test/recipe-source-sync.test.ts b/apps/api/test/recipe-source-sync.test.ts new file mode 100644 index 0000000..0eefee3 --- /dev/null +++ b/apps/api/test/recipe-source-sync.test.ts @@ -0,0 +1,204 @@ +import type { SignupInput } 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 { findImportedExternalIds, 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"; + +/** 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 `RecipeSourceAdapter` whose list/fetchDetail/parse are never actually called here — only `key`/`name` matter for exercising `syncRecipeSources`. */ +function buildFakeAdapter(key: string, name: string): RecipeSourceAdapter { + return { + key, + name, + async list() { + return { items: [], nextCursor: null }; + }, + async fetchDetail() { + throw new Error("not implemented"); + }, + parse() { + throw new Error("not implemented"); + }, + }; +} + +describe("recipe-source-sync", () => { + const app = createApp(); + + async function signup(): Promise<{ profileId: number }> { + const res = await request.agent(app).post("/auth/signup").send(buildSignupPayload()); + return { profileId: res.body.id }; + } + + beforeEach(async () => { + await resetDatabase(); + clearRecipeSources(); + }); + + afterEach(() => { + clearRecipeSources(); + }); + + after(async () => { + await prisma.$disconnect(); + }); + + describe("syncRecipeSources", () => { + it("does nothing when the registry is empty", async () => { + await syncRecipeSources(prisma); + expect(await prisma.source.count()).to.equal(0); + }); + + it("creates a Source row per registered adapter", async () => { + registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source")); + + await syncRecipeSources(prisma); + + const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } }); + expect(source.name).to.equal("Fake Source"); + }); + + it("is idempotent — running it twice doesn't duplicate rows", async () => { + registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source")); + + await syncRecipeSources(prisma); + await syncRecipeSources(prisma); + + expect(await prisma.source.count()).to.equal(1); + }); + + it("updates the name when the adapter's own name changes between syncs", async () => { + registerRecipeSource(buildFakeAdapter("fakeSource", "Old Name")); + await syncRecipeSources(prisma); + clearRecipeSources(); + registerRecipeSource(buildFakeAdapter("fakeSource", "New Name")); + + await syncRecipeSources(prisma); + + const source = await prisma.source.findUniqueOrThrow({ where: { key: "fakeSource" } }); + expect(source.name).to.equal("New Name"); + }); + + it("never deletes a Source row whose key fell out of the registry", async () => { + registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source")); + await syncRecipeSources(prisma); + clearRecipeSources(); + + await syncRecipeSources(prisma); + + expect(await prisma.source.count()).to.equal(1); + }); + }); + + 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()); + }); + + it("returns an empty set for an empty externalIds list", async () => { + expect(await findImportedExternalIds(prisma, "fakeSource", [])).to.deep.equal(new Set()); + }); + + it("returns exactly the externalIds already imported from that source", 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({ + data: { + name: "Tarte", + authorId: profileId, + portions: 4, + sourceId: source.id, + externalId: "1", + }, + }); + // 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"])); + }); + + it("scopes matches to the given source — the same externalId from a different source doesn't count", async () => { + const { profileId } = await signup(); + registerRecipeSource(buildFakeAdapter("fakeSource", "Fake Source")); + registerRecipeSource(buildFakeAdapter("otherSource", "Other Source")); + await syncRecipeSources(prisma); + const otherSource = await prisma.source.findUniqueOrThrow({ where: { key: "otherSource" } }); + + await prisma.recipe.create({ + data: { + name: "Tarte", + authorId: profileId, + portions: 4, + sourceId: otherSource.id, + externalId: "1", + }, + }); + + expect(await findImportedExternalIds(prisma, "fakeSource", ["1"])).to.deep.equal(new Set()); + }); + }); + + describe("Recipe(sourceId, externalId) uniqueness", () => { + it("rejects importing the same source recipe twice", 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({ + data: { + name: "Tarte", + authorId: profileId, + portions: 4, + sourceId: source.id, + externalId: "1", + }, + }); + + let rejected = false; + try { + await prisma.recipe.create({ + data: { + name: "Tarte (again)", + authorId: profileId, + portions: 4, + sourceId: source.id, + externalId: "1", + }, + }); + } catch { + rejected = true; + } + expect(rejected).to.be.true; + }); + + it("allows any number of manually-authored recipes (both columns null)", async () => { + const { profileId } = await signup(); + + await prisma.recipe.create({ data: { name: "Une", authorId: profileId, portions: 4 } }); + await prisma.recipe.create({ data: { name: "Deux", authorId: profileId, portions: 4 } }); + + expect(await prisma.recipe.count()).to.equal(2); + }); + }); +});