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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user