diff --git a/src/lib/intake-links/index.ts b/src/lib/intake-links/index.ts new file mode 100644 index 0000000..a583da3 --- /dev/null +++ b/src/lib/intake-links/index.ts @@ -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 { + const pb = await getAdminPb(); + try { + return await pb + .collection("intake_links") + .getFirstListItem(`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 { + 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 { + 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 { + const pb = await getAdminPb(); + await pb.collection("intake_links").update(link.id, { + submission_count: link.submission_count + 1, + last_submission_at: new Date().toISOString(), + }); +}