mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
e3b0cf60c5
- 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
80 lines
3.4 KiB
TypeScript
80 lines
3.4 KiB
TypeScript
/**
|
|
* Library suggestions: a series Greg moved to Reading in PersonalReader that
|
|
* has no Library item yet. The Suwayomi sync job keeps the list in its cache
|
|
* (entry `_suggestions`); this module serves it to the admin page, leaves
|
|
* out series that now have a Library item, and remembers dismissals in a
|
|
* small table, so a dismissed series stays dismissed across runs.
|
|
*
|
|
* Adding one is not done here: the admin page creates the Library item with
|
|
* the normal write API (`POST /api/changes`), so every rule applies.
|
|
*
|
|
* - `GET /api/suggestions` (editor, admin, agent): `[{ key, anilistId, title, medium, cover? }]`
|
|
* - `POST /api/suggestions/<key>/dismiss` (editor, admin)
|
|
*/
|
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
import type { DatabaseSync } from "node:sqlite";
|
|
import { can, type Role } from "./config.ts";
|
|
import type { Store } from "./store.ts";
|
|
import { SUGGESTIONS_KEY } from "./sync/jobs/reader.ts";
|
|
import type { Suggestion } from "./sync/jobs/suwayomi.ts";
|
|
|
|
/** Jobs whose cache may hold suggestions. */
|
|
const SOURCES = ["suwayomi"];
|
|
|
|
export interface SuggestionView extends Suggestion {
|
|
/** Stable id for dismissing: `anilist-<id>`. */
|
|
key: string;
|
|
}
|
|
|
|
const tables = new WeakMap<DatabaseSync, true>();
|
|
function ready(db: DatabaseSync) {
|
|
if (!tables.has(db)) {
|
|
db.exec(`create table if not exists suggestion_dismissals (key text primary key, at text not null);`);
|
|
tables.set(db, true);
|
|
}
|
|
return db;
|
|
}
|
|
|
|
export function listSuggestions(store: Store): SuggestionView[] {
|
|
const db = ready(store.db);
|
|
const dismissed = new Set((db.prepare("select key from suggestion_dismissals").all() as { key: string }[]).map((r) => r.key));
|
|
const linked = new Set(store.list({ type: "library-item" }).map((n) => n.data.anilistId).filter((id) => typeof id === "number"));
|
|
const out = new Map<string, SuggestionView>();
|
|
for (const job of SOURCES) {
|
|
const items = (store.syncCache.get(job, SUGGESTIONS_KEY)?.values.items as Suggestion[] | undefined) ?? [];
|
|
for (const s of items) {
|
|
if (typeof s?.anilistId !== "number" || linked.has(s.anilistId)) continue;
|
|
const key = `anilist-${s.anilistId}`;
|
|
if (!dismissed.has(key) && !out.has(key)) out.set(key, { ...s, key });
|
|
}
|
|
}
|
|
return [...out.values()].sort((a, b) => a.title.localeCompare(b.title));
|
|
}
|
|
|
|
export function dismissSuggestion(store: Store, key: string, at = new Date().toISOString()) {
|
|
ready(store.db).prepare("insert into suggestion_dismissals (key, at) values (?, ?) on conflict(key) do nothing").run(key, at);
|
|
}
|
|
|
|
export async function handleSuggestions(
|
|
_req: IncomingMessage,
|
|
res: ServerResponse,
|
|
o: { path: string; method: string; role: Role; store: Store },
|
|
): Promise<boolean> {
|
|
const send = (status: number, body: unknown) => {
|
|
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
res.end(JSON.stringify(body));
|
|
return true;
|
|
};
|
|
if (o.path === "/api/suggestions" && o.method === "GET") {
|
|
if (!can.readDrafts(o.role)) return send(403, { error: `The ${o.role} token cannot do this` });
|
|
return send(200, listSuggestions(o.store));
|
|
}
|
|
const m = o.path.match(/^\/api\/suggestions\/(anilist-\d{1,10})\/dismiss$/);
|
|
if (m && o.method === "POST") {
|
|
if (!can.review(o.role)) return send(403, { error: `The ${o.role} token cannot do this` });
|
|
dismissSuggestion(o.store, m[1]!);
|
|
return send(200, { ok: true });
|
|
}
|
|
return false;
|
|
}
|