455 lines
16 KiB
TypeScript
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" },
|
|
],
|
|
};
|