shops_model.js 23 KB

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