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.
613 lines
29 KiB
JavaScript
613 lines
29 KiB
JavaScript
const pull = require("../server/node_modules/pull-stream")
|
|
const moment = require("../server/node_modules/moment")
|
|
const { getConfig } = require("../configs/config-manager.js")
|
|
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
|
const { dedupeBy, norm } = require('../backend/dedupe')
|
|
const { readTyped } = require("./typed_log")
|
|
const logLimit = (getConfig().ssbLogStream && getConfig().ssbLogStream.limit) || 1000
|
|
|
|
module.exports = ({ cooler }) => {
|
|
let ssb
|
|
const openSsb = async () => {
|
|
if (!ssb) ssb = await cooler.open()
|
|
return ssb
|
|
}
|
|
|
|
const TYPE = "project"
|
|
|
|
const clampPercent = (n) => {
|
|
const x = parseInt(n, 10)
|
|
if (!Number.isFinite(x)) return 0
|
|
return Math.max(0, Math.min(100, x))
|
|
}
|
|
|
|
const PROJECT_TYPES = ["project", "projectOpinion", "projectFollow", "projectPledge", "projectClaim", "projectPledgeConfirm", "tombstone"]
|
|
|
|
async function getAllMsgs(ssbClient) {
|
|
return readTyped(ssbClient, PROJECT_TYPES, { limit: logLimit })
|
|
}
|
|
|
|
function extractBlobId(possibleMarkdownImage) {
|
|
return possibleMarkdownImage || null
|
|
}
|
|
|
|
function normalizeMilestonesFrom(data) {
|
|
if (Array.isArray(data.milestones)) {
|
|
return data.milestones
|
|
.map((m) => {
|
|
return {
|
|
title: String((m && m.title) || "").trim(),
|
|
description: (m && m.description) || "",
|
|
targetPercent: clampPercent(m && m.targetPercent),
|
|
dueDate: m && m.dueDate ? new Date(m.dueDate).toISOString() : null,
|
|
done: !!(m && m.done)
|
|
}
|
|
})
|
|
.filter((m) => m.title)
|
|
}
|
|
|
|
const title = String((data["milestones[0][title]"] || data.milestoneTitle || "")).trim()
|
|
const description = data["milestones[0][description]"] || data.milestoneDescription || ""
|
|
const tpRaw = (data["milestones[0][targetPercent]"] != null ? data["milestones[0][targetPercent]"] : data.milestoneTargetPercent) != null
|
|
? (data["milestones[0][targetPercent]"] != null ? data["milestones[0][targetPercent]"] : data.milestoneTargetPercent)
|
|
: 0
|
|
const targetPercent = clampPercent(tpRaw)
|
|
const dueRaw = data["milestones[0][dueDate]"] || data.milestoneDueDate || null
|
|
const dueDate = dueRaw ? new Date(dueRaw).toISOString() : null
|
|
const out = []
|
|
if (title) out.push({ title, description, targetPercent, dueDate, done: false })
|
|
return out
|
|
}
|
|
|
|
function safeMilestoneIndex(project, idx) {
|
|
const total = Array.isArray(project.milestones) ? project.milestones.length : 0
|
|
if (idx === null || idx === undefined || idx === "" || isNaN(idx)) return null
|
|
const n = parseInt(idx, 10)
|
|
if (!Number.isFinite(n)) return null
|
|
if (n < 0 || n >= total) return null
|
|
return n
|
|
}
|
|
|
|
function autoCompleteMilestoneIfReady(projectLike, milestoneIdx) {
|
|
if (milestoneIdx === null || milestoneIdx === undefined) {
|
|
return { milestones: projectLike.milestones || [], progress: projectLike.progress || 0, changed: false }
|
|
}
|
|
const milestones = Array.isArray(projectLike.milestones) ? projectLike.milestones.slice() : []
|
|
if (!milestones[milestoneIdx]) {
|
|
return { milestones, progress: projectLike.progress || 0, changed: false }
|
|
}
|
|
const bounties = Array.isArray(projectLike.bounties) ? projectLike.bounties : []
|
|
const related = bounties.filter((b) => b && b.milestoneIndex === milestoneIdx)
|
|
if (related.length === 0) {
|
|
return { milestones, progress: projectLike.progress || 0, changed: false }
|
|
}
|
|
const allDone = related.every((b) => !!(b && b.done))
|
|
let progress = projectLike.progress || 0
|
|
let changed = false
|
|
if (allDone && !milestones[milestoneIdx].done) {
|
|
milestones[milestoneIdx].done = true
|
|
const target = clampPercent(milestones[milestoneIdx].targetPercent || 0)
|
|
const pInt = parseInt(progress, 10)
|
|
progress = Math.max(Number.isFinite(pInt) ? pInt : 0, target)
|
|
changed = true
|
|
}
|
|
return { milestones, progress, changed }
|
|
}
|
|
|
|
const COLLAB_MSG = new Set(["projectOpinion", "projectFollow", "projectPledge", "projectClaim", "projectPledgeConfirm"])
|
|
|
|
function buildProjectIndex(messages) {
|
|
const tomb = buildValidatedTombstoneSet(messages)
|
|
const nodes = new Map()
|
|
const collab = { opinion: [], follow: [], pledge: [], claim: [], pledgeConfirm: [] }
|
|
for (const m of messages) {
|
|
const c = m && m.value && m.value.content
|
|
if (!c) continue
|
|
if (c.type === "tombstone") continue
|
|
if (c.type === "projectOpinion") { collab.opinion.push({ target: c.target, author: m.value.author, category: c.category, ts: (m.value && m.value.timestamp) || 0 }); continue }
|
|
if (c.type === "projectFollow") { collab.follow.push({ target: c.target, author: m.value.author, on: c.on !== false, ts: (m.value && m.value.timestamp) || 0 }); continue }
|
|
if (c.type === "projectPledge") { collab.pledge.push({ target: c.target, author: m.value.author, amount: Math.max(0, parseFloat(c.amount || 0) || 0), transferId: c.transferId || null, milestoneIndex: c.milestoneIndex != null ? c.milestoneIndex : null, bountyIndex: c.bountyIndex != null ? c.bountyIndex : null, ts: (m.value && m.value.timestamp) || 0 }); continue }
|
|
if (c.type === "projectPledgeConfirm") { collab.pledgeConfirm.push({ target: c.target, author: m.value.author, transferId: c.transferId || null, ts: (m.value && m.value.timestamp) || 0 }); continue }
|
|
if (c.type === "projectClaim") { collab.claim.push({ target: c.target, author: m.value.author, index: parseInt(c.index, 10), ts: (m.value && m.value.timestamp) || 0 }); continue }
|
|
if (c.type !== TYPE) continue
|
|
nodes.set(m.key, { key: m.key, ts: (m.value && m.value.timestamp) || 0, c, author: m.value.author })
|
|
}
|
|
const naiveNext = new Map(), naivePrev = new Map(), strictNext = new Map()
|
|
for (const [key, n] of nodes) {
|
|
const t = n.c.replaces
|
|
if (!t) continue
|
|
naiveNext.set(t, key); naivePrev.set(key, t)
|
|
const orig = nodes.get(t)
|
|
if (orig && orig.author === n.author) strictNext.set(t, key)
|
|
}
|
|
return { tomb, nodes, collab, naiveNext, naivePrev, strictNext }
|
|
}
|
|
|
|
function resolveProjectGroup(idx, root) {
|
|
const { nodes, collab, naiveNext, strictNext } = idx
|
|
const followStrict = (key) => { let x = key, g = 0; while (strictNext.has(x) && g++ < 100000) x = strictNext.get(x); return x }
|
|
const rootNode = nodes.get(root)
|
|
const rootAuthor = rootNode ? rootNode.author : null
|
|
let tip = followStrict(root)
|
|
const tn = nodes.get(tip)
|
|
if (!tn || tn.author !== rootAuthor) tip = root
|
|
const best = nodes.get(tip)
|
|
if (!best) return null
|
|
|
|
const groupKeys = []
|
|
{ let x = root, g = 0; groupKeys.push(x); while (naiveNext.has(x) && g++ < 100000) { x = naiveNext.get(x); groupKeys.push(x) } }
|
|
|
|
const project = { ...best.c, id: tip }
|
|
|
|
const followers = new Set()
|
|
const opinions = {}
|
|
const opinionSet = new Set()
|
|
let backers = []
|
|
const claimByIndex = new Map()
|
|
for (const k of groupKeys) {
|
|
const n = nodes.get(k); if (!n) continue
|
|
if (n.author !== rootAuthor) continue
|
|
const c = n.c
|
|
for (const f of (Array.isArray(c.followers) ? c.followers : [])) followers.add(f)
|
|
if (Array.isArray(c.backers) && c.backers.length > backers.length) backers = c.backers.slice()
|
|
for (const v of (Array.isArray(c.opinions_inhabitants) ? c.opinions_inhabitants : [])) {
|
|
if (!opinionSet.has(v)) opinionSet.add(v)
|
|
}
|
|
if (c.opinions && typeof c.opinions === "object") { for (const kk of Object.keys(c.opinions)) opinions[kk] = Math.max(opinions[kk] || 0, Number(c.opinions[kk]) || 0) }
|
|
if (Array.isArray(c.bounties)) c.bounties.forEach((b, i) => { if (b && b.claimedBy && !claimByIndex.has(i)) claimByIndex.set(i, b.claimedBy) })
|
|
}
|
|
|
|
for (const f of collab.follow.filter(x => x.target === root).sort((a, b) => a.ts - b.ts)) { if (f.on) followers.add(f.author); else followers.delete(f.author) }
|
|
for (const op of collab.opinion.filter(x => x.target === root)) { if (!opinionSet.has(op.author)) { opinionSet.add(op.author); opinions[op.category] = (opinions[op.category] || 0) + 1 } }
|
|
const confirmedTransfers = new Set(collab.pledgeConfirm.filter(x => x.target === root && x.author === rootAuthor && x.transferId).map(x => String(x.transferId)))
|
|
const pledgeMsgs = collab.pledge.filter(x => x.target === root).sort((a, b) => a.ts - b.ts)
|
|
for (const p of pledgeMsgs) backers.push({ userId: p.author, amount: p.amount, at: new Date(p.ts || 0).toISOString(), transferId: p.transferId || null, milestoneIndex: p.milestoneIndex, bountyIndex: p.bountyIndex, confirmed: !!(p.transferId && confirmedTransfers.has(String(p.transferId))) })
|
|
for (const cl of collab.claim.filter(x => x.target === root).sort((a, b) => a.ts - b.ts)) { if (Number.isInteger(cl.index) && !claimByIndex.has(cl.index)) claimByIndex.set(cl.index, cl.author) }
|
|
|
|
project.followers = [...followers]
|
|
project.opinions = opinions
|
|
project.opinions_inhabitants = [...opinionSet]
|
|
project.backers = backers
|
|
const pledged = backers.reduce((s, b) => s + (parseFloat(b && b.amount || 0) || 0), 0)
|
|
project.pledged = pledged
|
|
if (Array.isArray(project.bounties)) project.bounties = project.bounties.map((b, i) => ({ ...b, claimedBy: claimByIndex.has(i) ? claimByIndex.get(i) : (b && b.claimedBy) || null }))
|
|
const goalNum = parseFloat(project.goal || 0) || 0
|
|
if (goalNum > 0) project.progress = Math.max(clampPercent(project.progress), Math.min(100, Math.round((pledged / goalNum) * 100)))
|
|
|
|
return { tip, root, best, project, tombstoned: idx.tomb.has(tip) }
|
|
}
|
|
|
|
const rootOfIdx = (idx, key) => { let x = key, g = 0; while (idx.naivePrev.has(x) && idx.nodes.has(idx.naivePrev.get(x)) && g++ < 100000) x = idx.naivePrev.get(x); return x }
|
|
|
|
async function resolveTipId(id) {
|
|
const ssbClient = await openSsb()
|
|
const idx = buildProjectIndex(await getAllMsgs(ssbClient))
|
|
const g = resolveProjectGroup(idx, rootOfIdx(idx, id))
|
|
if (!g || g.tombstoned) throw new Error("Project not found")
|
|
return g.tip
|
|
}
|
|
|
|
async function getById(id) {
|
|
const ssbClient = await openSsb()
|
|
const idx = buildProjectIndex(await getAllMsgs(ssbClient))
|
|
const g = resolveProjectGroup(idx, rootOfIdx(idx, id))
|
|
if (!g || g.tombstoned) throw new Error("Project not found")
|
|
return g.project
|
|
}
|
|
|
|
const CONTENT_COLLAB_FIELDS = ["followers", "backers", "opinions", "opinions_inhabitants", "pledged", "activity"]
|
|
function stripCollab(content) {
|
|
const out = { ...content }
|
|
delete out.id
|
|
for (const f of CONTENT_COLLAB_FIELDS) delete out[f]
|
|
out.followers = []
|
|
out.backers = []
|
|
out.opinions = {}
|
|
out.opinions_inhabitants = []
|
|
out.pledged = 0
|
|
if (Array.isArray(out.bounties)) out.bounties = out.bounties.map((b) => ({ ...b, claimedBy: null }))
|
|
return out
|
|
}
|
|
|
|
async function publishReplace(ssbClient, currentId, content) {
|
|
const tomb = { type: "tombstone", target: currentId, deletedAt: new Date().toISOString(), author: ssbClient.id }
|
|
const updated = { ...stripCollab(content), type: TYPE, replaces: currentId, updatedAt: new Date().toISOString() }
|
|
await new Promise((res, rej) => ssbClient.publish(tomb, (e) => (e ? rej(e) : res())))
|
|
return new Promise((res, rej) => ssbClient.publish(updated, (e, m) => (e ? rej(e) : res(m))))
|
|
}
|
|
|
|
function isParticipant(project, uid) {
|
|
if (!project || !uid) return false
|
|
const backers = Array.isArray(project.backers) ? project.backers : []
|
|
if (backers.some((b) => b && b.userId === uid)) return true
|
|
const bounties = Array.isArray(project.bounties) ? project.bounties : []
|
|
if (bounties.some((b) => b && b.claimedBy === uid)) return true
|
|
return false
|
|
}
|
|
|
|
return {
|
|
type: TYPE,
|
|
|
|
async createProject(data) {
|
|
const ssbClient = await openSsb()
|
|
const blobId = extractBlobId(data.image)
|
|
const milestones = normalizeMilestonesFrom(data)
|
|
|
|
let goal = parseFloat(data.goal || 0) || 0
|
|
if (goal < 0) goal = 0
|
|
|
|
const deadlineISO = data.deadline ? new Date(data.deadline).toISOString() : null
|
|
if (deadlineISO && new Date(deadlineISO).getTime() < Date.now()) throw new Error("The deadline cannot be in the past")
|
|
|
|
const content = {
|
|
type: TYPE,
|
|
title: data.title,
|
|
description: data.description,
|
|
image: blobId || null,
|
|
goal,
|
|
pledged: parseFloat(data.pledged || 0) || 0,
|
|
deadline: deadlineISO,
|
|
progress: clampPercent(data.progress || 0),
|
|
status: String(data.status || "ACTIVE").toUpperCase(),
|
|
milestones,
|
|
bounties: Array.isArray(data.bounties)
|
|
? data.bounties
|
|
.map((b) => {
|
|
return {
|
|
title: String((b && b.title) || "").trim(),
|
|
amount: Math.max(0, parseFloat((b && b.amount) || 0) || 0),
|
|
description: (b && b.description) || "",
|
|
claimedBy: (b && b.claimedBy) || null,
|
|
done: !!(b && b.done),
|
|
milestoneIndex: b && b.milestoneIndex != null ? parseInt(b.milestoneIndex, 10) : null
|
|
}
|
|
})
|
|
.filter((b) => b.title)
|
|
: [],
|
|
followers: [],
|
|
backers: [],
|
|
author: ssbClient.id,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: null,
|
|
mapUrl: String(data.mapUrl || "").trim(),
|
|
clearnetPublic: data.clearnetPublic === true || data.clearnetPublic === 'true' || data.clearnetPublic === 'on',
|
|
opinions: {},
|
|
opinions_inhabitants: []
|
|
}
|
|
|
|
return new Promise((res, rej) => ssbClient.publish(content, (e, m) => (e ? rej(e) : res(m))))
|
|
},
|
|
|
|
async updateProject(id, patch) {
|
|
const ssbClient = await openSsb()
|
|
const current = await getById(id)
|
|
if (current.author !== ssbClient.id) throw new Error("Unauthorized")
|
|
|
|
let blobId = patch.image === undefined ? current.image : patch.image
|
|
blobId = extractBlobId(blobId)
|
|
|
|
let milestones = patch.milestones === undefined ? current.milestones : patch.milestones
|
|
if (milestones != null) {
|
|
milestones = Array.isArray(milestones)
|
|
? milestones
|
|
.map((m) => {
|
|
return {
|
|
title: String((m && m.title) || "").trim(),
|
|
description: (m && m.description) || "",
|
|
targetPercent: clampPercent(m && m.targetPercent),
|
|
dueDate: m && m.dueDate ? new Date(m.dueDate).toISOString() : null,
|
|
done: !!(m && m.done)
|
|
}
|
|
})
|
|
.filter((m) => m.title)
|
|
: current.milestones
|
|
}
|
|
|
|
let bounties = patch.bounties === undefined ? current.bounties : patch.bounties
|
|
if (bounties != null) {
|
|
bounties = Array.isArray(bounties)
|
|
? bounties
|
|
.map((b) => {
|
|
return {
|
|
title: String((b && b.title) || "").trim(),
|
|
amount: Math.max(0, parseFloat((b && b.amount) || 0) || 0),
|
|
description: (b && b.description) || "",
|
|
claimedBy: (b && b.claimedBy) || null,
|
|
done: !!(b && b.done),
|
|
milestoneIndex: b && b.milestoneIndex != null ? safeMilestoneIndex({ milestones: milestones || current.milestones }, b.milestoneIndex) : null
|
|
}
|
|
})
|
|
.filter((b) => b.title)
|
|
: current.bounties
|
|
}
|
|
|
|
let deadline = patch.deadline === undefined ? current.deadline : patch.deadline
|
|
if (deadline != null && deadline !== "") {
|
|
deadline = new Date(deadline).toISOString()
|
|
const changed = !current.deadline || new Date(current.deadline).getTime() !== new Date(deadline).getTime()
|
|
if (changed && new Date(deadline).getTime() < Date.now()) throw new Error("The deadline cannot be in the past")
|
|
}
|
|
else if (deadline === "") deadline = null
|
|
|
|
const updated = {
|
|
...current,
|
|
...patch,
|
|
image: blobId || null,
|
|
milestones,
|
|
bounties,
|
|
deadline,
|
|
progress: patch.progress === undefined ? current.progress : clampPercent(patch.progress),
|
|
status: patch.status === undefined ? current.status : String(patch.status || "").toUpperCase(),
|
|
clearnetPublic: patch.clearnetPublic === undefined ? !!current.clearnetPublic : (patch.clearnetPublic === true || patch.clearnetPublic === 'true' || patch.clearnetPublic === 'on')
|
|
}
|
|
|
|
return publishReplace(ssbClient, current.id, updated)
|
|
},
|
|
|
|
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 idx = buildProjectIndex(await getAllMsgs(ssbClient))
|
|
const g = resolveProjectGroup(idx, rootOfIdx(idx, id))
|
|
if (!g || g.tombstoned) throw new Error('Project not found')
|
|
if ((g.project.opinions_inhabitants || []).includes(userId)) throw new Error('Already opined')
|
|
const content = { type: 'projectOpinion', target: g.root, category, createdAt: new Date().toISOString() }
|
|
return new Promise((res, rej) => ssbClient.publish(content, (e, m) => (e ? rej(e) : res(m))))
|
|
},
|
|
|
|
async deleteProject(id) {
|
|
const ssbClient = await openSsb()
|
|
const tip = await resolveTipId(id)
|
|
const project = await getById(tip)
|
|
if (project.author !== ssbClient.id) throw new Error("Unauthorized")
|
|
const tomb = { type: "tombstone", target: tip, deletedAt: new Date().toISOString(), author: ssbClient.id }
|
|
return new Promise((res, rej) => ssbClient.publish(tomb, (e, r) => (e ? rej(e) : res(r))))
|
|
},
|
|
|
|
async updateProjectStatus(id, status) {
|
|
const s = String(status || "").toUpperCase()
|
|
return this.updateProject(id, { status: s })
|
|
},
|
|
|
|
async updateProjectProgress(id, progress) {
|
|
const p = clampPercent(progress)
|
|
return this.updateProject(id, { progress: p, ...(p >= 100 ? { status: "COMPLETED" } : {}) })
|
|
},
|
|
|
|
async followProject(id, uid) {
|
|
const ssbClient = await openSsb()
|
|
const idx = buildProjectIndex(await getAllMsgs(ssbClient))
|
|
const g = resolveProjectGroup(idx, rootOfIdx(idx, id))
|
|
if (!g || g.tombstoned) throw new Error("Project not found")
|
|
const content = { type: "projectFollow", target: g.root, on: true, createdAt: new Date().toISOString() }
|
|
return new Promise((res, rej) => ssbClient.publish(content, (e, m) => (e ? rej(e) : res(m))))
|
|
},
|
|
|
|
async unfollowProject(id, uid) {
|
|
const ssbClient = await openSsb()
|
|
const idx = buildProjectIndex(await getAllMsgs(ssbClient))
|
|
const g = resolveProjectGroup(idx, rootOfIdx(idx, id))
|
|
if (!g || g.tombstoned) throw new Error("Project not found")
|
|
const content = { type: "projectFollow", target: g.root, on: false, createdAt: new Date().toISOString() }
|
|
return new Promise((res, rej) => ssbClient.publish(content, (e, m) => (e ? rej(e) : res(m))))
|
|
},
|
|
|
|
async pledgeToProject(id, uid, amount, extra = {}) {
|
|
const ssbClient = await openSsb()
|
|
const idx = buildProjectIndex(await getAllMsgs(ssbClient))
|
|
const g = resolveProjectGroup(idx, rootOfIdx(idx, id))
|
|
if (!g || g.tombstoned) throw new Error("Project not found")
|
|
const amt = Math.max(0, parseFloat(amount || 0) || 0)
|
|
if (amt <= 0) throw new Error("Invalid amount")
|
|
const content = { type: "projectPledge", target: g.root, amount: amt, createdAt: new Date().toISOString() }
|
|
if (extra && extra.transferId) content.transferId = extra.transferId
|
|
if (extra && extra.milestoneIndex != null) content.milestoneIndex = extra.milestoneIndex
|
|
if (extra && extra.bountyIndex != null) content.bountyIndex = extra.bountyIndex
|
|
return new Promise((res, rej) => ssbClient.publish(content, (e, m) => (e ? rej(e) : res(m))))
|
|
},
|
|
|
|
async confirmPledge(id, transferId) {
|
|
const ssbClient = await openSsb()
|
|
const userId = ssbClient.id
|
|
const idx = buildProjectIndex(await getAllMsgs(ssbClient))
|
|
const g = resolveProjectGroup(idx, rootOfIdx(idx, id))
|
|
if (!g || g.tombstoned) throw new Error("Project not found")
|
|
if (String(g.project.author) !== String(userId)) throw new Error("Only the project author can confirm pledges")
|
|
const content = { type: "projectPledgeConfirm", target: g.root, transferId: String(transferId || ""), createdAt: new Date().toISOString() }
|
|
return new Promise((res, rej) => ssbClient.publish(content, (e, m) => (e ? rej(e) : res(m))))
|
|
},
|
|
|
|
async addBounty(id, bounty) {
|
|
const tip = await resolveTipId(id)
|
|
const ssbClient = await openSsb()
|
|
const project = await getById(tip)
|
|
if (project.author !== ssbClient.id) throw new Error("Unauthorized")
|
|
const bounties = Array.isArray(project.bounties) ? project.bounties.slice() : []
|
|
const clean = {
|
|
title: String((bounty && bounty.title) || "").trim(),
|
|
amount: Math.max(0, parseFloat((bounty && bounty.amount) || 0) || 0),
|
|
description: (bounty && bounty.description) || "",
|
|
claimedBy: null,
|
|
done: false,
|
|
milestoneIndex: safeMilestoneIndex(project, bounty && bounty.milestoneIndex)
|
|
}
|
|
if (!clean.title) throw new Error("Bounty title required")
|
|
bounties.push(clean)
|
|
return publishReplace(ssbClient, project.id, { ...project, bounties })
|
|
},
|
|
|
|
async updateBounty(id, index, patch) {
|
|
const tip = await resolveTipId(id)
|
|
const ssbClient = await openSsb()
|
|
const project = await getById(tip)
|
|
if (project.author !== ssbClient.id) throw new Error("Unauthorized")
|
|
const bounties = Array.isArray(project.bounties) ? project.bounties.slice() : []
|
|
if (!bounties[index]) throw new Error("Bounty not found")
|
|
|
|
if (patch.title !== undefined) bounties[index].title = String(patch.title || "").trim()
|
|
if (patch.amount !== undefined) bounties[index].amount = Math.max(0, parseFloat(patch.amount || 0) || 0)
|
|
if (patch.description !== undefined) bounties[index].description = patch.description || ""
|
|
if (patch.milestoneIndex !== undefined) bounties[index].milestoneIndex = safeMilestoneIndex(project, patch.milestoneIndex)
|
|
if (patch.done !== undefined) bounties[index].done = !!patch.done
|
|
|
|
return publishReplace(ssbClient, project.id, { ...project, bounties })
|
|
},
|
|
|
|
async addMilestone(id, milestone) {
|
|
const tip = await resolveTipId(id)
|
|
const ssbClient = await openSsb()
|
|
const project = await getById(tip)
|
|
if (project.author !== ssbClient.id) throw new Error("Unauthorized")
|
|
const milestones = Array.isArray(project.milestones) ? project.milestones.slice() : []
|
|
const clean = {
|
|
title: String((milestone && milestone.title) || "").trim(),
|
|
description: (milestone && milestone.description) || "",
|
|
targetPercent: clampPercent(milestone && milestone.targetPercent),
|
|
dueDate: milestone && milestone.dueDate ? new Date(milestone.dueDate).toISOString() : null,
|
|
done: false
|
|
}
|
|
if (!clean.title) throw new Error("Milestone title required")
|
|
milestones.push(clean)
|
|
return publishReplace(ssbClient, project.id, { ...project, milestones })
|
|
},
|
|
|
|
async updateMilestone(id, index, patch) {
|
|
const tip = await resolveTipId(id)
|
|
const ssbClient = await openSsb()
|
|
const project = await getById(tip)
|
|
if (project.author !== ssbClient.id) throw new Error("Unauthorized")
|
|
const milestones = Array.isArray(project.milestones) ? project.milestones.slice() : []
|
|
if (!milestones[index]) throw new Error("Milestone not found")
|
|
|
|
if (patch.title !== undefined) milestones[index].title = String(patch.title || "").trim()
|
|
if (patch.description !== undefined) milestones[index].description = patch.description || ""
|
|
if (patch.targetPercent !== undefined) milestones[index].targetPercent = clampPercent(patch.targetPercent)
|
|
if (patch.dueDate !== undefined) milestones[index].dueDate = patch.dueDate ? new Date(patch.dueDate).toISOString() : null
|
|
|
|
let progress = project.progress
|
|
if (patch.done !== undefined) {
|
|
milestones[index].done = !!patch.done
|
|
if (milestones[index].done) {
|
|
const target = clampPercent(milestones[index].targetPercent || 0)
|
|
const pInt = parseInt(project.progress || 0, 10)
|
|
progress = Math.max(Number.isFinite(pInt) ? pInt : 0, target)
|
|
}
|
|
}
|
|
|
|
const updated = { ...project, milestones, ...(progress !== project.progress ? { progress, ...(progress >= 100 ? { status: "COMPLETED" } : {}) } : {}) }
|
|
return publishReplace(ssbClient, project.id, updated)
|
|
},
|
|
|
|
async claimBounty(id, index, uid) {
|
|
const ssbClient = await openSsb()
|
|
const idx = buildProjectIndex(await getAllMsgs(ssbClient))
|
|
const g = resolveProjectGroup(idx, rootOfIdx(idx, id))
|
|
if (!g || g.tombstoned) throw new Error("Project not found")
|
|
const bounties = Array.isArray(g.project.bounties) ? g.project.bounties : []
|
|
if (!bounties[index]) throw new Error("Bounty not found")
|
|
if (bounties[index].claimedBy) throw new Error("Already claimed")
|
|
if (g.project.author === uid) throw new Error("Authors cannot claim")
|
|
const content = { type: "projectClaim", target: g.root, index: parseInt(index, 10), createdAt: new Date().toISOString() }
|
|
return new Promise((res, rej) => ssbClient.publish(content, (e, m) => (e ? rej(e) : res(m))))
|
|
},
|
|
|
|
async completeBounty(id, index, uid) {
|
|
const tip = await resolveTipId(id)
|
|
const ssbClient = await openSsb()
|
|
const project = await getById(tip)
|
|
if (project.author !== uid) throw new Error("Unauthorized")
|
|
const bounties = Array.isArray(project.bounties) ? project.bounties.slice() : []
|
|
if (!bounties[index]) throw new Error("Bounty not found")
|
|
bounties[index].done = true
|
|
|
|
const ac = autoCompleteMilestoneIfReady({ ...project, bounties }, bounties[index].milestoneIndex)
|
|
const patch = { ...project, bounties }
|
|
if (ac && ac.changed) {
|
|
patch.milestones = ac.milestones
|
|
patch.progress = ac.progress
|
|
if (ac.progress >= 100) patch.status = "COMPLETED"
|
|
}
|
|
|
|
return publishReplace(ssbClient, project.id, patch)
|
|
},
|
|
|
|
async completeMilestone(id, index, uid) {
|
|
const tip = await resolveTipId(id)
|
|
const ssbClient = await openSsb()
|
|
const project = await getById(tip)
|
|
if (project.author !== uid) throw new Error("Unauthorized")
|
|
const milestones = Array.isArray(project.milestones) ? project.milestones.slice() : []
|
|
if (!milestones[index]) throw new Error("Milestone not found")
|
|
milestones[index].done = true
|
|
const target = clampPercent(milestones[index].targetPercent || 0)
|
|
const pInt = parseInt(project.progress || 0, 10)
|
|
const progress = Math.max(Number.isFinite(pInt) ? pInt : 0, target)
|
|
const patch = { ...project, milestones, progress }
|
|
if (progress >= 100) patch.status = "COMPLETED"
|
|
return publishReplace(ssbClient, project.id, patch)
|
|
},
|
|
|
|
async listProjects(filter, opts = {}) {
|
|
const ssbClient = await openSsb()
|
|
const currentUserId = ssbClient.id
|
|
const idx = buildProjectIndex(await getAllMsgs(ssbClient))
|
|
|
|
const roots = new Set()
|
|
for (const key of idx.nodes.keys()) roots.add(rootOfIdx(idx, key))
|
|
|
|
const out = []
|
|
for (const root of roots) {
|
|
const g = resolveProjectGroup(idx, root)
|
|
if (!g || g.tombstoned) continue
|
|
const c = g.project
|
|
out.push({
|
|
...c,
|
|
id: g.tip,
|
|
status: String(c.status || "ACTIVE").toUpperCase(),
|
|
createdAt: c.createdAt || new Date(g.best.ts).toISOString(),
|
|
deadline: c.deadline || null
|
|
})
|
|
}
|
|
|
|
let list = dedupeBy(out, p => p.title ? [norm(p.author), norm(p.title), norm(p.createdAt)].join('|') : null)
|
|
const F = String(filter || "ALL").toUpperCase()
|
|
|
|
if (F === "MINE") list = list.filter((p) => p && p.author === currentUserId)
|
|
else if (F === "APPLIED") list = list.filter((p) => p && p.author !== currentUserId && isParticipant(p, currentUserId))
|
|
else if (F === "ACTIVE") list = list.filter((p) => String((p && p.status) || "").toUpperCase() === "ACTIVE")
|
|
else if (F === "COMPLETED") list = list.filter((p) => String((p && p.status) || "").toUpperCase() === "COMPLETED")
|
|
else if (F === "PAUSED") list = list.filter((p) => String((p && p.status) || "").toUpperCase() === "PAUSED")
|
|
else if (F === "CANCELLED") list = list.filter((p) => String((p && p.status) || "").toUpperCase() === "CANCELLED")
|
|
else if (F === "RECENT") list = list.filter((p) => p && moment(p.createdAt).isAfter(moment().subtract(24, "hours")))
|
|
else if (F === "FOLLOWING") list = list.filter((p) => Array.isArray(p.followers) && p.followers.includes(currentUserId))
|
|
|
|
const q = norm(opts.q)
|
|
if (q) {
|
|
list = list.filter((p) =>
|
|
norm(p.title).includes(q) ||
|
|
norm(p.description).includes(q) ||
|
|
(Array.isArray(p.tags) && p.tags.some(t => norm(t).includes(q)))
|
|
)
|
|
}
|
|
|
|
if (F === "TOP") {
|
|
list.sort((a, b) => (parseFloat(b.pledged || 0) / (parseFloat(b.goal || 1))) - (parseFloat(a.pledged || 0) / (parseFloat(a.goal || 1))))
|
|
} else {
|
|
list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
|
}
|
|
|
|
return list
|
|
},
|
|
|
|
async getProjectById(id) {
|
|
return getById(id)
|
|
},
|
|
|
|
async getProjectTipId(id) {
|
|
return resolveTipId(id)
|
|
}
|
|
}
|
|
}
|
|
|