rspace-online/modules/rtime/mod.ts

433 lines
16 KiB
TypeScript

/**
* rTime module — timebank commitment pool & weaving dashboard.
*
* Visualize community hour pledges as floating orbs in a basket,
* then weave commitments into tasks on an SVG canvas. Optional
* Cyclos integration for real timebank balances.
*
* All state stored in Automerge documents via SyncServer.
* Doc layout:
* {space}:rtime:commitments → CommitmentsDoc
* {space}:rtime:tasks → TasksDoc
*/
import { Hono } from "hono";
import * as Automerge from '@automerge/automerge';
import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import { verifyToken, extractToken } from "../../server/auth";
import { renderLanding } from "./landing";
import type { SyncServer } from '../../server/local-first/sync-server';
import {
commitmentsSchema, tasksSchema,
commitmentsDocId, tasksDocId,
} from './schemas';
import type {
CommitmentsDoc, TasksDoc,
Commitment, Task, Connection, ExecState, Skill,
} from './schemas';
import {
intentsSchema, solverResultsSchema,
skillCurvesSchema, reputationSchema,
} from './schemas-intent';
import { createIntentRoutes } from './intent-routes';
const routes = new Hono();
// ── SyncServer ref (set during onInit) ──
let _syncServer: SyncServer | null = null;
// ── Mount intent routes ──
const intentRoutes = createIntentRoutes(() => _syncServer);
routes.route('/', intentRoutes);
// ── Automerge helpers ──
function ensureCommitmentsDoc(space: string): CommitmentsDoc {
const docId = commitmentsDocId(space);
let doc = _syncServer!.getDoc<CommitmentsDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<CommitmentsDoc>(), 'init commitments', (d) => {
const init = commitmentsSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
});
_syncServer!.setDoc(docId, doc);
}
return doc;
}
function ensureTasksDoc(space: string): TasksDoc {
const docId = tasksDocId(space);
let doc = _syncServer!.getDoc<TasksDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<TasksDoc>(), 'init tasks', (d) => {
const init = tasksSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
});
_syncServer!.setDoc(docId, doc);
}
return doc;
}
function newId(): string {
return crypto.randomUUID();
}
// ── Cyclos proxy config ──
const CYCLOS_URL = process.env.CYCLOS_URL || '';
const CYCLOS_API_KEY = process.env.CYCLOS_API_KEY || '';
function cyclosHeaders(): Record<string, string> {
const h: Record<string, string> = { 'Content-Type': 'application/json' };
if (CYCLOS_API_KEY) h['Authorization'] = `Basic ${Buffer.from(CYCLOS_API_KEY).toString('base64')}`;
return h;
}
// ── Demo seeding ──
const DEMO_COMMITMENTS: Omit<Commitment, 'id' | 'createdAt'>[] = [
{ memberName: 'Maya Chen', hours: 3, skill: 'facilitation', desc: 'Circle facilitation for group sessions' },
{ memberName: 'Jordan Rivera', hours: 2, skill: 'design', desc: 'Event poster and social media graphics' },
{ memberName: 'Sam Okafor', hours: 4, skill: 'tech', desc: 'Website updates and form setup' },
{ memberName: 'Priya Sharma', hours: 2, skill: 'outreach', desc: 'Community outreach and flyering' },
{ memberName: 'Alex Kim', hours: 1, skill: 'logistics', desc: 'Venue setup and teardown' },
{ memberName: 'Taylor Brooks', hours: 3, skill: 'facilitation', desc: 'Harm reduction education session' },
{ memberName: 'Robin Patel', hours: 2, skill: 'design', desc: 'Printed resource cards' },
{ memberName: 'Casey Morgan', hours: 2, skill: 'logistics', desc: 'Supply procurement and transport' },
];
const DEMO_TASKS: Omit<Task, 'id'>[] = [
{ name: 'Organize Community Event', description: '', needs: { facilitation: 3, design: 2, outreach: 2, logistics: 2 }, links: [], notes: '' },
{ name: 'Run Harm Reduction Workshop', description: '', needs: { facilitation: 4, design: 2, tech: 4, logistics: 1 }, links: [], notes: '' },
];
function seedDemoIfEmpty(space: string = 'demo') {
if (!_syncServer) return;
const existing = _syncServer.getDoc<CommitmentsDoc>(commitmentsDocId(space));
if (existing?.meta?.seeded) return;
ensureCommitmentsDoc(space);
const now = Date.now();
_syncServer.changeDoc<CommitmentsDoc>(commitmentsDocId(space), 'seed commitments', (d) => {
for (const c of DEMO_COMMITMENTS) {
const id = newId();
d.items[id] = { id, ...c, createdAt: now } as any;
}
(d.meta as any).seeded = true;
});
ensureTasksDoc(space);
_syncServer.changeDoc<TasksDoc>(tasksDocId(space), 'seed tasks', (d) => {
for (const t of DEMO_TASKS) {
const id = newId();
d.tasks[id] = { id, ...t } as any;
}
});
console.log(`[rTime] Demo data seeded for "${space}": ${DEMO_COMMITMENTS.length} commitments, ${DEMO_TASKS.length} tasks`);
}
// ── Commitments API ──
routes.get("/api/commitments", (c) => {
const space = c.req.param("space") || "demo";
ensureCommitmentsDoc(space);
const doc = _syncServer!.getDoc<CommitmentsDoc>(commitmentsDocId(space))!;
const items = Object.values(doc.items);
return c.json({ commitments: items });
});
routes.post("/api/commitments", 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 space = c.req.param("space") || "demo";
const body = await c.req.json();
const { memberName, hours, skill, desc } = body;
if (!memberName || !hours || !skill) return c.json({ error: "memberName, hours, skill required" }, 400);
const id = newId();
const now = Date.now();
ensureCommitmentsDoc(space);
_syncServer!.changeDoc<CommitmentsDoc>(commitmentsDocId(space), 'add commitment', (d) => {
d.items[id] = { id, memberName, hours: Math.max(1, Math.min(10, hours)), skill, desc: desc || '', createdAt: now } as any;
});
const doc = _syncServer!.getDoc<CommitmentsDoc>(commitmentsDocId(space))!;
return c.json(doc.items[id], 201);
});
routes.delete("/api/commitments/:id", 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 id = c.req.param("id");
ensureCommitmentsDoc(space);
const doc = _syncServer!.getDoc<CommitmentsDoc>(commitmentsDocId(space))!;
if (!doc.items[id]) return c.json({ error: "Not found" }, 404);
_syncServer!.changeDoc<CommitmentsDoc>(commitmentsDocId(space), 'remove commitment', (d) => {
delete d.items[id];
});
return c.json({ ok: true });
});
// ── Tasks API ──
routes.get("/api/tasks", (c) => {
const space = c.req.param("space") || "demo";
ensureTasksDoc(space);
const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
return c.json({
tasks: Object.values(doc.tasks),
connections: Object.values(doc.connections),
execStates: Object.values(doc.execStates),
});
});
routes.post("/api/tasks", 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 { name, description, needs } = body;
if (!name || !needs) return c.json({ error: "name, needs required" }, 400);
const id = newId();
ensureTasksDoc(space);
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'add task', (d) => {
d.tasks[id] = { id, name, description: description || '', needs, links: [], notes: '' } as any;
});
const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
return c.json(doc.tasks[id], 201);
});
routes.put("/api/tasks/:id", 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 id = c.req.param("id");
const body = await c.req.json();
ensureTasksDoc(space);
const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
if (!doc.tasks[id]) return c.json({ error: "Not found" }, 404);
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'update task', (d) => {
const t = d.tasks[id];
if (body.name !== undefined) t.name = body.name;
if (body.description !== undefined) t.description = body.description;
if (body.needs !== undefined) t.needs = body.needs;
if (body.links !== undefined) t.links = body.links;
if (body.notes !== undefined) t.notes = body.notes;
});
const updated = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
return c.json(updated.tasks[id]);
});
// ── Connections API ──
routes.post("/api/connections", 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 { fromCommitmentId, toTaskId, skill } = body;
if (!fromCommitmentId || !toTaskId || !skill) return c.json({ error: "fromCommitmentId, toTaskId, skill required" }, 400);
const id = newId();
ensureTasksDoc(space);
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'add connection', (d) => {
d.connections[id] = { id, fromCommitmentId, toTaskId, skill } as any;
});
const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
return c.json(doc.connections[id], 201);
});
routes.delete("/api/connections/:id", 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 id = c.req.param("id");
ensureTasksDoc(space);
const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
if (!doc.connections[id]) return c.json({ error: "Not found" }, 404);
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'remove connection', (d) => {
delete d.connections[id];
});
return c.json({ ok: true });
});
// ── Exec State API ──
routes.put("/api/tasks/:id/exec-state", 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 taskId = c.req.param("id");
const body = await c.req.json();
const { steps, launchedAt } = body;
ensureTasksDoc(space);
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'update exec state', (d) => {
if (!d.execStates[taskId]) {
d.execStates[taskId] = { taskId, steps: {}, launchedAt: undefined } as any;
}
if (steps) d.execStates[taskId].steps = steps;
if (launchedAt) d.execStates[taskId].launchedAt = launchedAt;
});
const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
return c.json(doc.execStates[taskId]);
});
// ── Cyclos proxy API (graceful no-op when CYCLOS_URL not set) ──
routes.get("/api/cyclos/members", async (c) => {
if (!CYCLOS_URL) return c.json({ error: "Cyclos not configured", members: [] });
try {
const resp = await fetch(`${CYCLOS_URL}/api/users?roles=member&fields=id,display,email`, { headers: cyclosHeaders() });
if (!resp.ok) throw new Error(`Cyclos ${resp.status}`);
const users = await resp.json() as any[];
const members = await Promise.all(users.map(async (u: any) => {
try {
const balResp = await fetch(`${CYCLOS_URL}/api/${u.id}/accounts`, { headers: cyclosHeaders() });
const accounts = balResp.ok ? await balResp.json() as any[] : [];
const balance = accounts[0]?.status?.balance || '0';
return { id: u.id, name: u.display, email: u.email, balance: parseFloat(balance) };
} catch {
return { id: u.id, name: u.display, email: u.email, balance: 0 };
}
}));
return c.json({ members });
} catch (err: any) {
return c.json({ error: 'Failed to fetch from Cyclos', members: [] }, 502);
}
});
routes.post("/api/cyclos/commitments", async (c) => {
if (!CYCLOS_URL) return c.json({ error: "Cyclos not configured" }, 501);
const body = await c.req.json();
const { fromUserId, amount, description } = body;
if (!fromUserId || !amount) return c.json({ error: "fromUserId, amount required" }, 400);
try {
const resp = await fetch(`${CYCLOS_URL}/api/${fromUserId}/payments`, {
method: 'POST',
headers: cyclosHeaders(),
body: JSON.stringify({ type: 'user.toSystem', amount: String(amount), description: description || 'Commitment', subject: 'system' }),
});
if (!resp.ok) throw new Error(await resp.text());
const result = await resp.json();
return c.json(result);
} catch (err: any) {
return c.json({ error: 'Cyclos commitment failed' }, 502);
}
});
routes.post("/api/cyclos/transfers", async (c) => {
if (!CYCLOS_URL) return c.json({ error: "Cyclos not configured" }, 501);
const body = await c.req.json();
const { fromUserId, toUserId, amount, description } = body;
if (!fromUserId || !toUserId || !amount) return c.json({ error: "fromUserId, toUserId, amount required" }, 400);
try {
const resp = await fetch(`${CYCLOS_URL}/api/${fromUserId}/payments`, {
method: 'POST',
headers: cyclosHeaders(),
body: JSON.stringify({ type: 'user.toUser', amount: String(amount), description: description || 'Hour transfer', subject: toUserId }),
});
if (!resp.ok) throw new Error(await resp.text());
const result = await resp.json();
return c.json(result);
} catch (err: any) {
return c.json({ error: 'Cyclos transfer failed' }, 502);
}
});
// ── Page routes ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
title: `${space} — rTime | rSpace`,
moduleId: "rtime",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-timebank-app space="${space}" view="pool"></folk-timebank-app>`,
scripts: `<script type="module" src="/modules/rtime/folk-timebank-app.js?v=1"></script>`,
styles: `<link rel="stylesheet" href="/modules/rtime/rtime.css">`,
}));
});
// ── Module export ──
export const timeModule: RSpaceModule = {
id: "rtime",
name: "rTime",
icon: "⏳",
description: "Timebank commitment pool & weaving dashboard",
scoping: { defaultScope: 'space', userConfigurable: false },
docSchemas: [
{ pattern: '{space}:rtime:commitments', description: 'Commitment pool', init: commitmentsSchema.init },
{ pattern: '{space}:rtime:tasks', description: 'Tasks, connections, exec states', init: tasksSchema.init },
{ pattern: '{space}:rtime:intents', description: 'Intent pool (offers & needs)', init: intentsSchema.init },
{ pattern: '{space}:rtime:solver-results', description: 'Solver collaboration recommendations', init: solverResultsSchema.init },
{ pattern: '{space}:rtime:skill-curves', description: 'Per-skill demand/supply pricing', init: skillCurvesSchema.init },
{ pattern: '{space}:rtime:reputation', description: 'Per-member per-skill reputation', init: reputationSchema.init },
],
routes,
landingPage: renderLanding,
seedTemplate: seedDemoIfEmpty,
async onInit(ctx) {
_syncServer = ctx.syncServer;
seedDemoIfEmpty();
},
feeds: [
{
id: "commitments",
name: "Commitments",
kind: "economic",
description: "Hour pledges from community members",
filterable: true,
},
],
outputPaths: [
{ path: "commitments", name: "Commitments", icon: "🧺", description: "Community hour pledges" },
{ path: "weave", name: "Weave", icon: "🧶", description: "Task weaving dashboard" },
{ path: "collaborate", name: "Collaborate", icon: "🤝", description: "Intent-routed collaboration matching" },
],
onboardingActions: [
{ label: "Pledge Hours", icon: "⏳", description: "Add a commitment to the pool", type: 'create', href: '/rtime' },
],
};