rspace-online/modules/rwork/mod.ts

455 lines
16 KiB
TypeScript

/**
* 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<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}:work: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 work boards
const spaceWorkDocs = _syncServer!.getDocIds().filter((id) => id.startsWith(`${space}:work:boards:`));
if (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: '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 for "${space}": 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<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 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<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: '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<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(),
});
});
// ── 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<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 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<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,
});
});
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<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(':work: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([]);
});
// ── 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: `<folk-work-board space="${space}"></folk-work-board>`,
scripts: `<script type="module" src="/modules/rwork/folk-work-board.js?v=3"></script>`,
styles: `<link rel="stylesheet" href="/modules/rwork/work.css">`,
}));
});
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,
seedTemplate: seedDemoIfEmpty,
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<BoardDoc>();
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" },
],
};