250 lines
7.9 KiB
TypeScript
250 lines
7.9 KiB
TypeScript
/**
|
|
* Funds module — budget flows, river visualization, and treasury management.
|
|
*
|
|
* Proxies flow-service API calls and serves the BudgetRiver visualization.
|
|
*/
|
|
|
|
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 type { RSpaceModule } from "../../shared/module";
|
|
import { getModuleInfoList } from "../../shared/module";
|
|
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
|
|
|
|
const FLOW_SERVICE_URL = process.env.FLOW_SERVICE_URL || "http://payment-flow:3010";
|
|
|
|
// ── 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("[Funds] DB schema initialized");
|
|
} catch (e) {
|
|
console.error("[Funds] DB init error:", e);
|
|
}
|
|
}
|
|
|
|
initDB();
|
|
|
|
const routes = new Hono();
|
|
|
|
// ─── Flow Service API proxy ─────────────────────────────
|
|
// These proxy to the payment-flow backend so the frontend
|
|
// can call them from the same origin.
|
|
|
|
routes.get("/api/flows", async (c) => {
|
|
const owner = c.req.header("X-Owner-Address") || "";
|
|
const space = c.req.query("space") || "";
|
|
|
|
// If space filter provided, get flow IDs from space_flows table
|
|
if (space) {
|
|
try {
|
|
const rows = await sql.unsafe(
|
|
"SELECT flow_id FROM rfunds.space_flows WHERE space_slug = $1",
|
|
[space],
|
|
);
|
|
if (rows.length === 0) return c.json([]);
|
|
|
|
// Fetch each flow from flow-service
|
|
const flows = await Promise.all(
|
|
rows.map(async (r: any) => {
|
|
try {
|
|
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${r.flow_id}`);
|
|
if (res.ok) return await res.json();
|
|
} catch {}
|
|
return null;
|
|
}),
|
|
);
|
|
return c.json(flows.filter(Boolean));
|
|
} catch {
|
|
// Fall through to unfiltered fetch
|
|
}
|
|
}
|
|
|
|
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows?owner=${encodeURIComponent(owner)}`);
|
|
return c.json(await res.json(), res.status as any);
|
|
});
|
|
|
|
routes.get("/api/flows/:flowId", async (c) => {
|
|
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}`);
|
|
return c.json(await res.json(), res.status as any);
|
|
});
|
|
|
|
routes.post("/api/flows", async (c) => {
|
|
// Auth-gated: require EncryptID token
|
|
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.text();
|
|
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"X-Owner-Address": claims.sub,
|
|
},
|
|
body,
|
|
});
|
|
return c.json(await res.json(), res.status as any);
|
|
});
|
|
|
|
routes.post("/api/flows/:flowId/deposit", async (c) => {
|
|
const body = await c.req.text();
|
|
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/deposit`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body,
|
|
});
|
|
return c.json(await res.json(), res.status as any);
|
|
});
|
|
|
|
routes.post("/api/flows/:flowId/withdraw", async (c) => {
|
|
const body = await c.req.text();
|
|
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/withdraw`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body,
|
|
});
|
|
return c.json(await res.json(), res.status as any);
|
|
});
|
|
|
|
routes.post("/api/flows/:flowId/activate", async (c) => {
|
|
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/activate`, { method: "POST" });
|
|
return c.json(await res.json(), res.status as any);
|
|
});
|
|
|
|
routes.post("/api/flows/:flowId/pause", async (c) => {
|
|
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/pause`, { method: "POST" });
|
|
return c.json(await res.json(), res.status as any);
|
|
});
|
|
|
|
routes.post("/api/flows/:flowId/funnels", async (c) => {
|
|
const body = await c.req.text();
|
|
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/funnels`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body,
|
|
});
|
|
return c.json(await res.json(), res.status as any);
|
|
});
|
|
|
|
routes.post("/api/flows/:flowId/outcomes", async (c) => {
|
|
const body = await c.req.text();
|
|
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/outcomes`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body,
|
|
});
|
|
return c.json(await res.json(), res.status as any);
|
|
});
|
|
|
|
routes.get("/api/flows/:flowId/transactions", async (c) => {
|
|
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/transactions`);
|
|
return c.json(await res.json(), res.status as any);
|
|
});
|
|
|
|
// ─── Space-flow association endpoints ────────────────────
|
|
|
|
routes.post("/api/space-flows", 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 { space, flowId } = await c.req.json();
|
|
if (!space || !flowId) return c.json({ error: "space and flowId required" }, 400);
|
|
|
|
await sql.unsafe(
|
|
`INSERT INTO rfunds.space_flows (space_slug, flow_id, added_by)
|
|
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
|
|
[space, flowId, claims.sub],
|
|
);
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
routes.delete("/api/space-flows/:flowId", async (c) => {
|
|
const token = extractToken(c.req.raw.headers);
|
|
if (!token) return c.json({ error: "Authentication required" }, 401);
|
|
|
|
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
|
|
|
const flowId = c.req.param("flowId");
|
|
const space = c.req.query("space") || "";
|
|
if (!space) return c.json({ error: "space query param required" }, 400);
|
|
|
|
await sql.unsafe(
|
|
"DELETE FROM rfunds.space_flows WHERE space_slug = $1 AND flow_id = $2",
|
|
[space, flowId],
|
|
);
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
// ─── Page routes ────────────────────────────────────────
|
|
|
|
const fundsScripts = `
|
|
<script type="module" src="/modules/funds/folk-funds-app.js"></script>
|
|
<script type="module" src="/modules/funds/folk-budget-river.js"></script>`;
|
|
|
|
const fundsStyles = `<link rel="stylesheet" href="/modules/funds/funds.css">`;
|
|
|
|
// Landing page — TBFF info + flow list
|
|
routes.get("/", (c) => {
|
|
const spaceSlug = c.req.param("space") || "demo";
|
|
return c.html(renderShell({
|
|
title: `rFunds — TBFF Flow Funding | rSpace`,
|
|
moduleId: "funds",
|
|
spaceSlug,
|
|
modules: getModuleInfoList(),
|
|
theme: "light",
|
|
styles: fundsStyles,
|
|
body: `<folk-funds-app space="${spaceSlug}"></folk-funds-app>`,
|
|
scripts: fundsScripts,
|
|
}));
|
|
});
|
|
|
|
// Demo mode — hardcoded demo data, no API needed
|
|
routes.get("/demo", (c) => {
|
|
const spaceSlug = c.req.param("space") || "demo";
|
|
return c.html(renderShell({
|
|
title: `TBFF Demo — rFunds | rSpace`,
|
|
moduleId: "funds",
|
|
spaceSlug,
|
|
modules: getModuleInfoList(),
|
|
theme: "light",
|
|
styles: fundsStyles,
|
|
body: `<folk-funds-app space="${spaceSlug}" mode="demo"></folk-funds-app>`,
|
|
scripts: fundsScripts,
|
|
}));
|
|
});
|
|
|
|
// Flow detail — specific flow from API
|
|
routes.get("/flow/:flowId", (c) => {
|
|
const spaceSlug = c.req.param("space") || "demo";
|
|
const flowId = c.req.param("flowId");
|
|
return c.html(renderShell({
|
|
title: `Flow — rFunds | rSpace`,
|
|
moduleId: "funds",
|
|
spaceSlug,
|
|
modules: getModuleInfoList(),
|
|
theme: "light",
|
|
styles: fundsStyles,
|
|
body: `<folk-funds-app space="${spaceSlug}" flow-id="${flowId}"></folk-funds-app>`,
|
|
scripts: fundsScripts,
|
|
}));
|
|
});
|
|
|
|
export const fundsModule: RSpaceModule = {
|
|
id: "funds",
|
|
name: "rFunds",
|
|
icon: "\uD83C\uDF0A",
|
|
description: "Budget flows, river visualization, and treasury management",
|
|
routes,
|
|
standaloneDomain: "rfunds.online",
|
|
};
|