| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501 |
- const pull = require("../server/node_modules/pull-stream")
- const { getConfig } = require("../configs/config-manager.js")
- const categories = require("../backend/opinion_categories")
- const logLimit = getConfig().ssbLogStream?.limit || 1000
- const safeArr = (v) => (Array.isArray(v) ? v : [])
- const safeText = (v) => String(v || "").trim()
- const normalizeTags = (raw) => {
- if (raw === undefined || raw === null) return []
- if (Array.isArray(raw)) return raw.map(t => String(t || "").trim()).filter(Boolean)
- return String(raw).split(",").map(t => t.trim()).filter(Boolean)
- }
- const voteSum = (opinions = {}) => Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0)
- module.exports = ({ cooler, tribeCrypto }) => {
- let ssb
- const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
- const readAll = async (ssbClient) =>
- new Promise((resolve, reject) =>
- pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)))
- )
- const buildIndex = (messages) => {
- const tomb = new Set()
- const nodes = new Map()
- const parent = new Map()
- const child = new Map()
- for (const m of messages) {
- const k = m.key
- const v = m.value || {}
- const c = v.content
- if (!c) continue
- if (c.type === "tombstone" && c.target) { tomb.add(c.target); continue }
- if (c.type === "shop" || c.type === "shopProduct") {
- nodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
- if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k) }
- }
- }
- const rootOf = (id) => { let cur = id; while (parent.has(cur)) cur = parent.get(cur); return cur }
- const tipOf = (id) => { let cur = id; while (child.has(cur)) cur = child.get(cur); return cur }
- const roots = new Set()
- for (const id of nodes.keys()) roots.add(rootOf(id))
- const tipByRoot = new Map()
- for (const r of roots) tipByRoot.set(r, tipOf(r))
- return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot }
- }
- const buildShop = (node, rootId) => {
- const c = node.c || {}
- if (c.type !== "shop") return null
- return {
- key: node.key,
- rootId,
- title: c.title || "",
- shortDescription: c.shortDescription || "",
- description: c.description || "",
- image: c.image || null,
- url: c.url || "",
- location: c.location || "",
- tags: safeArr(c.tags),
- visibility: c.visibility || "OPEN",
- author: c.author || node.author,
- createdAt: c.createdAt || new Date(node.ts).toISOString(),
- updatedAt: c.updatedAt || null,
- opinions: c.opinions || {},
- opinions_inhabitants: safeArr(c.opinions_inhabitants),
- mapUrl: c.mapUrl || ""
- }
- }
- const buildProduct = (node, rootId) => {
- const c = node.c || {}
- if (c.type !== "shopProduct") return null
- return {
- key: node.key,
- rootId,
- shopId: c.shopId || "",
- title: c.title || "",
- description: c.description || "",
- image: c.image || null,
- price: c.price || "0.000000",
- stock: Number(c.stock) || 0,
- featured: !!c.featured,
- author: c.author || node.author,
- createdAt: c.createdAt || new Date(node.ts).toISOString(),
- updatedAt: c.updatedAt || null,
- opinions: c.opinions || {},
- opinions_inhabitants: safeArr(c.opinions_inhabitants),
- buyers: (tribeCrypto && tribeCrypto.getKey(rootId)) || ssb?.id === (c.author || node.author) ? safeArr(c.buyers) : []
- }
- }
- const countProductsFromIndex = (idx, shopRootId) => {
- let count = 0
- for (const tipId of idx.tipByRoot.values()) {
- if (idx.tomb.has(tipId)) continue
- const node = idx.nodes.get(tipId)
- if (!node || node.c.type !== "shopProduct") continue
- if (node.c.shopId === shopRootId) count++
- }
- return count
- }
- return {
- type: "shop",
- async resolveRootId(id) {
- const ssbClient = await openSsb()
- const messages = await readAll(ssbClient)
- const idx = buildIndex(messages)
- let tip = id
- while (idx.child.has(tip)) tip = idx.child.get(tip)
- if (idx.tomb.has(tip)) throw new Error("Not found")
- let root = tip
- while (idx.parent.has(root)) root = idx.parent.get(root)
- return root
- },
- async resolveCurrentId(id) {
- const ssbClient = await openSsb()
- const messages = await readAll(ssbClient)
- const idx = buildIndex(messages)
- let tip = id
- while (idx.child.has(tip)) tip = idx.child.get(tip)
- if (idx.tomb.has(tip)) throw new Error("Not found")
- return tip
- },
- async createShop(title, shortDescription, description, image, url, location, tagsRaw, visibility, mapUrl) {
- const ssbClient = await openSsb()
- const blobId = image ? String(image).trim() || null : null
- const tags = normalizeTags(tagsRaw)
- const vis = String(visibility || "OPEN").toUpperCase() === "CLOSED" ? "CLOSED" : "OPEN"
- const now = new Date().toISOString()
- const content = {
- type: "shop",
- title: safeText(title),
- shortDescription: safeText(shortDescription),
- description: safeText(description),
- image: blobId,
- url: safeText(url),
- location: safeText(location),
- tags,
- visibility: vis,
- mapUrl: safeText(mapUrl),
- author: ssbClient.id,
- createdAt: now,
- updatedAt: now,
- opinions: {},
- opinions_inhabitants: []
- }
- return new Promise((resolve, reject) => {
- ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
- })
- },
- async updateShopById(id, data) {
- const tipId = await this.resolveCurrentId(id)
- const ssbClient = await openSsb()
- const userId = ssbClient.id
- return new Promise((resolve, reject) => {
- ssbClient.get(tipId, (err, item) => {
- if (err || !item?.content) return reject(new Error("Shop not found"))
- if (item.content.author !== userId) return reject(new Error("Not the author"))
- const c = item.content
- const updated = {
- ...c,
- title: data.title !== undefined ? safeText(data.title) : c.title,
- shortDescription: data.shortDescription !== undefined ? safeText(data.shortDescription) : c.shortDescription,
- description: data.description !== undefined ? safeText(data.description) : c.description,
- image: data.image !== undefined ? (data.image ? String(data.image).trim() || null : c.image) : c.image,
- url: data.url !== undefined ? safeText(data.url) : c.url,
- location: data.location !== undefined ? safeText(data.location) : c.location,
- tags: data.tags !== undefined ? normalizeTags(data.tags) : c.tags,
- visibility: data.visibility !== undefined ? (String(data.visibility).toUpperCase() === "CLOSED" ? "CLOSED" : "OPEN") : c.visibility,
- updatedAt: new Date().toISOString(),
- replaces: tipId
- }
- const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
- ssbClient.publish(tombstone, (e1) => {
- if (e1) return reject(e1)
- ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res))
- })
- })
- })
- },
- async deleteShopById(id) {
- const tipId = await this.resolveCurrentId(id)
- const ssbClient = await openSsb()
- const userId = ssbClient.id
- return new Promise((resolve, reject) => {
- ssbClient.get(tipId, (err, item) => {
- if (err || !item?.content) return reject(new Error("Shop not found"))
- if (item.content.author !== userId) return reject(new Error("Not the author"))
- const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
- ssbClient.publish(tombstone, (e) => e ? reject(e) : resolve())
- })
- })
- },
- async getShopById(id) {
- const ssbClient = await openSsb()
- const messages = await readAll(ssbClient)
- const idx = buildIndex(messages)
- let tip = id
- while (idx.child.has(tip)) tip = idx.child.get(tip)
- if (idx.tomb.has(tip)) return null
- const node = idx.nodes.get(tip)
- if (!node || node.c.type !== "shop") return null
- let root = tip
- while (idx.parent.has(root)) root = idx.parent.get(root)
- const shop = buildShop(node, root)
- if (!shop) return null
- shop.productCount = countProductsFromIndex(idx, root)
- return shop
- },
- async listAll({ filter = "all", q = "", sort = "recent", viewerId } = {}) {
- const ssbClient = await openSsb()
- const uid = viewerId || ssbClient.id
- const messages = await readAll(ssbClient)
- const idx = buildIndex(messages)
- const items = []
- for (const [rootId, tipId] of idx.tipByRoot.entries()) {
- if (idx.tomb.has(tipId)) continue
- const node = idx.nodes.get(tipId)
- if (!node || node.c.type !== "shop") continue
- const shop = buildShop(node, rootId)
- if (!shop) continue
- if (shop.visibility === "CLOSED" && shop.author !== uid) continue
- shop.productCount = countProductsFromIndex(idx, rootId)
- items.push(shop)
- }
- let list = items
- const now = Date.now()
- if (filter === "mine") list = list.filter(s => s.author === uid)
- else if (filter === "recent") list = list.filter(s => new Date(s.createdAt).getTime() >= now - 86400000)
- else if (filter === "top") list = list.slice().sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt))
- if (q) {
- const qq = q.toLowerCase()
- list = list.filter(s => {
- const t = String(s.title || "").toLowerCase()
- const d = String(s.description || "").toLowerCase()
- const loc = String(s.location || "").toLowerCase()
- const tags = safeArr(s.tags).join(" ").toLowerCase()
- return t.includes(qq) || d.includes(qq) || loc.includes(qq) || tags.includes(qq)
- })
- }
- if (filter !== "top") {
- if (sort === "top") list = list.slice().sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt))
- else list = list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
- }
- return list
- },
- async createProduct(shopId, title, description, image, price, stock, featured) {
- const ssbClient = await openSsb()
- const blobId = image ? String(image).trim() || null : null
- const p = parseFloat(String(price || "").replace(",", "."))
- if (!Number.isFinite(p) || p <= 0) throw new Error("Invalid price")
- const s = parseInt(String(stock || "1"), 10)
- if (!Number.isFinite(s) || s < 0) throw new Error("Invalid stock")
- const now = new Date().toISOString()
- const content = {
- type: "shopProduct",
- shopId,
- title: safeText(title),
- description: safeText(description),
- image: blobId,
- price: p.toFixed(6),
- stock: s,
- featured: !!featured,
- author: ssbClient.id,
- createdAt: now,
- updatedAt: now,
- opinions: {},
- opinions_inhabitants: []
- }
- return new Promise((resolve, reject) => {
- ssbClient.publish(content, (err, msg) => {
- if (err) return reject(err)
- if (msg && msg.key && tribeCrypto) {
- const key = tribeCrypto.generateTribeKey()
- tribeCrypto.setKey(msg.key, key, 1)
- }
- resolve(msg)
- })
- })
- },
- async updateProductById(id, data) {
- const tipId = await this.resolveCurrentId(id)
- const ssbClient = await openSsb()
- const userId = ssbClient.id
- return new Promise((resolve, reject) => {
- ssbClient.get(tipId, (err, item) => {
- if (err || !item?.content) return reject(new Error("Product not found"))
- if (item.content.author !== userId) return reject(new Error("Not the author"))
- const c = item.content
- const pRaw = data.price !== undefined ? parseFloat(String(data.price || "").replace(",", ".")) : null
- const sRaw = data.stock !== undefined ? parseInt(String(data.stock || "0"), 10) : null
- const updated = {
- ...c,
- title: data.title !== undefined ? safeText(data.title) : c.title,
- description: data.description !== undefined ? safeText(data.description) : c.description,
- image: data.image !== undefined ? (data.image ? String(data.image).trim() || null : c.image) : c.image,
- price: pRaw !== null && Number.isFinite(pRaw) && pRaw > 0 ? pRaw.toFixed(6) : c.price,
- stock: sRaw !== null && Number.isFinite(sRaw) && sRaw >= 0 ? sRaw : c.stock,
- featured: data.featured !== undefined ? !!data.featured : !!c.featured,
- updatedAt: new Date().toISOString(),
- replaces: tipId
- }
- const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
- ssbClient.publish(tombstone, (e1) => {
- if (e1) return reject(e1)
- ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res))
- })
- })
- })
- },
- async deleteProductById(id) {
- const tipId = await this.resolveCurrentId(id)
- const ssbClient = await openSsb()
- const userId = ssbClient.id
- return new Promise((resolve, reject) => {
- ssbClient.get(tipId, (err, item) => {
- if (err || !item?.content) return reject(new Error("Product not found"))
- if (item.content.author !== userId) return reject(new Error("Not the author"))
- const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
- ssbClient.publish(tombstone, (e) => e ? reject(e) : resolve())
- })
- })
- },
- async getProductById(id) {
- const ssbClient = await openSsb()
- const messages = await readAll(ssbClient)
- const idx = buildIndex(messages)
- let tip = id
- while (idx.child.has(tip)) tip = idx.child.get(tip)
- if (idx.tomb.has(tip)) return null
- const node = idx.nodes.get(tip)
- if (!node || node.c.type !== "shopProduct") return null
- let root = tip
- while (idx.parent.has(root)) root = idx.parent.get(root)
- return buildProduct(node, root)
- },
- async listProducts(shopRootId) {
- const ssbClient = await openSsb()
- const messages = await readAll(ssbClient)
- const idx = buildIndex(messages)
- const items = []
- for (const [rootId, tipId] of idx.tipByRoot.entries()) {
- if (idx.tomb.has(tipId)) continue
- const node = idx.nodes.get(tipId)
- if (!node || node.c.type !== "shopProduct") continue
- if (node.c.shopId !== shopRootId) continue
- const prod = buildProduct(node, rootId)
- if (prod) items.push(prod)
- }
- return items.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
- },
- async listFeaturedProducts(shopRootId) {
- const ssbClient = await openSsb()
- const messages = await readAll(ssbClient)
- const idx = buildIndex(messages)
- const items = []
- for (const [rootId, tipId] of idx.tipByRoot.entries()) {
- if (idx.tomb.has(tipId)) continue
- const node = idx.nodes.get(tipId)
- if (!node || node.c.type !== "shopProduct") continue
- if (node.c.shopId !== shopRootId) continue
- if (!node.c.featured) continue
- const prod = buildProduct(node, rootId)
- if (prod) items.push(prod)
- }
- return items.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)).slice(0, 4)
- },
- async listAllProducts({ filter = "all", sort = "recent" } = {}) {
- const ssbClient = await openSsb()
- const messages = await readAll(ssbClient)
- const idx = buildIndex(messages)
- const items = []
- for (const [rootId, tipId] of idx.tipByRoot.entries()) {
- if (idx.tomb.has(tipId)) continue
- const node = idx.nodes.get(tipId)
- if (!node || node.c.type !== "shopProduct") continue
- const prod = buildProduct(node, rootId)
- if (prod) items.push(prod)
- }
- if (filter === "top") return items.slice().sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt))
- return items.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
- },
- async buyProduct(productId) {
- const tipId = await this.resolveCurrentId(productId)
- const ssbClient = await openSsb()
- const userId = ssbClient.id
- return new Promise((resolve, reject) => {
- ssbClient.get(tipId, (err, item) => {
- if (err || !item?.content) return reject(new Error("Product not found"))
- const c = item.content
- if (c.author === userId) return reject(new Error("Cannot buy your own product"))
- const stock = Number(c.stock) || 0
- if (stock <= 0) return reject(new Error("Out of stock"))
- const updated = {
- ...c,
- stock: stock - 1,
- buyers: safeArr(c.buyers).concat(userId),
- updatedAt: new Date().toISOString(),
- replaces: tipId
- }
- const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
- ssbClient.publish(tombstone, (e1) => {
- if (e1) return reject(e1)
- ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res))
- })
- })
- })
- },
- async createOpinion(id, category) {
- if (!categories.includes(category)) throw new Error("Invalid category")
- const ssbClient = await openSsb()
- const userId = ssbClient.id
- const tipId = await this.resolveCurrentId(id)
- return new Promise((resolve, reject) => {
- ssbClient.get(tipId, (err, item) => {
- if (err || !item?.content) return reject(new Error("Not found"))
- const c = item.content
- const buyers = safeArr(c.buyers)
- if (!buyers.includes(userId)) return reject(new Error("Must purchase before rating"))
- const voters = safeArr(c.opinions_inhabitants)
- if (voters.includes(userId)) return reject(new Error("Already voted"))
- const updated = {
- ...c,
- opinions: { ...(c.opinions || {}), [category]: ((c.opinions || {})[category] || 0) + 1 },
- opinions_inhabitants: voters.concat(userId),
- updatedAt: new Date().toISOString(),
- replaces: tipId
- }
- const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
- ssbClient.publish(tombstone, (e1) => {
- if (e1) return reject(e1)
- ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res))
- })
- })
- })
- }
- }
- }
|