votes_model.js 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  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 logLimit = getConfig().ssbLogStream?.limit || 1000;
  5. module.exports = ({ cooler }) => {
  6. let ssb;
  7. const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
  8. return {
  9. async createVote(question, deadline, options = ['YES', 'NO', 'ABSTENTION', 'CONFUSED', 'FOLLOW_MAJORITY', 'NOT_INTERESTED'], tagsRaw = []) {
  10. const ssb = await openSsb();
  11. const userId = ssb.id;
  12. const parsedDeadline = moment(deadline, moment.ISO_8601, true);
  13. if (!parsedDeadline.isValid() || parsedDeadline.isBefore(moment())) throw new Error('Invalid deadline');
  14. const tags = Array.isArray(tagsRaw) ? tagsRaw.filter(Boolean) : tagsRaw.split(',').map(t => t.trim()).filter(Boolean);
  15. const content = {
  16. type: 'votes',
  17. question,
  18. options,
  19. deadline: parsedDeadline.toISOString(),
  20. createdBy: userId,
  21. status: 'OPEN',
  22. votes: options.reduce((acc, opt) => ({ ...acc, [opt]: 0 }), {}),
  23. totalVotes: 0,
  24. voters: [],
  25. tags,
  26. opinions: {},
  27. opinions_inhabitants: [],
  28. createdAt: new Date().toISOString()
  29. };
  30. return new Promise((res, rej) => ssb.publish(content, (err, msg) => err ? rej(err) : res(msg)));
  31. },
  32. async deleteVoteById(id) {
  33. const ssb = await openSsb();
  34. const userId = ssb.id;
  35. const vote = await new Promise((res, rej) => ssb.get(id, (err, vote) => err ? rej(new Error('Vote not found')) : res(vote)));
  36. if (vote.content.createdBy !== userId) throw new Error('Not the author');
  37. const tombstone = { type: 'tombstone', target: id, deletedAt: new Date().toISOString(), author: userId };
  38. return new Promise((res, rej) => ssb.publish(tombstone, (err, result) => err ? rej(err) : res(result)));
  39. },
  40. async updateVoteById(id, { question, deadline, options, tags }) {
  41. const ssb = await openSsb();
  42. const userId = ssb.id;
  43. const oldMsg = await new Promise((res, rej) =>
  44. ssb.get(id, (err, msg) => err || !msg ? rej(new Error('Vote not found')) : res(msg))
  45. );
  46. const c = oldMsg.content;
  47. if (c.type !== 'votes') throw new Error('Invalid type');
  48. if (c.createdBy !== userId) throw new Error('Not the author');
  49. let newDeadline = c.deadline;
  50. if (deadline != null && deadline !== '') {
  51. const parsed = moment(deadline, moment.ISO_8601, true);
  52. if (!parsed.isValid() || parsed.isBefore(moment())) throw new Error('Invalid deadline');
  53. newDeadline = parsed.toISOString();
  54. }
  55. let newOptions = c.options;
  56. let newVotesMap = c.votes;
  57. let newTotalVotes = c.totalVotes;
  58. const optionsCambiaron = Array.isArray(options) && (
  59. options.length !== c.options.length ||
  60. options.some((o, i) => o !== c.options[i])
  61. );
  62. if (optionsCambiaron) {
  63. if (c.totalVotes > 0) {
  64. throw new Error('Cannot change options after voting has started');
  65. }
  66. newOptions = options;
  67. newVotesMap = newOptions.reduce((acc, opt) => (acc[opt] = 0, acc), {});
  68. newTotalVotes = 0;
  69. }
  70. const newTags =
  71. Array.isArray(tags) ? tags.filter(Boolean)
  72. : typeof tags === 'string' ? tags.split(',').map(t => t.trim()).filter(Boolean)
  73. : c.tags || [];
  74. const updated = {
  75. ...c,
  76. replaces: id,
  77. question: question ?? c.question,
  78. deadline: newDeadline,
  79. options: newOptions,
  80. votes: newVotesMap,
  81. totalVotes: newTotalVotes,
  82. tags: newTags,
  83. updatedAt: new Date().toISOString()
  84. };
  85. return new Promise((res, rej) => ssb.publish(updated, (err, result) => err ? rej(err) : res(result)));
  86. },
  87. async voteOnVote(id, choice) {
  88. const ssb = await openSsb();
  89. const userId = ssb.id;
  90. const vote = await new Promise((res, rej) => ssb.get(id, (err, vote) => err ? rej(new Error('Vote not found')) : res(vote)));
  91. if (!vote.content.options.includes(choice)) throw new Error('Invalid choice');
  92. if (vote.content.voters.includes(userId)) throw new Error('Already voted');
  93. vote.content.votes[choice] += 1;
  94. vote.content.voters.push(userId);
  95. vote.content.totalVotes += 1;
  96. const tombstone = { type: 'tombstone', target: id, deletedAt: new Date().toISOString(), author: userId };
  97. const updated = { ...vote.content, updatedAt: new Date().toISOString(), replaces: id };
  98. await new Promise((res, rej) => ssb.publish(tombstone, err => err ? rej(err) : res()));
  99. return new Promise((res, rej) => ssb.publish(updated, (err, result) => err ? rej(err) : res(result)));
  100. },
  101. async getVoteById(id) {
  102. const ssb = await openSsb();
  103. const now = moment();
  104. const results = await new Promise((resolve, reject) => {
  105. pull(
  106. ssb.createLogStream({ limit: logLimit }),
  107. pull.collect((err, arr) => err ? reject(err) : resolve(arr))
  108. );
  109. });
  110. const votesByKey = new Map();
  111. const latestByRoot = new Map();
  112. for (const r of results) {
  113. const key = r.key;
  114. const v = r.value;
  115. const c = v && v.content;
  116. if (!c) continue;
  117. if (c.type === 'votes') {
  118. votesByKey.set(key, c);
  119. const ts = Number(v.timestamp || r.timestamp || Date.now());
  120. const root = c.replaces || key;
  121. const prev = latestByRoot.get(root);
  122. if (!prev || ts > prev.ts) latestByRoot.set(root, { key, ts });
  123. }
  124. }
  125. const latestEntry = latestByRoot.get(id);
  126. let latestId = latestEntry ? latestEntry.key : id;
  127. let content = votesByKey.get(latestId);
  128. if (!content) {
  129. const orig = await new Promise((res, rej) => ssb.get(id, (err, vote) => err ? rej(new Error('Vote not found')) : res(vote)));
  130. content = orig.content;
  131. latestId = id;
  132. }
  133. const status = content.status === 'OPEN' && moment(content.deadline).isBefore(now) ? 'CLOSED' : content.status;
  134. return { id, latestId, ...content, status };
  135. },
  136. async listAll(filter = 'all') {
  137. const ssb = await openSsb();
  138. const userId = ssb.id;
  139. const now = moment();
  140. return new Promise((resolve, reject) => {
  141. pull(ssb.createLogStream({ limit: logLimit }),
  142. pull.collect((err, results) => {
  143. if (err) return reject(err);
  144. const tombstoned = new Set();
  145. const replaced = new Map();
  146. const votes = new Map();
  147. for (const r of results) {
  148. const { key, value: { content: c } } = r;
  149. if (!c) continue;
  150. if (c.type === 'tombstone') tombstoned.add(c.target);
  151. if (c.type === 'votes') {
  152. if (c.replaces) replaced.set(c.replaces, key);
  153. const status = c.status === 'OPEN' && moment(c.deadline).isBefore(now) ? 'CLOSED' : c.status;
  154. votes.set(key, { id: key, ...c, status });
  155. }
  156. }
  157. tombstoned.forEach(id => votes.delete(id));
  158. replaced.forEach((_, oldId) => votes.delete(oldId));
  159. const out = [...votes.values()];
  160. if (filter === 'mine') return resolve(out.filter(v => v.createdBy === userId));
  161. if (filter === 'open') return resolve(out.filter(v => v.status === 'OPEN'));
  162. if (filter === 'closed') return resolve(out.filter(v => v.status === 'CLOSED'));
  163. resolve(out);
  164. }));
  165. });
  166. },
  167. async createOpinion(id, category) {
  168. const ssb = await openSsb();
  169. const userId = ssb.id;
  170. const vote = await new Promise((res, rej) => ssb.get(id, (err, vote) => err ? rej(new Error('Vote not found')) : res(vote)));
  171. if (vote.content.opinions_inhabitants.includes(userId)) throw new Error('Already voted');
  172. const tombstone = { type: 'tombstone', target: id, deletedAt: new Date().toISOString(), author: userId };
  173. const updated = {
  174. ...vote.content,
  175. opinions: { ...vote.content.opinions, [category]: (vote.content.opinions[category] || 0) + 1 },
  176. opinions_inhabitants: [...vote.content.opinions_inhabitants, userId],
  177. updatedAt: new Date().toISOString(),
  178. replaces: id
  179. };
  180. await new Promise((res, rej) => ssb.publish(tombstone, err => err ? rej(err) : res()));
  181. return new Promise((res, rej) => ssb.publish(updated, (err, result) => err ? rej(err) : res(result)));
  182. }
  183. };
  184. };