feat: harden analytics dashboard

This commit is contained in:
2026-05-27 15:19:31 +08:00
parent 5e0ca6a504
commit 356039d9cf
34 changed files with 1424 additions and 879 deletions

62
lib/api-params.ts Normal file
View File

@@ -0,0 +1,62 @@
import { NextResponse } from "next/server";
export type ParseResult<T> =
| { ok: true; value: T }
| { ok: false; field: string };
interface PositiveIntOptions {
field: string;
defaultValue: number;
min: number;
max?: number;
}
function parseInteger(raw: string | null, field: string): ParseResult<number | undefined> {
if (raw === null || raw === "") return { ok: true, value: undefined };
if (!/^-?\d+$/.test(raw)) return { ok: false, field };
const value = Number(raw);
if (!Number.isSafeInteger(value)) return { ok: false, field };
return { ok: true, value };
}
export function parseOptionalInt(raw: string | null, field: string): ParseResult<number | undefined> {
const parsed = parseInteger(raw, field);
if (!parsed.ok || parsed.value === undefined) return parsed;
if (parsed.value < 0) return { ok: false, field };
return parsed;
}
export function parsePositiveInt(raw: string | null, options: PositiveIntOptions): ParseResult<number> {
const parsed = parseInteger(raw, options.field);
if (!parsed.ok) return parsed;
let value = parsed.value ?? options.defaultValue;
if (value < 0) return { ok: false, field: options.field };
if (value < options.min) value = options.min;
if (options.max !== undefined && value > options.max) value = options.max;
return { ok: true, value };
}
export function parseTimestampRange(
searchParams: URLSearchParams
): ParseResult<{ startTs?: number; endTs?: number }> {
const start = parseOptionalInt(searchParams.get("start"), "start");
if (!start.ok) return start;
const end = parseOptionalInt(searchParams.get("end"), "end");
if (!end.ok) return end;
if (start.value !== undefined && end.value !== undefined && start.value > end.value) {
return { ok: false, field: "range" };
}
return { ok: true, value: { startTs: start.value, endTs: end.value } };
}
export function jsonError(field?: string, status = 400) {
const body = field
? { error: "Invalid query parameter", field }
: { error: "Internal server error" };
return NextResponse.json(body, { status });
}