refactor(backend): type the customer query results (#159) #162

Merged
bermudalamb merged 1 commits from feature/159-type-customer-queries into main 2026-08-24 13:23:30 -05:00
+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]),