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.
622 lines
25 KiB
JavaScript
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)))
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|