Files
redefined-designs/backend/src/routes/adminEmailTemplates.ts
T
bermudalamb 179cbad225 refactor(backend): type the remaining query results (#159)
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
2026-08-24 15:03:48 -05:00

190 lines
6.8 KiB
TypeScript

import { Router, Request, Response } from 'express';
import { pool } from '../db';
import { asyncRoute } from '../asyncRoute';
import {
TEMPLATES,
TemplateKey,
StoredTemplate,
missingPlaceholders,
renderTemplate,
formatDuration,
greeting,
SAMPLE_VALUES
} from '../emailTemplates';
import { getSettings } from '../adminSettings';
/** A row of the admin_settings key/value store. */
interface SettingRow {
key: string;
value: string;
}
const router = Router();
const KEYS = Object.keys(TEMPLATES) as TemplateKey[];
// Stored in admin_settings rather than a table of their own: it is already a
// key/value store with a settled read/write shape, and five templates is not a
// schema.
const settingKey = (key: TemplateKey, part: 'subject' | 'body') => `email_${key}_${part}`;
function isTemplateKey(value: unknown): value is TemplateKey {
return typeof value === 'string' && (KEYS as string[]).includes(value);
}
export async function loadStoredTemplate(key: TemplateKey): Promise<StoredTemplate> {
const { rows } = await pool.query<SettingRow>(`SELECT key, value FROM admin_settings WHERE key = ANY($1)`, [
[settingKey(key, 'subject'), settingKey(key, 'body')]
]);
const stored: StoredTemplate = {};
for (const row of rows) {
if (row.key === settingKey(key, 'subject')) stored.subject = row.value;
if (row.key === settingKey(key, 'body')) stored.body = row.value;
}
return stored;
}
// Returns the definitions alongside whatever is stored, so the admin screen can
// show the placeholders a template accepts and which of them it must keep,
// rather than the editor having to know.
router.get('/', asyncRoute(async (_req: Request, res: Response) => {
const { rows } = await pool.query<SettingRow>(
`SELECT key, value FROM admin_settings WHERE key LIKE 'email\\_%'`
);
const stored = new Map<string, string>(rows.map((r) => [r.key, r.value]));
res.json(
KEYS.map((key) => ({
key,
label: TEMPLATES[key].label,
required: TEMPLATES[key].required,
available: TEMPLATES[key].available,
defaultSubject: TEMPLATES[key].defaultSubject,
defaultBody: TEMPLATES[key].defaultBody,
// Null rather than the default, so the admin can tell "not customised"
// from "customised to exactly the default text".
subject: stored.get(settingKey(key, 'subject')) ?? null,
body: stored.get(settingKey(key, 'body')) ?? null
}))
);
}));
// Renders what an email would look like, from the subject and body in the
// editor rather than from what is stored — so an admin sees the effect of an
// edit before committing to it.
//
// Rendered here rather than in the browser, deliberately. renderTemplate is the
// only thing that turns this markdown into HTML, and markdown-it is configured
// with html: false, which is what stops an admin putting script into a
// customer's inbox. A second renderer in the frontend would be a second place
// for that setting to be wrong, and a preview that differs from the mailer is
// worse than no preview.
//
// Deliberately does not enforce required placeholders. Saving refuses a body
// that dropped one; previewing it is how an admin sees what they have done.
/**
* The sample values, with the three duration placeholders replaced by what the
* settings actually hold.
*
* The preview exists so an admin sees the email that will be sent. A duration
* drawn from a static sample would show "one hour" while the setting said two,
* which is the precise failure this placeholder was added to remove.
*/
async function previewValues(key: TemplateKey): Promise<Record<string, string>> {
const {
cartExpiryHours,
verifyTokenHours,
passwordResetHours,
greetingFormat,
greetingFallback
} = await getSettings();
// `expiresIn` names one placeholder but two different lifetimes, so the value
// depends on which template is being previewed. The route knows the key.
const expiresIn = key === 'passwordReset' ? passwordResetHours : verifyTokenHours;
return {
...SAMPLE_VALUES,
holdDuration: formatDuration(cartExpiryHours),
expiresIn: formatDuration(expiresIn),
// Built from the configured format for the same reason as the durations:
// the preview is meant to show the email that will be sent.
greeting: greeting(SAMPLE_VALUES.firstName, greetingFormat, greetingFallback, SAMPLE_VALUES.lastName)
};
}
router.post('/:key/preview', asyncRoute(async (req: Request, res: Response) => {
const key = req.params.key;
if (!isTemplateKey(key)) {
return res.status(404).json({ error: 'unknown template' });
}
const { subject, body } = req.body ?? {};
const rendered = renderTemplate(
key,
{
subject: typeof subject === 'string' ? subject : null,
body: typeof body === 'string' ? body : null
},
await previewValues(key)
);
res.json(rendered);
}));
router.put('/:key', asyncRoute(async (req: Request, res: Response) => {
const key = req.params.key;
if (!isTemplateKey(key)) {
return res.status(404).json({ error: 'unknown template' });
}
const subject = typeof req.body?.subject === 'string' ? req.body.subject.trim() : '';
const body = typeof req.body?.body === 'string' ? req.body.body.trim() : '';
if (!subject) {
return res.status(400).json({ error: 'a subject is required' });
}
if (!body) {
return res.status(400).json({ error: 'a body is required' });
}
// The rule that makes this feature safe rather than a way to break password
// resets from a settings screen. A body without its link still sends, still
// looks correct in the log, and is useless to everyone who receives it — so
// the save is refused rather than warned about.
const missing = missingPlaceholders(key, body);
if (missing.length) {
const named = missing.map((name) => '{{' + name + '}}').join(' and ');
return res.status(400).json({ error: `the body must keep ${named}` });
}
for (const [part, value] of [
['subject', subject],
['body', body]
] as const) {
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()`,
[settingKey(key, part), value]
);
}
res.json({ key, subject, body });
}));
// Restores the built-in copy by removing the stored rows, rather than by
// writing the default into them — so "not customised" stays distinguishable
// from "customised back to the original wording".
router.delete('/:key', asyncRoute(async (req: Request, res: Response) => {
const key = req.params.key;
if (!isTemplateKey(key)) {
return res.status(404).json({ error: 'unknown template' });
}
await pool.query(`DELETE FROM admin_settings WHERE key = ANY($1)`, [
[settingKey(key, 'subject'), settingKey(key, 'body')]
]);
res.json({ key, subject: null, body: null });
}));
export default router;