| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626 |
- const pull = require('../server/node_modules/pull-stream');
- const moment = require('../server/node_modules/moment');
- const { getConfig } = require('../configs/config-manager.js');
- const logLimit = getConfig().ssbLogStream?.limit || 1000;
- const CASE_ANSWER_DAYS = 7;
- const CASE_EVIDENCE_DAYS = 14;
- const CASE_DECISION_DAYS = 21;
- const POPULAR_DAYS = 14;
- const FEED_ID_RE = /^@.+\.ed25519$/;
- module.exports = ({ cooler, services = {} }) => {
- let ssb;
- let userId;
- const openSsb = async () => {
- if (!ssb) {
- ssb = await cooler.open();
- userId = ssb.id;
- }
- return ssb;
- };
- const nowISO = () => new Date().toISOString();
- const ensureArray = (x) => (Array.isArray(x) ? x : x ? [x] : []);
- async function readLog() {
- const ssbClient = await openSsb();
- return new Promise((resolve, reject) => {
- pull(
- ssbClient.createLogStream({ limit: logLimit }),
- pull.collect((err, arr) => (err ? reject(err) : resolve(arr)))
- );
- });
- }
- async function listByType(type) {
- const msgs = await readLog();
- const tomb = new Set();
- const rep = new Map();
- const map = new Map();
- for (const m of msgs) {
- const k = m.key || m.id;
- const c = m.value?.content || m.content;
- if (!c) continue;
- if (c.type === 'tombstone' && c.target) tomb.add(c.target);
- if (c.type === type) {
- if (c.replaces) rep.set(c.replaces, k);
- map.set(k, { id: k, ...c });
- }
- }
- for (const oldId of rep.keys()) map.delete(oldId);
- for (const tId of tomb) map.delete(tId);
- return [...map.values()];
- }
- async function getCurrentUserId() {
- await openSsb();
- return userId;
- }
- async function resolveRespondent(candidateInput) {
- const s = String(candidateInput || '').trim();
- if (!s) return null;
- if (FEED_ID_RE.test(s)) {
- return { type: 'inhabitant', id: s };
- }
- if (services.tribes && services.tribes.getTribeById) {
- try {
- const t = await services.tribes.getTribeById(s);
- if (t && t.id) return { type: 'tribe', id: t.id };
- } catch {}
- }
- return null;
- }
- function computeDeadlines(openedAt) {
- const answerBy = moment(openedAt).add(CASE_ANSWER_DAYS, 'days').toISOString();
- const evidenceBy = moment(openedAt).add(CASE_EVIDENCE_DAYS, 'days').toISOString();
- const decisionBy = moment(openedAt).add(CASE_DECISION_DAYS, 'days').toISOString();
- return { answerBy, evidenceBy, decisionBy };
- }
- async function openCase({ titleBase, respondentInput, method }) {
- const ssbClient = await openSsb();
- const rawTitle = String(titleBase || '').trim();
- if (!rawTitle) throw new Error('Title is required.');
- const resp = await resolveRespondent(respondentInput);
- if (!resp) throw new Error('Accused / Respondent not found.');
- const m = String(method || '').trim().toUpperCase();
- const ALLOWED = new Set(['JUDGE', 'DICTATOR', 'POPULAR', 'MEDIATION', 'KARMATOCRACY']);
- if (!ALLOWED.has(m)) throw new Error('Invalid resolution method.');
- if (m === 'DICTATOR' && services.parliament && services.parliament.getGovernmentCard) {
- try {
- const gov = await services.parliament.getGovernmentCard();
- const gm = String(gov && gov.method ? gov.method : '').toUpperCase();
- if (gm !== 'DICTATORSHIP') throw new Error('DICTATOR method requires DICTATORSHIP government.');
- } catch (e) {
- throw new Error('Unable to verify government method for DICTATOR.');
- }
- }
- const openedAt = nowISO();
- const prefix = moment(openedAt).format('MM/YYYY') + '_';
- const title = prefix + rawTitle;
- const { answerBy, evidenceBy, decisionBy } = computeDeadlines(openedAt);
- const content = {
- type: 'courtsCase',
- title,
- accuser: userId,
- respondentType: resp.type,
- respondentId: resp.id,
- method: m,
- status: 'OPEN',
- openedAt,
- answerBy,
- evidenceBy,
- decisionBy,
- mediatorsAccuser: [],
- mediatorsRespondent: [],
- createdAt: openedAt
- };
- return await new Promise((resolve, reject) =>
- ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
- );
- }
- async function listCases(filter = 'open') {
- const all = await listByType('courtsCase');
- const sorted = all.sort((a, b) => {
- const ta = new Date(a.openedAt || a.createdAt || 0).getTime();
- const tb = new Date(b.openedAt || b.createdAt || 0).getTime();
- return tb - ta;
- });
- if (filter === 'open') {
- return sorted.filter((c) => {
- const s = String(c.status || '').toUpperCase();
- return s !== 'DECIDED' && s !== 'CLOSED' && s !== 'SOLVED' && s !== 'UNSOLVED' && s !== 'DISCARDED';
- });
- }
- if (filter === 'history') {
- return sorted.filter((c) => {
- const s = String(c.status || '').toUpperCase();
- return s === 'DECIDED' || s === 'CLOSED' || s === 'SOLVED' || s === 'UNSOLVED' || s === 'DISCARDED';
- });
- }
- return sorted;
- }
- async function listCasesForUser(uid) {
- const all = await listByType('courtsCase');
- const id = String(uid || userId || '');
- const rows = [];
- for (const c of all) {
- const isAccuser = String(c.accuser || '') === id;
- const isRespondent = String(c.respondentId || '') === id;
- const ma = ensureArray(c.mediatorsAccuser || []);
- const mr = ensureArray(c.mediatorsRespondent || []);
- const isMediator = ma.includes(id) || mr.includes(id);
- const isJudge = String(c.judgeId || '') === id;
- const isDictator = false;
- const mine = isAccuser || isRespondent || isMediator || isJudge || isDictator;
- if (!mine) continue;
- let myPublicPreference = null;
- if (isAccuser && typeof c.publicPrefAccuser === 'boolean') {
- myPublicPreference = c.publicPrefAccuser;
- } else if (isRespondent && typeof c.publicPrefRespondent === 'boolean') {
- myPublicPreference = c.publicPrefRespondent;
- }
- rows.push({
- ...c,
- respondent: c.respondentId || c.respondent,
- isAccuser,
- isRespondent,
- isMediator,
- isJudge,
- isDictator,
- mine,
- myPublicPreference
- });
- }
- rows.sort((a, b) => {
- const ta = new Date(a.openedAt || a.createdAt || 0).getTime();
- const tb = new Date(b.openedAt || b.createdAt || 0).getTime();
- return tb - ta;
- });
- return rows;
- }
- async function getCaseById(caseId) {
- const id = String(caseId || '').trim();
- if (!id) return null;
- const all = await listByType('courtsCase');
- return all.find((c) => c.id === id) || null;
- }
- async function upsertCase(obj) {
- const ssbClient = await openSsb();
- const { id, ...rest } = obj;
- const updated = {
- ...rest,
- type: 'courtsCase',
- replaces: id,
- updatedAt: nowISO()
- };
- return await new Promise((resolve, reject) =>
- ssbClient.publish(updated, (err, msg) => (err ? reject(err) : resolve(msg)))
- );
- }
- function getCaseRole(caseObj, uid) {
- const id = String(uid || '');
- if (!id) return 'OTHER';
- if (String(caseObj.accuser || '') === id) return 'ACCUSER';
- if (String(caseObj.respondentId || '') === id) return 'DEFENCE';
- const ma = ensureArray(caseObj.mediatorsAccuser || []);
- const mr = ensureArray(caseObj.mediatorsRespondent || []);
- if (ma.includes(id) || mr.includes(id)) return 'MEDIATOR';
- if (String(caseObj.judgeId || '') === id) return 'JUDGE';
- return 'OTHER';
- }
- async function setMediators({ caseId, side, mediators }) {
- const c = await getCaseById(caseId);
- if (!c) throw new Error('Case not found.');
- const role = side === 'accuser' ? 'ACCUSER' : side === 'respondent' ? 'DEFENCE' : null;
- if (!role) throw new Error('Invalid side.');
- const myRole = getCaseRole(c, userId);
- if (role === 'ACCUSER' && myRole !== 'ACCUSER') throw new Error('Only accuser can set these mediators.');
- if (role === 'DEFENCE' && myRole !== 'DEFENCE') throw new Error('Only defence can set these mediators.');
- const list = Array.from(
- new Set(
- ensureArray(mediators || [])
- .map((x) => String(x || '').trim())
- .filter(Boolean)
- )
- );
- const clean = list.filter((id) => id !== c.accuser && id !== c.respondentId);
- if (side === 'accuser') c.mediatorsAccuser = clean;
- else c.mediatorsRespondent = clean;
- await upsertCase(c);
- return c;
- }
- async function assignJudge({ caseId, judgeId }) {
- const c = await getCaseById(caseId);
- if (!c) throw new Error('Case not found.');
- const m = String(c.method || '').toUpperCase();
- if (m !== 'JUDGE') throw new Error('This case does not use a judge.');
- const myRole = getCaseRole(c, userId);
- if (myRole !== 'ACCUSER' && myRole !== 'DEFENCE') throw new Error('Only parties can assign a judge.');
- const id = String(judgeId || '').trim();
- if (!id) throw new Error('Judge ID is required.');
- if (!FEED_ID_RE.test(id)) throw new Error('Invalid judge ID.');
- if (id === String(c.accuser || '') || id === String(c.respondentId || '')) {
- throw new Error('Judge cannot be a party of the case.');
- }
- c.judgeId = id;
- await upsertCase(c);
- return c;
- }
- async function addEvidence({ caseId, text, link, imageMarkdown }) {
- const c = await getCaseById(caseId);
- if (!c) throw new Error('Case not found.');
- const role = getCaseRole(c, userId);
- if (role === 'OTHER') throw new Error('You are not involved in this case.');
- const t = String(text || '').trim();
- const l = String(link || '').trim();
- let imageUrl = null;
- if (imageMarkdown) {
- const match = imageMarkdown.match(/\(([^)]+)\)/);
- imageUrl = match ? match[1] : imageMarkdown;
- }
- if (!t && !l && !imageUrl) throw new Error('Text, link or image is required.');
- const ssbClient = await openSsb();
- const content = {
- type: 'courtsEvidence',
- caseId: c.id,
- author: userId,
- role,
- text: t,
- link: l,
- imageUrl,
- createdAt: nowISO()
- };
- return await new Promise((resolve, reject) =>
- ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
- );
- }
- async function answerCase({ caseId, stance, text }) {
- const c = await getCaseById(caseId);
- if (!c) throw new Error('Case not found.');
- if (String(c.respondentId || '') !== String(userId || '')) throw new Error('Only the respondent can answer.');
- const s = String(stance || '').trim().toUpperCase();
- const ALLOWED = new Set(['DENY', 'ADMIT', 'PARTIAL']);
- if (!ALLOWED.has(s)) throw new Error('Invalid stance.');
- const t = String(text || '').trim();
- if (!t) throw new Error('Response text is required.');
- const ssbClient = await openSsb();
- const content = {
- type: 'courtsAnswer',
- caseId: c.id,
- respondent: userId,
- stance: s,
- text: t,
- createdAt: nowISO()
- };
- await new Promise((resolve, reject) =>
- ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
- );
- c.status = 'IN_PROGRESS';
- c.answeredAt = nowISO();
- await upsertCase(c);
- return c;
- }
- async function issueVerdict({ caseId, result, orders }) {
- const c = await getCaseById(caseId);
- if (!c) throw new Error('Case not found.');
- const involved =
- String(c.accuser || '') === String(userId || '') ||
- String(c.respondentId || '') === String(userId || '') ||
- ensureArray(c.mediatorsAccuser || []).includes(userId) ||
- ensureArray(c.mediatorsRespondent || []).includes(userId);
- if (involved) throw new Error('You cannot be judge and party in the same case.');
- const r = String(result || '').trim();
- if (!r) throw new Error('Result is required.');
- const o = String(orders || '').trim();
- const ssbClient = await openSsb();
- const content = {
- type: 'courtsVerdict',
- caseId: c.id,
- judgeId: userId,
- result: r,
- orders: o,
- createdAt: nowISO()
- };
- await new Promise((resolve, reject) =>
- ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
- );
- c.status = 'DECIDED';
- c.verdictAt = nowISO();
- c.judgeId = userId;
- await upsertCase(c);
- return c;
- }
- async function proposeSettlement({ caseId, terms }) {
- const c = await getCaseById(caseId);
- if (!c) throw new Error('Case not found.');
- const role = getCaseRole(c, userId);
- if (role === 'OTHER') throw new Error('You are not involved in this case.');
- const t = String(terms || '').trim();
- if (!t) throw new Error('Terms are required.');
- const ssbClient = await openSsb();
- const content = {
- type: 'courtsSettlementProposal',
- caseId: c.id,
- proposer: userId,
- terms: t,
- createdAt: nowISO()
- };
- return await new Promise((resolve, reject) =>
- ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
- );
- }
- async function acceptSettlement({ caseId }) {
- const c = await getCaseById(caseId);
- if (!c) throw new Error('Case not found.');
- const role = getCaseRole(c, userId);
- if (role !== 'ACCUSER' && role !== 'DEFENCE') throw new Error('Only parties can accept a settlement.');
- const ssbClient = await openSsb();
- const content = {
- type: 'courtsSettlementAccepted',
- caseId: c.id,
- by: userId,
- createdAt: nowISO()
- };
- await new Promise((resolve, reject) =>
- ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
- );
- c.status = 'CLOSED';
- c.closedAt = nowISO();
- await upsertCase(c);
- return c;
- }
- async function setPublicPreference({ caseId, preference }) {
- const c = await getCaseById(caseId);
- if (!c) throw new Error('Case not found.');
- const id = String(userId || '');
- const pref = !!preference;
- if (String(c.accuser || '') === id) {
- c.publicPrefAccuser = pref;
- } else if (String(c.respondentId || '') === id) {
- c.publicPrefRespondent = pref;
- } else {
- throw new Error('Only parties can set visibility preference.');
- }
- await upsertCase(c);
- return c;
- }
- async function openPopularVote({ caseId }) {
- if (!services.votes || !services.votes.createVote) throw new Error('Votes service not available.');
- const c = await getCaseById(caseId);
- if (!c) throw new Error('Case not found.');
- const m = String(c.method || '').toUpperCase();
- if (m !== 'POPULAR' && m !== 'KARMATOCRACY') throw new Error('This case does not use public voting.');
- if (c.voteId) throw new Error('Vote already opened.');
- const question = c.title || `Case ${caseId}`;
- const deadline = moment().add(POPULAR_DAYS, 'days').toISOString();
- const voteMsg = await services.votes.createVote(
- question,
- deadline,
- ['YES', 'NO', 'ABSTENTION'],
- [`courtsCase:${caseId}`, `courtsMethod:${m}`]
- );
- c.voteId = voteMsg.key || voteMsg.id;
- await upsertCase(c);
- return c;
- }
- async function getInhabitantKarma(feedId) {
- if (services.banking && services.banking.getUserEngagementScore) {
- try {
- const v = await services.banking.getUserEngagementScore(feedId);
- return Number(v || 0) || 0;
- } catch {
- return 0;
- }
- }
- return 0;
- }
- async function getFirstUserTimestamp(feedId) {
- const ssbClient = await openSsb();
- return new Promise((resolve) => {
- pull(
- ssbClient.createUserStream({ id: feedId, reverse: false }),
- pull.filter((m) => m && m.value && m.value.content && m.value.content.type !== 'tombstone'),
- pull.take(1),
- pull.collect((err, arr) => {
- if (err || !arr || !arr.length) return resolve(Date.now());
- const m = arr[0];
- const ts = (m.value && m.value.timestamp) || m.timestamp || Date.now();
- resolve(ts < 1e12 ? ts * 1000 : ts);
- })
- );
- });
- }
- async function nominateJudge({ judgeId }) {
- const id = String(judgeId || '').trim();
- if (!id) throw new Error('Judge ID is required.');
- if (!FEED_ID_RE.test(id)) throw new Error('Invalid judge ID.');
- const ssbClient = await openSsb();
- const content = {
- type: 'courtsNomination',
- judgeId: id,
- createdAt: nowISO()
- };
- return await new Promise((resolve, reject) =>
- ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
- );
- }
- async function voteNomination(nominationId) {
- const id = String(nominationId || '').trim();
- if (!id) throw new Error('Nomination not found.');
- const nominations = await listByType('courtsNomination');
- const nomination = nominations.find((n) => n.id === id);
- if (!nomination) throw new Error('Nomination not found.');
- if (String(nomination.judgeId || '') === String(userId || '')) {
- throw new Error('You cannot vote for yourself.');
- }
- const votes = await listByType('courtsNominationVote');
- const already = votes.find(
- (v) =>
- String(v.nominationId || '') === id &&
- String(v.voter || '') === String(userId || '')
- );
- if (already) throw new Error('You have already voted.');
- const ssbClient = await openSsb();
- const content = {
- type: 'courtsNominationVote',
- nominationId: id,
- voter: userId,
- createdAt: nowISO()
- };
- return await new Promise((resolve, reject) =>
- ssbClient.publish(content, (err, msg) => (err ? reject(err) : resolve(msg)))
- );
- }
- async function listNominations() {
- const nominations = await listByType('courtsNomination');
- const votes = await listByType('courtsNominationVote');
- const byId = new Map();
- for (const n of nominations) {
- byId.set(n.id, { ...n, supports: 0, karma: 0, profileSince: 0 });
- }
- for (const v of votes) {
- const rec = byId.get(v.nominationId);
- if (rec) rec.supports = (rec.supports || 0) + 1;
- }
- const rows = [];
- for (const rec of byId.values()) {
- const karma = await getInhabitantKarma(rec.judgeId);
- const since = await getFirstUserTimestamp(rec.judgeId);
- rows.push({ ...rec, karma, profileSince: since });
- }
- rows.sort((a, b) => {
- if ((b.supports || 0) !== (a.supports || 0)) return (b.supports || 0) - (a.supports || 0);
- if ((b.karma || 0) !== (a.karma || 0)) return (b.karma || 0) - (a.karma || 0);
- if ((a.profileSince || 0) !== (b.profileSince || 0)) return (a.profileSince || 0) - (b.profileSince || 0);
- const ta = new Date(a.createdAt || 0).getTime();
- const tb = new Date(b.createdAt || 0).getTime();
- if (ta !== tb) return ta - tb;
- return String(a.judgeId || '').localeCompare(String(b.judgeId || ''));
- });
- return rows;
- }
- async function getCaseDetails({ caseId }) {
- const id = String(caseId || '').trim();
- if (!id) return null;
- const base = await getCaseById(id);
- if (!base) return null;
- const currentUser = await getCurrentUserId();
- const me = String(currentUser || '');
- const accuserId = String(base.accuser || '');
- const respondentId = String(base.respondentId || '');
- const ma = ensureArray(base.mediatorsAccuser || []);
- const mr = ensureArray(base.mediatorsRespondent || []);
- const judgeId = String(base.judgeId || '');
- const dictatorId = String(base.dictatorId || '');
- const isAccuser = accuserId === me;
- const isRespondent = respondentId === me;
- const isMediator = ma.includes(me) || mr.includes(me);
- const isJudge = judgeId === me;
- const isDictator = dictatorId === me;
- const mine = isAccuser || isRespondent || isMediator || isJudge || isDictator;
- let myPublicPreference = null;
- if (isAccuser && typeof base.publicPrefAccuser === 'boolean') {
- myPublicPreference = base.publicPrefAccuser;
- } else if (isRespondent && typeof base.publicPrefRespondent === 'boolean') {
- myPublicPreference = base.publicPrefRespondent;
- }
- const publicDetails = base.publicPrefAccuser === true && base.publicPrefRespondent === true;
- const evidencesAll = await listByType('courtsEvidence');
- const answersAll = await listByType('courtsAnswer');
- const settlementsAll = await listByType('courtsSettlementProposal');
- const verdictsAll = await listByType('courtsVerdict');
- const acceptedAll = await listByType('courtsSettlementAccepted');
- const evidences = evidencesAll
- .filter((e) => String(e.caseId || '') === id)
- .sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
- const answers = answersAll
- .filter((a) => String(a.caseId || '') === id)
- .sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
- const settlements = settlementsAll
- .filter((s) => String(s.caseId || '') === id)
- .sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
- const verdicts = verdictsAll
- .filter((v) => String(v.caseId || '') === id)
- .sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
- const verdict = verdicts.length ? verdicts[verdicts.length - 1] : null;
- const acceptedSettlements = acceptedAll
- .filter((s) => String(s.caseId || '') === id)
- .sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
- const decidedAt =
- base.verdictAt ||
- base.closedAt ||
- (verdict && verdict.createdAt) ||
- base.decidedAt;
- const hasVerdict = !!verdict;
- const supportCount = typeof base.supportCount !== 'undefined' ? base.supportCount : 0;
- return {
- ...base,
- id,
- respondent: base.respondentId || base.respondent,
- evidences,
- answers,
- settlements,
- acceptedSettlements,
- verdict,
- decidedAt,
- isAccuser,
- isRespondent,
- isMediator,
- isJudge,
- isDictator,
- mine,
- publicDetails,
- myPublicPreference,
- supportCount,
- hasVerdict
- };
- }
- return {
- getCurrentUserId,
- openCase,
- listCases,
- listCasesForUser,
- getCaseById,
- setMediators,
- assignJudge,
- addEvidence,
- answerCase,
- issueVerdict,
- proposeSettlement,
- acceptSettlement,
- setPublicPreference,
- openPopularVote,
- nominateJudge,
- voteNomination,
- listNominations,
- getCaseDetails
- };
- };
|