projects_model.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  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 () => {
  8. if (!ssb) ssb = await cooler.open()
  9. return ssb
  10. }
  11. const TYPE = 'project'
  12. const clampPercent = n => Math.max(0, Math.min(100, parseInt(n,10) || 0))
  13. async function getAllMsgs(ssbClient) {
  14. return new Promise((r, j) => {
  15. pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((e, m) => e ? j(e) : r(m)))
  16. })
  17. }
  18. function normalizeMilestonesFrom(data) {
  19. if (Array.isArray(data.milestones)) {
  20. return data.milestones.map(m => ({
  21. title: String(m.title || '').trim(),
  22. description: m.description || '',
  23. targetPercent: clampPercent(m.targetPercent || 0),
  24. dueDate: m.dueDate ? new Date(m.dueDate).toISOString() : null,
  25. done: !!m.done
  26. })).filter(m => m.title)
  27. }
  28. const title = String((data['milestones[0][title]'] || data.milestoneTitle || '')).trim()
  29. const description = data['milestones[0][description]'] || data.milestoneDescription || ''
  30. const tpRaw = (data['milestones[0][targetPercent]'] ?? data.milestoneTargetPercent) ?? 0
  31. const targetPercent = clampPercent(tpRaw)
  32. const dueRaw = data['milestones[0][dueDate]'] || data.milestoneDueDate || null
  33. const dueDate = dueRaw ? new Date(dueRaw).toISOString() : null
  34. const out = []
  35. if (title) out.push({ title, description, targetPercent, dueDate, done: false })
  36. return out
  37. }
  38. function autoCompleteMilestoneIfReady(projectLike, milestoneIdx, clampPercentFn) {
  39. if (milestoneIdx == null) {
  40. return { milestones: projectLike.milestones || [], progress: projectLike.progress || 0, changed: false }
  41. }
  42. const milestones = Array.isArray(projectLike.milestones) ? projectLike.milestones.slice() : []
  43. if (!milestones[milestoneIdx]) {
  44. return { milestones, progress: projectLike.progress || 0, changed: false }
  45. }
  46. const bounties = Array.isArray(projectLike.bounties) ? projectLike.bounties : []
  47. const related = bounties.filter(b => b.milestoneIndex === milestoneIdx)
  48. if (related.length === 0) {
  49. return { milestones, progress: projectLike.progress || 0, changed: false }
  50. }
  51. const allDone = related.every(b => !!b.done)
  52. let progress = projectLike.progress || 0
  53. let changed = false
  54. if (allDone && !milestones[milestoneIdx].done) {
  55. milestones[milestoneIdx].done = true
  56. const target = clampPercentFn(milestones[milestoneIdx].targetPercent || 0)
  57. progress = Math.max(parseInt(progress, 10) || 0, target)
  58. changed = true
  59. }
  60. return { milestones, progress, changed }
  61. }
  62. async function resolveTipId(id) {
  63. const ssbClient = await openSsb()
  64. const all = await getAllMsgs(ssbClient)
  65. const tomb = new Set()
  66. const replaces = new Map()
  67. all.forEach(m => {
  68. const c = m.value.content
  69. if (!c) return
  70. if (c.type === 'tombstone' && c.target) tomb.add(c.target)
  71. else if (c.type === TYPE && c.replaces) replaces.set(c.replaces, m.key)
  72. })
  73. let key = id
  74. while (replaces.has(key)) key = replaces.get(key)
  75. if (tomb.has(key)) throw new Error('Project not found')
  76. return key
  77. }
  78. async function getById(id) {
  79. const ssbClient = await openSsb()
  80. const tip = await resolveTipId(id)
  81. const msg = await new Promise((r, j) => ssbClient.get(tip, (e, m) => e ? j(e) : r(m)))
  82. if (!msg) throw new Error('Project not found')
  83. return { id: tip, ...msg.content }
  84. }
  85. function extractBlobId(possibleMarkdownImage) {
  86. let blobId = possibleMarkdownImage
  87. if (blobId && /\(([^)]+)\)/.test(blobId)) blobId = blobId.match(/\(([^)]+)\)/)[1]
  88. return blobId
  89. }
  90. function safeMilestoneIndex(project, idx) {
  91. const total = Array.isArray(project.milestones) ? project.milestones.length : 0
  92. if (idx === null || idx === undefined || idx === '' || isNaN(idx)) return null
  93. const n = parseInt(idx, 10)
  94. if (n < 0 || n >= total) return null
  95. return n
  96. }
  97. return {
  98. type: TYPE,
  99. async createProject(data) {
  100. const ssbClient = await openSsb()
  101. const blobId = extractBlobId(data.image)
  102. const milestones = normalizeMilestonesFrom(data)
  103. const content = {
  104. type: TYPE,
  105. title: data.title,
  106. description: data.description,
  107. image: blobId || null,
  108. goal: parseFloat(data.goal || 0) || 0,
  109. pledged: parseFloat(data.pledged || 0) || 0,
  110. deadline: data.deadline || null,
  111. progress: clampPercent(data.progress || 0),
  112. status: (data.status || 'ACTIVE').toUpperCase(),
  113. milestones,
  114. bounties: Array.isArray(data.bounties)
  115. ? data.bounties.map(b => ({
  116. title: String(b.title || '').trim(),
  117. amount: Math.max(0, parseFloat(b.amount || 0) || 0),
  118. description: b.description || '',
  119. claimedBy: b.claimedBy || null,
  120. done: !!b.done,
  121. milestoneIndex: b.milestoneIndex != null ? parseInt(b.milestoneIndex,10) : null
  122. }))
  123. : [],
  124. followers: [],
  125. backers: [],
  126. author: ssbClient.id,
  127. createdAt: new Date().toISOString(),
  128. updatedAt: null
  129. }
  130. return new Promise((res, rej) => ssbClient.publish(content, (e, m) => e ? rej(e) : res(m)))
  131. },
  132. async updateProject(id, patch) {
  133. const ssbClient = await openSsb()
  134. const current = await getById(id)
  135. if (current.author !== ssbClient.id) throw new Error('Unauthorized')
  136. let blobId = (patch.image === undefined ? current.image : patch.image)
  137. blobId = extractBlobId(blobId)
  138. let bounties = patch.bounties === undefined ? current.bounties : patch.bounties
  139. if (bounties) {
  140. bounties = bounties.map(b => ({
  141. title: String(b.title || '').trim(),
  142. amount: Math.max(0, parseFloat(b.amount || 0) || 0),
  143. description: b.description || '',
  144. claimedBy: b.claimedBy || null,
  145. done: !!b.done,
  146. milestoneIndex: b.milestoneIndex != null ? safeMilestoneIndex(current, b.milestoneIndex) : null
  147. }))
  148. }
  149. const tomb = { type: 'tombstone', target: current.id, deletedAt: new Date().toISOString(), author: ssbClient.id }
  150. const updated = {
  151. type: TYPE,
  152. ...current,
  153. ...patch,
  154. image: blobId || null,
  155. bounties,
  156. updatedAt: new Date().toISOString(),
  157. replaces: current.id
  158. }
  159. await new Promise((res, rej) => ssbClient.publish(tomb, e => e ? rej(e) : res()))
  160. return new Promise((res, rej) => ssbClient.publish(updated, (e, m) => e ? rej(e) : res(m)))
  161. },
  162. async deleteProject(id) {
  163. const ssbClient = await openSsb()
  164. const tip = await resolveTipId(id)
  165. const project = await getById(tip)
  166. if (project.author !== ssbClient.id) throw new Error('Unauthorized')
  167. const tomb = { type: 'tombstone', target: tip, deletedAt: new Date().toISOString(), author: ssbClient.id }
  168. return new Promise((res, rej) => ssbClient.publish(tomb, (e, r) => e ? rej(e) : res(r)))
  169. },
  170. async updateProjectStatus(id, status) {
  171. return this.updateProject(id, { status: String(status || '').toUpperCase() })
  172. },
  173. async updateProjectProgress(id, progress) {
  174. const p = clampPercent(progress)
  175. return this.updateProject(id, { progress: p, status: p >= 100 ? 'COMPLETED' : undefined })
  176. },
  177. async getProjectById(id) {
  178. const project = await projectsModel.getById(id);
  179. project.backers = project.backers || [];
  180. const bakers = project.backers.map(b => ({
  181. userId: b.userId,
  182. amount: b.amount,
  183. contributedAt: moment(b.at).format('YYYY/MM/DD')
  184. }));
  185. return { ...project, bakers };
  186. },
  187. async updateProjectGoalProgress(projectId, pledgeAmount) {
  188. const project = await projectsModel.getById(projectId);
  189. project.pledged += pledgeAmount;
  190. const goalProgress = (project.pledged / project.goal) * 100;
  191. await projectsModel.updateProject(projectId, { pledged: project.pledged, progress: goalProgress });
  192. },
  193. async followProject(id, userId) {
  194. const tip = await this.getProjectTipId(id)
  195. const project = await this.getProjectById(tip)
  196. const followers = Array.isArray(project.followers) ? project.followers.slice() : []
  197. if (!followers.includes(userId)) followers.push(userId)
  198. return this.updateProject(tip, { followers })
  199. },
  200. async unfollowProject(id, userId) {
  201. const tip = await this.getProjectTipId(id)
  202. const project = await this.getProjectById(tip)
  203. const followers = (project.followers || []).filter(uid => uid !== userId)
  204. return this.updateProject(tip, { followers })
  205. },
  206. async pledgeToProject(id, userId, amount) {
  207. openSsb().then(ssbClient => {
  208. const tip = getProjectTipId(id);
  209. getProjectById(tip).then(project => {
  210. const amt = Math.max(0, parseFloat(amount || 0) || 0);
  211. if (amt <= 0) throw new Error('Invalid amount');
  212. const backers = Array.isArray(project.backers) ? project.backers.slice() : [];
  213. backers.push({ userId, amount: amt, at: new Date().toISOString() });
  214. const pledged = (parseFloat(project.pledged || 0) || 0) + amt;
  215. updateProject(tip, { backers, pledged }).then(updated => {
  216. if (project.author == userId) {
  217. const recipients = [project.author];
  218. const content = {
  219. type: 'post',
  220. from: ssbClient.id,
  221. to: recipients,
  222. subject: 'PROJECT_PLEDGE',
  223. text: `${userId} has pledged ${amt} ECO to your project "${project.title}" /projects/${encodeURIComponent(tip)}`,
  224. sentAt: new Date().toISOString(),
  225. private: true,
  226. meta: {
  227. type: 'project-pledge',
  228. projectId: tip,
  229. projectTitle: project.title,
  230. amount: amt,
  231. pledgedBy: userId
  232. }
  233. };
  234. ssbClient.private.publish(content, recipients);
  235. }
  236. return updated;
  237. });
  238. });
  239. });
  240. },
  241. async addBounty(id, bounty) {
  242. const tip = await this.getProjectTipId(id);
  243. const project = await this.getProjectById(tip);
  244. const bounties = Array.isArray(project.bounties) ? project.bounties.slice() : [];
  245. const clean = {
  246. title: String(bounty.title || '').trim(),
  247. amount: Math.max(0, parseFloat(bounty.amount || 0) || 0),
  248. description: bounty.description || '',
  249. claimedBy: null,
  250. done: false,
  251. milestoneIndex: safeMilestoneIndex(project, bounty.milestoneIndex)
  252. };
  253. bounties.push(clean);
  254. return this.updateProject(tip, { bounties });
  255. },
  256. async updateBounty(id, index, patch) {
  257. const tip = await this.getProjectTipId(id);
  258. const project = await this.getProjectById(tip);
  259. const bounties = Array.isArray(project.bounties) ? project.bounties.slice() : [];
  260. if (!bounties[index]) throw new Error('Bounty not found');
  261. if (patch.title !== undefined) bounties[index].title = String(patch.title).trim();
  262. if (patch.amount !== undefined) bounties[index].amount = Math.max(0, parseFloat(patch.amount || 0) || 0);
  263. if (patch.description !== undefined) bounties[index].description = patch.description || '';
  264. if (patch.milestoneIndex !== undefined) {
  265. const newIdx = patch.milestoneIndex == null ? null : parseInt(patch.milestoneIndex, 10);
  266. bounties[index].milestoneIndex = (newIdx == null) ? null : (isNaN(newIdx) ? null : newIdx);
  267. }
  268. if (patch.done !== undefined) bounties[index].done = !!patch.done;
  269. return this.updateProject(tip, { bounties });
  270. },
  271. async updateMilestone(id, index, patch) {
  272. const tip = await this.getProjectTipId(id)
  273. const project = await this.getProjectById(tip)
  274. const milestones = Array.isArray(project.milestones) ? project.milestones.slice() : []
  275. if (!milestones[index]) throw new Error('Milestone not found')
  276. if (patch.title !== undefined) milestones[index].title = String(patch.title).trim()
  277. if (patch.targetPercent !== undefined) milestones[index].targetPercent = clampPercent(patch.targetPercent)
  278. if (patch.dueDate !== undefined) milestones[index].dueDate = patch.dueDate ? new Date(patch.dueDate).toISOString() : null
  279. let progress = project.progress
  280. if (patch.done !== undefined) {
  281. milestones[index].done = !!patch.done
  282. if (milestones[index].done) {
  283. const target = clampPercent(milestones[index].targetPercent || 0)
  284. progress = Math.max(parseInt(project.progress || 0, 10) || 0, target)
  285. }
  286. }
  287. const patchOut = { milestones }
  288. if (progress !== project.progress) {
  289. patchOut.progress = progress
  290. if (progress >= 100) patchOut.status = 'COMPLETED'
  291. }
  292. return this.updateProject(tip, patchOut)
  293. },
  294. async claimBounty(id, index, userId) {
  295. const tip = await this.getProjectTipId(id)
  296. const project = await this.getProjectById(tip)
  297. const bounties = Array.isArray(project.bounties) ? project.bounties.slice() : []
  298. if (!bounties[index]) throw new Error('Bounty not found')
  299. if (bounties[index].claimedBy) throw new Error('Already claimed')
  300. bounties[index].claimedBy = userId
  301. return this.updateProject(tip, { bounties })
  302. },
  303. async completeBounty(id, index, userId) {
  304. const tip = await this.getProjectTipId(id)
  305. const project = await this.getProjectById(tip)
  306. if (project.author !== userId) throw new Error('Unauthorized')
  307. const bounties = Array.isArray(project.bounties) ? project.bounties.slice() : []
  308. if (!bounties[index]) throw new Error('Bounty not found')
  309. bounties[index].done = true
  310. const { milestones, progress, changed } =
  311. autoCompleteMilestoneIfReady({ ...project, bounties }, bounties[index].milestoneIndex, clampPercent)
  312. const patch = { bounties }
  313. if (changed) {
  314. patch.milestones = milestones
  315. patch.progress = progress
  316. if (progress >= 100) patch.status = 'COMPLETED'
  317. }
  318. return this.updateProject(tip, patch)
  319. },
  320. async addMilestone(id, milestone) {
  321. const tip = await this.getProjectTipId(id)
  322. const project = await this.getProjectById(tip)
  323. const milestones = Array.isArray(project.milestones) ? project.milestones.slice() : []
  324. const clean = {
  325. title: String(milestone.title || '').trim(),
  326. description: milestone.description || '',
  327. targetPercent: clampPercent(milestone.targetPercent || 0),
  328. dueDate: milestone.dueDate ? new Date(milestone.dueDate).toISOString() : null,
  329. done: false
  330. }
  331. if (!clean.title) throw new Error('Milestone title required')
  332. milestones.push(clean)
  333. return this.updateProject(tip, { milestones })
  334. },
  335. async completeMilestone(id, index, userId) {
  336. const tip = await this.getProjectTipId(id)
  337. const project = await this.getProjectById(tip)
  338. if (project.author !== userId) throw new Error('Unauthorized')
  339. const milestones = Array.isArray(project.milestones) ? project.milestones.slice() : []
  340. if (!milestones[index]) throw new Error('Milestone not found')
  341. milestones[index].done = true
  342. const target = clampPercent(milestones[index].targetPercent || 0)
  343. const progress = Math.max(parseInt(project.progress || 0, 10) || 0, target)
  344. const patch = { milestones, progress }
  345. if (progress >= 100) patch.status = 'COMPLETED'
  346. return this.updateProject(tip, patch)
  347. },
  348. async listProjects(filter) {
  349. const ssbClient = await openSsb()
  350. const currentUserId = ssbClient.id
  351. return new Promise((res, rej) => {
  352. pull(
  353. ssbClient.createLogStream({ limit: logLimit }),
  354. pull.collect((e, msgs) => {
  355. if (e) return rej(e)
  356. const tomb = new Set()
  357. const replaces = new Map()
  358. const referencedAsReplaces = new Set()
  359. const projects = new Map()
  360. msgs.forEach(m => {
  361. const k = m.key
  362. const c = m.value.content
  363. if (!c) return
  364. if (c.type === 'tombstone' && c.target) { tomb.add(c.target); return }
  365. if (c.type !== TYPE) return
  366. if (c.replaces) { replaces.set(c.replaces, k); referencedAsReplaces.add(c.replaces) }
  367. projects.set(k, { key: k, content: c })
  368. })
  369. const tipProjects = []
  370. for (const [id, pr] of projects.entries()) if (!referencedAsReplaces.has(id)) tipProjects.push(pr)
  371. const groups = {}
  372. for (const pr of tipProjects) {
  373. const ancestor = pr.content.replaces || pr.key
  374. if (!groups[ancestor]) groups[ancestor] = []
  375. groups[ancestor].push(pr)
  376. }
  377. const liveTipIds = new Set()
  378. for (const group of Object.values(groups)) {
  379. let best = group[0]
  380. for (const pr of group) {
  381. const bestTime = new Date(best.content.updatedAt || best.content.createdAt || 0)
  382. const prTime = new Date(pr.content.updatedAt || pr.content.createdAt || 0)
  383. if (
  384. (best.content.status === 'CANCELLED' && pr.content.status !== 'CANCELLED') ||
  385. (best.content.status === pr.content.status && prTime > bestTime) ||
  386. pr.content.status === 'COMPLETED'
  387. ) best = pr
  388. }
  389. liveTipIds.add(best.key)
  390. }
  391. let list = Array.from(projects.values())
  392. .filter(p => liveTipIds.has(p.key) && !tomb.has(p.key))
  393. .map(p => ({ id: p.key, ...p.content }))
  394. const F = String(filter || 'ALL').toUpperCase()
  395. if (F === 'MINE') list = list.filter(p => p.author === currentUserId)
  396. else if (F === 'ACTIVE') list = list.filter(p => (p.status || '').toUpperCase() === 'ACTIVE')
  397. else if (F === 'COMPLETED') list = list.filter(p => (p.status || '').toUpperCase() === 'COMPLETED')
  398. else if (F === 'PAUSED') list = list.filter(p => (p.status || '').toUpperCase() === 'PAUSED')
  399. else if (F === 'CANCELLED') list = list.filter(p => (p.status || '').toUpperCase() === 'CANCELLED')
  400. else if (F === 'RECENT') list = list.filter(p => moment(p.createdAt).isAfter(moment().subtract(24, 'hours')))
  401. else if (F === 'FOLLOWING') list = list.filter(p => Array.isArray(p.followers) && p.followers.includes(currentUserId))
  402. if (F === 'TOP') list.sort((a, b) => (parseFloat(b.pledged||0)/(parseFloat(b.goal||1))) - (parseFloat(a.pledged||0)/(parseFloat(a.goal||1))))
  403. else list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
  404. res(list)
  405. })
  406. )
  407. })
  408. },
  409. async getProjectById(id) { return getById(id) },
  410. async getProjectTipId(id) { return resolveTipId(id) }
  411. }
  412. }