const pull = require('../server/node_modules/pull-stream'); const { getConfig } = require('../configs/config-manager.js'); const { buildValidatedTombstoneSet } = require('./tombstone_validator'); const { readTyped, memoIndex } = require('./typed_log'); const { versionGroups } = require('../backend/dedupe'); const opinionRules = require('../backend/opinion_categories'); const logLimit = getConfig().ssbLogStream?.limit || 1000; const POLL_TYPE = 'poll'; const VOTE_TYPE = 'pollVote'; const OPINION_TYPE = 'pollOpinion'; const { MAX_OPTIONS, MIN_OPTIONS, MAX_OPTION_LENGTH } = require('./polls_model_limits'); const clean = (v) => String(v == null ? '' : v).trim(); const normalizeOptions = (raw) => { const list = Array.isArray(raw) ? raw : String(raw || '').split('\n'); const out = []; const seen = new Set(); for (const entry of list) { const value = clean(entry).slice(0, MAX_OPTION_LENGTH); if (!value) continue; const key = value.toLowerCase(); if (seen.has(key)) continue; seen.add(key); out.push(value); if (out.length >= MAX_OPTIONS) break; } return out; }; const normalizeTags = (raw) => { const list = Array.isArray(raw) ? raw : String(raw || '').split(','); return list.map(t => clean(t)).filter(Boolean).slice(0, 20); }; module.exports = ({ cooler, isPublic = false, cryptoModel = null, chatsModel = null }) => { let ssb; const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; }; const POLLS_TYPES = [POLL_TYPE, VOTE_TYPE, OPINION_TYPE, 'pollClose', 'post', 'tombstone']; const getAllMessages = async (ssbClient) => readTyped(ssbClient, POLLS_TYPES, { limit: logLimit }); const decryptField = (value, keys) => { if (!cryptoModel || typeof value !== 'string' || !value) return null; for (const k of keys) { try { const out = cryptoModel.decryptWithKey(value, k); if (typeof out === 'string' && out) return out; } catch (_) {} } return null; }; const scopeKey = async (scope) => { if (!scope || !chatsModel) return null; if (!scope.chatId && !scope.tribeId) return null; try { return await chatsModel.encryptionKeyFor(scope.chatId || null, scope.tribeId || null); } catch (_) { return null; } }; const buildIndex = (messages) => { const tomb = buildValidatedTombstoneSet(messages); const nodes = new Map(); const votesByRoot = new Map(); const opinionsByRoot = new Map(); const closedRoots = new Set(); const commentsByRoot = new Map(); const others = []; for (const m of messages) { const v = m && m.value; const c = v && v.content; if (!c || typeof c !== 'object') continue; if (c.type === POLL_TYPE) nodes.set(m.key, { key: m.key, author: v.author, seq: v.sequence || 0, ts: v.timestamp || m.timestamp || 0, c }); else others.push(m); } const vg = versionGroups(Array.from(nodes.values()).map(n => ({ key: n.key, author: n.author, seq: n.seq, ts: n.ts, replaces: typeof n.c.replaces === 'string' ? n.c.replaces : null, claimedAuthor: typeof n.c.author === 'string' ? n.c.author : null }))); const rootOf = vg.rootOf; const tipOf = vg.tipOf; for (const m of others) { const v = m.value; const c = v.content; if (c.type === VOTE_TYPE && typeof c.target === 'string') { const target = rootOf(c.target); const entry = votesByRoot.get(target) || new Map(); const list = entry.get(v.author) || []; list.push({ ts: v.timestamp || m.timestamp || 0, choices: Array.isArray(c.choices) ? c.choices : [], encryptedChoices: typeof c.encryptedChoices === 'string' ? c.encryptedChoices : null }); entry.set(v.author, list); votesByRoot.set(target, entry); continue; } if (c.type === OPINION_TYPE && typeof c.target === 'string') { const target = rootOf(c.target); const entry = opinionsByRoot.get(target) || { counts: {}, voters: [] }; if (!entry.voters.includes(v.author)) { entry.voters.push(v.author); entry.counts[c.category] = (entry.counts[c.category] || 0) + 1; } opinionsByRoot.set(target, entry); continue; } if (c.type === 'pollClose' && typeof c.target === 'string') { closedRoots.add(`${v.author}|${rootOf(c.target)}`); continue; } if (c.type === 'post' && typeof c.root === 'string') { const target = rootOf(c.root); commentsByRoot.set(target, (commentsByRoot.get(target) || 0) + 1); } } return { tomb, nodes, vg, votesByRoot, opinionsByRoot, closedRoots, commentsByRoot, rootOf, tipOf }; }; const isExpired = (c) => { if (!c.deadline) return false; const t = Date.parse(c.deadline); return Number.isFinite(t) && t <= Date.now(); }; const buildPoll = (idx, rootId, viewerId, keys = []) => { const tipId = idx.tipOf(rootId); const node = idx.nodes.get(tipId); if (!node) return null; const c = node.c || {}; let question = clean(c.question); let rawOptions = c.options; let undecryptable = false; if (c.encryptedQuestion) { const q = decryptField(c.encryptedQuestion, keys); const o = decryptField(c.encryptedOptions, keys); if (q === null) undecryptable = true; question = q || ''; try { rawOptions = o ? JSON.parse(o) : []; } catch (_) { rawOptions = []; } } const options = normalizeOptions(rawOptions); const opensAt = (idx.nodes.get(rootId) || {}).ts || 0; const closesAt = c.deadline ? Date.parse(c.deadline) : NaN; const voteMap = new Map(); for (const [author, list] of (idx.votesByRoot.get(rootId) || new Map()).entries()) { const inWindow = list.filter(b => b.ts >= opensAt && (!Number.isFinite(closesAt) || b.ts <= closesAt)); if (inWindow.length) voteMap.set(author, inWindow.reduce((a, b) => (b.ts >= a.ts ? b : a))); } const counts = {}; for (const o of options) counts[o] = 0; let totalVoters = 0; const voters = []; let myChoices = []; for (const [author, entry] of voteMap.entries()) { let choices = Array.isArray(entry.choices) ? entry.choices : []; if (entry.encryptedChoices) { const plain = decryptField(entry.encryptedChoices, keys); try { choices = plain ? JSON.parse(plain) : []; } catch (_) { choices = []; } } const picked = (Array.isArray(choices) ? choices : []).filter(o => options.includes(o)); if (!picked.length) continue; totalVoters += 1; voters.push(author); for (const o of picked) counts[o] += 1; if (viewerId && author === viewerId) myChoices = picked; } const closed = idx.closedRoots.has(`${node.author}|${rootId}`) || isExpired(c); const op = idx.opinionsByRoot.get(rootId) || { counts: {}, voters: [] }; return { id: rootId, rootId, tipId, author: node.author, question, options, encrypted: !!c.encryptedQuestion, undecryptable, chatId: c.chatId || null, anonymous: c.anonymous === true, multiple: c.multiple === true, deadline: c.deadline || '', tags: normalizeTags(c.tags), tribeId: c.tribeId || null, houseKey: c.houseKey || null, createdAt: c.createdAt || new Date(node.ts).toISOString(), updatedAt: c.updatedAt || null, status: closed ? 'CLOSED' : 'OPEN', counts, totalVoters, voters: c.anonymous === true ? [] : voters, votersHidden: c.anonymous === true, myChoices, hasVoted: myChoices.length > 0, commentCount: idx.commentsByRoot.get(rootId) || 0, opinions: op.counts, opinions_inhabitants: op.voters }; }; const collect = async (viewerId) => { const ssbClient = await openSsb(); const messages = await getAllMessages(ssbClient); const idx = memoIndex('polls', messages, buildIndex); const keyCache = new Map(); const keysFor = async (c) => { if (!c || (!c.chatId && !c.tribeId)) return []; const cacheKey = `${c.chatId || ''}|${c.tribeId || ''}`; if (keyCache.has(cacheKey)) return keyCache.get(cacheKey); const k = await scopeKey({ chatId: c.chatId || null, tribeId: c.tribeId || null }); const keys = k ? [k] : []; keyCache.set(cacheKey, keys); return keys; }; const list = []; for (const rootId of idx.vg.roots) { const tipId = idx.tipOf(rootId); if (idx.tomb.has(tipId) || idx.tomb.has(rootId)) continue; const node = idx.nodes.get(tipId); const keys = await keysFor(node && node.c); const poll = buildPoll(idx, rootId, viewerId || ssbClient.id, keys); if (!poll) continue; if (poll.undecryptable) { list.push(poll); continue; } if (!poll.question || poll.options.length < MIN_OPTIONS) continue; list.push(poll); } return { list, idx, viewerId: viewerId || ssbClient.id }; }; return { POLL_TYPE, VOTE_TYPE, MAX_OPTIONS, MIN_OPTIONS, async listAll(filter = 'ALL', opts = {}) { const { list, viewerId } = await collect(); const f = String(filter || 'ALL').toUpperCase(); const scopeChat = opts.chatId || null; const scopeTribe = opts.tribeId || null; const scopeHouse = opts.houseKey || null; let out = list.filter(p => scopeChat ? p.chatId === scopeChat : scopeTribe ? (p.tribeId === scopeTribe && !p.chatId) : scopeHouse ? p.houseKey === scopeHouse : (!p.tribeId && !p.chatId && !p.houseKey)); if (f === 'MINE') out = out.filter(p => String(p.author) === String(viewerId)); else if (f === 'OPEN') out = out.filter(p => p.status === 'OPEN'); else if (f === 'CLOSED') out = out.filter(p => p.status === 'CLOSED'); else if (f === 'VOTED') out = out.filter(p => p.hasVoted); else if (f === 'RECENT') out = out.filter(p => (Date.parse(p.createdAt || '') || 0) >= Date.now() - 86400000); else if (f === 'FAVORITES') { const fav = new Set((opts.favorites || []).map(String)); out = out.filter(p => fav.has(String(p.id))); } const q = clean(opts.q).toLowerCase(); if (q) out = out.filter(p => p.question.toLowerCase().includes(q) || p.options.some(o => o.toLowerCase().includes(q)) || p.tags.some(t => t.toLowerCase().includes(q))); if (f === 'TOP') out.sort((a, b) => b.totalVoters - a.totalVoters || new Date(b.createdAt) - new Date(a.createdAt)); else out.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); return out; }, async getPollById(id, viewerId = null) { const { list, idx } = await collect(viewerId); const rootId = idx.rootOf(id); const poll = list.find(p => p.id === rootId || p.id === id || p.tipId === id); if (!poll) throw new Error('Poll not found'); return poll; }, async resolveRootId(id) { const { idx } = await collect(); return idx.rootOf(id); }, async createPoll(data = {}) { if (isPublic) throw new Error('Not available in public mode'); const question = clean(data.question); if (!question) throw new Error('Question is required'); const options = normalizeOptions(data.options); if (options.length < MIN_OPTIONS) throw new Error(`A poll needs at least ${MIN_OPTIONS} options`); const deadline = clean(data.deadline); if (deadline) { const t = Date.parse(deadline); if (!Number.isFinite(t)) throw new Error('Invalid deadline'); if (t <= Date.now()) throw new Error('Deadline must be in the future'); } const ssbClient = await openSsb(); const content = { type: POLL_TYPE, anonymous: data.anonymous === true || data.anonymous === 'true' || data.anonymous === '1', multiple: data.multiple === true || data.multiple === 'true' || data.multiple === '1', deadline, tags: normalizeTags(data.tags), createdAt: new Date().toISOString(), ...(data.tribeId ? { tribeId: String(data.tribeId) } : {}), ...(data.chatId ? { chatId: String(data.chatId) } : {}), ...(data.houseKey ? { houseKey: String(data.houseKey) } : {}) }; const key = await scopeKey({ chatId: data.chatId, tribeId: data.tribeId }); if (key && cryptoModel) { content.encryptedQuestion = cryptoModel.encryptWithKey(question, key); content.encryptedOptions = cryptoModel.encryptWithKey(JSON.stringify(options), key); } else { content.question = question; content.options = options; } return new Promise((res, rej) => ssbClient.publish(content, (err, msg) => err ? rej(err) : res(msg))); }, async updatePoll(id, data = {}) { const ssbClient = await openSsb(); const userId = ssbClient.id; const poll = await this.getPollById(id, userId); if (poll.author !== userId) throw new Error('Not the author'); if (poll.status === 'CLOSED') throw new Error('Cannot edit a closed poll'); if (poll.totalVoters > 0) throw new Error('Cannot edit a poll that already has votes'); const options = data.options !== undefined ? normalizeOptions(data.options) : poll.options; if (options.length < MIN_OPTIONS) throw new Error(`A poll needs at least ${MIN_OPTIONS} options`); const deadline = data.deadline !== undefined ? clean(data.deadline) : poll.deadline; if (deadline) { const t = Date.parse(deadline); if (!Number.isFinite(t)) throw new Error('Invalid deadline'); } const question = data.question !== undefined ? clean(data.question) : poll.question; opinionRules.assertMediaKept(poll, question !== poll.question || JSON.stringify(options) !== JSON.stringify(poll.options)); const content = { type: POLL_TYPE, replaces: poll.tipId, anonymous: data.anonymous !== undefined ? (data.anonymous === true || data.anonymous === 'true' || data.anonymous === '1') : poll.anonymous, multiple: data.multiple !== undefined ? (data.multiple === true || data.multiple === 'true' || data.multiple === '1') : poll.multiple, deadline, tags: data.tags !== undefined ? normalizeTags(data.tags) : poll.tags, createdAt: poll.createdAt, updatedAt: new Date().toISOString(), ...(poll.tribeId ? { tribeId: poll.tribeId } : {}), ...(poll.chatId ? { chatId: poll.chatId } : {}), ...(poll.houseKey ? { houseKey: poll.houseKey } : {}) }; const key = await scopeKey({ chatId: poll.chatId, tribeId: poll.tribeId }); if (key && cryptoModel) { content.encryptedQuestion = cryptoModel.encryptWithKey(question, key); content.encryptedOptions = cryptoModel.encryptWithKey(JSON.stringify(options), key); } else { content.question = question; content.options = options; } return new Promise((res, rej) => ssbClient.publish(content, (err, msg) => err ? rej(err) : res(msg))); }, async vote(id, choicesRaw) { const ssbClient = await openSsb(); const userId = ssbClient.id; const poll = await this.getPollById(id, userId); if (poll.status === 'CLOSED') throw new Error('This poll is closed'); const wanted = (Array.isArray(choicesRaw) ? choicesRaw : [choicesRaw]) .map(c => clean(c)) .filter(c => poll.options.includes(c)); const unique = [...new Set(wanted)]; if (!unique.length) throw new Error('Pick at least one option'); if (!poll.multiple && unique.length > 1) throw new Error('This poll only accepts one option'); const content = { type: VOTE_TYPE, target: poll.rootId, createdAt: new Date().toISOString(), ...(poll.chatId ? { chatId: poll.chatId } : {}) }; const key = await scopeKey({ chatId: poll.chatId, tribeId: poll.tribeId }); if (key && cryptoModel) content.encryptedChoices = cryptoModel.encryptWithKey(JSON.stringify(unique), key); else content.choices = unique; return new Promise((res, rej) => ssbClient.publish(content, (err, msg) => err ? rej(err) : res(msg))); }, async closePoll(id) { const ssbClient = await openSsb(); const userId = ssbClient.id; const poll = await this.getPollById(id, userId); if (poll.author !== userId) throw new Error('Not the author'); if (poll.status === 'CLOSED') return { status: 'already_closed' }; const content = { type: 'pollClose', target: poll.rootId, createdAt: new Date().toISOString() }; await new Promise((res, rej) => ssbClient.publish(content, (err, msg) => err ? rej(err) : res(msg))); return { status: 'ok' }; }, async deletePoll(id) { const ssbClient = await openSsb(); const userId = ssbClient.id; const poll = await this.getPollById(id, userId); if (poll.author !== userId) throw new Error('Not the author'); const content = { type: 'tombstone', target: poll.tipId, deletedAt: new Date().toISOString(), author: userId }; return new Promise((res, rej) => ssbClient.publish(content, (err, msg) => err ? rej(err) : res(msg))); }, async createOpinion(id, category) { const categories = require('../backend/opinion_categories'); if (!categories.includes(category)) throw new Error('Invalid opinion category'); const ssbClient = await openSsb(); const userId = ssbClient.id; const poll = await this.getPollById(id, userId); if (poll.opinions_inhabitants.includes(userId)) throw new Error('Already opined'); const content = { type: OPINION_TYPE, target: poll.rootId, category, createdAt: new Date().toISOString() }; return new Promise((res, rej) => ssbClient.publish(content, (err, result) => err ? rej(err) : res(result))); } }; };