refactor(backend): type the remaining query results (#159) #165

Merged
bermudalamb merged 2 commits from feature/159-type-remaining-queries into main 2026-08-24 15:03:50 -05:00
9 changed files with 192 additions and 29 deletions
+7 -1
View File
@@ -13,6 +13,12 @@ import { pool } from './db';
* were the only kind until the greeting format arrived; typing it per setting * were the only kind until the greeting format arrived; typing it per setting
* rather than assuming means the next string one costs nothing. * 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 = [ const DEFINITIONS = [
{ key: 'cart_expiry_hours', name: 'cartExpiryHours', type: 'hours', fallback: 24 }, { key: 'cart_expiry_hours', name: 'cartExpiryHours', type: 'hours', fallback: 24 },
{ key: 'verify_token_hours', name: 'verifyTokenHours', type: 'hours', fallback: 24 }, { key: 'verify_token_hours', name: 'verifyTokenHours', type: 'hours', fallback: 24 },
@@ -39,7 +45,7 @@ export const TEXT_SETTINGS: readonly TextSettingName[] = DEFINITIONS.filter(
).map(d => d.name); ).map(d => d.name);
export async function getSettings(): Promise<AdminSettings> { export async function getSettings(): Promise<AdminSettings> {
const { rows } = await pool.query(`SELECT key, value FROM admin_settings`); 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 stored = new Map<string, string>(rows.map(r => [r.key, r.value]));
const settings = {} as Record<SettingName, number | string>; const settings = {} as Record<SettingName, number | string>;
+6 -1
View File
@@ -9,6 +9,11 @@ declare global {
} }
} }
/** Who a session cookie belongs to, if it is still valid. */
interface SessionOwnerRow {
customer_id: number;
}
export async function attachCustomer(req: Request, _res: Response, next: NextFunction): Promise<void> { export async function attachCustomer(req: Request, _res: Response, next: NextFunction): Promise<void> {
const token = req.cookies?.rd_session; const token = req.cookies?.rd_session;
if (!token) return next(); if (!token) return next();
@@ -16,7 +21,7 @@ export async function attachCustomer(req: Request, _res: Response, next: NextFun
// than when its 30-day cookie eventually expires. Register, login and // than when its 30-day cookie eventually expires. Register, login and
// password reset all mint sessions, so checking here covers every path // password reset all mint sessions, so checking here covers every path
// instead of three separate ones. // instead of three separate ones.
const { rows } = await pool.query( const { rows } = await pool.query<SessionOwnerRow>(
`SELECT s.customer_id `SELECT s.customer_id
FROM customer_sessions s FROM customer_sessions s
JOIN customers c ON c.id = s.customer_id JOIN customers c ON c.id = s.customer_id
+36 -7
View File
@@ -2,6 +2,35 @@ import { Router, Request, Response } from 'express';
import { pool } from '../db'; import { pool } from '../db';
import { asyncRoute } from '../asyncRoute'; import { asyncRoute } from '../asyncRoute';
interface CategoryRow {
id: number;
name: string;
parent_id: number | null;
sort_order: number;
}
/** The tree adds a usage count, cast to int so it arrives as a number. */
interface CategoryListRow extends CategoryRow {
item_count: number;
}
interface IdRow {
id: number;
}
interface CountRow {
n: number;
}
/**
* `SELECT 1 ...`, used only for `.length`. The column has no name of its own —
* Postgres calls it `?column?` — so the shape is an index signature rather than
* a field, and nothing reads a value out of it.
*/
interface ExistsProbe {
[column: string]: number;
}
const router = Router(); const router = Router();
// Postgres unique-violation SQLSTATE — raised by the two partial indexes that // Postgres unique-violation SQLSTATE — raised by the two partial indexes that
@@ -33,12 +62,12 @@ function readParentId(value: unknown): number | null | undefined {
} }
async function parentExists(id: number): Promise<boolean> { async function parentExists(id: number): Promise<boolean> {
const { rows } = await pool.query(`SELECT 1 FROM categories WHERE id = $1`, [id]); const { rows } = await pool.query<ExistsProbe>(`SELECT 1 FROM categories WHERE id = $1`, [id]);
return rows.length > 0; return rows.length > 0;
} }
router.get('/', asyncRoute(async (_req: Request, res: Response) => { router.get('/', asyncRoute(async (_req: Request, res: Response) => {
const { rows } = await pool.query( const { rows } = await pool.query<CategoryListRow>(
`SELECT c.id, c.name, c.parent_id, c.sort_order, `SELECT c.id, c.name, c.parent_id, c.sort_order,
(SELECT COUNT(*)::int FROM items i WHERE i.category_id = c.id) AS item_count (SELECT COUNT(*)::int FROM items i WHERE i.category_id = c.id) AS item_count
FROM categories c FROM categories c
@@ -65,7 +94,7 @@ router.post('/', asyncRoute(async (req: Request, res: Response) => {
const sortOrder = Number.isSafeInteger(req.body.sort_order) ? req.body.sort_order : 0; const sortOrder = Number.isSafeInteger(req.body.sort_order) ? req.body.sort_order : 0;
try { try {
const { rows } = await pool.query( const { rows } = await pool.query<CategoryRow>(
`INSERT INTO categories (name, parent_id, sort_order) VALUES ($1, $2, $3) `INSERT INTO categories (name, parent_id, sort_order) VALUES ($1, $2, $3)
RETURNING id, name, parent_id, sort_order`, RETURNING id, name, parent_id, sort_order`,
[name, parent, sortOrder] [name, parent, sortOrder]
@@ -107,7 +136,7 @@ async function resolveParentId(
// Moving a node beneath itself or one of its own descendants would detach // Moving a node beneath itself or one of its own descendants would detach
// that whole branch from the tree into an unreachable cycle. // that whole branch from the tree into an unreachable cycle.
const { rows: cycle } = await pool.query( const { rows: cycle } = await pool.query<ExistsProbe>(
`${SUBTREE_CTE} SELECT 1 FROM subtree WHERE id = $2`, `${SUBTREE_CTE} SELECT 1 FROM subtree WHERE id = $2`,
[id, parsed] [id, parsed]
); );
@@ -145,7 +174,7 @@ router.put('/:id', asyncRoute(async (req: Request, res: Response) => {
: existing.rows[0].sort_order; : existing.rows[0].sort_order;
try { try {
const { rows } = await pool.query( const { rows } = await pool.query<CategoryRow>(
`UPDATE categories SET name = $1, parent_id = $2, sort_order = $3 WHERE id = $4 `UPDATE categories SET name = $1, parent_id = $2, sort_order = $3 WHERE id = $4
RETURNING id, name, parent_id, sort_order`, RETURNING id, name, parent_id, sort_order`,
[name, parent, sortOrder, id] [name, parent, sortOrder, id]
@@ -161,13 +190,13 @@ router.put('/:id', asyncRoute(async (req: Request, res: Response) => {
router.delete('/:id', asyncRoute(async (req: Request, res: Response) => { router.delete('/:id', asyncRoute(async (req: Request, res: Response) => {
const id = Number(req.params.id); const id = Number(req.params.id);
const { rows: subtree } = await pool.query(`${SUBTREE_CTE} SELECT id FROM subtree`, [id]); const { rows: subtree } = await pool.query<IdRow>(`${SUBTREE_CTE} SELECT id FROM subtree`, [id]);
if (!subtree.length) { if (!subtree.length) {
return res.status(404).json({ error: 'not found' }); return res.status(404).json({ error: 'not found' });
} }
const ids = subtree.map((row: { id: number }) => row.id); const ids = subtree.map((row: { id: number }) => row.id);
const { rows: affected } = await pool.query( const { rows: affected } = await pool.query<CountRow>(
`SELECT COUNT(*)::int AS n FROM items WHERE category_id = ANY($1::int[])`, `SELECT COUNT(*)::int AS n FROM items WHERE category_id = ANY($1::int[])`,
[ids] [ids]
); );
+73 -8
View File
@@ -2,10 +2,75 @@ import { Router, Request, Response } from 'express';
import { pool } from '../db'; import { pool } from '../db';
import { asyncRoute } from '../asyncRoute'; import { asyncRoute } from '../asyncRoute';
/**
* Row shapes for the reads here, kept in step with their SQL by hand.
*
* NOTE ON THE AGGREGATES. Postgres returns COUNT as bigint and SUM as numeric,
* and node-postgres hands both back as **strings** — only an explicit ::int cast
* comes back as a number. So order_count and total_spent_cents are strings while
* reserved_count, which is cast, is a number. Verified against the database
* rather than assumed.
*
* The admin UI declares both as `number` and survives on coercion: `a - b` and
* `v / 100` both coerce a numeric string. The first `+` written against them
* will concatenate instead. Typed honestly here so the mismatch is visible
* rather than inherited.
*/
interface CustomerListRow {
id: number;
email: string;
name: string | null;
email_verified: boolean;
marketing_consent: boolean;
created_at: Date;
disabled_at: Date | null;
order_count: string;
total_spent_cents: string;
last_order_at: Date | null;
reserved_count: number;
}
interface CustomerDetailRow {
id: number;
email: string;
name: string | null;
email_verified: boolean;
marketing_consent: boolean;
marketing_consent_at: Date | null;
created_at: Date;
}
interface AdminOrderRow {
id: number;
processor: string;
processor_order_id: string | null;
amount_cents: number | null;
status: string | null;
created_at: Date;
item_name: string;
}
interface ReservedItemRow {
item_id: number;
name: string;
price_cents: number;
added_at: Date;
expires_at: Date;
}
/** What the cart-clearing DELETEs return, so the caller can release the items. */
interface HeldItemRow {
item_id: number;
}
interface IdRow {
id: number;
}
const router = Router(); const router = Router();
router.get('/', asyncRoute(async (_req: Request, res: Response) => { router.get('/', asyncRoute(async (_req: Request, res: Response) => {
const { rows } = await pool.query(` const { rows } = await pool.query<CustomerListRow>(`
SELECT SELECT
c.id, c.email, nullif(btrim(concat_ws(' ', c.first_name, c.last_name)), '') AS name, c.id, c.email, nullif(btrim(concat_ws(' ', c.first_name, c.last_name)), '') AS name,
c.email_verified, c.marketing_consent, c.created_at, c.disabled_at, c.email_verified, c.marketing_consent, c.created_at, c.disabled_at,
@@ -39,7 +104,7 @@ router.post('/:id/disable', asyncRoute(async (req: Request, res: Response) => {
try { try {
await client.query('BEGIN'); await client.query('BEGIN');
const { rows } = await client.query( const { rows } = await client.query<IdRow>(
`UPDATE customers SET disabled_at = now() WHERE id = $1 RETURNING id`, `UPDATE customers SET disabled_at = now() WHERE id = $1 RETURNING id`,
[req.params.id] [req.params.id]
); );
@@ -56,7 +121,7 @@ router.post('/:id/disable', asyncRoute(async (req: Request, res: Response) => {
// A disabled account cannot check out, so holding one-of-a-kind stock off // A disabled account cannot check out, so holding one-of-a-kind stock off
// the storefront until the expiry sweep serves nobody. Guarded on // the storefront until the expiry sweep serves nobody. Guarded on
// 'reserved' so a sold item is never resurrected. // 'reserved' so a sold item is never resurrected.
const { rows: held } = await client.query( const { rows: held } = await client.query<HeldItemRow>(
`DELETE FROM cart_items ci `DELETE FROM cart_items ci
USING carts ca USING carts ca
WHERE ci.cart_id = ca.id AND ca.customer_id = $1 WHERE ci.cart_id = ca.id AND ca.customer_id = $1
@@ -85,7 +150,7 @@ router.post('/:id/disable', asyncRoute(async (req: Request, res: Response) => {
// well have been sold to someone else in the meantime, and silently re-reserving // well have been sold to someone else in the meantime, and silently re-reserving
// them would be worse than making the customer add them again. // them would be worse than making the customer add them again.
router.post('/:id/enable', asyncRoute(async (req: Request, res: Response) => { router.post('/:id/enable', asyncRoute(async (req: Request, res: Response) => {
const { rows } = await pool.query( const { rows } = await pool.query<IdRow>(
`UPDATE customers SET disabled_at = NULL WHERE id = $1 RETURNING id`, `UPDATE customers SET disabled_at = NULL WHERE id = $1 RETURNING id`,
[req.params.id] [req.params.id]
); );
@@ -94,7 +159,7 @@ router.post('/:id/enable', asyncRoute(async (req: Request, res: Response) => {
})); }));
router.get('/:id/reserved', asyncRoute(async (req: Request, res: Response) => { router.get('/:id/reserved', asyncRoute(async (req: Request, res: Response) => {
const { rows } = await pool.query( const { rows } = await pool.query<ReservedItemRow>(
`SELECT i.id AS item_id, i.name, i.price_cents, ci.added_at, ci.expires_at `SELECT i.id AS item_id, i.name, i.price_cents, ci.added_at, ci.expires_at
FROM cart_items ci FROM cart_items ci
JOIN carts ca ON ca.id = ci.cart_id JOIN carts ca ON ca.id = ci.cart_id
@@ -113,7 +178,7 @@ router.post('/:id/reserved/:itemId/release', asyncRoute(async (req: Request, res
const client = await pool.connect(); const client = await pool.connect();
try { try {
await client.query('BEGIN'); await client.query('BEGIN');
const { rows } = await client.query( const { rows } = await client.query<HeldItemRow>(
`DELETE FROM cart_items ci `DELETE FROM cart_items ci
USING carts ca USING carts ca
WHERE ci.cart_id = ca.id AND ca.customer_id = $1 AND ci.item_id = $2 WHERE ci.cart_id = ca.id AND ca.customer_id = $1 AND ci.item_id = $2
@@ -141,7 +206,7 @@ router.post('/:id/reserved/:itemId/release', asyncRoute(async (req: Request, res
})); }));
router.get('/:id', asyncRoute(async (req: Request, res: Response) => { router.get('/:id', asyncRoute(async (req: Request, res: Response) => {
const { rows: customerRows } = await pool.query( const { rows: customerRows } = await pool.query<CustomerDetailRow>(
`SELECT id, email, nullif(btrim(concat_ws(' ', first_name, last_name)), '') AS name, `SELECT id, email, nullif(btrim(concat_ws(' ', first_name, last_name)), '') AS name,
email_verified, marketing_consent, marketing_consent_at, created_at email_verified, marketing_consent, marketing_consent_at, created_at
FROM customers WHERE id = $1`, FROM customers WHERE id = $1`,
@@ -149,7 +214,7 @@ router.get('/:id', asyncRoute(async (req: Request, res: Response) => {
); );
if (!customerRows.length) return res.status(404).json({ error: 'not found' }); if (!customerRows.length) return res.status(404).json({ error: 'not found' });
const { rows: orderRows } = await pool.query( const { rows: orderRows } = await pool.query<AdminOrderRow>(
`SELECT o.id, o.processor, o.processor_order_id, o.amount_cents, o.status, o.created_at, i.name AS item_name `SELECT o.id, o.processor, o.processor_order_id, o.amount_cents, o.status, o.created_at, i.name AS item_name
FROM orders o JOIN items i ON i.id = o.item_id FROM orders o JOIN items i ON i.id = o.item_id
WHERE o.customer_id = $1 WHERE o.customer_id = $1
+8 -2
View File
@@ -13,6 +13,12 @@ import {
} from '../emailTemplates'; } from '../emailTemplates';
import { getSettings } from '../adminSettings'; import { getSettings } from '../adminSettings';
/** A row of the admin_settings key/value store. */
interface SettingRow {
key: string;
value: string;
}
const router = Router(); const router = Router();
const KEYS = Object.keys(TEMPLATES) as TemplateKey[]; const KEYS = Object.keys(TEMPLATES) as TemplateKey[];
@@ -27,7 +33,7 @@ function isTemplateKey(value: unknown): value is TemplateKey {
} }
export async function loadStoredTemplate(key: TemplateKey): Promise<StoredTemplate> { export async function loadStoredTemplate(key: TemplateKey): Promise<StoredTemplate> {
const { rows } = await pool.query(`SELECT key, value FROM admin_settings WHERE key = ANY($1)`, [ const { rows } = await pool.query<SettingRow>(`SELECT key, value FROM admin_settings WHERE key = ANY($1)`, [
[settingKey(key, 'subject'), settingKey(key, 'body')] [settingKey(key, 'subject'), settingKey(key, 'body')]
]); ]);
const stored: StoredTemplate = {}; const stored: StoredTemplate = {};
@@ -42,7 +48,7 @@ export async function loadStoredTemplate(key: TemplateKey): Promise<StoredTempla
// show the placeholders a template accepts and which of them it must keep, // show the placeholders a template accepts and which of them it must keep,
// rather than the editor having to know. // rather than the editor having to know.
router.get('/', asyncRoute(async (_req: Request, res: Response) => { router.get('/', asyncRoute(async (_req: Request, res: Response) => {
const { rows } = await pool.query( const { rows } = await pool.query<SettingRow>(
`SELECT key, value FROM admin_settings WHERE key LIKE 'email\\_%'` `SELECT key, value FROM admin_settings WHERE key LIKE 'email\\_%'`
); );
const stored = new Map<string, string>(rows.map((r) => [r.key, r.value])); const stored = new Map<string, string>(rows.map((r) => [r.key, r.value]));
+14 -3
View File
@@ -3,6 +3,17 @@ import { pool } from '../db';
import { asyncRoute } from '../asyncRoute'; import { asyncRoute } from '../asyncRoute';
import { TAG_COLORS, tagColorFor } from '../utils'; import { TAG_COLORS, tagColorFor } from '../utils';
interface TagRow {
id: number;
name: string;
color: string;
}
/** The list adds a usage count, cast to int so it arrives as a number. */
interface TagListRow extends TagRow {
item_count: number;
}
const router = Router(); const router = Router();
const UNIQUE_VIOLATION = '23505'; const UNIQUE_VIOLATION = '23505';
@@ -22,7 +33,7 @@ function readColor(value: unknown): string | null | undefined {
} }
router.get('/', asyncRoute(async (_req: Request, res: Response) => { router.get('/', asyncRoute(async (_req: Request, res: Response) => {
const { rows } = await pool.query( const { rows } = await pool.query<TagListRow>(
`SELECT t.id, t.name, t.color, `SELECT t.id, t.name, t.color,
(SELECT COUNT(*)::int FROM item_tags it WHERE it.tag_id = t.id) AS item_count (SELECT COUNT(*)::int FROM item_tags it WHERE it.tag_id = t.id) AS item_count
FROM tags t FROM tags t
@@ -44,7 +55,7 @@ router.post('/', asyncRoute(async (req: Request, res: Response) => {
const color = requestedColor ?? tagColorFor(name); const color = requestedColor ?? tagColorFor(name);
try { try {
const { rows } = await pool.query( const { rows } = await pool.query<TagRow>(
`INSERT INTO tags (name, color) VALUES ($1, $2) RETURNING id, name, color`, `INSERT INTO tags (name, color) VALUES ($1, $2) RETURNING id, name, color`,
[name, color] [name, color]
); );
@@ -83,7 +94,7 @@ router.put('/:id', asyncRoute(async (req: Request, res: Response) => {
} }
try { try {
const { rows } = await pool.query( const { rows } = await pool.query<TagRow>(
`UPDATE tags SET name = $1, color = $2 WHERE id = $3 RETURNING id, name, color`, `UPDATE tags SET name = $1, color = $2 WHERE id = $3 RETURNING id, name, color`,
[name, color, id] [name, color, id]
); );
+5 -1
View File
@@ -2,11 +2,15 @@ import { Router, Request, Response } from 'express';
import { pool } from '../db'; import { pool } from '../db';
import { asyncRoute } from '../asyncRoute'; import { asyncRoute } from '../asyncRoute';
interface IdRow {
id: number;
}
const router = Router(); const router = Router();
router.get('/unsubscribe', asyncRoute(async (req: Request, res: Response) => { router.get('/unsubscribe', asyncRoute(async (req: Request, res: Response) => {
const token = req.query.token as string; const token = req.query.token as string;
const { rows } = await pool.query(`SELECT id FROM customers WHERE unsubscribe_token = $1`, [token]); const { rows } = await pool.query<IdRow>(`SELECT id FROM customers WHERE unsubscribe_token = $1`, [token]);
if (!rows.length) { if (!rows.length) {
res.status(400).send('<html><body><h2>Invalid or expired unsubscribe link.</h2></body></html>'); res.status(400).send('<html><body><h2>Invalid or expired unsubscribe link.</h2></body></html>');
return; return;
+26 -4
View File
@@ -4,10 +4,32 @@ import { asyncRoute } from '../asyncRoute';
import { requireCustomer } from '../middleware/customerAuth'; import { requireCustomer } from '../middleware/customerAuth';
import { validateAddress, uspsConfigured, UspsValidationResult } from '../usps'; import { validateAddress, uspsConfigured, UspsValidationResult } from '../usps';
/**
* A whole `shipping_addresses` row. Every query here uses `SELECT *` or
* `RETURNING *`, so one shape covers the file. Kept in step with the schema by
* hand.
*/
interface ShippingAddressRow {
id: number;
customer_id: number;
full_name: string;
address_line1: string;
address_line2: string | null;
city: string;
state: string;
postal_code: string;
country: string;
is_default: boolean;
usps_validated: boolean;
// jsonb, and only ever handed back to the client — never read here.
usps_standardized: unknown;
created_at: Date;
}
const router = Router(); const router = Router();
router.get('/', requireCustomer, asyncRoute(async (req: Request, res: Response) => { router.get('/', requireCustomer, asyncRoute(async (req: Request, res: Response) => {
const { rows } = await pool.query( const { rows } = await pool.query<ShippingAddressRow>(
`SELECT * FROM shipping_addresses WHERE customer_id = $1 ORDER BY is_default DESC, created_at DESC`, `SELECT * FROM shipping_addresses WHERE customer_id = $1 ORDER BY is_default DESC, created_at DESC`,
[req.customerId] [req.customerId]
); );
@@ -31,7 +53,7 @@ if ((country || 'US') === 'US') {
if (isDefault) { if (isDefault) {
await client.query(`UPDATE shipping_addresses SET is_default = false WHERE customer_id = $1`, [req.customerId]); await client.query(`UPDATE shipping_addresses SET is_default = false WHERE customer_id = $1`, [req.customerId]);
} }
const { rows } = await client.query( const { rows } = await client.query<ShippingAddressRow>(
`INSERT INTO shipping_addresses `INSERT INTO shipping_addresses
(customer_id, full_name, address_line1, address_line2, city, state, postal_code, country, is_default, usps_validated, usps_standardized) (customer_id, full_name, address_line1, address_line2, city, state, postal_code, country, is_default, usps_validated, usps_standardized)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`,
@@ -59,7 +81,7 @@ router.put('/:id', requireCustomer, asyncRoute(async (req: Request, res: Respons
if (isDefault) { if (isDefault) {
await client.query(`UPDATE shipping_addresses SET is_default = false WHERE customer_id = $1`, [req.customerId]); await client.query(`UPDATE shipping_addresses SET is_default = false WHERE customer_id = $1`, [req.customerId]);
} }
const { rows } = await client.query( const { rows } = await client.query<ShippingAddressRow>(
`UPDATE shipping_addresses `UPDATE shipping_addresses
SET full_name=$1, address_line1=$2, address_line2=$3, city=$4, state=$5, postal_code=$6, country=$7, is_default=$8, SET full_name=$1, address_line1=$2, address_line2=$3, city=$4, state=$5, postal_code=$6, country=$7, is_default=$8,
usps_validated = false, usps_standardized = NULL usps_validated = false, usps_standardized = NULL
@@ -88,7 +110,7 @@ router.post('/:id/set-default', requireCustomer, asyncRoute(async (req: Request,
try { try {
await client.query('BEGIN'); await client.query('BEGIN');
await client.query(`UPDATE shipping_addresses SET is_default = false WHERE customer_id = $1`, [req.customerId]); await client.query(`UPDATE shipping_addresses SET is_default = false WHERE customer_id = $1`, [req.customerId]);
const { rows } = await client.query( const { rows } = await client.query<ShippingAddressRow>(
`UPDATE shipping_addresses SET is_default = true WHERE id = $1 AND customer_id = $2 RETURNING *`, `UPDATE shipping_addresses SET is_default = true WHERE id = $1 AND customer_id = $2 RETURNING *`,
[req.params.id, req.customerId] [req.params.id, req.customerId]
); );
+17 -2
View File
@@ -10,7 +10,7 @@ import { validateEnv } from './envValidation';
// Release cart holds whose expiry has passed. // Release cart holds whose expiry has passed.
async function sweepExpiredCarts(): Promise<void> { async function sweepExpiredCarts(): Promise<void> {
try { try {
const { rows } = await pool.query( const { rows } = await pool.query<ExpiredCartItemRow>(
`DELETE FROM cart_items WHERE expires_at < now() RETURNING item_id` `DELETE FROM cart_items WHERE expires_at < now() RETURNING item_id`
); );
for (const row of rows) { for (const row of rows) {
@@ -25,7 +25,7 @@ async function sweepExpiredCarts(): Promise<void> {
// their cart. // their cart.
async function sendCartReminders(): Promise<void> { async function sendCartReminders(): Promise<void> {
try { try {
const { rows } = await pool.query(` 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 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 FROM cart_items ci
JOIN carts ca ON ca.id = ci.cart_id JOIN carts ca ON ca.id = ci.cart_id
@@ -73,6 +73,21 @@ async function sendCartReminders(): Promise<void> {
} }
} }
/** 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 // Neither scheduler has anything to await these with, so `void` states that the
// promise is deliberately dropped. That is only safe because both functions // promise is deliberately dropped. That is only safe because both functions
// catch their own errors above — an escaping rejection would be unhandled, and // catch their own errors above — an escaping rejection would be unhandled, and