Initial commit: redefined-designs storefront

This commit is contained in:
2026-08-13 21:07:54 +00:00
commit 9be4986dd3
38 changed files with 2121 additions and 0 deletions
+186
View File
@@ -0,0 +1,186 @@
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';
const router = Router();
const SESSION_DAYS = 30;
const MARKETING_CONSENT_TEXT =
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
function setSessionCookie(res: Response, token: string) {
res.cookie('rd_session', token, {
httpOnly: true,
secure: true,
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;
}
function publicCustomer(c: any) {
return {
id: c.id,
email: c.email,
name: c.name,
email_verified: c.email_verified,
marketing_consent: c.marketing_consent,
created_at: c.created_at
};
}
router.post('/register', async (req: Request, res: Response) => {
const { email, password, name, marketingConsent } = req.body;
if (!email || !password || String(password).length < 8) {
return res.status(400).json({ error: 'valid email and password (min 8 chars) 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, name, marketing_consent, marketing_consent_at, marketing_consent_text, unsubscribe_token)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
[
normalizedEmail, passwordHash, name || null,
consent, consent ? new Date() : null, consent ? MARKETING_CONSENT_TEXT : null,
unsubscribeToken
]
);
const customer = rows[0];
const verifyToken = crypto.randomBytes(24).toString('hex');
await pool.query(
`INSERT INTO customer_tokens (token, customer_id, kind, expires_at) VALUES ($1, $2, 'verify_email', $3)`,
[verifyToken, customer.id, new Date(Date.now() + 24 * 60 * 60 * 1000)]
);
const verifyUrl = `${process.env.PUBLIC_URL}/verify-email?token=${verifyToken}`;
sendMail(
customer.email,
'Verify your Redefined Designs account',
`<p>Welcome! Please <a href="${verifyUrl}">verify your email</a> to finish setting up your account.</p>`
).catch(err => console.error('verify email send failed', err));
const sessionToken = await createSession(customer.id);
setSessionCookie(res, sessionToken);
res.json(publicCustomer(customer));
});
router.post('/verify-email', 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' });
});
router.post('/login', 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' });
}
const sessionToken = await createSession(customer.id);
setSessionCookie(res, sessionToken);
res.json(publicCustomer(customer));
});
router.post('/logout', 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', requireCustomer, 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]));
});
router.put('/me', requireCustomer, async (req: Request, res: Response) => {
const { name } = req.body;
const { rows } = await pool.query(
`UPDATE customers SET name = $1 WHERE id = $2 RETURNING *`,
[name || null, req.customerId]
);
res.json(publicCustomer(rows[0]));
});
router.post('/change-password', requireCustomer, 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]);
res.status(204).end();
});
// Explicit, revocable marketing consent (GDPR Art. 7 / ePrivacy)
router.post('/me/consent', requireCustomer, 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, 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);
});
// GDPR right to access / data portability
router.get('/me/export', requireCustomer, 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()
});
});
// GDPR right to erasure — order records kept for accounting but stripped of the customer link
router.delete('/me', requireCustomer, 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;