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