From b824f02f02ad833018072c892957e195b42ad4fe Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 21 Sep 2026 13:16:38 +0200 Subject: [PATCH] feat(intake-links): per-(link, project) cap read/write Phase 9a Task 3. isLinkProjectUsable reads today's counter (or treats no row as 0). bumpDailyCapForProject upserts via the unique (link, project, date) index. The Phase 8 helpers stay live for the old shape; Phase 9b's wizard picks which one to call based on whether the link is per-project (Phase 8) or per-client (Phase 9). --- src/lib/intake-links/index.ts | 77 ++++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/src/lib/intake-links/index.ts b/src/lib/intake-links/index.ts index a583da3..dad9e0c 100644 --- a/src/lib/intake-links/index.ts +++ b/src/lib/intake-links/index.ts @@ -1,7 +1,16 @@ import "server-only"; import { getAdminPb } from "@/lib/pb/admin"; -import type { IntakeLinkRecord } from "@/lib/pb/types"; +import type { + IntakeLinkRecord, + IntakeLinkSubmissionRecord, +} from "@/lib/pb/types"; +import { + computeDateForCounter, + defaultCapFor, + isDailyCapReached, + nextCount, +} from "@/lib/intake-links/submissions"; const DEFAULT_DAILY_CAP = 100; @@ -102,3 +111,69 @@ export async function bumpSubmissionCount(link: IntakeLinkRecord): Promise last_submission_at: new Date().toISOString(), }); } + +/** + * Phase 9: True when the (link, project) pair is still usable right now. + * + * Reads today's counter from the new `intake_link_submissions` collection. + * On any DB read error, returns false — Phase 8 used the same conservative + * default (a flaky read fails closed, never open). + */ +export async function isLinkProjectUsable( + link: IntakeLinkRecord, + projectId: string, + now: Date = new Date() +): Promise { + if (!link.active) return false; + if (link.expires_at) { + const expiry = link.expires_at.slice(0, 10); + if (computeDateForCounter(now) > expiry) return false; + } + const cap = defaultCapFor(link.max_submissions_per_day); + const today = computeDateForCounter(now); + + const pb = await getAdminPb(); + try { + const row = await pb + .collection("intake_link_submissions") + .getFirstListItem( + `link = "${link.id}" && project = "${projectId}" && date = "${today}"` + ); + return !isDailyCapReached(row.count, cap); + } catch { + // No row for today → 0 submissions → below the cap (unless cap=0). + return !isDailyCapReached(0, cap); + } +} + +/** + * Phase 9: Increment today's counter for the (link, project) pair. + * + * Uses the unique `(link, project, date)` index: a fresh row is created on + * first submission of the day, subsequent submissions update the count. + */ +export async function bumpDailyCapForProject( + link: IntakeLinkRecord, + projectId: string +): Promise { + const pb = await getAdminPb(); + const today = computeDateForCounter(); + + try { + const existing = await pb + .collection("intake_link_submissions") + .getFirstListItem( + `link = "${link.id}" && project = "${projectId}" && date = "${today}"` + ); + await pb.collection("intake_link_submissions").update(existing.id, { + count: nextCount(existing.count), + }); + } catch { + await pb.collection("intake_link_submissions").create({ + link: link.id, + project: projectId, + date: today, + count: 1, + }); + } +}