180 lines
6.2 KiB
TypeScript
180 lines
6.2 KiB
TypeScript
/**
|
|
* 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: `<link rel="stylesheet" href="/modules/work/work.css">`,
|
|
body: `<folk-work-board space="${space}"></folk-work-board>`,
|
|
scripts: `<script type="module" src="/modules/work/folk-work-board.js"></script>`,
|
|
}));
|
|
});
|
|
|
|
export const workModule: RSpaceModule = {
|
|
id: "work",
|
|
name: "rWork",
|
|
icon: "\u{1F4CB}",
|
|
description: "Kanban workspace boards for collaborative task management",
|
|
routes,
|
|
standaloneDomain: "rwork.online",
|
|
};
|