Files
redefined-designs/backend/src/server.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

123 lines
5.1 KiB
TypeScript
Executable File

import cron from 'node-cron';
import app from './app';
import { pool } from './db';
import { sendMail } from './mailer';
import { renderTemplate, greeting, formatDuration } from './emailTemplates';
import { getSettings } from './adminSettings';
import { loadStoredTemplate } from './routes/adminEmailTemplates';
import { validateEnv } from './envValidation';
// Release cart holds whose expiry has passed.
async function sweepExpiredCarts(): Promise<void> {
try {
const { rows } = await pool.query<ExpiredCartItemRow>(
`DELETE FROM cart_items WHERE expires_at < now() RETURNING item_id`
);
for (const row of rows) {
await pool.query(`UPDATE items SET status = 'available' WHERE id = $1 AND status = 'reserved'`, [row.item_id]);
}
} catch (err) {
console.error('cart expiry sweep failed:', (err as Error).message);
}
}
// Remind customers who opted into marketing email about items still held in
// their cart.
async function sendCartReminders(): Promise<void> {
try {
const { rows } = await pool.query<ReminderRow>(`
SELECT c.email, c.first_name, c.last_name, i.name AS item_name, ci.expires_at, ci.id AS cart_item_id
FROM cart_items ci
JOIN carts ca ON ca.id = ci.cart_id
JOIN customers c ON c.id = ca.customer_id
JOIN items i ON i.id = ci.item_id
WHERE c.marketing_consent = true
AND (ci.last_reminder_sent_at IS NULL OR ci.last_reminder_sent_at < now() - interval '20 hours')
AND ci.expires_at > now()
`);
const byEmail = new Map<string, { firstName: string | null; lastName: string | null; items: { name: string; expiresAt: Date; cartItemId: number }[] }>();
for (const row of rows) {
if (!byEmail.has(row.email)) byEmail.set(row.email, { firstName: row.first_name, lastName: row.last_name, items: [] });
byEmail.get(row.email)!.items.push({ name: row.item_name, expiresAt: row.expires_at, cartItemId: row.cart_item_id });
}
// Loaded once rather than per recipient: the copy is shared, only the
// greeting and the item list differ.
const stored = await loadStoredTemplate('cartReminder');
const { cartExpiryHours, greetingFormat, greetingFallback } = await getSettings();
const holdDuration = formatDuration(cartExpiryHours);
for (const [email, data] of byEmail) {
// Markdown, not HTML. Values are substituted into the template source
// before it is rendered, and the renderer escapes raw HTML — so an <li>
// here would reach the customer as literal angle brackets.
const itemList = data.items
.map(i => `- ${i.name} — reserved until ${i.expiresAt.toLocaleString()}`)
.join('\n');
const { subject, html } = renderTemplate('cartReminder', stored, {
greeting: greeting(data.firstName, greetingFormat, greetingFallback, data.lastName),
firstName: data.firstName ?? '',
lastName: data.lastName ?? '',
itemList,
cartUrl: `${process.env.PUBLIC_URL}/cart`,
holdDuration
});
await sendMail(email, subject, html);
const ids = data.items.map(i => i.cartItemId);
await pool.query(`UPDATE cart_items SET last_reminder_sent_at = now() WHERE id = ANY($1::int[])`, [ids]);
}
} catch (err) {
console.error('daily cart reminder job failed:', (err as Error).message);
}
}
/** What the expiry sweep releases, so the items can be returned to the shop. */
interface ExpiredCartItemRow {
item_id: number;
}
/** One held item and who to remind about it. */
interface ReminderRow {
email: string;
first_name: string | null;
last_name: string | null;
item_name: string;
expires_at: Date;
cart_item_id: number;
}
// Neither scheduler has anything to await these with, so `void` states that the
// promise is deliberately dropped. That is only safe because both functions
// catch their own errors above — an escaping rejection would be unhandled, and
// Node terminates the process on those by default, so a database blip during
// the sweep would take the container down with it.
setInterval(() => void sweepExpiredCarts(), 5 * 60 * 1000);
cron.schedule('0 9 * * *', () => void sendCartReminders());
const PORT = parseInt(process.env.PORT || '3000', 10);
// Checked at boot rather than left to be discovered by the first request that
// happens to need a missing value. Every problem is reported at once — fixing a
// fresh environment one restart at a time is miserable — and anything fatal
// stops the process, the same way a failed migration does rather than serving
// against a schema it does not match. The admin-gate warning lives here too now
// (#63), so there is one place that says what this container is and is not
// configured to do. See envValidation.ts and #64.
const { errors, warnings } = validateEnv(process.env);
for (const warning of warnings) {
console.warn(`[config] ${warning}`);
}
if (errors.length) {
console.error(`[config] refusing to start — ${errors.length} problem(s) with the environment:`);
for (const error of errors) {
console.error(`[config] - ${error}`);
}
process.exit(1);
}
app.listen(PORT, () => console.log(`redefined-designs listening on ${PORT}`));