Files
com.gregkilmer.www/services/admin/src/inbox.ts
T
Claude e3b0cf60c5 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
2026-10-11 23:50:06 +00:00

99 lines
5.2 KiB
TypeScript

/**
* The Inbox: saved copies of inquiries and order requests sent through the
* site, unhandled first and newest first. Each one shows whether its email
* reached Greg, so a failed email is never a lost request.
*/
import type { Submission, SubmissionCounts } from "./client.ts";
import { html, qs, when, type Html } from "./html.ts";
export const inboxHref = (id: number) => `/inbox/${id}`;
const kindLabel = (s: Submission) => (s.kind === "order" ? "Order" : "Inquiry");
const typeLabels: Record<string, string> = { job: "Job or role", freelance: "Freelance web work", patiun: "Patiun custom order" };
const typeLabel = (s: Submission) => (s.kind === "inquiry" ? (typeLabels[s.type] ?? s.type) : s.type);
export function deliveryPill(s: Pick<Submission, "delivery">): Html {
switch (s.delivery) {
case "sent":
return html`<span class="pill live">Emailed</span>`;
case "failed":
return html`<span class="pill bad">Email failed</span>`;
case "skipped":
return html`<span class="pill draft">Not emailed</span>`;
default:
return html`<span class="pill draft">Email unknown</span>`;
}
}
const handledPill = (s: Submission) => (s.handled ? html`<span class="pill">Handled</span>` : html`<span class="pill draft">New</span>`);
const size = (n: number) => (n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`);
/** The first line of the longest field, as a hint of what it is about. */
function gist(s: Submission): string {
const d = s.fields.find((f) => /^(details|notes)$/i.test(f.label))?.value ?? s.fields[0]?.value ?? "";
const line = d.split("\n").find((l) => l.trim()) ?? "";
return line.length > 90 ? `${line.slice(0, 89)}…` : line;
}
export function inboxList(items: Submission[], counts: SubmissionCounts, show: string): Html {
const filters: [string, string][] = [
["open", "Not handled"],
["all", "All"],
["handled", "Handled"],
];
return html`<h1>Inbox</h1>
<p class="lede">Inquiries and order requests sent through the site. Each one is emailed to you and saved here, so nothing is lost if the email is.</p>
<div class="grid">
<div class="panel"><div class="stat">${counts.unhandled}</div>not handled yet</div>
<div class="panel"><div class="stat">${counts.failed}</div>not handled and the email failed</div>
<div class="panel"><div class="stat">${counts.total}</div>saved in all</div>
</div>
<p class="actions">${filters.map(([v, l]) => (v === show ? html`<strong>${l}</strong>` : html`<a href="/inbox${qs({ show: v === "open" ? undefined : v })}">${l}</a>`))}</p>
${items.length
? html`<div class="table-wrap"><table><thead><tr><th>From</th><th>What</th><th>About</th><th>Received</th><th>Email</th><th></th></tr></thead><tbody>
${items.map(
(s) => html`<tr>
<td><a href="${inboxHref(s.id)}">${s.name}</a><br><span class="muted">${s.email}</span></td>
<td>${kindLabel(s)}<br><span class="muted">${typeLabel(s)}</span></td>
<td>${gist(s)}${s.attachment ? html` <span class="pill">File</span>` : ""}</td>
<td>${when(s.createdAt)}</td>
<td>${deliveryPill(s)}</td>
<td>${handledPill(s)}</td>
</tr>`,
)}
</tbody></table></div>`
: html`<p class="muted">${show === "open" ? "Nothing waiting. Every request is handled." : "None yet."}</p>`}`;
}
export function inboxOne(s: Submission): Html {
const reply = `mailto:${s.email}?subject=${encodeURIComponent(s.kind === "order" ? "Your order request" : "Your inquiry")}`;
return html`<h1>${kindLabel(s)} from ${s.name}</h1>
<p class="lede"><a href="/inbox">Back to the inbox</a> · Received ${when(s.createdAt)} · ${handledPill(s)} ${s.handled ? when(s.handledAt) : ""}</p>
${s.delivery === "failed"
? html`<div class="flash err" role="alert">The email for this ${kindLabel(s).toLowerCase()} did not go out${s.deliveryError ? html`: ${s.deliveryError}` : "."} This saved copy is the only one.</div>`
: s.delivery === "skipped"
? html`<div class="flash" role="status">No email was sent (SMTP is not set up on the site). This saved copy is the only one.</div>`
: s.delivery === "pending"
? html`<div class="flash" role="status">The site did not report whether the email went out. Check your mail before relying on it.</div>`
: ""}
<div class="panel">
<dl class="fields">
<dt>Kind</dt><dd>${kindLabel(s)}: ${typeLabel(s)}</dd>
<dt>Name</dt><dd>${s.name}</dd>
<dt>Email</dt><dd><a href="${reply}">${s.email}</a></dd>
${s.fields.map((f) => html`<dt>${f.label}</dt><dd>${f.value.includes("\n") ? html`<pre>${f.value}</pre>` : f.value}</dd>`)}
${s.attachment ? html`<dt>Attachment</dt><dd><a href="/inbox/${s.id}/attachment" download>${s.attachment.name}</a> <span class="muted">${size(s.attachment.size)}, downloads as a file</span></dd>` : ""}
<dt>Email to you</dt><dd>${deliveryPill(s)} ${s.deliveryAt ? when(s.deliveryAt) : ""}</dd>
</dl>
</div>
<div class="actions">
<a class="button" href="${reply}">Reply by email</a>
<form method="post" action="/inbox/${s.id}/handled" class="inline">
<input type="hidden" name="handled" value="${s.handled ? "0" : "1"}">
<button class="${s.handled ? "" : "primary"}">${s.handled ? "Mark as not handled" : "Mark as handled"}</button>
</form>
</div>`;
}