feat(intake-links): owner actions for create / rotate / revoke

Phase 8a Task 14. Server Actions are owner-only via requireOwner().
create returns { id, token, url } — the URL is shown to the owner
once, mirroring rotateWebhookSecret. rotate issues a fresh token and
invalidates the old URL immediately. setIntakeLinkActiveAction
covers revoke (active=false). Notes are capped at 1000 chars on
both write paths.
This commit is contained in:
Dave 2026-09-21 02:27:45 +02:00
parent bb78c9b56f
commit c29f855ffd

View file

@ -0,0 +1,99 @@
"use server";
import { revalidatePath } from "next/cache";
import { getAdminPb } from "@/lib/pb/admin";
import { requireSession } from "@/lib/auth/session";
import { generateIntakeToken } from "@/lib/intake/token";
import type { IntakeLinkRecord } from "@/lib/pb/types";
/** Owner-only entry point. See src/app/actions/log-work.ts for the pattern. */
async function requireOwner() {
const session = await requireSession();
if (session.role !== "owner") throw new Error("Not found");
return session;
}
export async function createIntakeLinkAction(input: {
projectId: string;
label: string;
expiresAt?: string;
maxSubmissionsPerDay?: number;
notes?: string;
}): Promise<{ id: string; token: string; url: string }> {
await requireOwner();
const pb = await getAdminPb();
const label = input.label.trim().slice(0, 80);
if (!label) throw new Error("A label is required");
const record = await pb.collection("intake_links").create<IntakeLinkRecord>({
project: input.projectId,
label,
token: generateIntakeToken(),
active: true,
expires_at: input.expiresAt ?? "",
max_submissions_per_day: input.maxSubmissionsPerDay ?? 100,
submission_count: 0,
submission_count_today: 0,
submission_count_today_date: "",
last_submission_at: "",
notes: (input.notes ?? "").slice(0, 1000),
});
revalidatePath(`/admin/projects/${input.projectId}`);
return {
id: record.id,
token: record.token,
url: intakeUrlFor(record.token),
};
}
/** Rotate the token on an existing link — the old URL stops working
* immediately. */
export async function rotateIntakeTokenAction(input: {
linkId: string;
projectId: string;
}): Promise<{ token: string; url: string }> {
await requireOwner();
const pb = await getAdminPb();
const token = generateIntakeToken();
await pb.collection("intake_links").update(input.linkId, { token });
revalidatePath(`/admin/projects/${input.projectId}`);
return { token, url: intakeUrlFor(token) };
}
export async function setIntakeLinkActiveAction(input: {
linkId: string;
projectId: string;
active: boolean;
}): Promise<void> {
await requireOwner();
const pb = await getAdminPb();
await pb.collection("intake_links").update(input.linkId, {
active: input.active,
});
revalidatePath(`/admin/projects/${input.projectId}`);
}
export async function setIntakeLinkNotesAction(input: {
linkId: string;
projectId: string;
notes: string;
}): Promise<void> {
await requireOwner();
const pb = await getAdminPb();
await pb.collection("intake_links").update(input.linkId, {
notes: input.notes.slice(0, 1000),
});
revalidatePath(`/admin/projects/${input.projectId}`);
}
function intakeUrlFor(token: string): string {
const base = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
return `${base}/intake/${token}`;
}