880 lines
32 KiB
TypeScript
880 lines
32 KiB
TypeScript
/**
|
|
* 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<BoardDoc>(id);
|
|
if (!doc) {
|
|
doc = Automerge.change(Automerge.init<BoardDoc>(), '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<BoardDoc>(boardDocId(space, space));
|
|
if ((_seedCheckDoc?.meta as any)?.seeded || spaceWorkDocs.length > 0) return;
|
|
|
|
const docId = boardDocId(space, space);
|
|
|
|
const doc = Automerge.change(Automerge.init<BoardDoc>(), '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<BoardDoc>(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<BoardDoc>(docId);
|
|
if (existing) return; // already seeded
|
|
|
|
const now = Date.now();
|
|
const doc = Automerge.change(Automerge.init<BoardDoc>(), '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<BoardDoc>(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<BoardDoc>(docId);
|
|
if (existing) return c.json({ error: "Space with this slug already exists" }, 409);
|
|
|
|
const now = Date.now();
|
|
const doc = Automerge.change(Automerge.init<BoardDoc>(), '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<BoardDoc>(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<string, number> = {};
|
|
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<BoardDoc>(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,
|
|
});
|
|
});
|
|
|
|
// Notify space members about the new task
|
|
import('../rinbox/agent-notify').then(({ sendSpaceNotification }) => {
|
|
sendSpaceNotification(slug, `New Task: ${title.trim()}`,
|
|
`<h3>${title.trim()}</h3>${description ? `<p>${description}</p>` : ''}<p><strong>Priority:</strong> ${priority || 'MEDIUM'}</p><p><a href="https://rspace.online/${slug}/rtasks">View in rTasks</a></p>`
|
|
).catch(() => {});
|
|
}).catch(() => {});
|
|
|
|
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<BoardDoc>(docId);
|
|
if (doc && doc.tasks[id]) {
|
|
targetDocId = docId;
|
|
break;
|
|
}
|
|
}
|
|
if (!targetDocId) return c.json({ error: "Task not found" }, 404);
|
|
|
|
_syncServer!.changeDoc<BoardDoc>(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<BoardDoc>(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<BoardDoc>(docId);
|
|
if (doc && doc.tasks[id]) {
|
|
targetDocId = docId;
|
|
break;
|
|
}
|
|
}
|
|
if (!targetDocId) return c.json({ error: "Task not found" }, 404);
|
|
|
|
_syncServer!.changeDoc<BoardDoc>(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<ClickUpConnectionDoc>(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<BoardDoc>(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<ClickUpConnectionDoc>(docId);
|
|
if (!connDoc) {
|
|
connDoc = Automerge.change(Automerge.init<ClickUpConnectionDoc>(), 'init clickup connection', (d) => {
|
|
d.meta = { module: 'tasks', collection: 'clickup-connection', version: 1, spaceSlug: space, createdAt: Date.now() };
|
|
});
|
|
_syncServer!.setDoc(docId, connDoc);
|
|
}
|
|
|
|
_syncServer!.changeDoc<ClickUpConnectionDoc>(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<BoardDoc>(docId);
|
|
if (doc?.board?.clickup?.webhookId) {
|
|
try { await client.deleteWebhook(doc.board.clickup.webhookId); } catch {}
|
|
_syncServer!.changeDoc<BoardDoc>(docId, 'Remove ClickUp sync', (d) => {
|
|
delete d.board.clickup;
|
|
});
|
|
}
|
|
}
|
|
|
|
// Remove connection
|
|
_syncServer!.changeDoc<ClickUpConnectionDoc>(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<BoardDoc>(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<BoardDoc>(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<BoardDoc>(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: `<folk-tasks-board space="${space}"></folk-tasks-board>`,
|
|
scripts: `<script type="module" src="/modules/rtasks/folk-tasks-board.js?v=4"></script>`,
|
|
styles: `<link rel="stylesheet" href="/modules/rtasks/tasks.css">`,
|
|
}));
|
|
});
|
|
|
|
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<BoardDoc>();
|
|
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<BoardDoc>(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);
|
|
}
|