mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
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:
@@ -15,6 +15,8 @@ import { ApiError, type ContentApi, type Outcome } from "./client.ts";
|
||||
import { html, qs, type Html } from "./html.ts";
|
||||
import { layout, type Flash } from "./layout.ts";
|
||||
import * as pages from "./pages.ts";
|
||||
import * as inbox from "./inbox.ts";
|
||||
import { suggestionChange, suggestionsPanel } from "./suggestions.ts";
|
||||
|
||||
const css = readFileSync(new URL("../public/admin.css", import.meta.url), "utf8");
|
||||
|
||||
@@ -47,8 +49,13 @@ export function createAdminServer(api: ContentApi, opts: { now?: () => Date } =
|
||||
async function get(path: string, q: URLSearchParams): Promise<Page | Redirect> {
|
||||
switch (path) {
|
||||
case "/": {
|
||||
const [status, held, problems] = await Promise.all([api.status(), api.held(), api.problems()]);
|
||||
return { title: "Overview", body: pages.overview(status, held, problems) };
|
||||
const [status, held, problems, suggestions] = await Promise.all([
|
||||
api.status(),
|
||||
api.held(),
|
||||
api.problems(),
|
||||
api.suggestions().catch(() => []),
|
||||
]);
|
||||
return { title: "Overview", body: html`${pages.overview(status, held, problems)}${suggestionsPanel(suggestions)}` };
|
||||
}
|
||||
case "/syncs": {
|
||||
const job = q.get("job") ?? "";
|
||||
@@ -106,6 +113,16 @@ export function createAdminServer(api: ContentApi, opts: { now?: () => Date } =
|
||||
case "/problems":
|
||||
return { title: "Problems", body: pages.problemsView(await api.problems()) };
|
||||
}
|
||||
if (path === "/inbox") {
|
||||
const show = q.get("show") ?? "open";
|
||||
const { counts, submissions } = await api.submissions(show === "all" ? undefined : show === "handled");
|
||||
return { title: "Inbox", body: inbox.inboxList(submissions, counts, show) };
|
||||
}
|
||||
const inboxOne = path.match(/^\/inbox\/(\d+)$/);
|
||||
if (inboxOne) {
|
||||
const s = await api.submission(Number(inboxOne[1]));
|
||||
return { title: `${s.kind === "order" ? "Order" : "Inquiry"} from ${s.name}`, body: inbox.inboxOne(s) };
|
||||
}
|
||||
const review = path.match(/^\/review\/(\d+)$/);
|
||||
if (review) {
|
||||
const h = await api.heldOne(Number(review[1]));
|
||||
@@ -179,6 +196,17 @@ export function createAdminServer(api: ContentApi, opts: { now?: () => Date } =
|
||||
}
|
||||
return after("/capture", o, "Added to the Library.");
|
||||
}
|
||||
case "/suggestions/add": {
|
||||
const key = required(f, "key");
|
||||
const s = (await api.suggestions()).find((x) => x.key === key);
|
||||
if (!s) throw new ApiError(409, "That suggestion is gone: it was added or dismissed already.", null);
|
||||
const o = await api.apply(suggestionChange(s));
|
||||
return after("/", o, `Added ${s.title} to the Library as Now.`);
|
||||
}
|
||||
case "/suggestions/dismiss": {
|
||||
await api.dismissSuggestion(required(f, "key"));
|
||||
return new Redirect(`/${qs({ msg: "Dismissed. It will not be suggested again." })}`);
|
||||
}
|
||||
case "/store/category": {
|
||||
const [settings] = await api.fullNotes("store-settings");
|
||||
if (!settings) throw new ApiError(404, "There is no Store/_settings note yet.", null);
|
||||
@@ -207,6 +235,12 @@ export function createAdminServer(api: ContentApi, opts: { now?: () => Date } =
|
||||
return after("/store", o, "Saved.");
|
||||
}
|
||||
}
|
||||
const handled = path.match(/^\/inbox\/(\d+)\/handled$/);
|
||||
if (handled) {
|
||||
const id = Number(handled[1]);
|
||||
const s = await api.setHandled(id, f.get("handled") === "1");
|
||||
return new Redirect(`${s.handled ? "/inbox" : inbox.inboxHref(id)}${qs({ msg: s.handled ? `Marked ${s.name}'s ${s.kind} as handled.` : "Marked as not handled." })}`);
|
||||
}
|
||||
const decide = path.match(/^\/review\/(\d+)\/(accept|reject)$/);
|
||||
if (decide) {
|
||||
const id = Number(decide[1]);
|
||||
@@ -245,6 +279,21 @@ export function createAdminServer(api: ContentApi, opts: { now?: () => Date } =
|
||||
return;
|
||||
}
|
||||
|
||||
const inboxFile = url.pathname.match(/^\/inbox\/(\d+)\/attachment$/);
|
||||
if (method === "GET" && inboxFile) {
|
||||
// Always a download, never shown inline: a visitor chose this file.
|
||||
const r = await api.submissionFile(Number(inboxFile[1]));
|
||||
if (!r.ok || !r.body) return text(res, r.status === 404 ? 404 : 502, "No such attachment.");
|
||||
res.writeHead(200, {
|
||||
"content-type": r.headers.get("content-type") ?? "application/octet-stream",
|
||||
"content-disposition": r.headers.get("content-disposition") ?? "attachment",
|
||||
"x-content-type-options": "nosniff",
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
Readable.fromWeb(r.body as never).pipe(res);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST") {
|
||||
if (!sameOrigin(req)) return text(res, 403, "Form posts must come from the admin page itself.");
|
||||
const form = new URLSearchParams(await readBody(req));
|
||||
@@ -259,18 +308,21 @@ export function createAdminServer(api: ContentApi, opts: { now?: () => Date } =
|
||||
res.writeHead(303, { location: out.to });
|
||||
return res.end();
|
||||
}
|
||||
const held = (await api.held().catch(() => [])).length;
|
||||
page(res, 200, out, url, held);
|
||||
const [held, unhandled] = await Promise.all([
|
||||
api.held().then((h) => h.length).catch(() => 0),
|
||||
api.submissions(false).then((s) => s.counts.unhandled).catch(() => 0),
|
||||
]);
|
||||
page(res, 200, out, url, held, unhandled);
|
||||
}
|
||||
|
||||
function page(res: ServerResponse, status: number, p: Page, url: URL, held: number) {
|
||||
function page(res: ServerResponse, status: number, p: Page, url: URL, held: number, unhandled = 0) {
|
||||
const flash: Flash = {
|
||||
msg: url.searchParams.get("msg") ?? undefined,
|
||||
err: url.searchParams.get("err") ?? undefined,
|
||||
reasons: url.searchParams.getAll("r"),
|
||||
};
|
||||
res.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
||||
res.end(layout({ title: p.title, path: url.pathname, held, flash, body: p.body }).toString());
|
||||
res.end(layout({ title: p.title, path: url.pathname, held, inbox: unhandled, flash, body: p.body }).toString());
|
||||
}
|
||||
|
||||
return createServer((req, res) => {
|
||||
|
||||
Reference in New Issue
Block a user