import cron from 'node-cron'; import app from './app'; import { pool } from './db'; import { sendMail } from './mailer'; // 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.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, { name: row.name, items: [] }); byEmail.get(row.email)!.items.push({ name: row.item_name, expiresAt: row.expires_at, cartItemId: row.cart_item_id }); } for (const [email, data] of byEmail) { const itemList = data.items.map(i => `
  • ${i.name} — reserved until ${i.expiresAt.toLocaleString()}
  • `).join(''); await sendMail( email, 'Items waiting in your cart', `

    Hi${data.name ? ' ' + data.name : ''},

    You still have items in your cart at Redefined Designs:

      ${itemList}

    View your cart before your reservation expires.

    ` ); 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); } } // 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()); const PORT = parseInt(process.env.PORT || '3000', 10); // Said at boot rather than left to be discovered. Without the secret the admin // API is protected only by the reverse proxy's auth_request regex, which lives // outside this repository and is bypassed entirely by anything reaching this // container's published port directly. That is a defensible way to run — it is // how this app has always run — but it should be a visible choice rather than a // silent one. See middleware/adminGate.ts and #63. if (!process.env.ADMIN_GATE_SECRET) { console.warn( '[admin-gate] ADMIN_GATE_SECRET is not set — /api/admin is protected only by the reverse ' + 'proxy. Anything able to reach this container directly can administer the store.' ); } app.listen(PORT, () => console.log(`redefined-designs listening on ${PORT}`));