OASIS_MOBILE/src/models/market_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

622 lines
25 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 categories = require("../backend/opinion_categories")
const { buildValidatedTombstoneSet } = require('./tombstone_validator')
const { readTyped, memoIndex } = require('./typed_log')
const { dedupeByPreferring, norm, versionGroups } = require('../backend/dedupe')
const logLimit = getConfig().ssbLogStream?.limit || 1000
const N = (s) => String(s || "").toUpperCase().replace(/\s+/g, "_")
const D = (s) => ({ FOR_SALE: "FOR SALE", OPEN: "OPEN", RESERVED: "RESERVED", CLOSED: "CLOSED", SOLD: "SOLD", DISCARDED: "DISCARDED" })[s] || (s ? s.replace(/_/g, " ") : s)
const ORDER = ["FOR_SALE", "OPEN", "RESERVED", "CLOSED", "SOLD", "DISCARDED"]
const OI = (s) => ORDER.indexOf(N(s))
const parseBidEntry = (raw) => {
const s = String(raw || "").trim()
if (!s) return null
if (s.includes("|")) {
const parts = s.split("|")
if (parts.length < 3) return null
const bidder = parts[0] || ""
const amount = parseFloat(String(parts[1] || "").replace(",", "."))
const time = parts.slice(2).join("|")
if (!bidder || !Number.isFinite(amount) || !time) return null
return { bidder, amount, time }
}
const first = s.indexOf(":")
const second = s.indexOf(":", first + 1)
if (first === -1 || second === -1) return null
const bidder = s.slice(0, first)
const amountStr = s.slice(first + 1, second)
const time = s.slice(second + 1)
const amount = parseFloat(String(amountStr || "").replace(",", "."))
if (!bidder || !Number.isFinite(amount) || !time) return null
return { bidder, amount, time }
}
const highestBidAmount = (poll) => {
const arr = Array.isArray(poll) ? poll : []
let best = 0
for (const x of arr) {
const b = parseBidEntry(x)
if (b && Number.isFinite(b.amount) && b.amount > best) best = b.amount
}
return best
}
const hasBidder = (poll, userId) => {
const arr = Array.isArray(poll) ? poll : []
for (const x of arr) {
const b = parseBidEntry(x)
if (b && b.bidder === userId) return true
}
return false
}
module.exports = ({ cooler, tribeCrypto }) => {
let ssb
const openSsb = async () => {
if (!ssb) ssb = await cooler.open()
return ssb
}
const MARKET_TYPES = ["market", "marketBid", "marketPurchase", "marketOpinion", "tombstone"]
const readAll = async (ssbClient) => readTyped(ssbClient, MARKET_TYPES, { limit: logLimit })
const buildMarketIndex = (messages) => {
const tomb = buildValidatedTombstoneSet(messages)
const nodes = new Map()
const bids = []
const purchases = []
const opinionMsgs = []
for (const m of messages) {
const c = m.value && m.value.content
if (!c) continue
if (c.type === "tombstone") continue
if (c.type === "marketBid") { bids.push({ target: c.target, author: m.value.author, amount: c.amount, time: c.time, ts: (m.value && m.value.timestamp) || 0 }); continue }
if (c.type === "marketPurchase") { purchases.push({ target: c.target, author: m.value.author, ts: (m.value && m.value.timestamp) || 0 }); continue }
if (c.type === "marketOpinion" && c.target) { opinionMsgs.push({ target: c.target, author: m.value.author, category: c.category }); continue }
if (c.type !== "market") continue
nodes.set(m.key, { key: m.key, ts: (m.value && m.value.timestamp) || m.timestamp || 0, seq: m.value.sequence || 0, c, author: m.value.author })
}
const vg = versionGroups(Array.from(nodes.values()).map(n => ({ key: n.key, author: n.author, seq: n.seq, ts: n.ts, replaces: typeof n.c.replaces === "string" ? n.c.replaces : null, claimedAuthor: typeof n.c.seller === "string" ? n.c.seller : null })))
return { tomb, nodes, bids, purchases, opinionMsgs, vg }
}
const resolveGroups = (idx) => {
const { tomb, nodes, bids, purchases, opinionMsgs, vg } = idx
const rootOf = vg.rootOf
const bidsByRoot = new Map()
for (const bd of bids) { if (!nodes.has(bd.target)) continue; const r = rootOf(bd.target); if (!bidsByRoot.has(r)) bidsByRoot.set(r, []); bidsByRoot.get(r).push(bd) }
const soldByRoot = new Map()
const buyersByRoot = new Map()
for (const pu of purchases) {
if (!nodes.has(pu.target)) continue
const r = rootOf(pu.target)
soldByRoot.set(r, (soldByRoot.get(r) || 0) + 1)
if (!buyersByRoot.has(r)) buyersByRoot.set(r, new Set())
buyersByRoot.get(r).add(pu.author)
}
const opinionsByRoot = new Map()
for (const op of (opinionMsgs || [])) {
if (!nodes.has(op.target)) continue
const r = rootOf(op.target)
if (!opinionsByRoot.has(r)) opinionsByRoot.set(r, [])
opinionsByRoot.get(r).push(op)
}
const out = new Map()
for (const root of vg.roots) {
const keys = vg.groups.get(root) || [root]
const rootNode = nodes.get(root)
const sellerId = rootNode ? rootNode.author : null
const sellerKeys = keys.filter(k => { const n = nodes.get(k); return n && n.author === sellerId })
const tip = vg.tipOf(root)
if (tomb.has(tip)) continue
let best = nodes.get(tip)
if (!best) continue
let bestS = N(best.c.status || "FOR_SALE")
for (const k of (sellerKeys.length ? sellerKeys : keys)) {
const n = nodes.get(k); if (!n) continue
const s = N(n.c.status || "")
if (OI(s) > OI(bestS)) { best = n; bestS = s }
}
const pollSet = new Set(); const poll = []
const addLine = (line) => { const b = parseBidEntry(line); if (!b) return; const kk = `${b.bidder}|${b.amount}|${b.time}`; if (pollSet.has(kk)) return; pollSet.add(kk); poll.push(line) }
for (const k of (sellerKeys.length ? sellerKeys : keys)) { const n = nodes.get(k); if (n && Array.isArray(n.c.auctions_poll)) for (const line of n.c.auctions_poll) addLine(line) }
for (const bd of (bidsByRoot.get(root) || [])) { const amt = Number(bd.amount); addLine(`${bd.author}|${Number.isFinite(amt) ? amt.toFixed(6) : bd.amount}|${bd.time}`) }
const opinions = {}
const voters = []
for (const op of (opinionsByRoot.get(root) || [])) {
if (!op.author || op.author === sellerId) continue
if (voters.includes(op.author)) continue
if (!categories.includes(op.category)) continue
voters.push(op.author)
opinions[op.category] = (opinions[op.category] || 0) + 1
}
out.set(root, { tip, rootId: root, best, statusN: bestS, poll, soldCount: soldByRoot.get(root) || 0, buyers: Array.from(buyersByRoot.get(root) || []), opinions, voters })
}
return out
}
return {
type: "market",
async createItem(item_type, title, description, image, price, tagsRaw = [], item_status, deadline, includesShipping = false, stock = 0, mapUrl = "", shopOpts = {}, visibility = "PUBLIC") {
const ssbClient = await openSsb()
const formattedDeadline = deadline ? moment(deadline, moment.ISO_8601, true) : null
if (!formattedDeadline || !formattedDeadline.isValid()) throw new Error("Invalid deadline")
if (formattedDeadline.isBefore(moment(), "minute")) throw new Error("Cannot create an item in the past")
let blobId = null
if (image) {
blobId = String(image).trim() || null
}
const tags = Array.isArray(tagsRaw) ? tagsRaw.filter(Boolean) : String(tagsRaw).split(",").map((t) => t.trim()).filter(Boolean)
const p = typeof price === "string" ? parseFloat(String(price).replace(",", ".")) : parseFloat(price)
if (!Number.isFinite(p) || p <= 0) throw new Error("Invalid price")
const s = parseInt(String(stock || "0"), 10)
if (!Number.isFinite(s) || s <= 0) throw new Error("Invalid stock")
const itemContent = {
type: "market",
item_type,
title,
description,
image: blobId,
price: p.toFixed(6),
tags,
item_status,
status: "FOR SALE",
deadline: formattedDeadline.toISOString(),
includesShipping: !!includesShipping,
stock: s,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
seller: ssbClient.id,
auctions_poll: [],
mapUrl: String(mapUrl || "").trim(),
shopProductId: shopOpts.shopProductId || "",
shopId: shopOpts.shopId || "",
shopTitle: shopOpts.shopTitle || "",
industry: shopOpts.industry || "",
visibility: String(visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC"
}
return new Promise((resolve, reject) => {
ssbClient.publish(itemContent, (err, res) => {
if (err) return reject(err)
if (res && res.key && tribeCrypto) {
const key = tribeCrypto.generateTribeKey()
tribeCrypto.setKey(res.key, key, 1)
}
resolve(res)
})
})
},
async resolveCurrentId(itemId) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const { tomb, vg } = buildMarketIndex(messages)
const cur = vg.tipOf(itemId)
if (tomb.has(cur) || vg.isForeignCopy(vg.rootOf(cur))) throw new Error("Item not found")
return cur
},
async updateItemById(itemId, updatedData) {
const tipId = await this.resolveCurrentId(itemId)
const ssbClient = await openSsb()
const userId = ssbClient.id
const normalizeTags = (v) => {
if (v === undefined) return undefined
if (Array.isArray(v)) return v.filter(Boolean)
if (typeof v === "string") return v.split(",").map((t) => t.trim()).filter(Boolean)
return []
}
const normalized = { ...(updatedData || {}) }
const tagsCandidate = normalizeTags(updatedData && updatedData.tags)
if (tagsCandidate !== undefined) normalized.tags = tagsCandidate
if (normalized.price !== undefined && normalized.price !== null && normalized.price !== "") {
const p = typeof normalized.price === "string" ? parseFloat(String(normalized.price).replace(",", ".")) : parseFloat(normalized.price)
if (!Number.isFinite(p) || p <= 0) throw new Error("Invalid price")
normalized.price = p.toFixed(6)
}
const currentItem = await new Promise((resolve) => ssbClient.get(tipId, (e, m) => resolve(e ? null : (m && m.content) || null)))
if (normalized.deadline !== undefined && normalized.deadline !== null && normalized.deadline !== "") {
const dl = moment(normalized.deadline, moment.ISO_8601, true)
if (!dl.isValid()) throw new Error("Invalid deadline")
const changed = !currentItem || !currentItem.deadline || !moment(currentItem.deadline).isSame(dl)
if (changed && dl.isBefore(moment(), "minute")) throw new Error("The deadline cannot be in the past")
normalized.deadline = dl.toISOString()
}
if (normalized.stock !== undefined) {
const s = parseInt(String(normalized.stock), 10)
if (!Number.isFinite(s) || s < 0) throw new Error("Invalid stock")
normalized.stock = s
}
if (normalized.includesShipping !== undefined) {
normalized.includesShipping = !!normalized.includesShipping
}
if (normalized.visibility !== undefined) {
normalized.visibility = String(normalized.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC"
}
return new Promise((resolve, reject) => {
ssbClient.get(tipId, (err, item) => {
if (err || !item || !item.content) return reject(new Error("Item not found"))
if (item.content.seller !== userId) return reject(new Error("Not the seller"))
const curStatusNorm = N(item.content.status || "FOR SALE")
const curStatus = D(curStatusNorm)
if (["SOLD", "DISCARDED"].includes(curStatus)) return reject(new Error("Cannot update this item"))
const updated = {
...item.content,
...normalized,
tags: updatedData && updatedData.tags !== undefined ? normalized.tags : item.content.tags,
updatedAt: new Date().toISOString(),
replaces: tipId
}
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
ssbClient.publish(tombstone, (err1) => {
if (err1) return reject(err1)
ssbClient.publish(updated, (err2, res) => (err2 ? reject(err2) : resolve(res)))
})
})
})
},
async deleteItemById(itemId) {
const tipId = await this.resolveCurrentId(itemId)
const ssbClient = await openSsb()
const userId = ssbClient.id
return new Promise((resolve, reject) => {
ssbClient.get(tipId, (err, item) => {
if (err || !item || !item.content) return reject(new Error("Item not found"))
if (item.content.seller !== userId) return reject(new Error("Not the seller"))
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
ssbClient.publish(tombstone, (err2) => (err2 ? reject(err2) : resolve({ message: "Item deleted successfully" })))
})
})
},
async listAllItems(filter = "all") {
const ssbClient = await openSsb()
const userId = ssbClient.id
const messages = await readAll(ssbClient)
const items = []
const now = moment()
for (const { tip, rootId, best, statusN, poll, soldCount, buyers, opinions, voters } of resolveGroups(buildMarketIndex(messages)).values()) {
const leaf = tip
const c = best.c
let status = D(statusN)
const stock = Math.max(0, (Number(c.stock) || 0) - (soldCount || 0))
if (status === "FOR SALE" && soldCount > 0 && stock === 0) status = "SOLD"
if (c.deadline) {
const dl = moment(c.deadline)
if (dl.isValid() && dl.isBefore(now)) {
if (status !== "SOLD" && status !== "DISCARDED") {
if (String(c.item_type || "").toLowerCase() === "auction") {
status = highestBidAmount(poll) > 0 ? "SOLD" : "DISCARDED"
} else {
status = "DISCARDED"
}
}
}
}
if (status === "FOR SALE" && stock === 0) continue
const visibility = String(c.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC"
if (visibility === "HIDDEN" && best.author !== userId) continue
items.push({
id: leaf,
rootId,
title: c.title,
description: c.description,
image: c.image,
price: c.price,
tags: c.tags || [],
item_type: c.item_type,
item_status: c.item_status || "NEW",
status,
visibility,
createdAt: c.createdAt || new Date(best.ts).toISOString(),
updatedAt: c.updatedAt,
seller: best.author,
includesShipping: !!c.includesShipping,
stock,
deadline: c.deadline || null,
auctions_poll: (tribeCrypto && tribeCrypto.getKey(rootId)) ? poll : [],
mapUrl: c.mapUrl || "",
shopProductId: c.shopProductId || "",
shopId: c.shopId || "",
shopTitle: c.shopTitle || "",
industry: c.industry || "",
opinions: opinions || {},
opinions_inhabitants: voters || [],
purchasedByViewer: Array.isArray(buyers) && buyers.includes(userId),
ratedByViewer: Array.isArray(voters) && voters.includes(userId)
})
}
let list = dedupeByPreferring(items, (i) => (i.seller && i.createdAt) ? norm(i.seller) + "|" + norm(i.createdAt) : null, (i) => (Array.isArray(i.auctions_poll) ? i.auctions_poll.length : 0))
switch (filter) {
case "mine":
list = list.filter((i) => i.seller === userId)
break
case "exchange":
list = list.filter((i) => i.item_type === "exchange" && i.status === "FOR SALE")
break
case "auctions":
list = list.filter((i) => i.item_type === "auction" && i.status === "FOR SALE")
break
case "mybids":
list = list.filter((i) => i.item_type === "auction").filter((i) => hasBidder(i.auctions_poll, userId))
break
case "new":
list = list.filter((i) => i.item_status === "NEW" && i.status === "FOR SALE")
break
case "used":
list = list.filter((i) => i.item_status === "USED" && i.status === "FOR SALE")
break
case "broken":
list = list.filter((i) => i.item_status === "BROKEN" && i.status === "FOR SALE")
break
case "for sale":
list = list.filter((i) => i.status === "FOR SALE")
break
case "sold":
list = list.filter((i) => i.status === "SOLD")
break
case "discarded":
list = list.filter((i) => i.status === "DISCARDED")
break
case "recent": {
const oneDayAgo = moment().subtract(1, "days")
list = list.filter((i) => i.status === "FOR SALE" && moment(i.createdAt).isAfter(oneDayAgo))
break
}
}
return list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
},
async getItemById(itemId) {
const ssbClient = await openSsb()
const userId = ssbClient.id
const messages = await readAll(ssbClient)
const idx = memoIndex('market', messages, buildMarketIndex)
const grp = resolveGroups(idx).get(idx.vg.rootOf(itemId))
if (!grp) return null
const { tip, rootId, best, statusN, poll, soldCount, buyers, opinions, voters } = grp
const c = best.c
let status = D(statusN)
const stock = Math.max(0, (Number(c.stock) || 0) - (soldCount || 0))
if (status === "FOR SALE" && soldCount > 0 && stock === 0) status = "SOLD"
const visibility = String(c.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC"
if (visibility === "HIDDEN" && best.author !== userId) return null
const now = moment()
if (c.deadline) {
const dl = moment(c.deadline)
if (dl.isValid() && dl.isBefore(now)) {
if (status !== "SOLD" && status !== "DISCARDED") {
if (String(c.item_type || "").toLowerCase() === "auction") {
status = highestBidAmount(poll) > 0 ? "SOLD" : "DISCARDED"
} else {
status = "DISCARDED"
}
}
}
}
return {
id: tip,
rootId,
title: c.title,
description: c.description,
image: c.image,
price: c.price,
tags: c.tags || [],
item_type: c.item_type,
item_status: c.item_status,
status,
visibility,
createdAt: c.createdAt || new Date(best.ts).toISOString(),
updatedAt: c.updatedAt,
seller: best.author,
includesShipping: !!c.includesShipping,
stock,
deadline: c.deadline,
auctions_poll: (tribeCrypto && tribeCrypto.getKey(rootId)) ? poll : [],
mapUrl: c.mapUrl || "",
shopProductId: c.shopProductId || "",
shopId: c.shopId || "",
shopTitle: c.shopTitle || "",
industry: c.industry || "",
opinions: opinions || {},
opinions_inhabitants: voters || [],
purchasedByViewer: Array.isArray(buyers) && buyers.includes(userId),
ratedByViewer: Array.isArray(voters) && voters.includes(userId)
}
},
async checkAuctionItemsStatus(items) {
const ssbClient = await openSsb()
const myId = ssbClient.id
const now = moment()
const list = Array.isArray(items) ? items : []
let changed = 0
for (const item of list) {
if (!item || !item.deadline) continue
if (item.seller !== myId) continue
const dl = moment(item.deadline)
if (!dl.isValid()) continue
if (!dl.isBefore(now)) continue
const curStatus = D(N(item.status))
if (curStatus === "SOLD" || curStatus === "DISCARDED") continue
let status = curStatus
const kind = String(item.item_type || "").toLowerCase()
if (kind === "auction") {
status = highestBidAmount(item.auctions_poll) > 0 ? "SOLD" : "DISCARDED"
} else {
status = "DISCARDED"
}
try {
await this.updateItemById(item.id, { status })
changed++
} catch (_) {}
}
return changed
},
async getItemByShopProductId(shopProductId) {
if (!shopProductId) return null
const items = await this.listAllItems("all")
return items.find((i) => i.shopProductId === shopProductId) || null
},
async createOpinion(itemId, category) {
if (!categories.includes(category)) throw new Error("Invalid category")
const ssbClient = await openSsb()
const userId = ssbClient.id
const messages = await readAll(ssbClient)
const idx = buildMarketIndex(messages)
const group = resolveGroups(idx).get(idx.vg.rootOf(itemId))
if (!group) throw new Error("Item not found")
if (group.best.author === userId) throw new Error("You cannot rate your own item")
if ((group.voters || []).includes(userId)) throw new Error("Already voted")
if (!(group.buyers || []).includes(userId)) throw new Error("You can rate only an item you have bought")
const content = { type: "marketOpinion", target: group.rootId, category, createdAt: new Date().toISOString() }
return new Promise((res, rej) => ssbClient.publish(content, (e, m) => e ? rej(e) : res(m)))
},
async setItemAsSold(itemId) {
const tipId = await this.resolveCurrentId(itemId)
const ssbClient = await openSsb()
const userId = ssbClient.id
return new Promise((resolve, reject) => {
ssbClient.get(tipId, (err, item) => {
if (err || !item || !item.content) return reject(new Error("Item not found"))
if (item.content.seller !== userId) return reject(new Error("Not the seller"))
const curStatus = String(item.content.status).toUpperCase().replace(/\s+/g, "_")
if (["SOLD", "DISCARDED"].includes(curStatus)) return reject(new Error("Already sold/discarded"))
const soldMsg = {
...item.content,
stock: 0,
status: "SOLD",
updatedAt: new Date().toISOString(),
replaces: tipId
}
const tomb1 = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
ssbClient.publish(tomb1, (err1) => {
if (err1) return reject(err1)
ssbClient.publish(soldMsg, (err2, soldRes) => {
if (err2) return reject(err2)
const touchMsg = {
...soldMsg,
updatedAt: new Date().toISOString(),
replaces: soldRes.key
}
const tomb2 = { type: "tombstone", target: soldRes.key, deletedAt: new Date().toISOString(), author: userId }
ssbClient.publish(tomb2, (err3) => {
if (err3) return reject(err3)
ssbClient.publish(touchMsg, (err4, finalRes) => (err4 ? reject(err4) : resolve(finalRes)))
})
})
})
})
})
},
async addBidToAuction(itemId, userId, bidAmount) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const idx = buildMarketIndex(messages)
const grp = resolveGroups(idx).get(idx.vg.rootOf(itemId))
if (!grp) throw new Error("Item not found")
const c = grp.best.c
if (String(c.item_type || "").toLowerCase() !== "auction") throw new Error("Not an auction")
if (grp.best.author === userId) throw new Error("Cannot bid on your own item")
if (D(N(c.status || "FOR_SALE")) !== "FOR SALE") throw new Error("Auction is not active")
const dl = c.deadline ? moment(c.deadline) : null
if (!dl || !dl.isValid()) throw new Error("Invalid deadline")
if (dl.isBefore(moment())) throw new Error("Auction closed")
if ((Number(c.stock) || 0) <= 0) throw new Error("Out of stock")
const basePrice = parseFloat(String(c.price || "0").replace(",", "."))
const bid = parseFloat(String(bidAmount || "").replace(",", "."))
if (!Number.isFinite(bid) || bid <= 0) throw new Error("Invalid bid")
const highest = highestBidAmount(grp.poll)
const min = Number.isFinite(highest) && highest > 0 ? highest : Number.isFinite(basePrice) ? basePrice : 0
if (bid <= min) throw new Error("Bid not highest")
const content = { type: "marketBid", target: grp.rootId, amount: bid.toFixed(6), time: new Date().toISOString(), createdAt: new Date().toISOString() }
return new Promise((resolve, reject) =>
ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)))
)
},
async decrementStock(itemId) {
const ssbClient = await openSsb()
const item = await this.getItemById(itemId)
if (!item) throw new Error("Item not found")
const curStatus = String(item.status).toUpperCase().replace(/\s+/g, "_")
if (["SOLD", "DISCARDED"].includes(curStatus)) return { ok: true, noop: true }
if ((Number(item.stock) || 0) <= 0) return { ok: true, noop: true }
const content = { type: "marketPurchase", target: item.rootId, createdAt: new Date().toISOString() }
return new Promise((resolve, reject) =>
ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)))
)
}
}
}