Loads Brevo's web tracker for a signed-in customer who has consented, reports route changes as page views, and tracks the three events the issue asked for: added_to_cart, favorited, and checkout_completed. The four design questions were settled on the issue in August and this implements those answers. The consent gate is the part worth reading. The decision recorded on the issue was "gate it behind consent", but the sentence customers actually agreed to named only email: "I want to receive occasional emails about new one-of-a-kind items". Gating a tracker on `marketing_consent` while that was the stored wording would have treated "email me about new items" as authorisation to send someone's browsing to a third party, which it does not say — and this project stores the wording verbatim against each customer precisely so that a record says what the customer saw. So the sentence is widened here, and the tracker is gated on `analytics_consent`, a field the server computes by comparing the wording stored against a customer with the current constant. Changing the sentence therefore does not retroactively widen anybody's consent: everyone who agreed to the old text keeps their email consent and is not tracked until they re-consent through the account page. A boolean alone could not tell those two populations apart, which is the whole reason the text is stored per customer. `analyticsConsent` is exported and has its own unit test, because "agreeing to the old wording does not authorise tracking" is the rule that silently tracks people if it regresses — their flag really is true. QA stays out of the live Brevo account by construction rather than by remembering. The key is per-environment, the tracker never loads without one, and `docker-compose.qa.yml` sets an empty literal with no stack variable behind it, so nothing can inherit a value from the host or be pasted in from production's stack. Same reasoning as QA_DB_PASSWORD and the QA_SMTP_ names beside it. Events are reported from the API layer rather than the UI call sites, so no caller can add to the cart or favorite an item without it being counted, and each fires only after the response was accepted — a refused add is not reported as one. The two checkout completions each name their processor, because a demo purchase charges nothing and counting it as a sale would overstate revenue. The privacy policy gains an analytics section in this change rather than a follow-up, since the published policy previously described none of this and would otherwise have lagged the code. It is deliberate about the limits: withdrawing consent stops further reporting, but anything already sent stays with Brevo, and a script already injected cannot be un-injected — `stopBrevoTracking` stops calls, it does not unload sa.js. That is said in the code too, because "tracking stops" reads as a stronger promise than any web tracker can make. Verified: backend tsc clean, both lint suites 0 errors with no new warnings, 474 unit tests passing across 33 suites, and the frontend production build green including the compose-environment guard. Not verified: integration and e2e, which need a database and a Node this machine does not have active, and no real Brevo key was exercised — the tracker has never been observed reporting to an actual account. Closes #56 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
625 lines
27 KiB
TypeScript
Executable File
625 lines
27 KiB
TypeScript
Executable File
import { Router, Request, Response } from 'express';
|
|
import bcrypt from 'bcryptjs';
|
|
import { PASSWORD_HASH_ROUNDS } from '../passwordHashing';
|
|
import crypto from 'node:crypto';
|
|
import { pool, requireRow } from '../db';
|
|
import { requireCustomer } from '../middleware/customerAuth';
|
|
import { sendMail } from '../mailer';
|
|
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';
|
|
|
|
const router = Router();
|
|
|
|
const SESSION_DAYS = 30;
|
|
|
|
// 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,
|
|
lastName: string | null = null
|
|
): Promise<void> {
|
|
await pool.query(
|
|
`DELETE FROM customer_tokens WHERE customer_id = $1 AND kind = 'verify_email'`,
|
|
[customerId]
|
|
);
|
|
const { verifyTokenHours, greetingFormat, greetingFallback } = await getSettings();
|
|
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() + verifyTokenHours * 60 * 60 * 1000)]
|
|
);
|
|
const verifyUrl = `${process.env.PUBLIC_URL}/verify-email?token=${token}`;
|
|
const template = renderTemplate('verification', await loadStoredTemplate('verification'), {
|
|
greeting: greeting(firstName, greetingFormat, greetingFallback, lastName),
|
|
firstName: firstName ?? '',
|
|
lastName: lastName ?? '',
|
|
verifyUrl,
|
|
expiresIn: formatDuration(verifyTokenHours)
|
|
});
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* Whether this customer has agreed to the *current* consent wording, which is
|
|
* the only thing that authorises the Brevo tracker (#56).
|
|
*
|
|
* Not the same question as `marketing_consent`. That flag says the customer
|
|
* agreed to something; `marketing_consent_text` says what. The sentence was
|
|
* widened to cover analytics, so a customer who consented to the older wording
|
|
* agreed to emails and nothing more — they keep receiving email and are not
|
|
* tracked until they re-consent to the current text through the account page.
|
|
*
|
|
* Comparing the stored string is the point rather than an implementation
|
|
* detail: it is why the wording is recorded per customer at all. A boolean on
|
|
* its own could not tell these two populations apart, and assuming they are the
|
|
* same is exactly the retroactive widening this avoids.
|
|
*
|
|
* Computed here rather than stored, so it can never drift from the constant.
|
|
*
|
|
* Exported for the unit test, and narrowed to the two fields it actually reads
|
|
* rather than taking a whole CustomerRecord — the rule is about those two and
|
|
* nothing else, and a test should not have to invent a customer to state it.
|
|
*/
|
|
export function analyticsConsent(
|
|
c: Pick<CustomerRecord, 'marketing_consent' | 'marketing_consent_text'>
|
|
): boolean {
|
|
return c.marketing_consent && c.marketing_consent_text === MARKETING_CONSENT_TEXT;
|
|
}
|
|
|
|
/**
|
|
* Takes a CustomerRecord rather than a CustomerRow because `analytics_consent`
|
|
* is derived from `marketing_consent_text`, which is not on the narrower type.
|
|
* Every caller already holds a full record — each query is `SELECT *`.
|
|
*/
|
|
function publicCustomer(c: CustomerRecord) {
|
|
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,
|
|
// Deliberately separate from marketing_consent: the two disagree for every
|
|
// customer who consented before the wording was widened.
|
|
analytics_consent: analyticsConsent(c),
|
|
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<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, PASSWORD_HASH_ROUNDS);
|
|
const unsubscribeToken = crypto.randomBytes(16).toString('hex');
|
|
const consent = !!marketingConsent;
|
|
|
|
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 *`,
|
|
[
|
|
normalizedEmail, passwordHash, first, last,
|
|
consent, consent ? new Date() : null, consent ? MARKETING_CONSENT_TEXT : null,
|
|
unsubscribeToken
|
|
]
|
|
);
|
|
const customer = requireRow(rows, 'the registration INSERT');
|
|
|
|
await issueVerificationEmail(customer.id, customer.email, customer.first_name, customer.last_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<CustomerTokenRow>(
|
|
`SELECT * FROM customer_tokens WHERE token = $1 AND kind = 'verify_email' AND expires_at > now()`,
|
|
[token]
|
|
);
|
|
const [verifyToken] = rows;
|
|
if (!verifyToken) return res.status(400).json({ error: 'invalid or expired token' });
|
|
await pool.query(`UPDATE customers SET email_verified = true WHERE id = $1`, [verifyToken.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<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
|
|
// requireCustomer has already matched this id against a live session.
|
|
const customer = requireRow(rows, 'the signed-in customer');
|
|
|
|
// 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, customer.last_name);
|
|
res.status(204).end();
|
|
})
|
|
);
|
|
|
|
|
|
// 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<CustomerRecord>(`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 { passwordResetHours, greetingFormat, greetingFallback } = await getSettings();
|
|
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() + passwordResetHours * 60 * 60 * 1000)]
|
|
);
|
|
|
|
const resetUrl = `${process.env.PUBLIC_URL}/reset-password?token=${token}`;
|
|
const resetTemplate = renderTemplate('passwordReset', await loadStoredTemplate('passwordReset'), {
|
|
greeting: greeting(customer.first_name, greetingFormat, greetingFallback, customer.last_name),
|
|
firstName: customer.first_name ?? '',
|
|
lastName: customer.last_name ?? '',
|
|
resetUrl,
|
|
expiresIn: formatDuration(passwordResetHours)
|
|
});
|
|
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<CustomerTokenRow>(
|
|
`SELECT * FROM customer_tokens WHERE token = $1 AND kind = 'password_reset' AND expires_at > now()`,
|
|
[token]
|
|
);
|
|
const [resetToken] = rows;
|
|
if (!resetToken) return res.status(400).json({ error: 'invalid or expired token' });
|
|
const customerId = resetToken.customer_id;
|
|
|
|
// A token issued before the account was disabled would otherwise still mint a
|
|
// fresh session.
|
|
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' });
|
|
}
|
|
|
|
const passwordHash = await bcrypt.hash(String(password), PASSWORD_HASH_ROUNDS);
|
|
|
|
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<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [customerId]);
|
|
const sessionToken = await createSession(customerId);
|
|
setSessionCookie(res, sessionToken);
|
|
res.json(publicCustomer(requireRow(fresh, 'the customer whose password was just reset')));
|
|
}));
|
|
|
|
router.post('/login', asyncRoute(async (req: Request, res: Response) => {
|
|
const { email, password } = req.body;
|
|
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' });
|
|
}
|
|
// 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<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
|
|
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<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.
|
|
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<CustomerRecord>(
|
|
`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(requireRow(rows, 'the favorite-alerts UPDATE')));
|
|
}));
|
|
|
|
router.get('/me', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
|
const [customer] = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [req.customerId]).then(r => r.rows);
|
|
if (!customer) return res.status(404).json({ error: 'not found' });
|
|
res.json(publicCustomer(customer));
|
|
}));
|
|
|
|
// 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<CustomerRecord>(
|
|
`UPDATE customers SET first_name = $1, last_name = $2 WHERE id = $3 RETURNING *`,
|
|
[first, last, req.customerId]
|
|
);
|
|
res.json(publicCustomer(requireRow(rows, 'the name UPDATE')));
|
|
}));
|
|
|
|
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<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
|
|
const customer = requireRow(rows, 'the signed-in customer');
|
|
if (!(await bcrypt.compare(currentPassword || '', customer.password_hash))) {
|
|
return res.status(401).json({ error: 'current password is incorrect' });
|
|
}
|
|
const newHash = await bcrypt.hash(newPassword, PASSWORD_HASH_ROUNDS);
|
|
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<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
|
|
const customer = requireRow(rows, 'the signed-in customer');
|
|
|
|
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<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' });
|
|
}
|
|
|
|
// 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, customer.last_name);
|
|
|
|
const { greetingFormat, greetingFallback } = await getSettings();
|
|
const notice = renderTemplate('emailChanged', await loadStoredTemplate('emailChanged'), {
|
|
greeting: greeting(customer.first_name, greetingFormat, greetingFallback, customer.last_name),
|
|
firstName: customer.first_name ?? '',
|
|
lastName: customer.last_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<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
|
|
res.json(publicCustomer(requireRow(updated, 'the customer after the email change')));
|
|
}));
|
|
|
|
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<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`,
|
|
[req.customerId]
|
|
);
|
|
res.json(rows);
|
|
}));
|
|
|
|
router.get('/me/export', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
|
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(requireRow(customerRows, 'the signed-in customer')),
|
|
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;
|