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>
134 lines
5.6 KiB
TypeScript
Executable File
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}`));
|