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
+49
View File
@@ -5,6 +5,7 @@
*/
import type { Note } from "./content.ts";
import type { SiteIndex } from "./site.ts";
import { formatDate } from "./site.ts";
export type Item = Note<"library-item">;
export type Game = Note<"board-game">;
@@ -47,6 +48,29 @@ export function byStanding(a: Item, b: Item): number {
);
}
/** Progress PersonalReader syncs onto a linked item (see withSynced in content.ts). */
interface ReaderSynced {
chapterProgress?: { chapter?: number; readAt?: string };
bookProgress?: { percent?: number; readAt?: string };
}
/** "Chapter 42, read Oct 3, 2026" or "62% read", from PersonalReader; undefined when nothing is synced. */
export function readerProgress(item: Item): string | undefined {
const d = item.data as ReaderSynced;
const c = d.chapterProgress;
const b = d.bookProgress;
const what = c?.chapter ? `Chapter ${c.chapter}` : b && typeof b.percent === "number" && b.percent > 0 ? `${b.percent}% read` : undefined;
if (!what) return undefined;
const at = (c?.chapter ? c.readAt : b?.readAt)?.slice(0, 10);
return at ? `${what}, read ${formatDate(at)}` : what;
}
/** The row's details line. The note's own `progress` wins over the synced one. */
export const itemDetails = (item: Item): (string | number)[] => {
const d = item.data;
return [d.creator, d.year, d.platform, d.progress || readerProgress(item), d.group].filter((x): x is string | number => x !== undefined && x !== "");
};
export interface LibraryFilter {
type?: MediaKey;
q: string;
@@ -152,6 +176,31 @@ export function gameShows(g: Game, f: ShelfFilter, text: string): boolean {
return !f.q || text.includes(f.q);
}
/** Values BoardGameGeek syncs onto a game (see withSynced in content.ts). */
interface GameSynced {
minPlayers?: number;
maxPlayers?: number;
playTime?: number;
minPlayTime?: number;
maxPlayTime?: number;
}
const range = (lo?: number, hi?: number) => (lo && hi && lo !== hi ? `${lo}–${hi}` : String(lo || hi || ""));
/** "2–4 players", "1 player" or undefined when BoardGameGeek has not been synced. */
export function gamePlayers(g: Game): string | undefined {
const d = g.data as GameSynced;
const r = range(d.minPlayers, d.maxPlayers);
return r ? `${r} ${r === "1" ? "player" : "players"}` : undefined;
}
/** "60–90 min", "45 min" or undefined. */
export function gamePlayTime(g: Game): string | undefined {
const d = g.data as GameSynced;
const r = d.minPlayTime && d.maxPlayTime ? range(d.minPlayTime, d.maxPlayTime) : range(d.playTime);
return r ? `${r} min` : undefined;
}
/** Every "how it plays" value on the shelf, A to Z. */
export const playStyles = (games: Game[]) =>
[...new Set(games.flatMap((g) => g.data.howItPlays ?? []))].sort((a, b) => a.localeCompare(b));