diff --git a/backend/src/intake/draftingWorker.ts b/backend/src/intake/draftingWorker.ts index 3c2effe..6df031b 100644 --- a/backend/src/intake/draftingWorker.ts +++ b/backend/src/intake/draftingWorker.ts @@ -6,6 +6,7 @@ import { typeForExtension } from '../uploadTypes'; import { getAnthropicClient } from './anthropicClient'; import { draftListing } from './draftListing'; import { applyDraft } from './applyDraft'; +import { notifyDraftReady } from './notifyDraft'; /** * Turns queued submissions into drafts. @@ -144,6 +145,14 @@ export async function draftQueued(limit = DEFAULT_BATCH): Promise { } await draftOne(client, row.item_id, row.submitter_note, photos); 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) { const message = err instanceof Error ? err.message : String(err); console.error(`[drafting] item ${row.item_id}: ${message}`); diff --git a/backend/src/intake/notifyDraft.ts b/backend/src/intake/notifyDraft.ts new file mode 100644 index 0000000..4de460c --- /dev/null +++ b/backend/src/intake/notifyDraft.ts @@ -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 { + 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( + `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); +}