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
+111
View File
@@ -0,0 +1,111 @@
/**
* Bambuddy: filament colors in stock, for the Store's Filament options.
* Hourly. Read only.
*
* UNVERIFIED: Bambuddy's API is not documented anywhere this project has, and
* no request has been made to a real Bambuddy. The endpoint path is a setting
* (BAMBUDDY_SPOOLS_PATH) and everything that depends on the response's shape
* is in `mapBambuddySpools` below, so fixing it against the real API is a
* change to one function. Check it against a live Bambuddy before relying on
* it (docs/content-service.md, "Sync jobs").
*/
import type { SyncJob } from "../types.ts";
import { mergeOptions, normalizeHex, storeOnLines, type StockOption } from "./stock.ts";
export const BAMBUDDY_DEFAULT_PATH = "/api/v1/inventory/spools";
export interface BambuddyOptions {
url: string;
/** Path of the spool or filament inventory endpoint. */
path?: string;
/** Sent as `Authorization: Bearer`, or in `authHeader` when that is set. */
apiKey?: string;
authHeader?: string;
/** Option group the colors feed. Default Filament. */
group?: string;
/** Only these materials (like PLA, PETG), when set. */
materials?: string[];
/** A spool with less filament than this, in grams, counts as out. Default 50. */
minGrams?: number;
everyMinutes?: number;
healthcheckUrl?: string;
}
export interface Spool {
name: string;
hex?: string;
material?: string;
/** Grams left, when the source says. */
remaining?: number;
}
const pick = (o: Record<string, unknown>, keys: string[]) => {
for (const k of keys) {
const v = o[k];
if (v !== undefined && v !== null && v !== "") return v;
}
return undefined;
};
/**
* THE ONE PLACE that knows Bambuddy's response shape (a guess; see above).
* Accepts a list, or an object holding one under items, spools, data or
* results, and reads each spool's color name, hex, material and grams left
* under the common spellings. Archived or empty-named entries are skipped.
*/
export function mapBambuddySpools(json: unknown): Spool[] {
const root = json as Record<string, unknown> | unknown[];
const list = Array.isArray(root)
? root
: (["items", "spools", "data", "results"].map((k) => (root as Record<string, unknown>)?.[k]).find(Array.isArray) as unknown[] | undefined);
if (!list) throw new Error("Bambuddy answered with no list of spools");
const out: Spool[] = [];
for (const raw of list) {
if (!raw || typeof raw !== "object") continue;
const o = raw as Record<string, unknown>;
if (o.archived === true || o.deleted === true) continue;
const name = pick(o, ["color_name", "colorName", "color", "name"]);
if (typeof name !== "string" || !name.trim()) continue;
const s: Spool = { name: name.trim() };
const hex = normalizeHex(pick(o, ["color_hex", "colorHex", "hex", "rgba", "color_code"]));
if (hex) s.hex = hex;
const material = pick(o, ["material", "filament_type", "type"]);
if (typeof material === "string") s.material = material.trim();
const grams = Number(pick(o, ["remaining_weight", "remainingWeight", "weight_remaining", "remaining", "remaining_grams"]));
if (Number.isFinite(grams)) s.remaining = grams;
out.push(s);
}
return out;
}
/** Spools as Filament options: in stock when some spool of that color has enough left. */
export function filamentOptions(spools: Spool[], o: Pick<BambuddyOptions, "materials" | "minGrams"> = {}): StockOption[] {
const materials = o.materials?.length ? new Set(o.materials.map((m) => m.toLowerCase())) : null;
const min = o.minGrams ?? 50;
return mergeOptions(
spools
.filter((s) => !materials || (s.material && materials.has(s.material.toLowerCase())))
.map((s) => ({ name: s.name, ...(s.hex ? { hex: s.hex } : {}), inStock: s.remaining === undefined || s.remaining >= min })),
);
}
export function bambuddyJob(o: BambuddyOptions): SyncJob {
const group = o.group ?? "Filament";
return {
name: "bambuddy",
everyMinutes: o.everyMinutes ?? 60,
healthcheckUrl: o.healthcheckUrl,
async run(ctx) {
const headers: Record<string, string> = { accept: "application/json" };
if (o.apiKey) headers[o.authHeader?.toLowerCase() || "authorization"] = o.authHeader ? o.apiKey : `Bearer ${o.apiKey}`;
const url = `${o.url.replace(/\/$/, "")}/${(o.path ?? BAMBUDDY_DEFAULT_PATH).replace(/^\//, "")}`;
const res = await ctx.fetch(url, { headers });
if (res.status === 401 || res.status === 403) throw new Error(`Bambuddy refused the request (${res.status}); check BAMBUDDY_API_KEY`);
if (!res.ok) throw new Error(`Bambuddy answered ${res.status}; check BAMBUDDY_SPOOLS_PATH`);
const options = filamentOptions(mapBambuddySpools(await res.json()), o);
const lines = storeOnLines(ctx, "filamentStock", [{ group, options }]);
const inStock = options.filter((x) => x.inStock).length;
return { message: `${inStock} of ${options.length} colors in stock, on ${lines} lines`, counts: { colors: options.length, inStock, lines } };
},
};
}