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 { getSettings } from '../adminSettings';
|
||||||
import { loadStoredTemplate } from './adminEmailTemplates';
|
import { loadStoredTemplate } from './adminEmailTemplates';
|
||||||
import { MARKETING_CONSENT_TEXT, isValidEmail } from '../utils';
|
import { MARKETING_CONSENT_TEXT, isValidEmail } from '../utils';
|
||||||
|
import { ItemStatus } from '../types';
|
||||||
import { FAVORITE_ALERTS_CONSENT_TEXT } from '../favoriteAlerts';
|
import { FAVORITE_ALERTS_CONSENT_TEXT } from '../favoriteAlerts';
|
||||||
import { asyncRoute } from '../asyncRoute';
|
import { asyncRoute } from '../asyncRoute';
|
||||||
import { passwordResetRequestLimiter, verificationResendLimiter } from '../rateLimit';
|
import { passwordResetRequestLimiter, verificationResendLimiter } from '../rateLimit';
|
||||||
@@ -90,6 +91,90 @@ interface CustomerRow {
|
|||||||
created_at: Date;
|
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) {
|
function publicCustomer(c: CustomerRow) {
|
||||||
return {
|
return {
|
||||||
id: c.id,
|
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' });
|
return res.status(400).json({ error: 'last name is required' });
|
||||||
}
|
}
|
||||||
const normalizedEmail = String(email).toLowerCase().trim();
|
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' });
|
if (existing.length) return res.status(409).json({ error: 'an account with this email already exists' });
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(password, 12);
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
const unsubscribeToken = crypto.randomBytes(16).toString('hex');
|
const unsubscribeToken = crypto.randomBytes(16).toString('hex');
|
||||||
const consent = !!marketingConsent;
|
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)
|
`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 *`,
|
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) => {
|
router.post('/verify-email', asyncRoute(async (req: Request, res: Response) => {
|
||||||
const { token } = req.body;
|
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()`,
|
`SELECT * FROM customer_tokens WHERE token = $1 AND kind = 'verify_email' AND expires_at > now()`,
|
||||||
[token]
|
[token]
|
||||||
);
|
);
|
||||||
@@ -164,7 +249,7 @@ router.post(
|
|||||||
requireCustomer,
|
requireCustomer,
|
||||||
verificationResendLimiter,
|
verificationResendLimiter,
|
||||||
asyncRoute(async (req: Request, res: Response) => {
|
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];
|
const customer = rows[0];
|
||||||
|
|
||||||
// Refused rather than quietly sending. A pointless email is worse than an
|
// 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' });
|
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];
|
const customer = rows[0];
|
||||||
|
|
||||||
if (customer && !customer.disabled_at) {
|
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' });
|
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()`,
|
`SELECT * FROM customer_tokens WHERE token = $1 AND kind = 'password_reset' AND expires_at > now()`,
|
||||||
[token]
|
[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
|
// A token issued before the account was disabled would otherwise still mint a
|
||||||
// fresh session.
|
// 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) {
|
if (owner[0]?.disabled_at) {
|
||||||
return res.status(403).json({ error: 'this account has been disabled' });
|
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();
|
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);
|
const sessionToken = await createSession(customerId);
|
||||||
setSessionCookie(res, sessionToken);
|
setSessionCookie(res, sessionToken);
|
||||||
res.json(publicCustomer(fresh[0]));
|
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) => {
|
router.post('/login', asyncRoute(async (req: Request, res: Response) => {
|
||||||
const { email, password } = req.body;
|
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];
|
const customer = rows[0];
|
||||||
if (!customer || !(await bcrypt.compare(password || '', customer.password_hash))) {
|
if (!customer || !(await bcrypt.compare(password || '', customer.password_hash))) {
|
||||||
return res.status(401).json({ error: 'invalid email or password' });
|
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) => {
|
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
|
`SELECT f.item_id, f.created_at, i.name, i.status
|
||||||
FROM favorites f JOIN items i ON i.id = f.item_id
|
FROM favorites f JOIN items i ON i.id = f.item_id
|
||||||
WHERE f.customer_id = $1
|
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) => {
|
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' });
|
if (!item.length) return res.status(404).json({ error: 'not found' });
|
||||||
|
|
||||||
// Idempotent: a double click, or two tabs, must not be an error.
|
// 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.
|
// so the record says what was actually agreed to.
|
||||||
router.put('/me/favorite-alerts', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
router.put('/me/favorite-alerts', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||||
const enabled = !!req.body?.enabled;
|
const enabled = !!req.body?.enabled;
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query<CustomerRecord>(
|
||||||
`UPDATE customers
|
`UPDATE customers
|
||||||
SET favorite_alerts = $1,
|
SET favorite_alerts = $1,
|
||||||
favorite_alerts_at = $2,
|
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) => {
|
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' });
|
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
||||||
res.json(publicCustomer(rows[0]));
|
res.json(publicCustomer(rows[0]));
|
||||||
}));
|
}));
|
||||||
@@ -370,7 +455,7 @@ router.put('/me', requireCustomer, asyncRoute(async (req: Request, res: Response
|
|||||||
if (!last) {
|
if (!last) {
|
||||||
return res.status(400).json({ error: 'last name is required' });
|
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 *`,
|
`UPDATE customers SET first_name = $1, last_name = $2 WHERE id = $3 RETURNING *`,
|
||||||
[first, last, req.customerId]
|
[first, last, req.customerId]
|
||||||
);
|
);
|
||||||
@@ -382,7 +467,7 @@ router.post('/change-password', requireCustomer, asyncRoute(async (req: Request,
|
|||||||
if (!newPassword || String(newPassword).length < 8) {
|
if (!newPassword || String(newPassword).length < 8) {
|
||||||
return res.status(400).json({ error: 'new password must be at least 8 characters' });
|
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];
|
const customer = rows[0];
|
||||||
if (!(await bcrypt.compare(currentPassword || '', customer.password_hash))) {
|
if (!(await bcrypt.compare(currentPassword || '', customer.password_hash))) {
|
||||||
return res.status(401).json({ error: 'current password is incorrect' });
|
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' });
|
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];
|
const customer = rows[0];
|
||||||
|
|
||||||
if (!(await bcrypt.compare(String(currentPassword ?? ''), customer.password_hash))) {
|
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' });
|
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) {
|
if (taken.length) {
|
||||||
return res.status(409).json({ error: 'an account with this email already exists' });
|
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)
|
sendMail(previousEmail, notice.subject, notice.html)
|
||||||
.catch(err => console.error('email change notice send failed', err));
|
.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]));
|
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) => {
|
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
|
`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
|
FROM orders o JOIN items i ON i.id = o.item_id
|
||||||
WHERE o.customer_id = $1 ORDER BY o.created_at DESC`,
|
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) => {
|
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: customerRows } = await pool.query<CustomerRecord>(`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: orderRows } = await pool.query<OrderRecord>(`SELECT * FROM orders WHERE customer_id = $1`, [req.customerId]);
|
||||||
res.setHeader('Content-Disposition', 'attachment; filename="my-data.json"');
|
res.setHeader('Content-Disposition', 'attachment; filename="my-data.json"');
|
||||||
res.json({
|
res.json({
|
||||||
customer: publicCustomer(customerRows[0]),
|
customer: publicCustomer(customerRows[0]),
|
||||||
|
|||||||
Reference in New Issue
Block a user