Merge pull request 'refactor(backend): type the customer query results (#159)' (#162) from feature/159-type-customer-queries into main
Reviewed-on: #162
This commit was merged in pull request #162.
This commit is contained in:
+106
-21
@@ -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]),
|
||||
|
||||
Reference in New Issue
Block a user