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:
parent
bb78c9b56f
commit
c29f855ffd
1 changed files with 99 additions and 0 deletions
99
src/app/actions/intake-links.ts
Normal file
99
src/app/actions/intake-links.ts
Normal 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}`;
|
||||
}
|
||||
Loading…
Reference in a new issue