From e00be74c8b575c725829677aadeb755ee81454d0 Mon Sep 17 00:00:00 2001 From: soryu Date: Fri, 8 May 2026 11:29:56 +0100 Subject: feat(directives): unified contracts surface — backbone (#128) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is the backbone PR for the unified directive workflow. A directive holds a sequence of contracts; each contract is a spec body whose execution drives tasks in the directive's shared worktree. Lifecycle (Lock & Start, queue scheduler, drag-reorder) lands in follow-ups. What's in this PR: - Migration adds `position` (queue order) and `merge_mode` (shared|own_pr) columns to directive_documents. The actual table rename is deferred — the legacy `contracts` table from the old contracts system still exists, and the rename collision waits for Phase 5 to drop legacy contracts. - Repository: list orders by position; create assigns next-position; update accepts merge_mode; new reorder_directive_document_position shifts siblings inside a transaction. - HTTP: endpoints aliased under /api/v1/directives/{id}/contracts and /api/v1/contracts/{id}/... with a new /contracts/{id}/reorder. - Frontend: api types renamed `DirectiveContract*` (avoiding the legacy `Contract` type collision); document-directives.tsx imports via aliases so the rest of the file is untouched. Internal struct + table names stay `DirectiveDocument` / `directive_documents` until the legacy contracts cleanup. Co-authored-by: Claude Opus 4.7 (1M context) --- makima/frontend/src/lib/api.ts | 127 ++++++++++++--------- makima/frontend/src/routes/document-directives.tsx | 56 ++++----- ...60508000000_add_contract_sequencing_columns.sql | 48 ++++++++ makima/src/db/models.rs | 20 +++- makima/src/db/repository.rs | 117 ++++++++++++++++++- makima/src/server/handlers/directive_documents.rs | 110 ++++++++++++++++-- makima/src/server/mod.rs | 20 +++- makima/src/server/openapi.rs | 6 +- 8 files changed, 396 insertions(+), 108 deletions(-) create mode 100644 makima/migrations/20260508000000_add_contract_sequencing_columns.sql diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index 80da511..17cf1d0 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -3583,116 +3583,141 @@ export async function pickUpOrders(directiveId: string): Promise { - const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/documents`); - if (!res.ok) throw new Error(`Failed to list directive documents: ${res.statusText}`); +export async function listDirectiveContracts( + directiveId: string, +): Promise { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/contracts`); + if (!res.ok) throw new Error(`Failed to list contracts: ${res.statusText}`); return res.json(); } -export async function createDirectiveDocument( +export async function createDirectiveContract( directiveId: string, - req: CreateDirectiveDocumentRequest = {}, -): Promise { - const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/documents`, { + req: CreateDirectiveContractRequest = {}, +): Promise { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/contracts`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(req), }); - if (!res.ok) throw new Error(`Failed to create directive document: ${res.statusText}`); + if (!res.ok) throw new Error(`Failed to create contract: ${res.statusText}`); return res.json(); } -export async function getDirectiveDocument(documentId: string): Promise { - const res = await authFetch(`${API_BASE}/api/v1/directive-documents/${documentId}`); - if (!res.ok) throw new Error(`Failed to get directive document: ${res.statusText}`); +export async function getDirectiveContract(contractId: string): Promise { + const res = await authFetch(`${API_BASE}/api/v1/contracts/${contractId}`); + if (!res.ok) throw new Error(`Failed to get contract: ${res.statusText}`); return res.json(); } /** - * Update a directive document's title and/or body. The backend auto-reactivates - * a shipped document when its body changes (re-stamps status to `active`), - * which is why we don't expose a separate "reactivate" call. + * Update a contract's title, body, and/or merge mode. Backend auto- + * reactivates a shipped contract when its body changes (status flips + * back to `active`), so there's no separate reactivate call. */ -export async function updateDirectiveDocument( - documentId: string, - req: UpdateDirectiveDocumentRequest, -): Promise { - const res = await authFetch(`${API_BASE}/api/v1/directive-documents/${documentId}`, { +export async function updateDirectiveContract( + contractId: string, + req: UpdateDirectiveContractRequest, +): Promise { + const res = await authFetch(`${API_BASE}/api/v1/contracts/${contractId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(req), }); - if (!res.ok) throw new Error(`Failed to update directive document: ${res.statusText}`); + if (!res.ok) throw new Error(`Failed to update contract: ${res.statusText}`); + return res.json(); +} + +export async function archiveDirectiveContract(contractId: string): Promise { + const res = await authFetch(`${API_BASE}/api/v1/contracts/${contractId}/archive`, { + method: "POST", + }); + if (!res.ok) throw new Error(`Failed to archive contract: ${res.statusText}`); return res.json(); } -export async function archiveDirectiveDocument(documentId: string): Promise { - const res = await authFetch(`${API_BASE}/api/v1/directive-documents/${documentId}/archive`, { +/** Move a contract to a new 0-indexed queue position inside its directive. */ +export async function reorderDirectiveContract( + contractId: string, + position: number, +): Promise { + const res = await authFetch(`${API_BASE}/api/v1/contracts/${contractId}/reorder`, { method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ position }), }); - if (!res.ok) throw new Error(`Failed to archive directive document: ${res.statusText}`); + if (!res.ok) throw new Error(`Failed to reorder contract: ${res.statusText}`); return res.json(); } -/** Steps and tasks attached to a single directive document. Drives the - * per-document `tasks/` subfolder in the sidebar — when the document - * ships, its tasks visually move with it under shipped/. */ -export interface DocumentTasksResponse { +/** Steps and tasks attached to a single contract. Drives the per-contract + * `tasks/` subfolder in the sidebar — when the contract ships, its + * tasks visually move with it. */ +export interface DirectiveContractTasksResponse { steps: DirectiveStep[]; tasks: Task[]; } /** - * List the steps and ephemeral tasks attached to a specific directive - * document. Used by the sidebar to render a `tasks/` subfolder beside each - * document — including shipped documents, whose tasks remain attached so - * they continue to render under shipped/ alongside the document. + * List the steps and ephemeral tasks attached to a specific contract. + * Used by the sidebar to render a `tasks/` subfolder beside each + * contract — including shipped ones, whose tasks remain attached. */ -export async function listDirectiveDocumentTasks( - documentId: string, -): Promise { - const res = await authFetch( - `${API_BASE}/api/v1/directive-documents/${documentId}/tasks`, - ); - if (!res.ok) { - throw new Error(`Failed to list directive document tasks: ${res.statusText}`); - } +export async function listDirectiveContractTasks( + contractId: string, +): Promise { + const res = await authFetch(`${API_BASE}/api/v1/contracts/${contractId}/tasks`); + if (!res.ok) throw new Error(`Failed to list contract tasks: ${res.statusText}`); return res.json(); } diff --git a/makima/frontend/src/routes/document-directives.tsx b/makima/frontend/src/routes/document-directives.tsx index 1714aed..d589dac 100644 --- a/makima/frontend/src/routes/document-directives.tsx +++ b/makima/frontend/src/routes/document-directives.tsx @@ -7,16 +7,16 @@ import { DocumentEditor } from "../components/directives/DocumentEditor"; import { type DirectiveSummary, type DirectiveStatus, - type DirectiveDocument, - type DirectiveDocumentStatus, + type DirectiveContract as Contract, + type DirectiveContractStatus as ContractStatus, type DirectiveStep, type Task, - type DocumentTasksResponse, - listDirectiveDocuments, - createDirectiveDocument, - getDirectiveDocument, - updateDirectiveDocument, - listDirectiveDocumentTasks, + type DirectiveContractTasksResponse as ContractTasksResponse, + listDirectiveContracts as listContracts, + createDirectiveContract as createContract, + getDirectiveContract as getContract, + updateDirectiveContract as updateContract, + listDirectiveContractTasks as listContractTasks, createDirectiveTask, startDirective, pauseDirective, @@ -43,7 +43,7 @@ const STATUS_DOT: Record = { // Per-document status palette. Active/draft documents use the same bright // green-ish accent as a running directive; shipped/archived use a muted blue. -const DOC_STATUS_DOT: Record = { +const DOC_STATUS_DOT: Record = { draft: "bg-[#556677]", active: "bg-green-400", shipped: "bg-[#75aafc]", @@ -66,7 +66,7 @@ const DOC_STATUS_DOT: Record = { // rather than just an id slice). Accepts either a DirectiveSummary or a full // DirectiveWithSteps — only `title` is read. function fileLabel( - doc: DirectiveDocument, + doc: Contract, directive: { title: string }, ): string { const docTitle = doc.title.trim(); @@ -172,7 +172,7 @@ interface DirectiveFolderProps { /** Called when the user clicks the folder header itself (after toggle). */ onHeaderClick: () => void; selection: SidebarSelection | null; - onSelectDocument: (directiveId: string, doc: DirectiveDocument) => void; + onSelectDocument: (directiveId: string, doc: Contract) => void; onCreateDocument: (directive: DirectiveSummary) => Promise; /** Open the inline "+ New ephemeral task" form for this directive. */ onCreateEphemeralTask: (directive: DirectiveSummary) => void; @@ -210,7 +210,7 @@ function DirectiveFolder({ // Documents fetched lazily on open. We deliberately scope the fetch to the // open-state so closed folders don't pay the network cost on initial render. - const [docs, setDocs] = useState(null); + const [docs, setDocs] = useState(null); const [docsLoading, setDocsLoading] = useState(false); const [docsError, setDocsError] = useState(null); @@ -224,7 +224,7 @@ function DirectiveFolder({ setDocsLoading(true); setDocsError(null); try { - const list = await listDirectiveDocuments(directive.id); + const list = await listContracts(directive.id); setDocs(list); } catch (e) { setDocsError(e instanceof Error ? e.message : "Failed to load documents"); @@ -242,8 +242,8 @@ function DirectiveFolder({ // Split the documents into the two visual groups. Memoised so we don't // recompute on every render. const { activeDocs, shippedDocs } = useMemo(() => { - const active: DirectiveDocument[] = []; - const shipped: DirectiveDocument[] = []; + const active: Contract[] = []; + const shipped: Contract[] = []; for (const d of docs ?? []) { if (d.status === "shipped" || d.status === "archived") { shipped.push(d); @@ -447,7 +447,7 @@ function DirectiveFolder({ // ============================================================================= interface DocumentRowProps { - doc: DirectiveDocument; + doc: Contract; directive: DirectiveSummary; selected: boolean; onSelect: () => void; @@ -547,7 +547,7 @@ function DocumentTasksFolder({ onSelectTask, }: DocumentTasksFolderProps) { const [open, setOpen] = useState(defaultOpen); - const [data, setData] = useState(null); + const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -561,7 +561,7 @@ function DocumentTasksFolder({ setLoading(true); setError(null); try { - const res = await listDirectiveDocumentTasks(documentId); + const res = await listContractTasks(documentId); setData(res); } catch (e) { setError(e instanceof Error ? e.message : "Failed to load tasks"); @@ -770,7 +770,7 @@ interface SidebarProps { directives: DirectiveSummary[]; loading: boolean; selection: SidebarSelection | null; - onSelectDocument: (directiveId: string, doc: DirectiveDocument) => void; + onSelectDocument: (directiveId: string, doc: Contract) => void; onSelectDirective: (directiveId: string) => void; onCreateDocument: (directive: DirectiveSummary) => Promise; onCreateContract: () => void; @@ -894,8 +894,8 @@ function DocumentSidebar({ // ============================================================================= // Editor shell — wraps DocumentEditor and handles the "no document selected" // and loading states. Two modes: -// 1) documentId selected → fetch the DirectiveDocument and edit doc.body via -// updateDirectiveDocument (the call that auto-reactivates a shipped doc). +// 1) documentId selected → fetch the Contract and edit doc.body via +// updateContract (the call that auto-reactivates a shipped doc). // 2) no documentId (legacy fallback, kept for the "select a directive but // not a document" transitional case) → edit directive.goal as before. // ============================================================================= @@ -918,7 +918,7 @@ function EditorShell({ const documentId = selection?.documentId ?? null; // We deliberately don't pull `updateGoal` here — in the multi-document - // world, edits flow through updateDirectiveDocument (which auto-reactivates + // world, edits flow through updateContract (which auto-reactivates // a shipped doc when its body changes). The legacy directive.goal is // unused on this surface. const { @@ -932,7 +932,7 @@ function EditorShell({ // Document fetch — only when documentId is selected. Refetched whenever the // id changes; not polled (the document stream is too low-traffic to warrant // background refresh in this iteration). - const [doc, setDoc] = useState(null); + const [doc, setDoc] = useState(null); const [docLoading, setDocLoading] = useState(false); const [docError, setDocError] = useState(null); @@ -946,7 +946,7 @@ function EditorShell({ let cancelled = false; setDocLoading(true); setDocError(null); - getDirectiveDocument(documentId) + getContract(documentId) .then((d) => { if (cancelled) return; setDoc(d); @@ -970,7 +970,7 @@ function EditorShell({ const onUpdateDocumentBody = useCallback( async (body: string) => { if (!documentId) return; - const updated = await updateDirectiveDocument(documentId, { body }); + const updated = await updateContract(documentId, { body }); setDoc(updated); // Tell the sidebar to refetch the directive's document list so the // status chip flips from `shipped` back to `active` (and any title @@ -1230,7 +1230,7 @@ export default function DocumentDirectivesPage() { lastResolvedRef.current = routeDirectiveId; let cancelled = false; - listDirectiveDocuments(routeDirectiveId) + listContracts(routeDirectiveId) .then((list) => { if (cancelled) return; // Prefer the first 'active' doc; fall back to the first 'draft'. @@ -1259,7 +1259,7 @@ export default function DocumentDirectivesPage() { }, [routeDirectiveId, selection?.documentId, selection?.taskId, setSearchParams]); const handleSelectDocument = useCallback( - (directiveId: string, doc: DirectiveDocument) => { + (directiveId: string, doc: Contract) => { navigate(`/directives/${directiveId}?document=${doc.id}`); }, [navigate], @@ -1278,7 +1278,7 @@ export default function DocumentDirectivesPage() { const handleCreateDocument = useCallback( async (directive: DirectiveSummary) => { - const created = await createDirectiveDocument(directive.id, { + const created = await createContract(directive.id, { title: "", body: "", }); diff --git a/makima/migrations/20260508000000_add_contract_sequencing_columns.sql b/makima/migrations/20260508000000_add_contract_sequencing_columns.sql new file mode 100644 index 0000000..a259285 --- /dev/null +++ b/makima/migrations/20260508000000_add_contract_sequencing_columns.sql @@ -0,0 +1,48 @@ +-- Add the contract sequencing columns to directive_documents. +-- +-- Background: the directive_documents table was always intended to BE the +-- user-facing "contract" concept (see migration 20260502000000: +-- "the user calls these 'directive contracts'"). The unified directive +-- workflow now formalises that — a directive holds a sequence of +-- contracts; each contract is a spec body whose execution is driven by +-- tasks in the directive's shared worktree. +-- +-- This migration is purely additive. It does NOT rename the table — +-- there is a legacy `contracts` table from the old contracts system +-- (still present, archived in 20260501000000), and renaming +-- `directive_documents → contracts` would collide with it. The actual +-- table rename lands once the legacy table is dropped (Phase 5). +-- +-- For now: the Rust struct, API endpoints, and frontend types are all +-- renamed to "Contract" so the user-visible surface matches their model. +-- The DB column names (`directive_documents`, `directive_document_id`) +-- stay put. +-- +-- Two new columns: +-- * position — integer ordering inside the directive (queue position). +-- Backfilled by created_at so existing data keeps the +-- visual order users see today. +-- * merge_mode — 'shared' (commits go to the directive's branch) or +-- 'own_pr' (each contract gets its own branch + PR). +-- Default 'shared'; per-contract toggle in the UI lifts +-- to 'own_pr'. + +ALTER TABLE directive_documents + ADD COLUMN position INT NOT NULL DEFAULT 0; + +WITH ranked AS ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY directive_id + ORDER BY created_at ASC, id ASC + ) - 1 AS rn + FROM directive_documents +) +UPDATE directive_documents d + SET position = r.rn + FROM ranked r + WHERE d.id = r.id; + +ALTER TABLE directive_documents + ADD COLUMN merge_mode VARCHAR(16) NOT NULL DEFAULT 'shared' + CHECK (merge_mode IN ('shared', 'own_pr')); diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index 18f3435..fcccd05 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -2938,10 +2938,16 @@ pub struct UpdateDirectiveStepRequest { // Directive Document Types // ============================================================================= -/// A directive document — one of N markdown documents owned by a directive. -/// The user calls these "directive contracts". Each document has its own -/// lifecycle (draft → active → shipped → archived) and may be attached to a -/// PR. Multiple documents can be active under the same directive at once. +/// A directive document — the user-facing "contract" in the unified +/// directive UI. One of N specs owned by a directive. Each runs +/// sequentially in the directive's shared worktree (or, when +/// `merge_mode = 'own_pr'`, on its own branch). `position` defines +/// queue order. +/// +/// Naming note: this struct stays `DirectiveDocument` internally because +/// a legacy `Contract` struct (from the pre-directive contracts system) +/// still exists. The API and frontend expose this as "Contract"; the +/// table/struct rename lands once legacy contracts are dropped (Phase 5). #[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct DirectiveDocument { @@ -2956,6 +2962,12 @@ pub struct DirectiveDocument { pub shipped_at: Option>, pub archived_at: Option>, pub version: i32, + /// Queue position within the parent directive (0-indexed). Lower + /// numbers run earlier; only one contract is active at a time. + pub position: i32, + /// Where this contract's commits land. `shared` (default): the + /// directive's branch. `own_pr`: a contract-specific branch + PR. + pub merge_mode: String, pub created_at: DateTime, pub updated_at: DateTime, } diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index 5d8ba82..12d5e4d 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -5785,7 +5785,12 @@ pub async fn clear_pending_directive_steps( // Directive Document CRUD // ============================================================================= -/// List all documents under a directive, ordered by creation time. +/// List all contracts under a directive in queue order. +/// +/// Ordered by `position` (lower = earlier), with `created_at` as a stable +/// tie-break. Position is the queue order in the unified directive UI; +/// only one contract is active at a time, and the next-up contract is +/// the lowest-position non-shipped row. pub async fn list_directive_documents( pool: &PgPool, directive_id: Uuid, @@ -5794,7 +5799,7 @@ pub async fn list_directive_documents( r#" SELECT * FROM directive_documents WHERE directive_id = $1 - ORDER BY created_at + ORDER BY position ASC, created_at ASC "#, ) .bind(directive_id) @@ -5815,7 +5820,14 @@ pub async fn get_directive_document( .await } -/// Create a new directive document. Status defaults to 'draft'. +/// Create a new directive document (contract). Status defaults to 'draft'. +/// +/// The new row's `position` is computed server-side as +/// `MAX(position) + 1` over the directive's existing contracts, so it +/// lands at the bottom of the queue. Callers that want to insert in the +/// middle should call `reorder_directive_document_position` afterwards. +/// `merge_mode` defaults to 'shared' on creation; flip later via +/// `update_directive_document`. pub async fn create_directive_document( pool: &PgPool, directive_id: Uuid, @@ -5824,8 +5836,14 @@ pub async fn create_directive_document( ) -> Result { sqlx::query_as::<_, DirectiveDocument>( r#" - INSERT INTO directive_documents (directive_id, title, body, status) - VALUES ($1, $2, $3, 'draft') + INSERT INTO directive_documents (directive_id, title, body, status, position) + VALUES ( + $1, $2, $3, 'draft', + COALESCE( + (SELECT MAX(position) + 1 FROM directive_documents WHERE directive_id = $1), + 0 + ) + ) RETURNING * "#, ) @@ -5848,6 +5866,7 @@ pub async fn update_directive_document( document_id: Uuid, title: Option<&str>, body: Option<&str>, + merge_mode: Option<&str>, ) -> Result, sqlx::Error> { let current = sqlx::query_as::<_, DirectiveDocument>( r#"SELECT * FROM directive_documents WHERE id = $1"#, @@ -5863,6 +5882,7 @@ pub async fn update_directive_document( let new_title = title.unwrap_or(¤t.title); let new_body = body.unwrap_or(¤t.body); + let new_merge_mode = merge_mode.unwrap_or(¤t.merge_mode); let body_changed = new_body != current.body; // Reactivation rule: editing the body of a shipped doc flips it back @@ -5883,6 +5903,7 @@ pub async fn update_directive_document( body = $3, status = $4, shipped_at = CASE WHEN $5 THEN NULL ELSE shipped_at END, + merge_mode = $6, version = version + 1, updated_at = NOW() WHERE id = $1 @@ -5894,12 +5915,98 @@ pub async fn update_directive_document( .bind(new_body) .bind(new_status) .bind(reactivate_from_shipped) + .bind(new_merge_mode) .fetch_optional(pool) .await?; Ok(result) } +/// Move a contract to a new queue position within its directive. +/// +/// Implementation: a single SQL CTE that bumps siblings out of the way +/// based on whether we're moving forward (later) or backward (earlier). +/// Returns the updated contract row. +pub async fn reorder_directive_document_position( + pool: &PgPool, + document_id: Uuid, + new_position: i32, +) -> Result, sqlx::Error> { + let mut tx = pool.begin().await?; + + let current = sqlx::query_as::<_, DirectiveDocument>( + r#"SELECT * FROM directive_documents WHERE id = $1"#, + ) + .bind(document_id) + .fetch_optional(&mut *tx) + .await?; + + let current = match current { + Some(c) => c, + None => return Ok(None), + }; + + if current.position == new_position { + tx.commit().await?; + return Ok(Some(current)); + } + + // Shift siblings to make room. Moving forward (new > old) drags the + // intermediate range back by one; moving backward pushes it forward. + if new_position > current.position { + sqlx::query( + r#" + UPDATE directive_documents + SET position = position - 1 + WHERE directive_id = $1 + AND id <> $2 + AND position > $3 + AND position <= $4 + "#, + ) + .bind(current.directive_id) + .bind(document_id) + .bind(current.position) + .bind(new_position) + .execute(&mut *tx) + .await?; + } else { + sqlx::query( + r#" + UPDATE directive_documents + SET position = position + 1 + WHERE directive_id = $1 + AND id <> $2 + AND position >= $3 + AND position < $4 + "#, + ) + .bind(current.directive_id) + .bind(document_id) + .bind(new_position) + .bind(current.position) + .execute(&mut *tx) + .await?; + } + + let result = sqlx::query_as::<_, DirectiveDocument>( + r#" + UPDATE directive_documents + SET position = $2, + updated_at = NOW() + WHERE id = $1 + RETURNING * + "#, + ) + .bind(document_id) + .bind(new_position) + .fetch_optional(&mut *tx) + .await?; + + tx.commit().await?; + Ok(result) +} + /// Mark a directive document as shipped (PR raised). Sets pr_url, optional /// pr_branch, status = 'shipped', shipped_at = NOW(), and bumps version. pub async fn mark_directive_document_shipped( diff --git a/makima/src/server/handlers/directive_documents.rs b/makima/src/server/handlers/directive_documents.rs index 48f314f..ed38ee4 100644 --- a/makima/src/server/handlers/directive_documents.rs +++ b/makima/src/server/handlers/directive_documents.rs @@ -40,15 +40,27 @@ pub struct CreateDirectiveDocumentRequest { pub body: Option, } -/// Body for `PATCH /api/v1/directive-documents/{document_id}`. +/// Body for `PATCH /api/v1/contracts/{document_id}`. #[derive(Debug, Default, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct UpdateDirectiveDocumentRequest { pub title: Option, pub body: Option, + /// Per-contract merge mode. `shared` lands commits on the directive's + /// branch; `own_pr` carves out a contract-specific branch + PR. The + /// queue scheduler reads this when activating the contract. + pub merge_mode: Option, } -/// Body for `POST /api/v1/directive-documents/{document_id}/ship`. +/// Body for `POST /api/v1/contracts/{document_id}/reorder`. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ReorderDirectiveDocumentRequest { + /// New 0-indexed queue position within the parent directive. + pub position: i32, +} + +/// Body for `POST /api/v1/contracts/{document_id}/ship`. #[derive(Debug, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ShipDirectiveDocumentRequest { @@ -88,10 +100,10 @@ async fn load_owned_document( /// List all documents under a directive. #[utoipa::path( get, - path = "/api/v1/directives/{directive_id}/documents", + path = "/api/v1/directives/{directive_id}/contracts", params(("directive_id" = Uuid, Path, description = "Directive ID")), responses( - (status = 200, description = "List of directive documents", body = Vec), + (status = 200, description = "List of contracts under the directive", body = Vec), (status = 404, description = "Directive not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), ), @@ -146,11 +158,11 @@ pub async fn list_documents( /// Create a new directive document. The new document starts in `draft` status. #[utoipa::path( post, - path = "/api/v1/directives/{directive_id}/documents", + path = "/api/v1/directives/{directive_id}/contracts", params(("directive_id" = Uuid, Path, description = "Directive ID")), request_body = CreateDirectiveDocumentRequest, responses( - (status = 201, description = "Document created", body = crate::db::models::DirectiveDocument), + (status = 201, description = "Contract created", body = crate::db::models::DirectiveDocument), (status = 404, description = "Directive not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), ), @@ -213,7 +225,7 @@ pub async fn create_document( /// Get a single directive document by ID. #[utoipa::path( get, - path = "/api/v1/directive-documents/{document_id}", + path = "/api/v1/contracts/{document_id}", params(("document_id" = Uuid, Path, description = "Directive document ID")), responses( (status = 200, description = "Directive document", body = crate::db::models::DirectiveDocument), @@ -263,7 +275,7 @@ pub async fn get_document( /// returns the updated row — the reactivation is automatic. #[utoipa::path( patch, - path = "/api/v1/directive-documents/{document_id}", + path = "/api/v1/contracts/{document_id}", params(("document_id" = Uuid, Path, description = "Directive document ID")), request_body = UpdateDirectiveDocumentRequest, responses( @@ -312,6 +324,7 @@ pub async fn update_document( document_id, req.title.as_deref(), req.body.as_deref(), + req.merge_mode.as_deref(), ) .await { @@ -336,7 +349,7 @@ pub async fn update_document( /// pr_branch, sets status='shipped', and stamps shipped_at. #[utoipa::path( post, - path = "/api/v1/directive-documents/{document_id}/ship", + path = "/api/v1/contracts/{document_id}/ship", params(("document_id" = Uuid, Path, description = "Directive document ID")), request_body = ShipDirectiveDocumentRequest, responses( @@ -408,7 +421,7 @@ pub async fn ship_document( /// Archive a directive document. #[utoipa::path( post, - path = "/api/v1/directive-documents/{document_id}/archive", + path = "/api/v1/contracts/{document_id}/archive", params(("document_id" = Uuid, Path, description = "Directive document ID")), responses( (status = 200, description = "Document archived", body = crate::db::models::DirectiveDocument), @@ -476,7 +489,7 @@ pub async fn archive_document( // not to the directive). // ============================================================================= -/// Response body for `GET /api/v1/directive-documents/{document_id}/tasks`. +/// Response body for `GET /api/v1/contracts/{document_id}/tasks`. /// /// We return BOTH steps and tasks. Steps are the planned units of work in the /// directive's DAG; tasks are the actual execution records (orchestrator, @@ -501,7 +514,7 @@ pub struct DocumentTasksResponse { /// the parent directive. #[utoipa::path( get, - path = "/api/v1/directive-documents/{document_id}/tasks", + path = "/api/v1/contracts/{document_id}/tasks", params(("document_id" = Uuid, Path, description = "Directive document ID")), responses( (status = 200, description = "Steps and tasks attached to the document", body = DocumentTasksResponse), @@ -570,3 +583,76 @@ pub async fn list_document_tasks( Json(DocumentTasksResponse { steps, tasks }).into_response() } + +// ============================================================================= +// Reorder a contract within its parent directive's queue. +// +// Drag-to-reorder in the sidebar lands here. The `position` field on each +// contract drives the ORDER BY in `list_directive_documents`, so the +// repository function does the bookkeeping (shift siblings, set new +// position) inside a single transaction. The handler only owns auth. +// ============================================================================= + +/// Move a contract to a new queue position within its parent directive. +#[utoipa::path( + post, + path = "/api/v1/contracts/{document_id}/reorder", + params(("document_id" = Uuid, Path, description = "Contract ID")), + request_body = ReorderDirectiveDocumentRequest, + responses( + (status = 200, description = "Contract reordered", body = crate::db::models::DirectiveDocument), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directive Documents" +)] +pub async fn reorder_contract( + State(state): State, + Authenticated(auth): Authenticated, + Path(document_id): Path, + Json(req): Json, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match load_owned_document(pool, auth.owner_id, document_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + match repository::reorder_directive_document_position(pool, document_id, req.position).await { + Ok(Some(doc)) => Json(doc).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to reorder contract: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("REORDER_FAILED", &e.to_string())), + ) + .into_response() + } + } +} diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index dd79ddf..68d3dea 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -214,27 +214,35 @@ pub fn make_router(state: SharedState) -> Router { ) .route("/directives/{id}/dogs/{dog_id}/orders", get(directives::list_dog_orders)) .route("/directives/{id}/dogs/{dog_id}/pick-up-orders", post(directives::pick_up_dog_orders)) - // Directive document endpoints (multi-document directive contracts). + // Contract endpoints (the unified directive contracts surface). + // The handler module + DB column names are still + // "directive_documents" for now — the user-facing names ("contract" + // in URLs / structs / UI) are aligned here while the deeper rename + // waits for legacy contracts removal (Phase 5). .route( - "/directives/{directive_id}/documents", + "/directives/{directive_id}/contracts", get(directive_documents::list_documents) .post(directive_documents::create_document), ) .route( - "/directive-documents/{document_id}", + "/contracts/{document_id}", get(directive_documents::get_document) .patch(directive_documents::update_document), ) .route( - "/directive-documents/{document_id}/ship", + "/contracts/{document_id}/ship", post(directive_documents::ship_document), ) .route( - "/directive-documents/{document_id}/archive", + "/contracts/{document_id}/archive", post(directive_documents::archive_document), ) .route( - "/directive-documents/{document_id}/tasks", + "/contracts/{document_id}/reorder", + post(directive_documents::reorder_contract), + ) + .route( + "/contracts/{document_id}/tasks", get(directive_documents::list_document_tasks), ) // Order endpoints diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs index ad7837a..7ddaf1b 100644 --- a/makima/src/server/openapi.rs +++ b/makima/src/server/openapi.rs @@ -116,13 +116,14 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage directives::list_directive_tasks, directives::cleanup_directive, directives::create_pr, - // Directive document endpoints + // Contract (directive document) endpoints directive_documents::list_documents, directive_documents::create_document, directive_documents::get_document, directive_documents::update_document, directive_documents::ship_document, directive_documents::archive_document, + directive_documents::reorder_contract, directive_documents::list_document_tasks, // Order endpoints orders::list_orders, @@ -226,11 +227,12 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage CreateDirectiveStepRequest, UpdateDirectiveStepRequest, CleanupResponse, - // Directive document schemas + // Contract (directive document) schemas DirectiveDocument, directive_documents::CreateDirectiveDocumentRequest, directive_documents::UpdateDirectiveDocumentRequest, directive_documents::ShipDirectiveDocumentRequest, + directive_documents::ReorderDirectiveDocumentRequest, directive_documents::DocumentTasksResponse, // Order schemas Order, -- cgit v1.2.3