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:
parent
3b8ffb8e1f
commit
3ece6cb956
1 changed files with 104 additions and 0 deletions
104
src/lib/intake-links/index.ts
Normal file
104
src/lib/intake-links/index.ts
Normal 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(),
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue