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 { try { const { rows } = await pool.query( `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 { try { const { rows } = await pool.query(` 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(); 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
  • // 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}`));