shops_model.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501
  1. const pull = require("../server/node_modules/pull-stream")
  2. const { getConfig } = require("../configs/config-manager.js")
  3. const categories = require("../backend/opinion_categories")
  4. const logLimit = getConfig().ssbLogStream?.limit || 1000
  5. const safeArr = (v) => (Array.isArray(v) ? v : [])
  6. const safeText = (v) => String(v || "").trim()
  7. const normalizeTags = (raw) => {
  8. if (raw === undefined || raw === null) return []
  9. if (Array.isArray(raw)) return raw.map(t => String(t || "").trim()).filter(Boolean)
  10. return String(raw).split(",").map(t => t.trim()).filter(Boolean)
  11. }
  12. const voteSum = (opinions = {}) => Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0)
  13. module.exports = ({ cooler, tribeCrypto }) => {
  14. let ssb
  15. const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
  16. const readAll = async (ssbClient) =>
  17. new Promise((resolve, reject) =>
  18. pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)))
  19. )
  20. const buildIndex = (messages) => {
  21. const tomb = new Set()
  22. const nodes = new Map()
  23. const parent = new Map()
  24. const child = new Map()
  25. for (const m of messages) {
  26. const k = m.key
  27. const v = m.value || {}
  28. const c = v.content
  29. if (!c) continue
  30. if (c.type === "tombstone" && c.target) { tomb.add(c.target); continue }
  31. if (c.type === "shop" || c.type === "shopProduct") {
  32. nodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
  33. if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k) }
  34. }
  35. }
  36. const rootOf = (id) => { let cur = id; while (parent.has(cur)) cur = parent.get(cur); return cur }
  37. const tipOf = (id) => { let cur = id; while (child.has(cur)) cur = child.get(cur); return cur }
  38. const roots = new Set()
  39. for (const id of nodes.keys()) roots.add(rootOf(id))
  40. const tipByRoot = new Map()
  41. for (const r of roots) tipByRoot.set(r, tipOf(r))
  42. return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot }
  43. }
  44. const buildShop = (node, rootId) => {
  45. const c = node.c || {}
  46. if (c.type !== "shop") return null
  47. return {
  48. key: node.key,
  49. rootId,
  50. title: c.title || "",
  51. shortDescription: c.shortDescription || "",
  52. description: c.description || "",
  53. image: c.image || null,
  54. url: c.url || "",
  55. location: c.location || "",
  56. tags: safeArr(c.tags),
  57. visibility: c.visibility || "OPEN",
  58. author: c.author || node.author,
  59. createdAt: c.createdAt || new Date(node.ts).toISOString(),
  60. updatedAt: c.updatedAt || null,
  61. opinions: c.opinions || {},
  62. opinions_inhabitants: safeArr(c.opinions_inhabitants),
  63. mapUrl: c.mapUrl || ""
  64. }
  65. }
  66. const buildProduct = (node, rootId) => {
  67. const c = node.c || {}
  68. if (c.type !== "shopProduct") return null
  69. return {
  70. key: node.key,
  71. rootId,
  72. shopId: c.shopId || "",
  73. title: c.title || "",
  74. description: c.description || "",
  75. image: c.image || null,
  76. price: c.price || "0.000000",
  77. stock: Number(c.stock) || 0,
  78. featured: !!c.featured,
  79. author: c.author || node.author,
  80. createdAt: c.createdAt || new Date(node.ts).toISOString(),
  81. updatedAt: c.updatedAt || null,
  82. opinions: c.opinions || {},
  83. opinions_inhabitants: safeArr(c.opinions_inhabitants),
  84. buyers: (tribeCrypto && tribeCrypto.getKey(rootId)) || ssb?.id === (c.author || node.author) ? safeArr(c.buyers) : []
  85. }
  86. }
  87. const countProductsFromIndex = (idx, shopRootId) => {
  88. let count = 0
  89. for (const tipId of idx.tipByRoot.values()) {
  90. if (idx.tomb.has(tipId)) continue
  91. const node = idx.nodes.get(tipId)
  92. if (!node || node.c.type !== "shopProduct") continue
  93. if (node.c.shopId === shopRootId) count++
  94. }
  95. return count
  96. }
  97. return {
  98. type: "shop",
  99. async resolveRootId(id) {
  100. const ssbClient = await openSsb()
  101. const messages = await readAll(ssbClient)
  102. const idx = buildIndex(messages)
  103. let tip = id
  104. while (idx.child.has(tip)) tip = idx.child.get(tip)
  105. if (idx.tomb.has(tip)) throw new Error("Not found")
  106. let root = tip
  107. while (idx.parent.has(root)) root = idx.parent.get(root)
  108. return root
  109. },
  110. async resolveCurrentId(id) {
  111. const ssbClient = await openSsb()
  112. const messages = await readAll(ssbClient)
  113. const idx = buildIndex(messages)
  114. let tip = id
  115. while (idx.child.has(tip)) tip = idx.child.get(tip)
  116. if (idx.tomb.has(tip)) throw new Error("Not found")
  117. return tip
  118. },
  119. async createShop(title, shortDescription, description, image, url, location, tagsRaw, visibility, mapUrl) {
  120. const ssbClient = await openSsb()
  121. const blobId = image ? String(image).trim() || null : null
  122. const tags = normalizeTags(tagsRaw)
  123. const vis = String(visibility || "OPEN").toUpperCase() === "CLOSED" ? "CLOSED" : "OPEN"
  124. const now = new Date().toISOString()
  125. const content = {
  126. type: "shop",
  127. title: safeText(title),
  128. shortDescription: safeText(shortDescription),
  129. description: safeText(description),
  130. image: blobId,
  131. url: safeText(url),
  132. location: safeText(location),
  133. tags,
  134. visibility: vis,
  135. mapUrl: safeText(mapUrl),
  136. author: ssbClient.id,
  137. createdAt: now,
  138. updatedAt: now,
  139. opinions: {},
  140. opinions_inhabitants: []
  141. }
  142. return new Promise((resolve, reject) => {
  143. ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
  144. })
  145. },
  146. async updateShopById(id, data) {
  147. const tipId = await this.resolveCurrentId(id)
  148. const ssbClient = await openSsb()
  149. const userId = ssbClient.id
  150. return new Promise((resolve, reject) => {
  151. ssbClient.get(tipId, (err, item) => {
  152. if (err || !item?.content) return reject(new Error("Shop not found"))
  153. if (item.content.author !== userId) return reject(new Error("Not the author"))
  154. const c = item.content
  155. const updated = {
  156. ...c,
  157. title: data.title !== undefined ? safeText(data.title) : c.title,
  158. shortDescription: data.shortDescription !== undefined ? safeText(data.shortDescription) : c.shortDescription,
  159. description: data.description !== undefined ? safeText(data.description) : c.description,
  160. image: data.image !== undefined ? (data.image ? String(data.image).trim() || null : c.image) : c.image,
  161. url: data.url !== undefined ? safeText(data.url) : c.url,
  162. location: data.location !== undefined ? safeText(data.location) : c.location,
  163. tags: data.tags !== undefined ? normalizeTags(data.tags) : c.tags,
  164. visibility: data.visibility !== undefined ? (String(data.visibility).toUpperCase() === "CLOSED" ? "CLOSED" : "OPEN") : c.visibility,
  165. updatedAt: new Date().toISOString(),
  166. replaces: tipId
  167. }
  168. const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
  169. ssbClient.publish(tombstone, (e1) => {
  170. if (e1) return reject(e1)
  171. ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res))
  172. })
  173. })
  174. })
  175. },
  176. async deleteShopById(id) {
  177. const tipId = await this.resolveCurrentId(id)
  178. const ssbClient = await openSsb()
  179. const userId = ssbClient.id
  180. return new Promise((resolve, reject) => {
  181. ssbClient.get(tipId, (err, item) => {
  182. if (err || !item?.content) return reject(new Error("Shop not found"))
  183. if (item.content.author !== userId) return reject(new Error("Not the author"))
  184. const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
  185. ssbClient.publish(tombstone, (e) => e ? reject(e) : resolve())
  186. })
  187. })
  188. },
  189. async getShopById(id) {
  190. const ssbClient = await openSsb()
  191. const messages = await readAll(ssbClient)
  192. const idx = buildIndex(messages)
  193. let tip = id
  194. while (idx.child.has(tip)) tip = idx.child.get(tip)
  195. if (idx.tomb.has(tip)) return null
  196. const node = idx.nodes.get(tip)
  197. if (!node || node.c.type !== "shop") return null
  198. let root = tip
  199. while (idx.parent.has(root)) root = idx.parent.get(root)
  200. const shop = buildShop(node, root)
  201. if (!shop) return null
  202. shop.productCount = countProductsFromIndex(idx, root)
  203. return shop
  204. },
  205. async listAll({ filter = "all", q = "", sort = "recent", viewerId } = {}) {
  206. const ssbClient = await openSsb()
  207. const uid = viewerId || ssbClient.id
  208. const messages = await readAll(ssbClient)
  209. const idx = buildIndex(messages)
  210. const items = []
  211. for (const [rootId, tipId] of idx.tipByRoot.entries()) {
  212. if (idx.tomb.has(tipId)) continue
  213. const node = idx.nodes.get(tipId)
  214. if (!node || node.c.type !== "shop") continue
  215. const shop = buildShop(node, rootId)
  216. if (!shop) continue
  217. if (shop.visibility === "CLOSED" && shop.author !== uid) continue
  218. shop.productCount = countProductsFromIndex(idx, rootId)
  219. items.push(shop)
  220. }
  221. let list = items
  222. const now = Date.now()
  223. if (filter === "mine") list = list.filter(s => s.author === uid)
  224. else if (filter === "recent") list = list.filter(s => new Date(s.createdAt).getTime() >= now - 86400000)
  225. else if (filter === "top") list = list.slice().sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt))
  226. if (q) {
  227. const qq = q.toLowerCase()
  228. list = list.filter(s => {
  229. const t = String(s.title || "").toLowerCase()
  230. const d = String(s.description || "").toLowerCase()
  231. const loc = String(s.location || "").toLowerCase()
  232. const tags = safeArr(s.tags).join(" ").toLowerCase()
  233. return t.includes(qq) || d.includes(qq) || loc.includes(qq) || tags.includes(qq)
  234. })
  235. }
  236. if (filter !== "top") {
  237. if (sort === "top") list = list.slice().sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt))
  238. else list = list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
  239. }
  240. return list
  241. },
  242. async createProduct(shopId, title, description, image, price, stock, featured) {
  243. const ssbClient = await openSsb()
  244. const blobId = image ? String(image).trim() || null : null
  245. const p = parseFloat(String(price || "").replace(",", "."))
  246. if (!Number.isFinite(p) || p <= 0) throw new Error("Invalid price")
  247. const s = parseInt(String(stock || "1"), 10)
  248. if (!Number.isFinite(s) || s < 0) throw new Error("Invalid stock")
  249. const now = new Date().toISOString()
  250. const content = {
  251. type: "shopProduct",
  252. shopId,
  253. title: safeText(title),
  254. description: safeText(description),
  255. image: blobId,
  256. price: p.toFixed(6),
  257. stock: s,
  258. featured: !!featured,
  259. author: ssbClient.id,
  260. createdAt: now,
  261. updatedAt: now,
  262. opinions: {},
  263. opinions_inhabitants: []
  264. }
  265. return new Promise((resolve, reject) => {
  266. ssbClient.publish(content, (err, msg) => {
  267. if (err) return reject(err)
  268. if (msg && msg.key && tribeCrypto) {
  269. const key = tribeCrypto.generateTribeKey()
  270. tribeCrypto.setKey(msg.key, key, 1)
  271. }
  272. resolve(msg)
  273. })
  274. })
  275. },
  276. async updateProductById(id, data) {
  277. const tipId = await this.resolveCurrentId(id)
  278. const ssbClient = await openSsb()
  279. const userId = ssbClient.id
  280. return new Promise((resolve, reject) => {
  281. ssbClient.get(tipId, (err, item) => {
  282. if (err || !item?.content) return reject(new Error("Product not found"))
  283. if (item.content.author !== userId) return reject(new Error("Not the author"))
  284. const c = item.content
  285. const pRaw = data.price !== undefined ? parseFloat(String(data.price || "").replace(",", ".")) : null
  286. const sRaw = data.stock !== undefined ? parseInt(String(data.stock || "0"), 10) : null
  287. const updated = {
  288. ...c,
  289. title: data.title !== undefined ? safeText(data.title) : c.title,
  290. description: data.description !== undefined ? safeText(data.description) : c.description,
  291. image: data.image !== undefined ? (data.image ? String(data.image).trim() || null : c.image) : c.image,
  292. price: pRaw !== null && Number.isFinite(pRaw) && pRaw > 0 ? pRaw.toFixed(6) : c.price,
  293. stock: sRaw !== null && Number.isFinite(sRaw) && sRaw >= 0 ? sRaw : c.stock,
  294. featured: data.featured !== undefined ? !!data.featured : !!c.featured,
  295. updatedAt: new Date().toISOString(),
  296. replaces: tipId
  297. }
  298. const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
  299. ssbClient.publish(tombstone, (e1) => {
  300. if (e1) return reject(e1)
  301. ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res))
  302. })
  303. })
  304. })
  305. },
  306. async deleteProductById(id) {
  307. const tipId = await this.resolveCurrentId(id)
  308. const ssbClient = await openSsb()
  309. const userId = ssbClient.id
  310. return new Promise((resolve, reject) => {
  311. ssbClient.get(tipId, (err, item) => {
  312. if (err || !item?.content) return reject(new Error("Product not found"))
  313. if (item.content.author !== userId) return reject(new Error("Not the author"))
  314. const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
  315. ssbClient.publish(tombstone, (e) => e ? reject(e) : resolve())
  316. })
  317. })
  318. },
  319. async getProductById(id) {
  320. const ssbClient = await openSsb()
  321. const messages = await readAll(ssbClient)
  322. const idx = buildIndex(messages)
  323. let tip = id
  324. while (idx.child.has(tip)) tip = idx.child.get(tip)
  325. if (idx.tomb.has(tip)) return null
  326. const node = idx.nodes.get(tip)
  327. if (!node || node.c.type !== "shopProduct") return null
  328. let root = tip
  329. while (idx.parent.has(root)) root = idx.parent.get(root)
  330. return buildProduct(node, root)
  331. },
  332. async listProducts(shopRootId) {
  333. const ssbClient = await openSsb()
  334. const messages = await readAll(ssbClient)
  335. const idx = buildIndex(messages)
  336. const items = []
  337. for (const [rootId, tipId] of idx.tipByRoot.entries()) {
  338. if (idx.tomb.has(tipId)) continue
  339. const node = idx.nodes.get(tipId)
  340. if (!node || node.c.type !== "shopProduct") continue
  341. if (node.c.shopId !== shopRootId) continue
  342. const prod = buildProduct(node, rootId)
  343. if (prod) items.push(prod)
  344. }
  345. return items.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
  346. },
  347. async listFeaturedProducts(shopRootId) {
  348. const ssbClient = await openSsb()
  349. const messages = await readAll(ssbClient)
  350. const idx = buildIndex(messages)
  351. const items = []
  352. for (const [rootId, tipId] of idx.tipByRoot.entries()) {
  353. if (idx.tomb.has(tipId)) continue
  354. const node = idx.nodes.get(tipId)
  355. if (!node || node.c.type !== "shopProduct") continue
  356. if (node.c.shopId !== shopRootId) continue
  357. if (!node.c.featured) continue
  358. const prod = buildProduct(node, rootId)
  359. if (prod) items.push(prod)
  360. }
  361. return items.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)).slice(0, 4)
  362. },
  363. async listAllProducts({ filter = "all", sort = "recent" } = {}) {
  364. const ssbClient = await openSsb()
  365. const messages = await readAll(ssbClient)
  366. const idx = buildIndex(messages)
  367. const items = []
  368. for (const [rootId, tipId] of idx.tipByRoot.entries()) {
  369. if (idx.tomb.has(tipId)) continue
  370. const node = idx.nodes.get(tipId)
  371. if (!node || node.c.type !== "shopProduct") continue
  372. const prod = buildProduct(node, rootId)
  373. if (prod) items.push(prod)
  374. }
  375. if (filter === "top") return items.slice().sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt))
  376. return items.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
  377. },
  378. async buyProduct(productId) {
  379. const tipId = await this.resolveCurrentId(productId)
  380. const ssbClient = await openSsb()
  381. const userId = ssbClient.id
  382. return new Promise((resolve, reject) => {
  383. ssbClient.get(tipId, (err, item) => {
  384. if (err || !item?.content) return reject(new Error("Product not found"))
  385. const c = item.content
  386. if (c.author === userId) return reject(new Error("Cannot buy your own product"))
  387. const stock = Number(c.stock) || 0
  388. if (stock <= 0) return reject(new Error("Out of stock"))
  389. const updated = {
  390. ...c,
  391. stock: stock - 1,
  392. buyers: safeArr(c.buyers).concat(userId),
  393. updatedAt: new Date().toISOString(),
  394. replaces: tipId
  395. }
  396. const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
  397. ssbClient.publish(tombstone, (e1) => {
  398. if (e1) return reject(e1)
  399. ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res))
  400. })
  401. })
  402. })
  403. },
  404. async createOpinion(id, category) {
  405. if (!categories.includes(category)) throw new Error("Invalid category")
  406. const ssbClient = await openSsb()
  407. const userId = ssbClient.id
  408. const tipId = await this.resolveCurrentId(id)
  409. return new Promise((resolve, reject) => {
  410. ssbClient.get(tipId, (err, item) => {
  411. if (err || !item?.content) return reject(new Error("Not found"))
  412. const c = item.content
  413. const buyers = safeArr(c.buyers)
  414. if (!buyers.includes(userId)) return reject(new Error("Must purchase before rating"))
  415. const voters = safeArr(c.opinions_inhabitants)
  416. if (voters.includes(userId)) return reject(new Error("Already voted"))
  417. const updated = {
  418. ...c,
  419. opinions: { ...(c.opinions || {}), [category]: ((c.opinions || {})[category] || 0) + 1 },
  420. opinions_inhabitants: voters.concat(userId),
  421. updatedAt: new Date().toISOString(),
  422. replaces: tipId
  423. }
  424. const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
  425. ssbClient.publish(tombstone, (e1) => {
  426. if (e1) return reject(e1)
  427. ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res))
  428. })
  429. })
  430. })
  431. }
  432. }
  433. }