shops_model.js 23 KB

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