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
+16 -8
View File
@@ -52,6 +52,12 @@ export interface RuleContext {
settings: RuleSettings;
}
/**
* Authors held to the agent rules. A sync job writes as `sync` and may do
* nothing an agent cannot: the same contact, publish, edit and batch limits.
*/
export const isLimited = (a: Author) => a === "agent" || a === "sync";
export type Verdict = { decision: "accept" } | { decision: "hold"; reasons: HeldReason[] };
/** Strings that look like credentials. Matching is on new text only. */
@@ -125,26 +131,28 @@ export function evaluate(p: Proposed, ctx: RuleContext): Verdict {
for (const [what, re] of SECRET_PATTERNS) {
if (newMatches(re, p.text, before).length) reasons.push({ code: "secret", message: `The note now contains what looks like ${what}` });
}
if (p.author === "agent") {
const who = p.author === "sync" ? "A sync" : "An agent";
if (isLimited(p.author)) {
const allowed = new Set(settings.allowedEmails.map((e) => e.toLowerCase()));
const emails = newMatches(EMAIL, p.text, before).filter((e) => !allowed.has(e.toLowerCase()));
if (emails.length) reasons.push({ code: "contact", message: `An agent added an email address: ${emails.join(", ")}` });
if (newMatches(PHONE, p.text, before).length) reasons.push({ code: "contact", message: "An agent added a phone number" });
if (emails.length) reasons.push({ code: "contact", message: `${who} added an email address: ${emails.join(", ")}` });
if (newMatches(PHONE, p.text, before).length) reasons.push({ code: "contact", message: `${who} added a phone number` });
}
// 6. Agents stay inside their lane.
if (p.author === "agent") {
if (live && live.author !== "agent" && live.data.agentEdits !== true) {
reasons.push({ code: "agent-edit", message: "An agent changed a note you wrote, which needs your review" });
if (isLimited(p.author)) {
// A sync may change notes it wrote itself; anything else needs agentEdits.
if (live && live.author !== p.author && live.data.agentEdits !== true) {
reasons.push({ code: "agent-edit", message: `${who} changed a note you wrote, which needs your review` });
}
const publishing = data.publish === true && !(live?.publish ?? false);
if (publishing && !settings.agentAutoPublish.includes(p.checked.type)) {
reasons.push({ code: "agent-publish", message: `An agent wants to publish a ${p.checked.type}, which needs your approval` });
reasons.push({ code: "agent-publish", message: `${who} wants to publish a ${p.checked.type}, which needs your approval` });
}
if (ctx.recentAgentChanges >= settings.agentBatchLimit) {
reasons.push({
code: "batch",
message: `More than ${settings.agentBatchLimit} agent changes in ${settings.agentBatchWindowMinutes} minutes`,
message: `More than ${settings.agentBatchLimit} ${p.author} changes in ${settings.agentBatchWindowMinutes} minutes`,
});
}
}