diff --git a/backend/src/routes/customers.ts b/backend/src/routes/customers.ts index d7c33b0..07c71c1 100755 --- a/backend/src/routes/customers.ts +++ b/backend/src/routes/customers.ts @@ -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(`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( `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( `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(`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(`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( `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(`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(`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(`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( `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(`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( `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(`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( `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(`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(`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(`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(`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( `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(`SELECT * FROM customers WHERE id = $1`, [req.customerId]); + const { rows: orderRows } = await pool.query(`SELECT * FROM orders WHERE customer_id = $1`, [req.customerId]); res.setHeader('Content-Disposition', 'attachment; filename="my-data.json"'); res.json({ customer: publicCustomer(customerRows[0]),