Merge pull request #36 from kyuno053/feat/recipe-source-linking

feat(recipes): relie les recettes à leur source (sourceId + externalId)
This commit is contained in:
kyuno053 2026-08-20 07:48:52 +02:00 committed by GitHub
commit af8e1df701
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 326 additions and 7 deletions

View file

@ -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");

View file

@ -211,8 +211,20 @@ model PlanningItem {
// Recipes // 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 { model Source {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
key String @unique
name String name String
url String? url String?
@ -239,6 +251,17 @@ model Recipe {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
sourceId Int? @map("source_id") 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? description String?
picture String? picture String?
/// How many portions this recipe yields as written (its ingredient /// How many portions this recipe yields as written (its ingredient
@ -267,6 +290,7 @@ model Recipe {
favoritedBy RecipeFavorite[] favoritedBy RecipeFavorite[]
diets RecipeDiet[] diets RecipeDiet[]
@@unique([sourceId, externalId])
@@map("recipe") @@map("recipe")
} }

View file

@ -1,4 +1,5 @@
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
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";
// Standalone CLI entry point (not `src/db/prisma.ts` — that module pulls in // 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(); const prisma = new PrismaClient();
seedReferenceData(prisma) seedReferenceData(prisma)
.then(() => syncRecipeSources(prisma))
.then(() => prisma.$disconnect()) .then(() => prisma.$disconnect())
.catch(async (err) => { .catch(async (err) => {
console.error(err); console.error(err);

View file

@ -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<void> {
for (const adapter of listRecipeSources()) {
await prisma.source.upsert({
where: { key: adapter.key },
update: { name: adapter.name },
create: { key: adapter.key, name: adapter.name },
});
}
}
/**
* 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<Set<string>> {
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] : [])),
);
}

View file

@ -4,13 +4,12 @@ import type { RecipeSourceAdapter } from "./recipe-source-adapter.js";
* In-memory registry of every {@link RecipeSourceAdapter} (recipe-source-adapter.ts) * In-memory registry of every {@link RecipeSourceAdapter} (recipe-source-adapter.ts)
* this process knows about, keyed by `adapter.key`. Deliberately not * this process knows about, keyed by `adapter.key`. Deliberately not
* DB-backed an adapter *is* code (a website's fetch/parse logic can't * 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, * live in a database row) but the `Source` table (schema.prisma) is kept
* which records *where a saved recipe came from* (a name/url pair) once * in sync with it (see `syncRecipeSources`, recipe-source-sync.ts) so a
* the import pipeline actually persists one. The two are related but * saved `Recipe.sourceId` has a row to point at. Actually saving an
* distinct: this registry is "which sources can we import from right now", * imported recipe (setting `Recipe.sourceId`/`externalId`) is still future
* `Source` rows are "which sources a saved recipe cites" wiring the two * work for whichever module ends up driving the import pipeline this
* together is future work for whichever module ends up saving imported * registry only answers "which sources can we import from right now".
* recipes.
* *
* No adapter is registered here yet this file only provides the * No adapter is registered here yet this file only provides the
* mechanism; `registerRecipeSource` is meant to be called once per adapter * mechanism; `registerRecipeSource` is meant to be called once per adapter

View file

@ -1,4 +1,5 @@
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";
// Single TRUNCATE ... CASCADE covers FK ordering and resets identity // 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 // truncating it, so every test starts from the same realistic reference
// data the real app seeds (`prisma/seed.ts`) rather than empty tables — // data the real app seeds (`prisma/seed.ts`) rather than empty tables —
// tests exercising dietId/allergyIds/unitId need real rows to reference. // 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() { export async function resetDatabase() {
await prisma.$executeRawUnsafe(` await prisma.$executeRawUnsafe(`
TRUNCATE TABLE TRUNCATE TABLE
@ -18,4 +22,5 @@ export async function resetDatabase() {
RESTART IDENTITY CASCADE; RESTART IDENTITY CASCADE;
`); `);
await seedReferenceData(prisma); await seedReferenceData(prisma);
await syncRecipeSources(prisma);
} }

View file

@ -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);
});
});
});