Files
redefined-designs/backend/src/server.ts
T
bermudalambandClaude Opus 5 e163bb39c6 feat(intake): run the drafting worker after a submission and on a sweep (#223)
Two drivers. The call after a successful submission means a draft is usually waiting by the time anybody looks; the five-minute sweep means a restart mid-draft is recoverable rather than a permanently stalled row, and picks up whatever the first call missed.

Neither is awaited. A slow or failing model must not become a failed upload for someone who did nothing wrong, which is the whole reason drafting does not happen inline — the cost of a dropped call is a few minutes' delay, not a lost submission.

Both catch for themselves. The comment beside the existing schedulers points out that `void` is only safe because those functions handle their own errors, and draftQueued does not: its first query can reject, and an escaping rejection would take the container down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 08:32:40 -05:00

134 lines
5.6 KiB
TypeScript
Executable File

import cron from 'node-cron';
import app from './app';
import { pool } from './db';
import { sendMail } from './mailer';
import { renderTemplate, greeting, formatDuration } from './emailTemplates';
import { getSettings } from './adminSettings';
import { loadStoredTemplate } from './routes/adminEmailTemplates';
import { validateEnv } from './envValidation';
import { draftQueued } from './intake/draftingWorker';
// Release cart holds whose expiry has passed.
async function sweepExpiredCarts(): Promise<void> {
try {
const { rows } = await pool.query<ExpiredCartItemRow>(
`DELETE FROM cart_items WHERE expires_at < now() RETURNING item_id`
);
for (const row of rows) {
await pool.query(`UPDATE items SET status = 'available' WHERE id = $1 AND status = 'reserved'`, [row.item_id]);
}
} catch (err) {
console.error('cart expiry sweep failed:', (err as Error).message);
}
}
// Remind customers who opted into marketing email about items still held in
// their cart.
async function sendCartReminders(): Promise<void> {
try {
const { rows } = await pool.query<ReminderRow>(`
SELECT c.email, c.first_name, c.last_name, i.name AS item_name, ci.expires_at, ci.id AS cart_item_id
FROM cart_items ci
JOIN carts ca ON ca.id = ci.cart_id
JOIN customers c ON c.id = ca.customer_id
JOIN items i ON i.id = ci.item_id
WHERE c.marketing_consent = true
AND (ci.last_reminder_sent_at IS NULL OR ci.last_reminder_sent_at < now() - interval '20 hours')
AND ci.expires_at > now()
`);
const byEmail = new Map<string, { firstName: string | null; lastName: string | null; items: { name: string; expiresAt: Date; cartItemId: number }[] }>();
for (const row of rows) {
if (!byEmail.has(row.email)) byEmail.set(row.email, { firstName: row.first_name, lastName: row.last_name, items: [] });
byEmail.get(row.email)!.items.push({ name: row.item_name, expiresAt: row.expires_at, cartItemId: row.cart_item_id });
}
// Loaded once rather than per recipient: the copy is shared, only the
// greeting and the item list differ.
const stored = await loadStoredTemplate('cartReminder');
const { cartExpiryHours, greetingFormat, greetingFallback } = await getSettings();
const holdDuration = formatDuration(cartExpiryHours);
for (const [email, data] of byEmail) {
// Markdown, not HTML. Values are substituted into the template source
// before it is rendered, and the renderer escapes raw HTML — so an <li>
// here would reach the customer as literal angle brackets.
const itemList = data.items
.map(i => `- ${i.name} — reserved until ${i.expiresAt.toLocaleString()}`)
.join('\n');
const { subject, html } = renderTemplate('cartReminder', stored, {
greeting: greeting(data.firstName, greetingFormat, greetingFallback, data.lastName),
firstName: data.firstName ?? '',
lastName: data.lastName ?? '',
itemList,
cartUrl: `${process.env.PUBLIC_URL}/cart`,
holdDuration
});
await sendMail(email, subject, html);
const ids = data.items.map(i => i.cartItemId);
await pool.query(`UPDATE cart_items SET last_reminder_sent_at = now() WHERE id = ANY($1::int[])`, [ids]);
}
} catch (err) {
console.error('daily cart reminder job failed:', (err as Error).message);
}
}
/** What the expiry sweep releases, so the items can be returned to the shop. */
interface ExpiredCartItemRow {
item_id: number;
}
/** One held item and who to remind about it. */
interface ReminderRow {
email: string;
first_name: string | null;
last_name: string | null;
item_name: string;
expires_at: Date;
cart_item_id: number;
}
// Neither scheduler has anything to await these with, so `void` states that the
// promise is deliberately dropped. That is only safe because both functions
// catch their own errors above — an escaping rejection would be unhandled, and
// Node terminates the process on those by default, so a database blip during
// the sweep would take the container down with it.
setInterval(() => void sweepExpiredCarts(), 5 * 60 * 1000);
cron.schedule('0 9 * * *', () => void sendCartReminders());
// Every five minutes, in the same shape as the cart sweep. This is what makes a
// restart mid-draft recoverable rather than a permanently stalled row, and what
// picks up anything the post-submission call missed. Unlike the two above,
// draftQueued does not catch at its own top level — the initial query can
// reject — so it catches here instead, for the reason the comment above gives.
setInterval(
() => void draftQueued().catch((err) => console.error('[drafting] sweep:', err)),
5 * 60 * 1000
);
const PORT = parseInt(process.env.PORT || '3000', 10);
// Checked at boot rather than left to be discovered by the first request that
// happens to need a missing value. Every problem is reported at once — fixing a
// fresh environment one restart at a time is miserable — and anything fatal
// stops the process, the same way a failed migration does rather than serving
// against a schema it does not match. The admin-gate warning lives here too now
// (#63), so there is one place that says what this container is and is not
// configured to do. See envValidation.ts and #64.
const { errors, warnings } = validateEnv(process.env);
for (const warning of warnings) {
console.warn(`[config] ${warning}`);
}
if (errors.length) {
console.error(`[config] refusing to start — ${errors.length} problem(s) with the environment:`);
for (const error of errors) {
console.error(`[config] - ${error}`);
}
process.exit(1);
}
app.listen(PORT, () => console.log(`redefined-designs listening on ${PORT}`));