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:
@@ -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(() => []);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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()];
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user