From 2721d890ece00c27dd764d56bb623fe9b7b1e19c Mon Sep 17 00:00:00 2001 From: xarmian Date: Sat, 30 May 2026 20:16:04 -0400 Subject: [PATCH] feat(web): bulk-mutation API client method + types (TASK-1669) (#671) * feat(web): bulk-mutation API client method + types (TASK-1669) Add api.items.bulk(ws, data) hitting POST /workspaces/{ws}/items/bulk (TASK-1668), plus types in lib/types: BulkItemOp, a discriminated-union BulkItemsRequest (each verb requires only its own params), and BulkItemOutcome / BulkItemFailure / BulkItemsResponse for the per-row outcome envelope. Wire contract matches the backend struct field-for- field; `details` is unknown (server json.RawMessage). Unblocks the lane-header bulk-action UI (TASK-1672). * fix(web): assign bulk op ids are string-only, not nullable per Codex review (round 1) The server treats JSON null as absent (mirrors ItemUpdate) and rejects an assign with no real id and no clear flag, so a nullable type would let a caller type-check `assigned_user_id: null` into a 400. Type these string- only and document that clearing goes through the clear flags. --- web/src/lib/api/client.ts | 22 ++++++++++++ web/src/lib/types/index.ts | 68 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index b577ad52..2486ed2f 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -7,6 +7,8 @@ import type { CollectionUpdate, Backlink, Item, + BulkItemsRequest, + BulkItemsResponse, ItemChangeRow, ItemChangesResponse, ItemCreate, @@ -710,6 +712,26 @@ export const api = { method: 'DELETE' }), + /** + * Apply one mutation verb (archive / move / tag / untag / + * set-priority / assign) to many items in a single request + * (TASK-1668 / TASK-1669). Editor/owner gated. + * + * Emits ONE `items_bulk_updated` SSE event per affected collection + * + one webhook instead of per-item fan-out — used by the lane- + * header bulk actions, which operate on a whole filtered lane. + * + * The call resolves 200 even when some rows fail: inspect the + * `failed` array (each carries `error` and, for structured + * rejections like `open_children`, `code` + `details`). `updated` + * lists the rows that changed and `total === updated + failed`. + */ + bulk: (ws: string, data: BulkItemsRequest) => + request(`/workspaces/${ws}/items/bulk`, { + method: 'POST', + body: JSON.stringify(data) + }), + restore: (ws: string, slug: string) => request(`/workspaces/${ws}/items/${slug}/restore`, { method: 'POST' diff --git a/web/src/lib/types/index.ts b/web/src/lib/types/index.ts index 1e07660a..c349ad31 100644 --- a/web/src/lib/types/index.ts +++ b/web/src/lib/types/index.ts @@ -583,6 +583,74 @@ export interface ItemUpdate { force?: boolean; } +// ─── Bulk mutation (TASK-1668 / TASK-1669) ─────────────────────────────────── + +// The verbs the bulk endpoint accepts. One mutation applied to many items +// in a single request (POST /workspaces/{ws}/items/bulk), emitting one SSE +// event per affected collection + one webhook instead of per-item fan-out. +export type BulkItemOp = + | 'archive' + | 'move' + | 'tag' + | 'untag' + | 'set-priority' + | 'assign'; + +// `BulkItemsRequest` is a discriminated union on `op` so each verb only +// accepts (and requires) its own params. `ids` are item refs (TASK-5) or +// UUIDs. `force` overrides the open-children guard on status-bearing moves +// (mirrors ItemUpdate.force). `move` requires status and/or collection +// (target slug) — kept both-optional here; the server validates the +// at-least-one rule. +export type BulkItemsRequest = + | { op: 'archive'; ids: string[]; force?: boolean } + | { + op: 'move'; + ids: string[]; + status?: string; + collection?: string; + force?: boolean; + } + | { op: 'set-priority'; ids: string[]; priority: string; force?: boolean } + | { op: 'tag' | 'untag'; ids: string[]; tags: string[]; force?: boolean } + | { + op: 'assign'; + ids: string[]; + // Set an assignee/role by id. To CLEAR, use the clear flags — + // the server treats JSON null as absent (mirrors ItemUpdate), + // so `assigned_user_id: null` would be rejected, not a clear. + assigned_user_id?: string; + agent_role_id?: string; + clear_assigned_user?: boolean; + clear_agent_role?: boolean; + force?: boolean; + }; + +// One successfully-mutated row. +export interface BulkItemOutcome { + ref: string; + id: string; +} + +// One row that failed. `code`/`details` carry the structured server error +// when present (e.g. `open_children` with the blocking-child list); `error` +// is always the human-readable message. +export interface BulkItemFailure { + ref: string; + error: string; + code?: string; + details?: unknown; +} + +// Per-row outcome envelope. The HTTP call resolves 200 even on partial +// failure — branch on `failed` to react. `total === updated + failed`. +export interface BulkItemsResponse { + op: BulkItemOp; + updated: BulkItemOutcome[]; + failed: BulkItemFailure[]; + total: number; +} + // ─── Versions ──────────────────────────────────────────────────────────────── export interface Version {