271 lines
8.7 KiB
TypeScript
271 lines
8.7 KiB
TypeScript
/**
|
|
* Vote module — conviction voting engine.
|
|
*
|
|
* Credit-weighted conviction voting for collaborative governance.
|
|
* Spaces run ranked proposals with configurable parameters.
|
|
*/
|
|
|
|
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("[Vote] DB schema initialized");
|
|
} catch (e) {
|
|
console.error("[Vote] DB init error:", e);
|
|
}
|
|
}
|
|
|
|
initDB();
|
|
|
|
// ── Helper: get or create user by DID ──
|
|
async function getOrCreateUser(did: string, username?: string) {
|
|
const rows = await sql.unsafe(
|
|
`INSERT INTO rvote.users (did, username) VALUES ($1, $2)
|
|
ON CONFLICT (did) DO UPDATE SET username = COALESCE($2, rvote.users.username)
|
|
RETURNING *`,
|
|
[did, username || null]
|
|
);
|
|
return rows[0];
|
|
}
|
|
|
|
// ── Helper: calculate effective weight with decay ──
|
|
function getEffectiveWeight(weight: number, createdAt: Date): number {
|
|
const ageMs = Date.now() - createdAt.getTime();
|
|
const ageDays = ageMs / (1000 * 60 * 60 * 24);
|
|
if (ageDays < 30) return weight;
|
|
if (ageDays >= 60) return 0;
|
|
const decayProgress = (ageDays - 30) / 30;
|
|
return Math.round(weight * (1 - decayProgress));
|
|
}
|
|
|
|
// ── Helper: recalculate proposal score ──
|
|
async function recalcScore(proposalId: string) {
|
|
const votes = await sql.unsafe(
|
|
"SELECT weight, created_at FROM rvote.votes WHERE proposal_id = $1",
|
|
[proposalId]
|
|
);
|
|
let score = 0;
|
|
for (const v of votes) {
|
|
score += getEffectiveWeight(v.weight, new Date(v.created_at));
|
|
}
|
|
await sql.unsafe(
|
|
"UPDATE rvote.proposals SET score = $1, updated_at = NOW() WHERE id = $2",
|
|
[score, proposalId]
|
|
);
|
|
return score;
|
|
}
|
|
|
|
// ── Spaces API ──
|
|
|
|
// GET /api/spaces — list spaces
|
|
routes.get("/api/spaces", async (c) => {
|
|
const rows = await sql.unsafe(
|
|
"SELECT * FROM rvote.spaces ORDER BY created_at DESC LIMIT 50"
|
|
);
|
|
return c.json({ spaces: rows });
|
|
});
|
|
|
|
// POST /api/spaces — create space
|
|
routes.post("/api/spaces", async (c) => {
|
|
const body = await c.req.json();
|
|
const { name, slug, description, visibility = "public_read" } = body;
|
|
if (!name || !slug) return c.json({ error: "name and slug required" }, 400);
|
|
if (!/^[a-z0-9-]+$/.test(slug)) return c.json({ error: "Invalid slug" }, 400);
|
|
|
|
// TODO: extract DID from auth header
|
|
const ownerDid = "anonymous";
|
|
|
|
try {
|
|
const rows = await sql.unsafe(
|
|
`INSERT INTO rvote.spaces (slug, name, description, owner_did, visibility)
|
|
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
|
[slug, name, description || null, ownerDid, visibility]
|
|
);
|
|
return c.json(rows[0], 201);
|
|
} catch (e: any) {
|
|
if (e.code === "23505") return c.json({ error: "Space already exists" }, 409);
|
|
throw e;
|
|
}
|
|
});
|
|
|
|
// GET /api/spaces/:slug — space detail
|
|
routes.get("/api/spaces/:slug", async (c) => {
|
|
const slug = c.req.param("slug");
|
|
const rows = await sql.unsafe("SELECT * FROM rvote.spaces WHERE slug = $1", [slug]);
|
|
if (rows.length === 0) return c.json({ error: "Space not found" }, 404);
|
|
return c.json(rows[0]);
|
|
});
|
|
|
|
// ── Proposals API ──
|
|
|
|
// GET /api/proposals — list proposals (query: space_slug, status)
|
|
routes.get("/api/proposals", async (c) => {
|
|
const { space_slug, status, limit = "50", offset = "0" } = c.req.query();
|
|
const conditions: string[] = [];
|
|
const params: unknown[] = [];
|
|
let idx = 1;
|
|
|
|
if (space_slug) {
|
|
conditions.push(`space_slug = $${idx}`);
|
|
params.push(space_slug);
|
|
idx++;
|
|
}
|
|
if (status) {
|
|
conditions.push(`status = $${idx}`);
|
|
params.push(status);
|
|
idx++;
|
|
}
|
|
|
|
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
const rows = await sql.unsafe(
|
|
`SELECT * FROM rvote.proposals ${where} ORDER BY score DESC, created_at DESC LIMIT ${Math.min(parseInt(limit), 100)} OFFSET ${parseInt(offset) || 0}`,
|
|
params
|
|
);
|
|
return c.json({ proposals: rows });
|
|
});
|
|
|
|
// POST /api/proposals — create proposal
|
|
routes.post("/api/proposals", async (c) => {
|
|
const body = await c.req.json();
|
|
const { space_slug, title, description } = body;
|
|
if (!space_slug || !title) return c.json({ error: "space_slug and title required" }, 400);
|
|
|
|
// Verify space exists
|
|
const space = await sql.unsafe("SELECT slug FROM rvote.spaces WHERE slug = $1", [space_slug]);
|
|
if (space.length === 0) return c.json({ error: "Space not found" }, 404);
|
|
|
|
const rows = await sql.unsafe(
|
|
`INSERT INTO rvote.proposals (space_slug, author_id, title, description)
|
|
VALUES ($1, (SELECT id FROM rvote.users LIMIT 1), $2, $3) RETURNING *`,
|
|
[space_slug, title, description || null]
|
|
);
|
|
return c.json(rows[0], 201);
|
|
});
|
|
|
|
// GET /api/proposals/:id — proposal detail
|
|
routes.get("/api/proposals/:id", async (c) => {
|
|
const id = c.req.param("id");
|
|
const rows = await sql.unsafe(
|
|
`SELECT p.*,
|
|
(SELECT count(*) FROM rvote.votes WHERE proposal_id = p.id) as vote_count
|
|
FROM rvote.proposals p WHERE p.id = $1`,
|
|
[id]
|
|
);
|
|
if (rows.length === 0) return c.json({ error: "Proposal not found" }, 404);
|
|
return c.json(rows[0]);
|
|
});
|
|
|
|
// POST /api/proposals/:id/vote — cast conviction vote
|
|
routes.post("/api/proposals/:id/vote", async (c) => {
|
|
const id = c.req.param("id");
|
|
const body = await c.req.json();
|
|
const { weight = 1 } = body;
|
|
|
|
// Verify proposal is in RANKING
|
|
const proposal = await sql.unsafe(
|
|
"SELECT * FROM rvote.proposals WHERE id = $1",
|
|
[id]
|
|
);
|
|
if (proposal.length === 0) return c.json({ error: "Proposal not found" }, 404);
|
|
if (proposal[0].status !== "RANKING") return c.json({ error: "Proposal not in ranking phase" }, 400);
|
|
|
|
const creditCost = weight * weight; // quadratic cost
|
|
|
|
// Upsert vote
|
|
await sql.unsafe(
|
|
`INSERT INTO rvote.votes (user_id, proposal_id, weight, credit_cost, decays_at)
|
|
VALUES ((SELECT id FROM rvote.users LIMIT 1), $1, $2, $3, NOW() + INTERVAL '30 days')
|
|
ON CONFLICT (user_id, proposal_id)
|
|
DO UPDATE SET weight = $2, credit_cost = $3, created_at = NOW(), decays_at = NOW() + INTERVAL '30 days'`,
|
|
[id, weight, creditCost]
|
|
);
|
|
|
|
// Recalculate score and check for promotion
|
|
const score = await recalcScore(id);
|
|
const space = await sql.unsafe(
|
|
"SELECT * FROM rvote.spaces WHERE slug = $1",
|
|
[proposal[0].space_slug]
|
|
);
|
|
const threshold = space[0]?.promotion_threshold || 100;
|
|
|
|
if (score >= threshold && proposal[0].status === "RANKING") {
|
|
const votingDays = space[0]?.voting_period_days || 7;
|
|
await sql.unsafe(
|
|
`UPDATE rvote.proposals SET status = 'VOTING', voting_ends_at = NOW() + ($1 || ' days')::INTERVAL, updated_at = NOW() WHERE id = $2`,
|
|
[votingDays, id]
|
|
);
|
|
}
|
|
|
|
return c.json({ ok: true, score, creditCost });
|
|
});
|
|
|
|
// POST /api/proposals/:id/final-vote — cast binary vote
|
|
routes.post("/api/proposals/:id/final-vote", async (c) => {
|
|
const id = c.req.param("id");
|
|
const body = await c.req.json();
|
|
const { vote } = body;
|
|
if (!["YES", "NO", "ABSTAIN"].includes(vote)) return c.json({ error: "Invalid vote" }, 400);
|
|
|
|
const proposal = await sql.unsafe("SELECT * FROM rvote.proposals WHERE id = $1", [id]);
|
|
if (proposal.length === 0) return c.json({ error: "Proposal not found" }, 404);
|
|
if (proposal[0].status !== "VOTING") return c.json({ error: "Proposal not in voting phase" }, 400);
|
|
|
|
await sql.unsafe(
|
|
`INSERT INTO rvote.final_votes (user_id, proposal_id, vote)
|
|
VALUES ((SELECT id FROM rvote.users LIMIT 1), $1, $2)
|
|
ON CONFLICT (user_id, proposal_id) DO UPDATE SET vote = $2`,
|
|
[id, vote]
|
|
);
|
|
|
|
// Update counts
|
|
const counts = await sql.unsafe(
|
|
`SELECT vote, count(*) as cnt FROM rvote.final_votes WHERE proposal_id = $1 GROUP BY vote`,
|
|
[id]
|
|
);
|
|
const tally: Record<string, number> = { YES: 0, NO: 0, ABSTAIN: 0 };
|
|
for (const row of counts) tally[row.vote] = parseInt(row.cnt);
|
|
|
|
await sql.unsafe(
|
|
"UPDATE rvote.proposals SET final_yes = $1, final_no = $2, final_abstain = $3, updated_at = NOW() WHERE id = $4",
|
|
[tally.YES, tally.NO, tally.ABSTAIN, id]
|
|
);
|
|
|
|
return c.json({ ok: true, tally });
|
|
});
|
|
|
|
// ── Page route ──
|
|
routes.get("/", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
return c.html(renderShell({
|
|
title: `${space} — Vote | rSpace`,
|
|
moduleId: "vote",
|
|
spaceSlug: space,
|
|
modules: getModuleInfoList(),
|
|
theme: "light",
|
|
styles: `<link rel="stylesheet" href="/modules/vote/vote.css">`,
|
|
body: `<folk-vote-dashboard space="${space}"></folk-vote-dashboard>`,
|
|
scripts: `<script type="module" src="/modules/vote/folk-vote-dashboard.js"></script>`,
|
|
}));
|
|
});
|
|
|
|
export const voteModule: RSpaceModule = {
|
|
id: "vote",
|
|
name: "rVote",
|
|
icon: "\u{1F5F3}",
|
|
description: "Conviction voting engine for collaborative governance",
|
|
routes,
|
|
standaloneDomain: "rvote.online",
|
|
};
|