feat(intake): tell the admin when a draft is ready (#224)

Sent after the draft commits, fire and forget. A mail failure must never mark a draft that was written correctly as failed: the review queue is what the admin actually works from, and the email is a convenience on top of it.

Every quiet path returns rather than throws. No recipient configured is not an error — nobody has said where to send it and the draft is waiting regardless. No INTAKE_ACTION_SECRET means the two shortcut links render empty rather than broken, because a link that could not be verified is worse than none. An item with no draft row simply returns.

A missing description is said plainly rather than left blank. An empty paragraph in a notification reads as a bug; "no description was drafted for this item" reads as the fact that it is, and tells the admin what to expect on the screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 15:24:54 -05:00
co-authored by Claude Opus 5
parent 0517faca60
commit faed47e105
2 changed files with 79 additions and 0 deletions
+9
View File
@@ -6,6 +6,7 @@ import { typeForExtension } from '../uploadTypes';
import { getAnthropicClient } from './anthropicClient'; import { getAnthropicClient } from './anthropicClient';
import { draftListing } from './draftListing'; import { draftListing } from './draftListing';
import { applyDraft } from './applyDraft'; import { applyDraft } from './applyDraft';
import { notifyDraftReady } from './notifyDraft';
/** /**
* Turns queued submissions into drafts. * Turns queued submissions into drafts.
@@ -144,6 +145,14 @@ export async function draftQueued(limit = DEFAULT_BATCH): Promise<SweepResult> {
} }
await draftOne(client, row.item_id, row.submitter_note, photos); await draftOne(client, row.item_id, row.submitter_note, photos);
drafted++; drafted++;
// Fire and forget, and deliberately after the draft is committed. A mail
// failure must never mark a draft that was written correctly as failed —
// the queue is what the admin actually works from, and the email is a
// convenience on top of it.
void notifyDraftReady(row.item_id).catch((err) =>
console.error(`[drafting] notifying for item ${row.item_id}:`, err)
);
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
console.error(`[drafting] item ${row.item_id}: ${message}`); console.error(`[drafting] item ${row.item_id}: ${message}`);
+70
View File
@@ -0,0 +1,70 @@
import { pool } from '../db';
import { sendMail } from '../mailer';
import { renderTemplate } from '../emailTemplates';
import { loadStoredTemplate } from '../routes/adminEmailTemplates';
import { getSettings } from '../adminSettings';
import { trimTrailingSlashes } from '../utils';
import { actionUrl } from './actionLinks';
interface NotifyRow {
item_name: string;
price_cents: number;
ai_name: string | null;
ai_description: string | null;
submitter_note: string | null;
link_label: string | null;
}
/**
* Tells the admin an item arrived and has been drafted.
*
* Everything here is best-effort by design. The review queue is the source of
* truth: a ready draft is visible and actionable whether or not this ever sent,
* so a missing recipient, an SMTP outage, or a template that will not render
* must all end in a log line rather than an exception reaching the worker and
* marking a perfectly good draft as failed.
*/
export async function notifyDraftReady(itemId: number): Promise<void> {
const { intakeNotifyEmail } = await getSettings();
const to = intakeNotifyEmail?.trim();
if (!to) {
// Not an error, and deliberately not a warning either. Nobody has said
// where to send it, and the draft is waiting in the queue regardless.
return;
}
const { rows } = await pool.query<NotifyRow>(
`SELECT i.name AS item_name, i.price_cents,
d.ai_name, d.ai_description, d.submitter_note,
l.label AS link_label
FROM item_drafts d
JOIN items i ON i.id = d.item_id
LEFT JOIN upload_links l ON l.id = d.upload_link_id
WHERE d.item_id = $1`,
[itemId]
);
const row = rows[0];
if (!row) return;
const base = trimTrailingSlashes(process.env.PUBLIC_URL ?? '');
const template = renderTemplate('intakeDraft', await loadStoredTemplate('intakeDraft'), {
itemName: row.item_name,
draftName: row.ai_name ?? row.item_name,
// Said plainly rather than left blank. An empty description in a
// notification reads as a bug; "no description was drafted" reads as the
// fact that it is, and tells the admin what to expect on the screen.
draftDescription: row.ai_description ?? 'No description was drafted for this item.',
price: `$${(row.price_cents / 100).toFixed(2)}`,
submitterNote: row.submitter_note ?? 'The sender left no note.',
linkLabel: row.link_label ?? 'an upload link',
reviewUrl: `${base}/admin`,
// Empty rather than a broken link when there is no secret to sign with.
// The body renders without them; a link that could not be verified would
// be worse than none.
regenerateUrl: actionUrl(itemId, 'regenerate') ?? '',
discardUrl: actionUrl(itemId, 'discard') ?? ''
});
await sendMail(to, template.subject, template.html);
}