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
+79
View File
@@ -0,0 +1,79 @@
/**
* Library suggestions: a series Greg moved to Reading in PersonalReader that
* has no Library item yet. The Suwayomi sync job keeps the list in its cache
* (entry `_suggestions`); this module serves it to the admin page, leaves
* out series that now have a Library item, and remembers dismissals in a
* small table, so a dismissed series stays dismissed across runs.
*
* Adding one is not done here: the admin page creates the Library item with
* the normal write API (`POST /api/changes`), so every rule applies.
*
* - `GET /api/suggestions` (editor, admin, agent): `[{ key, anilistId, title, medium, cover? }]`
* - `POST /api/suggestions/<key>/dismiss` (editor, admin)
*/
import type { IncomingMessage, ServerResponse } from "node:http";
import type { DatabaseSync } from "node:sqlite";
import { can, type Role } from "./config.ts";
import type { Store } from "./store.ts";
import { SUGGESTIONS_KEY } from "./sync/jobs/reader.ts";
import type { Suggestion } from "./sync/jobs/suwayomi.ts";
/** Jobs whose cache may hold suggestions. */
const SOURCES = ["suwayomi"];
export interface SuggestionView extends Suggestion {
/** Stable id for dismissing: `anilist-<id>`. */
key: string;
}
const tables = new WeakMap<DatabaseSync, true>();
function ready(db: DatabaseSync) {
if (!tables.has(db)) {
db.exec(`create table if not exists suggestion_dismissals (key text primary key, at text not null);`);
tables.set(db, true);
}
return db;
}
export function listSuggestions(store: Store): SuggestionView[] {
const db = ready(store.db);
const dismissed = new Set((db.prepare("select key from suggestion_dismissals").all() as { key: string }[]).map((r) => r.key));
const linked = new Set(store.list({ type: "library-item" }).map((n) => n.data.anilistId).filter((id) => typeof id === "number"));
const out = new Map<string, SuggestionView>();
for (const job of SOURCES) {
const items = (store.syncCache.get(job, SUGGESTIONS_KEY)?.values.items as Suggestion[] | undefined) ?? [];
for (const s of items) {
if (typeof s?.anilistId !== "number" || linked.has(s.anilistId)) continue;
const key = `anilist-${s.anilistId}`;
if (!dismissed.has(key) && !out.has(key)) out.set(key, { ...s, key });
}
}
return [...out.values()].sort((a, b) => a.title.localeCompare(b.title));
}
export function dismissSuggestion(store: Store, key: string, at = new Date().toISOString()) {
ready(store.db).prepare("insert into suggestion_dismissals (key, at) values (?, ?) on conflict(key) do nothing").run(key, at);
}
export async function handleSuggestions(
_req: IncomingMessage,
res: ServerResponse,
o: { path: string; method: string; role: Role; store: Store },
): Promise<boolean> {
const send = (status: number, body: unknown) => {
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
res.end(JSON.stringify(body));
return true;
};
if (o.path === "/api/suggestions" && o.method === "GET") {
if (!can.readDrafts(o.role)) return send(403, { error: `The ${o.role} token cannot do this` });
return send(200, listSuggestions(o.store));
}
const m = o.path.match(/^\/api\/suggestions\/(anilist-\d{1,10})\/dismiss$/);
if (m && o.method === "POST") {
if (!can.review(o.role)) return send(403, { error: `The ${o.role} token cannot do this` });
dismissSuggestion(o.store, m[1]!);
return send(200, { ok: true });
}
return false;
}