feat(intake-links): token resolver and daily-cap helpers

Phase 8a Task 5. resolveIntakeLink returns null for unknown tokens
(spec §3: caller gives the same response as for a revoked token).
isLinkUsable lazily resets today's counter on date rollover.
validateIntakeLinkForStep returns a three-valued LinkState so the
route can distinguish 'inactive/expired' from 'daily-cap'.
This commit is contained in:
Dave 2026-09-21 01:59:28 +02:00
parent 3b8ffb8e1f
commit 3ece6cb956

View file

@ -0,0 +1,104 @@
import "server-only";
import { getAdminPb } from "@/lib/pb/admin";
import type { IntakeLinkRecord } from "@/lib/pb/types";
const DEFAULT_DAILY_CAP = 100;
/** Returned by `validateIntakeLink`. Distinguishes "no" from "rate-limited"
* so the route can return a different message. */
export type LinkState =
| { ok: true; link: IntakeLinkRecord; projectId: string }
| { ok: false; reason: "inactive" | "expired" | "daily-cap"; link?: IntakeLinkRecord };
/** Local-date string in YYYY-MM-DD form, for the lazy-reset counter. */
function localDate(now: Date = new Date()): string {
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, "0");
const d = String(now.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
/**
* Resolve a token to its link record.
*
* Returns `null` when the token does not match any row. The caller is expected
* to give the same response in that case as for a revoked or expired token
* (the spec's §3 "doesn't shout" property).
*/
export async function resolveIntakeLink(
token: string
): Promise<IntakeLinkRecord | null> {
const pb = await getAdminPb();
try {
return await pb
.collection("intake_links")
.getFirstListItem<IntakeLinkRecord>(`token = "${token.replace(/"/g, '\\"')}"`);
} catch {
return null;
}
}
/**
* True when the link is still usable right now, lazily resetting today's
* counter if the date has rolled over. Reads only.
*/
export function isLinkUsable(link: IntakeLinkRecord, now: Date = new Date()): boolean {
if (!link.active) return false;
if (link.expires_at) {
const expiry = link.expires_at.slice(0, 10); // YYYY-MM-DD slice
if (localDate(now) > expiry) return false;
}
const cap = link.max_submissions_per_day || DEFAULT_DAILY_CAP;
const counter = link.submission_count_today_date === localDate(now)
? link.submission_count_today
: 0;
return counter < cap;
}
/**
* Validate a token for a single step request.
*
* "Step" here means an interview call. The daily cap is enforced here so a
* hostile client cannot run up the API bill past the cap — the cap on the
* submission route is separate (in Task 8) and applies only when a card is
* actually created.
*/
export async function validateIntakeLinkForStep(
token: string
): Promise<LinkState> {
const link = await resolveIntakeLink(token);
if (!link) return { ok: false, reason: "inactive" };
if (!isLinkUsable(link)) {
if (!link.active || (link.expires_at && localDate() > link.expires_at.slice(0, 10))) {
return { ok: false, reason: "inactive", link };
}
return { ok: false, reason: "daily-cap", link };
}
return { ok: true, link, projectId: link.project };
}
/**
* Increment today's counter. Idempotent only at the caller's discretion —
* this function does the lazy reset and writes the new value.
*/
export async function bumpDailyCap(link: IntakeLinkRecord): Promise<void> {
const pb = await getAdminPb();
const today = localDate();
const current = link.submission_count_today_date === today
? link.submission_count_today
: 0;
await pb.collection("intake_links").update(link.id, {
submission_count_today: current + 1,
submission_count_today_date: today,
});
}
/** Increment the lifetime counter and stamp the last-submission time. */
export async function bumpSubmissionCount(link: IntakeLinkRecord): Promise<void> {
const pb = await getAdminPb();
await pb.collection("intake_links").update(link.id, {
submission_count: link.submission_count + 1,
last_submission_at: new Date().toISOString(),
});
}