refactor: turn on noUncheckedIndexedAccess in both workspaces (#101) #167
@@ -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