Initial commit: redefined-designs storefront
This commit is contained in:
Executable
+9
@@ -0,0 +1,9 @@
|
||||
import { Pool } from 'pg';
|
||||
|
||||
export const pool = new Pool({
|
||||
host: process.env.PGHOST,
|
||||
port: parseInt(process.env.PGPORT || '5432', 10),
|
||||
user: process.env.PGUSER,
|
||||
password: process.env.PGPASSWORD,
|
||||
database: process.env.PGDATABASE
|
||||
});
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST || 'smtp.gmail.com',
|
||||
port: parseInt(process.env.SMTP_PORT || '465', 10),
|
||||
secure: process.env.SMTP_SECURE !== 'false',
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASSWORD
|
||||
}
|
||||
});
|
||||
|
||||
export async function sendMail(to: string, subject: string, html: string): Promise<void> {
|
||||
if (!process.env.SMTP_USER || !process.env.SMTP_PASSWORD) {
|
||||
console.warn(`SMTP not configured — skipping email to ${to}: "${subject}"`);
|
||||
return;
|
||||
}
|
||||
await transporter.sendMail({
|
||||
from: process.env.SMTP_FROM || process.env.SMTP_USER,
|
||||
to,
|
||||
subject,
|
||||
html
|
||||
});
|
||||
}
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { pool } from '../db';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
customerId?: number;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function attachCustomer(req: Request, _res: Response, next: NextFunction): Promise<void> {
|
||||
const token = req.cookies?.rd_session;
|
||||
if (!token) return next();
|
||||
const { rows } = await pool.query(
|
||||
`SELECT customer_id FROM customer_sessions WHERE token = $1 AND expires_at > now()`,
|
||||
[token]
|
||||
);
|
||||
if (rows.length) req.customerId = rows[0].customer_id;
|
||||
next();
|
||||
}
|
||||
|
||||
export function requireCustomer(req: Request, res: Response, next: NextFunction): void {
|
||||
if (!req.customerId) {
|
||||
res.status(401).json({ error: 'not authenticated' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
Executable
+124
@@ -0,0 +1,124 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import { pool } from '../db';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: '/app/uploads',
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `${Date.now()}-${Math.round(Math.random() * 1e6)}${ext}`);
|
||||
}
|
||||
});
|
||||
const upload = multer({ storage });
|
||||
|
||||
const SELECT_WITH_IMAGES = `
|
||||
SELECT i.*,
|
||||
COALESCE(
|
||||
json_agg(json_build_object('id', img.id, 'image_path', img.image_path, 'sort_order', img.sort_order)
|
||||
ORDER BY img.sort_order) FILTER (WHERE img.id IS NOT NULL),
|
||||
'[]'
|
||||
) AS images
|
||||
FROM items i
|
||||
LEFT JOIN item_images img ON img.item_id = i.id
|
||||
`;
|
||||
|
||||
router.get('/items', async (_req: Request, res: Response) => {
|
||||
const { rows } = await pool.query(`${SELECT_WITH_IMAGES} GROUP BY i.id ORDER BY i.created_at DESC`);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
router.post('/items', upload.array('images', 6), async (req: Request, res: Response) => {
|
||||
const { name, description, price } = req.body;
|
||||
const files = (req.files as Express.Multer.File[]) || [];
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { rows } = await client.query(
|
||||
`INSERT INTO items (name, description, price_cents) VALUES ($1, $2, $3) RETURNING *`,
|
||||
[name, description, Math.round(parseFloat(price) * 100)]
|
||||
);
|
||||
const item = rows[0];
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
await client.query(
|
||||
`INSERT INTO item_images (item_id, image_path, sort_order) VALUES ($1, $2, $3)`,
|
||||
[item.id, `/uploads/${files[i].filename}`, i]
|
||||
);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
const { rows: full } = await pool.query(`${SELECT_WITH_IMAGES} WHERE i.id = $1 GROUP BY i.id`, [item.id]);
|
||||
res.json(full[0]);
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/items/:id', upload.array('images', 6), async (req: Request, res: Response) => {
|
||||
const { name, description, price } = req.body;
|
||||
const files = (req.files as Express.Multer.File[]) || [];
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query(
|
||||
`UPDATE items SET name=$1, description=$2, price_cents=$3 WHERE id=$4`,
|
||||
[name, description, Math.round(parseFloat(price) * 100), req.params.id]
|
||||
);
|
||||
if (files.length) {
|
||||
const { rows: existing } = await client.query(
|
||||
`SELECT COALESCE(MAX(sort_order), -1) AS max_sort FROM item_images WHERE item_id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
let nextSort = existing[0].max_sort + 1;
|
||||
for (const file of files) {
|
||||
await client.query(
|
||||
`INSERT INTO item_images (item_id, image_path, sort_order) VALUES ($1, $2, $3)`,
|
||||
[req.params.id, `/uploads/${file.filename}`, nextSort++]
|
||||
);
|
||||
}
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
const { rows: full } = await pool.query(`${SELECT_WITH_IMAGES} WHERE i.id = $1 GROUP BY i.id`, [req.params.id]);
|
||||
res.json(full[0]);
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/items/:id', async (req: Request, res: Response) => {
|
||||
await pool.query(`DELETE FROM items WHERE id = $1`, [req.params.id]);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
router.delete('/items/:id/images/:imageId', async (req: Request, res: Response) => {
|
||||
await pool.query(`DELETE FROM item_images WHERE id = $1 AND item_id = $2`, [req.params.imageId, req.params.id]);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
router.post('/items/:id/mark-sold', async (req: Request, res: Response) => {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE items SET status='sold', sold_at=now() WHERE id=$1 RETURNING *`,
|
||||
[req.params.id]
|
||||
);
|
||||
res.json(rows[0]);
|
||||
});
|
||||
|
||||
router.post('/items/:id/mark-available', async (req: Request, res: Response) => {
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE items SET status='available', sold_at=NULL, reserved_until=NULL, paypal_order_id=NULL
|
||||
WHERE id=$1 RETURNING *`,
|
||||
[req.params.id]
|
||||
);
|
||||
res.json(rows[0]);
|
||||
});
|
||||
|
||||
export default router;
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { pool } from '../db';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
c.id, c.email, c.name, c.email_verified, c.marketing_consent, c.created_at,
|
||||
COUNT(o.id) FILTER (WHERE o.status = 'completed') AS order_count,
|
||||
COALESCE(SUM(o.amount_cents) FILTER (WHERE o.status = 'completed'), 0) AS total_spent_cents,
|
||||
MAX(o.created_at) AS last_order_at
|
||||
FROM customers c
|
||||
LEFT JOIN orders o ON o.customer_id = c.id
|
||||
GROUP BY c.id
|
||||
ORDER BY c.created_at DESC
|
||||
`);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
const { rows: customerRows } = await pool.query(
|
||||
`SELECT id, email, name, email_verified, marketing_consent, marketing_consent_at, created_at
|
||||
FROM customers WHERE id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
if (!customerRows.length) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
const { rows: orderRows } = await pool.query(
|
||||
`SELECT o.id, o.processor, o.processor_order_id, 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.params.id]
|
||||
);
|
||||
|
||||
res.json({ customer: customerRows[0], orders: orderRows });
|
||||
});
|
||||
|
||||
export default router;
|
||||
Executable
+186
@@ -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;
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { pool } from '../db';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/:id/purchase', async (req: Request, res: Response) => {
|
||||
if (process.env.DEMO_MODE === 'false') {
|
||||
return res.status(403).json({ error: 'demo mode disabled' });
|
||||
}
|
||||
const itemId = req.params.id;
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { rows } = await client.query(`SELECT * FROM items WHERE id = $1 FOR UPDATE`, [itemId]);
|
||||
const item = rows[0];
|
||||
if (!item) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'not found' }); }
|
||||
if (item.status === 'sold') { await client.query('ROLLBACK'); return res.status(409).json({ error: 'already sold' }); }
|
||||
|
||||
const { rows: updated } = await client.query(
|
||||
`UPDATE items SET status = 'sold', sold_at = now() WHERE id = $1 RETURNING *`,
|
||||
[itemId]
|
||||
);
|
||||
await client.query(
|
||||
`INSERT INTO orders (item_id, customer_id, processor, processor_order_id, amount_cents, status, raw_event)
|
||||
VALUES ($1, $2, 'demo', $3, $4, 'completed', $5)`,
|
||||
[itemId, req.customerId || null, `demo-${Date.now()}`, item.price_cents, JSON.stringify({ demo: true })]
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
res.json({ status: 'sold', item: updated[0] });
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { pool } from '../db';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const SELECT_WITH_IMAGES = `
|
||||
SELECT i.id, i.name, i.description, i.price_cents, i.status, i.created_at,
|
||||
COALESCE(
|
||||
json_agg(json_build_object('id', img.id, 'image_path', img.image_path, 'sort_order', img.sort_order)
|
||||
ORDER BY img.sort_order) FILTER (WHERE img.id IS NOT NULL),
|
||||
'[]'
|
||||
) AS images
|
||||
FROM items i
|
||||
LEFT JOIN item_images img ON img.item_id = i.id
|
||||
`;
|
||||
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
const { rows } = await pool.query(`${SELECT_WITH_IMAGES} GROUP BY i.id ORDER BY i.created_at DESC`);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
const { rows } = await pool.query(`${SELECT_WITH_IMAGES} WHERE i.id = $1 GROUP BY i.id`, [req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
||||
res.json(rows[0]);
|
||||
});
|
||||
|
||||
export default router;
|
||||
Executable
+156
@@ -0,0 +1,156 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { pool } from '../db';
|
||||
|
||||
const router = Router();
|
||||
const webhookRouter = Router();
|
||||
|
||||
const PAYPAL_BASE =
|
||||
process.env.PAYPAL_ENV === 'live'
|
||||
? 'https://api-m.paypal.com'
|
||||
: 'https://api-m.sandbox.paypal.com';
|
||||
|
||||
const RESERVATION_MINUTES = parseInt(process.env.RESERVATION_MINUTES || '15', 10);
|
||||
|
||||
async function getAccessToken(): Promise<string> {
|
||||
const auth = Buffer.from(
|
||||
`${process.env.PAYPAL_CLIENT_ID}:${process.env.PAYPAL_CLIENT_SECRET}`
|
||||
).toString('base64');
|
||||
const resp = await fetch(`${PAYPAL_BASE}/v1/oauth2/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Basic ${auth}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: 'grant_type=client_credentials'
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error('paypal auth failed: ' + JSON.stringify(data));
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
router.post('/:id/create', async (req: Request, res: Response) => {
|
||||
const itemId = req.params.id;
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { rows } = await client.query(`SELECT * FROM items WHERE id = $1 FOR UPDATE`, [itemId]);
|
||||
const item = rows[0];
|
||||
if (!item) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'not found' }); }
|
||||
if (item.status === 'sold') { await client.query('ROLLBACK'); return res.status(409).json({ error: 'already sold' }); }
|
||||
if (item.status === 'reserved' && new Date(item.reserved_until) > new Date()) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(409).json({ error: 'currently reserved by another checkout' });
|
||||
}
|
||||
|
||||
const token = await getAccessToken();
|
||||
const amount = (item.price_cents / 100).toFixed(2);
|
||||
const orderResp = await fetch(`${PAYPAL_BASE}/v2/checkout/orders`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
intent: 'CAPTURE',
|
||||
purchase_units: [{
|
||||
custom_id: String(item.id),
|
||||
description: item.name,
|
||||
amount: { currency_code: process.env.SITE_CURRENCY || 'USD', value: amount }
|
||||
}]
|
||||
})
|
||||
});
|
||||
const order = await orderResp.json();
|
||||
if (!orderResp.ok) { await client.query('ROLLBACK'); return res.status(502).json({ error: 'paypal order create failed', detail: order }); }
|
||||
|
||||
const reservedUntil = new Date(Date.now() + RESERVATION_MINUTES * 60 * 1000);
|
||||
await client.query(
|
||||
`UPDATE items SET status = 'reserved', reserved_until = $1, paypal_order_id = $2 WHERE id = $3`,
|
||||
[reservedUntil, order.id, item.id]
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
res.json({ orderID: order.id });
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/capture', async (req: Request, res: Response) => {
|
||||
const itemId = req.params.id;
|
||||
const { orderID } = req.body;
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const token = await getAccessToken();
|
||||
const captureResp = await fetch(`${PAYPAL_BASE}/v2/checkout/orders/${orderID}/capture`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }
|
||||
});
|
||||
const capture = await captureResp.json();
|
||||
if (!captureResp.ok || capture.status !== 'COMPLETED') {
|
||||
return res.status(502).json({ error: 'capture failed', detail: capture });
|
||||
}
|
||||
|
||||
await client.query('BEGIN');
|
||||
const { rows } = await client.query(
|
||||
`UPDATE items SET status = 'sold', sold_at = now()
|
||||
WHERE id = $1 AND paypal_order_id = $2 AND status != 'sold'
|
||||
RETURNING *`,
|
||||
[itemId, orderID]
|
||||
);
|
||||
const capturedAmount = capture.purchase_units?.[0]?.payments?.captures?.[0]?.amount?.value;
|
||||
await client.query(
|
||||
`INSERT INTO orders (item_id, customer_id, processor, processor_order_id, amount_cents, status, raw_event)
|
||||
VALUES ($1, $2, 'paypal', $3, $4, 'completed', $5)`,
|
||||
[itemId, req.customerId || null, orderID, Math.round(parseFloat(capturedAmount || '0') * 100), capture]
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
res.json({ status: 'sold', item: rows[0] || null });
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'internal error' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
webhookRouter.post('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const token = await getAccessToken();
|
||||
const verifyResp = await fetch(`${PAYPAL_BASE}/v1/notifications/verify-webhook-signature`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
auth_algo: req.headers['paypal-auth-algo'],
|
||||
cert_url: req.headers['paypal-cert-url'],
|
||||
transmission_id: req.headers['paypal-transmission-id'],
|
||||
transmission_sig: req.headers['paypal-transmission-sig'],
|
||||
transmission_time: req.headers['paypal-transmission-time'],
|
||||
webhook_id: process.env.PAYPAL_WEBHOOK_ID,
|
||||
webhook_event: req.body
|
||||
})
|
||||
});
|
||||
const verification = await verifyResp.json();
|
||||
if (verification.verification_status !== 'SUCCESS') {
|
||||
console.warn('paypal webhook signature invalid');
|
||||
return res.status(400).end();
|
||||
}
|
||||
|
||||
const event = req.body;
|
||||
if (event.event_type === 'PAYMENT.CAPTURE.COMPLETED') {
|
||||
const itemId = event.resource?.custom_id;
|
||||
if (itemId) {
|
||||
await pool.query(
|
||||
`UPDATE items SET status = 'sold', sold_at = now() WHERE id = $1 AND status != 'sold'`,
|
||||
[itemId]
|
||||
);
|
||||
}
|
||||
}
|
||||
res.status(200).end();
|
||||
} catch (err) {
|
||||
console.error('webhook error', err);
|
||||
res.status(500).end();
|
||||
}
|
||||
});
|
||||
|
||||
export { router, webhookRouter };
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { pool } from '../db';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/unsubscribe', async (req: Request, res: Response) => {
|
||||
const token = req.query.token as string;
|
||||
const { rows } = await pool.query(`SELECT id FROM customers WHERE unsubscribe_token = $1`, [token]);
|
||||
if (!rows.length) {
|
||||
res.status(400).send('<html><body><h2>Invalid or expired unsubscribe link.</h2></body></html>');
|
||||
return;
|
||||
}
|
||||
await pool.query(
|
||||
`UPDATE customers SET marketing_consent = false, marketing_consent_at = now(),
|
||||
marketing_consent_text = 'Unsubscribed via email link' WHERE id = $1`,
|
||||
[rows[0].id]
|
||||
);
|
||||
res.send('<html><body><h2>You\'ve been unsubscribed.</h2><p>You will no longer receive marketing emails from Redefined Designs.</p></body></html>');
|
||||
});
|
||||
|
||||
export default router;
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
import express from 'express';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import path from 'path';
|
||||
import { pool } from './db';
|
||||
import itemsRouter from './routes/items';
|
||||
import { router as paypalRouter, webhookRouter as paypalWebhookRouter } from './routes/paypal';
|
||||
import adminRouter from './routes/admin';
|
||||
import adminCustomersRouter from './routes/adminCustomers';
|
||||
import demoRouter from './routes/demo';
|
||||
import customersRouter from './routes/customers';
|
||||
import publicRouter from './routes/public';
|
||||
import { attachCustomer } from './middleware/customerAuth';
|
||||
|
||||
const app = express();
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
app.use('/webhooks/paypal', express.json(), paypalWebhookRouter);
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use(attachCustomer);
|
||||
app.use('/uploads', express.static('/app/uploads'));
|
||||
|
||||
app.get('/api/config', (_req, res) => {
|
||||
const clientId = process.env.PAYPAL_CLIENT_ID;
|
||||
const isPlaceholder = !clientId || clientId.length < 10 || clientId === 'REPLACE_WITH_PAYPAL_CLIENT_ID';
|
||||
res.json({
|
||||
paypalClientId: isPlaceholder ? null : clientId,
|
||||
demoMode: process.env.DEMO_MODE !== 'false',
|
||||
currency: process.env.SITE_CURRENCY || 'USD'
|
||||
});
|
||||
});
|
||||
|
||||
app.use('/api/items', itemsRouter);
|
||||
app.use('/api/checkout/paypal', paypalRouter);
|
||||
app.use('/api/checkout/demo', demoRouter);
|
||||
app.use('/api/admin/customers', adminCustomersRouter);
|
||||
app.use('/api/admin', adminRouter);
|
||||
app.use('/api/customers', customersRouter);
|
||||
app.use('/', publicRouter);
|
||||
|
||||
const staticDir = path.join(__dirname, '..', 'public');
|
||||
app.use(express.static(staticDir));
|
||||
app.get('*', (_req, res) => {
|
||||
res.sendFile(path.join(staticDir, 'index.html'));
|
||||
});
|
||||
|
||||
setInterval(async () => {
|
||||
try {
|
||||
await pool.query(
|
||||
`UPDATE items SET status = 'available', reserved_until = NULL, paypal_order_id = NULL
|
||||
WHERE status = 'reserved' AND reserved_until < now()`
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('reservation sweep failed:', (err as Error).message);
|
||||
}
|
||||
}, 60 * 1000);
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '3000', 10);
|
||||
app.listen(PORT, () => console.log(`redefined-designs listening on ${PORT}`));
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
export type ItemStatus = 'available' | 'reserved' | 'sold';
|
||||
|
||||
export interface ItemImage {
|
||||
id: number;
|
||||
image_path: string;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface Item {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
price_cents: number;
|
||||
images: ItemImage[];
|
||||
status: ItemStatus;
|
||||
reserved_until: string | null;
|
||||
sold_at: string | null;
|
||||
paypal_order_id: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
Reference in New Issue
Block a user