All files / routes/admin/rrm signups.routes.ts

100% Statements 34/34
91.66% Branches 11/12
100% Functions 8/8
100% Lines 31/31

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142                                                                          2x                   2x   2x             7x 7x 1x 1x   1x         7x 4x                         5x 5x 5x 3x     3x   5x       2x 7x 7x 7x 7x       7x             7x           5x 5x     5x   5x 4x 4x                 5x           2x          
/**
 * RRM Sign-ups admin API (F-21, loose-ends review §3).
 *
 * Organic-source prospects — people who arrived through the landing page and
 * submitted the form themselves, not a name on a scraped/imported list. This
 * is the screen the owner actually watches in week one, so each row carries
 * WHAT the person submitted (review §3.2), not just that they exist.
 *
 * Mounted by the RRM sub-router at `/signups`, under the same `/api/admin`
 * guards (`contextMiddleware` + `requirePlatformAdmin`) as the rest of this
 * directory. Nothing here re-applies them.
 */
 
import { Hono } from "hono";
import { z } from "zod";
import type { Dal } from "../../../dal";
import { RrmSubmissionsDal } from "../../../dal/rrm";
import type { RrmSubmission } from "../../../db/schema/rrm";
import { ValidationError } from "../../../lib/errors";
import { buildPaginationMeta, getPagination } from "../../../lib/pagination";
import { handleError, successWithPagination } from "../../../lib/response";
import type { Services } from "../../../services";
import {
	serializeProspect,
	serializeSubmissionSummary,
} from "./prospects.routes";
 
type Env = {
	Bindings: CloudflareBindings;
	Variables: {
		user: { id: string; name: string; email: string } | null;
		session: unknown;
		dal: Dal;
		services: Services;
	};
};
 
const signups = new Hono<Env>();
 
/**
 * Each page's prospect ids feed one `inArray` lookup against
 * `rrm_submissions` (the N+1 guard below) — one bound parameter per id. The
 * shared `getPagination()` default ceiling is 100, which sits exactly at
 * D1's per-statement bound-parameter limit; CLAUDE.md documents PR #403
 * shipping precisely that bug. Capped here at half that, independent of the
 * shared default, so this route can never be the one that rediscovers it.
 */
const MAX_PAGE_SIZE = 50;
 
const listQuerySchema = z.object({
	page: z.coerce.number().int().positive().optional(),
	limit: z.coerce.number().int().positive().optional(),
});
 
/** `handleError` has no ZodError branch, so validation failures are named here. */
function parseOrThrow<T>(schema: z.ZodType<T>, value: unknown): T {
	const result = schema.safeParse(value);
	if (result.success) return result.data;
	const detail = result.error.issues
		.map((issue) => `${issue.path.join(".") || "query"}: ${issue.message}`)
		.join("; ");
	throw new ValidationError(detail);
}
 
/** Query params arrive as strings; an empty one means "unset", not "invalid". */
function cleanQuery(raw: Record<string, string>): Record<string, string> {
	return Object.fromEntries(
		Object.entries(raw).filter(([, value]) => value !== ""),
	);
}
 
/**
 * One pass over a globally newest-first submissions list, building both the
 * per-prospect count and the latest row — the two things the Sign-ups list
 * needs per prospect, without a second query or a per-row `ORDER BY`.
 */
function summarizeByProspect(rows: RrmSubmission[]): {
	counts: Map<string, number>;
	latest: Map<string, RrmSubmission>;
} {
	const counts = new Map<string, number>();
	const latest = new Map<string, RrmSubmission>();
	for (const row of rows) {
		counts.set(row.prospectId, (counts.get(row.prospectId) ?? 0) + 1);
		// First row seen per prospect, in a globally newest-first list, IS the
		// newest for that prospect — no per-group sort needed.
		if (!latest.has(row.prospectId)) latest.set(row.prospectId, row);
	}
	return { counts, latest };
}
 
// GET /api/admin/rrm/signups
signups.get("/", async (c) => {
	try {
		const dal = c.get("dal");
		const filters = parseOrThrow(listQuerySchema, cleanQuery(c.req.query()));
		const { page, limit: rawLimit } = getPagination(
			filters.page,
			filters.limit,
		);
		const limit = Math.min(rawLimit, MAX_PAGE_SIZE);
 
		// `source = 'organic'` — the DAL's existing filter and default sort
		// (newest `dateCreated` first) already give the exact ordering and
		// exclusion this endpoint needs. The `rejected` pseudo-prospect
		// (submissions with no real `rrm_prospects` row) is never in this
		// result: it has no row here to exclude.
		const { items, total } = await dal.rrmProspects.list({
			source: "organic",
			page,
			limit,
		});
 
		const ids = items.map((p) => p.id);
		const submissions = await new RrmSubmissionsDal(dal.db).findByProspectIds(
			ids,
		);
		const { counts, latest } = summarizeByProspect(submissions);
 
		const data = items.map((p) => {
			const latestRow = latest.get(p.id);
			return {
				...serializeProspect(p),
				submissionCount: counts.get(p.id) ?? 0,
				latestSubmission: latestRow
					? serializeSubmissionSummary(latestRow)
					: null,
			};
		});
 
		return successWithPagination(
			c,
			data,
			buildPaginationMeta(total, page, limit),
		);
	} catch (err) {
		return handleError(c, err);
	}
});
 
export default signups;