Files
provenance/frontend/app/trees/[id]/proposals/page.tsx
T
justin abaa8efdd5 Fix #214: ChangeProposal (propose-then-confirm)
Implements non-negotiable #1: the AI assistant never writes autonomously. Every
assistant/contributor "write" emits a ChangeProposal — a structured diff a human
approves, edits, or rejects. Design: docs/design/change-proposal.md.

Structural guarantee: a proposal's operations reach the DB ONLY via
change_proposal_service.apply(), which requires the actor be an editor and
dispatches each op through the normal editing services (person/name/event/
relationship/source/citation create/update/delete) — so every change passes the
privacy engine and is audited as the approving human. propose() only inserts a
pending row; it performs no domain mutation. Model providers stay read-only, so
no model response can mutate tree data.

- ChangeProposal model + migration (status pending|applied|rejected, origin
  assistant|contributor, JSONB operations, reviewer + apply_error).
- Service: propose / list / get / apply (with optional edited ops) / reject /
  delete; a dispatcher mapping ops → editing services. v1 applies ops in order,
  not cross-op transactional (single-op is atomic; documented).
- API /trees/{id}/proposals + a frontend review page (approve/reject; editor-
  gated) and sidebar entry.

Tests: proposal doesn't apply until approved; reject doesn't apply; non-editor
member can see but not apply; multi-op; approve-with-edits; apply-error keeps it
pending. Full suite 87 passed; single alembic head.

Closes #214

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-09 15:44:40 -04:00

151 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api/client";
import type { components } from "@/lib/api/schema";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
type Proposal = components["schemas"]["ChangeProposalRead"];
type Op = { op: string; entity_type: string; entity_id?: string | null; payload?: Record<string, unknown> };
const STATUS_STYLE: Record<string, string> = {
pending: "border-bronze/40 text-bronze",
applied: "border-green-600/40 text-green-700 dark:text-green-400",
rejected: "border-[var(--border)] text-[var(--muted)]",
};
export default function ProposalsPage() {
const { id: treeId } = useParams<{ id: string }>();
const [proposals, setProposals] = useState<Proposal[]>([]);
const [meRole, setMeRole] = useState<string | null>(null);
const [ready, setReady] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const load = useCallback(async () => {
const [props, me, members] = await Promise.all([
api.GET("/api/v1/trees/{tree_id}/proposals", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/users/me"),
api.GET("/api/v1/trees/{tree_id}/members", { params: { path: { tree_id: treeId } } }),
]);
const myId = me.data?.id;
setMeRole((members.data ?? []).find((m) => m.user_id === myId)?.role ?? null);
setProposals(props.data ?? []);
setReady(true);
}, [treeId]);
useEffect(() => {
load();
}, [load]);
const canReview = meRole === "owner" || meRole === "editor";
async function act(pid: string, action: "apply" | "reject") {
setBusy(pid);
const { error, response } = await api.POST(
`/api/v1/trees/{tree_id}/proposals/{proposal_id}/${action}` as "/api/v1/trees/{tree_id}/proposals/{proposal_id}/apply",
{ params: { path: { tree_id: treeId, proposal_id: pid } } },
);
setBusy(null);
if (error) {
// 409 = couldn't apply (e.g. references a missing record); reload shows apply_error.
if (response.status !== 409) alert("Action failed.");
}
load();
}
if (!ready) return <p className="text-[var(--muted)]">Loading</p>;
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">Change proposals</h1>
<p className="mt-1 text-sm text-[var(--muted)]">
Suggested edits (from the assistant or contributors). Nothing changes until you approve
approving applies it as your own edit, through the normal checks and audit log.
</p>
</div>
{proposals.length === 0 ? (
<p className="text-[var(--muted)]">No proposals.</p>
) : (
<ul className="space-y-3">
{proposals.map((p) => {
const ops = (p.operations as Op[]) ?? [];
return (
<li key={p.id}>
<Card>
<CardContent className="space-y-3 p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-medium">{p.summary}</div>
<div className="text-xs text-[var(--muted)]">
{p.origin} · {ops.length} change{ops.length === 1 ? "" : "s"}
</div>
</div>
<span
className={`shrink-0 rounded-full border px-2.5 py-0.5 text-xs ${
STATUS_STYLE[p.status] ?? ""
}`}
>
{p.status}
</span>
</div>
{p.rationale && <p className="text-sm text-[var(--muted)]">{p.rationale}</p>}
<ul className="space-y-1 text-sm">
{ops.map((o, i) => (
<li key={i} className="rounded-md bg-bronze/[0.05] px-3 py-1.5">
<span className="font-medium capitalize">{o.op}</span>{" "}
<span className="text-[var(--muted)]">{o.entity_type}</span>
{o.payload && Object.keys(o.payload).length > 0 && (
<span className="text-[var(--muted)]">
{" "}
·{" "}
{Object.entries(o.payload)
.map(([k, v]) => `${k}: ${String(v)}`)
.join(", ")}
</span>
)}
</li>
))}
</ul>
{p.apply_error && (
<p className="rounded-md border border-red-600/40 bg-red-600/[0.06] px-3 py-2 text-sm text-red-700 dark:text-red-400">
Couldnt apply: {p.apply_error}
</p>
)}
{p.status === "pending" && canReview && (
<div className="flex gap-2">
<Button size="sm" disabled={busy === p.id} onClick={() => act(p.id, "apply")}>
Approve & apply
</Button>
<Button
size="sm"
variant="outline"
disabled={busy === p.id}
onClick={() => act(p.id, "reject")}
>
Reject
</Button>
</div>
)}
{p.status === "pending" && !canReview && (
<p className="text-xs text-[var(--muted)]">Only an editor can approve this.</p>
)}
</CardContent>
</Card>
</li>
);
})}
</ul>
)}
</div>
);
}