/** * Work module — kanban workspace boards. * * Multi-tenant collaborative workspace with drag-and-drop kanban, * configurable statuses, and activity logging. */ import { Hono } from "hono"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { sql } from "../../shared/db/pool"; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; const routes = new Hono(); // ── DB initialization ── const SCHEMA_SQL = readFileSync(resolve(import.meta.dir, "db/schema.sql"), "utf-8"); async function initDB() { try { await sql.unsafe(SCHEMA_SQL); console.log("[Work] DB schema initialized"); } catch (e) { console.error("[Work] DB init error:", e); } } initDB(); // ── API: Spaces ── // GET /api/spaces — list workspaces routes.get("/api/spaces", async (c) => { const rows = await sql.unsafe( `SELECT s.*, count(DISTINCT sm.id)::int as member_count, count(DISTINCT t.id)::int as task_count FROM rwork.spaces s LEFT JOIN rwork.space_members sm ON sm.space_id = s.id LEFT JOIN rwork.tasks t ON t.space_id = s.id GROUP BY s.id ORDER BY s.created_at DESC` ); return c.json(rows); }); // POST /api/spaces — create workspace routes.post("/api/spaces", async (c) => { 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 rows = await sql.unsafe( `INSERT INTO rwork.spaces (name, slug, description, icon) VALUES ($1, $2, $3, $4) RETURNING *`, [name.trim(), slug, description || null, icon || null] ); return c.json(rows[0], 201); }); // GET /api/spaces/:slug — workspace detail routes.get("/api/spaces/:slug", async (c) => { const slug = c.req.param("slug"); const rows = await sql.unsafe("SELECT * FROM rwork.spaces WHERE slug = $1", [slug]); if (rows.length === 0) return c.json({ error: "Space not found" }, 404); return c.json(rows[0]); }); // ── 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 rows = await sql.unsafe( `SELECT t.*, u.username as assignee_name FROM rwork.tasks t JOIN rwork.spaces s ON s.id = t.space_id AND s.slug = $1 LEFT JOIN rwork.users u ON u.id = t.assignee_id ORDER BY t.status, t.sort_order, t.created_at DESC`, [slug] ); return c.json(rows); }); // POST /api/spaces/:slug/tasks — create task routes.post("/api/spaces/:slug/tasks", async (c) => { 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 space = await sql.unsafe("SELECT id, statuses FROM rwork.spaces WHERE slug = $1", [slug]); if (space.length === 0) return c.json({ error: "Space not found" }, 404); const taskStatus = status || space[0].statuses?.[0] || "TODO"; const rows = await sql.unsafe( `INSERT INTO rwork.tasks (space_id, title, description, status, priority, labels) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, [space[0].id, title.trim(), description || null, taskStatus, priority || "MEDIUM", labels || []] ); return c.json(rows[0], 201); }); // PATCH /api/tasks/:id — update task (status change, assignment, etc.) routes.patch("/api/tasks/:id", async (c) => { const id = c.req.param("id"); const body = await c.req.json(); const { title, description, status, priority, labels, sort_order, assignee_id } = body; const fields: string[] = []; const params: unknown[] = []; let idx = 1; if (title !== undefined) { fields.push(`title = $${idx}`); params.push(title); idx++; } if (description !== undefined) { fields.push(`description = $${idx}`); params.push(description); idx++; } if (status !== undefined) { fields.push(`status = $${idx}`); params.push(status); idx++; } if (priority !== undefined) { fields.push(`priority = $${idx}`); params.push(priority); idx++; } if (labels !== undefined) { fields.push(`labels = $${idx}`); params.push(labels); idx++; } if (sort_order !== undefined) { fields.push(`sort_order = $${idx}`); params.push(sort_order); idx++; } if (assignee_id !== undefined) { fields.push(`assignee_id = $${idx}`); params.push(assignee_id || null); idx++; } if (fields.length === 0) return c.json({ error: "No fields to update" }, 400); fields.push("updated_at = NOW()"); params.push(id); const rows = await sql.unsafe( `UPDATE rwork.tasks SET ${fields.join(", ")} WHERE id = $${idx} RETURNING *`, params ); if (rows.length === 0) return c.json({ error: "Task not found" }, 404); return c.json(rows[0]); }); // DELETE /api/tasks/:id routes.delete("/api/tasks/:id", async (c) => { const result = await sql.unsafe("DELETE FROM rwork.tasks WHERE id = $1 RETURNING id", [c.req.param("id")]); if (result.length === 0) return c.json({ error: "Task not found" }, 404); return c.json({ ok: true }); }); // ── API: Activity ── // GET /api/spaces/:slug/activity — recent activity routes.get("/api/spaces/:slug/activity", async (c) => { const slug = c.req.param("slug"); const rows = await sql.unsafe( `SELECT a.*, u.username FROM rwork.activity_log a JOIN rwork.spaces s ON s.id = a.space_id AND s.slug = $1 LEFT JOIN rwork.users u ON u.id = a.user_id ORDER BY a.created_at DESC LIMIT 50`, [slug] ); return c.json(rows); }); // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; return c.html(renderShell({ title: `${space} — Work | rSpace`, moduleId: "work", spaceSlug: space, modules: getModuleInfoList(), theme: "light", styles: ``, body: ``, scripts: ``, })); }); export const workModule: RSpaceModule = { id: "work", name: "rWork", icon: "\u{1F4CB}", description: "Kanban workspace boards for collaborative task management", routes, standaloneDomain: "rwork.online", };