refactor: turn on noUncheckedIndexedAccess in both workspaces (#101) #167
@@ -7,3 +7,28 @@ export const pool = new Pool({
|
|||||||
password: process.env.PGPASSWORD,
|
password: process.env.PGPASSWORD,
|
||||||
database: process.env.PGDATABASE
|
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[] {
|
export function missingPlaceholders(key: TemplateKey, body: string): string[] {
|
||||||
const present = new Set<string>();
|
const present = new Set<string>();
|
||||||
for (const match of body.matchAll(PLACEHOLDER)) {
|
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));
|
return TEMPLATES[key].required.filter((name) => !present.has(name));
|
||||||
}
|
}
|
||||||
|
|
||||||
function substitute(text: string, values: Record<string, string>): string {
|
function substitute(text: string, values: Record<string, string>): string {
|
||||||
return text.replace(PLACEHOLDER, (whole, name: string) =>
|
return text.replace(PLACEHOLDER, (whole, name: string) => {
|
||||||
Object.prototype.hasOwnProperty.call(values, name) ? values[name] : whole
|
// 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);
|
const localWithSuffix = trimmed.slice(0, at);
|
||||||
return {
|
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)
|
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`,
|
WHERE s.token = $1 AND s.expires_at > now() AND c.disabled_at IS NULL`,
|
||||||
[token]
|
[token]
|
||||||
);
|
);
|
||||||
if (rows.length) req.customerId = rows[0].customer_id;
|
const [session] = rows;
|
||||||
|
if (session) req.customerId = session.customer_id;
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import multer from 'multer';
|
|||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { PoolClient } from 'pg';
|
import { PoolClient } from 'pg';
|
||||||
import { pool } from '../db';
|
import { pool, requireRow } from '../db';
|
||||||
import { ADMIN_ITEM_SELECT, AdminItemRow, ItemRecord } from '../itemSelect';
|
import { ADMIN_ITEM_SELECT, AdminItemRow, ItemRecord } from '../itemSelect';
|
||||||
import { ItemStatus } from '../types';
|
import { ItemStatus } from '../types';
|
||||||
import { asyncRoute } from '../asyncRoute';
|
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 *`,
|
`INSERT INTO items (name, description, price_cents, category_id) VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||||
[name, description, Math.round(parseFloat(price) * 100), categoryId ?? null]
|
[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++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO item_images (item_id, image_path, sort_order) VALUES ($1, $2, $3)`,
|
`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) {
|
if (tagNames) {
|
||||||
@@ -282,7 +282,7 @@ router.post('/items', uploadImages, asyncRoute(async (req: Request, res: Respons
|
|||||||
}
|
}
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
const { rows: full } = await pool.query<AdminItemRow>(`${ADMIN_ITEM_SELECT} WHERE i.id = $1`, [item.id]);
|
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) {
|
} catch (err) {
|
||||||
await client.query('ROLLBACK');
|
await client.query('ROLLBACK');
|
||||||
console.error(err);
|
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`,
|
`SELECT COALESCE(MAX(sort_order), -1) AS max_sort FROM item_images WHERE item_id = $1`,
|
||||||
[req.params.id]
|
[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) {
|
for (const file of files) {
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO item_images (item_id, image_path, sort_order) VALUES ($1, $2, $3)`,
|
`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' });
|
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') {
|
if (status === 'pending') {
|
||||||
return res.status(400).json({ error: 'this item is already pending' });
|
return res.status(400).json({ error: 'this item is already pending' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import { pool } from '../db';
|
import { pool, requireRow } from '../db';
|
||||||
import { asyncRoute } from '../asyncRoute';
|
import { asyncRoute } from '../asyncRoute';
|
||||||
|
|
||||||
interface CategoryRow {
|
interface CategoryRow {
|
||||||
@@ -205,7 +205,7 @@ router.delete('/:id', asyncRoute(async (req: Request, res: Response) => {
|
|||||||
// being deleted along with their category.
|
// being deleted along with their category.
|
||||||
await pool.query(`DELETE FROM categories WHERE id = $1`, [id]);
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import { pool } from '../db';
|
import { pool, requireRow } from '../db';
|
||||||
import { asyncRoute } from '../asyncRoute';
|
import { asyncRoute } from '../asyncRoute';
|
||||||
import { requireCustomer } from '../middleware/customerAuth';
|
import { requireCustomer } from '../middleware/customerAuth';
|
||||||
import { getSettings } from '../adminSettings';
|
import { getSettings } from '../adminSettings';
|
||||||
@@ -57,13 +57,17 @@ const CART_ITEM_SELECT = `
|
|||||||
|
|
||||||
router.get('/', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
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]);
|
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 [cart] = cartRows;
|
||||||
const { rows: items } = await pool.query<CartRow>(CART_ITEM_SELECT, [cartRows[0].id]);
|
if (!cart) return res.json({ items: [] });
|
||||||
|
const { rows: items } = await pool.query<CartRow>(CART_ITEM_SELECT, [cart.id]);
|
||||||
res.json({ items });
|
res.json({ items });
|
||||||
}));
|
}));
|
||||||
|
|
||||||
router.post('/items/:itemId', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
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;
|
const itemId = req.params.itemId;
|
||||||
|
if (!itemId) return res.status(400).json({ error: 'itemId is required' });
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
await client.query('BEGIN');
|
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' });
|
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;
|
let cartId: number;
|
||||||
if (cartRows.length) {
|
if (existingCart) {
|
||||||
cartId = cartRows[0].id;
|
cartId = existingCart.id;
|
||||||
await client.query(`UPDATE carts SET updated_at = now() WHERE id = $1`, [cartId]);
|
await client.query(`UPDATE carts SET updated_at = now() WHERE id = $1`, [cartId]);
|
||||||
} else {
|
} else {
|
||||||
const { rows: newCart } = await client.query<IdRow>(
|
const { rows: newCart } = await client.query<IdRow>(
|
||||||
`INSERT INTO carts (customer_id) VALUES ($1) RETURNING id`,
|
`INSERT INTO carts (customer_id) VALUES ($1) RETURNING id`,
|
||||||
[req.customerId]
|
[req.customerId]
|
||||||
);
|
);
|
||||||
cartId = newCart[0].id;
|
cartId = requireRow(newCart, 'the cart INSERT').id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { cartExpiryHours } = await getSettings();
|
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) => {
|
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;
|
const itemId = req.params.itemId;
|
||||||
|
if (!itemId) return res.status(400).json({ error: 'itemId is required' });
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import type { PoolClient } from 'pg';
|
import type { PoolClient } from 'pg';
|
||||||
import { pool } from '../db';
|
import { pool, requireRow } from '../db';
|
||||||
import { asyncRoute } from '../asyncRoute';
|
import { asyncRoute } from '../asyncRoute';
|
||||||
import { requireCustomer } from '../middleware/customerAuth';
|
import { requireCustomer } from '../middleware/customerAuth';
|
||||||
import { notifyFavoritersOfSale } from '../favoriteAlerts';
|
import { notifyFavoritersOfSale } from '../favoriteAlerts';
|
||||||
@@ -70,8 +70,9 @@ interface LockedCart {
|
|||||||
// and returns { cartId, items: [{id, name, price_cents}], totalCents }.
|
// and returns { cartId, items: [{id, name, price_cents}], totalCents }.
|
||||||
async function loadLockedCart(client: PoolClient, customerId: number): Promise<LockedCart | null> {
|
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 { rows: cartRows } = await client.query<IdRow>(`SELECT id FROM carts WHERE customer_id = $1`, [customerId]);
|
||||||
if (!cartRows.length) return null;
|
const [cart] = cartRows;
|
||||||
const cartId = cartRows[0].id;
|
if (!cart) return null;
|
||||||
|
const cartId = cart.id;
|
||||||
const { rows: items } = await client.query<CartItem>(
|
const { rows: items } = await client.query<CartItem>(
|
||||||
`SELECT i.id, i.name, i.price_cents
|
`SELECT i.id, i.name, i.price_cents
|
||||||
FROM cart_items ci
|
FROM cart_items ci
|
||||||
@@ -114,7 +115,7 @@ async function openCheckout(
|
|||||||
VALUES ($1, $2, $3, $4, $5, 'pending') RETURNING id`,
|
VALUES ($1, $2, $3, $4, $5, 'pending') RETURNING id`,
|
||||||
[customerId, shippingAddressId, processor, processorOrderId, cart.totalCents]
|
[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) {
|
for (const it of cart.items) {
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO checkout_items (checkout_id, item_id, price_cents) VALUES ($1, $2, $3)`,
|
`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' });
|
if (!rows.length) return res.status(404).json({ error: 'checkout not found' });
|
||||||
|
|
||||||
await client.query('BEGIN');
|
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 client.query('COMMIT');
|
||||||
await notifyFavoritersOfSale(sold.itemIds, sold.buyerId);
|
await notifyFavoritersOfSale(sold.itemIds, sold.buyerId);
|
||||||
res.json({ status: 'completed' });
|
res.json({ status: 'completed' });
|
||||||
@@ -282,7 +283,8 @@ webhookRouter.post('/', asyncRoute(async (req: Request, res: Response) => {
|
|||||||
const checkoutId = event.resource?.custom_id;
|
const checkoutId = event.resource?.custom_id;
|
||||||
if (checkoutId) {
|
if (checkoutId) {
|
||||||
const { rows } = await pool.query<CheckoutStatusRow>(`SELECT status FROM checkouts WHERE id = $1`, [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();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import { pool } from '../db';
|
import { pool, requireRow } from '../db';
|
||||||
import { requireCustomer } from '../middleware/customerAuth';
|
import { requireCustomer } from '../middleware/customerAuth';
|
||||||
import { sendMail } from '../mailer';
|
import { sendMail } from '../mailer';
|
||||||
import { renderTemplate, greeting, formatDuration } from '../emailTemplates';
|
import { renderTemplate, greeting, formatDuration } from '../emailTemplates';
|
||||||
@@ -220,7 +220,7 @@ router.post('/register', asyncRoute(async (req: Request, res: Response) => {
|
|||||||
unsubscribeToken
|
unsubscribeToken
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
const customer = rows[0];
|
const customer = requireRow(rows, 'the registration INSERT');
|
||||||
|
|
||||||
await issueVerificationEmail(customer.id, customer.email, customer.first_name, customer.last_name);
|
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()`,
|
`SELECT * FROM customer_tokens WHERE token = $1 AND kind = 'verify_email' AND expires_at > now()`,
|
||||||
[token]
|
[token]
|
||||||
);
|
);
|
||||||
if (!rows.length) return res.status(400).json({ error: 'invalid or expired token' });
|
const [verifyToken] = rows;
|
||||||
await pool.query(`UPDATE customers SET email_verified = true WHERE id = $1`, [rows[0].customer_id]);
|
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]);
|
await pool.query(`DELETE FROM customer_tokens WHERE token = $1`, [token]);
|
||||||
res.json({ status: 'verified' });
|
res.json({ status: 'verified' });
|
||||||
}));
|
}));
|
||||||
@@ -250,7 +251,8 @@ router.post(
|
|||||||
verificationResendLimiter,
|
verificationResendLimiter,
|
||||||
asyncRoute(async (req: Request, res: Response) => {
|
asyncRoute(async (req: Request, res: Response) => {
|
||||||
const { rows } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
|
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
|
// 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.
|
// 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()`,
|
`SELECT * FROM customer_tokens WHERE token = $1 AND kind = 'password_reset' AND expires_at > now()`,
|
||||||
[token]
|
[token]
|
||||||
);
|
);
|
||||||
if (!rows.length) return res.status(400).json({ error: 'invalid or expired token' });
|
const [resetToken] = rows;
|
||||||
const customerId = rows[0].customer_id;
|
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
|
// A token issued before the account was disabled would otherwise still mint a
|
||||||
// fresh session.
|
// 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 { rows: fresh } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [customerId]);
|
||||||
const sessionToken = await createSession(customerId);
|
const sessionToken = await createSession(customerId);
|
||||||
setSessionCookie(res, sessionToken);
|
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) => {
|
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 *`,
|
WHERE id = $4 RETURNING *`,
|
||||||
[enabled, enabled ? new Date() : null, enabled ? FAVORITE_ALERTS_CONSENT_TEXT : null, req.customerId]
|
[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) => {
|
router.get('/me', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
|
||||||
const { rows } = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [req.customerId]);
|
const [customer] = await pool.query<CustomerRecord>(`SELECT * FROM customers WHERE id = $1`, [req.customerId]).then(r => r.rows);
|
||||||
if (!rows.length) return res.status(404).json({ error: 'not found' });
|
if (!customer) return res.status(404).json({ error: 'not found' });
|
||||||
res.json(publicCustomer(rows[0]));
|
res.json(publicCustomer(customer));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Kept in step with registration for consistency. Note nothing in the frontend
|
// 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 *`,
|
`UPDATE customers SET first_name = $1, last_name = $2 WHERE id = $3 RETURNING *`,
|
||||||
[first, last, req.customerId]
|
[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) => {
|
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' });
|
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 { 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))) {
|
if (!(await bcrypt.compare(currentPassword || '', customer.password_hash))) {
|
||||||
return res.status(401).json({ error: 'current password is incorrect' });
|
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 { 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))) {
|
if (!(await bcrypt.compare(String(currentPassword ?? ''), customer.password_hash))) {
|
||||||
return res.status(401).json({ error: 'current password is incorrect' });
|
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));
|
.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]);
|
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) => {
|
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]);
|
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.setHeader('Content-Disposition', 'attachment; filename="my-data.json"');
|
||||||
res.json({
|
res.json({
|
||||||
customer: publicCustomer(customerRows[0]),
|
customer: publicCustomer(requireRow(customerRows, 'the signed-in customer')),
|
||||||
orders: orderRows,
|
orders: orderRows,
|
||||||
exported_at: new Date().toISOString()
|
exported_at: new Date().toISOString()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import { pool } from '../db';
|
import { pool, requireRow } from '../db';
|
||||||
import { asyncRoute } from '../asyncRoute';
|
import { asyncRoute } from '../asyncRoute';
|
||||||
|
|
||||||
interface IdRow {
|
interface IdRow {
|
||||||
@@ -18,7 +18,7 @@ router.get('/unsubscribe', asyncRoute(async (req: Request, res: Response) => {
|
|||||||
await pool.query(
|
await pool.query(
|
||||||
`UPDATE customers SET marketing_consent = false, marketing_consent_at = now(),
|
`UPDATE customers SET marketing_consent = false, marketing_consent_at = now(),
|
||||||
marketing_consent_text = 'Unsubscribed via email link' WHERE id = $1`,
|
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>');
|
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
|
// 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
|
// colours so the admin palette picker and the auto-assignment below can never
|
||||||
// drift apart — the frontend renders whatever string lands in tags.color.
|
// 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',
|
'magenta', 'red', 'volcano', 'orange', 'gold', 'lime',
|
||||||
'green', 'cyan', 'blue', 'geekblue', 'purple'
|
'green', 'cyan', 'blue', 'geekblue', 'purple'
|
||||||
];
|
];
|
||||||
@@ -61,7 +61,14 @@ export function tagColorFor(name: string): string {
|
|||||||
for (let i = 0; i < normalized.length; i++) {
|
for (let i = 0; i < normalized.length; i++) {
|
||||||
hash = ((hash << 5) + hash + normalized.charCodeAt(i)) | 0;
|
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 =
|
export const MARKETING_CONSENT_TEXT =
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ describe('POST /api/client-errors', () => {
|
|||||||
|
|
||||||
expect(res.status).toBe(204);
|
expect(res.status).toBe(204);
|
||||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
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('[client-error]');
|
||||||
expect(logged).toContain('context=catalogue');
|
expect(logged).toContain('context=catalogue');
|
||||||
expect(logged).toContain('Cannot read properties of undefined');
|
expect(logged).toContain('Cannot read properties of undefined');
|
||||||
@@ -64,7 +64,7 @@ describe('POST /api/client-errors', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(204);
|
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).toContain('[truncated]');
|
||||||
expect(logged.length).toBeLessThan(2000);
|
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 });
|
const res = await request(app).post('/api/client-errors').send({ context: 'page', message });
|
||||||
|
|
||||||
expect(res.status).toBe(204);
|
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).toContain(`message: ${message}\n`);
|
||||||
expect(logged).not.toContain('[truncated]');
|
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 });
|
const res = await request(app).post('/api/client-errors').send({ context: 'page', message });
|
||||||
|
|
||||||
expect(res.status).toBe(204);
|
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]`);
|
expect(logged).toContain(`message: ${'a'.repeat(500)}… [truncated]`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ describe('POST /api/client-errors', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(204);
|
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:'));
|
const stackLine = logged.split('\n').find((line) => line.trim().startsWith('stack:'));
|
||||||
expect(stackLine).toContain('[truncated]');
|
expect(stackLine).toContain('[truncated]');
|
||||||
});
|
});
|
||||||
@@ -112,7 +112,7 @@ describe('POST /api/client-errors', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(204);
|
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
|
const componentStackLine = logged
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.find((line) => line.trim().startsWith('componentStack:'));
|
.find((line) => line.trim().startsWith('componentStack:'));
|
||||||
@@ -127,7 +127,7 @@ describe('POST /api/client-errors', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(204);
|
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];
|
const pathLine = logged.split('\n')[0];
|
||||||
expect(pathLine).toContain('[truncated]');
|
expect(pathLine).toContain('[truncated]');
|
||||||
});
|
});
|
||||||
@@ -142,7 +142,7 @@ describe('POST /api/client-errors', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(204);
|
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
|
// The template itself joins four fixed lines with three newlines; that
|
||||||
// count must not grow no matter what the caller sends.
|
// 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(res.status).toBe(204);
|
||||||
expect(sentMail).toHaveBeenCalledTimes(1);
|
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
|
// 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);
|
requireAdminGate(h.req, h.res, h.next);
|
||||||
|
|
||||||
expect(warn).toHaveBeenCalledTimes(1);
|
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('[admin-gate]');
|
||||||
expect(logged).toContain('/api/admin/items');
|
expect(logged).toContain('/api/admin/items');
|
||||||
expect(logged).not.toContain('some-guessed-value');
|
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.
|
// this guard was found to be broken.
|
||||||
for (const line of source.split(/\r?\n/)) {
|
for (const line of source.split(/\r?\n/)) {
|
||||||
const match = /^\s+- ([A-Z_0-9]+)=(.*)$/.exec(line);
|
const match = /^\s+- ([A-Z_0-9]+)=(.*)$/.exec(line);
|
||||||
if (match) {
|
// Both groups are non-optional in the pattern, so a match always has them —
|
||||||
entries.set(match[1], match[2].trim());
|
// 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;
|
return entries;
|
||||||
|
|||||||
@@ -6,6 +6,11 @@
|
|||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"strict": true,
|
"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,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"resolveJsonModule": true
|
"resolveJsonModule": true
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const { Title } = Typography;
|
|||||||
|
|
||||||
// Mirrors TAG_COLORS in the backend's utils.ts — the server rejects anything
|
// Mirrors TAG_COLORS in the backend's utils.ts — the server rejects anything
|
||||||
// outside this set, so the two lists have to stay aligned.
|
// 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',
|
'magenta', 'red', 'volcano', 'orange', 'gold', 'lime',
|
||||||
'green', 'cyan', 'blue', 'geekblue', 'purple'
|
'green', 'cyan', 'blue', 'geekblue', 'purple'
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -166,7 +166,15 @@ export default function FilterDrawer({
|
|||||||
value={[filters.minPriceCents ?? bounds.min_cents, filters.maxPriceCents ?? sliderMax]}
|
value={[filters.minPriceCents ?? bounds.min_cents, filters.maxPriceCents ?? sliderMax]}
|
||||||
tooltip={{ formatter: (value) => `$${((value ?? 0) / 100).toFixed(0)}` }}
|
tooltip={{ formatter: (value) => `$${((value ?? 0) / 100).toFixed(0)}` }}
|
||||||
onChange={([min, max]) =>
|
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 }}>
|
<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
|
// Relative luminance per WCAG, used to tell "light" from "dark" without
|
||||||
// asserting exact hex values, which would break on any palette tweak.
|
// asserting exact hex values, which would break on any palette tweak.
|
||||||
function luminance(rgb: string): number {
|
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 channel = (c: number) => {
|
||||||
const s = c / 255;
|
const s = c / 255;
|
||||||
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
// #101. Indexing gives `T | undefined`, which it always did — this makes
|
||||||
|
// the compiler say so.
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"noEmit": true
|
"noEmit": true
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
// #101. Indexing gives `T | undefined`, which it always did — this makes
|
||||||
|
// the compiler say so.
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"noEmit": true
|
"noEmit": true
|
||||||
|
|||||||
Reference in New Issue
Block a user