A verification email was sent once, at registration. If it was lost, filtered, or arrived after the 24-hour token had expired, the customer had no route back except registering again with a different address. POST /api/customers/resend-verification mints a fresh token and sends the mail, and the account page grows a "Send it again" button beside the warning that was already there. The button only exists while the address is unverified: on a verified account it would be a control whose only outcome is a refusal, and the endpoint refuses that case rather than sending a pointless email. The mint-token-and-send sequence now lives in one place. Registration and the email change already did the same three steps - supersede any outstanding token, mint a new one, send it - and this would have been a third copy. The step most likely to be dropped in a copy is the supersede, and it is the one that matters: without it an older message still sitting in the inbox goes on verifying. Anything that makes the server send mail on request is an abuse vector, so this is rate limited to three an hour, keyed on the customer id. That is tighter than either existing limiter and sidesteps #84's IPv6 problem entirely, since a signed-in caller has an identity better than an address to count against and cannot escape the bucket by moving within a delegated prefix. The refusal says the mail probably did send and to check the spam folder, which is both more useful and more honest than a bare 429. The claim that keying on customer id also solved test isolation was wrong, and the tests caught it. resetDb truncates with RESTART IDENTITY, so every integration test's first customer is id 1: three tests that each sent once left the fourth starting at its limit, and two tests failed on a 429 they never asked for. A "fresh customer per test" is a fresh row with a recycled identity. The limiter now has an explicit exported store the suite clears between tests, and the comment that claimed otherwise has been corrected rather than left to mislead the next reader. Verification: seven integration tests covering the unauthenticated refusal, the send, the new token invalidating the old one - asserted through the endpoint that would honour the stale link rather than by counting rows - the new link actually verifying, the already-verified refusal, the allowance stopping the fourth send rather than merely reporting it, and one customer's exhausted allowance leaving another's intact. Three end-to-end tests for the button, its confirmation and the message on the fourth click. The 33 integration tests across the three suites this touched all pass, as do the 199 backend unit tests. tsc clean on both sides, ESLint no errors. Closes #110 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
491 lines
21 KiB
TypeScript
Executable File
491 lines
21 KiB
TypeScript
Executable File
import { Router, Request, Response } from 'express';
|
|
import bcrypt from 'bcryptjs';
|
|
import crypto from 'node:crypto';
|
|
import { pool } from '../db';
|
|
import { requireCustomer } from '../middleware/customerAuth';
|
|
import { sendMail } from '../mailer';
|
|
import { renderTemplate, greeting } from '../emailTemplates';
|
|
import { loadStoredTemplate } from './adminEmailTemplates';
|
|
import { MARKETING_CONSENT_TEXT, isValidEmail } from '../utils';
|
|
import { FAVORITE_ALERTS_CONSENT_TEXT } from '../favoriteAlerts';
|
|
import { asyncRoute } from '../asyncRoute';
|
|
import { passwordResetRequestLimiter, verificationResendLimiter } from '../rateLimit';
|
|
|
|
const router = Router();
|
|
|
|
const SESSION_DAYS = 30;
|
|
|
|
const VERIFY_TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
|
|
|
|
// Registration, changing an address, and resending all need the same three
|
|
// steps: supersede any outstanding link, mint a new one, send it. Written out
|
|
// three times they would drift, and the step most likely to be forgotten is the
|
|
// first — which is the one that matters, since an un-superseded link means an
|
|
// older message in the inbox still verifies.
|
|
//
|
|
// Sending is fire-and-forget by the same rule the rest of this file follows:
|
|
// the token row is written first, so a send that fails cannot leave a customer
|
|
// believing a link exists that does not, only waiting for one that never came.
|
|
async function issueVerificationEmail(
|
|
customerId: number,
|
|
email: string,
|
|
firstName: string | null
|
|
): Promise<void> {
|
|
await pool.query(
|
|
`DELETE FROM customer_tokens WHERE customer_id = $1 AND kind = 'verify_email'`,
|
|
[customerId]
|
|
);
|
|
const token = crypto.randomBytes(24).toString('hex');
|
|
await pool.query(
|
|
`INSERT INTO customer_tokens (token, customer_id, kind, expires_at) VALUES ($1, $2, 'verify_email', $3)`,
|
|
[token, customerId, new Date(Date.now() + VERIFY_TOKEN_TTL_MS)]
|
|
);
|
|
const verifyUrl = `${process.env.PUBLIC_URL}/verify-email?token=${token}`;
|
|
const template = renderTemplate('verification', await loadStoredTemplate('verification'), {
|
|
greeting: greeting(firstName),
|
|
verifyUrl
|
|
});
|
|
sendMail(email, template.subject, template.html)
|
|
.catch(err => console.error('verify email send failed', err));
|
|
}
|
|
|
|
function setSessionCookie(res: Response, token: string) {
|
|
res.cookie('rd_session', token, {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: 'lax',
|
|
maxAge: SESSION_DAYS * 24 * 60 * 60 * 1000
|
|
});
|
|
}
|
|
|
|
async function createSession(customerId: number): Promise<string> {
|
|
const token = crypto.randomBytes(32).toString('hex');
|
|
const expiresAt = new Date(Date.now() + SESSION_DAYS * 24 * 60 * 60 * 1000);
|
|
await pool.query(
|
|
`INSERT INTO customer_sessions (token, customer_id, expires_at) VALUES ($1, $2, $3)`,
|
|
[token, customerId, expiresAt]
|
|
);
|
|
return token;
|
|
}
|
|
|
|
// The subset of a customers row that is safe to return to the customer it
|
|
// belongs to. Typed as its own shape rather than `any` so that adding a column
|
|
// to the table — a password hash, a token, an internal note — cannot silently
|
|
// start being echoed back by a `...c` somewhere downstream.
|
|
interface CustomerRow {
|
|
id: number;
|
|
email: string;
|
|
// Nullable despite registration requiring both, because customers who
|
|
// registered while the field was optional genuinely have no name. The
|
|
// requirement is enforced at registration, not asserted by the schema.
|
|
first_name: string | null;
|
|
last_name: string | null;
|
|
email_verified: boolean;
|
|
marketing_consent: boolean;
|
|
favorite_alerts: boolean;
|
|
created_at: Date;
|
|
}
|
|
|
|
function publicCustomer(c: CustomerRow) {
|
|
return {
|
|
id: c.id,
|
|
email: c.email,
|
|
first_name: c.first_name,
|
|
last_name: c.last_name,
|
|
email_verified: c.email_verified,
|
|
marketing_consent: c.marketing_consent,
|
|
favorite_alerts: c.favorite_alerts,
|
|
created_at: c.created_at
|
|
};
|
|
}
|
|
|
|
router.post('/register', asyncRoute(async (req: Request, res: Response) => {
|
|
const { email, password, firstName, lastName, marketingConsent } = req.body;
|
|
if (!email || !isValidEmail(String(email)) || !password || String(password).length < 8) {
|
|
return res.status(400).json({ error: 'valid email and password (min 8 chars) required' });
|
|
}
|
|
// Named individually rather than as one "name is required", so a form that
|
|
// filled one field and not the other is told which.
|
|
const first = String(firstName ?? '').trim();
|
|
const last = String(lastName ?? '').trim();
|
|
if (!first) {
|
|
return res.status(400).json({ error: 'first name is required' });
|
|
}
|
|
if (!last) {
|
|
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]);
|
|
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(
|
|
`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 *`,
|
|
[
|
|
normalizedEmail, passwordHash, first, last,
|
|
consent, consent ? new Date() : null, consent ? MARKETING_CONSENT_TEXT : null,
|
|
unsubscribeToken
|
|
]
|
|
);
|
|
const customer = rows[0];
|
|
|
|
await issueVerificationEmail(customer.id, customer.email, customer.first_name);
|
|
|
|
const sessionToken = await createSession(customer.id);
|
|
setSessionCookie(res, sessionToken);
|
|
res.json(publicCustomer(customer));
|
|
}));
|
|
|
|
router.post('/verify-email', asyncRoute(async (req: Request, res: Response) => {
|
|
const { token } = req.body;
|
|
const { rows } = await pool.query(
|
|
`SELECT * FROM customer_tokens WHERE token = $1 AND kind = 'verify_email' AND expires_at > now()`,
|
|
[token]
|
|
);
|
|
if (!rows.length) return res.status(400).json({ error: 'invalid or expired token' });
|
|
await pool.query(`UPDATE customers SET email_verified = true WHERE id = $1`, [rows[0].customer_id]);
|
|
await pool.query(`DELETE FROM customer_tokens WHERE token = $1`, [token]);
|
|
res.json({ status: 'verified' });
|
|
}));
|
|
|
|
// The limiter is mounted after requireCustomer, deliberately: it keys on
|
|
// req.customerId, which does not exist until requireCustomer has run. Mounted
|
|
// the other way round every anonymous caller would share one bucket.
|
|
router.post(
|
|
'/resend-verification',
|
|
requireCustomer,
|
|
verificationResendLimiter,
|
|
asyncRoute(async (req: Request, res: Response) => {
|
|
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
|
|
// answer, and the account page has no reason to offer the button here.
|
|
if (customer.email_verified) {
|
|
return res.status(400).json({ error: 'your email address is already verified' });
|
|
}
|
|
|
|
await issueVerificationEmail(customer.id, customer.email, customer.first_name);
|
|
res.status(204).end();
|
|
})
|
|
);
|
|
|
|
const RESET_TOKEN_TTL_MS = 60 * 60 * 1000;
|
|
|
|
// Always answers 200, whether or not the address has an account. A response
|
|
// that differed would let anyone test addresses for membership.
|
|
//
|
|
// Note /register still reveals existence via its 409 on a duplicate address,
|
|
// so this protection is currently partial — closing that is its own change.
|
|
router.post('/request-password-reset', passwordResetRequestLimiter, asyncRoute(async (req: Request, res: Response) => {
|
|
const email = String(req.body?.email || '').toLowerCase().trim();
|
|
if (!email || !isValidEmail(email)) {
|
|
return res.status(400).json({ error: 'a valid email is required' });
|
|
}
|
|
|
|
const { rows } = await pool.query(`SELECT * FROM customers WHERE email = $1`, [email]);
|
|
const customer = rows[0];
|
|
|
|
if (customer && !customer.disabled_at) {
|
|
// Supersede any outstanding token, so a link cannot be resurrected later
|
|
// from an older message in the customer's inbox.
|
|
await pool.query(`DELETE FROM customer_tokens WHERE customer_id = $1 AND kind = 'password_reset'`, [customer.id]);
|
|
|
|
const token = crypto.randomBytes(32).toString('hex');
|
|
await pool.query(
|
|
`INSERT INTO customer_tokens (token, customer_id, kind, expires_at) VALUES ($1, $2, 'password_reset', $3)`,
|
|
[token, customer.id, new Date(Date.now() + RESET_TOKEN_TTL_MS)]
|
|
);
|
|
|
|
const resetUrl = `${process.env.PUBLIC_URL}/reset-password?token=${token}`;
|
|
const resetTemplate = renderTemplate('passwordReset', await loadStoredTemplate('passwordReset'), {
|
|
greeting: greeting(customer.first_name),
|
|
resetUrl
|
|
});
|
|
sendMail(customer.email, resetTemplate.subject, resetTemplate.html)
|
|
.catch(err => console.error('password reset email send failed', err));
|
|
}
|
|
|
|
res.json({ status: 'sent' });
|
|
}));
|
|
|
|
// Deliberately not rate limited. The limiter above is keyed on the submitted
|
|
// email, which this endpoint does not carry, so reusing it would put every
|
|
// customer completing a reset into one shared bucket. Nor is a limit needed
|
|
// here: the token is 32 random bytes, single-use, and expires in an hour, and
|
|
// the expensive bcrypt hash only runs *after* the token has been matched, so
|
|
// invalid guesses cost a single indexed lookup.
|
|
router.post('/reset-password', asyncRoute(async (req: Request, res: Response) => {
|
|
const { token, password } = req.body || {};
|
|
if (!password || String(password).length < 8) {
|
|
// Checked before the token is looked at, so a rejected attempt does not
|
|
// consume the customer's only reset link.
|
|
return res.status(400).json({ error: 'password must be at least 8 characters' });
|
|
}
|
|
|
|
const { rows } = await pool.query(
|
|
`SELECT * FROM customer_tokens WHERE token = $1 AND kind = 'password_reset' AND expires_at > now()`,
|
|
[token]
|
|
);
|
|
if (!rows.length) return res.status(400).json({ error: 'invalid or expired token' });
|
|
const customerId = rows[0].customer_id;
|
|
|
|
// 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]);
|
|
if (owner[0]?.disabled_at) {
|
|
return res.status(403).json({ error: 'this account has been disabled' });
|
|
}
|
|
|
|
const passwordHash = await bcrypt.hash(String(password), 12);
|
|
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
await client.query(
|
|
// The customer has demonstrably received mail at this address, which is
|
|
// exactly what verification proves, so an unverified address becomes
|
|
// verified here.
|
|
`UPDATE customers SET password_hash = $1, email_verified = true WHERE id = $2`,
|
|
[passwordHash, customerId]
|
|
);
|
|
// Every existing session goes, including any an attacker holds. Without
|
|
// this, a reset prompted by a compromise leaves the intruder signed in for
|
|
// up to 30 days.
|
|
await client.query(`DELETE FROM customer_sessions WHERE customer_id = $1`, [customerId]);
|
|
await client.query(`DELETE FROM customer_tokens WHERE customer_id = $1 AND kind = 'password_reset'`, [customerId]);
|
|
await client.query('COMMIT');
|
|
} catch (err) {
|
|
await client.query('ROLLBACK');
|
|
throw err;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
|
|
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]));
|
|
}));
|
|
|
|
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 customer = rows[0];
|
|
if (!customer || !(await bcrypt.compare(password || '', customer.password_hash))) {
|
|
return res.status(401).json({ error: 'invalid email or password' });
|
|
}
|
|
// Only after the password checks out, so a wrong password still looks like a
|
|
// wrong password and this does not become a bulk membership oracle.
|
|
if (customer.disabled_at) {
|
|
return res.status(403).json({ error: 'this account has been disabled' });
|
|
}
|
|
const sessionToken = await createSession(customer.id);
|
|
setSessionCookie(res, sessionToken);
|
|
res.json(publicCustomer(customer));
|
|
}));
|
|
|
|
router.post('/logout', asyncRoute(async (req: Request, res: Response) => {
|
|
const token = req.cookies?.rd_session;
|
|
if (token) await pool.query(`DELETE FROM customer_sessions WHERE token = $1`, [token]);
|
|
res.clearCookie('rd_session');
|
|
res.status(204).end();
|
|
}));
|
|
|
|
router.get('/me/favorites', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
|
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
|
|
ORDER BY f.created_at DESC`,
|
|
[req.customerId]
|
|
);
|
|
res.json(rows);
|
|
}));
|
|
|
|
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]);
|
|
if (!item.length) return res.status(404).json({ error: 'not found' });
|
|
|
|
// Idempotent: a double click, or two tabs, must not be an error.
|
|
await pool.query(
|
|
`INSERT INTO favorites (customer_id, item_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
|
[req.customerId, req.params.itemId]
|
|
);
|
|
res.status(201).json({ item_id: Number(req.params.itemId) });
|
|
}));
|
|
|
|
router.delete('/me/favorites/:itemId', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
|
await pool.query(`DELETE FROM favorites WHERE customer_id = $1 AND item_id = $2`,
|
|
[req.customerId, req.params.itemId]);
|
|
res.status(204).end();
|
|
}));
|
|
|
|
// A consent of its own, deliberately not the marketing flag. Recorded the same
|
|
// way as the marketing consent — flag, timestamp, and the exact wording shown —
|
|
// 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(
|
|
`UPDATE customers
|
|
SET favorite_alerts = $1,
|
|
favorite_alerts_at = $2,
|
|
favorite_alerts_text = $3
|
|
WHERE id = $4 RETURNING *`,
|
|
[enabled, enabled ? new Date() : null, enabled ? FAVORITE_ALERTS_CONSENT_TEXT : null, req.customerId]
|
|
);
|
|
res.json(publicCustomer(rows[0]));
|
|
}));
|
|
|
|
router.get('/me', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
|
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]));
|
|
}));
|
|
|
|
// Kept in step with registration for consistency. Note nothing in the frontend
|
|
// calls this today — the account page has no name editing — so this is API
|
|
// surface without a caller rather than a path in use.
|
|
router.put('/me', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
|
const { firstName, lastName } = req.body;
|
|
// Registration demands both and refuses each by name. Accepting empty values
|
|
// here would let a customer clear fields they could not have skipped when
|
|
// signing up, which is the same rule disagreeing with itself.
|
|
const first = String(firstName ?? '').trim();
|
|
const last = String(lastName ?? '').trim();
|
|
if (!first) {
|
|
return res.status(400).json({ error: 'first name is required' });
|
|
}
|
|
if (!last) {
|
|
return res.status(400).json({ error: 'last name is required' });
|
|
}
|
|
const { rows } = await pool.query(
|
|
`UPDATE customers SET first_name = $1, last_name = $2 WHERE id = $3 RETURNING *`,
|
|
[first, last, req.customerId]
|
|
);
|
|
res.json(publicCustomer(rows[0]));
|
|
}));
|
|
|
|
router.post('/change-password', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
|
const { currentPassword, newPassword } = req.body;
|
|
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 customer = rows[0];
|
|
if (!(await bcrypt.compare(currentPassword || '', customer.password_hash))) {
|
|
return res.status(401).json({ error: 'current password is incorrect' });
|
|
}
|
|
const newHash = await bcrypt.hash(newPassword, 12);
|
|
await pool.query(`UPDATE customers SET password_hash = $1 WHERE id = $2`, [newHash, req.customerId]);
|
|
|
|
// Password reset already ends every session, on the reasoning that a password
|
|
// is changed precisely when the old one may be known to someone else. A
|
|
// change left the other sessions alive, which is the same reasoning reaching
|
|
// the opposite conclusion for no recorded reason. The current session is
|
|
// spared so the change does not eject the person making it.
|
|
await pool.query(
|
|
`DELETE FROM customer_sessions WHERE customer_id = $1 AND token <> $2`,
|
|
[req.customerId, req.cookies?.rd_session ?? '']
|
|
);
|
|
|
|
res.status(204).end();
|
|
}));
|
|
|
|
// Changing the address a password reset goes to is how an account is taken
|
|
// over, so this asks for the current password exactly as change-password does.
|
|
// A live session alone is not enough.
|
|
router.put('/me/email', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
|
const { currentPassword, email } = req.body;
|
|
|
|
const normalized = String(email ?? '').toLowerCase().trim();
|
|
if (!normalized || !isValidEmail(normalized)) {
|
|
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 customer = rows[0];
|
|
|
|
if (!(await bcrypt.compare(String(currentPassword ?? ''), customer.password_hash))) {
|
|
return res.status(401).json({ error: 'current password is incorrect' });
|
|
}
|
|
|
|
if (normalized === customer.email) {
|
|
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]);
|
|
if (taken.length) {
|
|
return res.status(409).json({ error: 'an account with this email already exists' });
|
|
}
|
|
|
|
// Captured before the update, because it is where the notice has to go.
|
|
const previousEmail = customer.email;
|
|
|
|
await pool.query(
|
|
`UPDATE customers SET email = $1, email_verified = false WHERE id = $2`,
|
|
[normalized, req.customerId]
|
|
);
|
|
|
|
// Supersedes any outstanding link as part of issuing the new one, so a
|
|
// message already sitting in the old inbox cannot verify the new address.
|
|
//
|
|
// Both sends happen after the row is written, never before — the same rule
|
|
// favoriteAlerts follows, so a change that failed cannot produce mail saying
|
|
// it succeeded.
|
|
await issueVerificationEmail(req.customerId as number, normalized, customer.first_name);
|
|
|
|
const notice = renderTemplate('emailChanged', await loadStoredTemplate('emailChanged'), {
|
|
greeting: greeting(customer.first_name),
|
|
newEmail: normalized
|
|
});
|
|
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]);
|
|
res.json(publicCustomer(updated[0]));
|
|
}));
|
|
|
|
router.post('/me/consent', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
|
const consent = !!req.body.marketingConsent;
|
|
await pool.query(
|
|
`UPDATE customers SET marketing_consent = $1, marketing_consent_at = now(), marketing_consent_text = $2 WHERE id = $3`,
|
|
[consent, consent ? MARKETING_CONSENT_TEXT : 'Withdrew consent via account settings', req.customerId]
|
|
);
|
|
res.status(204).end();
|
|
}));
|
|
|
|
router.get('/me/orders', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
|
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`,
|
|
[req.customerId]
|
|
);
|
|
res.json(rows);
|
|
}));
|
|
|
|
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]);
|
|
res.setHeader('Content-Disposition', 'attachment; filename="my-data.json"');
|
|
res.json({
|
|
customer: publicCustomer(customerRows[0]),
|
|
orders: orderRows,
|
|
exported_at: new Date().toISOString()
|
|
});
|
|
}));
|
|
|
|
router.delete('/me', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
|
await pool.query(`UPDATE orders SET customer_id = NULL WHERE customer_id = $1`, [req.customerId]);
|
|
await pool.query(`DELETE FROM customers WHERE id = $1`, [req.customerId]);
|
|
res.clearCookie('rd_session');
|
|
res.status(204).end();
|
|
}));
|
|
|
|
export default router;
|