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
+19 -1
View File
@@ -20,6 +20,24 @@ export interface Note<T extends NoteType = NoteType> {
data: NoteData<T>;
body: string;
acceptedAt: string;
/**
* What the content service's sync jobs fetched for this note (BoardGameGeek,
* Archidekt, cover art). Already merged under `data` by `withSynced`, so
* pages read `data`; kept here so a page can tell a synced value apart.
*/
synced?: Record<string, unknown>;
}
/**
* Lay the synced values under the note's own: a value set in the note always
* wins, and a note with nothing synced is returned as it came.
*/
export function withSynced<T extends Note>(note: T): T {
const synced = note.synced;
if (!synced || !Object.keys(synced).length) return note;
const data: Record<string, unknown> = { ...synced };
for (const [k, v] of Object.entries(note.data as Record<string, unknown>)) if (v !== undefined && v !== null) data[k] = v;
return { ...note, data: data as T["data"] };
}
export interface Redirect {
@@ -48,7 +66,7 @@ async function loadAll(): Promise<SiteIndex> {
for (let offset = 0; ; offset += 1000) {
const published = includeDrafts() ? "" : "&published=true";
const page = await api<(Note & { publish?: boolean })[]>(`/api/notes?full=true${published}&limit=1000&offset=${offset}`);
notes.push(...page);
notes.push(...page.map(withSynced));
if (page.length < 1000) break;
}
const redirects = await api<Redirect[]>("/api/redirects").catch(() => []);
+155
View File
@@ -0,0 +1,155 @@
/**
* What the form endpoints do outside the process: Turnstile, the content
* service (live store notes, saving the copy, recording delivery) and SMTP.
* Settings come from process.env through env(); see services/site/.env.example.
*/
import type { Note } from "./content.ts";
import { env } from "./env.ts";
import { DEFAULT_ATTACHMENT_MB, RateLimiter, type Row, type Upload } from "./forms.ts";
import { SiteIndex } from "./site.ts";
import { storeState, type StoreState } from "./store.ts";
const contentUrl = () => env("CONTENT_URL", "http://localhost:4310").replace(/\/$/, "");
export const maxAttachmentBytes = () => Math.round(Number(env("FORM_MAX_ATTACHMENT_MB", String(DEFAULT_ATTACHMENT_MB))) * 1024 * 1024);
export const inquiryLimiter = new RateLimiter(
() => Number(env("CONTACT_RATE_LIMIT", "5")),
() => Number(env("CONTACT_RATE_WINDOW_MIN", "15")) * 60_000,
);
export const orderLimiter = new RateLimiter(
() => Number(env("ORDER_RATE_LIMIT", env("CONTACT_RATE_LIMIT", "5"))),
() => Number(env("ORDER_RATE_WINDOW_MIN", env("CONTACT_RATE_WINDOW_MIN", "15"))) * 60_000,
);
/** The public site key, for the widget. */
export const turnstileSiteKey = () => env("PUBLIC_TURNSTILE_SITE_KEY") ?? env("TURNSTILE_SITE_KEY");
/**
* Cloudflare Turnstile. Skipped when TURNSTILE_SECRET_KEY is unset (local
* development); when it is set, a missing token, a bad token or an error
* talking to Cloudflare all turn the post down.
*/
export async function verifyTurnstile(token: string, ip: string, fetcher: typeof fetch = fetch): Promise<boolean> {
const secret = env("TURNSTILE_SECRET_KEY");
if (!secret) return true;
if (!token) return false;
try {
const body = new URLSearchParams({ secret, response: token });
if (ip && ip !== "unknown") body.set("remoteip", ip);
const res = await fetcher("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
body,
signal: AbortSignal.timeout(8000),
});
const data = (await res.json()) as { success?: boolean };
return data.success === true;
} catch (err) {
console.error("Turnstile verification failed; turning the post down.", err);
return false;
}
}
/**
* The store's notes as they are right now, straight from the content
* service rather than the site's cached copy, so an order is checked against
* what Greg last saved. Throws when the service cannot answer.
*/
export async function liveStore(): Promise<StoreState> {
const get = async (type: string) => {
const res = await fetch(`${contentUrl()}/api/notes?type=${type}&full=true&published=true&limit=1000`, {
headers: { authorization: `Bearer ${env("CONTENT_TOKEN", "")}` },
signal: AbortSignal.timeout(8000),
});
if (!res.ok) throw new Error(`Content service answered ${res.status} for ${type}`);
return (await res.json()) as Note[];
};
const [settings, lines] = await Promise.all([get("store-settings"), get("product-line")]);
return storeState(new SiteIndex([...settings, ...lines]));
}
export interface SubmissionInput {
kind: "inquiry" | "order";
type: string;
name: string;
email: string;
fields: Row[];
attachment?: Upload | null;
}
const intake = async (path: string, body: unknown) => {
const token = env("CONTENT_INTAKE_TOKEN");
if (!token) throw new Error("CONTENT_INTAKE_TOKEN is not set");
const res = await fetch(`${contentUrl()}${path}`, {
method: "POST",
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(20_000),
});
const data = (await res.json().catch(() => null)) as Record<string, unknown> | null;
if (!res.ok) throw new Error(`Content service answered ${res.status}: ${String(data?.error ?? "")}`);
return data;
};
/** Save the copy with the intake token. Returns the id and the receipt for reporting delivery. */
export async function saveSubmission(s: SubmissionInput): Promise<{ id: number; receipt: string }> {
const attachment = s.attachment ? { name: s.attachment.name, data: Buffer.from(await s.attachment.arrayBuffer()).toString("base64") } : undefined;
const out = (await intake("/api/submissions", { ...s, attachment })) as { id: number; receipt: string };
return { id: out.id, receipt: out.receipt };
}
export async function reportDelivery(id: number, receipt: string, status: "sent" | "skipped" | "failed", error?: string) {
await intake(`/api/submissions/${id}/delivery`, { receipt, status, ...(error ? { error } : {}) });
}
export interface Mail {
to: string;
replyTo: { name: string; address: string };
subject: string;
text: string;
attachment?: Upload | null;
}
/**
* Send through SMTP (nodemailer). "skipped" when SMTP_HOST is unset, so a
* site without mail still saves copies. Throws when sending fails.
*/
export async function sendMail(m: Mail): Promise<"sent" | "skipped"> {
const host = env("SMTP_HOST");
if (!host) return "skipped";
const nodemailer = (await import("nodemailer")).default;
const port = Number(env("SMTP_PORT", "587"));
const user = env("SMTP_USER");
const transport = nodemailer.createTransport({
host,
port,
secure: port === 465,
auth: user ? { user, pass: env("SMTP_PASS", "") } : undefined,
// Only for a relay on the same private network that presents a self-signed certificate.
...(env("SMTP_ALLOW_SELFSIGNED") === "true" ? { tls: { rejectUnauthorized: false } } : {}),
connectionTimeout: 10_000,
greetingTimeout: 10_000,
socketTimeout: 20_000,
});
await transport.sendMail({
from: env("SMTP_FROM", "GregKilmer.com <no-reply@gregkilmer.com>"),
to: m.to,
replyTo: m.replyTo,
subject: m.subject.replace(/[\r\n]+/g, " "),
text: m.text,
attachments: m.attachment ? [{ filename: m.attachment.name, content: Buffer.from(await m.attachment.arrayBuffer()) }] : undefined,
});
return "sent";
}
export const contactTo = () => env("CONTACT_TO", "contact@gregkilmer.com");
export const orderTo = () => env("ORDER_TO", "orders@gregkilmer.com");
/** Short-lived cookie settings for the confirmation and try-again cookies. */
export const flashCookie = (url: URL) => ({
path: "/",
httpOnly: true,
sameSite: "lax" as const,
secure: import.meta.env.PROD || url.protocol === "https:",
maxAge: 600,
});
+476
View File
@@ -0,0 +1,476 @@
/**
* The inquiry and order forms' rules, as plain functions: what a valid post
* looks like, what an order may be accepted for, what the confirmation and
* the "try again" pages are handed, and how saving and emailing combine.
* Nothing here touches the network, so tests run it without Astro.
*/
import type { InquiryType, SentSummary } from "./work.ts";
import { parseInquiryType } from "./work.ts";
import { channelOf, hasCapacity, optionGroups, type Line, type StoreState } from "./store.ts";
export interface Row {
label: string;
value: string;
}
/** What a form post gives: FormData, or anything shaped like it in tests. */
export interface FormLike {
get(name: string): FormDataEntryValue | null;
}
export type FieldErrors = Record<string, string>;
export type Invalid = { ok: false; code: ErrorCode; errors: FieldErrors };
export type ErrorCode = "invalid" | "bot" | "rate" | "closed" | "full" | "unavailable" | "failed";
/** What the visitor reads for each kind of failure. Nothing here leaks internals. */
export const errorMessages: Record<ErrorCode, string> = {
invalid: "Some answers need another look. Nothing was sent yet.",
bot: "The bot check did not go through. Please try again.",
rate: "Too many requests from here in a short time. Please try again a little later.",
closed: "Orders for that are closed right now, so nothing was sent.",
full: "That line has no open slots right now, so nothing was sent.",
unavailable: "The store could not confirm availability just now. Please try again in a few minutes.",
failed: "Your request could not be sent just now. Please try again a little later.",
};
export const parseErrorCode = (raw: string | null | undefined): ErrorCode | undefined =>
raw && raw in errorMessages ? (raw as ErrorCode) : undefined;
const text = (form: FormLike, key: string): string => {
const v = form.get(key);
return typeof v === "string" ? v.replace(/\r\n/g, "\n").trim() : "";
};
const EMAIL = /^[^\s@<>()[\]\\,;:"]+@[^\s@<>()[\]\\,;:"]+\.[^\s@<>()[\]\\,;:"]{2,}$/;
export const isEmail = (s: string) => s.length <= 200 && EMAIL.test(s);
export const isHttpUrl = (s: string) => {
try {
const u = new URL(s);
return (u.protocol === "http:" || u.protocol === "https:") && !!u.hostname;
} catch {
return false;
}
};
const isDate = (s: string) => /^\d{4}-\d{2}-\d{2}$/.test(s) && !Number.isNaN(Date.parse(`${s}T00:00:00Z`));
/** The hidden field bots fill and people never see. */
export const HONEYPOT = "leave_blank";
export const isBot = (form: FormLike) => text(form, HONEYPOT) !== "";
/** The Turnstile widget adds this field to the form it sits in. */
export const TURNSTILE_FIELD = "cf-turnstile-response";
// ---------------------------------------------------------------- attachments
/** Matches the content service's list (services/content/src/submissions.ts). */
export const ATTACHMENT_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp", "avif", "heic", "pdf", "txt", "csv", "dek", "dec"];
export const DEFAULT_ATTACHMENT_MB = 10;
export interface Upload {
name: string;
size: number;
type: string;
arrayBuffer(): Promise<ArrayBuffer>;
}
const isUpload = (v: unknown): v is Upload =>
typeof v === "object" && v !== null && typeof (v as Upload).arrayBuffer === "function" && typeof (v as Upload).size === "number";
/** The attached file, or null for none; an error message when it cannot be taken. */
export function checkAttachment(form: FormLike, key: string, maxBytes: number): { file: Upload | null; error?: string } {
const v = form.get(key);
if (!isUpload(v) || v.size === 0) return { file: null };
const ext = /\.([A-Za-z0-9]{1,8})$/.exec(v.name)?.[1]?.toLowerCase();
if (!ext || !ATTACHMENT_EXTENSIONS.includes(ext)) {
return { file: null, error: "Attach an image, a PDF, or a text, CSV or deck list file." };
}
if (v.size > maxBytes) return { file: null, error: `Attachments can be up to ${Math.floor(maxBytes / 1024 / 1024)} MB.` };
return { file: v };
}
// ---------------------------------------------------------------- inquiries
export const choices = {
job_kind: ["Full-time", "Contract", "Not sure yet"],
freelance_need: ["A new site", "An app", "An integration", "Something else"],
patiun_item: ["Proxy cards", "Deck box", "Something else"],
} as const;
export const timing = [
{ value: "no-rush", label: "No rush" },
{ value: "within-a-month", label: "Within a month" },
{ value: "date", label: "A specific date" },
];
type Question =
| { key: string; label: string; kind: "text"; max: number }
| { key: string; label: string; kind: "url" }
| { key: string; label: string; kind: "choice"; options: readonly string[] }
| { key: string; label: string; kind: "timing"; dateKey: string };
/** Each type's own questions, in form order. Fields of other types are ignored. */
export const questions: Record<InquiryType, Question[]> = {
job: [
{ key: "job_company", label: "Company", kind: "text", max: 160 },
{ key: "job_role", label: "Role", kind: "text", max: 160 },
{ key: "job_kind", label: "Kind of role", kind: "choice", options: choices.job_kind },
{ key: "job_link", label: "Posting", kind: "url" },
],
freelance: [
{ key: "freelance_need", label: "Needs", kind: "choice", options: choices.freelance_need },
{ key: "freelance_site", label: "Current site", kind: "url" },
{ key: "freelance_timing", label: "Timeline", kind: "timing", dateKey: "freelance_date" },
],
patiun: [
{ key: "patiun_item", label: "Item", kind: "choice", options: choices.patiun_item },
{ key: "patiun_quantity", label: "How many", kind: "text", max: 120 },
{ key: "patiun_timing", label: "Need it by", kind: "timing", dateKey: "patiun_date" },
],
};
export interface Inquiry {
type: InquiryType;
name: string;
email: string;
details: string;
/** The chosen type's answers, in form order, empty ones left out. */
rows: Row[];
attachment: Upload | null;
}
const formatDay = (d: string) => new Date(`${d}T00:00:00Z`).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
export function validateInquiry(form: FormLike, opts: { maxBytes: number }): { ok: true; value: Inquiry } | Invalid {
const errors: FieldErrors = {};
const type = parseInquiryType(text(form, "type"));
if (!type) errors.type = "Pick what this is about.";
const name = text(form, "name");
if (!name) errors.name = "Add your name.";
else if (name.length > 120) errors.name = "Keep your name under 120 characters.";
const email = text(form, "email");
if (!isEmail(email)) errors.email = "Add an email address I can reply to.";
const details = text(form, "details");
if (details.length < 10) errors.details = "Tell me a little more: a sentence or two is plenty.";
else if (details.length > 5000) errors.details = "Keep the details under 5,000 characters, or attach a file.";
const rows: Row[] = [];
for (const q of type ? questions[type] : []) {
const v = text(form, q.key);
if (!v) continue;
if (q.kind === "text") {
if (v.length > q.max) errors[q.key] = `Keep this under ${q.max} characters.`;
else rows.push({ label: q.label, value: v });
} else if (q.kind === "url") {
if (v.length > 500 || !isHttpUrl(v)) errors[q.key] = "Use a full link starting with https://.";
else rows.push({ label: q.label, value: v });
} else if (q.kind === "choice") {
if (!q.options.includes(v)) errors[q.key] = "Pick one of the options.";
else rows.push({ label: q.label, value: v });
} else {
const t = timing.find((x) => x.value === v);
if (!t) errors[q.key] = "Pick one of the options.";
else if (t.value === "date") {
const d = text(form, q.dateKey);
if (!isDate(d)) errors[q.dateKey] = "Pick the date.";
else rows.push({ label: q.label, value: formatDay(d) });
} else rows.push({ label: q.label, value: t.label });
}
}
const att = checkAttachment(form, "attachment", opts.maxBytes);
if (att.error) errors.attachment = att.error;
if (Object.keys(errors).length || !type) return { ok: false, code: "invalid", errors };
return { ok: true, value: { type, name, email, details, rows, attachment: att.file } };
}
const inquiryTypeLabels: Record<InquiryType, string> = { job: "A job or role", freelance: "Freelance web work", patiun: "Patiun custom order" };
/** Everything the saved copy and the email carry, in order. */
export const inquiryFields = (q: Inquiry): Row[] => [...q.rows, { label: "Details", value: q.details }];
export function inquiryEmail(q: Inquiry, savedId: number | null): { subject: string; text: string } {
return {
subject: `Inquiry: ${inquiryTypeLabels[q.type]} from ${q.name}`,
text: [
`New inquiry through gregkilmer.com`,
``,
`Type: ${inquiryTypeLabels[q.type]}`,
`Name: ${q.name}`,
`Email: ${q.email}`,
...q.rows.map((r) => `${r.label}: ${r.value}`),
``,
`Details:`,
q.details,
``,
q.attachment ? `Attached: ${q.attachment.name}` : "",
savedId ? `Saved in the admin inbox as #${savedId}.` : `Not saved in the admin inbox: the content service did not take it. This email is the only copy.`,
].join("\n"),
};
}
/** The `gk-inquiry` cookie the sent page reads: what the visitor sent, briefly. */
export function inquirySent(q: Inquiry): SentSummary {
const short = (v: string) => (v.length > 120 ? `${v.slice(0, 119)}…` : v);
return {
type: q.type,
name: q.name,
email: q.email,
rows: [...q.rows.map((r) => ({ label: r.label, value: short(r.value) })), ...(q.attachment ? [{ label: "Attachment", value: short(q.attachment.name) }] : [])].slice(0, 6),
};
}
// ---------------------------------------------------------------- orders
export interface OrderRequest {
name: string;
email: string;
line: string;
quantity: string;
details: string;
notes: string;
}
/** Check the fields that do not depend on the store. */
export function validateOrderFields(form: FormLike): { ok: true; value: OrderRequest } | Invalid {
const errors: FieldErrors = {};
const name = text(form, "name");
if (!name) errors.name = "Add your name.";
else if (name.length > 120) errors.name = "Keep your name under 120 characters.";
const email = text(form, "email");
if (!isEmail(email)) errors.email = "Add an email address I can reply to.";
const line = text(form, "line");
if (!/^[a-z0-9-]{1,120}$/.test(line)) errors.line = "Pick what you would like.";
const quantity = text(form, "quantity");
if (!quantity) errors.quantity = "Give a rough quantity.";
else if (quantity.length > 200) errors.quantity = "Keep this under 200 characters.";
const details = text(form, "details");
if (details.length > 20_000) errors.details = "Keep this under 20,000 characters.";
const notes = text(form, "notes");
if (notes.length > 5000) errors.notes = "Keep notes under 5,000 characters.";
if (text(form, "terms") !== "accepted") errors.terms = "Read and accept the order terms to send a request.";
if (Object.keys(errors).length) return { ok: false, code: "invalid", errors };
return { ok: true, value: { name, email, line, quantity, details, notes } };
}
export interface CheckedOrder extends OrderRequest {
lineNote: Line;
/** The chosen options, as { label: group, value: option }. */
options: Row[];
}
/**
* Whether the store takes this order now, against the live notes. An order
* is accepted only for a line sold here, in a category with ordersOpen true,
* with an open slot or stock, and only with options that exist and are in
* stock. Anything else is turned down, whatever the form said.
*/
export function checkOrder(req: OrderRequest, form: FormLike, st: StoreState): { ok: true; value: CheckedOrder } | Invalid {
const line = st.lines.find((l) => l.slug === req.line);
if (!line || channelOf(line) !== "here") return { ok: false, code: "invalid", errors: { line: "That is not something you can order here." } };
if (!st.category(line.data.category).ordersOpen) return { ok: false, code: "closed", errors: {} };
if (!hasCapacity(line)) return { ok: false, code: "full", errors: {} };
const options: Row[] = [];
const errors: FieldErrors = {};
for (const [group, opts] of optionGroups(line)) {
const key = `option.${line.slug}.${group}`;
const v = text(form, key);
if (!v) continue;
const o = opts.find((x) => x.name === v);
if (!o) errors[key] = `Pick one of the ${group.toLowerCase()} options.`;
else if (!o.inStock) errors[key] = `${o.name} is out of stock. Pick another.`;
else options.push({ label: group, value: o.name });
}
if (Object.keys(errors).length) return { ok: false, code: "invalid", errors };
return { ok: true, value: { ...req, lineNote: line, options } };
}
export const orderFields = (o: CheckedOrder): Row[] => [
{ label: "Line", value: o.lineNote.title },
...o.options,
{ label: "Quantity", value: o.quantity },
...(o.details ? [{ label: "Details", value: o.details }] : []),
...(o.notes ? [{ label: "Notes", value: o.notes }] : []),
{ label: "Terms", value: "Accepted" },
];
export function orderEmail(o: CheckedOrder, savedId: number | null): { subject: string; text: string } {
return {
subject: `Order request: ${o.lineNote.title} from ${o.name}`,
text: [
`New order request through gregkilmer.com. No payment was taken.`,
``,
`Name: ${o.name}`,
`Email: ${o.email}`,
...orderFields(o).flatMap((r) => (r.value.includes("\n") ? [``, `${r.label}:`, r.value, ``] : [`${r.label}: ${r.value}`])),
``,
savedId ? `Saved in the admin inbox as #${savedId}.` : `Not saved in the admin inbox: the content service did not take it. This email is the only copy.`,
].join("\n"),
};
}
export interface OrderSent {
name: string;
email: string;
line: string;
lineSlug: string;
rows: Row[];
}
/** The `gk-order` cookie the order confirmation reads. */
export function orderSent(o: CheckedOrder): OrderSent {
const short = (v: string) => (v.length > 120 ? `${v.slice(0, 119)}…` : v);
return {
name: o.name,
email: o.email,
line: o.lineNote.title,
lineSlug: o.lineNote.slug,
rows: [...o.options, { label: "Quantity", value: o.quantity }].map((r) => ({ label: r.label, value: short(r.value) })).slice(0, 6),
};
}
export function parseOrderSent(raw: string | undefined): OrderSent | undefined {
if (!raw) return undefined;
try {
const v = JSON.parse(raw.startsWith("%7B") || raw.startsWith("%7b") ? decodeURIComponent(raw) : raw) as Record<string, unknown>;
const str = (x: unknown) => (typeof x === "string" && x.trim() ? x.trim().slice(0, 300) : "");
const rows = Array.isArray(v.rows)
? v.rows
.map((r) => ({ label: str((r as Row)?.label), value: str((r as Row)?.value) }))
.filter((r) => r.label && r.value)
.slice(0, 8)
: [];
return { name: str(v.name), email: str(v.email), line: str(v.line), lineSlug: str(v.lineSlug), rows };
} catch {
return undefined;
}
}
// ---------------------------------------------------------------- try again
/**
* What goes back to the form when a post is turned down: the error, which
* fields to mark, and the visitor's answers. It travels as a short-lived
* cookie, so it has to fit: the longest answers are cut first.
*/
export interface Draft {
code: ErrorCode;
errors: FieldErrors;
values: Record<string, string>;
}
export const DRAFT_LIMIT = 3600;
/** Every text value a form posted, files and the bot fields left out. */
export function formValues(form: { entries(): IterableIterator<[string, FormDataEntryValue]> } | FormData): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of (form as FormData).entries()) {
if (typeof v !== "string" || k === HONEYPOT || k === TURNSTILE_FIELD || k === "terms") continue;
if (k.length > 160) continue;
out[k] = v.slice(0, 20_000);
}
return out;
}
export function encodeDraft(d: Draft, limit = DRAFT_LIMIT): string {
const values = { ...d.values };
const enc = () => encodeURIComponent(JSON.stringify({ ...d, values }));
let out = enc();
while (out.length > limit) {
const longest = Object.entries(values).sort((a, b) => b[1].length - a[1].length)[0];
if (!longest || longest[1].length === 0) break;
const [k, v] = longest;
values[k] = v.length > 200 ? v.slice(0, Math.floor(v.length / 2)) : "";
out = enc();
}
return JSON.stringify({ ...d, values });
}
export function parseDraft(raw: string | undefined): Draft | undefined {
if (!raw) return undefined;
try {
const v = JSON.parse(raw.startsWith("%7B") || raw.startsWith("%7b") ? decodeURIComponent(raw) : raw) as Record<string, unknown>;
const code = parseErrorCode(typeof v.code === "string" ? v.code : undefined);
if (!code) return undefined;
const strings = (x: unknown) =>
Object.fromEntries(Object.entries(typeof x === "object" && x ? x : {}).filter((e): e is [string, string] => typeof e[1] === "string"));
return { code, errors: strings(v.errors), values: strings(v.values) };
} catch {
return undefined;
}
}
// ---------------------------------------------------------------- send
export type EmailResult = "sent" | "skipped" | "failed";
/**
* Save the copy, then email, then record how the email went. The visitor
* hears it worked when either the saved copy or the email made it; when
* neither did, the form shows an error.
*/
export async function saveAndSend(deps: {
save(): Promise<{ id: number; receipt: string }>;
email(savedId: number | null): Promise<"sent" | "skipped">;
report(id: number, receipt: string, status: EmailResult, error?: string): Promise<void>;
log?(message: string, err: unknown): void;
}): Promise<{ ok: boolean; savedId: number | null; email: EmailResult }> {
const log = deps.log ?? ((m, e) => console.error(m, e));
let saved: { id: number; receipt: string } | null = null;
try {
saved = await deps.save();
} catch (err) {
log("Saving the copy failed; trying the email anyway.", err);
}
let email: EmailResult;
let error: string | undefined;
try {
email = await deps.email(saved?.id ?? null);
} catch (err) {
email = "failed";
error = err instanceof Error ? err.message.slice(0, 500) : String(err).slice(0, 500);
log("Sending the email failed.", err);
}
if (saved) {
await deps.report(saved.id, saved.receipt, email, error).catch((err: unknown) => log("Recording the email result failed.", err));
}
return { ok: !!saved || email === "sent", savedId: saved?.id ?? null, email };
}
// ---------------------------------------------------------------- rate limit
/** N posts per address per window, in memory. Enough for one site process. */
export class RateLimiter {
private readonly hits = new Map<string, { count: number; reset: number }>();
constructor(
private readonly limit: () => number,
private readonly windowMs: () => number,
private readonly now: () => number = Date.now,
) {}
/** Counts this post; true when it is over the limit. */
hit(key: string): boolean {
const t = this.now();
if (this.hits.size > 5000) for (const [k, e] of this.hits) if (t > e.reset) this.hits.delete(k);
const e = this.hits.get(key);
if (!e || t > e.reset) {
this.hits.set(key, { count: 1, reset: t + this.windowMs() });
return false;
}
e.count += 1;
return e.count > this.limit();
}
}
/** The visitor's address: the proxy's X-Forwarded-For first, else the socket's. */
export function clientIp(headers: Headers, fallback?: string): string {
const fwd = headers.get("x-forwarded-for");
if (fwd) return fwd.split(",")[0]!.trim();
return headers.get("x-real-ip") ?? fallback ?? "unknown";
}
+49
View File
@@ -5,6 +5,7 @@
*/
import type { Note } from "./content.ts";
import type { SiteIndex } from "./site.ts";
import { formatDate } from "./site.ts";
export type Item = Note<"library-item">;
export type Game = Note<"board-game">;
@@ -47,6 +48,29 @@ export function byStanding(a: Item, b: Item): number {
);
}
/** Progress PersonalReader syncs onto a linked item (see withSynced in content.ts). */
interface ReaderSynced {
chapterProgress?: { chapter?: number; readAt?: string };
bookProgress?: { percent?: number; readAt?: string };
}
/** "Chapter 42, read Oct 3, 2026" or "62% read", from PersonalReader; undefined when nothing is synced. */
export function readerProgress(item: Item): string | undefined {
const d = item.data as ReaderSynced;
const c = d.chapterProgress;
const b = d.bookProgress;
const what = c?.chapter ? `Chapter ${c.chapter}` : b && typeof b.percent === "number" && b.percent > 0 ? `${b.percent}% read` : undefined;
if (!what) return undefined;
const at = (c?.chapter ? c.readAt : b?.readAt)?.slice(0, 10);
return at ? `${what}, read ${formatDate(at)}` : what;
}
/** The row's details line. The note's own `progress` wins over the synced one. */
export const itemDetails = (item: Item): (string | number)[] => {
const d = item.data;
return [d.creator, d.year, d.platform, d.progress || readerProgress(item), d.group].filter((x): x is string | number => x !== undefined && x !== "");
};
export interface LibraryFilter {
type?: MediaKey;
q: string;
@@ -152,6 +176,31 @@ export function gameShows(g: Game, f: ShelfFilter, text: string): boolean {
return !f.q || text.includes(f.q);
}
/** Values BoardGameGeek syncs onto a game (see withSynced in content.ts). */
interface GameSynced {
minPlayers?: number;
maxPlayers?: number;
playTime?: number;
minPlayTime?: number;
maxPlayTime?: number;
}
const range = (lo?: number, hi?: number) => (lo && hi && lo !== hi ? `${lo}–${hi}` : String(lo || hi || ""));
/** "2–4 players", "1 player" or undefined when BoardGameGeek has not been synced. */
export function gamePlayers(g: Game): string | undefined {
const d = g.data as GameSynced;
const r = range(d.minPlayers, d.maxPlayers);
return r ? `${r} ${r === "1" ? "player" : "players"}` : undefined;
}
/** "60–90 min", "45 min" or undefined. */
export function gamePlayTime(g: Game): string | undefined {
const d = g.data as GameSynced;
const r = d.minPlayTime && d.maxPlayTime ? range(d.minPlayTime, d.maxPlayTime) : range(d.playTime);
return r ? `${r} min` : undefined;
}
/** Every "how it plays" value on the shelf, A to Z. */
export const playStyles = (games: Game[]) =>
[...new Set(games.flatMap((g) => g.data.howItPlays ?? []))].sort((a, b) => a.localeCompare(b));
+37 -4
View File
@@ -118,10 +118,43 @@ export function priceSummary(line: Line): { from: string; note?: string; more: s
return { from: low.price, note: low.note, more };
}
/** Option groups in note order, with their options. */
export function optionGroups(line: Line): [string, NonNullable<Line["data"]["options"]>][] {
const map = new Map<string, NonNullable<Line["data"]["options"]>>();
for (const o of line.data.options ?? []) map.set(o.group, [...(map.get(o.group) ?? []), o]);
export type Option = NonNullable<Line["data"]["options"]>[number] & { inStock: boolean };
/** Stock the content service syncs onto a line: Bambuddy filament, Homebox leather and paper. */
type StockGroup = { group: string; options: { name: string; hex?: string; inStock: boolean }[] };
interface StockSynced {
filamentStock?: StockGroup[];
materialStock?: StockGroup[];
}
/**
* Option groups in note order, with their options. Synced stock sits under
* the note's own values: a note option keeps every value it sets, takes the
* synced `inStock` and `hex` only where it leaves them out (out of stock
* when the source does not have it, in stock when nothing is synced), and
* in-stock colors the note does not list follow its own.
*/
export function optionGroups(line: Line): [string, Option[]][] {
const d = line.data as Line["data"] & StockSynced;
const synced = new Map<string, StockGroup["options"]>();
for (const g of [...(d.filamentStock ?? []), ...(d.materialStock ?? [])]) synced.set(g.group.toLowerCase(), g.options);
const map = new Map<string, Option[]>();
for (const o of line.data.options ?? []) {
const stock = synced.get(o.group.toLowerCase());
const match = stock?.find((x) => x.name.toLowerCase() === o.name.toLowerCase() || (!!o.hex && x.hex?.toLowerCase() === o.hex.toLowerCase()));
const hex = o.hex ?? match?.hex;
map.set(o.group, [...(map.get(o.group) ?? []), { ...o, ...(hex ? { hex } : {}), inStock: o.inStock ?? (stock ? !!match?.inStock : true) }]);
}
for (const [group, opts] of map) {
const stock = synced.get(group.toLowerCase()) ?? [];
const listed = new Set(opts.map((o) => o.name.toLowerCase()));
const listedHex = new Set(opts.map((o) => o.hex?.toLowerCase()).filter(Boolean));
for (const x of stock) {
if (!x.inStock || listed.has(x.name.toLowerCase()) || (x.hex && listedHex.has(x.hex.toLowerCase()))) continue;
opts.push({ group, name: x.name, ...(x.hex ? { hex: x.hex } : {}), inStock: true });
listed.add(x.name.toLowerCase());
}
}
return [...map.entries()];
}
+23
View File
@@ -91,6 +91,29 @@ export function statusesFor(s: SiteIndex, page: string) {
/** Archidekt deck address. */
export const archidektUrl = (id: number) => `https://archidekt.com/decks/${id}`;
/** Values Archidekt syncs onto a deck (see withSynced in content.ts). */
interface DeckSynced {
commanderArt?: string;
colors?: string[];
commanders?: string[];
}
/** The deck's own cover, else its synced commander art. */
export function deckArt(deck: Note<"deck">): string | undefined {
const d = deck.data as DeckSynced & { cover?: string };
return mediaSrc(d.cover ?? d.commanderArt);
}
const COLOR_NAMES: Record<string, string> = { W: "White", U: "Blue", B: "Black", R: "Red", G: "Green" };
/** The deck's color identity as names, WUBRG order; empty when not synced. */
export function deckColors(deck: Note<"deck">): string[] {
return ((deck.data as DeckSynced).colors ?? []).map((c) => COLOR_NAMES[c]).filter((c): c is string => !!c);
}
/** The deck's commanders, when synced. */
export const deckCommanders = (deck: Note<"deck">): string[] => (deck.data as DeckSynced).commanders ?? [];
/** Deck's list address: Archidekt when set, else its own list link. */
export function deckUrl(deck: Note<"deck">): string | undefined {
return deck.data.archidektId ? archidektUrl(deck.data.archidektId) : deck.data.listUrl;