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).
This commit is contained in:
Dave 2026-09-21 13:16:38 +02:00
parent c4cd1e3646
commit b824f02f02

View file

@ -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<void>
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<boolean> {
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<IntakeLinkSubmissionRecord>(
`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<void> {
const pb = await getAdminPb();
const today = computeDateForCounter();
try {
const existing = await pb
.collection("intake_link_submissions")
.getFirstListItem<IntakeLinkSubmissionRecord>(
`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,
});
}
}