/** * Work module — kanban workspace boards. * * Multi-tenant collaborative workspace with drag-and-drop kanban, * configurable statuses, and activity logging. * * All persistence uses Automerge documents via SyncServer — * no PostgreSQL dependency. */ import { Hono } from "hono"; import * as Automerge from '@automerge/automerge'; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule, SpaceLifecycleContext } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; import { renderLanding } from "./landing"; import type { SyncServer } from '../../server/local-first/sync-server'; import { boardSchema, boardDocId, createTaskItem } from './schemas'; import type { BoardDoc, TaskItem, BoardMeta } from './schemas'; const routes = new Hono(); // ── Local-first helpers ── let _syncServer: SyncServer | null = null; /** * Lazily create the board Automerge doc if it doesn't exist yet. * Returns the current (immutable) doc snapshot. */ function ensureDoc(space: string, boardId?: string): BoardDoc { const id = boardDocId(space, boardId ?? space); let doc = _syncServer!.getDoc(id); if (!doc) { doc = Automerge.change(Automerge.init(), 'init board', (d) => { const init = boardSchema.init(); d.meta = init.meta; d.meta.spaceSlug = space; d.board = init.board; d.board.id = boardId ?? space; d.board.slug = boardId ?? space; d.board.name = space; d.tasks = {}; }); _syncServer!.setDoc(id, doc); } return doc; } /** * Get all board doc IDs for a given space. */ function getBoardDocIds(space: string): string[] { return _syncServer!.getDocIds().filter((id) => id.startsWith(`${space}:work:boards:`)); } /** * Seed demo data if no boards exist yet. */ function seedDemoIfEmpty() { // Check if any work boards exist at all const allWorkDocs = _syncServer!.getDocIds().filter((id) => id.includes(':work:boards:')); if (allWorkDocs.length > 0) return; const space = 'rspace-dev'; const docId = boardDocId(space, space); const doc = Automerge.change(Automerge.init(), 'seed demo board', (d) => { const now = Date.now(); d.meta = { module: 'work', collection: 'boards', version: 1, spaceSlug: space, createdAt: now }; d.board = { id: space, name: 'rSpace Development', slug: space, description: 'Building the cosmolocal r* ecosystem', icon: null, ownerDid: 'did:demo:seed', statuses: ['TODO', 'IN_PROGRESS', 'REVIEW', 'DONE'], labels: [], createdAt: now, updatedAt: now, }; d.tasks = {}; const seedTasks: Array<{ title: string; status: string; priority: string; labels: string[]; sort: number }> = [ { title: "Add dark mode toggle to settings page", status: "TODO", priority: "MEDIUM", labels: ["feature"], sort: 0 }, { title: "Write API documentation for rPubs endpoints", status: "TODO", priority: "LOW", labels: ["docs"], sort: 1 }, { title: "Investigate slow PDF generation on large documents", status: "TODO", priority: "HIGH", labels: ["bug"], sort: 2 }, { title: "Implement file search and filtering in rFiles", status: "IN_PROGRESS", priority: "HIGH", labels: ["feature"], sort: 0 }, { title: "Set up SMTP relay for transactional notifications", status: "IN_PROGRESS", priority: "MEDIUM", labels: ["chore"], sort: 1 }, { title: "Add PDF export to rNotes notebooks", status: "REVIEW", priority: "MEDIUM", labels: ["feature"], sort: 0 }, { title: "Fix conviction score decay calculation in rVote", status: "REVIEW", priority: "HIGH", labels: ["bug"], sort: 1 }, { title: "Deploy EncryptID passkey authentication", status: "DONE", priority: "URGENT", labels: ["feature"], sort: 0 }, { title: "Set up Cloudflare tunnel for all r* domains", status: "DONE", priority: "HIGH", labels: ["chore"], sort: 1 }, { title: "Create cosmolocal provider directory with 6 printers", status: "DONE", priority: "MEDIUM", labels: ["feature"], sort: 2 }, { title: "Migrate email from Resend to self-hosted Mailcow", status: "DONE", priority: "MEDIUM", labels: ["chore"], sort: 3 }, ]; for (const t of seedTasks) { const taskId = crypto.randomUUID(); d.tasks[taskId] = createTaskItem(taskId, space, t.title, { status: t.status, priority: t.priority, labels: t.labels, sortOrder: t.sort, createdBy: 'did:demo:seed', }); } }); _syncServer!.setDoc(docId, doc); console.log("[Work] Demo data seeded: 1 board, 11 tasks"); } // ── API: Spaces (Boards) ── // GET /api/spaces — list workspaces (boards) routes.get("/api/spaces", async (c) => { const allIds = _syncServer!.getDocIds().filter((id) => id.includes(':work:boards:')); const rows = allIds.map((docId) => { const doc = _syncServer!.getDoc(docId); if (!doc) return null; const taskCount = Object.keys(doc.tasks).length; return { id: doc.board.id, name: doc.board.name, slug: doc.board.slug, description: doc.board.description, icon: doc.board.icon, owner_did: doc.board.ownerDid, statuses: doc.board.statuses, created_at: new Date(doc.board.createdAt).toISOString(), updated_at: new Date(doc.board.updatedAt).toISOString(), member_count: 0, task_count: taskCount, }; }).filter(Boolean); // Sort by created_at DESC rows.sort((a, b) => (b!.created_at > a!.created_at ? 1 : -1)); return c.json(rows); }); // POST /api/spaces — create workspace (board) routes.post("/api/spaces", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); let claims; try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const body = await c.req.json(); const { name, description, icon } = body; if (!name?.trim()) return c.json({ error: "Name required" }, 400); const slug = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); const docId = boardDocId(slug, slug); // Check if board already exists const existing = _syncServer!.getDoc(docId); if (existing) return c.json({ error: "Space with this slug already exists" }, 409); const now = Date.now(); const doc = Automerge.change(Automerge.init(), 'create board', (d) => { d.meta = { module: 'work', collection: 'boards', version: 1, spaceSlug: slug, createdAt: now }; d.board = { id: slug, name: name.trim(), slug, description: description || '', icon: icon || null, ownerDid: claims.sub, statuses: ['TODO', 'IN_PROGRESS', 'DONE'], labels: [], createdAt: now, updatedAt: now, }; d.tasks = {}; }); _syncServer!.setDoc(docId, doc); return c.json({ id: slug, name: name.trim(), slug, description: description || null, icon: icon || null, owner_did: claims.sub, statuses: ['TODO', 'IN_PROGRESS', 'DONE'], created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString(), }, 201); }); // GET /api/spaces/:slug — workspace detail routes.get("/api/spaces/:slug", async (c) => { const slug = c.req.param("slug"); const docId = boardDocId(slug, slug); const doc = _syncServer!.getDoc(docId); if (!doc) return c.json({ error: "Space not found" }, 404); return c.json({ id: doc.board.id, name: doc.board.name, slug: doc.board.slug, description: doc.board.description, icon: doc.board.icon, owner_did: doc.board.ownerDid, statuses: doc.board.statuses, labels: doc.board.labels, created_at: new Date(doc.board.createdAt).toISOString(), updated_at: new Date(doc.board.updatedAt).toISOString(), }); }); // ── API: Tasks ── // GET /api/spaces/:slug/tasks — list tasks in workspace routes.get("/api/spaces/:slug/tasks", async (c) => { const slug = c.req.param("slug"); const doc = ensureDoc(slug); const tasks = Object.values(doc.tasks).map((t) => ({ id: t.id, space_id: t.spaceId, title: t.title, description: t.description, status: t.status, priority: t.priority, labels: t.labels, assignee_id: t.assigneeId, assignee_name: null, created_by: t.createdBy, sort_order: t.sortOrder, created_at: new Date(t.createdAt).toISOString(), updated_at: new Date(t.updatedAt).toISOString(), })); // Sort by status, then sort_order, then created_at DESC const statusOrder: Record = {}; doc.board.statuses.forEach((s, i) => { statusOrder[s] = i; }); tasks.sort((a, b) => { const sa = statusOrder[a.status] ?? 999; const sb = statusOrder[b.status] ?? 999; if (sa !== sb) return sa - sb; if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order; return b.created_at > a.created_at ? 1 : -1; }); return c.json(tasks); }); // POST /api/spaces/:slug/tasks — create task routes.post("/api/spaces/:slug/tasks", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); let claims; try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const slug = c.req.param("slug"); const body = await c.req.json(); const { title, description, status, priority, labels } = body; if (!title?.trim()) return c.json({ error: "Title required" }, 400); const doc = ensureDoc(slug); const taskStatus = status || doc.board.statuses[0] || "TODO"; const taskId = crypto.randomUUID(); const now = Date.now(); const docId = boardDocId(slug, slug); _syncServer!.changeDoc(docId, `Create task ${taskId}`, (d) => { d.tasks[taskId] = createTaskItem(taskId, slug, title.trim(), { description: description || '', status: taskStatus, priority: priority || 'MEDIUM', labels: labels || [], createdBy: claims.sub, }); }); return c.json({ id: taskId, space_id: slug, title: title.trim(), description: description || null, status: taskStatus, priority: priority || "MEDIUM", labels: labels || [], assignee_id: null, created_by: claims.sub, sort_order: 0, created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString(), }, 201); }); // PATCH /api/tasks/:id — update task (status change, assignment, etc.) routes.patch("/api/tasks/:id", async (c) => { // Optional auth — track who updated const token = extractToken(c.req.raw.headers); let updatedBy: string | null = null; if (token) { try { const claims = await verifyEncryptIDToken(token); updatedBy = claims.sub; } catch {} } const id = c.req.param("id"); const body = await c.req.json(); const { title, description, status, priority, labels, sort_order, assignee_id } = body; // Check that at least one field is being updated if (title === undefined && description === undefined && status === undefined && priority === undefined && labels === undefined && sort_order === undefined && assignee_id === undefined) { return c.json({ error: "No fields to update" }, 400); } // Find which board doc contains this task const allBoardIds = _syncServer!.getDocIds().filter((docId) => docId.includes(':work:boards:')); let targetDocId: string | null = null; for (const docId of allBoardIds) { const doc = _syncServer!.getDoc(docId); if (doc && doc.tasks[id]) { targetDocId = docId; break; } } if (!targetDocId) return c.json({ error: "Task not found" }, 404); _syncServer!.changeDoc(targetDocId, `Update task ${id}`, (d) => { const task = d.tasks[id]; if (!task) return; if (title !== undefined) task.title = title; if (description !== undefined) task.description = description; if (status !== undefined) task.status = status; if (priority !== undefined) task.priority = priority; if (labels !== undefined) task.labels = labels; if (sort_order !== undefined) task.sortOrder = sort_order; if (assignee_id !== undefined) task.assigneeId = assignee_id || null; task.updatedAt = Date.now(); }); // Return the updated task const updatedDoc = _syncServer!.getDoc(targetDocId)!; const task = updatedDoc.tasks[id]; return c.json({ id: task.id, space_id: task.spaceId, title: task.title, description: task.description, status: task.status, priority: task.priority, labels: task.labels, assignee_id: task.assigneeId, created_by: task.createdBy, sort_order: task.sortOrder, created_at: new Date(task.createdAt).toISOString(), updated_at: new Date(task.updatedAt).toISOString(), }); }); // DELETE /api/tasks/:id routes.delete("/api/tasks/:id", async (c) => { const id = c.req.param("id"); // Find which board doc contains this task const allBoardIds = _syncServer!.getDocIds().filter((docId) => docId.includes(':work:boards:')); let targetDocId: string | null = null; for (const docId of allBoardIds) { const doc = _syncServer!.getDoc(docId); if (doc && doc.tasks[id]) { targetDocId = docId; break; } } if (!targetDocId) return c.json({ error: "Task not found" }, 404); _syncServer!.changeDoc(targetDocId, `Delete task ${id}`, (d) => { delete d.tasks[id]; }); return c.json({ ok: true }); }); // ── API: Activity ── // GET /api/spaces/:slug/activity — recent activity // With Automerge, activity is tracked via document change history. // Return an empty array for now; real activity can be derived from // Automerge.getHistory() or a dedicated activity doc in the future. routes.get("/api/spaces/:slug/activity", async (c) => { return c.json([]); }); // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; return c.html(renderShell({ title: `${space} — Work | rSpace`, moduleId: "rwork", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", body: ``, scripts: ``, styles: ``, })); }); export const workModule: RSpaceModule = { id: "rwork", name: "rWork", icon: "📋", description: "Kanban workspace boards for collaborative task management", scoping: { defaultScope: 'space', userConfigurable: false }, docSchemas: [{ pattern: '{space}:work:boards:{boardId}', description: 'Kanban board with tasks', init: boardSchema.init }], routes, standaloneDomain: "rwork.online", landingPage: renderLanding, async onInit(ctx) { _syncServer = ctx.syncServer; seedDemoIfEmpty(); }, async onSpaceCreate(ctx: SpaceLifecycleContext) { if (!_syncServer) return; const docId = boardDocId(ctx.spaceSlug, ctx.spaceSlug); const doc = Automerge.init(); const initialized = Automerge.change(doc, 'Init board', (d) => { d.meta = { module: 'work', collection: 'boards', version: 1, spaceSlug: ctx.spaceSlug, createdAt: Date.now() }; d.board = { id: ctx.spaceSlug, name: ctx.spaceSlug, slug: ctx.spaceSlug, description: '', icon: null, ownerDid: ctx.ownerDID, statuses: ['TODO', 'IN_PROGRESS', 'DONE'], labels: [], createdAt: Date.now(), updatedAt: Date.now() }; d.tasks = {}; }); _syncServer.setDoc(docId, initialized); }, feeds: [ { id: "task-activity", name: "Task Activity", kind: "data", description: "Task creation, status changes, and assignment updates", filterable: true, }, { id: "board-summary", name: "Board Summary", kind: "data", description: "Kanban board state — counts by status column", }, ], acceptsFeeds: ["governance", "data"], outputPaths: [ { path: "projects", name: "Projects", icon: "📋", description: "Kanban project boards" }, { path: "tasks", name: "Tasks", icon: "✅", description: "Task cards across all boards" }, ], };