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
This commit is contained in:
@@ -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>;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]));
|
||||||
|
|||||||
@@ -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]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user