Merge pull request 'refactor(backend): type the remaining query results (#159)' (#165) from feature/159-type-remaining-queries into main
Reviewed-on: #165
This commit was merged in pull request #165.
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