import { Router, Request, Response } from 'express'; import { pool } from '../db'; import { asyncRoute } from '../asyncRoute'; const router = Router(); router.get('/', asyncRoute(async (_req: Request, res: Response) => { const { rows } = await pool.query(` SELECT c.id, c.email, nullif(btrim(concat_ws(' ', c.first_name, c.last_name)), '') AS name, c.email_verified, c.marketing_consent, c.created_at, c.disabled_at, COUNT(o.id) FILTER (WHERE o.status = 'completed') AS order_count, COALESCE(SUM(o.amount_cents) FILTER (WHERE o.status = 'completed'), 0) AS total_spent_cents, MAX(o.created_at) AS last_order_at, -- Counted with a subquery rather than another LEFT JOIN: joining a second -- one-to-many relation alongside orders would multiply the rows and -- inflate order_count and total_spent_cents. (SELECT COUNT(*)::int FROM cart_items ci JOIN carts ca ON ca.id = ci.cart_id JOIN items i ON i.id = ci.item_id WHERE ca.customer_id = c.id AND i.status = 'reserved') AS reserved_count FROM customers c LEFT JOIN orders o ON o.customer_id = c.id GROUP BY c.id ORDER BY c.created_at DESC `); res.json(rows); })); // Disabling is reversible, so this records a timestamp rather than flipping a // boolean — when it happened comes free. // // Everything below happens in one transaction. A disable that evicted the // sessions but left the cart held, or vice versa, would be worse than either // outcome alone. router.post('/:id/disable', asyncRoute(async (req: Request, res: Response) => { const client = await pool.connect(); try { await client.query('BEGIN'); const { rows } = await client.query( `UPDATE customers SET disabled_at = now() WHERE id = $1 RETURNING id`, [req.params.id] ); if (!rows.length) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'not found' }); } // Immediate eviction. attachCustomer also refuses a disabled account, so // this is belt and braces — but it means the rows are gone rather than // lingering until their 30-day expiry. await client.query(`DELETE FROM customer_sessions WHERE customer_id = $1`, [req.params.id]); // A disabled account cannot check out, so holding one-of-a-kind stock off // the storefront until the expiry sweep serves nobody. Guarded on // 'reserved' so a sold item is never resurrected. const { rows: held } = await client.query( `DELETE FROM cart_items ci USING carts ca WHERE ci.cart_id = ca.id AND ca.customer_id = $1 RETURNING ci.item_id`, [req.params.id] ); if (held.length) { await client.query( `UPDATE items SET status = 'available', reserved_until = NULL WHERE id = ANY($1::int[]) AND status = 'reserved'`, [held.map((row: { item_id: number }) => row.item_id)] ); } await client.query('COMMIT'); res.status(204).end(); } catch (err) { await client.query('ROLLBACK'); throw err; } finally { client.release(); } })); // Restores sign-in only. Items released by the disable stay released — they may // well have been sold to someone else in the meantime, and silently re-reserving // them would be worse than making the customer add them again. router.post('/:id/enable', asyncRoute(async (req: Request, res: Response) => { const { rows } = await pool.query( `UPDATE customers SET disabled_at = NULL WHERE id = $1 RETURNING id`, [req.params.id] ); if (!rows.length) return res.status(404).json({ error: 'not found' }); res.status(204).end(); })); router.get('/:id/reserved', asyncRoute(async (req: Request, res: Response) => { const { rows } = await pool.query( `SELECT i.id AS item_id, i.name, i.price_cents, ci.added_at, ci.expires_at FROM cart_items ci JOIN carts ca ON ca.id = ci.cart_id JOIN items i ON i.id = ci.item_id WHERE ca.customer_id = $1 AND i.status = 'reserved' ORDER BY ci.added_at`, [req.params.id] ); res.json(rows); })); // Mirrors the customer's own cart removal: drop the cart row and return the // item to available. Deliberately no email — this is an action the customer // did not take, and an unprompted "we removed your item" invites confusion. router.post('/:id/reserved/:itemId/release', asyncRoute(async (req: Request, res: Response) => { const client = await pool.connect(); try { await client.query('BEGIN'); const { rows } = await client.query( `DELETE FROM cart_items ci USING carts ca WHERE ci.cart_id = ca.id AND ca.customer_id = $1 AND ci.item_id = $2 RETURNING ci.item_id`, [req.params.id, req.params.itemId] ); if (!rows.length) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'that customer is not holding this item' }); } // Guarded on 'reserved' so releasing never resurrects a sold item. await client.query( `UPDATE items SET status = 'available', reserved_until = NULL WHERE id = $1 AND status = 'reserved'`, [req.params.itemId] ); await client.query('COMMIT'); res.status(204).end(); } catch (err) { await client.query('ROLLBACK'); throw err; } finally { client.release(); } })); router.get('/:id', asyncRoute(async (req: Request, res: Response) => { const { rows: customerRows } = await pool.query( `SELECT id, email, nullif(btrim(concat_ws(' ', first_name, last_name)), '') AS name, email_verified, marketing_consent, marketing_consent_at, created_at FROM customers WHERE id = $1`, [req.params.id] ); if (!customerRows.length) return res.status(404).json({ error: 'not found' }); const { rows: orderRows } = await pool.query( `SELECT o.id, o.processor, o.processor_order_id, o.amount_cents, o.status, o.created_at, i.name AS item_name FROM orders o JOIN items i ON i.id = o.item_id WHERE o.customer_id = $1 ORDER BY o.created_at DESC`, [req.params.id] ); res.json({ customer: customerRows[0], orders: orderRows }); })); export default router;