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>
This commit is contained in:
@@ -12,6 +12,7 @@ from app.api.v1 import (
|
|||||||
members,
|
members,
|
||||||
names,
|
names,
|
||||||
persons,
|
persons,
|
||||||
|
proposals,
|
||||||
public,
|
public,
|
||||||
relationships,
|
relationships,
|
||||||
sources,
|
sources,
|
||||||
@@ -34,3 +35,4 @@ api_router.include_router(gedcom.router)
|
|||||||
api_router.include_router(cleanup.router)
|
api_router.include_router(cleanup.router)
|
||||||
api_router.include_router(public.router)
|
api_router.include_router(public.router)
|
||||||
api_router.include_router(members.router)
|
api_router.include_router(members.router)
|
||||||
|
api_router.include_router(proposals.router)
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""Change-proposal endpoints: list / create / get / apply / reject / delete.
|
||||||
|
|
||||||
|
Applying a proposal is the only way its operations reach the database, and only
|
||||||
|
an editor can do it (enforced in the service). See docs/design/change-proposal.md.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
|
from app.models.enums import ChangeProposalStatus
|
||||||
|
from app.schemas.change_proposal import (
|
||||||
|
ChangeProposalCreate,
|
||||||
|
ChangeProposalRead,
|
||||||
|
ProposalReview,
|
||||||
|
)
|
||||||
|
from app.services import change_proposal_service, tree_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/trees", tags=["proposals"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/proposals", response_model=list[ChangeProposalRead])
|
||||||
|
async def list_proposals(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
status: ChangeProposalStatus | None = None,
|
||||||
|
) -> list[ChangeProposalRead]:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
rows = await change_proposal_service.list_proposals(
|
||||||
|
session, viewer_id=current.id, tree=tree, status=status
|
||||||
|
)
|
||||||
|
return [ChangeProposalRead.model_validate(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{tree_id}/proposals", response_model=ChangeProposalRead, status_code=status.HTTP_201_CREATED
|
||||||
|
)
|
||||||
|
async def create_proposal(
|
||||||
|
tree_id: uuid.UUID, data: ChangeProposalCreate, session: SessionDep, current: CurrentUser
|
||||||
|
) -> ChangeProposalRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
operations = [op.model_dump(mode="json") for op in data.operations]
|
||||||
|
cp = await change_proposal_service.propose(
|
||||||
|
session,
|
||||||
|
tree=tree,
|
||||||
|
origin=data.origin,
|
||||||
|
created_by=current.id,
|
||||||
|
summary=data.summary,
|
||||||
|
rationale=data.rationale,
|
||||||
|
operations=operations,
|
||||||
|
)
|
||||||
|
return ChangeProposalRead.model_validate(cp)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/proposals/{proposal_id}", response_model=ChangeProposalRead)
|
||||||
|
async def get_proposal(
|
||||||
|
tree_id: uuid.UUID, proposal_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> ChangeProposalRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
cp = await change_proposal_service.get_proposal(
|
||||||
|
session, viewer_id=current.id, tree=tree, proposal_id=proposal_id
|
||||||
|
)
|
||||||
|
return ChangeProposalRead.model_validate(cp)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tree_id}/proposals/{proposal_id}/apply", response_model=ChangeProposalRead)
|
||||||
|
async def apply_proposal(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
proposal_id: uuid.UUID,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
data: ProposalReview | None = None,
|
||||||
|
) -> ChangeProposalRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
edited = (
|
||||||
|
[op.model_dump(mode="json") for op in data.operations]
|
||||||
|
if data and data.operations is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
cp = await change_proposal_service.apply(
|
||||||
|
session, actor=current, tree=tree, proposal_id=proposal_id, edited_operations=edited
|
||||||
|
)
|
||||||
|
return ChangeProposalRead.model_validate(cp)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tree_id}/proposals/{proposal_id}/reject", response_model=ChangeProposalRead)
|
||||||
|
async def reject_proposal(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
proposal_id: uuid.UUID,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
data: ProposalReview | None = None,
|
||||||
|
) -> ChangeProposalRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
cp = await change_proposal_service.reject(
|
||||||
|
session,
|
||||||
|
actor=current,
|
||||||
|
tree=tree,
|
||||||
|
proposal_id=proposal_id,
|
||||||
|
note=data.note if data else None,
|
||||||
|
)
|
||||||
|
return ChangeProposalRead.model_validate(cp)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{tree_id}/proposals/{proposal_id}", status_code=status.HTTP_204_NO_CONTENT
|
||||||
|
)
|
||||||
|
async def delete_proposal(
|
||||||
|
tree_id: uuid.UUID, proposal_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> None:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
await change_proposal_service.delete_proposal(
|
||||||
|
session, actor=current, tree=tree, proposal_id=proposal_id
|
||||||
|
)
|
||||||
@@ -4,6 +4,7 @@ and for ``create_all`` in tests."""
|
|||||||
from app.models.audit import AuditEntry
|
from app.models.audit import AuditEntry
|
||||||
from app.models.auth import Session, UserToken
|
from app.models.auth import Session, UserToken
|
||||||
from app.models.base import Base
|
from app.models.base import Base
|
||||||
|
from app.models.change_proposal import ChangeProposal
|
||||||
from app.models.event import Event
|
from app.models.event import Event
|
||||||
from app.models.media import Media
|
from app.models.media import Media
|
||||||
from app.models.person import Name, Person
|
from app.models.person import Name, Person
|
||||||
@@ -30,4 +31,5 @@ __all__ = [
|
|||||||
"Session",
|
"Session",
|
||||||
"UserToken",
|
"UserToken",
|
||||||
"Media",
|
"Media",
|
||||||
|
"ChangeProposal",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""ChangeProposal — a structured diff the AI assistant (or an untrusted
|
||||||
|
contributor) proposes, which a human approves/edits/rejects. Applying it routes
|
||||||
|
each operation through the normal editing services, so the change passes the
|
||||||
|
privacy engine and is audited as the approving human's action. See
|
||||||
|
docs/design/change-proposal.md and CLAUDE.md non-negotiable #1.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String, Text
|
||||||
|
from sqlalchemy import Enum as SAEnum
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import Base
|
||||||
|
from app.models.enums import ChangeProposalOrigin, ChangeProposalStatus
|
||||||
|
from app.models.mixins import SoftDelete, TenantScoped, Timestamps, UUIDPrimaryKey
|
||||||
|
|
||||||
|
|
||||||
|
class ChangeProposal(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
|
||||||
|
__tablename__ = "change_proposals"
|
||||||
|
|
||||||
|
status: Mapped[ChangeProposalStatus] = mapped_column(
|
||||||
|
SAEnum(ChangeProposalStatus, name="change_proposal_status"),
|
||||||
|
default=ChangeProposalStatus.pending,
|
||||||
|
server_default=ChangeProposalStatus.pending.value,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
origin: Mapped[ChangeProposalOrigin] = mapped_column(
|
||||||
|
SAEnum(ChangeProposalOrigin, name="change_proposal_origin"),
|
||||||
|
default=ChangeProposalOrigin.assistant,
|
||||||
|
server_default=ChangeProposalOrigin.assistant.value,
|
||||||
|
)
|
||||||
|
created_by_user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL")
|
||||||
|
)
|
||||||
|
summary: Mapped[str] = mapped_column(String(512))
|
||||||
|
rationale: Mapped[str | None] = mapped_column(Text)
|
||||||
|
# The structured diff: a list of {op, entity_type, entity_id?, payload} dicts.
|
||||||
|
operations: Mapped[list] = mapped_column(JSONB, nullable=False)
|
||||||
|
|
||||||
|
reviewed_by_user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL")
|
||||||
|
)
|
||||||
|
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
review_note: Mapped[str | None] = mapped_column(String(512))
|
||||||
|
apply_error: Mapped[str | None] = mapped_column(Text)
|
||||||
@@ -61,3 +61,14 @@ class AuditActorType(enum.StrEnum):
|
|||||||
class TokenPurpose(enum.StrEnum):
|
class TokenPurpose(enum.StrEnum):
|
||||||
email_verify = "email_verify"
|
email_verify = "email_verify"
|
||||||
password_reset = "password_reset"
|
password_reset = "password_reset"
|
||||||
|
|
||||||
|
|
||||||
|
class ChangeProposalStatus(enum.StrEnum):
|
||||||
|
pending = "pending"
|
||||||
|
applied = "applied"
|
||||||
|
rejected = "rejected"
|
||||||
|
|
||||||
|
|
||||||
|
class ChangeProposalOrigin(enum.StrEnum):
|
||||||
|
assistant = "assistant" # the AI assistant, acting on behalf of a user
|
||||||
|
contributor = "contributor" # an untrusted human edit awaiting moderation
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
from app.models.enums import ChangeProposalOrigin, ChangeProposalStatus
|
||||||
|
|
||||||
|
|
||||||
|
class ProposalOperation(BaseModel):
|
||||||
|
op: str # create | update | delete
|
||||||
|
entity_type: str # person | name | event | relationship | source | citation
|
||||||
|
entity_id: uuid.UUID | None = None
|
||||||
|
payload: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
class ChangeProposalCreate(BaseModel):
|
||||||
|
summary: str
|
||||||
|
rationale: str | None = None
|
||||||
|
origin: ChangeProposalOrigin = ChangeProposalOrigin.contributor
|
||||||
|
operations: list[ProposalOperation]
|
||||||
|
|
||||||
|
|
||||||
|
class ProposalReview(BaseModel):
|
||||||
|
note: str | None = None
|
||||||
|
# Optional edited operations to apply instead of the original (approve-with-edits).
|
||||||
|
operations: list[ProposalOperation] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChangeProposalRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
tree_id: uuid.UUID
|
||||||
|
status: ChangeProposalStatus
|
||||||
|
origin: ChangeProposalOrigin
|
||||||
|
created_by_user_id: uuid.UUID | None
|
||||||
|
summary: str
|
||||||
|
rationale: str | None
|
||||||
|
operations: list
|
||||||
|
reviewed_by_user_id: uuid.UUID | None
|
||||||
|
reviewed_at: datetime | None
|
||||||
|
review_note: str | None
|
||||||
|
apply_error: str | None
|
||||||
|
created_at: datetime
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
"""ChangeProposal lifecycle: propose (assistant/contributor) → review → apply/reject.
|
||||||
|
|
||||||
|
The structural guarantee (CLAUDE.md #1): a proposal's operations are executed
|
||||||
|
ONLY by ``apply()``, which requires the actor be an editor and dispatches every
|
||||||
|
op through the normal editing services — so each change passes the privacy
|
||||||
|
engine and is audited as the approving human. ``propose()`` only inserts a
|
||||||
|
pending row; it performs no domain mutation. See docs/design/change-proposal.md.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.change_proposal import ChangeProposal
|
||||||
|
from app.models.enums import (
|
||||||
|
ChangeProposalOrigin,
|
||||||
|
ChangeProposalStatus,
|
||||||
|
CitationConfidence,
|
||||||
|
ParentChildQualifier,
|
||||||
|
RelationshipType,
|
||||||
|
)
|
||||||
|
from app.models.tree import Tree
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services import (
|
||||||
|
citation_service,
|
||||||
|
event_service,
|
||||||
|
name_service,
|
||||||
|
person_service,
|
||||||
|
privacy,
|
||||||
|
relationship_service,
|
||||||
|
source_service,
|
||||||
|
)
|
||||||
|
from app.services.exceptions import Conflict, Forbidden, NotFound
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _uuid(v) -> uuid.UUID | None:
|
||||||
|
return uuid.UUID(str(v)) if v else None
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_editor(session: AsyncSession, *, actor: User, tree: Tree) -> None:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_member(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> None:
|
||||||
|
# Proposals can reference unredacted facts → members only.
|
||||||
|
if await privacy.get_membership_role(session, viewer_id, tree.id) is None:
|
||||||
|
raise Forbidden("only members can see change proposals")
|
||||||
|
|
||||||
|
|
||||||
|
async def _load(
|
||||||
|
session: AsyncSession, tree: Tree, proposal_id: uuid.UUID
|
||||||
|
) -> ChangeProposal:
|
||||||
|
cp = (
|
||||||
|
await session.execute(
|
||||||
|
select(ChangeProposal).where(
|
||||||
|
ChangeProposal.id == proposal_id,
|
||||||
|
ChangeProposal.tree_id == tree.id,
|
||||||
|
ChangeProposal.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if cp is None:
|
||||||
|
raise NotFound("proposal not found")
|
||||||
|
return cp
|
||||||
|
|
||||||
|
|
||||||
|
async def propose(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
tree: Tree,
|
||||||
|
origin: ChangeProposalOrigin,
|
||||||
|
created_by: uuid.UUID | None,
|
||||||
|
summary: str,
|
||||||
|
rationale: str | None,
|
||||||
|
operations: list[dict],
|
||||||
|
) -> ChangeProposal:
|
||||||
|
"""Insert a pending proposal. The ONLY mutation here is the proposal row — no
|
||||||
|
tree data changes. (No edit-rights check: proposing isn't writing.)"""
|
||||||
|
cp = ChangeProposal(
|
||||||
|
tree_id=tree.id,
|
||||||
|
origin=origin,
|
||||||
|
created_by_user_id=created_by,
|
||||||
|
summary=summary,
|
||||||
|
rationale=rationale,
|
||||||
|
operations=operations,
|
||||||
|
status=ChangeProposalStatus.pending,
|
||||||
|
)
|
||||||
|
session.add(cp)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(cp)
|
||||||
|
return cp
|
||||||
|
|
||||||
|
|
||||||
|
async def list_proposals(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
viewer_id: uuid.UUID,
|
||||||
|
tree: Tree,
|
||||||
|
status: ChangeProposalStatus | None = None,
|
||||||
|
) -> list[ChangeProposal]:
|
||||||
|
await _require_member(session, viewer_id=viewer_id, tree=tree)
|
||||||
|
stmt = select(ChangeProposal).where(
|
||||||
|
ChangeProposal.tree_id == tree.id, ChangeProposal.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
if status is not None:
|
||||||
|
stmt = stmt.where(ChangeProposal.status == status)
|
||||||
|
stmt = stmt.order_by(ChangeProposal.created_at.desc())
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def get_proposal(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, proposal_id: uuid.UUID
|
||||||
|
) -> ChangeProposal:
|
||||||
|
await _require_member(session, viewer_id=viewer_id, tree=tree)
|
||||||
|
return await _load(session, tree, proposal_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def reject(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
tree: Tree,
|
||||||
|
proposal_id: uuid.UUID,
|
||||||
|
note: str | None = None,
|
||||||
|
) -> ChangeProposal:
|
||||||
|
await _require_editor(session, actor=actor, tree=tree)
|
||||||
|
cp = await _load(session, tree, proposal_id)
|
||||||
|
if cp.status is not ChangeProposalStatus.pending:
|
||||||
|
raise Conflict("proposal is not pending")
|
||||||
|
cp.status = ChangeProposalStatus.rejected
|
||||||
|
cp.reviewed_by_user_id = actor.id
|
||||||
|
cp.reviewed_at = _now()
|
||||||
|
cp.review_note = note
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(cp)
|
||||||
|
return cp
|
||||||
|
|
||||||
|
|
||||||
|
async def apply(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
tree: Tree,
|
||||||
|
proposal_id: uuid.UUID,
|
||||||
|
edited_operations: list[dict] | None = None,
|
||||||
|
) -> ChangeProposal:
|
||||||
|
await _require_editor(session, actor=actor, tree=tree)
|
||||||
|
cp = await _load(session, tree, proposal_id)
|
||||||
|
if cp.status is not ChangeProposalStatus.pending:
|
||||||
|
raise Conflict("proposal is not pending")
|
||||||
|
ops = edited_operations if edited_operations is not None else list(cp.operations)
|
||||||
|
try:
|
||||||
|
for op in ops:
|
||||||
|
await _dispatch(session, actor=actor, tree=tree, op=op)
|
||||||
|
except Conflict:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # noqa: BLE001 — record the failure on the proposal
|
||||||
|
err = f"{type(exc).__name__}: {exc}"[:2000]
|
||||||
|
# The editing services raise (NotFound/Forbidden/validation) before
|
||||||
|
# committing, so the transaction is clean — record the error and commit.
|
||||||
|
# If a later op did write before failing, those ops already committed
|
||||||
|
# (v1 isn't cross-op transactional; see the design note).
|
||||||
|
cp = await _load(session, tree, proposal_id)
|
||||||
|
cp.apply_error = err
|
||||||
|
await session.commit()
|
||||||
|
raise Conflict(f"could not apply proposal: {err}") from exc
|
||||||
|
if edited_operations is not None:
|
||||||
|
cp.operations = edited_operations
|
||||||
|
cp.status = ChangeProposalStatus.applied
|
||||||
|
cp.reviewed_by_user_id = actor.id
|
||||||
|
cp.reviewed_at = _now()
|
||||||
|
cp.apply_error = None
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(cp)
|
||||||
|
return cp
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_proposal(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, proposal_id: uuid.UUID
|
||||||
|
) -> None:
|
||||||
|
await _require_editor(session, actor=actor, tree=tree)
|
||||||
|
cp = await _load(session, tree, proposal_id)
|
||||||
|
cp.deleted_at = _now()
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _bad(entity_type: str, action: str) -> Conflict:
|
||||||
|
return Conflict(f"unsupported operation '{action}' on '{entity_type}'")
|
||||||
|
|
||||||
|
|
||||||
|
async def _dispatch(session: AsyncSession, *, actor: User, tree: Tree, op: dict) -> None:
|
||||||
|
"""Route one operation through the matching editing service (privacy + audit)."""
|
||||||
|
et = op.get("entity_type")
|
||||||
|
action = op.get("op")
|
||||||
|
payload = op.get("payload") or {}
|
||||||
|
eid = op.get("entity_id")
|
||||||
|
|
||||||
|
if et == "person":
|
||||||
|
if action == "create":
|
||||||
|
await person_service.create_person(
|
||||||
|
session,
|
||||||
|
actor=actor,
|
||||||
|
tree=tree,
|
||||||
|
given=payload.get("given"),
|
||||||
|
surname=payload.get("surname"),
|
||||||
|
gender=payload.get("gender"),
|
||||||
|
is_living=payload.get("is_living"),
|
||||||
|
notes=payload.get("notes"),
|
||||||
|
)
|
||||||
|
elif action == "update":
|
||||||
|
await person_service.update_person(
|
||||||
|
session, actor=actor, tree=tree, person_id=_uuid(eid), changes=payload
|
||||||
|
)
|
||||||
|
elif action == "delete":
|
||||||
|
await person_service.delete_person(
|
||||||
|
session,
|
||||||
|
actor=actor,
|
||||||
|
tree=tree,
|
||||||
|
person_id=_uuid(eid),
|
||||||
|
cascade=bool(payload.get("cascade", False)),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise _bad(et, action)
|
||||||
|
elif et == "event":
|
||||||
|
if action == "create":
|
||||||
|
await event_service.create_event(
|
||||||
|
session,
|
||||||
|
actor=actor,
|
||||||
|
tree=tree,
|
||||||
|
event_type=payload["event_type"],
|
||||||
|
person_id=_uuid(payload.get("person_id")),
|
||||||
|
relationship_id=_uuid(payload.get("relationship_id")),
|
||||||
|
date_value=payload.get("date_value"),
|
||||||
|
date_precision=payload.get("date_precision"),
|
||||||
|
detail=payload.get("detail"),
|
||||||
|
notes=payload.get("notes"),
|
||||||
|
)
|
||||||
|
elif action == "update":
|
||||||
|
await event_service.update_event(
|
||||||
|
session, actor=actor, tree=tree, event_id=_uuid(eid), changes=payload
|
||||||
|
)
|
||||||
|
elif action == "delete":
|
||||||
|
await event_service.delete_event(
|
||||||
|
session, actor=actor, tree=tree, event_id=_uuid(eid)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise _bad(et, action)
|
||||||
|
elif et == "relationship":
|
||||||
|
if action == "create":
|
||||||
|
await relationship_service.create_relationship(
|
||||||
|
session,
|
||||||
|
actor=actor,
|
||||||
|
tree=tree,
|
||||||
|
type=RelationshipType(payload["type"]),
|
||||||
|
person_from_id=_uuid(payload["person_from_id"]),
|
||||||
|
person_to_id=_uuid(payload["person_to_id"]),
|
||||||
|
qualifier=ParentChildQualifier(payload["qualifier"])
|
||||||
|
if payload.get("qualifier")
|
||||||
|
else None,
|
||||||
|
notes=payload.get("notes"),
|
||||||
|
)
|
||||||
|
elif action == "delete":
|
||||||
|
await relationship_service.delete_relationship(
|
||||||
|
session, actor=actor, tree=tree, relationship_id=_uuid(eid)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise _bad(et, action)
|
||||||
|
elif et == "name":
|
||||||
|
if action == "create":
|
||||||
|
await name_service.create_name(
|
||||||
|
session,
|
||||||
|
actor=actor,
|
||||||
|
tree=tree,
|
||||||
|
person_id=_uuid(payload["person_id"]),
|
||||||
|
name_type=payload.get("name_type", "birth"),
|
||||||
|
given=payload.get("given"),
|
||||||
|
surname=payload.get("surname"),
|
||||||
|
prefix=payload.get("prefix"),
|
||||||
|
suffix=payload.get("suffix"),
|
||||||
|
nickname=payload.get("nickname"),
|
||||||
|
is_primary=bool(payload.get("is_primary", False)),
|
||||||
|
)
|
||||||
|
elif action == "update":
|
||||||
|
changes = {k: v for k, v in payload.items() if k != "person_id"}
|
||||||
|
await name_service.update_name(
|
||||||
|
session,
|
||||||
|
actor=actor,
|
||||||
|
tree=tree,
|
||||||
|
person_id=_uuid(payload["person_id"]),
|
||||||
|
name_id=_uuid(eid),
|
||||||
|
changes=changes,
|
||||||
|
)
|
||||||
|
elif action == "delete":
|
||||||
|
await name_service.delete_name(
|
||||||
|
session,
|
||||||
|
actor=actor,
|
||||||
|
tree=tree,
|
||||||
|
person_id=_uuid(payload["person_id"]),
|
||||||
|
name_id=_uuid(eid),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise _bad(et, action)
|
||||||
|
elif et == "source":
|
||||||
|
if action == "create":
|
||||||
|
await source_service.create_source(
|
||||||
|
session,
|
||||||
|
actor=actor,
|
||||||
|
tree=tree,
|
||||||
|
title=payload["title"],
|
||||||
|
author=payload.get("author"),
|
||||||
|
source_type=payload.get("source_type"),
|
||||||
|
repository=payload.get("repository"),
|
||||||
|
url=payload.get("url"),
|
||||||
|
citation_text=payload.get("citation_text"),
|
||||||
|
publication_info=payload.get("publication_info"),
|
||||||
|
quality_note=payload.get("quality_note"),
|
||||||
|
)
|
||||||
|
elif action == "delete":
|
||||||
|
await source_service.delete_source(
|
||||||
|
session, actor=actor, tree=tree, source_id=_uuid(eid)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise _bad(et, action)
|
||||||
|
elif et == "citation":
|
||||||
|
if action == "create":
|
||||||
|
await citation_service.create_citation(
|
||||||
|
session,
|
||||||
|
actor=actor,
|
||||||
|
tree=tree,
|
||||||
|
source_id=_uuid(payload["source_id"]),
|
||||||
|
person_id=_uuid(payload.get("person_id")),
|
||||||
|
event_id=_uuid(payload.get("event_id")),
|
||||||
|
name_id=_uuid(payload.get("name_id")),
|
||||||
|
relationship_id=_uuid(payload.get("relationship_id")),
|
||||||
|
page=payload.get("page"),
|
||||||
|
detail=payload.get("detail"),
|
||||||
|
confidence=CitationConfidence(payload["confidence"])
|
||||||
|
if payload.get("confidence")
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
elif action == "delete":
|
||||||
|
await citation_service.delete_citation(
|
||||||
|
session, actor=actor, tree=tree, citation_id=_uuid(eid)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise _bad(et, action)
|
||||||
|
else:
|
||||||
|
raise Conflict(f"unsupported entity type '{et}'")
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""change_proposals (AI propose-then-confirm)
|
||||||
|
|
||||||
|
Revision ID: a1b2c3d4e5f6
|
||||||
|
Revises: d4a9c1e7b2f3
|
||||||
|
Create Date: 2026-06-09
|
||||||
|
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "a1b2c3d4e5f6"
|
||||||
|
down_revision: str | None = "d4a9c1e7b2f3"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"change_proposals",
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column("tree_id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"status",
|
||||||
|
sa.Enum("pending", "applied", "rejected", name="change_proposal_status"),
|
||||||
|
server_default="pending",
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"origin",
|
||||||
|
sa.Enum("assistant", "contributor", name="change_proposal_origin"),
|
||||||
|
server_default="assistant",
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("created_by_user_id", sa.Uuid(), nullable=True),
|
||||||
|
sa.Column("summary", sa.String(length=512), nullable=False),
|
||||||
|
sa.Column("rationale", sa.Text(), nullable=True),
|
||||||
|
sa.Column("operations", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.Column("reviewed_by_user_id", sa.Uuid(), nullable=True),
|
||||||
|
sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("review_note", sa.String(length=512), nullable=True),
|
||||||
|
sa.Column("apply_error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["tree_id"], ["trees.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["reviewed_by_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_change_proposals_tree_id", "change_proposals", ["tree_id"])
|
||||||
|
op.create_index("ix_change_proposals_status", "change_proposals", ["status"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_change_proposals_status", table_name="change_proposals")
|
||||||
|
op.drop_index("ix_change_proposals_tree_id", table_name="change_proposals")
|
||||||
|
op.drop_table("change_proposals")
|
||||||
|
sa.Enum(name="change_proposal_status").drop(op.get_bind())
|
||||||
|
sa.Enum(name="change_proposal_origin").drop(op.get_bind())
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""ChangeProposal: a proposal mutates nothing until an editor approves it, and
|
||||||
|
application goes through the editing services (privacy + audit). See
|
||||||
|
docs/design/change-proposal.md and CLAUDE.md non-negotiable #1.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from tests.conftest import auth, register
|
||||||
|
|
||||||
|
|
||||||
|
async def _tree(client, email):
|
||||||
|
h = auth(await register(client, email))
|
||||||
|
tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
|
||||||
|
return h, tid
|
||||||
|
|
||||||
|
|
||||||
|
async def _propose(client, tid, headers, summary, operations, origin="assistant"):
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/proposals",
|
||||||
|
json={"summary": summary, "origin": origin, "operations": operations},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_proposal_not_applied_until_approved(client):
|
||||||
|
h, tid = await _tree(client, "cp-owner@ex.com")
|
||||||
|
cp = await _propose(
|
||||||
|
client,
|
||||||
|
tid,
|
||||||
|
h,
|
||||||
|
"Add Ada Lovelace",
|
||||||
|
[{"op": "create", "entity_type": "person", "payload": {"given": "Ada", "surname": "Lovelace"}}],
|
||||||
|
)
|
||||||
|
assert cp["status"] == "pending"
|
||||||
|
|
||||||
|
# The proposed person does NOT exist yet.
|
||||||
|
people = (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()
|
||||||
|
assert not any(p["primary_name"] == "Ada Lovelace" for p in people)
|
||||||
|
|
||||||
|
# Approve → applied → the person now exists.
|
||||||
|
a = await client.post(f"/api/v1/trees/{tid}/proposals/{cp['id']}/apply", headers=h)
|
||||||
|
assert a.status_code == 200 and a.json()["status"] == "applied"
|
||||||
|
people = (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()
|
||||||
|
assert any(p["primary_name"] == "Ada Lovelace" for p in people)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reject_does_not_apply(client):
|
||||||
|
h, tid = await _tree(client, "cp-reject@ex.com")
|
||||||
|
cp = await _propose(
|
||||||
|
client,
|
||||||
|
tid,
|
||||||
|
h,
|
||||||
|
"Add Reject Me",
|
||||||
|
[{"op": "create", "entity_type": "person", "payload": {"given": "Reject", "surname": "Me"}}],
|
||||||
|
)
|
||||||
|
rr = await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/proposals/{cp['id']}/reject", json={"note": "no"}, headers=h
|
||||||
|
)
|
||||||
|
assert rr.status_code == 200 and rr.json()["status"] == "rejected"
|
||||||
|
people = (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()
|
||||||
|
assert not any(p["primary_name"] == "Reject Me" for p in people)
|
||||||
|
# A rejected proposal can't then be applied.
|
||||||
|
assert (
|
||||||
|
await client.post(f"/api/v1/trees/{tid}/proposals/{cp['id']}/apply", headers=h)
|
||||||
|
).status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
async def test_non_editor_member_can_see_but_not_apply(client):
|
||||||
|
owner = auth(await register(client, "cp-o2@ex.com"))
|
||||||
|
viewer = auth(await register(client, "cp-v2@ex.com"))
|
||||||
|
tid = (
|
||||||
|
await client.post("/api/v1/trees", json={"name": "Shared"}, headers=owner)
|
||||||
|
).json()["id"]
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/members", json={"email": "cp-v2@ex.com", "role": "viewer"}, headers=owner
|
||||||
|
)
|
||||||
|
cp = await _propose(
|
||||||
|
client,
|
||||||
|
tid,
|
||||||
|
owner,
|
||||||
|
"Add V P",
|
||||||
|
[{"op": "create", "entity_type": "person", "payload": {"given": "V", "surname": "P"}}],
|
||||||
|
)
|
||||||
|
# A viewer (member) can see the proposal list...
|
||||||
|
assert (await client.get(f"/api/v1/trees/{tid}/proposals", headers=viewer)).status_code == 200
|
||||||
|
# ...but cannot apply it (not an editor).
|
||||||
|
assert (
|
||||||
|
await client.post(f"/api/v1/trees/{tid}/proposals/{cp['id']}/apply", headers=viewer)
|
||||||
|
).status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
async def test_multi_op_applies_all(client):
|
||||||
|
h, tid = await _tree(client, "cp-multi@ex.com")
|
||||||
|
pid = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/persons", json={"given": "Multi", "surname": "Op"}, headers=h
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
cp = await _propose(
|
||||||
|
client,
|
||||||
|
tid,
|
||||||
|
h,
|
||||||
|
"name + event on existing person",
|
||||||
|
[
|
||||||
|
{"op": "create", "entity_type": "name", "payload": {"person_id": pid, "name_type": "alias", "given": "Mo"}},
|
||||||
|
{"op": "create", "entity_type": "event", "payload": {"event_type": "birth", "person_id": pid, "date_value": "1900"}},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
await client.post(f"/api/v1/trees/{tid}/proposals/{cp['id']}/apply", headers=h)
|
||||||
|
).status_code == 200
|
||||||
|
names = (await client.get(f"/api/v1/trees/{tid}/persons/{pid}/names", headers=h)).json()
|
||||||
|
assert any(n.get("given") == "Mo" for n in names)
|
||||||
|
events = (await client.get(f"/api/v1/trees/{tid}/persons/{pid}/events", headers=h)).json()
|
||||||
|
assert any(e["date_value"] == "1900" for e in events)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_apply_with_edited_operations(client):
|
||||||
|
h, tid = await _tree(client, "cp-edit@ex.com")
|
||||||
|
cp = await _propose(
|
||||||
|
client,
|
||||||
|
tid,
|
||||||
|
h,
|
||||||
|
"Add Original",
|
||||||
|
[{"op": "create", "entity_type": "person", "payload": {"given": "Original", "surname": "Name"}}],
|
||||||
|
)
|
||||||
|
edited = {
|
||||||
|
"operations": [
|
||||||
|
{"op": "create", "entity_type": "person", "payload": {"given": "Edited", "surname": "Name"}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
assert (
|
||||||
|
await client.post(f"/api/v1/trees/{tid}/proposals/{cp['id']}/apply", json=edited, headers=h)
|
||||||
|
).status_code == 200
|
||||||
|
names = {p["primary_name"] for p in (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()}
|
||||||
|
assert "Edited Name" in names and "Original Name" not in names
|
||||||
|
|
||||||
|
|
||||||
|
async def test_apply_error_keeps_pending(client):
|
||||||
|
h, tid = await _tree(client, "cp-err@ex.com")
|
||||||
|
cp = await _propose(
|
||||||
|
client,
|
||||||
|
tid,
|
||||||
|
h,
|
||||||
|
"Bad update",
|
||||||
|
[{"op": "update", "entity_type": "person", "entity_id": str(uuid.uuid4()), "payload": {"given": "X"}}],
|
||||||
|
)
|
||||||
|
a = await client.post(f"/api/v1/trees/{tid}/proposals/{cp['id']}/apply", headers=h)
|
||||||
|
assert a.status_code == 409
|
||||||
|
g = (await client.get(f"/api/v1/trees/{tid}/proposals/{cp['id']}", headers=h)).json()
|
||||||
|
assert g["status"] == "pending"
|
||||||
|
assert g["apply_error"]
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Design note: ChangeProposal (propose-then-confirm)
|
||||||
|
|
||||||
|
Status: **in progress**. Implements non-negotiable #1 (CLAUDE.md): *the AI
|
||||||
|
assistant never writes autonomously.* Every assistant "write" emits a
|
||||||
|
**ChangeProposal** — a structured diff a human approves, edits, or rejects.
|
||||||
|
|
||||||
|
## The invariant, structurally
|
||||||
|
|
||||||
|
There must be **no code path where a model response mutates tree data**. We get
|
||||||
|
this by construction, not convention:
|
||||||
|
|
||||||
|
- Model providers (`app/integrations/models/*`) are read-only text/vector
|
||||||
|
producers — they never import a repository or session-mutating service.
|
||||||
|
- The assistant's tools, when they land, will call `change_proposal_service.propose(...)`,
|
||||||
|
which only **inserts a pending ChangeProposal**. It performs no domain mutation.
|
||||||
|
- A ChangeProposal's operations are executed **only** by
|
||||||
|
`change_proposal_service.apply(...)`, which:
|
||||||
|
1. requires the actor be an **editor/owner** of the tree (`privacy.can_edit_tree`),
|
||||||
|
2. dispatches each operation through the **normal editing services**
|
||||||
|
(`person_service`, `event_service`, …) — so every change passes the privacy
|
||||||
|
engine and writes an `AuditEntry` with the **human** as `actor`,
|
||||||
|
3. flips the proposal to `applied`.
|
||||||
|
|
||||||
|
So an assistant can *suggest* anything, but a change reaches the database only
|
||||||
|
when a human with edit rights approves it, and only via the same services a human
|
||||||
|
edit uses.
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
`ChangeProposal` (`TenantScoped` tree_id, `Timestamps`, `SoftDelete`):
|
||||||
|
|
||||||
|
| field | notes |
|
||||||
|
|---|---|
|
||||||
|
| `tree_id` | tenant boundary |
|
||||||
|
| `status` | `pending` \| `applied` \| `rejected` |
|
||||||
|
| `origin` | `assistant` \| `contributor` — who proposed it (the contributor case also moderates untrusted human edits) |
|
||||||
|
| `created_by_user_id` | the user on whose behalf the assistant acted, or the contributor |
|
||||||
|
| `summary` | one-line human description ("Add birth 1850 to John Smith") |
|
||||||
|
| `rationale` | the assistant's reasoning / sources (text) |
|
||||||
|
| `operations` | JSONB list of ops (the structured diff) |
|
||||||
|
| `reviewed_by_user_id`, `reviewed_at`, `review_note` | set on approve/reject |
|
||||||
|
| `apply_error` | populated if application failed (proposal stays `pending`) |
|
||||||
|
|
||||||
|
An **operation** is `{op, entity_type, entity_id?, payload}`:
|
||||||
|
- `op` ∈ `create` | `update` | `delete`
|
||||||
|
- `entity_type` ∈ `person` | `name` | `event` | `relationship` | `source` | `citation`
|
||||||
|
- `entity_id` — null for `create`; the target id for `update`/`delete`
|
||||||
|
- `payload` — proposed field values (`create`/`update`); ignored for `delete`
|
||||||
|
|
||||||
|
A proposal may carry several operations (e.g. "add a person and link them as a
|
||||||
|
child" = create person + create relationship), applied **in order**. The editing
|
||||||
|
services each commit, so v1 application is **not transactional across ops** — if
|
||||||
|
op N fails, ops 1..N-1 are already applied and the proposal stays `pending` with
|
||||||
|
`apply_error` set so the reviewer can fix and re-apply the remainder. Single-op
|
||||||
|
proposals (the common near-term case) are effectively atomic. Cross-op atomicity
|
||||||
|
is a follow-up (it needs the services to accept a no-commit mode).
|
||||||
|
|
||||||
|
## Service surface
|
||||||
|
|
||||||
|
- `propose(session, *, tree, origin, created_by, summary, rationale, operations) -> ChangeProposal`
|
||||||
|
— inserts a `pending` proposal. The **only** thing the assistant can call.
|
||||||
|
- `list_proposals` / `get_proposal` — visible to tree members.
|
||||||
|
- `apply(session, *, actor, tree, proposal_id, edited_operations=None) -> ChangeProposal`
|
||||||
|
— editor-only. Optional `edited_operations` lets the reviewer tweak the diff
|
||||||
|
before applying ("edit" in approve/edit/reject). Dispatches each op through the
|
||||||
|
editing services; on any failure, rolls back and records `apply_error`.
|
||||||
|
- `reject(session, *, actor, tree, proposal_id, note=None)` — editor-only.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
`/trees/{id}/proposals`: `GET` (list, `?status=`), `POST` (create — used by tests
|
||||||
|
and the future contributor flow), `GET /{pid}`, `POST /{pid}/apply`,
|
||||||
|
`POST /{pid}/reject`, `DELETE /{pid}`.
|
||||||
|
|
||||||
|
## Out of scope (follow-ups)
|
||||||
|
|
||||||
|
- The assistant itself (it will be the primary producer; #-future).
|
||||||
|
- A rich diff/edit UI — v1 ships a review list with approve/reject; "edit before
|
||||||
|
apply" is supported in the API and can get UI later.
|
||||||
|
- Dispatch for media/place/tree-settings ops (added when a producer needs them).
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Archive,
|
Archive,
|
||||||
ArrowDownUp,
|
ArrowDownUp,
|
||||||
BookText,
|
BookText,
|
||||||
|
ClipboardCheck,
|
||||||
Compass,
|
Compass,
|
||||||
FolderTree,
|
FolderTree,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
@@ -138,6 +139,12 @@ export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
icon={Sparkles}
|
icon={Sparkles}
|
||||||
active={pathname.startsWith(`/trees/${treeId}/cleanup`)}
|
active={pathname.startsWith(`/trees/${treeId}/cleanup`)}
|
||||||
/>
|
/>
|
||||||
|
<Item
|
||||||
|
href={`/trees/${treeId}/proposals`}
|
||||||
|
label="Proposals"
|
||||||
|
icon={ClipboardCheck}
|
||||||
|
active={pathname.startsWith(`/trees/${treeId}/proposals`)}
|
||||||
|
/>
|
||||||
<Item
|
<Item
|
||||||
href={`/trees/${treeId}/members`}
|
href={`/trees/${treeId}/members`}
|
||||||
label="Members"
|
label="Members"
|
||||||
|
|||||||
Vendored
+352
@@ -961,6 +961,76 @@ export interface paths {
|
|||||||
patch: operations["update_member_api_v1_trees__tree_id__members__membership_id__patch"];
|
patch: operations["update_member_api_v1_trees__tree_id__members__membership_id__patch"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/trees/{tree_id}/proposals": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** List Proposals */
|
||||||
|
get: operations["list_proposals_api_v1_trees__tree_id__proposals_get"];
|
||||||
|
put?: never;
|
||||||
|
/** Create Proposal */
|
||||||
|
post: operations["create_proposal_api_v1_trees__tree_id__proposals_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/trees/{tree_id}/proposals/{proposal_id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Get Proposal */
|
||||||
|
get: operations["get_proposal_api_v1_trees__tree_id__proposals__proposal_id__get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
/** Delete Proposal */
|
||||||
|
delete: operations["delete_proposal_api_v1_trees__tree_id__proposals__proposal_id__delete"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/trees/{tree_id}/proposals/{proposal_id}/apply": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Apply Proposal */
|
||||||
|
post: operations["apply_proposal_api_v1_trees__tree_id__proposals__proposal_id__apply_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/trees/{tree_id}/proposals/{proposal_id}/reject": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Reject Proposal */
|
||||||
|
post: operations["reject_proposal_api_v1_trees__tree_id__proposals__proposal_id__reject_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
@@ -1013,6 +1083,63 @@ export interface components {
|
|||||||
/** Source Id */
|
/** Source Id */
|
||||||
source_id?: string | null;
|
source_id?: string | null;
|
||||||
};
|
};
|
||||||
|
/** ChangeProposalCreate */
|
||||||
|
ChangeProposalCreate: {
|
||||||
|
/** Summary */
|
||||||
|
summary: string;
|
||||||
|
/** Rationale */
|
||||||
|
rationale?: string | null;
|
||||||
|
/** @default contributor */
|
||||||
|
origin?: components["schemas"]["ChangeProposalOrigin"];
|
||||||
|
/** Operations */
|
||||||
|
operations: components["schemas"]["ProposalOperation"][];
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* ChangeProposalOrigin
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
ChangeProposalOrigin: "assistant" | "contributor";
|
||||||
|
/** ChangeProposalRead */
|
||||||
|
ChangeProposalRead: {
|
||||||
|
/**
|
||||||
|
* Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
/**
|
||||||
|
* Tree Id
|
||||||
|
* Format: uuid
|
||||||
|
*/
|
||||||
|
tree_id: string;
|
||||||
|
status: components["schemas"]["ChangeProposalStatus"];
|
||||||
|
origin: components["schemas"]["ChangeProposalOrigin"];
|
||||||
|
/** Created By User Id */
|
||||||
|
created_by_user_id: string | null;
|
||||||
|
/** Summary */
|
||||||
|
summary: string;
|
||||||
|
/** Rationale */
|
||||||
|
rationale: string | null;
|
||||||
|
/** Operations */
|
||||||
|
operations: unknown[];
|
||||||
|
/** Reviewed By User Id */
|
||||||
|
reviewed_by_user_id: string | null;
|
||||||
|
/** Reviewed At */
|
||||||
|
reviewed_at: string | null;
|
||||||
|
/** Review Note */
|
||||||
|
review_note: string | null;
|
||||||
|
/** Apply Error */
|
||||||
|
apply_error: string | null;
|
||||||
|
/**
|
||||||
|
* Created At
|
||||||
|
* Format: date-time
|
||||||
|
*/
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* ChangeProposalStatus
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
ChangeProposalStatus: "pending" | "applied" | "rejected";
|
||||||
/**
|
/**
|
||||||
* CitationConfidence
|
* CitationConfidence
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
@@ -1560,6 +1687,29 @@ export interface components {
|
|||||||
/** Notes */
|
/** Notes */
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
};
|
};
|
||||||
|
/** ProposalOperation */
|
||||||
|
ProposalOperation: {
|
||||||
|
/** Op */
|
||||||
|
op: string;
|
||||||
|
/** Entity Type */
|
||||||
|
entity_type: string;
|
||||||
|
/** Entity Id */
|
||||||
|
entity_id?: string | null;
|
||||||
|
/**
|
||||||
|
* Payload
|
||||||
|
* @default {}
|
||||||
|
*/
|
||||||
|
payload?: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** ProposalReview */
|
||||||
|
ProposalReview: {
|
||||||
|
/** Note */
|
||||||
|
note?: string | null;
|
||||||
|
/** Operations */
|
||||||
|
operations?: components["schemas"]["ProposalOperation"][] | null;
|
||||||
|
};
|
||||||
/**
|
/**
|
||||||
* PublicTreeRead
|
* PublicTreeRead
|
||||||
* @description Tree projection for the public surface — deliberately omits owner_id so a
|
* @description Tree projection for the public surface — deliberately omits owner_id so a
|
||||||
@@ -4299,4 +4449,206 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
list_proposals_api_v1_trees__tree_id__proposals_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
status?: components["schemas"]["ChangeProposalStatus"] | null;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ChangeProposalRead"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
create_proposal_api_v1_trees__tree_id__proposals_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ChangeProposalCreate"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ChangeProposalRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
get_proposal_api_v1_trees__tree_id__proposals__proposal_id__get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
proposal_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ChangeProposalRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
delete_proposal_api_v1_trees__tree_id__proposals__proposal_id__delete: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
proposal_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
apply_proposal_api_v1_trees__tree_id__proposals__proposal_id__apply_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
proposal_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ProposalReview"] | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ChangeProposalRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
reject_proposal_api_v1_trees__tree_id__proposals__proposal_id__reject_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
tree_id: string;
|
||||||
|
proposal_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ProposalReview"] | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ChangeProposalRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3742,6 +3742,357 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/trees/{tree_id}/proposals": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"proposals"
|
||||||
|
],
|
||||||
|
"summary": "List Proposals",
|
||||||
|
"operationId": "list_proposals_api_v1_trees__tree_id__proposals_get",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "tree_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Tree Id"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "status",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/ChangeProposalStatus"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Status"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful Response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ChangeProposalRead"
|
||||||
|
},
|
||||||
|
"title": "Response List Proposals Api V1 Trees Tree Id Proposals Get"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"proposals"
|
||||||
|
],
|
||||||
|
"summary": "Create Proposal",
|
||||||
|
"operationId": "create_proposal_api_v1_trees__tree_id__proposals_post",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "tree_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Tree Id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ChangeProposalCreate"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "Successful Response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ChangeProposalRead"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/trees/{tree_id}/proposals/{proposal_id}": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"proposals"
|
||||||
|
],
|
||||||
|
"summary": "Get Proposal",
|
||||||
|
"operationId": "get_proposal_api_v1_trees__tree_id__proposals__proposal_id__get",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "tree_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Tree Id"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "proposal_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Proposal Id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful Response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ChangeProposalRead"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"tags": [
|
||||||
|
"proposals"
|
||||||
|
],
|
||||||
|
"summary": "Delete Proposal",
|
||||||
|
"operationId": "delete_proposal_api_v1_trees__tree_id__proposals__proposal_id__delete",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "tree_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Tree Id"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "proposal_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Proposal Id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "Successful Response"
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/trees/{tree_id}/proposals/{proposal_id}/apply": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"proposals"
|
||||||
|
],
|
||||||
|
"summary": "Apply Proposal",
|
||||||
|
"operationId": "apply_proposal_api_v1_trees__tree_id__proposals__proposal_id__apply_post",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "tree_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Tree Id"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "proposal_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Proposal Id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/ProposalReview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Data"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful Response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ChangeProposalRead"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/trees/{tree_id}/proposals/{proposal_id}/reject": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"proposals"
|
||||||
|
],
|
||||||
|
"summary": "Reject Proposal",
|
||||||
|
"operationId": "reject_proposal_api_v1_trees__tree_id__proposals__proposal_id__reject_post",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "tree_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Tree Id"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "proposal_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Proposal Id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/ProposalReview"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Data"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful Response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ChangeProposalRead"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation Error",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HTTPValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
@@ -3886,6 +4237,179 @@
|
|||||||
],
|
],
|
||||||
"title": "Body_upload_media_api_v1_trees__tree_id__media_post"
|
"title": "Body_upload_media_api_v1_trees__tree_id__media_post"
|
||||||
},
|
},
|
||||||
|
"ChangeProposalCreate": {
|
||||||
|
"properties": {
|
||||||
|
"summary": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Summary"
|
||||||
|
},
|
||||||
|
"rationale": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Rationale"
|
||||||
|
},
|
||||||
|
"origin": {
|
||||||
|
"$ref": "#/components/schemas/ChangeProposalOrigin",
|
||||||
|
"default": "contributor"
|
||||||
|
},
|
||||||
|
"operations": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ProposalOperation"
|
||||||
|
},
|
||||||
|
"type": "array",
|
||||||
|
"title": "Operations"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"summary",
|
||||||
|
"operations"
|
||||||
|
],
|
||||||
|
"title": "ChangeProposalCreate"
|
||||||
|
},
|
||||||
|
"ChangeProposalOrigin": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"assistant",
|
||||||
|
"contributor"
|
||||||
|
],
|
||||||
|
"title": "ChangeProposalOrigin"
|
||||||
|
},
|
||||||
|
"ChangeProposalRead": {
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Id"
|
||||||
|
},
|
||||||
|
"tree_id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"title": "Tree Id"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"$ref": "#/components/schemas/ChangeProposalStatus"
|
||||||
|
},
|
||||||
|
"origin": {
|
||||||
|
"$ref": "#/components/schemas/ChangeProposalOrigin"
|
||||||
|
},
|
||||||
|
"created_by_user_id": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Created By User Id"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Summary"
|
||||||
|
},
|
||||||
|
"rationale": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Rationale"
|
||||||
|
},
|
||||||
|
"operations": {
|
||||||
|
"items": {},
|
||||||
|
"type": "array",
|
||||||
|
"title": "Operations"
|
||||||
|
},
|
||||||
|
"reviewed_by_user_id": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Reviewed By User Id"
|
||||||
|
},
|
||||||
|
"reviewed_at": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Reviewed At"
|
||||||
|
},
|
||||||
|
"review_note": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Review Note"
|
||||||
|
},
|
||||||
|
"apply_error": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Apply Error"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"title": "Created At"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"tree_id",
|
||||||
|
"status",
|
||||||
|
"origin",
|
||||||
|
"created_by_user_id",
|
||||||
|
"summary",
|
||||||
|
"rationale",
|
||||||
|
"operations",
|
||||||
|
"reviewed_by_user_id",
|
||||||
|
"reviewed_at",
|
||||||
|
"review_note",
|
||||||
|
"apply_error",
|
||||||
|
"created_at"
|
||||||
|
],
|
||||||
|
"title": "ChangeProposalRead"
|
||||||
|
},
|
||||||
|
"ChangeProposalStatus": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"pending",
|
||||||
|
"applied",
|
||||||
|
"rejected"
|
||||||
|
],
|
||||||
|
"title": "ChangeProposalStatus"
|
||||||
|
},
|
||||||
"CitationConfidence": {
|
"CitationConfidence": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
@@ -5662,6 +6186,73 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "PersonUpdate"
|
"title": "PersonUpdate"
|
||||||
},
|
},
|
||||||
|
"ProposalOperation": {
|
||||||
|
"properties": {
|
||||||
|
"op": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Op"
|
||||||
|
},
|
||||||
|
"entity_type": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Entity Type"
|
||||||
|
},
|
||||||
|
"entity_id": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Entity Id"
|
||||||
|
},
|
||||||
|
"payload": {
|
||||||
|
"additionalProperties": true,
|
||||||
|
"type": "object",
|
||||||
|
"title": "Payload",
|
||||||
|
"default": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"op",
|
||||||
|
"entity_type"
|
||||||
|
],
|
||||||
|
"title": "ProposalOperation"
|
||||||
|
},
|
||||||
|
"ProposalReview": {
|
||||||
|
"properties": {
|
||||||
|
"note": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Note"
|
||||||
|
},
|
||||||
|
"operations": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ProposalOperation"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Operations"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"title": "ProposalReview"
|
||||||
|
},
|
||||||
"PublicTreeRead": {
|
"PublicTreeRead": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": {
|
"id": {
|
||||||
|
|||||||
Reference in New Issue
Block a user