refactor: turn on noUncheckedIndexedAccess in both workspaces (#101)
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
This commit is contained in:
@@ -7,3 +7,28 @@ export const pool = new Pool({
|
||||
password: process.env.PGPASSWORD,
|
||||
database: process.env.PGDATABASE
|
||||
});
|
||||
|
||||
/**
|
||||
* The single row a query is guaranteed to have returned.
|
||||
*
|
||||
* For `INSERT ... RETURNING` and `UPDATE ... WHERE id = $1 RETURNING` after the
|
||||
* row's existence has already been established: Postgres returns exactly one
|
||||
* row, so there is nothing to branch on, but `noUncheckedIndexedAccess` is right
|
||||
* that `rows[0]` is `T | undefined` and the compiler cannot know better.
|
||||
*
|
||||
* A thrown error rather than a non-null assertion. If the assumption is ever
|
||||
* wrong the assertion would hand `undefined` to the next line and fail somewhere
|
||||
* unrelated, whereas this fails here and says which query. `asyncRoute` turns it
|
||||
* into a 500, which is the right answer for "the database did not do what the
|
||||
* statement says it does".
|
||||
*
|
||||
* Reads that legitimately might find nothing do not use this — they destructure
|
||||
* and branch, so the check and the use are the same thing.
|
||||
*/
|
||||
export function requireRow<T>(rows: T[], what: string): T {
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
throw new Error(`expected ${what} to return a row, got none`);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -179,15 +179,23 @@ const PLACEHOLDER = /\{\{\s*(\w+)\s*\}\}/g;
|
||||
export function missingPlaceholders(key: TemplateKey, body: string): string[] {
|
||||
const present = new Set<string>();
|
||||
for (const match of body.matchAll(PLACEHOLDER)) {
|
||||
present.add(match[1]);
|
||||
// PLACEHOLDER has exactly one capture group, so a match always has [1] —
|
||||
// but a RegExpMatchArray cannot say so, hence the guard rather than an
|
||||
// assertion. A match without it would be a change to the pattern.
|
||||
const name = match[1];
|
||||
if (name) present.add(name);
|
||||
}
|
||||
return TEMPLATES[key].required.filter((name) => !present.has(name));
|
||||
}
|
||||
|
||||
function substitute(text: string, values: Record<string, string>): string {
|
||||
return text.replace(PLACEHOLDER, (whole, name: string) =>
|
||||
Object.prototype.hasOwnProperty.call(values, name) ? values[name] : whole
|
||||
);
|
||||
return text.replace(PLACEHOLDER, (whole, name: string) => {
|
||||
// hasOwnProperty does not narrow an index signature, so the lookup is done
|
||||
// once and tested. Checking the value also treats an explicitly-undefined
|
||||
// entry the same as a missing one, which is what the caller means.
|
||||
const value = values[name];
|
||||
return value === undefined ? whole : value;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -31,7 +31,9 @@ function parseAddress(address: string): ParsedAddress | null {
|
||||
}
|
||||
const localWithSuffix = trimmed.slice(0, at);
|
||||
return {
|
||||
local: localWithSuffix.split('+')[0],
|
||||
// split always yields at least one element, so this cannot actually be
|
||||
// undefined — but String.split's type cannot express that.
|
||||
local: localWithSuffix.split('+')[0] ?? localWithSuffix,
|
||||
domain: trimmed.slice(at + 1)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@ export async function attachCustomer(req: Request, _res: Response, next: NextFun
|
||||
WHERE s.token = $1 AND s.expires_at > now() AND c.disabled_at IS NULL`,
|
||||
[token]
|
||||
);
|
||||
if (rows.length) req.customerId = rows[0].customer_id;
|
||||
const [session] = rows;
|
||||
if (session) req.customerId = session.customer_id;
|
||||
next();
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import multer from 'multer';
|
||||
import { promises as fs } from 'fs';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { PoolClient } from 'pg';
|
||||
import { pool } from '../db';
|
||||
import { pool, requireRow } from '../db';
|
||||
import { ADMIN_ITEM_SELECT, AdminItemRow, ItemRecord } from '../itemSelect';
|
||||
import { ItemStatus } from '../types';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
@@ -270,11 +270,11 @@ router.post('/items', uploadImages, asyncRoute(async (req: Request, res: Respons
|
||||
`INSERT INTO items (name, description, price_cents, category_id) VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||
[name, description, Math.round(parseFloat(price) * 100), categoryId ?? null]
|
||||
);
|
||||
const item = rows[0];
|
||||
const item = requireRow(rows, 'the item INSERT');
|
||||
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]
|
||||
[item.id, `/uploads/${files[i]?.filename ?? ''}`, i]
|
||||
);
|
||||
}
|
||||
if (tagNames) {
|
||||
@@ -282,7 +282,7 @@ router.post('/items', uploadImages, asyncRoute(async (req: Request, res: Respons
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
const { rows: full } = await pool.query<AdminItemRow>(`${ADMIN_ITEM_SELECT} WHERE i.id = $1`, [item.id]);
|
||||
res.json(full[0]);
|
||||
res.json(requireRow(full, 'the item just inserted'));
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error(err);
|
||||
@@ -325,7 +325,8 @@ router.put('/items/:id', uploadImages, asyncRoute(async (req: Request, res: Resp
|
||||
`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;
|
||||
// COALESCE'd MAX, so the aggregate always returns exactly one row.
|
||||
let nextSort = requireRow(existing, 'the MAX(sort_order) aggregate').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)`,
|
||||
@@ -388,7 +389,7 @@ router.post('/items/:id/unpublish', asyncRoute(async (req: Request, res: Respons
|
||||
return res.status(404).json({ error: 'not found' });
|
||||
}
|
||||
|
||||
const status = rows[0].status;
|
||||
const status = requireRow(rows, 'the item status lookup').status;
|
||||
if (status === 'pending') {
|
||||
return res.status(400).json({ error: 'this item is already pending' });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { pool } from '../db';
|
||||
import { pool, requireRow } from '../db';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
|
||||
interface CategoryRow {
|
||||
@@ -205,7 +205,7 @@ router.delete('/:id', asyncRoute(async (req: Request, res: Response) => {
|
||||
// being deleted along with their category.
|
||||
await pool.query(`DELETE FROM categories WHERE id = $1`, [id]);
|
||||
|
||||
res.json({ deleted_categories: ids.length, uncategorized_items: affected[0].n });
|
||||
res.json({ deleted_categories: ids.length, uncategorized_items: requireRow(affected, 'the affected-items COUNT').n });
|
||||
}));
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { pool } from '../db';
|
||||
import { pool, requireRow } from '../db';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
import { requireCustomer } from '../middleware/customerAuth';
|
||||
import { getSettings } from '../adminSettings';
|
||||
@@ -57,13 +57,17 @@ const CART_ITEM_SELECT = `
|
||||
|
||||
router.get('/', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
const { rows: cartRows } = await pool.query<IdRow>(`SELECT id FROM carts WHERE customer_id = $1`, [req.customerId]);
|
||||
if (!cartRows.length) return res.json({ items: [] });
|
||||
const { rows: items } = await pool.query<CartRow>(CART_ITEM_SELECT, [cartRows[0].id]);
|
||||
const [cart] = cartRows;
|
||||
if (!cart) return res.json({ items: [] });
|
||||
const { rows: items } = await pool.query<CartRow>(CART_ITEM_SELECT, [cart.id]);
|
||||
res.json({ items });
|
||||
}));
|
||||
|
||||
router.post('/items/:itemId', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
// Express types route params as an index signature, so this is
|
||||
// `string | undefined` even though the route cannot match without it.
|
||||
const itemId = req.params.itemId;
|
||||
if (!itemId) return res.status(400).json({ error: 'itemId is required' });
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
@@ -75,17 +79,18 @@ router.post('/items/:itemId', requireCustomer, asyncRoute(async (req: Request, r
|
||||
return res.status(409).json({ error: 'item is no longer available' });
|
||||
}
|
||||
|
||||
let { rows: cartRows } = await client.query<IdRow>(`SELECT id FROM carts WHERE customer_id = $1`, [req.customerId]);
|
||||
const { rows: cartRows } = await client.query<IdRow>(`SELECT id FROM carts WHERE customer_id = $1`, [req.customerId]);
|
||||
const [existingCart] = cartRows;
|
||||
let cartId: number;
|
||||
if (cartRows.length) {
|
||||
cartId = cartRows[0].id;
|
||||
if (existingCart) {
|
||||
cartId = existingCart.id;
|
||||
await client.query(`UPDATE carts SET updated_at = now() WHERE id = $1`, [cartId]);
|
||||
} else {
|
||||
const { rows: newCart } = await client.query<IdRow>(
|
||||
`INSERT INTO carts (customer_id) VALUES ($1) RETURNING id`,
|
||||
[req.customerId]
|
||||
);
|
||||
cartId = newCart[0].id;
|
||||
cartId = requireRow(newCart, 'the cart INSERT').id;
|
||||
}
|
||||
|
||||
const { cartExpiryHours } = await getSettings();
|
||||
@@ -107,7 +112,10 @@ router.post('/items/:itemId', requireCustomer, asyncRoute(async (req: Request, r
|
||||
}));
|
||||
|
||||
router.delete('/items/:itemId', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
// Express types route params as an index signature, so this is
|
||||
// `string | undefined` even though the route cannot match without it.
|
||||
const itemId = req.params.itemId;
|
||||
if (!itemId) return res.status(400).json({ error: 'itemId is required' });
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import type { PoolClient } from 'pg';
|
||||
import { pool } from '../db';
|
||||
import { pool, requireRow } from '../db';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
import { requireCustomer } from '../middleware/customerAuth';
|
||||
import { notifyFavoritersOfSale } from '../favoriteAlerts';
|
||||
@@ -70,8 +70,9 @@ interface LockedCart {
|
||||
// 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]);
|
||||
if (!cartRows.length) return null;
|
||||
const cartId = cartRows[0].id;
|
||||
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
|
||||
@@ -114,7 +115,7 @@ async function openCheckout(
|
||||
VALUES ($1, $2, $3, $4, $5, 'pending') RETURNING id`,
|
||||
[customerId, shippingAddressId, processor, processorOrderId, cart.totalCents]
|
||||
);
|
||||
const checkoutId = checkoutRows[0].id;
|
||||
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)`,
|
||||
@@ -221,7 +222,7 @@ router.post('/paypal/capture', requireCustomer, asyncRoute(async (req: Request,
|
||||
if (!rows.length) return res.status(404).json({ error: 'checkout not found' });
|
||||
|
||||
await client.query('BEGIN');
|
||||
const sold = await completeCheckout(client, rows[0].id, 'paypal', orderID, capture);
|
||||
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' });
|
||||
@@ -282,7 +283,8 @@ webhookRouter.post('/', asyncRoute(async (req: Request, res: Response) => {
|
||||
const checkoutId = event.resource?.custom_id;
|
||||
if (checkoutId) {
|
||||
const { rows } = await pool.query<CheckoutStatusRow>(`SELECT status FROM checkouts WHERE id = $1`, [checkoutId]);
|
||||
if (rows.length && rows[0].status !== 'completed') {
|
||||
const [checkout] = rows;
|
||||
if (checkout && checkout.status !== 'completed') {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import crypto from 'node:crypto';
|
||||
import { pool } from '../db';
|
||||
import { pool, requireRow } from '../db';
|
||||
import { requireCustomer } from '../middleware/customerAuth';
|
||||
import { sendMail } from '../mailer';
|
||||
import { renderTemplate, greeting, formatDuration } from '../emailTemplates';
|
||||
@@ -220,7 +220,7 @@ router.post('/register', asyncRoute(async (req: Request, res: Response) => {
|
||||
unsubscribeToken
|
||||
]
|
||||
);
|
||||
const customer = rows[0];
|
||||
const customer = requireRow(rows, 'the registration INSERT');
|
||||
|
||||
await issueVerificationEmail(customer.id, customer.email, customer.first_name, customer.last_name);
|
||||
|
||||
@@ -235,8 +235,9 @@ router.post('/verify-email', asyncRoute(async (req: Request, res: Response) => {
|
||||
`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]);
|
||||
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' });
|
||||
}));
|
||||
@@ -250,7 +251,8 @@ router.post(
|
||||
verificationResendLimiter,
|
||||
asyncRoute(async (req: Request, res: Response) => {
|
||||
const { rows } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
|
||||
const customer = rows[0];
|
||||
// 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.
|
||||
@@ -323,8 +325,9 @@ router.post('/reset-password', asyncRoute(async (req: Request, res: Response) =>
|
||||
`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;
|
||||
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.
|
||||
@@ -361,7 +364,7 @@ router.post('/reset-password', asyncRoute(async (req: Request, res: Response) =>
|
||||
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(fresh[0]));
|
||||
res.json(publicCustomer(requireRow(fresh, 'the customer whose password was just reset')));
|
||||
}));
|
||||
|
||||
router.post('/login', asyncRoute(async (req: Request, res: Response) => {
|
||||
@@ -430,13 +433,13 @@ router.put('/me/favorite-alerts', requireCustomer, asyncRoute(async (req: Reques
|
||||
WHERE id = $4 RETURNING *`,
|
||||
[enabled, enabled ? new Date() : null, enabled ? FAVORITE_ALERTS_CONSENT_TEXT : null, req.customerId]
|
||||
);
|
||||
res.json(publicCustomer(rows[0]));
|
||||
res.json(publicCustomer(requireRow(rows, 'the favorite-alerts UPDATE')));
|
||||
}));
|
||||
|
||||
router.get('/me', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
const { rows } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
||||
res.json(publicCustomer(rows[0]));
|
||||
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
|
||||
@@ -459,7 +462,7 @@ router.put('/me', requireCustomer, asyncRoute(async (req: Request, res: Response
|
||||
`UPDATE customers SET first_name = $1, last_name = $2 WHERE id = $3 RETURNING *`,
|
||||
[first, last, req.customerId]
|
||||
);
|
||||
res.json(publicCustomer(rows[0]));
|
||||
res.json(publicCustomer(requireRow(rows, 'the name UPDATE')));
|
||||
}));
|
||||
|
||||
router.post('/change-password', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
@@ -468,7 +471,7 @@ router.post('/change-password', requireCustomer, asyncRoute(async (req: Request,
|
||||
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 = rows[0];
|
||||
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' });
|
||||
}
|
||||
@@ -500,7 +503,7 @@ router.put('/me/email', requireCustomer, asyncRoute(async (req: Request, res: Re
|
||||
}
|
||||
|
||||
const { rows } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
|
||||
const customer = rows[0];
|
||||
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' });
|
||||
@@ -542,7 +545,7 @@ router.put('/me/email', requireCustomer, asyncRoute(async (req: Request, res: Re
|
||||
.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(updated[0]));
|
||||
res.json(publicCustomer(requireRow(updated, 'the customer after the email change')));
|
||||
}));
|
||||
|
||||
router.post('/me/consent', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||
@@ -569,7 +572,7 @@ router.get('/me/export', requireCustomer, asyncRoute(async (req: Request, res: R
|
||||
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(customerRows[0]),
|
||||
customer: publicCustomer(requireRow(customerRows, 'the signed-in customer')),
|
||||
orders: orderRows,
|
||||
exported_at: new Date().toISOString()
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { pool } from '../db';
|
||||
import { pool, requireRow } from '../db';
|
||||
import { asyncRoute } from '../asyncRoute';
|
||||
|
||||
interface IdRow {
|
||||
@@ -18,7 +18,7 @@ router.get('/unsubscribe', asyncRoute(async (req: Request, res: Response) => {
|
||||
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]
|
||||
[requireRow(rows, 'the unsubscribe-token lookup').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>');
|
||||
}));
|
||||
|
||||
@@ -43,7 +43,7 @@ export function isValidEmail(email: string): boolean {
|
||||
// antd's preset Tag colours. Kept as the single source of truth for tag
|
||||
// colours so the admin palette picker and the auto-assignment below can never
|
||||
// drift apart — the frontend renders whatever string lands in tags.color.
|
||||
export const TAG_COLORS = [
|
||||
export const TAG_COLORS: [string, ...string[]] = [
|
||||
'magenta', 'red', 'volcano', 'orange', 'gold', 'lime',
|
||||
'green', 'cyan', 'blue', 'geekblue', 'purple'
|
||||
];
|
||||
@@ -61,7 +61,14 @@ export function tagColorFor(name: string): string {
|
||||
for (let i = 0; i < normalized.length; i++) {
|
||||
hash = ((hash << 5) + hash + normalized.charCodeAt(i)) | 0;
|
||||
}
|
||||
return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length];
|
||||
// The modulo keeps this in range, but an index signature cannot say so. The
|
||||
// fallback is the first colour rather than a throw: a tag with an unexpected
|
||||
// colour is not worth failing a request over.
|
||||
// TAG_COLORS is typed as a non-empty tuple, so index 0 is known to exist —
|
||||
// the annotation, rather than `as const`, because the elements must stay
|
||||
// `string` for the callers that assign them. The modulo keeps the computed
|
||||
// index in range; the fallback only exists because indexing cannot say so.
|
||||
return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length] ?? TAG_COLORS[0];
|
||||
}
|
||||
|
||||
export const MARKETING_CONSENT_TEXT =
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('POST /api/client-errors', () => {
|
||||
|
||||
expect(res.status).toBe(204);
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
const logged = errorSpy.mock.calls[0][0] as string;
|
||||
const logged = errorSpy.mock.calls[0]?.[0] as string;
|
||||
expect(logged).toContain('[client-error]');
|
||||
expect(logged).toContain('context=catalogue');
|
||||
expect(logged).toContain('Cannot read properties of undefined');
|
||||
@@ -64,7 +64,7 @@ describe('POST /api/client-errors', () => {
|
||||
});
|
||||
|
||||
expect(res.status).toBe(204);
|
||||
const logged = errorSpy.mock.calls[0][0] as string;
|
||||
const logged = errorSpy.mock.calls[0]?.[0] as string;
|
||||
expect(logged).toContain('[truncated]');
|
||||
expect(logged.length).toBeLessThan(2000);
|
||||
});
|
||||
@@ -76,7 +76,7 @@ describe('POST /api/client-errors', () => {
|
||||
const res = await request(app).post('/api/client-errors').send({ context: 'page', message });
|
||||
|
||||
expect(res.status).toBe(204);
|
||||
const logged = errorSpy.mock.calls[0][0] as string;
|
||||
const logged = errorSpy.mock.calls[0]?.[0] as string;
|
||||
expect(logged).toContain(`message: ${message}\n`);
|
||||
expect(logged).not.toContain('[truncated]');
|
||||
});
|
||||
@@ -87,7 +87,7 @@ describe('POST /api/client-errors', () => {
|
||||
const res = await request(app).post('/api/client-errors').send({ context: 'page', message });
|
||||
|
||||
expect(res.status).toBe(204);
|
||||
const logged = errorSpy.mock.calls[0][0] as string;
|
||||
const logged = errorSpy.mock.calls[0]?.[0] as string;
|
||||
expect(logged).toContain(`message: ${'a'.repeat(500)}… [truncated]`);
|
||||
});
|
||||
|
||||
@@ -99,7 +99,7 @@ describe('POST /api/client-errors', () => {
|
||||
});
|
||||
|
||||
expect(res.status).toBe(204);
|
||||
const logged = errorSpy.mock.calls[0][0] as string;
|
||||
const logged = errorSpy.mock.calls[0]?.[0] as string;
|
||||
const stackLine = logged.split('\n').find((line) => line.trim().startsWith('stack:'));
|
||||
expect(stackLine).toContain('[truncated]');
|
||||
});
|
||||
@@ -112,7 +112,7 @@ describe('POST /api/client-errors', () => {
|
||||
});
|
||||
|
||||
expect(res.status).toBe(204);
|
||||
const logged = errorSpy.mock.calls[0][0] as string;
|
||||
const logged = errorSpy.mock.calls[0]?.[0] as string;
|
||||
const componentStackLine = logged
|
||||
.split('\n')
|
||||
.find((line) => line.trim().startsWith('componentStack:'));
|
||||
@@ -127,7 +127,7 @@ describe('POST /api/client-errors', () => {
|
||||
});
|
||||
|
||||
expect(res.status).toBe(204);
|
||||
const logged = errorSpy.mock.calls[0][0] as string;
|
||||
const logged = errorSpy.mock.calls[0]?.[0] as string;
|
||||
const pathLine = logged.split('\n')[0];
|
||||
expect(pathLine).toContain('[truncated]');
|
||||
});
|
||||
@@ -142,7 +142,7 @@ describe('POST /api/client-errors', () => {
|
||||
});
|
||||
|
||||
expect(res.status).toBe(204);
|
||||
const logged = errorSpy.mock.calls[0][0] as string;
|
||||
const logged = errorSpy.mock.calls[0]?.[0] as string;
|
||||
|
||||
// The template itself joins four fixed lines with three newlines; that
|
||||
// count must not grow no matter what the caller sends.
|
||||
|
||||
@@ -67,7 +67,7 @@ describe('resending your own verification email', () => {
|
||||
|
||||
expect(res.status).toBe(204);
|
||||
expect(sentMail).toHaveBeenCalledTimes(1);
|
||||
expect(String(sentMail.mock.calls[0][0])).toBe(email);
|
||||
expect(String(sentMail.mock.calls[0]?.[0])).toBe(email);
|
||||
});
|
||||
|
||||
// The point of the whole thing. An un-superseded link means a message still
|
||||
|
||||
@@ -131,7 +131,7 @@ describe('requireAdminGate', () => {
|
||||
requireAdminGate(h.req, h.res, h.next);
|
||||
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
const logged = warn.mock.calls[0][0] as string;
|
||||
const logged = warn.mock.calls[0]?.[0] as string;
|
||||
expect(logged).toContain('[admin-gate]');
|
||||
expect(logged).toContain('/api/admin/items');
|
||||
expect(logged).not.toContain('some-guessed-value');
|
||||
|
||||
@@ -75,8 +75,11 @@ function environmentEntries(source: string): Map<string, string> {
|
||||
// this guard was found to be broken.
|
||||
for (const line of source.split(/\r?\n/)) {
|
||||
const match = /^\s+- ([A-Z_0-9]+)=(.*)$/.exec(line);
|
||||
if (match) {
|
||||
entries.set(match[1], match[2].trim());
|
||||
// Both groups are non-optional in the pattern, so a match always has them —
|
||||
// but RegExpExecArray cannot say so, and #101 made the compiler insist.
|
||||
const [, name, value] = match ?? [];
|
||||
if (name !== undefined && value !== undefined) {
|
||||
entries.set(name, value.trim());
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
// #101. Indexing an array gives `T | undefined`, which is what it always
|
||||
// was — this makes the compiler say so. Only worth having once query rows
|
||||
// carry real types (#159): before that `rows[0]` was `any`, and `any`
|
||||
// indexes to `any`, so there was nothing for this to check.
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
|
||||
@@ -15,7 +15,7 @@ const { Title } = Typography;
|
||||
|
||||
// Mirrors TAG_COLORS in the backend's utils.ts — the server rejects anything
|
||||
// outside this set, so the two lists have to stay aligned.
|
||||
const TAG_COLORS = [
|
||||
const TAG_COLORS: [string, ...string[]] = [
|
||||
'magenta', 'red', 'volcano', 'orange', 'gold', 'lime',
|
||||
'green', 'cyan', 'blue', 'geekblue', 'purple'
|
||||
];
|
||||
|
||||
@@ -166,7 +166,15 @@ export default function FilterDrawer({
|
||||
value={[filters.minPriceCents ?? bounds.min_cents, filters.maxPriceCents ?? sliderMax]}
|
||||
tooltip={{ formatter: (value) => `$${((value ?? 0) / 100).toFixed(0)}` }}
|
||||
onChange={([min, max]) =>
|
||||
onChange({ ...filters, minPriceCents: min, maxPriceCents: max })
|
||||
// antd types the slider's value as number[], so destructuring gives
|
||||
// `number | undefined`. A range slider always emits both ends; the
|
||||
// fallbacks are the bounds it was given rather than nulls, which
|
||||
// would read as "no filter" and widen the results.
|
||||
onChange({
|
||||
...filters,
|
||||
minPriceCents: min ?? bounds.min_cents,
|
||||
maxPriceCents: max ?? sliderMax
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 }}>
|
||||
|
||||
@@ -3,7 +3,9 @@ import { test, expect, AdminPage, createCategory, createTag, uniqueSuffix } from
|
||||
// Relative luminance per WCAG, used to tell "light" from "dark" without
|
||||
// asserting exact hex values, which would break on any palette tweak.
|
||||
function luminance(rgb: string): number {
|
||||
const [r, g, b] = (rgb.match(/\d+(\.\d+)?/g) ?? ['0', '0', '0']).slice(0, 3).map(Number);
|
||||
// Defaulted per channel rather than on the match: a colour string with
|
||||
// fewer than three numbers would otherwise leave a channel undefined.
|
||||
const [r = 0, g = 0, b = 0] = (rgb.match(/\d+(\.\d+)?/g) ?? []).slice(0, 3).map(Number);
|
||||
const channel = (c: number) => {
|
||||
const s = c / 255;
|
||||
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
// #101. Indexing gives `T | undefined`, which it always did — this makes
|
||||
// the compiler say so.
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"noEmit": true
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"moduleResolution": "node",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
// #101. Indexing gives `T | undefined`, which it always did — this makes
|
||||
// the compiler say so.
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"noEmit": true
|
||||
|
||||
Reference in New Issue
Block a user