Files
redefined-designs/backend/src/routes/adminCustomers.ts
T
bermudalambandClaude Opus 5 b287c07747
Tests / lint (pull_request) Successful in 1m38s
Tests / backend-unit (pull_request) Successful in 1m44s
Tests / frontend-e2e (pull_request) Failing after 9m50s
SonarQube Analysis / sonarqube (pull_request) Failing after 11m46s
feat: capture first and last name so emails can greet informally (#106)
Registration collected one optional Name, so every greeting had only a whole name to use: "Hi Thom Lamb," rather than "Hi Thom,". Both parts are now captured, and the cart reminder greets by first name.

Both are required of anyone new, refused individually rather than as a single "name is required" so a form that filled one and not the other is told which.

The columns are nullable even so, and that is deliberate. Marking them NOT NULL would mean backfilling legacy rows with empty strings, which asserts that every customer has a name — untrue of anyone who registered while the field was optional. The table records what is actually the case; the rule that new registrations must supply both lives in the route, where a missing field can produce a message naming it.

The backfill splits on the first space, and it is lossy in a way no version of this avoids. "Thom Lamb" becomes Thom and Lamb; "Mary Jane Smith" gets a last name of "Jane Smith"; names that are not two parts fare worse. It was chosen over leaving the columns empty because nothing currently lets a customer correct their own name — PUT /api/customers/me exists but no frontend calls it — so empty would have meant permanently unpersonalised for every existing customer. The migration says so, so nobody later reads backfilled values as data the customer supplied in that shape.

Verified against a seeded database rather than reasoned about, because this is the part that cannot be covered by the suite: migrations run in globalSetup before any test, and the old column is gone afterwards. Six representative rows through the real migration gave Thom/Lamb, Mary/"Jane Smith", Cher/null, "  Padded  Name  " trimmed to Padded/Name, and null and whitespace-only names left as null on both. The down migration rejoins the parts and returns all six to their original strings.

The old column is dropped rather than kept alongside, so there is one source of truth instead of two that drift.

The admin keeps receiving a single composed display name. It only ever shows one — the list cell and the drawer title — and never edits one, so giving it both parts plus the joining logic would be work for no reader.

Churn was the bulk of this: 14 backend registrations and 10 end-to-end registration forms. A first attempt at the backend fixtures also added names to login and password-reset payloads, which would still have passed since the server ignores unknown fields, but a login test implying login takes a name is a small lie; that was reverted and redone against register calls only.

Verified: 172 unit, 183 integration and 95 end-to-end passing, lint unchanged at 4 backend and 27 frontend warnings.

Not covered: the cart reminder itself, which runs from a cron and had no test before this either. The greeting change is a one-line substitution in that query.

Refs #106
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:23:13 -05:00

164 lines
6.0 KiB
TypeScript
Executable File

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;