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
+86
View File
@@ -0,0 +1,86 @@
/**
* PersonalReader progress on Library rows and synced stock on Store options.
* Synced values are the shapes the content service's jobs store (see
* services/content/src/sync/jobs/); a note's own values always win, and a
* note with nothing synced renders as before.
*/
import { describe, expect, it } from "vitest";
import { withSynced, type Note } from "../src/lib/content.ts";
import { itemDetails, readerProgress, type Item } from "../src/lib/library.ts";
import { optionGroups, type Line } from "../src/lib/store.ts";
const note = <T extends Note>(type: string, data: Record<string, unknown>, synced?: Record<string, unknown>) =>
withSynced({ path: `${type}/x.md`, type, name: "x", slug: "x", title: "X", body: "", acceptedAt: "", data: { type, ...data }, synced } as unknown as T);
describe("Library progress from PersonalReader", () => {
const base = { medium: "manhwa", state: "now", creator: "Someone", year: 2020 };
it("adds the chapter line to the details", () => {
const i = note<Item>("library-item", base, { chapterProgress: { title: "X", status: "now", chapter: 142, readAt: "2026-10-03T05:00:00.000Z" } });
expect(readerProgress(i)).toBe("Chapter 142, read Oct 3, 2026");
expect(itemDetails(i)).toEqual(["Someone", 2020, "Chapter 142, read Oct 3, 2026"]);
const book = note<Item>("library-item", { ...base, medium: "light novel" }, { bookProgress: { title: "X", percent: 62 } });
expect(readerProgress(book)).toBe("62% read");
});
it("lets the note's own progress win, and shows the same line when nothing is synced", () => {
const own = note<Item>("library-item", { ...base, progress: "16/18 volumes" }, { chapterProgress: { chapter: 3 } });
expect(itemDetails(own)).toEqual(["Someone", 2020, "16/18 volumes"]);
const bare = note<Item>("library-item", base);
expect(itemDetails(bare)).toEqual(["Someone", 2020]);
expect(readerProgress(note<Item>("library-item", base, { bookProgress: { percent: 0 } }))).toBeUndefined();
});
});
describe("Store options with synced stock", () => {
const lineData = (options: unknown[]) => ({ category: "Tabletop", mode: "made to order", channel: "here", summary: "", priceTiers: [], options });
const stock = {
filamentStock: [
{
group: "Filament",
options: [
{ name: "Jade White", hex: "#f5f5dc", inStock: true },
{ name: "Bambu Green", hex: "#00ae42", inStock: false },
{ name: "Charcoal", hex: "#2b2b2b", inStock: true },
],
},
],
};
it("is exactly the note's options when nothing is synced", () => {
const opts = [
{ group: "Filament", name: "Jade White", hex: "#ffffff", inStock: true },
{ group: "Finish", name: "Matte", inStock: false },
];
expect(optionGroups(note<Line>("product-line", lineData(opts)))).toEqual([
["Filament", [opts[0]]],
["Finish", [opts[1]]],
]);
// Left out with nothing synced: in stock, as before the field was optional.
expect(optionGroups(note<Line>("product-line", lineData([{ group: "Finish", name: "Gloss" }])))[0]![1][0]!.inStock).toBe(true);
});
it("sits under the note: note values win, gaps take the synced value, unlisted in-stock colors follow", () => {
const l = note<Line>(
"product-line",
lineData([
{ group: "Filament", name: "Jade White" },
{ group: "Filament", name: "Bambu Green", inStock: true },
{ group: "Filament", name: "Galaxy Black" },
{ group: "Finish", name: "Matte", inStock: true },
]),
stock,
);
const [[g, filament], [, finish]] = optionGroups(l) as [string, ReturnType<typeof optionGroups>[number][1]][];
expect(g).toBe("Filament");
expect(filament).toEqual([
{ group: "Filament", name: "Jade White", hex: "#f5f5dc", inStock: true },
// The note says in stock: the note wins over the source.
{ group: "Filament", name: "Bambu Green", hex: "#00ae42", inStock: true },
// Not at the source: out of stock.
{ group: "Filament", name: "Galaxy Black", inStock: false },
{ group: "Filament", name: "Charcoal", hex: "#2b2b2b", inStock: true },
]);
expect(finish).toEqual([{ group: "Finish", name: "Matte", inStock: true }]);
});
});