812 lines
29 KiB
TypeScript
812 lines
29 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 { notify } from '../../server/notification-service';
|
|
import type { SyncServer } from '../../server/local-first/sync-server';
|
|
import {
|
|
commitmentsSchema, tasksSchema, externalTimeLogsSchema,
|
|
commitmentsDocId, tasksDocId, externalTimeLogsDocId,
|
|
} from './schemas';
|
|
import type {
|
|
CommitmentsDoc, TasksDoc, ExternalTimeLogsDoc,
|
|
Commitment, Task, Connection, ExecState, Skill, ExternalTimeLog,
|
|
} 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();
|
|
}
|
|
|
|
// ── External Time Logs helpers ──
|
|
|
|
function ensureExternalTimeLogsDoc(space: string): ExternalTimeLogsDoc {
|
|
const docId = externalTimeLogsDocId(space);
|
|
let doc = _syncServer!.getDoc<ExternalTimeLogsDoc>(docId);
|
|
if (!doc) {
|
|
doc = Automerge.change(Automerge.init<ExternalTimeLogsDoc>(), 'init external-time-logs', (d) => {
|
|
const init = externalTimeLogsSchema.init();
|
|
Object.assign(d, init);
|
|
d.meta.spaceSlug = space;
|
|
});
|
|
_syncServer!.setDoc(docId, doc);
|
|
}
|
|
return doc;
|
|
}
|
|
|
|
// ── External Time Logs API (backlog-md integration) ──
|
|
|
|
routes.post("/api/external-time-logs", 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 { backlogTaskId, backlogTaskTitle, memberName, hours, skill, note, loggedAt } = body;
|
|
|
|
if (!backlogTaskId || !memberName || !hours || !skill) {
|
|
return c.json({ error: "backlogTaskId, memberName, hours, skill required" }, 400);
|
|
}
|
|
if (typeof hours !== 'number' || hours <= 0) {
|
|
return c.json({ error: "hours must be a positive number" }, 400);
|
|
}
|
|
|
|
const VALID_SKILLS = ['facilitation', 'design', 'tech', 'outreach', 'logistics'];
|
|
if (!VALID_SKILLS.includes(skill)) {
|
|
return c.json({ error: `skill must be one of: ${VALID_SKILLS.join(', ')}` }, 400);
|
|
}
|
|
|
|
const id = newId();
|
|
const now = Date.now();
|
|
|
|
ensureExternalTimeLogsDoc(space);
|
|
ensureCommitmentsDoc(space);
|
|
|
|
// Create external time log entry
|
|
_syncServer!.changeDoc<ExternalTimeLogsDoc>(externalTimeLogsDocId(space), 'import time log', (d) => {
|
|
d.logs[id] = {
|
|
id,
|
|
backlogTaskId,
|
|
backlogTaskTitle: backlogTaskTitle || backlogTaskId,
|
|
memberName,
|
|
memberId: (claims.did as string) || undefined,
|
|
hours,
|
|
skill: skill as Skill,
|
|
note: note || undefined,
|
|
loggedAt: loggedAt || now,
|
|
importedAt: now,
|
|
status: 'pending',
|
|
} as any;
|
|
});
|
|
|
|
// Auto-create a commitment from this time log
|
|
const commitmentId = newId();
|
|
_syncServer!.changeDoc<CommitmentsDoc>(commitmentsDocId(space), 'auto-create commitment from time log', (d) => {
|
|
d.items[commitmentId] = {
|
|
id: commitmentId,
|
|
memberName,
|
|
hours: Math.max(1, Math.min(10, hours)),
|
|
skill: skill as Skill,
|
|
desc: note || `Time logged: ${backlogTaskTitle || backlogTaskId}`,
|
|
createdAt: now,
|
|
status: 'active',
|
|
ownerDid: (claims.did as string) || '',
|
|
} as any;
|
|
});
|
|
|
|
// Link commitment to the time log
|
|
_syncServer!.changeDoc<ExternalTimeLogsDoc>(externalTimeLogsDocId(space), 'link commitment', (d) => {
|
|
d.logs[id].commitmentId = commitmentId as any;
|
|
d.logs[id].status = 'commitment_created' as any;
|
|
});
|
|
|
|
// Auto-connect to rTime task if a linked task exists
|
|
const tasksDocRef = _syncServer!.getDoc<TasksDoc>(tasksDocId(space));
|
|
if (tasksDocRef) {
|
|
const linkedTask = Object.values(tasksDocRef.tasks).find(
|
|
(t) => t.description?.includes(backlogTaskId) || t.name?.includes(backlogTaskId)
|
|
);
|
|
if (linkedTask) {
|
|
ensureTasksDoc(space);
|
|
const connId = newId();
|
|
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'auto-connect time log to task', (d) => {
|
|
d.connections[connId] = {
|
|
id: connId,
|
|
fromCommitmentId: commitmentId,
|
|
toTaskId: linkedTask.id,
|
|
skill,
|
|
hours,
|
|
status: 'committed',
|
|
} as any;
|
|
});
|
|
}
|
|
}
|
|
|
|
const doc = _syncServer!.getDoc<ExternalTimeLogsDoc>(externalTimeLogsDocId(space))!;
|
|
return c.json({ log: doc.logs[id], commitmentId }, 201);
|
|
});
|
|
|
|
routes.get("/api/external-time-logs", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
ensureExternalTimeLogsDoc(space);
|
|
const doc = _syncServer!.getDoc<ExternalTimeLogsDoc>(externalTimeLogsDocId(space))!;
|
|
let logs = Object.values(doc.logs);
|
|
|
|
// Filter by query params
|
|
const backlogTaskId = c.req.query("backlogTaskId");
|
|
const memberName = c.req.query("memberName");
|
|
const status = c.req.query("status");
|
|
if (backlogTaskId) logs = logs.filter(l => l.backlogTaskId === backlogTaskId);
|
|
if (memberName) logs = logs.filter(l => l.memberName === memberName);
|
|
if (status) logs = logs.filter(l => l.status === status);
|
|
|
|
return c.json({ logs });
|
|
});
|
|
|
|
routes.post("/api/external-time-logs/:id/settle", 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 logId = c.req.param("id");
|
|
ensureExternalTimeLogsDoc(space);
|
|
|
|
const doc = _syncServer!.getDoc<ExternalTimeLogsDoc>(externalTimeLogsDocId(space))!;
|
|
const log = doc.logs[logId];
|
|
if (!log) return c.json({ error: "Time log not found" }, 404);
|
|
if (log.status === 'settled') return c.json({ error: "Already settled" }, 400);
|
|
|
|
// Update log status to settled
|
|
_syncServer!.changeDoc<ExternalTimeLogsDoc>(externalTimeLogsDocId(space), 'settle time log', (d) => {
|
|
d.logs[logId].status = 'settled' as any;
|
|
});
|
|
|
|
// Update commitment status
|
|
if (log.commitmentId) {
|
|
ensureCommitmentsDoc(space);
|
|
const cDoc = _syncServer!.getDoc<CommitmentsDoc>(commitmentsDocId(space))!;
|
|
if (cDoc.items[log.commitmentId]) {
|
|
_syncServer!.changeDoc<CommitmentsDoc>(commitmentsDocId(space), 'settle commitment', (d) => {
|
|
d.items[log.commitmentId!].status = 'settled' as any;
|
|
});
|
|
}
|
|
}
|
|
|
|
// Update reputation: self-attestation with neutral rating (3/5)
|
|
const memberId = log.memberId || log.memberName;
|
|
const { reputationDocId } = await import('./schemas-intent');
|
|
const repDocId = reputationDocId(space);
|
|
const { reputationKey } = await import('./reputation');
|
|
const key = reputationKey(memberId, log.skill);
|
|
|
|
const repDoc = _syncServer!.getDoc<any>(repDocId);
|
|
if (repDoc) {
|
|
_syncServer!.changeDoc<any>(repDocId, 'update reputation from settled time log', (d: any) => {
|
|
if (!d.entries[key]) {
|
|
d.entries[key] = {
|
|
memberId,
|
|
skill: log.skill,
|
|
score: 50,
|
|
completedHours: 0,
|
|
ratings: [],
|
|
};
|
|
}
|
|
d.entries[key].completedHours = (d.entries[key].completedHours || 0) + log.hours;
|
|
// Self-attestation: neutral 3/5 rating
|
|
d.entries[key].ratings.push({
|
|
from: memberId,
|
|
score: 3,
|
|
timestamp: Date.now(),
|
|
});
|
|
});
|
|
}
|
|
|
|
// Update skill curves: add supply hours
|
|
const { skillCurvesDocId } = await import('./schemas-intent');
|
|
const scDocId = skillCurvesDocId(space);
|
|
const scDoc = _syncServer!.getDoc<any>(scDocId);
|
|
if (scDoc) {
|
|
_syncServer!.changeDoc<any>(scDocId, 'update skill curve from settled time log', (d: any) => {
|
|
if (!d.curves[log.skill]) {
|
|
d.curves[log.skill] = {
|
|
skill: log.skill,
|
|
supplyHours: 0,
|
|
demandHours: 0,
|
|
currentPrice: 100,
|
|
history: [],
|
|
};
|
|
}
|
|
d.curves[log.skill].supplyHours = (d.curves[log.skill].supplyHours || 0) + log.hours;
|
|
});
|
|
}
|
|
|
|
return c.json({ ok: true, logId, status: 'settled' });
|
|
});
|
|
|
|
routes.post("/api/tasks/:id/link-backlog", 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 { backlogTaskId } = body;
|
|
if (!backlogTaskId) return c.json({ error: "backlogTaskId required" }, 400);
|
|
|
|
ensureTasksDoc(space);
|
|
const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
|
|
if (!doc.tasks[taskId]) return c.json({ error: "Task not found" }, 404);
|
|
|
|
// Store backlog task ID in the task description as a cross-reference
|
|
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'link backlog task', (d) => {
|
|
const t = d.tasks[taskId];
|
|
const ref = `[backlog:${backlogTaskId}]`;
|
|
if (!t.description.includes(ref)) {
|
|
t.description = t.description ? `${t.description}\n${ref}` : ref;
|
|
}
|
|
});
|
|
|
|
const updated = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
|
|
return c.json(updated.tasks[taskId]);
|
|
});
|
|
|
|
// ── 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, ownerDid: (claims.did as string) || '' } 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);
|
|
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 { fromCommitmentId, toTaskId, skill, hours } = body;
|
|
if (!fromCommitmentId || !toTaskId || !skill) return c.json({ error: "fromCommitmentId, toTaskId, skill required" }, 400);
|
|
if (typeof hours !== 'number' || hours <= 0) return c.json({ error: "hours must be a positive number" }, 400);
|
|
|
|
const id = newId();
|
|
ensureTasksDoc(space);
|
|
ensureCommitmentsDoc(space);
|
|
|
|
// Validate: hours <= commitment's available hours
|
|
const cDoc = _syncServer!.getDoc<CommitmentsDoc>(commitmentsDocId(space));
|
|
const commitment = cDoc?.items?.[fromCommitmentId];
|
|
if (!commitment) return c.json({ error: "Commitment not found" }, 404);
|
|
const tDoc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
|
|
const usedHours = Object.values(tDoc.connections)
|
|
.filter(cn => cn.fromCommitmentId === fromCommitmentId)
|
|
.reduce((sum, cn) => sum + (cn.hours || 0), 0);
|
|
if (hours > commitment.hours - usedHours) {
|
|
return c.json({ error: "Requested hours exceed available hours" }, 400);
|
|
}
|
|
|
|
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'add connection', (d) => {
|
|
d.connections[id] = { id, fromCommitmentId, toTaskId, skill, hours, status: 'proposed' } as any;
|
|
});
|
|
|
|
// Notify commitment owner that their time was requested
|
|
const updatedTDoc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
|
|
const task = updatedTDoc.tasks?.[toTaskId];
|
|
if (commitment?.ownerDid && commitment.ownerDid !== claims.did) {
|
|
notify({
|
|
userDid: commitment.ownerDid,
|
|
category: 'module',
|
|
eventType: 'commitment_requested',
|
|
title: `${hours}hr of your ${commitment.hours}hr ${skill} commitment was requested`,
|
|
body: task ? `Task: ${task.name}` : undefined,
|
|
spaceSlug: space,
|
|
moduleId: 'rtime',
|
|
actionUrl: `/rtime`,
|
|
actorDid: claims.did as string | undefined,
|
|
metadata: { resultId: id, fromCommitmentId },
|
|
}).catch(() => {});
|
|
}
|
|
|
|
return c.json(updatedTDoc.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);
|
|
let claims;
|
|
try { claims = 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);
|
|
ensureCommitmentsDoc(space);
|
|
|
|
const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
|
|
const connection = doc.connections[id];
|
|
if (!connection) return c.json({ error: "Not found" }, 404);
|
|
|
|
// Look up commitment owner to notify them
|
|
const cDoc = _syncServer!.getDoc<CommitmentsDoc>(commitmentsDocId(space));
|
|
const commitment = cDoc?.items?.[connection.fromCommitmentId];
|
|
|
|
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'remove connection', (d) => {
|
|
delete d.connections[id];
|
|
});
|
|
|
|
// Notify commitment owner that the request was declined
|
|
if (commitment?.ownerDid && commitment.ownerDid !== (claims.did as string)) {
|
|
notify({
|
|
userDid: commitment.ownerDid,
|
|
category: 'module',
|
|
eventType: 'commitment_declined',
|
|
title: `Your ${commitment.hours}hr ${commitment.skill} commitment request was declined`,
|
|
spaceSlug: space,
|
|
moduleId: 'rtime',
|
|
actionUrl: `/rtime`,
|
|
actorDid: claims.did as string | undefined,
|
|
}).catch(() => {});
|
|
}
|
|
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
routes.patch("/api/connections/:id", 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 id = c.req.param("id");
|
|
const body = await c.req.json();
|
|
const { status } = body;
|
|
if (status !== 'committed' && status !== 'declined') return c.json({ error: "status must be 'committed' or 'declined'" }, 400);
|
|
|
|
ensureTasksDoc(space);
|
|
ensureCommitmentsDoc(space);
|
|
|
|
const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
|
|
const connection = doc.connections[id];
|
|
if (!connection) return c.json({ error: "Not found" }, 404);
|
|
|
|
if (status === 'declined') {
|
|
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'decline connection', (d) => {
|
|
delete d.connections[id];
|
|
});
|
|
return c.json({ ok: true, deleted: true });
|
|
}
|
|
|
|
// status === 'committed'
|
|
_syncServer!.changeDoc<TasksDoc>(tasksDocId(space), 'approve connection', (d) => {
|
|
d.connections[id].status = 'committed' as any;
|
|
});
|
|
|
|
const updated = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
|
|
return c.json(updated.connections[id]);
|
|
});
|
|
|
|
// ── 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">`,
|
|
}));
|
|
});
|
|
|
|
routes.post("/api/tasks/:id/export-to-backlog", 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");
|
|
ensureTasksDoc(space);
|
|
|
|
const doc = _syncServer!.getDoc<TasksDoc>(tasksDocId(space))!;
|
|
const task = doc.tasks[taskId];
|
|
if (!task) return c.json({ error: "Task not found" }, 404);
|
|
|
|
// Calculate total needs as estimated hours
|
|
const estimatedHours = Object.values(task.needs).reduce((sum, h) => sum + h, 0);
|
|
|
|
// Map task needs to acceptance criteria
|
|
const acceptanceCriteria = Object.entries(task.needs).map(([skill, hours]) =>
|
|
`${SKILL_LABELS[skill as Skill] || skill}: ${hours}h`
|
|
);
|
|
|
|
return c.json({
|
|
title: task.name,
|
|
description: task.description?.replace(/\[backlog:[^\]]+\]/g, '').trim() || '',
|
|
estimatedHours,
|
|
labels: Object.keys(task.needs),
|
|
notes: task.notes || '',
|
|
acceptanceCriteria,
|
|
rtime: {
|
|
taskId: task.id,
|
|
space,
|
|
},
|
|
});
|
|
});
|
|
|
|
routes.get("/dashboard", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
|
|
return c.html(renderShell({
|
|
title: `${space} — Fulfillment | rTime | rSpace`,
|
|
moduleId: "rtime",
|
|
spaceSlug: space,
|
|
modules: getModuleInfoList(),
|
|
theme: "dark",
|
|
body: `<folk-timebank-app space="${space}" view="dashboard"></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",
|
|
canvasShapes: ["folk-commitment-pool", "folk-task-request"],
|
|
canvasToolIds: ["create_commitment_pool", "create_task_request"],
|
|
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 },
|
|
{ pattern: '{space}:rtime:external-time-logs', description: 'External time logs from backlog-md', init: externalTimeLogsSchema.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: "canvas", name: "Canvas", icon: "🧺", description: "Unified commitment pool & task weaving canvas" },
|
|
{ path: "collaborate", name: "Collaborate", icon: "🤝", description: "Intent-routed collaboration matching" },
|
|
{ path: "dashboard", name: "Fulfillment", icon: "📊", description: "Personal commitment fulfillment tracking" },
|
|
],
|
|
onboardingActions: [
|
|
{ label: "Pledge Hours", icon: "⏳", description: "Add a commitment to the pool", type: 'create', href: '/rtime' },
|
|
],
|
|
};
|