OASIS_MOBILE/src/models/projects_model.js
oasis-bot c332afd6ea upstream: integrar Oasis 1.3.1
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.
2026-10-18 02:52:17 +02:00

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)
}
}
}