votes_model.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  1. const pull = require('../server/node_modules/pull-stream');
  2. const moment = require('../server/node_modules/moment');
  3. const { getConfig } = require('../configs/config-manager.js');
  4. const categories = require('../backend/opinion_categories')
  5. const logLimit = getConfig().ssbLogStream?.limit || 1000;
  6. module.exports = ({ cooler }) => {
  7. let ssb;
  8. const openSsb = async () => {
  9. if (!ssb) ssb = await cooler.open();
  10. return ssb;
  11. };
  12. const TYPE = 'votes';
  13. async function getAllMessages(ssbClient) {
  14. return new Promise((resolve, reject) => {
  15. pull(
  16. ssbClient.createLogStream({ limit: logLimit }),
  17. pull.collect((err, results) => (err ? reject(err) : resolve(results)))
  18. );
  19. });
  20. }
  21. function buildIndex(messages) {
  22. const tombstoned = new Set();
  23. const replaced = new Map();
  24. const votes = new Map();
  25. const parent = new Map();
  26. for (const m of messages) {
  27. const key = m.key;
  28. const v = m.value;
  29. const c = v && v.content;
  30. if (!c) continue;
  31. if (c.type === 'tombstone' && c.target) {
  32. tombstoned.add(c.target);
  33. continue;
  34. }
  35. if (c.type !== TYPE) continue;
  36. const node = {
  37. key,
  38. ts: v.timestamp || m.timestamp || 0,
  39. content: c
  40. };
  41. votes.set(key, node);
  42. if (c.replaces) {
  43. replaced.set(c.replaces, key);
  44. parent.set(key, c.replaces);
  45. }
  46. }
  47. return { tombstoned, replaced, votes, parent };
  48. }
  49. function statusFromContent(content, now) {
  50. const raw = String(content.status || 'OPEN').toUpperCase();
  51. if (raw === 'OPEN') {
  52. const dl = content.deadline ? moment(content.deadline) : null;
  53. if (dl && dl.isValid() && dl.isBefore(now)) return 'CLOSED';
  54. }
  55. return raw;
  56. }
  57. function computeActiveVotes(index) {
  58. const { tombstoned, replaced, votes, parent } = index;
  59. const active = new Map(votes);
  60. tombstoned.forEach(id => active.delete(id));
  61. replaced.forEach((_, oldId) => active.delete(oldId));
  62. const rootOf = id => {
  63. let cur = id;
  64. while (parent.has(cur)) cur = parent.get(cur);
  65. return cur;
  66. };
  67. const groups = new Map();
  68. for (const [id, node] of active.entries()) {
  69. const root = rootOf(id);
  70. if (!groups.has(root)) groups.set(root, []);
  71. groups.get(root).push(node);
  72. }
  73. const now = moment();
  74. const result = [];
  75. for (const nodes of groups.values()) {
  76. if (!nodes.length) continue;
  77. let best = nodes[0];
  78. let bestStatus = statusFromContent(best.content, now);
  79. for (let i = 1; i < nodes.length; i++) {
  80. const candidate = nodes[i];
  81. const cStatus = statusFromContent(candidate.content, now);
  82. if (cStatus === bestStatus) {
  83. const bestTime = new Date(best.content.updatedAt || best.content.createdAt || best.ts || 0);
  84. const cTime = new Date(candidate.content.updatedAt || candidate.content.createdAt || candidate.ts || 0);
  85. if (cTime > bestTime) {
  86. best = candidate;
  87. bestStatus = cStatus;
  88. }
  89. } else if (cStatus === 'CLOSED' && bestStatus !== 'CLOSED') {
  90. best = candidate;
  91. bestStatus = cStatus;
  92. } else if (cStatus === 'OPEN' && bestStatus !== 'OPEN') {
  93. best = candidate;
  94. bestStatus = cStatus;
  95. }
  96. }
  97. result.push({
  98. id: best.key,
  99. latestId: best.key,
  100. ...best.content,
  101. status: bestStatus
  102. });
  103. }
  104. return result;
  105. }
  106. async function resolveCurrentId(voteId) {
  107. const ssbClient = await openSsb();
  108. const messages = await getAllMessages(ssbClient);
  109. const forward = new Map();
  110. for (const m of messages) {
  111. const c = m.value && m.value.content;
  112. if (!c) continue;
  113. if (c.type === TYPE && c.replaces) {
  114. forward.set(c.replaces, m.key);
  115. }
  116. }
  117. let cur = voteId;
  118. while (forward.has(cur)) cur = forward.get(cur);
  119. return cur;
  120. }
  121. return {
  122. async createVote(question, deadline, options = ['YES', 'NO', 'ABSTENTION', 'CONFUSED', 'FOLLOW_MAJORITY', 'NOT_INTERESTED'], tagsRaw = []) {
  123. const ssbClient = await openSsb();
  124. const userId = ssbClient.id;
  125. const parsedDeadline = moment(deadline, moment.ISO_8601, true);
  126. if (!parsedDeadline.isValid() || parsedDeadline.isBefore(moment())) throw new Error('Invalid deadline');
  127. const tags = Array.isArray(tagsRaw)
  128. ? tagsRaw.filter(Boolean)
  129. : String(tagsRaw).split(',').map(t => t.trim()).filter(Boolean);
  130. const content = {
  131. type: TYPE,
  132. question,
  133. options,
  134. deadline: parsedDeadline.toISOString(),
  135. createdBy: userId,
  136. status: 'OPEN',
  137. votes: options.reduce((acc, opt) => {
  138. acc[opt] = 0;
  139. return acc;
  140. }, {}),
  141. totalVotes: 0,
  142. voters: [],
  143. tags,
  144. opinions: {},
  145. opinions_inhabitants: [],
  146. createdAt: new Date().toISOString(),
  147. updatedAt: null
  148. };
  149. return new Promise((res, rej) =>
  150. ssbClient.publish(content, (err, msg) => (err ? rej(err) : res(msg)))
  151. );
  152. },
  153. async deleteVoteById(id) {
  154. const ssbClient = await openSsb();
  155. const userId = ssbClient.id;
  156. const tipId = await resolveCurrentId(id);
  157. const vote = await new Promise((res, rej) =>
  158. ssbClient.get(tipId, (err, msg) => (err || !msg ? rej(new Error('Vote not found')) : res(msg)))
  159. );
  160. if (!vote.content || vote.content.createdBy !== userId) throw new Error('Not the author');
  161. const tombstone = {
  162. type: 'tombstone',
  163. target: tipId,
  164. deletedAt: new Date().toISOString(),
  165. author: userId
  166. };
  167. return new Promise((res, rej) =>
  168. ssbClient.publish(tombstone, (err, result) => (err ? rej(err) : res(result)))
  169. );
  170. },
  171. async updateVoteById(id, payload) {
  172. const { question, deadline, options, tags } = payload || {};
  173. const ssbClient = await openSsb();
  174. const userId = ssbClient.id;
  175. const tipId = await resolveCurrentId(id);
  176. const oldMsg = await new Promise((res, rej) =>
  177. ssbClient.get(tipId, (err, msg) => (err || !msg ? rej(new Error('Vote not found')) : res(msg)))
  178. );
  179. const c = oldMsg.content;
  180. if (!c || c.type !== TYPE) throw new Error('Invalid type');
  181. if (c.createdBy !== userId) throw new Error('Not the author');
  182. if (Object.keys(c.opinions || {}).length > 0) throw new Error('Cannot edit vote after it has received opinions.')
  183. let newDeadline = c.deadline;
  184. if (deadline != null && deadline !== '') {
  185. const parsed = moment(deadline, moment.ISO_8601, true);
  186. if (!parsed.isValid() || parsed.isBefore(moment())) throw new Error('Invalid deadline');
  187. newDeadline = parsed.toISOString();
  188. }
  189. let newOptions = c.options || [];
  190. let newVotesMap = c.votes || {};
  191. let newTotalVotes = c.totalVotes || 0;
  192. const optionsChanged = Array.isArray(options) && (
  193. options.length !== newOptions.length ||
  194. options.some((o, i) => o !== newOptions[i])
  195. );
  196. if (optionsChanged) {
  197. if ((c.totalVotes || 0) > 0) {
  198. throw new Error('Cannot change options after voting has started');
  199. }
  200. newOptions = options;
  201. newVotesMap = newOptions.reduce((acc, opt) => {
  202. acc[opt] = 0;
  203. return acc;
  204. }, {});
  205. newTotalVotes = 0;
  206. }
  207. let newTags = c.tags || [];
  208. if (Array.isArray(tags)) {
  209. newTags = tags.filter(Boolean);
  210. } else if (typeof tags === 'string') {
  211. newTags = tags.split(',').map(t => t.trim()).filter(Boolean);
  212. }
  213. const updated = {
  214. ...c,
  215. replaces: tipId,
  216. question: question != null ? question : c.question,
  217. deadline: newDeadline,
  218. options: newOptions,
  219. votes: newVotesMap,
  220. totalVotes: newTotalVotes,
  221. tags: newTags,
  222. updatedAt: new Date().toISOString()
  223. };
  224. return new Promise((res, rej) =>
  225. ssbClient.publish(updated, (err, result) => (err ? rej(err) : res(result)))
  226. );
  227. },
  228. async voteOnVote(id, choice) {
  229. const ssbClient = await openSsb();
  230. const userId = ssbClient.id;
  231. const tipId = await resolveCurrentId(id);
  232. const vote = await new Promise((res, rej) =>
  233. ssbClient.get(tipId, (err, msg) => (err || !msg ? rej(new Error('Vote not found')) : res(msg)))
  234. );
  235. const content = vote.content || {};
  236. const options = Array.isArray(content.options) ? content.options : [];
  237. if (!options.includes(choice)) throw new Error('Invalid choice');
  238. const voters = Array.isArray(content.voters) ? content.voters.slice() : [];
  239. if (voters.includes(userId)) throw new Error('Already voted');
  240. const votesMap = Object.assign({}, content.votes || {});
  241. votesMap[choice] = (votesMap[choice] || 0) + 1;
  242. voters.push(userId);
  243. const totalVotes = (parseInt(content.totalVotes || 0, 10) || 0) + 1;
  244. const tombstone = {
  245. type: 'tombstone',
  246. target: tipId,
  247. deletedAt: new Date().toISOString(),
  248. author: userId
  249. };
  250. const updated = {
  251. ...content,
  252. votes: votesMap,
  253. voters,
  254. totalVotes,
  255. updatedAt: new Date().toISOString(),
  256. replaces: tipId
  257. };
  258. await new Promise((res, rej) =>
  259. ssbClient.publish(tombstone, err => (err ? rej(err) : res()))
  260. );
  261. return new Promise((res, rej) =>
  262. ssbClient.publish(updated, (err, result) => (err ? rej(err) : res(result)))
  263. );
  264. },
  265. async getVoteById(id) {
  266. const ssbClient = await openSsb();
  267. const messages = await getAllMessages(ssbClient);
  268. const index = buildIndex(messages);
  269. const activeList = computeActiveVotes(index);
  270. const byId = new Map(activeList.map(v => [v.id, v]));
  271. if (byId.has(id)) {
  272. return byId.get(id);
  273. }
  274. const parent = index.parent;
  275. const rootOf = key => {
  276. let cur = key;
  277. while (parent.has(cur)) cur = parent.get(cur);
  278. return cur;
  279. };
  280. const root = rootOf(id);
  281. const candidate = activeList.find(v => rootOf(v.id) === root);
  282. if (candidate) {
  283. return candidate;
  284. }
  285. const msg = await new Promise((res, rej) =>
  286. ssbClient.get(id, (err, vote) => (err || !vote ? rej(new Error('Vote not found')) : res(vote)))
  287. );
  288. const content = msg.content || {};
  289. const status = statusFromContent(content, moment());
  290. return {
  291. id,
  292. latestId: id,
  293. ...content,
  294. status
  295. };
  296. },
  297. async listAll(filter = 'all') {
  298. const ssbClient = await openSsb();
  299. const userId = ssbClient.id;
  300. const messages = await getAllMessages(ssbClient);
  301. const index = buildIndex(messages);
  302. let list = computeActiveVotes(index);
  303. if (filter === 'mine') {
  304. list = list.filter(v => v.createdBy === userId);
  305. } else if (filter === 'open') {
  306. list = list.filter(v => v.status === 'OPEN');
  307. } else if (filter === 'closed') {
  308. list = list.filter(v => v.status === 'CLOSED');
  309. }
  310. return list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
  311. },
  312. async createOpinion(id, category) {
  313. if (!categories.includes(category)) throw new Error('Invalid voting category')
  314. const ssbClient = await openSsb();
  315. const userId = ssbClient.id;
  316. const tipId = await resolveCurrentId(id);
  317. const vote = await new Promise((res, rej) =>
  318. ssbClient.get(tipId, (err, msg) => (err || !msg ? rej(new Error('Vote not found')) : res(msg)))
  319. );
  320. const content = vote.content || {};
  321. const list = Array.isArray(content.opinions_inhabitants) ? content.opinions_inhabitants : [];
  322. if (list.includes(userId)) throw new Error('Already voted');
  323. const opinions = Object.assign({}, content.opinions || {});
  324. opinions[category] = (opinions[category] || 0) + 1;
  325. const tombstone = {
  326. type: 'tombstone',
  327. target: tipId,
  328. deletedAt: new Date().toISOString(),
  329. author: userId
  330. };
  331. const updated = {
  332. ...content,
  333. opinions,
  334. opinions_inhabitants: list.concat(userId),
  335. updatedAt: new Date().toISOString(),
  336. replaces: tipId
  337. };
  338. await new Promise((res, rej) =>
  339. ssbClient.publish(tombstone, err => (err ? rej(err) : res()))
  340. );
  341. return new Promise((res, rej) =>
  342. ssbClient.publish(updated, (err, result) => (err ? rej(err) : res(result)))
  343. );
  344. }
  345. };
  346. };