Feature/224 notification impl #265
@@ -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}`);
|
||||||
|
|||||||
@@ -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