Completes the typing. Every `.query(...)` in backend/src whose rows are read now carries a row type: adminCustomers, adminCategories, shippingAddresses, adminTags, adminEmailTemplates, adminSettings, public, server and the auth middleware. Typed sites go from 49 to 78, and there are no untyped reads left anywhere. Writes and transaction control stay untyped, which is the exemption #159's criteria allow for and the reason is stated in each file: they return nothing anyone reads, and annotating them would bury the ones that matter. The aggregates needed checking rather than guessing, and the answer was not what the shapes suggest. Postgres returns COUNT as bigint and SUM as numeric, and node-postgres hands both back as strings — only an explicit ::int cast arrives as a number. Probed against the real database: COUNT(*) is a string, COUNT(*)::int is a number, SUM() is a string, MAX(timestamptz) is a Date. That makes the admin customer list a mixture. order_count and total_spent_cents are strings; reserved_count, which the query casts, is a number. They are typed as what they are. Which surfaces a mismatch worth knowing about and not fixed here. frontend/src/admin/adminCustomersApi.ts declares both as `number`, and Customers.tsx sorts with `a.order_count - b.order_count` and renders with `(v / 100).toFixed(2)`. Those work, because `-` and `/` coerce a numeric string. The first `+` written against either — a column total, say — will concatenate instead. Nothing is broken today; the types on both sides simply disagree about reality, and one of them is now right. Changing the API to cast would alter the response shape, which is a behaviour change and belongs in its own issue. Two smaller shapes worth a note. shipping_addresses.usps_standardized is jsonb that is only ever handed to the client, so it is `unknown` rather than a guessed object. And `SELECT 1 ... ` used purely for `.length` has no column name of its own — Postgres calls it `?column?` — so it is an index signature with nothing read out of it rather than a fabricated field. Verified: tsc clean, unit 254/254, integration 238/238, backend lint unchanged from main. Closes #159
88 lines
3.6 KiB
TypeScript
88 lines
3.6 KiB
TypeScript
import { pool } from './db';
|
|
|
|
/**
|
|
* Every admin-configurable setting, in one table.
|
|
*
|
|
* `cart_expiry_hours` used to be read by an inline query in two places, each
|
|
* with its own `|| '24'`. With several settings and read sites scattered across
|
|
* routes and the cron job that stops being tenable: a default written twice is
|
|
* a default that will eventually disagree with itself. Adding a setting means
|
|
* adding a row here and nothing else.
|
|
*
|
|
* Values are stored as text, so each row declares how to read it back. Numbers
|
|
* were the only kind until the greeting format arrived; typing it per setting
|
|
* rather than assuming means the next string one costs nothing.
|
|
*/
|
|
/** A row of the key/value store this module reads. */
|
|
interface SettingRow {
|
|
key: string;
|
|
value: string;
|
|
}
|
|
|
|
const DEFINITIONS = [
|
|
{ key: 'cart_expiry_hours', name: 'cartExpiryHours', type: 'hours', fallback: 24 },
|
|
{ key: 'verify_token_hours', name: 'verifyTokenHours', type: 'hours', fallback: 24 },
|
|
{ key: 'password_reset_hours', name: 'passwordResetHours', type: 'hours', fallback: 1 },
|
|
{ key: 'greeting_format', name: 'greetingFormat', type: 'text', fallback: 'Hi {{firstName}},' },
|
|
{ key: 'greeting_fallback', name: 'greetingFallback', type: 'text', fallback: 'Hi,' }
|
|
] as const;
|
|
|
|
type Definition = (typeof DEFINITIONS)[number];
|
|
|
|
export type SettingName = Definition['name'];
|
|
|
|
export type HoursSettingName = Extract<Definition, { type: 'hours' }>['name'];
|
|
export type TextSettingName = Extract<Definition, { type: 'text' }>['name'];
|
|
|
|
export type AdminSettings = Record<HoursSettingName, number> & Record<TextSettingName, string>;
|
|
|
|
export const HOURS_SETTINGS: readonly HoursSettingName[] = DEFINITIONS.filter(
|
|
(d): d is Extract<Definition, { type: 'hours' }> => d.type === 'hours'
|
|
).map(d => d.name);
|
|
|
|
export const TEXT_SETTINGS: readonly TextSettingName[] = DEFINITIONS.filter(
|
|
(d): d is Extract<Definition, { type: 'text' }> => d.type === 'text'
|
|
).map(d => d.name);
|
|
|
|
export async function getSettings(): Promise<AdminSettings> {
|
|
const { rows } = await pool.query<SettingRow>(`SELECT key, value FROM admin_settings`);
|
|
const stored = new Map<string, string>(rows.map(r => [r.key, r.value]));
|
|
|
|
const settings = {} as Record<SettingName, number | string>;
|
|
for (const { key, name, type, fallback } of DEFINITIONS) {
|
|
const raw = stored.get(key);
|
|
if (type === 'text') {
|
|
// An empty format would render every greeting as nothing at all, which
|
|
// reads as a bug in the email rather than a setting someone cleared.
|
|
settings[name] = raw !== undefined && raw.trim() !== '' ? raw : fallback;
|
|
continue;
|
|
}
|
|
const parsed = parseFloat(raw ?? '');
|
|
// A row that is present but unparseable falls back rather than yielding
|
|
// NaN, which would otherwise reach Date arithmetic and mint a token with an
|
|
// Invalid Date expiry that no query could ever match.
|
|
settings[name] = Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
}
|
|
return settings as AdminSettings;
|
|
}
|
|
|
|
/**
|
|
* Writes the supplied settings, ignoring names that were not sent.
|
|
*
|
|
* Partial rather than whole-object so a caller updating one field does not have
|
|
* to know the current value of the others to avoid clobbering them.
|
|
*/
|
|
export async function updateSettings(
|
|
values: Partial<Record<SettingName, number | string>>
|
|
): Promise<void> {
|
|
for (const { key, name } of DEFINITIONS) {
|
|
const value = values[name];
|
|
if (value === undefined) continue;
|
|
await pool.query(
|
|
`INSERT INTO admin_settings (key, value, updated_at) VALUES ($1, $2, now())
|
|
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = now()`,
|
|
[key, String(value)]
|
|
);
|
|
}
|
|
}
|