calendars_model.js 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633
  1. const pull = require("../server/node_modules/pull-stream")
  2. const { getConfig } = require("../configs/config-manager.js")
  3. const logLimit = getConfig().ssbLogStream?.limit || 1000
  4. const safeText = (v) => String(v || "").trim()
  5. const normalizeTags = (raw) => {
  6. if (!raw) return []
  7. if (Array.isArray(raw)) return raw.map(t => String(t || "").trim()).filter(Boolean)
  8. return String(raw).split(",").map(t => t.trim()).filter(Boolean)
  9. }
  10. const hasAnyInterval = (w, m, y) => !!(w || m || y)
  11. const expandRecurrence = (firstDate, deadline, weekly, monthly, yearly) => {
  12. const start = new Date(firstDate)
  13. const out = [start]
  14. if (!deadline || !hasAnyInterval(weekly, monthly, yearly)) return out
  15. const end = new Date(deadline).getTime()
  16. const seen = new Set([start.getTime()])
  17. const walk = (mutate) => {
  18. const n = new Date(start)
  19. mutate(n)
  20. while (n.getTime() <= end) {
  21. const t = n.getTime()
  22. if (!seen.has(t)) { seen.add(t); out.push(new Date(n)) }
  23. mutate(n)
  24. }
  25. }
  26. if (weekly) walk((d) => d.setDate(d.getDate() + 7))
  27. if (monthly) walk((d) => d.setMonth(d.getMonth() + 1))
  28. if (yearly) walk((d) => d.setFullYear(d.getFullYear() + 1))
  29. return out.sort((a, b) => a.getTime() - b.getTime())
  30. }
  31. module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
  32. let ssb
  33. const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
  34. const readAll = async (ssbClient) =>
  35. new Promise((resolve, reject) =>
  36. pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)))
  37. )
  38. const tribeHelpers = tribeCrypto ? tribeCrypto.createHelpers(tribesModel) : null
  39. const encryptIfTribe = tribeHelpers ? tribeHelpers.encryptIfTribe : async (c) => c
  40. const decryptIfTribe = tribeHelpers ? tribeHelpers.decryptIfTribe : async (c) => c
  41. const assertReadable = tribeHelpers ? tribeHelpers.assertReadable : () => {}
  42. const decryptIndexNodes = tribeHelpers ? tribeHelpers.decryptIndexNodes : async () => {}
  43. const buildIndex = (messages) => {
  44. const tomb = new Set()
  45. const nodes = new Map()
  46. const parent = new Map()
  47. const child = new Map()
  48. const authorByKey = new Map()
  49. const tombRequests = []
  50. for (const m of messages) {
  51. const k = m.key
  52. const v = m.value || {}
  53. const c = v.content
  54. if (!c) continue
  55. if (c.type === "tombstone" && c.target) { tombRequests.push({ target: c.target, author: v.author }); continue }
  56. if (c.type === "calendar") {
  57. nodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
  58. authorByKey.set(k, v.author)
  59. if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k) }
  60. }
  61. }
  62. for (const t of tombRequests) {
  63. const targetAuthor = authorByKey.get(t.target)
  64. if (targetAuthor && t.author === targetAuthor) tomb.add(t.target)
  65. }
  66. const rootOf = (id) => { let cur = id; while (parent.has(cur)) cur = parent.get(cur); return cur }
  67. const tipOf = (id) => { let cur = id; while (child.has(cur)) cur = child.get(cur); return cur }
  68. const roots = new Set()
  69. for (const id of nodes.keys()) roots.add(rootOf(id))
  70. const tipByRoot = new Map()
  71. for (const r of roots) tipByRoot.set(r, tipOf(r))
  72. return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot }
  73. }
  74. const buildCalendar = (node, rootId) => {
  75. const c = node.c || {}
  76. if (c.type !== "calendar") return null
  77. const undec = c.encryptedPayload && c._decrypted === false
  78. return {
  79. key: node.key,
  80. rootId,
  81. title: undec ? "" : safeText(c.title),
  82. status: c.status || "OPEN",
  83. deadline: undec ? "" : (c.deadline || ""),
  84. tags: Array.isArray(c.tags) ? c.tags : [],
  85. author: c.author || node.author,
  86. participants: Array.isArray(c.participants) ? c.participants : [],
  87. createdAt: c.createdAt || new Date(node.ts).toISOString(),
  88. updatedAt: c.updatedAt || null,
  89. tribeId: c.tribeId || null,
  90. encrypted: !!undec
  91. }
  92. }
  93. const isClosed = (calendar) => {
  94. if (calendar.status === "CLOSED") return true
  95. if (!calendar.deadline) return false
  96. return new Date(calendar.deadline).getTime() <= Date.now()
  97. }
  98. return {
  99. type: "calendar",
  100. async resolveRootId(id) {
  101. const ssbClient = await openSsb()
  102. const messages = await readAll(ssbClient)
  103. const idx = buildIndex(messages)
  104. let tip = id
  105. while (idx.child.has(tip)) tip = idx.child.get(tip)
  106. if (idx.tomb.has(tip)) throw new Error("Not found")
  107. let root = tip
  108. while (idx.parent.has(root)) root = idx.parent.get(root)
  109. return root
  110. },
  111. async resolveCurrentId(id) {
  112. const ssbClient = await openSsb()
  113. const messages = await readAll(ssbClient)
  114. const idx = buildIndex(messages)
  115. let tip = id
  116. while (idx.child.has(tip)) tip = idx.child.get(tip)
  117. if (idx.tomb.has(tip)) throw new Error("Not found")
  118. return tip
  119. },
  120. async createCalendar({ title, status, deadline, tags, firstDate, firstDateLabel, firstNote, intervalWeekly, intervalMonthly, intervalYearly, tribeId }) {
  121. const ssbClient = await openSsb()
  122. const userId = ssbClient.id
  123. const now = new Date().toISOString()
  124. const validStatus = ["OPEN", "CLOSED"].includes(String(status).toUpperCase()) ? String(status).toUpperCase() : "OPEN"
  125. if (deadline && new Date(deadline).getTime() <= Date.now()) throw new Error("Deadline must be in the future")
  126. if (!firstDate || new Date(firstDate).getTime() <= Date.now()) throw new Error("First date must be in the future")
  127. let content = {
  128. type: "calendar",
  129. title: safeText(title),
  130. status: validStatus,
  131. deadline: deadline || "",
  132. tags: normalizeTags(tags),
  133. author: userId,
  134. participants: [userId],
  135. createdAt: now,
  136. updatedAt: now,
  137. ...(tribeId ? { tribeId } : {})
  138. }
  139. content = await encryptIfTribe(content)
  140. const calMsg = await new Promise((resolve, reject) => {
  141. ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
  142. })
  143. const calendarId = calMsg.key
  144. const dates = expandRecurrence(firstDate, deadline, intervalWeekly, intervalMonthly, intervalYearly)
  145. const allDateMsgs = []
  146. for (const d of dates) {
  147. let dateContent = {
  148. type: "calendarDate",
  149. calendarId,
  150. date: d.toISOString(),
  151. label: safeText(firstDateLabel),
  152. author: userId,
  153. createdAt: new Date().toISOString(),
  154. ...(tribeId ? { tribeId } : {})
  155. }
  156. dateContent = await encryptIfTribe(dateContent)
  157. const dateMsg = await new Promise((resolve, reject) => {
  158. ssbClient.publish(dateContent, (err, msg) => err ? reject(err) : resolve(msg))
  159. })
  160. allDateMsgs.push(dateMsg)
  161. }
  162. if (firstNote && safeText(firstNote) && allDateMsgs.length > 0) {
  163. for (const dateMsg of allDateMsgs) {
  164. let noteContent = {
  165. type: "calendarNote",
  166. calendarId,
  167. dateId: dateMsg.key,
  168. text: safeText(firstNote),
  169. author: userId,
  170. createdAt: new Date().toISOString(),
  171. ...(tribeId ? { tribeId } : {})
  172. }
  173. noteContent = await encryptIfTribe(noteContent)
  174. await new Promise((resolve, reject) => {
  175. ssbClient.publish(noteContent, (err, msg) => err ? reject(err) : resolve(msg))
  176. })
  177. }
  178. }
  179. return calMsg
  180. },
  181. async updateCalendarById(id, data) {
  182. const tipId = await this.resolveCurrentId(id)
  183. const ssbClient = await openSsb()
  184. const userId = ssbClient.id
  185. const item = await new Promise((resolve, reject) => {
  186. ssbClient.get(tipId, (err, it) => err ? reject(err) : resolve(it))
  187. })
  188. if (!item || !item.content) throw new Error("Calendar not found")
  189. const oldDec = await decryptIfTribe(item.content)
  190. assertReadable(oldDec, "Calendar")
  191. if ((oldDec.author || item.content.author) !== userId) throw new Error("Not the author")
  192. let updated = {
  193. type: "calendar",
  194. title: data.title !== undefined ? safeText(data.title) : (oldDec.title || ""),
  195. status: data.status !== undefined ? (["OPEN","CLOSED"].includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : oldDec.status) : (oldDec.status || "OPEN"),
  196. deadline: data.deadline !== undefined ? data.deadline : (oldDec.deadline || ""),
  197. tags: data.tags !== undefined ? normalizeTags(data.tags) : (Array.isArray(oldDec.tags) ? oldDec.tags : []),
  198. author: oldDec.author || userId,
  199. participants: oldDec.participants || [userId],
  200. ...(item.content.tribeId ? { tribeId: item.content.tribeId } : {}),
  201. createdAt: oldDec.createdAt,
  202. updatedAt: new Date().toISOString(),
  203. replaces: tipId
  204. }
  205. updated = await encryptIfTribe(updated)
  206. const result = await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res)))
  207. const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
  208. await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve()))
  209. return result
  210. },
  211. async deleteCalendarById(id) {
  212. const tipId = await this.resolveCurrentId(id)
  213. const ssbClient = await openSsb()
  214. const userId = ssbClient.id
  215. const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it)))
  216. if (!item || !item.content) throw new Error("Calendar not found")
  217. const dec = await decryptIfTribe(item.content)
  218. assertReadable(dec, "Calendar")
  219. if ((dec.author || item.content.author) !== userId) throw new Error("Not the author")
  220. const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
  221. return new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve()))
  222. },
  223. async joinCalendar(calendarId) {
  224. const tipId = await this.resolveCurrentId(calendarId)
  225. const ssbClient = await openSsb()
  226. const userId = ssbClient.id
  227. const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it)))
  228. if (!item || !item.content) throw new Error("Calendar not found")
  229. const dec = await decryptIfTribe(item.content)
  230. assertReadable(dec, "Calendar")
  231. const participants = Array.isArray(dec.participants) ? dec.participants : []
  232. if (participants.includes(userId)) return
  233. let updated = {
  234. type: "calendar",
  235. title: dec.title || "",
  236. status: dec.status || "OPEN",
  237. deadline: dec.deadline || "",
  238. tags: Array.isArray(dec.tags) ? dec.tags : [],
  239. author: dec.author,
  240. participants: [...participants, userId],
  241. ...(item.content.tribeId ? { tribeId: item.content.tribeId } : {}),
  242. createdAt: dec.createdAt,
  243. updatedAt: new Date().toISOString(),
  244. replaces: tipId
  245. }
  246. updated = await encryptIfTribe(updated)
  247. const result = await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res)))
  248. const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
  249. await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve()))
  250. return result
  251. },
  252. async leaveCalendar(calendarId) {
  253. const tipId = await this.resolveCurrentId(calendarId)
  254. const ssbClient = await openSsb()
  255. const userId = ssbClient.id
  256. const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it)))
  257. if (!item || !item.content) throw new Error("Calendar not found")
  258. const dec = await decryptIfTribe(item.content)
  259. assertReadable(dec, "Calendar")
  260. if ((dec.author || item.content.author) === userId) throw new Error("Author cannot leave")
  261. const participants = Array.isArray(dec.participants) ? dec.participants : []
  262. if (!participants.includes(userId)) return
  263. let updated = {
  264. type: "calendar",
  265. title: dec.title || "",
  266. status: dec.status || "OPEN",
  267. deadline: dec.deadline || "",
  268. tags: Array.isArray(dec.tags) ? dec.tags : [],
  269. author: dec.author,
  270. participants: participants.filter(p => p !== userId),
  271. ...(item.content.tribeId ? { tribeId: item.content.tribeId } : {}),
  272. createdAt: dec.createdAt,
  273. updatedAt: new Date().toISOString(),
  274. replaces: tipId
  275. }
  276. updated = await encryptIfTribe(updated)
  277. const result = await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res)))
  278. const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
  279. await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve()))
  280. return result
  281. },
  282. async getCalendarById(id) {
  283. const ssbClient = await openSsb()
  284. const messages = await readAll(ssbClient)
  285. const idx = buildIndex(messages)
  286. await decryptIndexNodes(idx)
  287. let tip = id
  288. while (idx.child.has(tip)) tip = idx.child.get(tip)
  289. if (idx.tomb.has(tip)) return null
  290. const node = idx.nodes.get(tip)
  291. if (!node || node.c.type !== "calendar") return null
  292. let root = tip
  293. while (idx.parent.has(root)) root = idx.parent.get(root)
  294. const cal = buildCalendar(node, root)
  295. if (!cal) return null
  296. cal.isClosed = isClosed(cal)
  297. return cal
  298. },
  299. async listAll({ filter = "all", viewerId } = {}) {
  300. const ssbClient = await openSsb()
  301. const uid = viewerId || ssbClient.id
  302. const messages = await readAll(ssbClient)
  303. const idx = buildIndex(messages)
  304. await decryptIndexNodes(idx)
  305. const items = []
  306. for (const [rootId, tipId] of idx.tipByRoot.entries()) {
  307. if (idx.tomb.has(tipId)) continue
  308. const node = idx.nodes.get(tipId)
  309. if (!node || node.c.type !== "calendar") continue
  310. const cal = buildCalendar(node, rootId)
  311. if (!cal) continue
  312. cal.isClosed = isClosed(cal)
  313. items.push(cal)
  314. }
  315. let list = items
  316. if (filter === "mine") list = list.filter(c => c.author === uid)
  317. else if (filter === "recent") {
  318. const now = Date.now()
  319. list = list.filter(c => new Date(c.createdAt).getTime() >= now - 86400000)
  320. }
  321. else if (filter === "open") list = list.filter(c => !c.isClosed)
  322. else if (filter === "closed") list = list.filter(c => c.isClosed)
  323. return list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
  324. },
  325. async addDate(calendarId, date, label, intervalWeekly, intervalMonthly, intervalYearly, intervalDeadline) {
  326. const ssbClient = await openSsb()
  327. const userId = ssbClient.id
  328. const rootId = await this.resolveRootId(calendarId)
  329. const cal = await this.getCalendarById(rootId)
  330. if (!cal) throw new Error("Calendar not found")
  331. if (cal.status === "CLOSED" && userId !== cal.author) throw new Error("Only the author can add dates to a CLOSED calendar")
  332. if (!date || new Date(date).getTime() <= Date.now()) throw new Error("Date must be in the future")
  333. const deadlineForExpansion = (intervalDeadline && hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly)) ? intervalDeadline : cal.deadline
  334. const dates = expandRecurrence(date, deadlineForExpansion, intervalWeekly, intervalMonthly, intervalYearly)
  335. const allMsgs = []
  336. for (const d of dates) {
  337. let dateContent = {
  338. type: "calendarDate",
  339. calendarId: rootId,
  340. date: d.toISOString(),
  341. label: safeText(label),
  342. author: userId,
  343. createdAt: new Date().toISOString(),
  344. ...(cal.tribeId ? { tribeId: cal.tribeId } : {})
  345. }
  346. dateContent = await encryptIfTribe(dateContent)
  347. const msg = await new Promise((resolve, reject) => {
  348. ssbClient.publish(dateContent, (err, m) => err ? reject(err) : resolve(m))
  349. })
  350. allMsgs.push(msg)
  351. }
  352. return allMsgs
  353. },
  354. async getDatesForCalendar(calendarId) {
  355. const rootId = await this.resolveRootId(calendarId)
  356. const ssbClient = await openSsb()
  357. const messages = await readAll(ssbClient)
  358. const authorByKey = new Map()
  359. for (const m of messages) authorByKey.set(m.key, (m.value || {}).author)
  360. const tombstoned = new Set()
  361. for (const m of messages) {
  362. const c = (m.value || {}).content
  363. if (c && c.type === "tombstone" && c.target) {
  364. const targetAuthor = authorByKey.get(c.target)
  365. if (targetAuthor && (m.value || {}).author === targetAuthor) tombstoned.add(c.target)
  366. }
  367. }
  368. const dates = []
  369. for (const m of messages) {
  370. if (tombstoned.has(m.key)) continue
  371. const v = m.value || {}
  372. const c = v.content
  373. if (!c || c.type !== "calendarDate") continue
  374. if (c.calendarId !== rootId) continue
  375. let dec = c
  376. if (c.encryptedPayload && tribeCrypto && tribesModel) {
  377. const r = await tribeCrypto.decryptFromTribe(c, tribesModel)
  378. dec = r && !r._undecryptable ? r : c
  379. if (r && r._undecryptable) continue
  380. }
  381. dates.push({
  382. key: m.key,
  383. calendarId: dec.calendarId || c.calendarId,
  384. date: dec.date,
  385. label: dec.label || "",
  386. author: dec.author || v.author,
  387. createdAt: dec.createdAt || new Date(v.timestamp || 0).toISOString()
  388. })
  389. }
  390. dates.sort((a, b) => new Date(a.date) - new Date(b.date))
  391. return dates
  392. },
  393. async deleteDate(dateId, calendarId) {
  394. const ssbClient = await openSsb()
  395. const userId = ssbClient.id
  396. const rootId = await this.resolveRootId(calendarId)
  397. const cal = await this.getCalendarById(rootId)
  398. if (!cal) throw new Error("Calendar not found")
  399. const messages = await readAll(ssbClient)
  400. const authorByKey = new Map()
  401. for (const m of messages) authorByKey.set(m.key, (m.value || {}).author)
  402. const tombstoned = new Set()
  403. for (const m of messages) {
  404. const c = (m.value || {}).content
  405. if (c && c.type === "tombstone" && c.target) {
  406. const targetAuthor = authorByKey.get(c.target)
  407. if (targetAuthor && (m.value || {}).author === targetAuthor) tombstoned.add(c.target)
  408. }
  409. }
  410. let dateAuthor = null
  411. for (const m of messages) {
  412. if (m.key !== dateId) continue
  413. const c = (m.value || {}).content
  414. if (!c || c.type !== "calendarDate") continue
  415. if (tombstoned.has(m.key)) break
  416. let dec = c
  417. if (c.encryptedPayload && tribeCrypto && tribesModel) {
  418. const r = await tribeCrypto.decryptFromTribe(c, tribesModel)
  419. if (r && !r._undecryptable) dec = r
  420. }
  421. dateAuthor = dec.author || (m.value || {}).author
  422. break
  423. }
  424. if (!dateAuthor) throw new Error("Date not found")
  425. if (dateAuthor !== userId && cal.author !== userId) throw new Error("Not authorized")
  426. for (const m of messages) {
  427. const c = (m.value || {}).content
  428. if (!c || c.type !== "calendarNote") continue
  429. if (tombstoned.has(m.key)) continue
  430. if (c.dateId !== dateId) continue
  431. await new Promise((resolve, reject) => {
  432. ssbClient.publish({ type: "tombstone", target: m.key, deletedAt: new Date().toISOString(), author: userId }, (e) => e ? reject(e) : resolve())
  433. })
  434. }
  435. return new Promise((resolve, reject) => {
  436. ssbClient.publish({ type: "tombstone", target: dateId, deletedAt: new Date().toISOString(), author: userId }, (e) => e ? reject(e) : resolve())
  437. })
  438. },
  439. async addNote(calendarId, dateId, text) {
  440. const ssbClient = await openSsb()
  441. const userId = ssbClient.id
  442. const rootId = await this.resolveRootId(calendarId)
  443. const cal = await this.getCalendarById(rootId)
  444. if (!cal) throw new Error("Calendar not found")
  445. if (!cal.participants.includes(userId)) throw new Error("Only participants can add notes")
  446. let noteContent = {
  447. type: "calendarNote",
  448. calendarId: rootId,
  449. dateId,
  450. text: safeText(text),
  451. author: userId,
  452. createdAt: new Date().toISOString(),
  453. ...(cal.tribeId ? { tribeId: cal.tribeId } : {})
  454. }
  455. noteContent = await encryptIfTribe(noteContent)
  456. return new Promise((resolve, reject) => {
  457. ssbClient.publish(noteContent, (err, msg) => err ? reject(err) : resolve(msg))
  458. })
  459. },
  460. async deleteNote(noteId) {
  461. const ssbClient = await openSsb()
  462. const userId = ssbClient.id
  463. const item = await new Promise((resolve, reject) => ssbClient.get(noteId, (e, it) => e ? reject(e) : resolve(it)))
  464. if (!item || !item.content) throw new Error("Note not found")
  465. const dec = await decryptIfTribe(item.content)
  466. if ((dec.author || item.content.author) !== userId) throw new Error("Not the author")
  467. return new Promise((resolve, reject) => {
  468. ssbClient.publish({ type: "tombstone", target: noteId, deletedAt: new Date().toISOString(), author: userId }, (e, msg) => e ? reject(e) : resolve(msg))
  469. })
  470. },
  471. async getNotesForDate(calendarId, dateId) {
  472. const rootId = await this.resolveRootId(calendarId)
  473. const ssbClient = await openSsb()
  474. const messages = await readAll(ssbClient)
  475. const authorByKey = new Map()
  476. for (const m of messages) authorByKey.set(m.key, (m.value || {}).author)
  477. const tombstoned = new Set()
  478. for (const m of messages) {
  479. const c = (m.value || {}).content
  480. if (c && c.type === "tombstone" && c.target) {
  481. const targetAuthor = authorByKey.get(c.target)
  482. if (targetAuthor && (m.value || {}).author === targetAuthor) tombstoned.add(c.target)
  483. }
  484. }
  485. const notes = []
  486. for (const m of messages) {
  487. const v = m.value || {}
  488. const c = v.content
  489. if (!c || c.type !== "calendarNote") continue
  490. if (tombstoned.has(m.key)) continue
  491. if (c.calendarId !== rootId || c.dateId !== dateId) continue
  492. let dec = c
  493. if (c.encryptedPayload && tribeCrypto && tribesModel) {
  494. const r = await tribeCrypto.decryptFromTribe(c, tribesModel)
  495. if (r && !r._undecryptable) dec = r
  496. else continue
  497. }
  498. notes.push({
  499. key: m.key,
  500. calendarId: dec.calendarId || c.calendarId,
  501. dateId: dec.dateId || c.dateId,
  502. text: dec.text || "",
  503. author: dec.author || v.author,
  504. createdAt: dec.createdAt || new Date(v.timestamp || 0).toISOString()
  505. })
  506. }
  507. notes.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
  508. return notes
  509. },
  510. async checkDueReminders() {
  511. if (!pmModel) return
  512. const ssbClient = await openSsb()
  513. const messages = await readAll(ssbClient)
  514. const now = Date.now()
  515. const sentMarkers = new Set()
  516. for (const m of messages) {
  517. const c = (m.value || {}).content
  518. if (!c || c.type !== "calendarReminderSent") continue
  519. sentMarkers.add(`${c.calendarId}::${c.dateId}`)
  520. }
  521. const authorByKey = new Map()
  522. for (const m of messages) authorByKey.set(m.key, (m.value || {}).author)
  523. const tombstoned = new Set()
  524. for (const m of messages) {
  525. const c = (m.value || {}).content
  526. if (c && c.type === "tombstone" && c.target) {
  527. const targetAuthor = authorByKey.get(c.target)
  528. if (targetAuthor && (m.value || {}).author === targetAuthor) tombstoned.add(c.target)
  529. }
  530. }
  531. const dueByCalendar = new Map()
  532. for (const m of messages) {
  533. if (tombstoned.has(m.key)) continue
  534. const v = m.value || {}
  535. const c = v.content
  536. if (!c || c.type !== "calendarDate") continue
  537. let dec = c
  538. if (c.encryptedPayload && tribeCrypto && tribesModel) {
  539. const r = await tribeCrypto.decryptFromTribe(c, tribesModel)
  540. if (!r || r._undecryptable) continue
  541. dec = r
  542. }
  543. if (!dec.date || new Date(dec.date).getTime() > now) continue
  544. if (sentMarkers.has(`${c.calendarId}::${m.key}`)) continue
  545. const entry = { key: m.key, calendarId: c.calendarId, date: dec.date, label: dec.label || "" }
  546. const list = dueByCalendar.get(c.calendarId) || []
  547. list.push(entry)
  548. dueByCalendar.set(c.calendarId, list)
  549. }
  550. const publishMarker = (calendarId, dateId) => new Promise((resolve, reject) => {
  551. ssbClient.publish({
  552. type: "calendarReminderSent",
  553. calendarId,
  554. dateId,
  555. sentAt: new Date().toISOString()
  556. }, (err) => err ? reject(err) : resolve())
  557. })
  558. for (const [calendarId, list] of dueByCalendar.entries()) {
  559. try {
  560. list.sort((a, b) => new Date(b.date) - new Date(a.date))
  561. const primary = list[0]
  562. const cal = await this.getCalendarById(calendarId)
  563. if (!cal) continue
  564. const participants = cal.participants.filter(p => typeof p === "string" && p.length > 0)
  565. if (participants.length > 0) {
  566. const notesForDay = await this.getNotesForDate(calendarId, primary.key)
  567. const notesBlock = notesForDay.length > 0
  568. ? notesForDay.map(n => ` - ${n.text}`).join("\n\n")
  569. : " (no notes)"
  570. const subject = `Calendar Reminder: ${cal.title}`
  571. const text =
  572. `Reminder from: ${cal.author}\n` +
  573. `Title: ${cal.title}\n` +
  574. `Date: ${primary.label || primary.date}\n\n` +
  575. `Notes for this day:\n\n${notesBlock}\n\n` +
  576. `Visit Calendar: /calendars/${cal.rootId}`
  577. const chunkSize = 6
  578. for (let i = 0; i < participants.length; i += chunkSize) {
  579. await pmModel.sendMessage(participants.slice(i, i + chunkSize), subject, text)
  580. }
  581. }
  582. for (const dd of list) {
  583. try { await publishMarker(calendarId, dd.key) } catch (_) {}
  584. }
  585. } catch (_) {}
  586. }
  587. }
  588. }
  589. }