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:
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Homebox: leather and paper materials in stock, for the Store's Leather and
|
||||
* Paper options. Hourly. Read only.
|
||||
*
|
||||
* API (Homebox's REST API, v1):
|
||||
* - `POST /api/v1/users/login` with username and password gives a token
|
||||
* (already prefixed `Bearer `), sent as `Authorization`.
|
||||
* - `GET /api/v1/labels` and `GET /api/v1/locations` name the ids to filter on.
|
||||
* - `GET /api/v1/items?labels=<id>&page=&pageSize=` (or `locations=<id>`)
|
||||
* lists the items, each with name, quantity and description.
|
||||
*
|
||||
* Which items count is a setting: HOMEBOX_LABELS (like `leather,paper`) and
|
||||
* HOMEBOX_LOCATIONS, each entry `name` or `name=Group`. Without `=Group` the
|
||||
* option group is the name capitalized (leather: Leather). An item is in
|
||||
* stock when its quantity is above zero; a `#rrggbb` in its description
|
||||
* becomes the swatch color.
|
||||
*
|
||||
* Written from Homebox's API documentation; not checked against a live
|
||||
* server from here (see docs/content-service.md).
|
||||
*/
|
||||
import type { SyncContext, SyncJob } from "../types.ts";
|
||||
import { mergeOptions, normalizeHex, storeOnLines, type StockGroup, type StockOption } from "./stock.ts";
|
||||
|
||||
export interface HomeboxFilter {
|
||||
/** Label or location name in Homebox, matched case-insensitively. */
|
||||
name: string;
|
||||
/** Store option group it feeds. */
|
||||
group: string;
|
||||
}
|
||||
|
||||
export interface HomeboxOptions {
|
||||
url: string;
|
||||
user: string;
|
||||
pass: string;
|
||||
labels: HomeboxFilter[];
|
||||
locations?: HomeboxFilter[];
|
||||
everyMinutes?: number;
|
||||
healthcheckUrl?: string;
|
||||
}
|
||||
|
||||
/** `leather,paper=Paper goods` as filters. */
|
||||
export function parseFilters(raw: string | undefined): HomeboxFilter[] {
|
||||
return (raw ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((s) => {
|
||||
const [name, group] = s.split("=").map((x) => x.trim());
|
||||
return { name: name!, group: group || name!.charAt(0).toUpperCase() + name!.slice(1) };
|
||||
});
|
||||
}
|
||||
|
||||
export interface HomeboxItem {
|
||||
name: string;
|
||||
quantity: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** `GET /api/v1/items`: `{ items, page, pageSize, total }`. */
|
||||
export function parseItems(json: unknown): { items: HomeboxItem[]; total: number } {
|
||||
const r = json as { items?: { name?: string; quantity?: number; description?: string; archived?: boolean }[]; total?: number };
|
||||
if (!Array.isArray(r?.items)) throw new Error("Homebox answered with no list of items");
|
||||
const items = r.items
|
||||
.filter((i) => i && typeof i.name === "string" && i.name.trim() && i.archived !== true)
|
||||
.map((i) => ({ name: i.name!.trim(), quantity: Number(i.quantity ?? 0) || 0, ...(i.description ? { description: i.description } : {}) }));
|
||||
return { items, total: Number(r.total ?? items.length) };
|
||||
}
|
||||
|
||||
/** `GET /api/v1/labels` or `/locations`: `[{ id, name }]` to a name to id map. */
|
||||
export function parseNamed(json: unknown): Map<string, string> {
|
||||
const list = Array.isArray(json) ? json : ((json as { items?: unknown[] })?.items ?? []);
|
||||
const out = new Map<string, string>();
|
||||
for (const x of list as { id?: string; name?: string }[]) if (x?.id && x.name) out.set(x.name.trim().toLowerCase(), x.id);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function materialOptions(items: HomeboxItem[]): StockOption[] {
|
||||
return mergeOptions(
|
||||
items.map((i) => {
|
||||
const hex = normalizeHex(/#[0-9a-f]{6}\b/i.exec(i.description ?? "")?.[0]);
|
||||
return { name: i.name, ...(hex ? { hex } : {}), inStock: i.quantity > 0 };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function call(ctx: SyncContext, url: string, init: RequestInit = {}) {
|
||||
const res = await ctx.fetch(url, init);
|
||||
if (res.status === 401 || res.status === 403) throw new Error(`Homebox refused the request (${res.status}); check HOMEBOX_USER and HOMEBOX_PASS`);
|
||||
if (!res.ok) throw new Error(`Homebox answered ${res.status}`);
|
||||
return res.json() as Promise<unknown>;
|
||||
}
|
||||
|
||||
export function homeboxJob(o: HomeboxOptions): SyncJob {
|
||||
const api = (path: string) => `${o.url.replace(/\/$/, "")}/api/v1${path}`;
|
||||
return {
|
||||
name: "homebox",
|
||||
everyMinutes: o.everyMinutes ?? 60,
|
||||
healthcheckUrl: o.healthcheckUrl,
|
||||
async run(ctx) {
|
||||
const login = (await call(ctx, api("/users/login"), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({ username: o.user, password: o.pass, stayLoggedIn: false }),
|
||||
})) as { token?: string };
|
||||
if (!login?.token) throw new Error("Homebox login gave no token");
|
||||
const headers = { accept: "application/json", authorization: /^Bearer /i.test(login.token) ? login.token : `Bearer ${login.token}` };
|
||||
|
||||
const filters: { kind: "labels" | "locations"; f: HomeboxFilter }[] = [
|
||||
...o.labels.map((f) => ({ kind: "labels" as const, f })),
|
||||
...(o.locations ?? []).map((f) => ({ kind: "locations" as const, f })),
|
||||
];
|
||||
const ids = { labels: new Map<string, string>(), locations: new Map<string, string>() };
|
||||
for (const kind of ["labels", "locations"] as const) {
|
||||
if (filters.some((x) => x.kind === kind)) ids[kind] = parseNamed(await call(ctx, api(`/${kind}`), { headers }));
|
||||
}
|
||||
|
||||
const byGroup = new Map<string, HomeboxItem[]>();
|
||||
const unknown: string[] = [];
|
||||
for (const { kind, f } of filters) {
|
||||
const id = ids[kind].get(f.name.toLowerCase());
|
||||
if (!id) {
|
||||
unknown.push(f.name);
|
||||
continue;
|
||||
}
|
||||
const items = byGroup.get(f.group) ?? [];
|
||||
for (let page = 1; page <= 20; page++) {
|
||||
const p = new URLSearchParams({ [kind]: id, page: String(page), pageSize: "100" });
|
||||
const r = parseItems(await call(ctx, api(`/items?${p}`), { headers }));
|
||||
items.push(...r.items);
|
||||
if (r.items.length < 100 || page * 100 >= r.total) break;
|
||||
}
|
||||
byGroup.set(f.group, items);
|
||||
}
|
||||
// A filter naming nothing in Homebox is a setup mistake: fail rather than mark everything out.
|
||||
if (unknown.length) throw new Error(`No Homebox label or location named ${unknown.join(", ")}`);
|
||||
|
||||
const groups: StockGroup[] = [...byGroup].map(([group, items]) => ({ group, options: materialOptions(items) }));
|
||||
const lines = storeOnLines(ctx, "materialStock", groups);
|
||||
const counts: Record<string, number> = { lines };
|
||||
for (const g of groups) counts[g.group.toLowerCase()] = g.options.filter((x) => x.inStock).length;
|
||||
return { message: `${groups.map((g) => `${g.group}: ${g.options.filter((x) => x.inStock).length} in stock`).join(", ")}; on ${lines} lines`, counts };
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user