OASIS_MOBILE/src/models/votes_model.js
oasis-bot c332afd6ea upstream: integrar Oasis 1.3.1
Base del merge a tres bandas: git 0.9.5 de epsylon, esta rama y el src de la
APK oficial 1.3.1 (la variante movil: OASIS_MOBILE, regex sin \p{}, audio y
video de Android). Corre en el mismo nodejs-mobile 18.20.4 que ya usabamos.

Se conserva de la rama: Karvan, identidades y multicuenta, topbar y barra
inferior, borrado de EXIF sin sharp, filtro hot del foro, RTC sin STUN y los
modulos apagados en movil (wallet, banking, torrents).

Se toma de upstream lo que ya hace de serie: respuesta citada en chats, ssb-lan
en movil, configuracion en ~/.ssb/oasis (sobrevive a reinstalar el backend).
El asistente de bienvenida deja elegir workflow tambien en movil.
2026-10-18 02:52:17 +02:00

389 lines
15 KiB
JavaScript

const pull = require('../server/node_modules/pull-stream');
const moment = require('../server/node_modules/moment');
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const { getConfig } = require('../configs/config-manager.js');
const { dedupeBy, norm } = require('../backend/dedupe');
const { buildVoteTally } = require('../backend/vote_tally');
const categories = require('../backend/opinion_categories');
const { readTyped, memoIndex } = require('./typed_log');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const MIN_VOTE_DAYS = 7;
const INSTITUTIONAL_VOTE_TAG = /^(gov|govMethod|courtsCase|courtsMethod):/;
const isVoteLocked = (v) => !!v && (
(parseInt(v.totalVotes || 0, 10) || 0) > 0
|| Object.values(v.opinions || {}).some(n => (Number(n) || 0) > 0)
|| (Array.isArray(v.tags) && v.tags.some(t => INSTITUTIONAL_VOTE_TAG.test(String(t))))
);
const lockedError = () => Object.assign(new Error('Vote locked'), { code: 'VOTE_LOCKED' });
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => {
if (!ssb) ssb = await cooler.open();
return ssb;
};
const TYPE = 'votes';
const VOTES_TYPES = [TYPE, 'votesVote', 'votesOpinion', 'tombstone'];
async function getAllMessages(ssbClient) {
return readTyped(ssbClient, VOTES_TYPES, { limit: logLimit });
}
function buildIndex(messages) {
const tombstoned = buildValidatedTombstoneSet(messages);
const votes = new Map();
const naivePrev = new Map();
const collabVotes = [];
const collabOpinions = [];
for (const m of messages) {
const key = m.key;
const v = m.value;
const c = v && v.content;
if (!c) continue;
if (c.type === 'tombstone') continue;
if (c.type === 'votesVote') { collabVotes.push({ target: c.target, author: v.author, choice: c.choice, ts: v.timestamp || 0 }); continue; }
if (c.type === 'votesOpinion') { collabOpinions.push({ target: c.target, author: v.author, category: c.category, ts: v.timestamp || 0 }); continue; }
if (c.type !== TYPE) continue;
votes.set(key, { key, ts: v.timestamp || m.timestamp || 0, content: c, author: v.author });
}
for (const [key, node] of votes) {
const t = node.content.replaces;
if (t) { naivePrev.set(key, t); }
}
return { tombstoned, votes, naivePrev, collabVotes, collabOpinions, voteTallyByKey: buildVoteTally(messages) };
}
const rootOfIn = (naivePrev, votes, key) => { let x = key, g = 0; while (naivePrev.has(x) && votes.has(naivePrev.get(x)) && g++ < 100000) x = naivePrev.get(x); return x; };
function statusFromContent(content, now) {
const raw = String(content.status || 'OPEN').toUpperCase();
if (raw === 'OPEN') {
const dl = content.deadline ? moment(content.deadline) : null;
if (dl && dl.isValid() && dl.isBefore(now)) return 'CLOSED';
}
return raw;
}
function computeActiveVotes(index) {
const { tombstoned, votes, naivePrev, collabVotes, collabOpinions } = index;
const rootOf = key => rootOfIn(naivePrev, votes, key);
const groups = new Map();
for (const [key, node] of votes.entries()) {
const root = rootOf(key);
if (!groups.has(root)) groups.set(root, []);
groups.get(root).push(node);
}
const cvByRoot = new Map(); const coByRoot = new Map();
for (const cv of collabVotes) { if (!votes.has(cv.target)) continue; const r = rootOf(cv.target); if (!cvByRoot.has(r)) cvByRoot.set(r, []); cvByRoot.get(r).push(cv); }
for (const co of collabOpinions) { if (!votes.has(co.target)) continue; const r = rootOf(co.target); if (!coByRoot.has(r)) coByRoot.set(r, []); coByRoot.get(r).push(co); }
const now = moment();
const result = [];
for (const [root, nodes] of groups.entries()) {
const rootNode = votes.get(root);
const rootAuthor = rootNode ? rootNode.author : null;
const ownerNodes = nodes.filter(n => n.author === rootAuthor);
const pool = ownerNodes.length ? ownerNodes : (rootNode ? [rootNode] : nodes);
let best = pool[0];
let bestStatus = statusFromContent(best.content, now);
for (let i = 1; i < pool.length; i++) {
const candidate = pool[i];
const cStatus = statusFromContent(candidate.content, now);
if (cStatus === bestStatus) {
const bestTime = new Date(best.content.updatedAt || best.content.createdAt || best.ts || 0);
const cTime = new Date(candidate.content.updatedAt || candidate.content.createdAt || candidate.ts || 0);
if (cTime > bestTime) { best = candidate; bestStatus = cStatus; }
} else if (cStatus === 'CLOSED' && bestStatus !== 'CLOSED') { best = candidate; bestStatus = cStatus; }
else if (cStatus === 'OPEN' && bestStatus !== 'OPEN') { best = candidate; bestStatus = cStatus; }
}
if (tombstoned.has(best.key)) continue;
const lc = best.content;
const options = Array.isArray(best.content.options) ? best.content.options : [];
const tally = index.voteTallyByKey ? index.voteTallyByKey.get(best.key) : null;
let votesMap;
let voters;
let totalVotes;
if (tally) {
votesMap = Object.assign({}, tally.votes || {});
for (const o of options) if (!(o in votesMap)) votesMap[o] = 0;
voters = tally.voters.slice();
totalVotes = tally.totalVotes;
} else {
votesMap = Object.assign({}, lc.votes || {});
for (const o of options) if (!(o in votesMap)) votesMap[o] = 0;
voters = Array.isArray(lc.voters) ? lc.voters.slice() : [];
const voterSet = new Set(voters);
totalVotes = parseInt(lc.totalVotes || 0, 10) || voters.length;
for (const cv of (cvByRoot.get(root) || [])) {
if (voterSet.has(cv.author)) continue;
if (!options.includes(cv.choice)) continue;
voterSet.add(cv.author); voters.push(cv.author);
votesMap[cv.choice] = (votesMap[cv.choice] || 0) + 1;
totalVotes += 1;
}
}
const opinions = Object.assign({}, lc.opinions || {});
const opInh = Array.isArray(lc.opinions_inhabitants) ? lc.opinions_inhabitants.slice() : [];
const opSet = new Set(opInh);
for (const co of (coByRoot.get(root) || [])) {
if (opSet.has(co.author)) continue;
opSet.add(co.author); opInh.push(co.author);
opinions[co.category] = (opinions[co.category] || 0) + 1;
}
result.push({
id: best.key,
latestId: best.key,
...best.content,
votes: votesMap,
voters,
totalVotes,
opinions,
opinions_inhabitants: opInh,
status: bestStatus
});
}
return result;
}
async function resolveCurrentId(voteId) {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const nodeByKey = new Map();
for (const m of messages) { const c = m.value && m.value.content; if (c && c.type === TYPE) nodeByKey.set(m.key, { author: m.value.author, replaces: c.replaces }); }
const strictForward = new Map();
for (const [key, n] of nodeByKey) { if (n.replaces && nodeByKey.has(n.replaces) && nodeByKey.get(n.replaces).author === n.author) strictForward.set(n.replaces, key); }
let cur = voteId, g = 0;
while (strictForward.has(cur) && g++ < 100000) cur = strictForward.get(cur);
return cur;
}
return {
MIN_VOTE_DAYS,
async createVote(question, deadline, options = ['YES', 'NO', 'ABSTENTION', 'CONFUSED', 'FOLLOW_MAJORITY', 'NOT_INTERESTED'], tagsRaw = []) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const parsedDeadline = moment(deadline, moment.ISO_8601, true);
if (!parsedDeadline.isValid()) throw new Error('Invalid deadline');
if (parsedDeadline.isBefore(moment().add(MIN_VOTE_DAYS, 'days').subtract(2, 'minutes'))) {
const err = new Error(`Deadline must be at least ${MIN_VOTE_DAYS} days from now`);
err.code = 'VOTE_DEADLINE_MIN';
throw err;
}
const tags = Array.isArray(tagsRaw)
? tagsRaw.filter(Boolean)
: String(tagsRaw || '').split(',').map(t => t.trim()).filter(Boolean);
const content = {
type: TYPE,
question,
options,
deadline: parsedDeadline.toISOString(),
createdBy: userId,
status: 'OPEN',
votes: options.reduce((acc, opt) => {
acc[opt] = 0;
return acc;
}, {}),
totalVotes: 0,
voters: [],
tags,
opinions: {},
opinions_inhabitants: [],
createdAt: new Date().toISOString(),
updatedAt: null
};
return new Promise((res, rej) =>
ssbClient.publish(content, (err, msg) => (err ? rej(err) : res(msg)))
);
},
async deleteVoteById(id) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await resolveCurrentId(id);
const vote = await new Promise((res, rej) =>
ssbClient.get(tipId, (err, msg) => (err || !msg ? rej(new Error('Vote not found')) : res(msg)))
);
if (!vote.content || vote.content.createdBy !== userId) throw new Error('Not the author');
if (isVoteLocked(vote.content) || isVoteLocked(await this.getVoteById(id).catch(() => null))) throw lockedError();
const tombstone = {
type: 'tombstone',
target: tipId,
deletedAt: new Date().toISOString(),
author: userId
};
return new Promise((res, rej) =>
ssbClient.publish(tombstone, (err, result) => (err ? rej(err) : res(result)))
);
},
async updateVoteById(id, payload) {
const { question, deadline, options, tags } = payload || {};
const ssbClient = await openSsb();
const userId = ssbClient.id;
const tipId = await resolveCurrentId(id);
const oldMsg = await new Promise((res, rej) =>
ssbClient.get(tipId, (err, msg) => (err || !msg ? rej(new Error('Vote not found')) : res(msg)))
);
const c = oldMsg.content;
if (!c || c.type !== TYPE) throw new Error('Invalid type');
if (c.createdBy !== userId) throw new Error('Not the author');
const agg = await this.getVoteById(id);
const aggTotalVotes = agg ? (parseInt(agg.totalVotes || 0, 10) || 0) : 0;
if (isVoteLocked(c) || isVoteLocked(agg)) throw lockedError();
let newDeadline = c.deadline;
if (deadline != null && deadline !== '') {
const parsed = moment(deadline, moment.ISO_8601, true);
if (!parsed.isValid()) throw new Error('Invalid deadline');
if (parsed.isBefore(moment().add(MIN_VOTE_DAYS, 'days').subtract(2, 'minutes'))) {
const err = new Error(`Deadline must be at least ${MIN_VOTE_DAYS} days from now`);
err.code = 'VOTE_DEADLINE_MIN';
throw err;
}
newDeadline = parsed.toISOString();
}
let newOptions = c.options || [];
let newVotesMap = c.votes || {};
let newTotalVotes = c.totalVotes || 0;
const optionsChanged = Array.isArray(options) && (
options.length !== newOptions.length ||
options.some((o, i) => o !== newOptions[i])
);
if (optionsChanged) {
if (aggTotalVotes > 0) {
throw new Error('Cannot change options after voting has started');
}
newOptions = options;
newVotesMap = newOptions.reduce((acc, opt) => {
acc[opt] = 0;
return acc;
}, {});
newTotalVotes = 0;
}
let newTags = c.tags || [];
if (Array.isArray(tags)) {
newTags = tags.filter(Boolean);
} else if (typeof tags === 'string') {
newTags = tags.split(',').map(t => t.trim()).filter(Boolean);
}
const updated = {
...c,
replaces: tipId,
question: question != null ? question : c.question,
deadline: newDeadline,
options: newOptions,
votes: newVotesMap,
totalVotes: newTotalVotes,
tags: newTags,
updatedAt: new Date().toISOString()
};
return new Promise((res, rej) =>
ssbClient.publish(updated, (err, result) => (err ? rej(err) : res(result)))
);
},
async voteOnVote(id, choice) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const vote = await this.getVoteById(id);
if (!vote) throw new Error('Vote not found');
if (vote.createdBy === userId) throw new Error('Creator cannot vote');
const options = Array.isArray(vote.options) ? vote.options : [];
if (!options.includes(choice)) throw new Error('Invalid choice');
if (Array.isArray(vote.voters) && vote.voters.includes(userId)) throw new Error('Already voted');
const content = { type: 'votesVote', target: vote.id, choice, createdAt: new Date().toISOString() };
return new Promise((res, rej) =>
ssbClient.publish(content, (err, result) => (err ? rej(err) : res(result)))
);
},
async getVoteById(id) {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const index = memoIndex('votes', messages, buildIndex);
const activeList = computeActiveVotes(index);
const byId = new Map(activeList.map(v => [v.id, v]));
if (byId.has(id)) {
return byId.get(id);
}
const rootOf = key => rootOfIn(index.naivePrev, index.votes, key);
const root = rootOf(id);
const candidate = activeList.find(v => rootOf(v.id) === root);
if (candidate) {
return candidate;
}
throw new Error('Vote not found');
},
async listAll(filter = 'all') {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const messages = await getAllMessages(ssbClient);
const index = buildIndex(messages);
let list = computeActiveVotes(index);
if (filter === 'mine') {
list = list.filter(v => v.createdBy === userId);
} else if (filter === 'recent') {
list = list.filter(v => (Date.parse(v.createdAt || '') || 0) >= Date.now() - 86400000);
} else if (filter === 'open') {
list = list.filter(v => v.status === 'OPEN');
} else if (filter === 'closed') {
list = list.filter(v => v.status === 'CLOSED');
}
const deduped = dedupeBy(list, v => v.question ? [norm(v.createdBy), norm(v.question), norm(v.deadline)].join('|') : null);
return deduped.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
},
async createOpinion(id, category) {
if (!categories.includes(category)) throw new Error('Invalid voting category');
const ssbClient = await openSsb();
const userId = ssbClient.id;
const vote2 = await this.getVoteById(id);
if (!vote2) throw new Error('Vote not found');
if (Array.isArray(vote2.opinions_inhabitants) && vote2.opinions_inhabitants.includes(userId)) throw new Error('Already voted');
const content = { type: 'votesOpinion', target: vote2.id, category, createdAt: new Date().toISOString() };
return new Promise((res, rej) =>
ssbClient.publish(content, (err, result) => (err ? rej(err) : res(result)))
);
}
};
};
module.exports.isVoteLocked = isVoteLocked;