diff --git a/backend/src/adminSettings.ts b/backend/src/adminSettings.ts index 08dcf0a..f084e63 100644 --- a/backend/src/adminSettings.ts +++ b/backend/src/adminSettings.ts @@ -13,6 +13,12 @@ import { pool } from './db'; * 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 }, @@ -39,7 +45,7 @@ export const TEXT_SETTINGS: readonly TextSettingName[] = DEFINITIONS.filter( ).map(d => d.name); export async function getSettings(): Promise { - const { rows } = await pool.query(`SELECT key, value FROM admin_settings`); + const { rows } = await pool.query(`SELECT key, value FROM admin_settings`); const stored = new Map(rows.map(r => [r.key, r.value])); const settings = {} as Record; diff --git a/backend/src/middleware/customerAuth.ts b/backend/src/middleware/customerAuth.ts index a27f3e2..d514acc 100755 --- a/backend/src/middleware/customerAuth.ts +++ b/backend/src/middleware/customerAuth.ts @@ -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 { const token = req.cookies?.rd_session; 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 // password reset all mint sessions, so checking here covers every path // instead of three separate ones. - const { rows } = await pool.query( + const { rows } = await pool.query( `SELECT s.customer_id FROM customer_sessions s JOIN customers c ON c.id = s.customer_id diff --git a/backend/src/routes/adminCategories.ts b/backend/src/routes/adminCategories.ts index 8b1e597..9c7ce1f 100644 --- a/backend/src/routes/adminCategories.ts +++ b/backend/src/routes/adminCategories.ts @@ -2,6 +2,35 @@ import { Router, Request, Response } from 'express'; import { pool } from '../db'; 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(); // 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 { - const { rows } = await pool.query(`SELECT 1 FROM categories WHERE id = $1`, [id]); + const { rows } = await pool.query(`SELECT 1 FROM categories WHERE id = $1`, [id]); return rows.length > 0; } router.get('/', asyncRoute(async (_req: Request, res: Response) => { - const { rows } = await pool.query( + const { rows } = await pool.query( `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 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; try { - const { rows } = await pool.query( + const { rows } = await pool.query( `INSERT INTO categories (name, parent_id, sort_order) VALUES ($1, $2, $3) RETURNING id, name, parent_id, sort_order`, [name, parent, sortOrder] @@ -107,7 +136,7 @@ async function resolveParentId( // Moving a node beneath itself or one of its own descendants would detach // that whole branch from the tree into an unreachable cycle. - const { rows: cycle } = await pool.query( + const { rows: cycle } = await pool.query( `${SUBTREE_CTE} SELECT 1 FROM subtree WHERE id = $2`, [id, parsed] ); @@ -145,7 +174,7 @@ router.put('/:id', asyncRoute(async (req: Request, res: Response) => { : existing.rows[0].sort_order; try { - const { rows } = await pool.query( + const { rows } = await pool.query( `UPDATE categories SET name = $1, parent_id = $2, sort_order = $3 WHERE id = $4 RETURNING id, name, parent_id, sort_order`, [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) => { 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(`${SUBTREE_CTE} SELECT id FROM subtree`, [id]); if (!subtree.length) { return res.status(404).json({ error: 'not found' }); } const ids = subtree.map((row: { id: number }) => row.id); - const { rows: affected } = await pool.query( + const { rows: affected } = await pool.query( `SELECT COUNT(*)::int AS n FROM items WHERE category_id = ANY($1::int[])`, [ids] ); diff --git a/backend/src/routes/adminCustomers.ts b/backend/src/routes/adminCustomers.ts index e2f19e3..d86125b 100755 --- a/backend/src/routes/adminCustomers.ts +++ b/backend/src/routes/adminCustomers.ts @@ -2,10 +2,75 @@ import { Router, Request, Response } from 'express'; import { pool } from '../db'; 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(); router.get('/', asyncRoute(async (_req: Request, res: Response) => { - const { rows } = await pool.query(` + const { rows } = await pool.query(` SELECT 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, @@ -39,7 +104,7 @@ router.post('/:id/disable', asyncRoute(async (req: Request, res: Response) => { try { await client.query('BEGIN'); - const { rows } = await client.query( + const { rows } = await client.query( `UPDATE customers SET disabled_at = now() WHERE id = $1 RETURNING 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 // the storefront until the expiry sweep serves nobody. Guarded on // 'reserved' so a sold item is never resurrected. - const { rows: held } = await client.query( + const { rows: held } = await client.query( `DELETE FROM cart_items ci USING carts ca 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 // them would be worse than making the customer add them again. router.post('/:id/enable', asyncRoute(async (req: Request, res: Response) => { - const { rows } = await pool.query( + const { rows } = await pool.query( `UPDATE customers SET disabled_at = NULL WHERE id = $1 RETURNING 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) => { - const { rows } = await pool.query( + const { rows } = await pool.query( `SELECT i.id AS item_id, i.name, i.price_cents, ci.added_at, ci.expires_at FROM cart_items ci 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(); try { await client.query('BEGIN'); - const { rows } = await client.query( + const { rows } = await client.query( `DELETE FROM cart_items ci USING carts ca 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) => { - const { rows: customerRows } = await pool.query( + const { rows: customerRows } = await pool.query( `SELECT id, email, nullif(btrim(concat_ws(' ', first_name, last_name)), '') AS name, email_verified, marketing_consent, marketing_consent_at, created_at 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' }); - const { rows: orderRows } = await pool.query( + const { rows: orderRows } = await pool.query( `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 WHERE o.customer_id = $1 diff --git a/backend/src/routes/adminEmailTemplates.ts b/backend/src/routes/adminEmailTemplates.ts index ecef93c..142c916 100644 --- a/backend/src/routes/adminEmailTemplates.ts +++ b/backend/src/routes/adminEmailTemplates.ts @@ -13,6 +13,12 @@ import { } 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[]; @@ -27,7 +33,7 @@ function isTemplateKey(value: unknown): value is TemplateKey { } export async function loadStoredTemplate(key: TemplateKey): Promise { - const { rows } = await pool.query(`SELECT key, value FROM admin_settings WHERE key = ANY($1)`, [ + const { rows } = await pool.query(`SELECT key, value FROM admin_settings WHERE key = ANY($1)`, [ [settingKey(key, 'subject'), settingKey(key, 'body')] ]); const stored: StoredTemplate = {}; @@ -42,7 +48,7 @@ export async function loadStoredTemplate(key: TemplateKey): Promise { - const { rows } = await pool.query( + const { rows } = await pool.query( `SELECT key, value FROM admin_settings WHERE key LIKE 'email\\_%'` ); const stored = new Map(rows.map((r) => [r.key, r.value])); diff --git a/backend/src/routes/adminTags.ts b/backend/src/routes/adminTags.ts index 7a0130d..09a5179 100644 --- a/backend/src/routes/adminTags.ts +++ b/backend/src/routes/adminTags.ts @@ -3,6 +3,17 @@ import { pool } from '../db'; import { asyncRoute } from '../asyncRoute'; 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 UNIQUE_VIOLATION = '23505'; @@ -22,7 +33,7 @@ function readColor(value: unknown): string | null | undefined { } router.get('/', asyncRoute(async (_req: Request, res: Response) => { - const { rows } = await pool.query( + const { rows } = await pool.query( `SELECT t.id, t.name, t.color, (SELECT COUNT(*)::int FROM item_tags it WHERE it.tag_id = t.id) AS item_count FROM tags t @@ -44,7 +55,7 @@ router.post('/', asyncRoute(async (req: Request, res: Response) => { const color = requestedColor ?? tagColorFor(name); try { - const { rows } = await pool.query( + const { rows } = await pool.query( `INSERT INTO tags (name, color) VALUES ($1, $2) RETURNING id, name, color`, [name, color] ); @@ -83,7 +94,7 @@ router.put('/:id', asyncRoute(async (req: Request, res: Response) => { } try { - const { rows } = await pool.query( + const { rows } = await pool.query( `UPDATE tags SET name = $1, color = $2 WHERE id = $3 RETURNING id, name, color`, [name, color, id] ); diff --git a/backend/src/routes/public.ts b/backend/src/routes/public.ts index 6cc1c79..f2af8e0 100755 --- a/backend/src/routes/public.ts +++ b/backend/src/routes/public.ts @@ -2,11 +2,15 @@ import { Router, Request, Response } from 'express'; import { pool } from '../db'; import { asyncRoute } from '../asyncRoute'; +interface IdRow { + id: number; +} + const router = Router(); router.get('/unsubscribe', asyncRoute(async (req: Request, res: Response) => { 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(`SELECT id FROM customers WHERE unsubscribe_token = $1`, [token]); if (!rows.length) { res.status(400).send('

Invalid or expired unsubscribe link.

'); return; diff --git a/backend/src/routes/shippingAddresses.ts b/backend/src/routes/shippingAddresses.ts index 571bfcc..4b20ffc 100644 --- a/backend/src/routes/shippingAddresses.ts +++ b/backend/src/routes/shippingAddresses.ts @@ -4,10 +4,32 @@ import { asyncRoute } from '../asyncRoute'; import { requireCustomer } from '../middleware/customerAuth'; 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(); router.get('/', requireCustomer, asyncRoute(async (req: Request, res: Response) => { - const { rows } = await pool.query( + const { rows } = await pool.query( `SELECT * FROM shipping_addresses WHERE customer_id = $1 ORDER BY is_default DESC, created_at DESC`, [req.customerId] ); @@ -31,7 +53,7 @@ if ((country || 'US') === 'US') { if (isDefault) { 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( `INSERT INTO shipping_addresses (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 *`, @@ -59,7 +81,7 @@ router.put('/:id', requireCustomer, asyncRoute(async (req: Request, res: Respons if (isDefault) { 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( `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, usps_validated = false, usps_standardized = NULL @@ -88,7 +110,7 @@ router.post('/:id/set-default', requireCustomer, asyncRoute(async (req: Request, try { await client.query('BEGIN'); 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( `UPDATE shipping_addresses SET is_default = true WHERE id = $1 AND customer_id = $2 RETURNING *`, [req.params.id, req.customerId] ); diff --git a/backend/src/server.ts b/backend/src/server.ts index c88abc2..af7d9cb 100755 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -10,7 +10,7 @@ import { validateEnv } from './envValidation'; // Release cart holds whose expiry has passed. async function sweepExpiredCarts(): Promise { try { - const { rows } = await pool.query( + const { rows } = await pool.query( `DELETE FROM cart_items WHERE expires_at < now() RETURNING item_id` ); for (const row of rows) { @@ -25,7 +25,7 @@ async function sweepExpiredCarts(): Promise { // their cart. async function sendCartReminders(): Promise { try { - const { rows } = await pool.query(` + const { rows } = await pool.query(` 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 @@ -73,6 +73,21 @@ async function sendCartReminders(): Promise { } } +/** 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