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
@@ -11,7 +11,7 @@ import StarChartArt from "@gk/ui/components/StarChartArt.astro";
import StatusPill from "@gk/ui/components/StatusPill.astro";
import StatusChip from "../StatusChip.astro";
import { formatDate, mediaSrc, type SiteIndex } from "../../lib/site.ts";
import type { Game } from "../../lib/library.ts";
import { gamePlayers, gamePlayTime, type Game } from "../../lib/library.ts";
interface Props {
game: Game;
@@ -22,6 +22,8 @@ const { game, site, hidden } = Astro.props;
const d = game.data;
const plays = d.howItPlays ?? [];
const status = site.statusFor(game);
// Players and play time sync from BoardGameGeek; nothing shows until they have.
const facts = [gamePlayers(game), gamePlayTime(game)].filter(Boolean);
---
<li
id={game.slug}
@@ -43,6 +45,7 @@ const status = site.statusFor(game);
{d.favorite && <><Star size={12} /><span class="gk-visually-hidden">Favorite</span></>}
</h3>
<p class="blurb">{d.blurb}</p>
{facts.length > 0 && <p class="facts">{facts.join(" · ")}</p>}
{
(status || d.wantToPlay || !d.owned || d.lastPlayed) && (
<div class="states">
@@ -110,6 +113,11 @@ const status = site.statusFor(game);
line-height: 1.45;
color: var(--gk-muted);
}
.facts {
margin: 0;
font-size: 12px;
color: var(--gk-soft);
}
.states {
display: flex;
flex-wrap: wrap;
@@ -10,7 +10,7 @@ import Star from "@gk/ui/components/Star.astro";
import StarChartArt from "@gk/ui/components/StarChartArt.astro";
import StatusChip from "../StatusChip.astro";
import { mediaSrc, type SiteIndex } from "../../lib/site.ts";
import { mediaGroupOf, mediumLabel, sectionOf, type Item } from "../../lib/library.ts";
import { itemDetails, mediaGroupOf, mediumLabel, sectionOf, type Item } from "../../lib/library.ts";
interface Props {
item: Item;
@@ -22,7 +22,7 @@ const d = item.data;
const status = site.statusFor(item);
const post = site.backlinks(item, { type: "post" })[0];
const postHref = post && site.href(post);
const details = [d.creator, d.year, d.platform, d.progress, d.group].filter((x) => x !== undefined && x !== "");
const details = itemDetails(item);
---
<li
id={item.slug}
@@ -2,12 +2,15 @@
/**
* The order request form. Posts to /api/orders. Field names:
* name, email, line (a line slug), option.<line slug>.<group> (an option
* name), quantity, details, notes, terms ("accepted").
* name), quantity, details, notes, terms ("accepted"), plus the honeypot and,
* when a Turnstile site key is set, the widget's cf-turnstile-response.
* `values` and `errors` refill the form after a post was turned down.
* Without JS every line's options show, labelled with their line; a small
* script shows only the chosen line's options and terms.
*/
import Button from "@gk/ui/components/Button.astro";
import Field from "@gk/ui/components/Field.astro";
import { HONEYPOT } from "../../lib/forms.ts";
import Star from "@gk/ui/components/Star.astro";
import { optionGroups, type Line, type Term } from "../../lib/store.ts";
import Terms from "./Terms.astro";
@@ -16,20 +19,23 @@ interface Props {
lines: Line[];
selected?: string;
storeTerms: Term[];
values?: Record<string, string>;
errors?: Record<string, string>;
turnstileKey?: string;
}
const { lines, selected, storeTerms } = Astro.props;
const { lines, selected, storeTerms, values: v = {}, errors: err = {}, turnstileKey } = Astro.props;
const chosen = lines.find((l) => l.slug === selected)?.slug ?? (lines.length === 1 ? lines[0]!.slug : undefined);
const slugPart = (x: string) => x.toLowerCase().replace(/[^a-z0-9]+/g, "-");
---
<form method="post" action="/api/orders" class="order" aria-labelledby="order-title" data-order-form>
<h2 id="order-title" class="gk-visually-hidden">Order request form</h2>
<div class="row">
<Field label="Name" name="name" autocomplete="name" required class="half" />
<Field label="Email" name="email" type="email" autocomplete="email" required class="half" />
<Field label="Name" name="name" autocomplete="name" required maxlength={120} class="half" value={v.name} error={err.name} />
<Field label="Email" name="email" type="email" autocomplete="email" required maxlength={200} class="half" value={v.email} error={err.email} />
</div>
<fieldset>
<legend>What would you like?</legend>
<legend>What would you like?{err.line && <span class="err"> {err.line}</span>}</legend>
<div class="choices">
{
lines.map((l) => (
@@ -49,11 +55,12 @@ const slugPart = (x: string) => x.toLowerCase().replace(/[^a-z0-9]+/g, "-");
<fieldset class="opts" data-line={l.slug} hidden={chosen ? l.slug !== chosen : undefined} disabled={chosen ? l.slug !== chosen : undefined}>
<legend>
{group} {lines.length > 1 && <span class="for">({l.title} only)</span>}
{err[`option.${l.slug}.${group}`] && <span class="err"> {err[`option.${l.slug}.${group}`]}</span>}
</legend>
<div class="choices">
{opts.map((o, i) => (
<label class:list={["gk-filter", "swatch", { out: !o.inStock }]}>
<input type="radio" name={`option.${l.slug}.${group}`} value={o.name} disabled={!o.inStock} id={`opt-${l.slug}-${slugPart(group)}-${i}`} />
<input type="radio" name={`option.${l.slug}.${group}`} value={o.name} disabled={!o.inStock} checked={o.inStock && v[`option.${l.slug}.${group}`] === o.name} id={`opt-${l.slug}-${slugPart(group)}-${i}`} />
{o.hex && <span class="dot" style={`background:${o.hex}`} aria-hidden="true" />}
{o.name}
{!o.inStock && <span class="stock">(out of stock)</span>}
@@ -65,16 +72,18 @@ const slugPart = (x: string) => x.toLowerCase().replace(/[^a-z0-9]+/g, "-");
)
}
<Field label="Approximate quantity" name="quantity" placeholder="e.g. 75 cards, or 1 deck box" required />
<Field
label="Card list or deck box spec"
name="details"
as="textarea"
rows={6}
hint="For cards, paste your full card list, one per line."
class="big"
/>
<Field label="Notes (optional)" name="notes" as="textarea" rows={3} />
<Field label="Approximate quantity" name="quantity" placeholder="e.g. 75 cards, or 1 deck box" required maxlength={200} value={v.quantity} error={err.quantity} />
<div class="gk-label big">
<label class="gk-label-text" for="f-details">Card list or deck box spec</label>
<textarea id="f-details" name="details" class="gk-field" rows={6} maxlength={20000} aria-describedby="f-details-hint">{v.details ?? ""}</textarea>
<span class="gk-hint" id="f-details-hint">For cards, paste your full card list, one per line.</span>
{err.details && <span class="err">{err.details}</span>}
</div>
<div class="gk-label">
<label class="gk-label-text" for="f-notes">Notes (optional)</label>
<textarea id="f-notes" name="notes" class="gk-field" rows={3} maxlength={5000}>{v.notes ?? ""}</textarea>
{err.notes && <span class="err">{err.notes}</span>}
</div>
{
(lines.some((l) => l.data.terms?.length) || storeTerms.length > 0) && (
@@ -102,9 +111,18 @@ const slugPart = (x: string) => x.toLowerCase().replace(/[^a-z0-9]+/g, "-");
<input type="checkbox" name="terms" value="accepted" required />
<span>I have read and accept the order terms for this request.</span>
</label>
{err.terms && <span class="err">{err.terms}</span>}
<div class="hp" aria-hidden="true">
<label>Leave this empty <input type="text" name={HONEYPOT} tabindex="-1" autocomplete="off" /></label>
</div>
{turnstileKey && <div class="cf-turnstile" data-sitekey={turnstileKey} data-theme="dark" data-response-field-name="cf-turnstile-response"></div>}
{turnstileKey && <noscript><span class="tnote">The bot check needs JavaScript. Turn it on for this page to send the form.</span></noscript>}
<div class="submit">
<Button type="submit">Send order request</Button>
{turnstileKey && <span class="tnote">Protected by Cloudflare Turnstile.</span>}
</div>
</form>
@@ -280,6 +298,22 @@ const slugPart = (x: string) => x.toLowerCase().replace(/[^a-z0-9]+/g, "-");
align-items: center;
gap: 12px;
}
.err {
font-size: 12px;
font-weight: 400;
color: #f0857a;
}
.tnote {
font-size: 13px;
color: var(--gk-muted);
}
.hp {
position: absolute;
left: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
}
@media (max-width: 640px) {
.order {
padding: 22px 16px;