refactor(backend): type the customer query results (#159)
Linting / lint (pull_request) Successful in 1m57s
SonarQube Analysis / sonarqube (pull_request) Failing after 5m3s

The largest file, 43 query sites, now with none of its reads untyped. Typed sites across the backend go from 22 to 43.

CustomerRecord extends the existing CustomerRow rather than restating it, because that is the relationship that actually holds. CustomerRow was already there and is not a table row — it is the subset safe to return to the customer, written that way so adding a column could not silently start being echoed back by a `...c` downstream. The full row read by `SELECT *` is that subset plus seven fields that are deliberately not on it, password_hash among them. Extending keeps the two connected: adding a column to the table means adding it to CustomerRecord and deciding at that moment whether it belongs in CustomerRow, which is exactly the decision the older comment is about.

The column list came from the live schema rather than from reading migrations, since the migrations are additive and reconstructing the current shape from six files invites getting a nullability wrong.

Typing the data export surfaced something worth a decision, and it is recorded in the code rather than quietly changed. `GET /me/export` runs `SELECT * FROM orders` and sends every column verbatim, including raw_event — the processor's entire capture payload. That is defensible for a GDPR export, since it is the customer's own transaction, but it is a decision rather than an accident, and it is now visible in a type instead of hidden behind `any`. The order-history route two functions above deliberately selects six named columns instead, which is the contrast that makes the export's behaviour worth confirming. No behaviour changed here; #159 is about types.

Nullability follows the schema rather than optimism: orders.amount_cents, status, item_id, customer_id and checkout_id are all nullable in Postgres, and customers.first_name and last_name are nullable despite registration requiring them, because customers who registered while the field was optional genuinely have none.

Verified: tsc clean, and the full integration suite passes 238/238 across 17 suites.

Refs #159
This commit is contained in:
2026-08-24 13:19:18 -05:00
parent bd30d20c20
commit c5fe84fba5
+106 -21
View File
@@ -8,6 +8,7 @@ import { renderTemplate, greeting, formatDuration } from '../emailTemplates';
import { getSettings } from '../adminSettings';
import { loadStoredTemplate } from './adminEmailTemplates';
import { MARKETING_CONSENT_TEXT, isValidEmail } from '../utils';
import { ItemStatus } from '../types';
import { FAVORITE_ALERTS_CONSENT_TEXT } from '../favoriteAlerts';
import { asyncRoute } from '../asyncRoute';
import { passwordResetRequestLimiter, verificationResendLimiter } from '../rateLimit';
@@ -90,6 +91,90 @@ interface CustomerRow {
created_at: Date;
}
/**
* A whole `customers` row, as `SELECT *` returns it.
*
* Extends CustomerRow rather than restating it, so the relationship is the one
* that actually holds: everything safe to return is also on the record, and the
* fields below are the ones that are not. Adding a column to the table means
* adding it here and deciding, at that moment, whether it belongs in
* CustomerRow too — which is the decision the comment above is about.
*
* Kept in step with the schema by hand; nothing checks this against Postgres.
*/
interface CustomerRecord extends CustomerRow {
password_hash: string;
disabled_at: Date | null;
unsubscribe_token: string;
marketing_consent_at: Date | null;
marketing_consent_text: string | null;
favorite_alerts_at: Date | null;
favorite_alerts_text: string | null;
}
/** Rows that are only ever probed for existence. */
interface IdRow {
id: number;
}
/**
* A single-use link. `kind` distinguishes verification from password reset;
* both are read the same way and both are deleted once spent.
*/
interface CustomerTokenRow {
token: string;
customer_id: number;
kind: string;
expires_at: Date;
created_at: Date;
}
/** Just the flag the disabled check reads. */
interface DisabledAtRow {
disabled_at: Date | null;
}
/** A favorited item as the account page lists it. */
interface FavoriteRow {
item_id: number;
created_at: Date;
name: string;
status: ItemStatus;
}
/**
* A whole `orders` row, as the data export returns it.
*
* Worth reading before changing the export: `raw_event` is the processor's
* entire capture payload, and this route sends every column of this row to the
* customer verbatim. That is defensible for a GDPR export — it is their
* transaction — but it is a decision rather than an accident, and typing it is
* what makes it visible. The order-history route above deliberately selects six
* named columns instead.
*/
interface OrderRecord {
id: number;
item_id: number | null;
customer_id: number | null;
checkout_id: number | null;
processor: string;
processor_order_id: string | null;
amount_cents: number | null;
status: string | null;
raw_event: unknown;
created_at: Date;
}
/** One line of a customer's own order history. */
interface CustomerOrderRow {
id: number;
processor: string;
amount_cents: number;
status: string;
created_at: Date;
item_name: string;
}
function publicCustomer(c: CustomerRow) {
return {
id: c.id,
@@ -119,14 +204,14 @@ router.post('/register', asyncRoute(async (req: Request, res: Response) => {
return res.status(400).json({ error: 'last name is required' });
}
const normalizedEmail = String(email).toLowerCase().trim();
const { rows: existing } = await pool.query(`SELECT id FROM customers WHERE email = $1`, [normalizedEmail]);
const { rows: existing } = await pool.query<IdRow>(`SELECT id FROM customers WHERE email = $1`, [normalizedEmail]);
if (existing.length) return res.status(409).json({ error: 'an account with this email already exists' });
const passwordHash = await bcrypt.hash(password, 12);
const unsubscribeToken = crypto.randomBytes(16).toString('hex');
const consent = !!marketingConsent;
const { rows } = await pool.query(
const { rows } = await pool.query<CustomerRecord>(
`INSERT INTO customers (email, password_hash, first_name, last_name, marketing_consent, marketing_consent_at, marketing_consent_text, unsubscribe_token)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
[
@@ -146,7 +231,7 @@ router.post('/register', asyncRoute(async (req: Request, res: Response) => {
router.post('/verify-email', asyncRoute(async (req: Request, res: Response) => {
const { token } = req.body;
const { rows } = await pool.query(
const { rows } = await pool.query<CustomerTokenRow>(
`SELECT * FROM customer_tokens WHERE token = $1 AND kind = 'verify_email' AND expires_at > now()`,
[token]
);
@@ -164,7 +249,7 @@ router.post(
requireCustomer,
verificationResendLimiter,
asyncRoute(async (req: Request, res: Response) => {
const { rows } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
const { rows } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
const customer = rows[0];
// Refused rather than quietly sending. A pointless email is worse than an
@@ -190,7 +275,7 @@ router.post('/request-password-reset', passwordResetRequestLimiter, asyncRoute(a
return res.status(400).json({ error: 'a valid email is required' });
}
const { rows } = await pool.query(`SELECT * FROM customers WHERE email = $1`, [email]);
const { rows } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE email = $1`, [email]);
const customer = rows[0];
if (customer && !customer.disabled_at) {
@@ -234,7 +319,7 @@ router.post('/reset-password', asyncRoute(async (req: Request, res: Response) =>
return res.status(400).json({ error: 'password must be at least 8 characters' });
}
const { rows } = await pool.query(
const { rows } = await pool.query<CustomerTokenRow>(
`SELECT * FROM customer_tokens WHERE token = $1 AND kind = 'password_reset' AND expires_at > now()`,
[token]
);
@@ -243,7 +328,7 @@ router.post('/reset-password', asyncRoute(async (req: Request, res: Response) =>
// A token issued before the account was disabled would otherwise still mint a
// fresh session.
const { rows: owner } = await pool.query(`SELECT disabled_at FROM customers WHERE id = $1`, [customerId]);
const { rows: owner } = await pool.query<DisabledAtRow>(`SELECT disabled_at FROM customers WHERE id = $1`, [customerId]);
if (owner[0]?.disabled_at) {
return res.status(403).json({ error: 'this account has been disabled' });
}
@@ -273,7 +358,7 @@ router.post('/reset-password', asyncRoute(async (req: Request, res: Response) =>
client.release();
}
const { rows: fresh } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [customerId]);
const { rows: fresh } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [customerId]);
const sessionToken = await createSession(customerId);
setSessionCookie(res, sessionToken);
res.json(publicCustomer(fresh[0]));
@@ -281,7 +366,7 @@ router.post('/reset-password', asyncRoute(async (req: Request, res: Response) =>
router.post('/login', asyncRoute(async (req: Request, res: Response) => {
const { email, password } = req.body;
const { rows } = await pool.query(`SELECT * FROM customers WHERE email = $1`, [String(email || '').toLowerCase().trim()]);
const { rows } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE email = $1`, [String(email || '').toLowerCase().trim()]);
const customer = rows[0];
if (!customer || !(await bcrypt.compare(password || '', customer.password_hash))) {
return res.status(401).json({ error: 'invalid email or password' });
@@ -304,7 +389,7 @@ router.post('/logout', asyncRoute(async (req: Request, res: Response) => {
}));
router.get('/me/favorites', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
const { rows } = await pool.query(
const { rows } = await pool.query<FavoriteRow>(
`SELECT f.item_id, f.created_at, i.name, i.status
FROM favorites f JOIN items i ON i.id = f.item_id
WHERE f.customer_id = $1
@@ -315,7 +400,7 @@ router.get('/me/favorites', requireCustomer, asyncRoute(async (req: Request, res
}));
router.post('/me/favorites/:itemId', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
const { rows: item } = await pool.query(`SELECT id FROM items WHERE id = $1`, [req.params.itemId]);
const { rows: item } = await pool.query<IdRow>(`SELECT id FROM items WHERE id = $1`, [req.params.itemId]);
if (!item.length) return res.status(404).json({ error: 'not found' });
// Idempotent: a double click, or two tabs, must not be an error.
@@ -337,7 +422,7 @@ router.delete('/me/favorites/:itemId', requireCustomer, asyncRoute(async (req: R
// so the record says what was actually agreed to.
router.put('/me/favorite-alerts', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
const enabled = !!req.body?.enabled;
const { rows } = await pool.query(
const { rows } = await pool.query<CustomerRecord>(
`UPDATE customers
SET favorite_alerts = $1,
favorite_alerts_at = $2,
@@ -349,7 +434,7 @@ router.put('/me/favorite-alerts', requireCustomer, asyncRoute(async (req: Reques
}));
router.get('/me', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
const { rows } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
const { rows } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
if (!rows.length) return res.status(404).json({ error: 'not found' });
res.json(publicCustomer(rows[0]));
}));
@@ -370,7 +455,7 @@ router.put('/me', requireCustomer, asyncRoute(async (req: Request, res: Response
if (!last) {
return res.status(400).json({ error: 'last name is required' });
}
const { rows } = await pool.query(
const { rows } = await pool.query<CustomerRecord>(
`UPDATE customers SET first_name = $1, last_name = $2 WHERE id = $3 RETURNING *`,
[first, last, req.customerId]
);
@@ -382,7 +467,7 @@ router.post('/change-password', requireCustomer, asyncRoute(async (req: Request,
if (!newPassword || String(newPassword).length < 8) {
return res.status(400).json({ error: 'new password must be at least 8 characters' });
}
const { rows } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
const { rows } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
const customer = rows[0];
if (!(await bcrypt.compare(currentPassword || '', customer.password_hash))) {
return res.status(401).json({ error: 'current password is incorrect' });
@@ -414,7 +499,7 @@ router.put('/me/email', requireCustomer, asyncRoute(async (req: Request, res: Re
return res.status(400).json({ error: 'a valid email is required' });
}
const { rows } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
const { rows } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
const customer = rows[0];
if (!(await bcrypt.compare(String(currentPassword ?? ''), customer.password_hash))) {
@@ -425,7 +510,7 @@ router.put('/me/email', requireCustomer, asyncRoute(async (req: Request, res: Re
return res.status(400).json({ error: 'that is already your email address' });
}
const { rows: taken } = await pool.query(`SELECT id FROM customers WHERE email = $1`, [normalized]);
const { rows: taken } = await pool.query<IdRow>(`SELECT id FROM customers WHERE email = $1`, [normalized]);
if (taken.length) {
return res.status(409).json({ error: 'an account with this email already exists' });
}
@@ -456,7 +541,7 @@ router.put('/me/email', requireCustomer, asyncRoute(async (req: Request, res: Re
sendMail(previousEmail, notice.subject, notice.html)
.catch(err => console.error('email change notice send failed', err));
const { rows: updated } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
const { rows: updated } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
res.json(publicCustomer(updated[0]));
}));
@@ -470,7 +555,7 @@ router.post('/me/consent', requireCustomer, asyncRoute(async (req: Request, res:
}));
router.get('/me/orders', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
const { rows } = await pool.query(
const { rows } = await pool.query<CustomerOrderRow>(
`SELECT o.id, o.processor, 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`,
@@ -480,8 +565,8 @@ router.get('/me/orders', requireCustomer, asyncRoute(async (req: Request, res: R
}));
router.get('/me/export', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
const { rows: customerRows } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
const { rows: orderRows } = await pool.query(`SELECT * FROM orders WHERE customer_id = $1`, [req.customerId]);
const { rows: customerRows } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
const { rows: orderRows } = await pool.query<OrderRecord>(`SELECT * FROM orders WHERE customer_id = $1`, [req.customerId]);
res.setHeader('Content-Disposition', 'attachment; filename="my-data.json"');
res.json({
customer: publicCustomer(customerRows[0]),