abaa8efdd5
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>
151 lines
5.9 KiB
TypeScript
151 lines
5.9 KiB
TypeScript
"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">
|
||
Couldn’t 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>
|
||
);
|
||
}
|