feat(admin): client-bound IntakeLinksPanel

Phase 9b Task 2. Panel takes a mode prop ('project' keeps Phase 8
behaviour; 'client' is the new shape). The Server Action's input
takes an IntakeLinkScope union ({type:'project', projectId} |
{type:'client', clientId}) so revalidatePath hits the right admin
page after every change.

The Phase 8 site ('/admin/projects/[id]') passes mode='project' and
projectId; the Phase 9 site ('/admin/clients/[id]') passes
mode='client' and clientId. Both render the same UI; only the scope
forwarded to the actions differs.
This commit is contained in:
Dave 2026-09-21 13:31:21 +02:00
parent 87fa28c4d1
commit 34c36eee15
4 changed files with 70 additions and 28 deletions

View file

@ -14,8 +14,19 @@ async function requireOwner() {
return session;
}
/** Where a link lives in the admin tree. Phase 8: project. Phase 9: client. */
export type IntakeLinkScope =
| { type: "project"; projectId: string }
| { type: "client"; clientId: string };
function adminPathForScope(scope: IntakeLinkScope): string {
return scope.type === "project"
? `/admin/projects/${scope.projectId}`
: `/admin/clients/${scope.clientId}`;
}
export async function createIntakeLinkAction(input: {
projectId: string;
scope: IntakeLinkScope;
label: string;
expiresAt?: string;
maxSubmissionsPerDay?: number;
@ -27,8 +38,7 @@ export async function createIntakeLinkAction(input: {
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,
const createFields: Record<string, unknown> = {
label,
token: generateIntakeToken(),
active: true,
@ -39,9 +49,20 @@ export async function createIntakeLinkAction(input: {
submission_count_today_date: "",
last_submission_at: "",
notes: (input.notes ?? "").slice(0, 1000),
});
};
if (input.scope.type === "project") {
createFields.project = input.scope.projectId;
createFields.client = "";
} else {
createFields.project = "";
createFields.client = input.scope.clientId;
}
revalidatePath(`/admin/projects/${input.projectId}`);
const record = await pb
.collection("intake_links")
.create<IntakeLinkRecord>(createFields);
revalidatePath(adminPathForScope(input.scope));
return {
id: record.id,
token: record.token,
@ -53,21 +74,21 @@ export async function createIntakeLinkAction(input: {
* immediately. */
export async function rotateIntakeTokenAction(input: {
linkId: string;
projectId: string;
scope: IntakeLinkScope;
}): 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}`);
revalidatePath(adminPathForScope(input.scope));
return { token, url: intakeUrlFor(token) };
}
export async function setIntakeLinkActiveAction(input: {
linkId: string;
projectId: string;
scope: IntakeLinkScope;
active: boolean;
}): Promise<void> {
await requireOwner();
@ -76,12 +97,12 @@ export async function setIntakeLinkActiveAction(input: {
await pb.collection("intake_links").update(input.linkId, {
active: input.active,
});
revalidatePath(`/admin/projects/${input.projectId}`);
revalidatePath(adminPathForScope(input.scope));
}
export async function setIntakeLinkNotesAction(input: {
linkId: string;
projectId: string;
scope: IntakeLinkScope;
notes: string;
}): Promise<void> {
await requireOwner();
@ -90,7 +111,7 @@ export async function setIntakeLinkNotesAction(input: {
await pb.collection("intake_links").update(input.linkId, {
notes: input.notes.slice(0, 1000),
});
revalidatePath(`/admin/projects/${input.projectId}`);
revalidatePath(adminPathForScope(input.scope));
}
function intakeUrlFor(token: string): string {

View file

@ -2,22 +2,21 @@ import { notFound } from "next/navigation";
import { AppShell } from "@/components/shell/app-shell";
import { TopBar } from "@/components/shell/top-bar";
import { IntakeLinksPanel } from "@/components/admin/intake-links-panel";
import { ShowInIntakeToggle } from "@/components/admin/show-in-intake-toggle";
import { getAdminPb } from "@/lib/pb/admin";
import type { ClientRecord, ProjectRecord } from "@/lib/pb/types";
import type { ClientRecord, IntakeLinkRecord, ProjectRecord } from "@/lib/pb/types";
/**
* Per-client request-link admin page.
*
* Two stacked panels: a Request-links panel (the same component Phase 8
* shipped for the project page, now bound to client instead — wired in
* Task 2) and a Projects list with the "List on request page" toggle
* next to each.
* shipped for the project page, now bound to client instead) and a
* Projects list with the "List on request page" toggle next to each.
*
* The Phase 8 /admin/projects/[projectId] page keeps its own
* Request-links panel for the legacy per-project link shape. The picker
* is the new default; the legacy path is for owners who haven't
* migrated yet.
* The Phase 8 /admin/projects/[projectId] page keeps its own Request-links
* panel for the legacy per-project link shape. The picker is the new
* default; the legacy path is for owners who haven't migrated yet.
*
* Owner-only by virtue of the admin layout, which is already enforced
* upstream.
@ -45,6 +44,13 @@ export default async function ClientAdminPage({
sort: "name",
});
const links = await pb
.collection("intake_links")
.getFullList<IntakeLinkRecord>({
filter: `client = "${clientId}"`,
sort: "-created",
});
return (
<AppShell role="owner" currentPath="/admin/clients">
<TopBar title={client.name} subtitle="Request links and project picker">
@ -55,9 +61,7 @@ export default async function ClientAdminPage({
<div className="flex-1 overflow-y-auto p-6">
<div className="mx-auto max-w-2xl space-y-4">
<section className="rounded-[var(--radius-tile)] border border-border-subtle p-4 text-xs text-text-muted">
Request links panel lands in Task 2.
</section>
<IntakeLinksPanel mode="client" clientId={client.id} links={links} />
<section className="space-y-3 rounded-[var(--radius-tile)] border border-border-subtle p-4">
<div>

View file

@ -65,7 +65,7 @@ export default async function ProjectAdminPage({
commitCount={commits.totalItems}
/>
<SummaryPanel projectId={project.id} status={summary} />
<IntakeLinksPanel projectId={project.id} links={links} />
<IntakeLinksPanel mode="project" projectId={project.id} links={links} />
<ShowInIntakeToggle
projectId={project.id}
initial={Boolean(project.show_in_intake)}

View file

@ -8,12 +8,18 @@ import {
rotateIntakeTokenAction,
setIntakeLinkActiveAction,
setIntakeLinkNotesAction,
type IntakeLinkScope,
} from "@/app/actions/intake-links";
import type { IntakeLinkRecord } from "@/lib/pb/types";
/**
* Owner UI for public intake links.
*
* Phase 8: mounted on `/admin/projects/[projectId]` with `mode="project"`.
* Phase 9: mounted on `/admin/clients/[clientId]` with `mode="client"`.
* The two paths share the same UI; only the `scope` they forward to the
* Server Actions differs.
*
* The URL is shown once on creation and on rotation. After that the owner
* can only re-copy the same URL — losing the URL does not lose access,
* because the admin page itself can read it. The token itself never leaves
@ -24,12 +30,23 @@ import type { IntakeLinkRecord } from "@/lib/pb/types";
* what the spec's §5.3 said the owner UI should read.
*/
export function IntakeLinksPanel({
mode,
projectId,
clientId,
links,
}: {
projectId: string;
mode: "project" | "client";
/** Phase 8 path: required when mode === "project". */
projectId?: string;
/** Phase 9 path: required when mode === "client". */
clientId?: string;
links: IntakeLinkRecord[];
}) {
const scope: IntakeLinkScope =
mode === "project"
? { type: "project", projectId: projectId! }
: { type: "client", clientId: clientId! };
const [pending, startTransition] = useTransition();
const [newUrl, setNewUrl] = useState<string | null>(null);
const [newLabel, setNewLabel] = useState("");
@ -42,7 +59,7 @@ export function IntakeLinksPanel({
startTransition(async () => {
try {
const result = await createIntakeLinkAction({
projectId,
scope,
label: newLabel,
});
setNewUrl(result.url);
@ -57,7 +74,7 @@ export function IntakeLinksPanel({
startTransition(async () => {
const result = await rotateIntakeTokenAction({
linkId: link.id,
projectId,
scope,
});
setNewUrl(result.url);
});
@ -67,7 +84,7 @@ export function IntakeLinksPanel({
startTransition(async () => {
await setIntakeLinkActiveAction({
linkId: link.id,
projectId,
scope,
active: !link.active,
});
});
@ -77,7 +94,7 @@ export function IntakeLinksPanel({
startTransition(async () => {
await setIntakeLinkNotesAction({
linkId: link.id,
projectId,
scope,
notes,
});
});