Indexing an array now yields `T | undefined`, which is what it always did — the compiler simply says so. Enabled in backend/tsconfig.json and frontend/tsconfig.json, and in tsconfig.sonar.json alongside it so the drift guard stays satisfied. The sequencing this issue insisted on was right, and the numbers show why. Before #159 typed the query results, `rows[0]` was `any` and `any` indexes to `any`: the flag would have found close to nothing and the count would have changed completely afterwards. With the rows typed it finds 57 — 50 in the backend, 4 in the frontend, 3 in the Playwright suite — and they fall into three kinds. Thirty are `rows[0]` after a `rows.length` guard. TypeScript cannot connect the two, and rewriting them as `const [row] = rows; if (!row) …` makes the guard and the use the same check, which is better code independently of the flag. Ten are rows a statement guarantees — `INSERT … RETURNING`, or a lookup for an id the session middleware has already matched. These get `requireRow(rows, what)`, a new helper in db.ts that throws naming the query. A thrown error rather than a non-null assertion: if the assumption is ever wrong, an assertion hands `undefined` to the next line and fails somewhere unrelated, whereas this fails at the query and says which. asyncRoute turns it into a 500, which is the right answer for "the database did not do what the statement says it does". It also states the assumption once instead of ten times. The rest is ordinary indexing the compiler cannot prove: a regex capture group that the pattern guarantees, `split('+')[0]`, a modulo kept in range, `hasOwnProperty` failing to narrow an index signature, and Express typing route params as an index signature so `req.params.itemId` is `string | undefined` on a route that cannot match without it. One correction to this issue's premise, which matters for what it was expected to find. The body says "in a handful it does not guard at all", and the unguarded-500 risk it describes was not found. Every `rows[0]` either sits behind a length check or behind a statement that guarantees a row. What the flag actually bought was the ten places where that guarantee was real but unstated, and those now say so. Two changes worth calling out because they are not mechanical. TAG_COLORS is typed `[string, ...string[]]` in both copies rather than `as const` — the first attempt used `as const`, which narrowed the elements to literals and broke adminTags, so the annotation keeps `string` while telling the compiler index 0 exists. And the filter drawer's slider falls back to the bounds it was given rather than to null, because null there reads as "no filter" and would widen the results rather than leave them unchanged. Test files needed changes too, since ts-jest compiles them against the same config: a regex destructure in the compose guard, and ten `mock.calls[0][0]` reads where the surrounding assertions already establish the call happened. Verified: tsc clean over backend, frontend src and the Playwright suite; unit 254/254; integration 238/238; frontend build clean; lint unchanged in both workspaces. Closes #101
311 lines
12 KiB
TypeScript
311 lines
12 KiB
TypeScript
import { Router, Request, Response } from 'express';
|
|
import type { PoolClient } from 'pg';
|
|
import { pool, requireRow } from '../db';
|
|
import { asyncRoute } from '../asyncRoute';
|
|
import { requireCustomer } from '../middleware/customerAuth';
|
|
import { notifyFavoritersOfSale } from '../favoriteAlerts';
|
|
|
|
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';
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Row shapes for the reads in this file.
|
|
*
|
|
* Only queries whose rows are actually read carry a type. The INSERTs, UPDATEs,
|
|
* DELETEs and the BEGIN/COMMIT/ROLLBACK calls return nothing anyone looks at,
|
|
* and annotating them would be ceremony that makes the ones that matter harder
|
|
* to pick out.
|
|
*
|
|
* Kept in step with their SQL by hand: `client.query<T>` asserts a shape rather
|
|
* than checking it, because TypeScript never reads the query string. The
|
|
* integration suite is what catches a select and its type disagreeing.
|
|
*/
|
|
interface IdRow {
|
|
id: number;
|
|
}
|
|
|
|
interface CheckoutItemRow {
|
|
item_id: number;
|
|
price_cents: number;
|
|
}
|
|
|
|
interface CheckoutOwnerRow {
|
|
customer_id: number | null;
|
|
}
|
|
|
|
interface CheckoutStatusRow {
|
|
status: string;
|
|
}
|
|
|
|
interface CartItem {
|
|
id: number;
|
|
name: string;
|
|
price_cents: number;
|
|
}
|
|
|
|
interface LockedCart {
|
|
cartId: number;
|
|
items: CartItem[];
|
|
totalCents: number;
|
|
}
|
|
|
|
// Locks the customer's cart, verifies every item is still reserved to them,
|
|
// and returns { cartId, items: [{id, name, price_cents}], totalCents }.
|
|
async function loadLockedCart(client: PoolClient, customerId: number): Promise<LockedCart | null> {
|
|
const { rows: cartRows } = await client.query<IdRow>(`SELECT id FROM carts WHERE customer_id = $1`, [customerId]);
|
|
const [cart] = cartRows;
|
|
if (!cart) return null;
|
|
const cartId = cart.id;
|
|
const { rows: items } = await client.query<CartItem>(
|
|
`SELECT i.id, i.name, i.price_cents
|
|
FROM cart_items ci
|
|
JOIN items i ON i.id = ci.item_id
|
|
WHERE ci.cart_id = $1
|
|
FOR UPDATE OF i`,
|
|
[cartId]
|
|
);
|
|
if (!items.length) return { cartId, items: [], totalCents: 0 };
|
|
const totalCents = items.reduce((sum, it) => sum + it.price_cents, 0);
|
|
return { cartId, items, totalCents };
|
|
}
|
|
|
|
type OpenedCheckout =
|
|
| { ok: true; checkoutId: number; cart: LockedCart }
|
|
| { ok: false; error: string };
|
|
|
|
// Both checkout flows open the same way: confirm the shipping address belongs
|
|
// to the caller, lock the cart, and record a pending checkout with its line
|
|
// items. The caller owns the transaction — on `ok: false` it should roll back
|
|
// and return the error as a 400.
|
|
async function openCheckout(
|
|
client: PoolClient,
|
|
customerId: number,
|
|
shippingAddressId: number,
|
|
processor: string,
|
|
processorOrderId: string | null
|
|
): Promise<OpenedCheckout> {
|
|
const { rows: addrRows } = await client.query<IdRow>(
|
|
`SELECT id FROM shipping_addresses WHERE id = $1 AND customer_id = $2`,
|
|
[shippingAddressId, customerId]
|
|
);
|
|
if (!addrRows.length) return { ok: false, error: 'invalid shipping address' };
|
|
|
|
const cart = await loadLockedCart(client, customerId);
|
|
if (!cart || !cart.items.length) return { ok: false, error: 'cart is empty' };
|
|
|
|
const { rows: checkoutRows } = await client.query<IdRow>(
|
|
`INSERT INTO checkouts (customer_id, shipping_address_id, processor, processor_order_id, amount_cents, status)
|
|
VALUES ($1, $2, $3, $4, $5, 'pending') RETURNING id`,
|
|
[customerId, shippingAddressId, processor, processorOrderId, cart.totalCents]
|
|
);
|
|
const checkoutId = requireRow(checkoutRows, 'the checkout INSERT').id;
|
|
for (const it of cart.items) {
|
|
await client.query(
|
|
`INSERT INTO checkout_items (checkout_id, item_id, price_cents) VALUES ($1, $2, $3)`,
|
|
[checkoutId, it.id, it.price_cents]
|
|
);
|
|
}
|
|
return { ok: true, checkoutId, cart };
|
|
}
|
|
|
|
router.post('/paypal/create', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
|
const { shippingAddressId } = req.body;
|
|
if (!shippingAddressId) return res.status(400).json({ error: 'shippingAddressId is required' });
|
|
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
const opened = await openCheckout(client, req.customerId as number, shippingAddressId, 'paypal', null);
|
|
if (!opened.ok) { await client.query('ROLLBACK'); return res.status(400).json({ error: opened.error }); }
|
|
const { checkoutId, cart } = opened;
|
|
|
|
const token = await getAccessToken();
|
|
const currency = process.env.SITE_CURRENCY || 'USD';
|
|
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(checkoutId),
|
|
amount: {
|
|
currency_code: currency,
|
|
value: (cart.totalCents / 100).toFixed(2),
|
|
breakdown: { item_total: { currency_code: currency, value: (cart.totalCents / 100).toFixed(2) } }
|
|
},
|
|
items: cart.items.map((it: CartItem) => ({
|
|
name: it.name.slice(0, 127),
|
|
quantity: '1',
|
|
unit_amount: { currency_code: currency, value: (it.price_cents / 100).toFixed(2) }
|
|
}))
|
|
}]
|
|
})
|
|
});
|
|
const order = await orderResp.json();
|
|
if (!orderResp.ok) { await client.query('ROLLBACK'); return res.status(502).json({ error: 'paypal order create failed', detail: order }); }
|
|
|
|
await client.query(`UPDATE checkouts SET processor_order_id = $1 WHERE id = $2`, [order.id, checkoutId]);
|
|
await client.query('COMMIT');
|
|
res.json({ orderID: order.id, checkoutId });
|
|
} catch (err) {
|
|
await client.query('ROLLBACK');
|
|
console.error(err);
|
|
res.status(500).json({ error: 'internal error' });
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}));
|
|
|
|
// Returns the sold item ids and the buyer, so the caller can notify favoriters
|
|
// *after* COMMIT. Sending inside the transaction would email people about a
|
|
// sale that then rolled back, and would hold the transaction open for SMTP.
|
|
async function completeCheckout(client: PoolClient, checkoutId: number, processor: string, processorOrderId: string | null, rawEvent: unknown): Promise<{ itemIds: number[]; buyerId: number | null }> {
|
|
const { rows: checkoutItems } = await client.query<CheckoutItemRow>(
|
|
`SELECT item_id, price_cents FROM checkout_items WHERE checkout_id = $1`,
|
|
[checkoutId]
|
|
);
|
|
const { rows: checkoutRows } = await client.query<CheckoutOwnerRow>(`SELECT customer_id FROM checkouts WHERE id = $1`, [checkoutId]);
|
|
const customerId = checkoutRows[0]?.customer_id;
|
|
|
|
for (const ci of checkoutItems) {
|
|
await client.query(`UPDATE items SET status = 'sold', sold_at = now() WHERE id = $1`, [ci.item_id]);
|
|
await client.query(
|
|
`INSERT INTO orders (item_id, customer_id, checkout_id, processor, processor_order_id, amount_cents, status, raw_event)
|
|
VALUES ($1, $2, $3, $4, $5, $6, 'completed', $7)`,
|
|
[ci.item_id, customerId, checkoutId, processor, processorOrderId, ci.price_cents, rawEvent]
|
|
);
|
|
await client.query(`DELETE FROM cart_items WHERE item_id = $1`, [ci.item_id]);
|
|
}
|
|
await client.query(`UPDATE checkouts SET status = 'completed', raw_event = $1 WHERE id = $2`, [rawEvent, checkoutId]);
|
|
|
|
return {
|
|
itemIds: checkoutItems.map((ci) => ci.item_id),
|
|
buyerId: customerId ?? null
|
|
};
|
|
}
|
|
|
|
router.post('/paypal/capture', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
|
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 });
|
|
}
|
|
|
|
const { rows } = await pool.query<IdRow>(
|
|
`SELECT id FROM checkouts WHERE processor_order_id = $1 AND customer_id = $2`,
|
|
[orderID, req.customerId]
|
|
);
|
|
if (!rows.length) return res.status(404).json({ error: 'checkout not found' });
|
|
|
|
await client.query('BEGIN');
|
|
const sold = await completeCheckout(client, requireRow(rows, 'the checkout lookup').id, 'paypal', orderID, capture);
|
|
await client.query('COMMIT');
|
|
await notifyFavoritersOfSale(sold.itemIds, sold.buyerId);
|
|
res.json({ status: 'completed' });
|
|
} catch (err) {
|
|
await client.query('ROLLBACK');
|
|
console.error(err);
|
|
res.status(500).json({ error: 'internal error' });
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}));
|
|
|
|
router.post('/demo/purchase', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
|
if (process.env.DEMO_MODE === 'false') return res.status(403).json({ error: 'demo mode disabled' });
|
|
const { shippingAddressId } = req.body;
|
|
if (!shippingAddressId) return res.status(400).json({ error: 'shippingAddressId is required' });
|
|
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
const opened = await openCheckout(client, req.customerId as number, shippingAddressId, 'demo', `demo-${Date.now()}`);
|
|
if (!opened.ok) { await client.query('ROLLBACK'); return res.status(400).json({ error: opened.error }); }
|
|
|
|
const sold = await completeCheckout(client, opened.checkoutId, 'demo', null, { demo: true });
|
|
await client.query('COMMIT');
|
|
await notifyFavoritersOfSale(sold.itemIds, sold.buyerId);
|
|
res.json({ status: 'completed' });
|
|
} catch (err) {
|
|
await client.query('ROLLBACK');
|
|
console.error(err);
|
|
res.status(500).json({ error: 'internal error' });
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}));
|
|
|
|
webhookRouter.post('/', asyncRoute(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') return res.status(400).end();
|
|
|
|
const event = req.body;
|
|
if (event.event_type === 'PAYMENT.CAPTURE.COMPLETED') {
|
|
const checkoutId = event.resource?.custom_id;
|
|
if (checkoutId) {
|
|
const { rows } = await pool.query<CheckoutStatusRow>(`SELECT status FROM checkouts WHERE id = $1`, [checkoutId]);
|
|
const [checkout] = rows;
|
|
if (checkout && checkout.status !== 'completed') {
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
const sold = await completeCheckout(client, parseInt(checkoutId, 10), 'paypal', event.resource?.id, event);
|
|
await client.query('COMMIT');
|
|
await notifyFavoritersOfSale(sold.itemIds, sold.buyerId);
|
|
} catch (e) {
|
|
await client.query('ROLLBACK');
|
|
console.error('webhook completeCheckout failed', e);
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
res.status(200).end();
|
|
} catch (err) {
|
|
console.error('webhook error', err);
|
|
res.status(500).end();
|
|
}
|
|
}));
|
|
|
|
export { router, webhookRouter };
|