diff --git a/apps/desktop/src/renderer/src/routes.tsx b/apps/desktop/src/renderer/src/routes.tsx index 8810d36cedc..903c7a29b0e 100644 --- a/apps/desktop/src/renderer/src/routes.tsx +++ b/apps/desktop/src/renderer/src/routes.tsx @@ -23,6 +23,7 @@ import { DashboardPage } from "@multica/views/dashboard"; import { AutopilotsPage } from "@multica/views/autopilots/components"; import { MyIssuesPage } from "@multica/views/my-issues"; import { TasksPage } from "@multica/views/tasks"; +import { DraftsPage } from "@multica/views/drafts"; import { ShipPage, ShipReleasePage } from "@multica/views/ship"; import { SkillsPage } from "@multica/views/skills"; import { DesktopRuntimesPage } from "./components/desktop-runtimes-page"; @@ -165,6 +166,17 @@ export const appRoutes: RouteObject[] = [ element: , handle: { title: "Task" }, }, + { + // Drafts master-detail. Selection rides on ?draft= (mirrors + // tasks), so there's no separate /drafts/:id route. + path: "drafts", + element: ( + + + + ), + handle: { title: "Drafts" }, + }, { path: "ship", element: , diff --git a/apps/web/app/[workspaceSlug]/(dashboard)/drafts/page.tsx b/apps/web/app/[workspaceSlug]/(dashboard)/drafts/page.tsx new file mode 100644 index 00000000000..389bc84d312 --- /dev/null +++ b/apps/web/app/[workspaceSlug]/(dashboard)/drafts/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { DraftsPage } from "@multica/views/drafts"; +import { ErrorBoundary } from "@multica/ui/components/common/error-boundary"; + +export default function Page() { + return ( + + + + ); +} diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index 28db48a5d4b..eab370b5804 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -5,6 +5,11 @@ import type { ListTasksResponse, CreateTaskRequest, UpdateTaskRequest, + Draft, + ListDraftsParams, + ListDraftsResponse, + CreateDraftRequest, + UpdateDraftRequest, CreateIssueRequest, UpdateIssueRequest, ListIssuesResponse, @@ -294,6 +299,10 @@ import { EMPTY_MCP_SERVER_DIRECTORY_RESPONSE, ListTasksResponseSchema, EMPTY_LIST_TASKS_RESPONSE, + DraftSchema, + ListDraftsResponseSchema, + EMPTY_LIST_DRAFTS_RESPONSE, + EMPTY_DRAFT, } from "./schemas"; /** Identifies the calling client to the server. @@ -791,6 +800,51 @@ export class ApiClient { return this.fetch(`/api/tasks/${id}/promote`, { method: "POST" }); } + // Drafts. Standalone workspace- + owner-scoped markdown documents (slice 0 + // of the Drafts feature). Every read goes through parseWithFallback so a + // server-side shape drift — an older desktop build talking to a newer + // backend — surfaces as the fallback rather than a white-screen. No bare + // `as` on any response body. + async listDrafts(params?: ListDraftsParams): Promise { + const search = new URLSearchParams(); + if (params?.workspace_id) search.set("workspace_id", params.workspace_id); + const raw = await this.fetch(`/api/drafts?${search}`); + return parseWithFallback(raw, ListDraftsResponseSchema, EMPTY_LIST_DRAFTS_RESPONSE, { + endpoint: "GET /api/drafts", + }); + } + + async getDraft(id: string): Promise { + const raw = await this.fetch(`/api/drafts/${id}`); + return parseWithFallback(raw, DraftSchema, EMPTY_DRAFT, { + endpoint: "GET /api/drafts/:id", + }); + } + + async createDraft(data: CreateDraftRequest): Promise { + const raw = await this.fetch("/api/drafts", { + method: "POST", + body: JSON.stringify(data), + }); + return parseWithFallback(raw, DraftSchema, EMPTY_DRAFT, { + endpoint: "POST /api/drafts", + }); + } + + async updateDraft(id: string, data: UpdateDraftRequest): Promise { + const raw = await this.fetch(`/api/drafts/${id}`, { + method: "PATCH", + body: JSON.stringify(data), + }); + return parseWithFallback(raw, DraftSchema, EMPTY_DRAFT, { + endpoint: "PATCH /api/drafts/:id", + }); + } + + async deleteDraft(id: string): Promise { + await this.fetch(`/api/drafts/${id}`, { method: "DELETE" }); + } + async batchUpdateIssues(issueIds: string[], updates: UpdateIssueRequest): Promise<{ updated: number }> { return this.fetch("/api/issues/batch-update", { method: "POST", diff --git a/packages/core/api/schemas.test.ts b/packages/core/api/schemas.test.ts index bc3bff1b8fa..c09487851f1 100644 --- a/packages/core/api/schemas.test.ts +++ b/packages/core/api/schemas.test.ts @@ -3,6 +3,8 @@ import { DashboardUsageDailyListSchema, ListIssuesResponseSchema, PromoteDeployEnvironmentResponseSchema, + DraftSchema, + ListDraftsResponseSchema, } from "./schemas"; const baseIssue = { @@ -110,3 +112,50 @@ describe("PromoteDeployEnvironmentResponseSchema (deploy-to-production)", () => ).toBe(false); }); }); + +// Drafts is a standalone entity behind parseWithFallback on every read. These +// tests pin the contract the schema must hold (API Response Compatibility): +// a malformed row fails closed so the client returns its fallback, an unknown +// status value parses (open enum / enum-drift), and a missing field is caught. +const baseDraft = { + id: "33333333-3333-3333-3333-333333333333", + workspace_id: "ws-1", + owner_user_id: "user-1", + title: "Draft title", + body: "# body", + status: "draft", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}; + +describe("DraftSchema / ListDraftsResponseSchema drift", () => { + it("accepts a well-formed draft and an unknown status value (open enum)", () => { + // A future server value like "accepted" — or anything the frontend has + // never seen — must parse, not crash, so the UI can render a generic + // fallback for it. + const parsed = DraftSchema.parse({ ...baseDraft, status: "some_future_status" }); + expect(parsed.status).toBe("some_future_status"); + }); + + it("rejects a draft missing a required field so parseWithFallback falls back", () => { + const { title: _omit, ...draftMissingTitle } = baseDraft; + expect(DraftSchema.safeParse(draftMissingTitle).success).toBe(false); + }); + + it("rejects a wrong-typed body (number) so the client returns EMPTY_DRAFT", () => { + expect(DraftSchema.safeParse({ ...baseDraft, body: 123 }).success).toBe(false); + }); + + it("defaults a null/missing drafts array to [] instead of throwing", () => { + // An older/newer backend that omits the wrapper array must degrade to an + // empty list, never a non-iterable that crashes the list page. + const parsed = ListDraftsResponseSchema.parse({ total: 0 }); + expect(parsed.drafts).toEqual([]); + }); + + it("rejects a non-array 'drafts' so parseWithFallback can return its fallback", () => { + expect( + ListDraftsResponseSchema.safeParse({ drafts: "nope", total: 0 }).success, + ).toBe(false); + }); +}); diff --git a/packages/core/api/schemas.ts b/packages/core/api/schemas.ts index 091b232724e..f55cdd90f50 100644 --- a/packages/core/api/schemas.ts +++ b/packages/core/api/schemas.ts @@ -16,6 +16,8 @@ import type { TimelineEntry, User, ListTasksResponse, + Draft, + ListDraftsResponse, } from "../types"; // --------------------------------------------------------------------------- @@ -343,6 +345,48 @@ export const EMPTY_LIST_TASKS_RESPONSE: ListTasksResponse = { total: 0, }; +// Drafts. Standalone entity (its own table — not an issue row). Same leniency +// rules as the schemas above: `status` is an open enum kept as `z.string()` so +// a future server value (e.g. "accepted") parses instead of crashing, +// optionals are nullable-tolerant, `.loose()` lets the server add fields later +// without stripping them. Reads go through parseWithFallback at the client +// boundary so an older desktop build talking to a newer backend degrades +// gracefully rather than white-screening. +export const DraftSchema = z.object({ + id: z.string(), + workspace_id: z.string(), + owner_user_id: z.string(), + title: z.string(), + body: z.string(), + status: z.string(), + created_at: z.string(), + updated_at: z.string(), +}).loose(); + +export const ListDraftsResponseSchema = z.object({ + drafts: z.array(DraftSchema).default([]), + total: z.number().default(0), +}).loose(); + +export const EMPTY_LIST_DRAFTS_RESPONSE: ListDraftsResponse = { + drafts: [], + total: 0, +}; + +// Fallback for the single-draft endpoints (get/create/update). A validation +// failure here is the rare worst case (badly-drifted backend); the empty draft +// keeps the editor rendering rather than throwing into the UI. +export const EMPTY_DRAFT: Draft = { + id: "", + workspace_id: "", + owner_user_id: "", + title: "", + body: "", + status: "draft", + created_at: "", + updated_at: "", +}; + const SubscriberSchema = z.object({ issue_id: z.string(), user_type: z.string(), diff --git a/packages/core/drafts/cache-helpers.ts b/packages/core/drafts/cache-helpers.ts new file mode 100644 index 00000000000..359f02859c6 --- /dev/null +++ b/packages/core/drafts/cache-helpers.ts @@ -0,0 +1,74 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { Draft, ListDraftsResponse } from "../types"; +import { draftKeys } from "./queries"; + +/** + * Drafts live in a flat, newest-edited-first list, so cache patching is array + * splicing. All helpers operate on the {@link ListDraftsResponse} shape that + * {@link draftListOptions}'s queryFn returns — the `select` projection that + * extracts `.drafts` is applied downstream (TanStack re-runs select after every + * cache write). + * + * Helpers iterate `qc.setQueriesData` over the `lists` prefix so any mounted + * list cache stays consistent. + */ + +export function prependDraftToLists(qc: QueryClient, wsId: string, draft: Draft): void { + qc.setQueriesData( + { queryKey: draftKeys.lists(wsId) }, + (old) => { + if (!old) return old; + // Defensive: if the same id is already present (WS event landed first), + // don't double-insert. + if (old.drafts.some((d) => d.id === draft.id)) return old; + return { ...old, drafts: [draft, ...old.drafts], total: old.total + 1 }; + }, + ); +} + +export function replaceDraftInLists(qc: QueryClient, wsId: string, draft: Draft): void { + qc.setQueriesData( + { queryKey: draftKeys.lists(wsId) }, + (old) => { + if (!old) return old; + let touched = false; + const drafts = old.drafts.map((d) => { + if (d.id !== draft.id) return d; + touched = true; + return draft; + }); + // No-op write if id wasn't in this cache — preserves referential + // identity, keeps React from re-rendering subscribers unnecessarily. + return touched ? { ...old, drafts } : old; + }, + ); +} + +export function removeDraftFromLists(qc: QueryClient, wsId: string, draftId: string): void { + qc.setQueriesData( + { queryKey: draftKeys.lists(wsId) }, + (old) => { + if (!old) return old; + const before = old.drafts.length; + const drafts = old.drafts.filter((d) => d.id !== draftId); + if (drafts.length === before) return old; + return { ...old, drafts, total: Math.max(0, old.total - 1) }; + }, + ); +} + +/** + * Replace a placeholder (temp) draft with the server-confirmed one. Used by the + * optimistic create flow: insert with a temp id while the request is in flight, + * then swap when the real row comes back. + */ +export function swapTempDraft(qc: QueryClient, wsId: string, tempId: string, draft: Draft): void { + qc.setQueriesData( + { queryKey: draftKeys.lists(wsId) }, + (old) => { + if (!old) return old; + const drafts = old.drafts.map((d) => (d.id === tempId ? draft : d)); + return { ...old, drafts }; + }, + ); +} diff --git a/packages/core/drafts/index.ts b/packages/core/drafts/index.ts new file mode 100644 index 00000000000..e7a5681c415 --- /dev/null +++ b/packages/core/drafts/index.ts @@ -0,0 +1,14 @@ +export { draftKeys, draftListOptions, draftDetailOptions } from "./queries"; +export { + useCreateDraft, + useUpdateDraft, + useDeleteDraft, + isTempDraftId, +} from "./mutations"; +export { + prependDraftToLists, + replaceDraftInLists, + removeDraftFromLists, + swapTempDraft, +} from "./cache-helpers"; +export { onDraftCreated, onDraftUpdated, onDraftDeleted } from "./ws-updaters"; diff --git a/packages/core/drafts/mutations.ts b/packages/core/drafts/mutations.ts new file mode 100644 index 00000000000..bfcaf6b5888 --- /dev/null +++ b/packages/core/drafts/mutations.ts @@ -0,0 +1,182 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { api } from "../api"; +import { useAuthStore } from "../auth"; +import { useWorkspaceId } from "../hooks"; +import type { CreateDraftRequest, Draft, ListDraftsResponse, UpdateDraftRequest } from "../types"; +import { draftKeys } from "./queries"; +import { + prependDraftToLists, + removeDraftFromLists, + replaceDraftInLists, + swapTempDraft, +} from "./cache-helpers"; + +/** + * Draft mutations with optimistic-update flows. Mirrors the task mutations: + * the cache shape is a flat list (see cache-helpers.ts). All flows roll back on + * error and invalidate at settle so the server's view becomes authoritative for + * any field the optimistic patch may have guessed wrong (timestamps). + */ + +/** Stable prefix for client-generated temp ids — lets the optimistic-create + * path identify its own placeholder rows during the swap. */ +const TEMP_ID_PREFIX = "temp-draft-"; + +function makeTempId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return TEMP_ID_PREFIX + crypto.randomUUID(); + } + return TEMP_ID_PREFIX + Math.random().toString(36).slice(2); +} + +export function isTempDraftId(id: string): boolean { + return id.startsWith(TEMP_ID_PREFIX); +} + +/** + * Create a draft with optimistic insertion. The placeholder row appears at the + * top of the list immediately; on success the temp row swaps for the real one; + * on failure it disappears (rollback). The caller drives navigation to the new + * draft (typically pushing `?draft=` once the real id lands via onSuccess). + */ +export function useCreateDraft() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + const user = useAuthStore((s) => s.user); + + return useMutation({ + mutationFn: (input: CreateDraftRequest) => api.createDraft(input), + onMutate: async (input) => { + await qc.cancelQueries({ queryKey: draftKeys.all(wsId) }); + + const now = new Date().toISOString(); + const tempId = makeTempId(); + const optimistic: Draft = { + id: tempId, + workspace_id: wsId, + owner_user_id: user?.id ?? "", + title: input.title ?? "", + body: input.body ?? "", + status: input.status ?? "draft", + created_at: now, + updated_at: now, + }; + prependDraftToLists(qc, wsId, optimistic); + return { tempId }; + }, + onSuccess: (draft, _input, ctx) => { + if (ctx?.tempId) { + swapTempDraft(qc, wsId, ctx.tempId, draft); + } else { + prependDraftToLists(qc, wsId, draft); + } + // Seed detail cache so opening the new draft is instant (no network). + qc.setQueryData(draftKeys.detail(wsId, draft.id), draft); + }, + onError: (_err, _input, ctx) => { + if (ctx?.tempId) { + removeDraftFromLists(qc, wsId, ctx.tempId); + } + }, + onSettled: () => { + qc.invalidateQueries({ queryKey: draftKeys.all(wsId) }); + }, + }); +} + +/** + * Update a draft with an optimistic in-place patch. Built for autosave: a + * debounced caller fires `{ id, patch: { body } }` (or title) on edit, the + * optimistic patch keeps the list + detail in sync instantly, and the server + * reconciles on settle. The patch is shallow — fields not in the request carry + * their previous values through. + */ +export function useUpdateDraft() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + + return useMutation({ + mutationFn: ({ id, patch }: { id: string; patch: UpdateDraftRequest }) => + api.updateDraft(id, patch), + onMutate: async ({ id, patch }) => { + await qc.cancelQueries({ queryKey: draftKeys.all(wsId) }); + + // draftKeys.lists() (not all()) — the latter prefix also matches detail + // caches (a single Draft, not {drafts, total}), and the list-shape + // updater below would crash on `old.drafts.map(...)`. + const previousLists = qc.getQueriesData({ queryKey: draftKeys.lists(wsId) }); + const previousDetail = qc.getQueryData(draftKeys.detail(wsId, id)); + + const now = new Date().toISOString(); + qc.setQueriesData( + { queryKey: draftKeys.lists(wsId) }, + (old) => { + if (!old) return old; + const drafts = old.drafts.map((d) => + d.id === id ? { ...d, ...patch, updated_at: now } : d, + ); + return { ...old, drafts }; + }, + ); + if (previousDetail) { + qc.setQueryData(draftKeys.detail(wsId, id), { + ...previousDetail, + ...patch, + updated_at: now, + }); + } + + return { previousLists, previousDetail }; + }, + onSuccess: (draft) => { + // Server-authoritative replace — picks up server-set fields (updated_at). + replaceDraftInLists(qc, wsId, draft); + qc.setQueryData(draftKeys.detail(wsId, draft.id), draft); + }, + onError: (_err, vars, ctx) => { + ctx?.previousLists.forEach(([key, data]) => { + qc.setQueryData(key, data); + }); + if (ctx?.previousDetail) { + qc.setQueryData(draftKeys.detail(wsId, vars.id), ctx.previousDetail); + } + }, + onSettled: (_data, _err, vars) => { + qc.invalidateQueries({ queryKey: draftKeys.detail(wsId, vars.id) }); + }, + }); +} + +/** + * Delete a draft with an optimistic remove. The row disappears from every list + * cache immediately; on error the snapshots are restored. Detail cache is + * dropped on success (refetching a deleted draft would 404). + */ +export function useDeleteDraft() { + const qc = useQueryClient(); + const wsId = useWorkspaceId(); + + return useMutation({ + mutationFn: (id: string) => api.deleteDraft(id), + onMutate: async (id) => { + await qc.cancelQueries({ queryKey: draftKeys.all(wsId) }); + const previousLists = qc.getQueriesData({ + queryKey: draftKeys.lists(wsId), + }); + const previousDetail = qc.getQueryData(draftKeys.detail(wsId, id)); + removeDraftFromLists(qc, wsId, id); + return { id, previousLists, previousDetail }; + }, + onSuccess: (_data, id) => { + qc.removeQueries({ queryKey: draftKeys.detail(wsId, id) }); + }, + onError: (_err, _id, ctx) => { + ctx?.previousLists.forEach(([key, data]) => { + qc.setQueryData(key, data); + }); + if (ctx?.previousDetail && ctx.id) { + qc.setQueryData(draftKeys.detail(wsId, ctx.id), ctx.previousDetail); + } + }, + }); +} diff --git a/packages/core/drafts/queries.ts b/packages/core/drafts/queries.ts new file mode 100644 index 00000000000..c569b529f0a --- /dev/null +++ b/packages/core/drafts/queries.ts @@ -0,0 +1,47 @@ +import { queryOptions } from "@tanstack/react-query"; +import { api } from "../api"; + +/** + * Draft query keys. Workspace scoping is part of the key so switching + * workspaces automatically swaps the visible draft list with no manual + * invalidation. Drafts are owner-scoped server-side (the API only returns the + * requesting user's drafts), so there's no per-filter list dimension in slice 0 + * — `list` takes only `wsId`. + */ +export const draftKeys = { + all: (wsId: string) => ["drafts", wsId] as const, + /** + * Prefix that matches the list cache for this workspace. Used by the cache + * helpers / WS updaters for list-shaped patching. + * + * Do NOT use {@link all} as the prefix for setQueriesData — it would also + * match {@link detail} caches (a single Draft), and the list-shape helpers + * would read `old.drafts` as undefined and throw. Always go through `lists` + * for list-shaped patching and `detail` for per-draft patching. + */ + lists: (wsId: string) => [...draftKeys.all(wsId), "list"] as const, + list: (wsId: string) => [...draftKeys.lists(wsId)] as const, + detail: (wsId: string, id: string) => + [...draftKeys.all(wsId), "detail", id] as const, +}; + +/** + * Workspace-scoped draft list (newest-edited first, server-ordered). A single + * flat query — drafts have no status buckets or filters in slice 0. The + * `select` layer extracts the array so consumers don't have to remember the + * response wrapper shape. + */ +export function draftListOptions(wsId: string) { + return queryOptions({ + queryKey: draftKeys.list(wsId), + queryFn: () => api.listDrafts({ workspace_id: wsId }), + select: (data) => data.drafts, + }); +} + +export function draftDetailOptions(wsId: string, id: string) { + return queryOptions({ + queryKey: draftKeys.detail(wsId, id), + queryFn: () => api.getDraft(id), + }); +} diff --git a/packages/core/drafts/ws-updaters.ts b/packages/core/drafts/ws-updaters.ts new file mode 100644 index 00000000000..74531147a07 --- /dev/null +++ b/packages/core/drafts/ws-updaters.ts @@ -0,0 +1,38 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { Draft } from "../types"; +import { draftKeys } from "./queries"; +import { + prependDraftToLists, + removeDraftFromLists, + replaceDraftInLists, +} from "./cache-helpers"; +import { isTempDraftId } from "./mutations"; + +/** + * WS updaters for the draft surface. Handler signatures mirror the task + * equivalents (qc, wsId, payload) so the realtime sync layer wires them with + * the same pattern. Cache patching is preferred over invalidation everywhere + * it's correct: patching is O(1) and renders instantly. WS events keep the + * draft list fresh across clients (and, in a later slice, when the agent edits + * a draft) — they never write to a store directly. + */ + +export function onDraftCreated(qc: QueryClient, wsId: string, draft: Draft): void { + // The cache helper de-dupes by id, so a duplicate WS event after our own + // optimistic insert is a no-op. + prependDraftToLists(qc, wsId, draft); + qc.setQueryData(draftKeys.detail(wsId, draft.id), draft); +} + +export function onDraftUpdated(qc: QueryClient, wsId: string, draft: Draft): void { + // A WS update for a temp id can never happen (the server doesn't know temp + // ids); the guard makes the contract explicit. + if (isTempDraftId(draft.id)) return; + replaceDraftInLists(qc, wsId, draft); + qc.setQueryData(draftKeys.detail(wsId, draft.id), (old) => (old ? { ...old, ...draft } : draft)); +} + +export function onDraftDeleted(qc: QueryClient, wsId: string, draftId: string): void { + removeDraftFromLists(qc, wsId, draftId); + qc.removeQueries({ queryKey: draftKeys.detail(wsId, draftId) }); +} diff --git a/packages/core/package.json b/packages/core/package.json index 6e6714a51ee..80b4ca6d6e0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -35,6 +35,7 @@ "./issues/stores/*": "./issues/stores/*.ts", "./tasks": "./tasks/index.ts", "./tasks/queries": "./tasks/queries.ts", + "./drafts": "./drafts/index.ts", "./tasks/mutations": "./tasks/mutations.ts", "./tasks/cache-helpers": "./tasks/cache-helpers.ts", "./tasks/ws-updaters": "./tasks/ws-updaters.ts", diff --git a/packages/core/paths/consistency.test.ts b/packages/core/paths/consistency.test.ts index 1b79c28841a..a9ba02f6d40 100644 --- a/packages/core/paths/consistency.test.ts +++ b/packages/core/paths/consistency.test.ts @@ -28,6 +28,7 @@ describe("paths.workspace() shape", () => { "inbox", "myIssues", "tasks", + "drafts", "runtimes", "skills", "squads", @@ -52,6 +53,7 @@ describe("paths.workspace() shape", () => { ["inbox", "inbox"], ["myIssues", "my-issues"], ["tasks", "tasks"], + ["drafts", "drafts"], ["runtimes", "runtimes"], ["skills", "skills"], ["squads", "squads"], diff --git a/packages/core/paths/paths.ts b/packages/core/paths/paths.ts index 05d0a142427..1f01f0997d1 100644 --- a/packages/core/paths/paths.ts +++ b/packages/core/paths/paths.ts @@ -41,6 +41,10 @@ function workspaceScoped(slug: string) { // links and redirects here on mount. Callers everywhere (TaskRow, // inbox "Open task" link) get the sidebar UX for free. taskDetail: (id: string) => `${ws}/tasks?task=${encode(id)}`, + drafts: () => `${ws}/drafts`, + // Master-detail sidebar URL, mirroring taskDetail: opening a draft syncs + // the selection to ?draft= on the drafts page. + draftDetail: (id: string) => `${ws}/drafts?draft=${encode(id)}`, channels: () => `${ws}/channels`, channelDetail: (id: string) => `${ws}/channels/${encode(id)}`, ship: () => `${ws}/ship`, diff --git a/packages/core/realtime/use-realtime-sync.ts b/packages/core/realtime/use-realtime-sync.ts index daf04368732..282446c476c 100644 --- a/packages/core/realtime/use-realtime-sync.ts +++ b/packages/core/realtime/use-realtime-sync.ts @@ -29,6 +29,7 @@ import { onIssueMetadataChanged, } from "../issues/ws-updaters"; import { onUserTaskCreated, onUserTaskUpdated, onUserTaskDeleted, onUserTaskPromoted } from "../tasks/ws-updaters"; +import { onDraftCreated, onDraftUpdated, onDraftDeleted } from "../drafts/ws-updaters"; import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted } from "../inbox/ws-updaters"; import { inboxKeys } from "../inbox/queries"; import { notificationPreferenceOptions } from "../notification-preferences/queries"; @@ -367,6 +368,9 @@ export function useRealtimeSync( // Routing is by exact event name in ws-client subscriptions, so prefix // collisions are notional only. "task:created", "task:updated", "task:deleted", "task:promoted", + // Draft lifecycle (standalone draft table). Handled by explicit handlers + // below; skip the generic prefix refresh. + "draft:created", "draft:updated", "draft:deleted", "comment:created", "comment:updated", "comment:deleted", "comment:resolved", "comment:unresolved", "activity:created", @@ -518,6 +522,30 @@ export function useRealtimeSync( if (wsId) onUserTaskPromoted(qc, wsId, task_id, issue); }); + // Draft lifecycle. The server publishes `{ draft: DraftResponse }` for + // create/update and `{ draft_id: string }` for delete. See server draft.go + // h.publish calls. Mirrors the user-task handlers above. + const unsubDraftCreated = ws.on("draft:created", (p) => { + const { draft } = (p ?? {}) as { draft?: import("../types").Draft }; + if (!draft?.id) return; + const wsId = getCurrentWsId(); + if (wsId) onDraftCreated(qc, wsId, draft); + }); + + const unsubDraftUpdated = ws.on("draft:updated", (p) => { + const { draft } = (p ?? {}) as { draft?: import("../types").Draft }; + if (!draft?.id) return; + const wsId = getCurrentWsId(); + if (wsId) onDraftUpdated(qc, wsId, draft); + }); + + const unsubDraftDeleted = ws.on("draft:deleted", (p) => { + const { draft_id } = (p ?? {}) as { draft_id?: string }; + if (!draft_id) return; + const wsId = getCurrentWsId(); + if (wsId) onDraftDeleted(qc, wsId, draft_id); + }); + const unsubIssueLabelsChanged = ws.on("issue_labels:changed", (p) => { const { issue_id, labels } = p as IssueLabelsChangedPayload; if (!issue_id) return; @@ -1282,6 +1310,9 @@ export function useRealtimeSync( unsubTaskUpdated(); unsubTaskDeleted(); unsubTaskPromoted(); + unsubDraftCreated(); + unsubDraftUpdated(); + unsubDraftDeleted(); unsubIssueLabelsChanged(); unsubIssueMetadataChanged(); unsubInboxNew(); diff --git a/packages/core/types/draft.ts b/packages/core/types/draft.ts new file mode 100644 index 00000000000..224f81c7f71 --- /dev/null +++ b/packages/core/types/draft.ts @@ -0,0 +1,54 @@ +/** + * Drafts are a standalone, workspace- + owner-scoped entity: a living markdown + * document a user co-authors with an agent. Slice 0 models only the single-user + * CRUD surface (title + markdown body + status); later slices add the + * annotation layer, the agent, and co-editing — the `owner_user_id` field and + * the open `status` enum are the forward-compat seams for that work. See server + * migration 122 and server draft.sql for the query-layer scoping. + */ + +/** + * Open enum. Slice 0 only ever produces "draft"; future values (e.g. + * "accepted") arrive without a frontend change. Modeled as a wide string union + * with an explicit fallback member so consumers can `switch` with a default + * branch and unknown server values render a generic fallback (enum-drift rule). + */ +export type DraftStatus = "draft" | "accepted" | (string & {}); + +export interface Draft { + id: string; + workspace_id: string; + /** The user who owns this draft. Named *owner* (not a single-user flag) so + * the multi-author surface in a later slice doesn't need a rename. */ + owner_user_id: string; + title: string; + /** Markdown body. Defaults to "" on the server. */ + body: string; + status: DraftStatus; + created_at: string; + updated_at: string; +} + +export interface ListDraftsParams { + workspace_id?: string; +} + +export interface ListDraftsResponse { + drafts: Draft[]; + total: number; +} + +/** All fields optional — a blank "New draft" create is a valid empty body. */ +export interface CreateDraftRequest { + title?: string; + body?: string; + status?: DraftStatus; +} + +/** A missing field is "no change" (server uses COALESCE), so a body-only + * autosave PATCH leaves title and status untouched. */ +export interface UpdateDraftRequest { + title?: string; + body?: string; + status?: DraftStatus; +} diff --git a/packages/core/types/events.ts b/packages/core/types/events.ts index ead3e913eac..4bc749fb213 100644 --- a/packages/core/types/events.ts +++ b/packages/core/types/events.ts @@ -36,6 +36,10 @@ export type WSEventType = | "task:updated" | "task:deleted" | "task:promoted" + // Draft lifecycle (standalone draft table). CRUD on a user's drafts. + | "draft:created" + | "draft:updated" + | "draft:deleted" | "inbox:new" | "inbox:read" | "inbox:archived" diff --git a/packages/core/types/index.ts b/packages/core/types/index.ts index a4d32715cce..78d1da2f41c 100644 --- a/packages/core/types/index.ts +++ b/packages/core/types/index.ts @@ -1,5 +1,6 @@ export type { Issue, IssueStatus, IssuePriority, IssueAssigneeType, IssueMetadata, IssueMetadataValue, IssueReaction } from "./issue"; export type { Task, TaskStatus, TaskAssigneeType, ListTasksParams, ListTasksResponse, CreateTaskRequest, UpdateTaskRequest } from "./task"; +export type { Draft, DraftStatus, ListDraftsParams, ListDraftsResponse, CreateDraftRequest, UpdateDraftRequest } from "./draft"; export type { Agent, AgentStatus, diff --git a/packages/views/drafts/components/draft-editor.tsx b/packages/views/drafts/components/draft-editor.tsx new file mode 100644 index 00000000000..1ba5225884c --- /dev/null +++ b/packages/views/drafts/components/draft-editor.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Trash2, X } from "lucide-react"; +import { draftDetailOptions, useUpdateDraft, useDeleteDraft } from "@multica/core/drafts"; +import { useWorkspaceId } from "@multica/core/hooks"; +import { ContentEditor, type ContentEditorRef, TitleEditor } from "../../editor"; +import { PageHeader } from "../../layout/page-header"; +import { Button } from "@multica/ui/components/ui/button"; +import { Skeleton } from "@multica/ui/components/ui/skeleton"; +import { toast } from "sonner"; +import { useT } from "../../i18n"; + +interface DraftEditorProps { + draftId: string; + /** Clears the master-detail selection (closes the editor pane). */ + onClose: () => void; + /** Called after a successful delete so the parent can clear its selection. */ + onDeleted: (id: string) => void; +} + +/** + * Draft editor pane (right side of the master-detail). Title + markdown body, + * both autosaving through the optimistic {@link useUpdateDraft} mutation: + * - Title commits on blur / Enter (TitleEditor). + * - Body autosaves on a 1s debounce (ContentEditor's onUpdate). + * + * Mirrors the memory artifact detail editor. Slice 0 has no annotations, + * agent, or co-editing surface — just single-user editing. + */ +export function DraftEditor({ draftId, onClose, onDeleted }: DraftEditorProps) { + const { t } = useT("drafts"); + const wsId = useWorkspaceId(); + + const { data: draft, isLoading } = useQuery(draftDetailOptions(wsId, draftId)); + + const updateDraft = useUpdateDraft(); + const deleteDraft = useDeleteDraft(); + + // Pull markdown on title-blur so persisting the title also flushes any + // in-flight body edits (mirrors the memory editor). + const contentRef = useRef(null); + + // Local title mirrors the server-side draft.title until the user types, then + // drifts and syncs back on save / remount. + const [titleDraft, setTitleDraft] = useState(""); + useEffect(() => { + if (draft) setTitleDraft(draft.title); + }, [draft?.id, draft?.title]); + + if (isLoading || !draft) { + return ( + + + + + + + + + + + + ); + } + + const saveTitle = (next: string) => { + const trimmed = next.trim(); + if (trimmed === draft.title) return; + updateDraft.mutate( + { id: draft.id, patch: { title: trimmed } }, + { onError: () => toast.error(t(($) => $.errors.save_failed)) }, + ); + }; + + const saveBody = (markdown: string) => { + if (markdown === draft.body) return; + updateDraft.mutate( + { id: draft.id, patch: { body: markdown } }, + { onError: () => toast.error(t(($) => $.errors.save_failed)) }, + ); + }; + + const handleDelete = () => { + if (!window.confirm(t(($) => $.detail.delete_confirm))) return; + deleteDraft.mutate(draft.id, { + onSuccess: () => { + toast.success(t(($) => $.detail.delete_success)); + onDeleted(draft.id); + }, + onError: () => toast.error(t(($) => $.errors.delete_failed)), + }); + }; + + return ( + + + + {draft.title.trim() || t(($) => $.detail.untitled)} + + + + + {t(($) => $.detail.delete_button)} + + + + + + + + + + $.detail.title_placeholder)} + className="text-2xl font-semibold" + onChange={setTitleDraft} + onSubmit={() => saveTitle(titleDraft)} + onBlur={() => saveTitle(titleDraft)} + /> + + $.detail.body_placeholder)} + onUpdate={saveBody} + debounceMs={1000} + /> + + + + + ); +} diff --git a/packages/views/drafts/components/drafts-page.test.tsx b/packages/views/drafts/components/drafts-page.test.tsx new file mode 100644 index 00000000000..e2943140749 --- /dev/null +++ b/packages/views/drafts/components/drafts-page.test.tsx @@ -0,0 +1,161 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { I18nProvider } from "@multica/core/i18n/react"; +import type { Draft } from "@multica/core/types"; +import enCommon from "../../locales/en/common.json"; +import enDrafts from "../../locales/en/drafts.json"; + +const TEST_RESOURCES = { en: { common: enCommon, drafts: enDrafts } }; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +vi.mock("@multica/core/hooks", () => ({ + useWorkspaceId: () => "ws-1", +})); + +// Captured nav state — `replace` is what the master-detail selection uses to +// sync ?draft= into the URL. The test reads it to assert selection wiring. +const replaceMock = vi.hoisted(() => vi.fn()); +const searchParams = vi.hoisted(() => new URLSearchParams()); +vi.mock("../../navigation", () => ({ + AppLink: ({ children, href, ...rest }: any) => ( + {children} + ), + useNavigation: () => ({ + push: vi.fn(), + replace: replaceMock, + pathname: "/ws/drafts", + searchParams, + }), + NavigationProvider: ({ children }: any) => children, +})); + +// Mock the editor pane — it pulls in tiptap (ContentEditor/TitleEditor), which +// is heavy and out of scope for the page-level master-detail test. The page's +// own list + "New draft" behavior is what's under test here. +vi.mock("./draft-editor", () => ({ + DraftEditor: ({ draftId }: { draftId: string }) => ( + editing {draftId} + ), +})); + +const createMutate = vi.hoisted(() => vi.fn()); +vi.mock("@multica/core/drafts", () => ({ + draftListOptions: () => ({ + queryKey: ["drafts", "ws-1", "list"], + queryFn: () => Promise.resolve(mockDrafts), + select: (data: any) => data, + }), + useCreateDraft: () => ({ mutate: createMutate, isPending: false }), +})); + +vi.mock("../../layout/page-header", () => ({ + PageHeader: ({ children, ...rest }: any) => {children}, +})); + +// react-resizable-panels' useDefaultLayout reads from storage/context we don't +// set up; stub it to a static layout so the page renders deterministically. +vi.mock("react-resizable-panels", async () => { + const actual = await vi.importActual("react-resizable-panels"); + return { + ...actual, + useDefaultLayout: () => ({ defaultLayout: undefined, onLayoutChanged: vi.fn() }), + }; +}); + +let mockDrafts: Draft[] = []; + +function makeDraft(over: Partial = {}): Draft { + return { + id: "d-1", + workspace_id: "ws-1", + owner_user_id: "user-1", + title: "My first draft", + body: "# hello", + status: "draft", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + ...over, + }; +} + +// --------------------------------------------------------------------------- +// Imports under test +// --------------------------------------------------------------------------- + +import { DraftsPage } from "./drafts-page"; + +function renderPage() { + const qc = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + mutations: { retry: false }, + }, + }); + return render( + + + + + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + searchParams.delete("draft"); + mockDrafts = [makeDraft()]; +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("DraftsPage", () => { + it("renders the user's drafts in the list", async () => { + renderPage(); + expect(await screen.findByText("My first draft")).toBeInTheDocument(); + }); + + it("shows the select prompt when no draft is selected", async () => { + renderPage(); + await screen.findByText("My first draft"); + expect(screen.getByText(enDrafts.detail.select_prompt)).toBeInTheDocument(); + expect(screen.queryByTestId("draft-editor")).not.toBeInTheDocument(); + }); + + it("selects a draft (syncs ?draft=) when its row is clicked", async () => { + renderPage(); + fireEvent.click(await screen.findByText("My first draft")); + expect(replaceMock).toHaveBeenCalledWith("/ws/drafts?draft=d-1"); + }); + + it("opens the editor pane for the draft named in ?draft", async () => { + searchParams.set("draft", "d-1"); + renderPage(); + const editor = await screen.findByTestId("draft-editor"); + expect(editor).toHaveTextContent("editing d-1"); + }); + + it("creates a draft via the New draft button", async () => { + renderPage(); + await screen.findByText("My first draft"); + fireEvent.click(screen.getByRole("button", { name: enDrafts.page.new_draft })); + expect(createMutate).toHaveBeenCalledTimes(1); + // Called with an empty create payload (blank draft) and an onSuccess that + // selects the freshly-created draft. + const [payload, opts] = createMutate.mock.calls[0]!; + expect(payload).toEqual({}); + opts.onSuccess(makeDraft({ id: "d-new" })); + expect(replaceMock).toHaveBeenCalledWith("/ws/drafts?draft=d-new"); + }); + + it("renders the empty state with a create affordance when there are no drafts", async () => { + mockDrafts = []; + renderPage(); + expect(await screen.findByText(enDrafts.page.empty_title)).toBeInTheDocument(); + }); +}); diff --git a/packages/views/drafts/components/drafts-page.tsx b/packages/views/drafts/components/drafts-page.tsx new file mode 100644 index 00000000000..d046b403efa --- /dev/null +++ b/packages/views/drafts/components/drafts-page.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { useCallback } from "react"; +import { NotebookPen, Plus } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { Skeleton } from "@multica/ui/components/ui/skeleton"; +import { Button } from "@multica/ui/components/ui/button"; +import { cn } from "@multica/ui/lib/utils"; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@multica/ui/components/ui/resizable"; +import { useDefaultLayout } from "react-resizable-panels"; +import { draftListOptions, useCreateDraft } from "@multica/core/drafts"; +import { useWorkspaceId } from "@multica/core/hooks"; +import type { Draft } from "@multica/core/types"; +import { PageHeader } from "../../layout/page-header"; +import { useNavigation } from "../../navigation"; +import { useT, useTimeAgo } from "../../i18n"; +import { DraftEditor } from "./draft-editor"; + +/** + * Drafts master-detail page. Left panel: the user's drafts (newest-edited + * first) with a "New draft" affordance. Right panel: the editor for the + * selected draft (title + markdown body, autosave). + * + * Selection lives in the URL as `?draft=` — mirrors the tasks page. Drafts + * are owner-scoped server-side, so there's no Mine/All toggle; every draft in + * the list belongs to the current user. + * + * Slice 0 has no annotations, agent, or co-editing. If/when those land they + * slot into the editor pane without changing this master-detail shell. + */ +export function DraftsPage() { + const { t } = useT("drafts"); + const wsId = useWorkspaceId(); + const { searchParams, replace, pathname } = useNavigation(); + + const selectedId = searchParams.get("draft") ?? ""; + + const setSelected = useCallback( + (id: string) => { + const next = new URLSearchParams(searchParams); + if (id) next.set("draft", id); + else next.delete("draft"); + const qs = next.toString(); + replace(qs ? `${pathname}?${qs}` : pathname); + }, + [pathname, replace, searchParams], + ); + + const { data: drafts = [], isLoading } = useQuery(draftListOptions(wsId)); + const createDraft = useCreateDraft(); + + const handleNewDraft = useCallback(() => { + createDraft.mutate( + {}, + { + // Select the new draft once the server returns its real id, so the + // editor opens on a persisted row (not the optimistic temp id). + onSuccess: (draft) => setSelected(draft.id), + }, + ); + }, [createDraft, setSelected]); + + const { defaultLayout, onLayoutChanged } = useDefaultLayout({ + id: "multica_drafts_layout", + }); + + const listHeader = ( + + + + {t(($) => $.page.title)} + + + + {t(($) => $.page.new_draft)} + + + ); + + const listBody = isLoading ? ( + + {Array.from({ length: 8 }).map((_, i) => ( + + + + + ))} + + ) : drafts.length === 0 ? ( + + ) : ( + + {drafts.map((draft) => ( + setSelected(draft.id)} + /> + ))} + + ); + + return ( + + + + {listHeader} + {listBody} + + + + + {selectedId ? ( + setSelected("")} + onDeleted={(id) => { + if (id === selectedId) setSelected(""); + }} + /> + ) : ( + + )} + + + ); +} + +interface DraftRowProps { + draft: Draft; + selected: boolean; + onSelect: () => void; +} + +function DraftRow({ draft, selected, onSelect }: DraftRowProps) { + const { t } = useT("drafts"); + const timeAgo = useTimeAgo(); + const title = draft.title.trim() || t(($) => $.detail.untitled); + return ( + + {title} + {timeAgo(draft.updated_at)} + + ); +} + +function EmptyState({ onNewDraft, pending }: { onNewDraft: () => void; pending: boolean }) { + const { t } = useT("drafts"); + return ( + + + + {t(($) => $.page.empty_title)} + + {t(($) => $.page.empty_description)} + + + + + {t(($) => $.page.new_draft)} + + + ); +} + +function SelectPrompt() { + const { t } = useT("drafts"); + return ( + + + + {t(($) => $.detail.select_prompt)} + + + ); +} diff --git a/packages/views/drafts/index.ts b/packages/views/drafts/index.ts new file mode 100644 index 00000000000..6195089c628 --- /dev/null +++ b/packages/views/drafts/index.ts @@ -0,0 +1,2 @@ +export { DraftsPage } from "./components/drafts-page"; +export { DraftEditor } from "./components/draft-editor"; diff --git a/packages/views/editor/utils/link-handler.ts b/packages/views/editor/utils/link-handler.ts index 55cfa11bdcb..c0b506fe06d 100644 --- a/packages/views/editor/utils/link-handler.ts +++ b/packages/views/editor/utils/link-handler.ts @@ -28,6 +28,7 @@ const WORKSPACE_ROUTE_SEGMENTS = new Set([ "channels", "inbox", "my-issues", + "drafts", "runtimes", "skills", "settings", diff --git a/packages/views/i18n/resources-types.ts b/packages/views/i18n/resources-types.ts index 754a24994e0..ae7b9845c07 100644 --- a/packages/views/i18n/resources-types.ts +++ b/packages/views/i18n/resources-types.ts @@ -15,6 +15,7 @@ import type labels from "../locales/en/labels.json"; import type members from "../locales/en/members.json"; import type myIssues from "../locales/en/my-issues.json"; import type tasks from "../locales/en/tasks.json"; +import type drafts from "../locales/en/drafts.json"; import type search from "../locales/en/search.json"; import type inbox from "../locales/en/inbox.json"; import type workspace from "../locales/en/workspace.json"; @@ -59,6 +60,7 @@ declare global { members: typeof members; "my-issues": typeof myIssues; tasks: typeof tasks; + drafts: typeof drafts; search: typeof search; inbox: typeof inbox; workspace: typeof workspace; diff --git a/packages/views/layout/app-sidebar.test.tsx b/packages/views/layout/app-sidebar.test.tsx index c6bb7a67a64..16f9c5e9f9d 100644 --- a/packages/views/layout/app-sidebar.test.tsx +++ b/packages/views/layout/app-sidebar.test.tsx @@ -87,6 +87,7 @@ vi.mock("@multica/core/paths", () => ({ inbox: () => "/acme/inbox", myIssues: () => "/acme/my-issues", tasks: () => "/acme/tasks", + drafts: () => "/acme/drafts", issues: () => "/acme/issues", projects: () => "/acme/projects", memory: () => "/acme/memory", diff --git a/packages/views/layout/app-sidebar.tsx b/packages/views/layout/app-sidebar.tsx index 355986a9c49..962e3b3dab6 100644 --- a/packages/views/layout/app-sidebar.tsx +++ b/packages/views/layout/app-sidebar.tsx @@ -18,6 +18,7 @@ import { CSS } from "@dnd-kit/utilities"; import { Inbox, ListTodo, + NotebookPen, Bot, MessageCircle, Monitor, @@ -111,6 +112,7 @@ type NavKey = | "inbox" | "myIssues" | "tasks" + | "drafts" | "issues" | "projects" | "memory" @@ -129,6 +131,7 @@ type NavLabelKey = | "inbox" | "my_issues" | "tasks" + | "drafts" | "issues" | "projects" | "memory" @@ -149,6 +152,9 @@ const personalNav: { key: NavKey; labelKey: NavLabelKey; icon: typeof Inbox }[] // because they're a personal-feeling surface — see the brand spec. The // workspace still owns the data; this is just where the entry-point lives. { key: "tasks", labelKey: "tasks", icon: ListTodo }, + // Drafts are owner-scoped (each user's own living documents), so they sit + // alongside Tasks in the personal section rather than under Workspace. + { key: "drafts", labelKey: "drafts", icon: NotebookPen }, ]; type NavItem = { key: NavKey; labelKey: NavLabelKey; icon: typeof Inbox }; diff --git a/packages/views/locales/en/drafts.json b/packages/views/locales/en/drafts.json new file mode 100644 index 00000000000..d59a1e942fd --- /dev/null +++ b/packages/views/locales/en/drafts.json @@ -0,0 +1,23 @@ +{ + "page": { + "title": "Drafts", + "new_draft": "New draft", + "empty_title": "No drafts yet", + "empty_description": "Drafts are living documents you write and shape over time. Create one to start writing." + }, + "detail": { + "untitled": "Untitled draft", + "title_placeholder": "Untitled draft", + "body_placeholder": "Start writing… (Markdown supported)", + "select_prompt": "Select a draft to open it, or create a new one.", + "delete_button": "Delete", + "delete_confirm": "Delete this draft? This can't be undone.", + "delete_success": "Draft deleted" + }, + "errors": { + "load_failed": "Failed to load draft.", + "create_failed": "Couldn't create draft. Please try again.", + "save_failed": "Couldn't save draft. Please try again.", + "delete_failed": "Couldn't delete draft. Please try again." + } +} diff --git a/packages/views/locales/en/layout.json b/packages/views/locales/en/layout.json index bcf7c979569..8199310674d 100644 --- a/packages/views/locales/en/layout.json +++ b/packages/views/locales/en/layout.json @@ -3,6 +3,7 @@ "inbox": "Inbox", "my_issues": "My Issues", "tasks": "Tasks", + "drafts": "Drafts", "issues": "Issues", "projects": "Projects", "memory": "Memory", diff --git a/packages/views/locales/index.ts b/packages/views/locales/index.ts index faa4d2ca8b5..ae05fb9b561 100644 --- a/packages/views/locales/index.ts +++ b/packages/views/locales/index.ts @@ -11,6 +11,7 @@ import enLabels from "./en/labels.json"; import enMembers from "./en/members.json"; import enMyIssues from "./en/my-issues.json"; import enTasks from "./en/tasks.json"; +import enDrafts from "./en/drafts.json"; import enSearch from "./en/search.json"; import enInbox from "./en/inbox.json"; import enWorkspace from "./en/workspace.json"; @@ -39,6 +40,7 @@ import zhHansLabels from "./zh-Hans/labels.json"; import zhHansMembers from "./zh-Hans/members.json"; import zhHansMyIssues from "./zh-Hans/my-issues.json"; import zhHansTasks from "./zh-Hans/tasks.json"; +import zhHansDrafts from "./zh-Hans/drafts.json"; import zhHansSearch from "./zh-Hans/search.json"; import zhHansInbox from "./zh-Hans/inbox.json"; import zhHansWorkspace from "./zh-Hans/workspace.json"; @@ -73,6 +75,7 @@ export const RESOURCES: Record = { members: enMembers, "my-issues": enMyIssues, tasks: enTasks, + drafts: enDrafts, search: enSearch, inbox: enInbox, workspace: enWorkspace, @@ -103,6 +106,7 @@ export const RESOURCES: Record = { members: zhHansMembers, "my-issues": zhHansMyIssues, tasks: zhHansTasks, + drafts: zhHansDrafts, search: zhHansSearch, inbox: zhHansInbox, workspace: zhHansWorkspace, diff --git a/packages/views/locales/zh-Hans/drafts.json b/packages/views/locales/zh-Hans/drafts.json new file mode 100644 index 00000000000..ae8f2e5e70a --- /dev/null +++ b/packages/views/locales/zh-Hans/drafts.json @@ -0,0 +1,23 @@ +{ + "page": { + "title": "draft", + "new_draft": "新建 draft", + "empty_title": "还没有 draft", + "empty_description": "draft 是可以持续撰写和打磨的活文档。创建一个开始写作。" + }, + "detail": { + "untitled": "未命名 draft", + "title_placeholder": "未命名 draft", + "body_placeholder": "开始写作…(支持 Markdown)", + "select_prompt": "选中一个 draft 打开,或新建一个。", + "delete_button": "删除", + "delete_confirm": "确定要删除这个 draft 吗?此操作不可撤销。", + "delete_success": "draft 已删除" + }, + "errors": { + "load_failed": "加载 draft 失败。", + "create_failed": "创建 draft 失败,请重试。", + "save_failed": "保存 draft 失败,请重试。", + "delete_failed": "删除 draft 失败,请重试。" + } +} diff --git a/packages/views/locales/zh-Hans/layout.json b/packages/views/locales/zh-Hans/layout.json index a8fdb00f0a4..247243cfb5c 100644 --- a/packages/views/locales/zh-Hans/layout.json +++ b/packages/views/locales/zh-Hans/layout.json @@ -3,6 +3,7 @@ "inbox": "收件箱", "my_issues": "我的 Issue", "tasks": "task", + "drafts": "draft", "issues": "Issue", "projects": "项目", "memory": "记忆", diff --git a/packages/views/package.json b/packages/views/package.json index 40c3d3efe5d..18f1dc0c8e5 100644 --- a/packages/views/package.json +++ b/packages/views/package.json @@ -25,6 +25,7 @@ "./modals/create-issue": "./modals/create-issue.tsx", "./my-issues": "./my-issues/index.ts", "./tasks": "./tasks/index.ts", + "./drafts": "./drafts/index.ts", "./skills": "./skills/index.ts", "./agents": "./agents/index.ts", "./members": "./members/index.ts", diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index ba1dc8a3a67..bf76d92da6f 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -484,6 +484,23 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus // {id} above because of the trailing `/messages` segment. r.Get("/api/tasks/{taskId}/messages", h.ListTaskMessagesByUser) + // Drafts (standalone workspace- + owner-scoped markdown documents). + // Slice 0 is single-user CRUD; later slices add the annotation + // surface, the agent, and co-editing. {id} is UUID-only (drafts + // have no human-readable identifier). + r.Route("/api/drafts", func(r chi.Router) { + r.Get("/", h.ListDrafts) + r.Post("/", h.CreateDraft) + r.Route("/{id}", func(r chi.Router) { + r.Get("/", h.GetDraft) + // PUT + PATCH on the same handler mirrors the issue/task API + // (the MCP client only has a `patch()` method). + r.Put("/", h.UpdateDraft) + r.Patch("/", h.UpdateDraft) + r.Delete("/", h.DeleteDraft) + }) + }) + // Labels r.Route("/api/labels", func(r chi.Router) { r.Get("/", h.ListLabels) diff --git a/server/internal/handler/draft.go b/server/internal/handler/draft.go new file mode 100644 index 00000000000..a0284b06d50 --- /dev/null +++ b/server/internal/handler/draft.go @@ -0,0 +1,301 @@ +package handler + +import ( + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/multica-ai/multica/server/internal/logger" + "github.com/multica-ai/multica/server/internal/util" + db "github.com/multica-ai/multica/server/pkg/db/generated" + "github.com/multica-ai/multica/server/pkg/protocol" +) + +// Drafts are a standalone, workspace-scoped, owner-scoped entity: a living +// markdown document a user co-authors with an agent. Slice 0 ships only the +// single-user CRUD surface (create / list / open / edit title+body / delete +// with autosave). Later slices layer on the annotation surface, the agent, +// Yjs co-editing, and graduation — the owner_user_id column and the open +// `status` enum are the forward-compat seams for that work. See migration 122 +// and draft.sql for the query-layer scoping. + +// draftDefaultStatus is the canonical slice-0 status. The column default is +// 'draft' too; this constant is the application-layer fallback the status +// switch downgrades unknown values to (enum-drift rule). +const draftDefaultStatus = "draft" + +// normalizeDraftStatus maps a caller-supplied status onto a value safe to +// write. `status` is an open enum: slice 0 only writes 'draft', but future +// slices add values like 'accepted'. The switch has a `default` branch per the +// repo's enum-drift rules — an empty / unknown value downgrades to the +// canonical 'draft' rather than 400ing, so an older server staying forward- +// compatible never rejects a value a newer client legitimately sends. +func normalizeDraftStatus(s string) string { + switch s { + case "draft", "accepted": + return s + default: + return draftDefaultStatus + } +} + +// DraftResponse is the JSON shape returned for a draft. +type DraftResponse struct { + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + OwnerUserID string `json:"owner_user_id"` + Title string `json:"title"` + Body string `json:"body"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func draftToResponse(d db.Draft) DraftResponse { + return DraftResponse{ + ID: uuidToString(d.ID), + WorkspaceID: uuidToString(d.WorkspaceID), + OwnerUserID: uuidToString(d.OwnerUserID), + Title: d.Title, + Body: d.Body, + Status: d.Status, + CreatedAt: timestampToString(d.CreatedAt), + UpdatedAt: timestampToString(d.UpdatedAt), + } +} + +// loadDraftForUser fetches a draft by UUID, enforcing workspace membership +// (the route is mounted behind RequireWorkspaceMember) and owner scoping. +// Per CLAUDE.md's "Backend Handler UUID Parsing Convention" this is the single +// resolution point: callers MUST use the returned db.Draft.ID for any +// subsequent write rather than re-parsing the raw URL string. +func (h *Handler) loadDraftForUser(w http.ResponseWriter, r *http.Request, draftID string) (db.Draft, bool) { + userID, ok := requireUserID(w, r) + if !ok { + return db.Draft{}, false + } + workspaceID := h.resolveWorkspaceID(r) + if workspaceID == "" { + writeError(w, http.StatusBadRequest, "workspace_id is required") + return db.Draft{}, false + } + draftUUID, err := util.ParseUUID(draftID) + if err != nil { + writeError(w, http.StatusNotFound, "draft not found") + return db.Draft{}, false + } + wsUUID, err := util.ParseUUID(workspaceID) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid workspace_id") + return db.Draft{}, false + } + ownerUUID, err := util.ParseUUID(userID) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid user id") + return db.Draft{}, false + } + draft, err := h.Queries.GetDraft(r.Context(), db.GetDraftParams{ + ID: draftUUID, + WorkspaceID: wsUUID, + OwnerUserID: ownerUUID, + }) + if err != nil { + writeError(w, http.StatusNotFound, "draft not found") + return db.Draft{}, false + } + return draft, true +} + +// CreateDraftRequest is the body for POST /drafts. All fields are optional — +// a "blank draft" create (just clicking New draft) is a valid empty body. +type CreateDraftRequest struct { + Title *string `json:"title"` + Body *string `json:"body"` + Status *string `json:"status"` +} + +// UpdateDraftRequest carries optional fields. A missing field is "no change" +// (the SQL uses COALESCE/narg), so a body-only autosave PATCH leaves title and +// status untouched. +type UpdateDraftRequest struct { + Title *string `json:"title"` + Body *string `json:"body"` + Status *string `json:"status"` +} + +func (h *Handler) ListDrafts(w http.ResponseWriter, r *http.Request) { + userID, ok := requireUserID(w, r) + if !ok { + return + } + workspaceID := h.resolveWorkspaceID(r) + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id") + if !ok { + return + } + ownerUUID, ok := parseUUIDOrBadRequest(w, userID, "user id") + if !ok { + return + } + + drafts, err := h.Queries.ListDraftsForUser(r.Context(), db.ListDraftsForUserParams{ + WorkspaceID: wsUUID, + OwnerUserID: ownerUUID, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list drafts") + return + } + + resp := make([]DraftResponse, len(drafts)) + for i, d := range drafts { + resp[i] = draftToResponse(d) + } + writeJSON(w, http.StatusOK, map[string]any{ + "drafts": resp, + "total": len(resp), + }) +} + +func (h *Handler) GetDraft(w http.ResponseWriter, r *http.Request) { + draft, ok := h.loadDraftForUser(w, r, chi.URLParam(r, "id")) + if !ok { + return + } + writeJSON(w, http.StatusOK, draftToResponse(draft)) +} + +func (h *Handler) CreateDraft(w http.ResponseWriter, r *http.Request) { + var req CreateDraftRequest + // An empty body is valid (blank draft = just clicking "New draft"). An + // empty body decodes to io.EOF, which we tolerate; only a malformed JSON + // payload is a 400. + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + userID, ok := requireUserID(w, r) + if !ok { + return + } + workspaceID := h.resolveWorkspaceID(r) + wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id") + if !ok { + return + } + ownerUUID, ok := parseUUIDOrBadRequest(w, userID, "user id") + if !ok { + return + } + + title := "" + if req.Title != nil { + title = *req.Title + } + body := "" + if req.Body != nil { + body = *req.Body + } + status := draftDefaultStatus + if req.Status != nil { + status = normalizeDraftStatus(*req.Status) + } + + draft, err := h.Queries.CreateDraft(r.Context(), db.CreateDraftParams{ + WorkspaceID: wsUUID, + OwnerUserID: ownerUUID, + Title: title, + Body: body, + Status: status, + }) + if err != nil { + slog.Warn("create draft failed", append(logger.RequestAttrs(r), "error", err, "workspace_id", workspaceID)...) + writeError(w, http.StatusInternalServerError, "failed to create draft") + return + } + + resp := draftToResponse(draft) + h.publish(protocol.EventDraftCreated, workspaceID, "member", userID, map[string]any{"draft": resp}) + writeJSON(w, http.StatusCreated, resp) +} + +func (h *Handler) UpdateDraft(w http.ResponseWriter, r *http.Request) { + draft, ok := h.loadDraftForUser(w, r, chi.URLParam(r, "id")) + if !ok { + return + } + + var req UpdateDraftRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + workspaceID := uuidToString(draft.WorkspaceID) + userID, ok := requireUserID(w, r) + if !ok { + return + } + + // COALESCE/narg semantics: a non-Valid pgtype.Text means "leave this + // column unchanged", so a body-only autosave PATCH never clobbers title or + // status. Mirrors UpdateTask / UpdateProject. + var title pgtype.Text + if req.Title != nil { + title = pgtype.Text{String: *req.Title, Valid: true} + } + var body pgtype.Text + if req.Body != nil { + body = pgtype.Text{String: *req.Body, Valid: true} + } + var status pgtype.Text + if req.Status != nil { + status = pgtype.Text{String: normalizeDraftStatus(*req.Status), Valid: true} + } + + updated, err := h.Queries.UpdateDraft(r.Context(), db.UpdateDraftParams{ + ID: draft.ID, + WorkspaceID: draft.WorkspaceID, + OwnerUserID: draft.OwnerUserID, + Title: title, + Body: body, + Status: status, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to update draft") + return + } + + resp := draftToResponse(updated) + h.publish(protocol.EventDraftUpdated, workspaceID, "member", userID, map[string]any{"draft": resp}) + writeJSON(w, http.StatusOK, resp) +} + +func (h *Handler) DeleteDraft(w http.ResponseWriter, r *http.Request) { + draft, ok := h.loadDraftForUser(w, r, chi.URLParam(r, "id")) + if !ok { + return + } + workspaceID := uuidToString(draft.WorkspaceID) + userID, ok := requireUserID(w, r) + if !ok { + return + } + + if err := h.Queries.DeleteDraft(r.Context(), db.DeleteDraftParams{ + ID: draft.ID, + WorkspaceID: draft.WorkspaceID, + OwnerUserID: draft.OwnerUserID, + }); err != nil { + writeError(w, http.StatusInternalServerError, "failed to delete draft") + return + } + + h.publish(protocol.EventDraftDeleted, workspaceID, "member", userID, map[string]any{"draft_id": uuidToString(draft.ID)}) + w.WriteHeader(http.StatusNoContent) +} diff --git a/server/internal/handler/draft_test.go b/server/internal/handler/draft_test.go new file mode 100644 index 00000000000..2e0d34e0c53 --- /dev/null +++ b/server/internal/handler/draft_test.go @@ -0,0 +1,260 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// Drafts are a standalone, workspace- + owner-scoped entity. These tests +// cover: +// - TestDraftCRUD: round-trip through create/get/list/update/delete, plus +// the partial-update invariant (body-only PATCH preserves title/status) +// - TestDraftUpdateRejectsBadUUID / TestDraftDeleteRejectsBadUUID: a +// malformed id never reaches a write query — it 404s (the loader maps an +// unparseable UUID to not-found) +// - TestDraftCreateAllowsEmptyBody: clicking "New draft" with no payload is +// a valid blank draft, not a 400 +// - TestDraftStatusEnumDrift: an unknown status downgrades to 'draft' +// rather than 400ing (open-enum / enum-drift rule) + +func TestDraftCRUD(t *testing.T) { + // Create + w := httptest.NewRecorder() + req := newRequest("POST", "/api/drafts?workspace_id="+testWorkspaceID, map[string]any{ + "title": "draft crud", + "body": "# hello\n\nsome markdown", + }) + testHandler.CreateDraft(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("CreateDraft: expected 201, got %d: %s", w.Code, w.Body.String()) + } + var created DraftResponse + if err := json.NewDecoder(w.Body).Decode(&created); err != nil { + t.Fatalf("decode create: %v", err) + } + if created.Title != "draft crud" { + t.Fatalf("CreateDraft: expected title 'draft crud', got %q", created.Title) + } + if created.Status != "draft" { + t.Fatalf("CreateDraft: expected default status 'draft', got %q", created.Status) + } + if created.OwnerUserID != testUserID { + t.Fatalf("CreateDraft: expected owner %s, got %s", testUserID, created.OwnerUserID) + } + draftID := created.ID + t.Cleanup(func() { + testPool.Exec(context.Background(), `DELETE FROM draft WHERE id = $1`, draftID) + }) + + // Get + w = httptest.NewRecorder() + req = newRequest("GET", "/api/drafts/"+draftID, nil) + req = withURLParam(req, "id", draftID) + testHandler.GetDraft(w, req) + if w.Code != http.StatusOK { + t.Fatalf("GetDraft: expected 200, got %d: %s", w.Code, w.Body.String()) + } + var fetched DraftResponse + if err := json.NewDecoder(w.Body).Decode(&fetched); err != nil { + t.Fatalf("decode get: %v", err) + } + if fetched.ID != draftID { + t.Fatalf("GetDraft: expected id %s, got %s", draftID, fetched.ID) + } + if fetched.Body != "# hello\n\nsome markdown" { + t.Fatalf("GetDraft: body not round-tripped, got %q", fetched.Body) + } + + // Update — partial (body only). Title and status must be preserved. + w = httptest.NewRecorder() + req = newRequest("PATCH", "/api/drafts/"+draftID, map[string]any{ + "body": "## edited", + }) + req = withURLParam(req, "id", draftID) + testHandler.UpdateDraft(w, req) + if w.Code != http.StatusOK { + t.Fatalf("UpdateDraft: expected 200, got %d: %s", w.Code, w.Body.String()) + } + var updated DraftResponse + if err := json.NewDecoder(w.Body).Decode(&updated); err != nil { + t.Fatalf("decode update: %v", err) + } + if updated.Body != "## edited" { + t.Fatalf("UpdateDraft: expected body '## edited', got %q", updated.Body) + } + if updated.Title != "draft crud" { + t.Fatalf("UpdateDraft: title should be preserved across partial patch, got %q", updated.Title) + } + if updated.Status != "draft" { + t.Fatalf("UpdateDraft: status should be preserved across partial patch, got %q", updated.Status) + } + + // List — should include our draft. + w = httptest.NewRecorder() + req = newRequest("GET", "/api/drafts?workspace_id="+testWorkspaceID, nil) + testHandler.ListDrafts(w, req) + if w.Code != http.StatusOK { + t.Fatalf("ListDrafts: expected 200, got %d: %s", w.Code, w.Body.String()) + } + var listResp map[string]any + json.NewDecoder(w.Body).Decode(&listResp) + drafts, _ := listResp["drafts"].([]any) + found := false + for _, item := range drafts { + m, _ := item.(map[string]any) + if m["id"] == draftID { + found = true + break + } + } + if !found { + t.Fatalf("ListDrafts: created draft %s not present in response", draftID) + } + + // Delete + w = httptest.NewRecorder() + req = newRequest("DELETE", "/api/drafts/"+draftID, nil) + req = withURLParam(req, "id", draftID) + testHandler.DeleteDraft(w, req) + if w.Code != http.StatusNoContent { + t.Fatalf("DeleteDraft: expected 204, got %d: %s", w.Code, w.Body.String()) + } + + // Verify deleted. + w = httptest.NewRecorder() + req = newRequest("GET", "/api/drafts/"+draftID, nil) + req = withURLParam(req, "id", draftID) + testHandler.GetDraft(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("GetDraft after delete: expected 404, got %d", w.Code) + } +} + +// TestDraftCreateAllowsEmptyBody confirms a blank "New draft" (no JSON +// payload) is a valid create, not a 400. +func TestDraftCreateAllowsEmptyBody(t *testing.T) { + w := httptest.NewRecorder() + req := newRequest("POST", "/api/drafts?workspace_id="+testWorkspaceID, nil) + testHandler.CreateDraft(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("CreateDraft empty body: expected 201, got %d: %s", w.Code, w.Body.String()) + } + var created DraftResponse + json.NewDecoder(w.Body).Decode(&created) + if created.Title != "" || created.Body != "" { + t.Fatalf("CreateDraft empty body: expected blank title/body, got title=%q body=%q", created.Title, created.Body) + } + if created.Status != "draft" { + t.Fatalf("CreateDraft empty body: expected status 'draft', got %q", created.Status) + } + t.Cleanup(func() { + testPool.Exec(context.Background(), `DELETE FROM draft WHERE id = $1`, created.ID) + }) +} + +// TestDraftStatusEnumDrift confirms the open-enum rule: an unknown status +// value downgrades to the canonical 'draft' rather than 400ing, so an older +// server stays forward-compatible with a newer client. +func TestDraftStatusEnumDrift(t *testing.T) { + w := httptest.NewRecorder() + req := newRequest("POST", "/api/drafts?workspace_id="+testWorkspaceID, map[string]any{ + "title": "enum drift probe", + "status": "some_future_status_we_dont_know", + }) + testHandler.CreateDraft(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("CreateDraft unknown status: expected 201 (downgrade, not reject), got %d: %s", w.Code, w.Body.String()) + } + var created DraftResponse + json.NewDecoder(w.Body).Decode(&created) + if created.Status != "draft" { + t.Fatalf("CreateDraft unknown status: expected downgrade to 'draft', got %q", created.Status) + } + t.Cleanup(func() { + testPool.Exec(context.Background(), `DELETE FROM draft WHERE id = $1`, created.ID) + }) +} + +// TestDraftUpdateRejectsBadUUID guards the UUID-parsing convention: a path id +// that is not a valid UUID must never reach the UPDATE query. The loader maps +// it to 404 — and critically must NOT 204/200 (silent no-op data loss class, +// see #1661). +func TestDraftUpdateRejectsBadUUID(t *testing.T) { + w := httptest.NewRecorder() + req := newRequest("PATCH", "/api/drafts/not-a-uuid", map[string]any{"title": "x"}) + req = withURLParam(req, "id", "not-a-uuid") + testHandler.UpdateDraft(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("UpdateDraft bad uuid: expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +// TestDraftDeleteRejectsBadUUID is the symmetric delete-side guard. +func TestDraftDeleteRejectsBadUUID(t *testing.T) { + w := httptest.NewRecorder() + req := newRequest("DELETE", "/api/drafts/not-a-uuid", nil) + req = withURLParam(req, "id", "not-a-uuid") + testHandler.DeleteDraft(w, req) + if w.Code == http.StatusNoContent { + t.Fatalf("DeleteDraft bad uuid: must not return 204; got %d", w.Code) + } + if w.Code != http.StatusNotFound { + t.Fatalf("DeleteDraft bad uuid: expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +// TestDraftGetDeniesOtherOwner confirms owner scoping: a draft owned by a +// different user in the same workspace is invisible (404) to the requester. +// Slice-0 drafts are single-author; this is the guard that keeps them so. +func TestDraftGetDeniesOtherOwner(t *testing.T) { + ctx := context.Background() + + // Seed a second user in the same workspace and a draft they own. + var otherUserID string + if err := testPool.QueryRow(ctx, ` + INSERT INTO "user" (name, email) VALUES ($1, $2) RETURNING id + `, "Draft Other Owner", "draft-other-owner@multica.ai").Scan(&otherUserID); err != nil { + t.Fatalf("seed other user: %v", err) + } + t.Cleanup(func() { + testPool.Exec(ctx, `DELETE FROM "user" WHERE id = $1`, otherUserID) + }) + + var otherDraftID string + if err := testPool.QueryRow(ctx, ` + INSERT INTO draft (workspace_id, owner_user_id, title, body) + VALUES ($1, $2, $3, $4) RETURNING id::text + `, testWorkspaceID, otherUserID, "not yours", "secret body").Scan(&otherDraftID); err != nil { + t.Fatalf("seed other draft: %v", err) + } + t.Cleanup(func() { + testPool.Exec(ctx, `DELETE FROM draft WHERE id = $1`, otherDraftID) + }) + + // The default test request authenticates as testUserID — that user must + // not be able to read another owner's draft. + w := httptest.NewRecorder() + req := newRequest("GET", "/api/drafts/"+otherDraftID, nil) + req = withURLParam(req, "id", otherDraftID) + testHandler.GetDraft(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("GetDraft other owner's draft: expected 404, got %d: %s", w.Code, w.Body.String()) + } + + // And it must not show up in the requester's list. + w = httptest.NewRecorder() + req = newRequest("GET", "/api/drafts?workspace_id="+testWorkspaceID, nil) + testHandler.ListDrafts(w, req) + var listResp map[string]any + json.NewDecoder(w.Body).Decode(&listResp) + drafts, _ := listResp["drafts"].([]any) + for _, item := range drafts { + m, _ := item.(map[string]any) + if m["id"] == otherDraftID { + t.Fatalf("ListDrafts leaked another owner's draft %s", otherDraftID) + } + } +} diff --git a/server/migrations/122_draft.down.sql b/server/migrations/122_draft.down.sql new file mode 100644 index 00000000000..bcd81433eb5 --- /dev/null +++ b/server/migrations/122_draft.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_draft_workspace_owner_updated; +DROP TABLE IF EXISTS draft; diff --git a/server/migrations/122_draft.up.sql b/server/migrations/122_draft.up.sql new file mode 100644 index 00000000000..be3a92f0b0f --- /dev/null +++ b/server/migrations/122_draft.up.sql @@ -0,0 +1,27 @@ +-- Drafts: a living markdown document a user co-authors with an agent. Slice 0 +-- ships only the standalone entity + single-user CRUD; later slices layer on +-- annotations, the agent, Yjs co-editing, and graduation. The owner column is +-- named `owner_user_id` (not a single-user flag) on purpose — forward-compat +-- for the future multi-author surface; slice 0 simply scopes every query to +-- the requesting user. +CREATE TABLE draft ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + owner_user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + title TEXT NOT NULL DEFAULT '', + body TEXT NOT NULL DEFAULT '', + -- Open enum. Slice 0 only writes 'draft'; future values (e.g. 'accepted') + -- arrive without a migration. Intentionally NOT a CHECK constraint so a + -- newer server can introduce a value an older one never saw — the Go side + -- switches on it with a `default` branch, the TS side treats unknown values + -- as a generic fallback (per the repo's enum-drift rules). + status TEXT NOT NULL DEFAULT 'draft', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Drives the default list query: drafts for one owner in one workspace, newest +-- edit first. The compound shape lets Postgres satisfy the WHERE + ORDER BY in +-- a single index scan. +CREATE INDEX idx_draft_workspace_owner_updated + ON draft(workspace_id, owner_user_id, updated_at DESC); diff --git a/server/pkg/db/generated/draft.sql.go b/server/pkg/db/generated/draft.sql.go new file mode 100644 index 00000000000..c0110817f9f --- /dev/null +++ b/server/pkg/db/generated/draft.sql.go @@ -0,0 +1,193 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: draft.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createDraft = `-- name: CreateDraft :one +INSERT INTO draft ( + workspace_id, owner_user_id, title, body, status +) VALUES ( + $1, $2, $3, $4, $5 +) RETURNING id, workspace_id, owner_user_id, title, body, status, created_at, updated_at +` + +type CreateDraftParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + OwnerUserID pgtype.UUID `json:"owner_user_id"` + Title string `json:"title"` + Body string `json:"body"` + Status string `json:"status"` +} + +// Owner is the requesting user. title/body/status fall back to their column +// defaults when the caller omits them (body ”, status 'draft'). +func (q *Queries) CreateDraft(ctx context.Context, arg CreateDraftParams) (Draft, error) { + row := q.db.QueryRow(ctx, createDraft, + arg.WorkspaceID, + arg.OwnerUserID, + arg.Title, + arg.Body, + arg.Status, + ) + var i Draft + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.OwnerUserID, + &i.Title, + &i.Body, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteDraft = `-- name: DeleteDraft :exec +DELETE FROM draft WHERE id = $1 AND workspace_id = $2 AND owner_user_id = $3 +` + +type DeleteDraftParams struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + OwnerUserID pgtype.UUID `json:"owner_user_id"` +} + +// Defense-in-depth: workspace_id + owner_user_id are SQL-layer guards (see +// DeleteProject / DeleteIssue for the rationale). A handler bug routing a +// foreign UUID through this delete silently no-ops rather than destroying +// another user's draft. +func (q *Queries) DeleteDraft(ctx context.Context, arg DeleteDraftParams) error { + _, err := q.db.Exec(ctx, deleteDraft, arg.ID, arg.WorkspaceID, arg.OwnerUserID) + return err +} + +const getDraft = `-- name: GetDraft :one +SELECT id, workspace_id, owner_user_id, title, body, status, created_at, updated_at FROM draft +WHERE id = $1 AND workspace_id = $2 AND owner_user_id = $3 +` + +type GetDraftParams struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + OwnerUserID pgtype.UUID `json:"owner_user_id"` +} + +// Scoped to workspace + owner: a draft UUID belonging to another user (or +// another workspace) returns no rows, which the handler maps to 404. +func (q *Queries) GetDraft(ctx context.Context, arg GetDraftParams) (Draft, error) { + row := q.db.QueryRow(ctx, getDraft, arg.ID, arg.WorkspaceID, arg.OwnerUserID) + var i Draft + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.OwnerUserID, + &i.Title, + &i.Body, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listDraftsForUser = `-- name: ListDraftsForUser :many + +SELECT id, workspace_id, owner_user_id, title, body, status, created_at, updated_at FROM draft +WHERE workspace_id = $1 AND owner_user_id = $2 +ORDER BY updated_at DESC +` + +type ListDraftsForUserParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + OwnerUserID pgtype.UUID `json:"owner_user_id"` +} + +// Draft CRUD. Drafts are a standalone, workspace-scoped, owner-scoped entity +// (their own table — unlike tasks, which share the issue table). Every query +// binds workspace_id (multi-tenancy) and owner_user_id (slice-0 single-author +// scoping) so a draft can never be read or mutated across either boundary. +// One owner's drafts in one workspace, newest edit first. Satisfied by +// idx_draft_workspace_owner_updated in a single index scan. +func (q *Queries) ListDraftsForUser(ctx context.Context, arg ListDraftsForUserParams) ([]Draft, error) { + rows, err := q.db.Query(ctx, listDraftsForUser, arg.WorkspaceID, arg.OwnerUserID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Draft{} + for rows.Next() { + var i Draft + if err := rows.Scan( + &i.ID, + &i.WorkspaceID, + &i.OwnerUserID, + &i.Title, + &i.Body, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateDraft = `-- name: UpdateDraft :one +UPDATE draft SET + title = COALESCE($4, title), + body = COALESCE($5, body), + status = COALESCE($6, status), + updated_at = now() +WHERE id = $1 AND workspace_id = $2 AND owner_user_id = $3 +RETURNING id, workspace_id, owner_user_id, title, body, status, created_at, updated_at +` + +type UpdateDraftParams struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + OwnerUserID pgtype.UUID `json:"owner_user_id"` + Title pgtype.Text `json:"title"` + Body pgtype.Text `json:"body"` + Status pgtype.Text `json:"status"` +} + +// Field-by-field optional update via COALESCE/narg so a partial PATCH (e.g. +// body-only autosave) leaves title and status untouched. The workspace_id + +// owner_user_id predicate is the SQL-layer guard: a UUID belonging to another +// user or workspace updates zero rows rather than mutating someone else's draft. +func (q *Queries) UpdateDraft(ctx context.Context, arg UpdateDraftParams) (Draft, error) { + row := q.db.QueryRow(ctx, updateDraft, + arg.ID, + arg.WorkspaceID, + arg.OwnerUserID, + arg.Title, + arg.Body, + arg.Status, + ) + var i Draft + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.OwnerUserID, + &i.Title, + &i.Body, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/server/pkg/db/generated/models.go b/server/pkg/db/generated/models.go index 7e9d4701bcf..2ec6605f16a 100644 --- a/server/pkg/db/generated/models.go +++ b/server/pkg/db/generated/models.go @@ -795,6 +795,17 @@ type DeployPreflight struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type Draft struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + OwnerUserID pgtype.UUID `json:"owner_user_id"` + Title string `json:"title"` + Body string `json:"body"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type Feedback struct { ID pgtype.UUID `json:"id"` UserID pgtype.UUID `json:"user_id"` diff --git a/server/pkg/db/queries/draft.sql b/server/pkg/db/queries/draft.sql new file mode 100644 index 00000000000..34c9b2e272e --- /dev/null +++ b/server/pkg/db/queries/draft.sql @@ -0,0 +1,46 @@ +-- Draft CRUD. Drafts are a standalone, workspace-scoped, owner-scoped entity +-- (their own table — unlike tasks, which share the issue table). Every query +-- binds workspace_id (multi-tenancy) and owner_user_id (slice-0 single-author +-- scoping) so a draft can never be read or mutated across either boundary. + +-- name: ListDraftsForUser :many +-- One owner's drafts in one workspace, newest edit first. Satisfied by +-- idx_draft_workspace_owner_updated in a single index scan. +SELECT * FROM draft +WHERE workspace_id = $1 AND owner_user_id = $2 +ORDER BY updated_at DESC; + +-- name: GetDraft :one +-- Scoped to workspace + owner: a draft UUID belonging to another user (or +-- another workspace) returns no rows, which the handler maps to 404. +SELECT * FROM draft +WHERE id = $1 AND workspace_id = $2 AND owner_user_id = $3; + +-- name: CreateDraft :one +-- Owner is the requesting user. title/body/status fall back to their column +-- defaults when the caller omits them (body '', status 'draft'). +INSERT INTO draft ( + workspace_id, owner_user_id, title, body, status +) VALUES ( + $1, $2, $3, $4, $5 +) RETURNING *; + +-- name: UpdateDraft :one +-- Field-by-field optional update via COALESCE/narg so a partial PATCH (e.g. +-- body-only autosave) leaves title and status untouched. The workspace_id + +-- owner_user_id predicate is the SQL-layer guard: a UUID belonging to another +-- user or workspace updates zero rows rather than mutating someone else's draft. +UPDATE draft SET + title = COALESCE(sqlc.narg('title'), title), + body = COALESCE(sqlc.narg('body'), body), + status = COALESCE(sqlc.narg('status'), status), + updated_at = now() +WHERE id = $1 AND workspace_id = $2 AND owner_user_id = $3 +RETURNING *; + +-- name: DeleteDraft :exec +-- Defense-in-depth: workspace_id + owner_user_id are SQL-layer guards (see +-- DeleteProject / DeleteIssue for the rationale). A handler bug routing a +-- foreign UUID through this delete silently no-ops rather than destroying +-- another user's draft. +DELETE FROM draft WHERE id = $1 AND workspace_id = $2 AND owner_user_id = $3; diff --git a/server/pkg/protocol/events.go b/server/pkg/protocol/events.go index 89b67695899..d4d8bf5666d 100644 --- a/server/pkg/protocol/events.go +++ b/server/pkg/protocol/events.go @@ -58,6 +58,16 @@ const ( // combined event keeps the transition atomic. EventUserTaskPromoted = "task:promoted" + // Draft events (CRUD on the standalone draft table). Workspace- and + // owner-scoped; the frontend invalidates the draft list/detail caches on + // receipt so a draft created/edited/deleted in one client (or, in a later + // slice, by the agent) stays fresh everywhere. Created/updated carry the + // full draft payload; deleted carries only `draft_id` (the receiver removes + // it from the list and clears its detail cache). + EventDraftCreated = "draft:created" + EventDraftUpdated = "draft:updated" + EventDraftDeleted = "draft:deleted" + // Inbox events EventInboxNew = "inbox:new" EventInboxRead = "inbox:read"
+ {t(($) => $.page.empty_description)} +
+ {t(($) => $.detail.select_prompt)} +