/** * Tasks 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 { verifyToken, extractToken } from "../../server/auth"; import { renderLanding } from "./landing"; import type { SyncServer } from '../../server/local-first/sync-server'; import { boardSchema, boardDocId, createTaskItem, clickupConnectionDocId, clickupConnectionSchema } from './schemas'; import type { BoardDoc, TaskItem, BoardMeta, ClickUpConnectionDoc } from './schemas'; import { ClickUpClient } from './lib/clickup-client'; import { importClickUpList, pushBoardToClickUp, handleClickUpWebhook, initClickUpSync } from './lib/clickup-sync'; import { buildStatusMaps } from './lib/clickup-mapping'; // Email checklist routes exported separately — see checklist-routes.ts 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}:tasks:boards:`)); } /** * Seed demo data if no boards exist for the given space. */ function seedDemoIfEmpty(space: string = 'rspace-dev') { if (!_syncServer) return; // Check if this space already has tasks boards (or was already seeded) const spaceWorkDocs = _syncServer!.getDocIds().filter((id) => id.startsWith(`${space}:tasks:boards:`)); const _seedCheckDoc = _syncServer!.getDoc(boardDocId(space, space)); if ((_seedCheckDoc?.meta as any)?.seeded || spaceWorkDocs.length > 0) return; const docId = boardDocId(space, space); const doc = Automerge.change(Automerge.init(), 'seed demo board', (d) => { const now = Date.now(); d.meta = { module: 'tasks', 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); _syncServer!.changeDoc(docId, 'mark seeded', (d) => { if (d.meta) (d.meta as any).seeded = true; }); console.log(`[Tasks] Demo data seeded for "${space}": 1 board, 11 tasks`); } /** * Seed a "BCRG Outcomes" board with 11 tasks matching the BCRG flow outcomes. * Called for demo space on startup and when new spaces are created via seedTemplate. */ function seedBCRGTasksIfEmpty(space: string = 'demo') { if (!_syncServer) return; const boardId = `${space}-bcrg`; const docId = boardDocId(space, boardId); const existing = _syncServer.getDoc(docId); if (existing) return; // already seeded const now = Date.now(); const doc = Automerge.change(Automerge.init(), 'seed BCRG outcomes board', (d) => { d.meta = { module: 'tasks', collection: 'boards', version: 1, spaceSlug: space, createdAt: now }; d.board = { id: boardId, name: 'BCRG Outcomes', slug: boardId, description: 'Tasks tracking BCRG community flow outcomes', icon: null, ownerDid: 'did:demo:seed', statuses: ['TODO', 'IN_PROGRESS', 'REVIEW', 'DONE'], labels: ['rflows', 'bcrg'], createdAt: now, updatedAt: now, }; d.tasks = {}; const bcrgTasks: Array<{ id: string; title: string; status: string; priority: string; description: string; sort: number }> = [ // 4 DONE (completed outcomes) { id: 'alice-comms', title: 'Comms Strategy', status: 'DONE', priority: 'MEDIUM', description: 'ref:rflows:outcome:alice-comms — Community communications and outreach', sort: 0 }, { id: 'carol-ops', title: 'Operations', status: 'DONE', priority: 'HIGH', description: 'ref:rflows:outcome:carol-ops — Day-to-day operational management', sort: 1 }, { id: 'dave-design', title: 'Design System', status: 'DONE', priority: 'HIGH', description: 'ref:rflows:outcome:dave-design — Shared UI/UX design system', sort: 2 }, { id: 'eve-legal', title: 'Legal Framework', status: 'DONE', priority: 'MEDIUM', description: 'ref:rflows:outcome:eve-legal — Legal structure and agreements', sort: 3 }, // 5 IN_PROGRESS (partially-funded outcomes) { id: 'alice-events', title: 'Event Series', status: 'IN_PROGRESS', priority: 'MEDIUM', description: 'ref:rflows:outcome:alice-events — Quarterly community gatherings', sort: 0 }, { id: 'bob-research', title: 'Field Research', status: 'IN_PROGRESS', priority: 'HIGH', description: 'ref:rflows:outcome:bob-research — Participatory action research', sort: 1 }, { id: 'carol-infra', title: 'Infrastructure', status: 'IN_PROGRESS', priority: 'HIGH', description: 'ref:rflows:outcome:carol-infra — Shared infrastructure and hosting', sort: 2 }, { id: 'dave-prototypes', title: 'Prototypes', status: 'IN_PROGRESS', priority: 'MEDIUM', description: 'ref:rflows:outcome:dave-prototypes — Rapid prototyping of new tools', sort: 3 }, { id: 'eve-compliance', title: 'Compliance', status: 'IN_PROGRESS', priority: 'HIGH', description: 'ref:rflows:outcome:eve-compliance — Regulatory compliance and reporting', sort: 4 }, // 2 TODO (not-started outcomes) { id: 'bob-writing', title: 'Publications', status: 'TODO', priority: 'LOW', description: 'ref:rflows:outcome:bob-writing — Research papers and policy briefs', sort: 0 }, { id: 'eve-governance', title: 'Governance Model', status: 'TODO', priority: 'MEDIUM', description: 'ref:rflows:outcome:eve-governance — Governance framework and voting mechanisms', sort: 1 }, ]; for (const t of bcrgTasks) { const taskId = crypto.randomUUID(); d.tasks[taskId] = createTaskItem(taskId, space, t.title, { status: t.status, priority: t.priority, description: t.description, labels: ['rflows', 'bcrg'], sortOrder: t.sort, createdBy: 'did:demo:seed', }); } }); _syncServer.setDoc(docId, doc); console.log(`[Tasks] BCRG outcomes board seeded for "${space}": 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(':tasks: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 verifyToken(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: 'tasks', 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(), ...(doc.board.clickup ? { clickup: { listId: doc.board.clickup.listId, listName: doc.board.clickup.listName, syncEnabled: doc.board.clickup.syncEnabled } } : {}), }); }); // ── 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(), ...(t.clickup ? { clickup: { taskId: t.clickup.taskId, url: t.clickup.url, syncStatus: t.clickup.syncStatus } } : {}), })); // 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 verifyToken(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 verifyToken(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(':tasks: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(':tasks: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([]); }); // ── ClickUp integration helpers ── function getClickUpConnection(space: string): ClickUpConnectionDoc | null { if (!_syncServer) return null; return _syncServer.getDoc(clickupConnectionDocId(space)) ?? null; } function getAccessToken(space: string): string | null { const conn = getClickUpConnection(space); return conn?.clickup?.accessToken || null; } // ── API: ClickUp Integration ── // GET /api/clickup/status — connection status routes.get("/api/clickup/status", async (c) => { const space = c.req.param("space") || "demo"; const conn = getClickUpConnection(space); if (!conn?.clickup) return c.json({ connected: false }); // Count synced boards const boardDocIds = getBoardDocIds(space); let syncedBoards = 0; let pendingTasks = 0; for (const docId of boardDocIds) { const doc = _syncServer!.getDoc(docId); if (doc?.board?.clickup?.syncEnabled) { syncedBoards++; for (const task of Object.values(doc.tasks)) { if (task.clickup && task.clickup.syncStatus !== 'synced') pendingTasks++; } } } return c.json({ connected: true, teamId: conn.clickup.teamId, teamName: conn.clickup.teamName, connectedAt: conn.clickup.connectedAt, syncedBoards, pendingTasks, }); }); // POST /api/clickup/connect-token — connect via personal API token routes.post("/api/clickup/connect-token", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const space = c.req.param("space") || "demo"; const body = await c.req.json(); const apiToken = body.token; if (!apiToken) return c.json({ error: "ClickUp API token required" }, 400); // Verify token by fetching teams const client = new ClickUpClient(apiToken); let teams: any[]; try { teams = await client.getTeams(); } catch (err) { return c.json({ error: "Invalid ClickUp API token" }, 400); } const team = teams[0]; if (!team) return c.json({ error: "No ClickUp workspaces found" }, 400); // Generate webhook secret const secretBuf = new Uint8Array(32); crypto.getRandomValues(secretBuf); const webhookSecret = Array.from(secretBuf).map(b => b.toString(16).padStart(2, '0')).join(''); // Store connection const docId = clickupConnectionDocId(space); let connDoc = _syncServer!.getDoc(docId); if (!connDoc) { connDoc = Automerge.change(Automerge.init(), 'init clickup connection', (d) => { d.meta = { module: 'tasks', collection: 'clickup-connection', version: 1, spaceSlug: space, createdAt: Date.now() }; }); _syncServer!.setDoc(docId, connDoc); } _syncServer!.changeDoc(docId, 'Connect ClickUp via API token', (d) => { d.clickup = { accessToken: apiToken, teamId: team.id, teamName: team.name, connectedAt: Date.now(), webhookSecret, }; }); return c.json({ ok: true, teamId: team.id, teamName: team.name }); }); // POST /api/clickup/disconnect — disconnect + cleanup webhook routes.post("/api/clickup/disconnect", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const space = c.req.param("space") || "demo"; const conn = getClickUpConnection(space); if (conn?.clickup) { // Cleanup webhooks on synced boards const client = new ClickUpClient(conn.clickup.accessToken); const boardDocIds = getBoardDocIds(space); for (const docId of boardDocIds) { const doc = _syncServer!.getDoc(docId); if (doc?.board?.clickup?.webhookId) { try { await client.deleteWebhook(doc.board.clickup.webhookId); } catch {} _syncServer!.changeDoc(docId, 'Remove ClickUp sync', (d) => { delete d.board.clickup; }); } } // Remove connection _syncServer!.changeDoc(clickupConnectionDocId(space), 'Disconnect ClickUp', (d) => { delete d.clickup; }); } return c.json({ ok: true }); }); // GET /api/clickup/workspaces — list ClickUp teams routes.get("/api/clickup/workspaces", async (c) => { const space = c.req.param("space") || "demo"; const accessToken = getAccessToken(space); if (!accessToken) return c.json({ error: "Not connected to ClickUp" }, 400); const client = new ClickUpClient(accessToken); const teams = await client.getTeams(); return c.json(teams.map((t: any) => ({ id: t.id, name: t.name, members: t.members?.length || 0 }))); }); // GET /api/clickup/spaces/:teamId — list ClickUp spaces routes.get("/api/clickup/spaces/:teamId", async (c) => { const space = c.req.param("space") || "demo"; const accessToken = getAccessToken(space); if (!accessToken) return c.json({ error: "Not connected to ClickUp" }, 400); const teamId = c.req.param("teamId"); const client = new ClickUpClient(accessToken); const spaces = await client.getSpaces(teamId); return c.json(spaces.map((s: any) => ({ id: s.id, name: s.name }))); }); // GET /api/clickup/lists/:spaceId — list all lists in a space (including folders) routes.get("/api/clickup/lists/:spaceId", async (c) => { const space = c.req.param("space") || "demo"; const accessToken = getAccessToken(space); if (!accessToken) return c.json({ error: "Not connected to ClickUp" }, 400); const spaceId = c.req.param("spaceId"); const client = new ClickUpClient(accessToken); // Get folderless lists + lists inside folders const [folderlessLists, folders] = await Promise.all([ client.getFolderlessLists(spaceId), client.getFolders(spaceId), ]); const lists: any[] = folderlessLists.map((l: any) => ({ id: l.id, name: l.name, taskCount: l.task_count || 0, folder: null, })); for (const folder of folders) { const folderLists = folder.lists || []; for (const l of folderLists) { lists.push({ id: l.id, name: l.name, taskCount: l.task_count || 0, folder: folder.name }); } } return c.json(lists); }); // POST /api/clickup/import — import a ClickUp list → rTasks board routes.post("/api/clickup/import", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const space = c.req.param("space") || "demo"; const accessToken = getAccessToken(space); if (!accessToken) return c.json({ error: "Not connected to ClickUp" }, 400); const body = await c.req.json(); const { listId, boardSlug, enableSync } = body; if (!listId) return c.json({ error: "listId required" }, 400); const slug = boardSlug || `clickup-${listId}`; try { const result = await importClickUpList(_syncServer!, space, slug, listId, accessToken, { enableSync: enableSync ?? false, createNew: !boardSlug, }); // Register webhook if sync enabled if (enableSync) { const conn = getClickUpConnection(space); if (conn?.clickup) { const client = new ClickUpClient(accessToken); const host = c.req.header('host') || 'rspace.online'; const protocol = c.req.header('x-forwarded-proto') || 'https'; const endpoint = `${protocol}://${host}/${space}/rtasks/api/clickup/webhook`; try { const wh = await client.createWebhook( conn.clickup.teamId, endpoint, ['taskCreated', 'taskUpdated', 'taskStatusUpdated', 'taskDeleted'], conn.clickup.webhookSecret, ); // Store webhook ID on board _syncServer!.changeDoc(boardDocId(space, slug), 'Store webhook ID', (d) => { if (d.board.clickup) d.board.clickup.webhookId = wh.id; }); } catch (err) { console.error('[ClickUp] Failed to create webhook:', err); } } } return c.json(result, 201); } catch (err: any) { return c.json({ error: err.message || 'Import failed' }, 500); } }); // POST /api/clickup/push-board/:slug — export rTasks board → ClickUp list routes.post("/api/clickup/push-board/:slug", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const space = c.req.param("space") || "demo"; const accessToken = getAccessToken(space); if (!accessToken) return c.json({ error: "Not connected to ClickUp" }, 400); const slug = c.req.param("slug"); const body = await c.req.json(); const { listId } = body; if (!listId) return c.json({ error: "listId required" }, 400); try { const result = await pushBoardToClickUp(_syncServer!, space, slug, listId, accessToken); return c.json(result); } catch (err: any) { return c.json({ error: err.message || 'Push failed' }, 500); } }); // POST /api/clickup/sync-board/:slug — toggle two-way sync on/off routes.post("/api/clickup/sync-board/:slug", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const space = c.req.param("space") || "demo"; const slug = c.req.param("slug"); const body = await c.req.json(); const { enabled } = body; const docId = boardDocId(space, slug); const doc = _syncServer!.getDoc(docId); if (!doc) return c.json({ error: "Board not found" }, 404); if (!doc.board.clickup) return c.json({ error: "Board not connected to ClickUp" }, 400); _syncServer!.changeDoc(docId, `Toggle ClickUp sync ${enabled ? 'on' : 'off'}`, (d) => { if (d.board.clickup) d.board.clickup.syncEnabled = !!enabled; }); return c.json({ ok: true, syncEnabled: !!enabled }); }); // POST /api/clickup/webhook — receive ClickUp webhook events (public, no auth) routes.post("/api/clickup/webhook", async (c) => { const space = c.req.param("space") || "demo"; const body = await c.req.json(); const signature = c.req.header('x-signature') || null; const result = await handleClickUpWebhook(_syncServer!, space, body, signature); return c.json(result, result.ok ? 200 : 400); }); // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; const dataSpace = c.get("effectiveSpace") || space; return c.html(renderShell({ title: `${space} — Tasks | rSpace`, moduleId: "rtasks", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", body: ``, scripts: ``, styles: ``, })); }); export const tasksModule: RSpaceModule = { id: "rtasks", name: "rTasks", icon: "📋", description: "Kanban workspace boards for collaborative task management", scoping: { defaultScope: 'space', userConfigurable: false }, docSchemas: [ { pattern: '{space}:tasks:boards:{boardId}', description: 'Kanban board with tasks', init: boardSchema.init }, { pattern: '{space}:tasks:clickup-connection', description: 'ClickUp integration credentials', init: clickupConnectionSchema.init }, ], settingsSchema: [ { key: 'clickupApiToken', label: 'ClickUp API Token', type: 'password', description: 'Personal API token from ClickUp Settings > Apps (pk_...)' }, ], routes, standaloneDomain: "rtasks.online", landingPage: renderLanding, seedTemplate(space: string) { seedDemoIfEmpty(space); seedBCRGTasksIfEmpty(space); }, async onInit(ctx) { _syncServer = ctx.syncServer; seedDemoIfEmpty(); seedBCRGTasksIfEmpty('demo'); initClickUpSync(ctx.syncServer); }, 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: 'tasks', 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" }, ], onboardingActions: [ { label: "Create a Taskboard", icon: "📋", description: "Start a new kanban project board", type: 'create', href: '/{space}/rtasks' }, ], }; // ── MI Integration ── export interface MITaskItem { id: string; title: string; status: string; priority: string | null; description: string; createdAt: number; } /** * Read recent/open tasks directly from Automerge for the MI system prompt. */ export function getRecentTasksForMI(space: string, limit = 5): MITaskItem[] { if (!_syncServer) return []; const allTasks: MITaskItem[] = []; for (const docId of _syncServer.getDocIds()) { if (!docId.startsWith(`${space}:tasks:boards:`)) continue; const doc = _syncServer.getDoc(docId); if (!doc?.tasks) continue; for (const task of Object.values(doc.tasks)) { allTasks.push({ id: task.id, title: task.title, status: task.status, priority: task.priority, description: (task.description || "").slice(0, 200), createdAt: task.createdAt, }); } } // Prioritize non-DONE tasks, then sort by creation date return allTasks .filter((t) => t.status !== "DONE") .sort((a, b) => b.createdAt - a.createdAt) .slice(0, limit); }