Phase 6: sync jobs, inquiry and order forms

- Sync scheduler in the content service with a per-job cache (sync_cache),
  sync_runs reporting and Healthchecks pings. Jobs: Archidekt, BoardGameGeek,
  cover art (Open Library, TMDB, AniList), Suwayomi and Kavita reading
  progress, Bambuddy filament and Homebox material stock. A note's own value
  always wins over a synced one.
- PersonalReader: updates only Library notes with a matching anilistId,
  changes to Greg's notes are held for review; Reading series with no note
  show as suggestions on the admin Overview.
- /api/inquiries and /api/orders: Turnstile, rate limits, a saved copy in the
  content service (intake token) plus email over SMTP. Orders are re-checked
  against the live notes: accepted only when the category's ordersOpen is
  true and the line has capacity.
- Admin Inbox for saved submissions. Options' inStock is optional so synced
  stock can fill it. All settings from process.env; .env.example per service.
- Sources could not be reached from the build environment; parsers are tested
  against fixtures labelled as not live.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
This commit is contained in:
Claude
2026-10-11 23:50:06 +00:00
parent 60dfeecd7f
commit e3b0cf60c5
76 changed files with 6050 additions and 86 deletions
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { withSynced, type Note } from "../src/lib/content.ts";
import { gamePlayers, gamePlayTime, type Game } from "../src/lib/library.ts";
import { deckArt, deckColors } from "../src/lib/world.ts";
const note = <T extends Note>(type: string, data: Record<string, unknown>, synced?: Record<string, unknown>) =>
({ path: `${type}/x.md`, type, name: "x", slug: "x", title: "X", body: "", acceptedAt: "", data: { type, ...data }, synced }) as unknown as T;
describe("synced values", () => {
it("sit under the note's own values: the note always wins", () => {
const n = withSynced(note("library-item", { cover: "[[mine.jpg]]", title: "X" }, { cover: "https://x/cover.jpg", year: 2006 }));
expect(n.data).toMatchObject({ cover: "[[mine.jpg]]", year: 2006 });
const bare = withSynced(note("library-item", { title: "X" }, { cover: "https://x/cover.jpg" }));
expect((bare.data as { cover?: string }).cover).toBe("https://x/cover.jpg");
});
it("leave a note with nothing synced exactly as it came", () => {
const n = note("board-game", { bggId: 1, blurb: "b" }, {});
expect(withSynced(n)).toBe(n);
const none = note<Game>("board-game", { bggId: 1, blurb: "b" });
expect(withSynced(none)).toBe(none);
expect([gamePlayers(none), gamePlayTime(none)]).toEqual([undefined, undefined]);
expect(deckArt(note("deck", { game: "[[Magic]]" }))).toBeUndefined();
expect(deckColors(note("deck", { game: "[[Magic]]" }))).toEqual([]);
});
it("give the Shelf players and play time, and the decks art and colors", () => {
const g = withSynced(note<Game>("board-game", { bggId: 1, blurb: "b" }, { minPlayers: 1, maxPlayers: 5, playTime: 70, minPlayTime: 40, maxPlayTime: 70 }));
expect([gamePlayers(g), gamePlayTime(g)]).toEqual(["1–5 players", "40–70 min"]);
const solo = withSynced(note<Game>("board-game", { bggId: 1, blurb: "b" }, { minPlayers: 1, maxPlayers: 1, playTime: 30 }));
expect([gamePlayers(solo), gamePlayTime(solo)]).toEqual(["1 player", "30 min"]);
const d = withSynced(note<Note<"deck">>("deck", { game: "[[Magic]]" }, { commanderArt: "https://cards.scryfall.io/a.jpg", colors: ["W", "U", "G"] }));
expect(deckArt(d)).toBe("https://cards.scryfall.io/a.jpg");
expect(deckColors(d)).toEqual(["White", "Blue", "Green"]);
const own = withSynced(note<Note<"deck">>("deck", { game: "[[Magic]]", cover: "[[deck.jpg]]" }, { commanderArt: "https://cards.scryfall.io/a.jpg" }));
expect(deckArt(own)).toBe("/media/deck.jpg");
});
});