calendars_model.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537
  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 }) => {
  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 buildIndex = (messages) => {
  39. const tomb = new Set()
  40. const nodes = new Map()
  41. const parent = new Map()
  42. const child = new Map()
  43. for (const m of messages) {
  44. const k = m.key
  45. const v = m.value || {}
  46. const c = v.content
  47. if (!c) continue
  48. if (c.type === "tombstone" && c.target) { tomb.add(c.target); continue }
  49. if (c.type === "calendar") {
  50. nodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
  51. if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k) }
  52. }
  53. }
  54. const rootOf = (id) => { let cur = id; while (parent.has(cur)) cur = parent.get(cur); return cur }
  55. const tipOf = (id) => { let cur = id; while (child.has(cur)) cur = child.get(cur); return cur }
  56. const roots = new Set()
  57. for (const id of nodes.keys()) roots.add(rootOf(id))
  58. const tipByRoot = new Map()
  59. for (const r of roots) tipByRoot.set(r, tipOf(r))
  60. return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot }
  61. }
  62. const buildCalendar = (node, rootId) => {
  63. const c = node.c || {}
  64. if (c.type !== "calendar") return null
  65. return {
  66. key: node.key,
  67. rootId,
  68. title: safeText(c.title),
  69. status: c.status || "OPEN",
  70. deadline: c.deadline || "",
  71. tags: Array.isArray(c.tags) ? c.tags : [],
  72. author: c.author || node.author,
  73. participants: Array.isArray(c.participants) ? c.participants : [],
  74. createdAt: c.createdAt || new Date(node.ts).toISOString(),
  75. updatedAt: c.updatedAt || null,
  76. tribeId: c.tribeId || null
  77. }
  78. }
  79. const isClosed = (calendar) => {
  80. if (calendar.status === "CLOSED") return true
  81. if (!calendar.deadline) return false
  82. return new Date(calendar.deadline).getTime() <= Date.now()
  83. }
  84. return {
  85. type: "calendar",
  86. async resolveRootId(id) {
  87. const ssbClient = await openSsb()
  88. const messages = await readAll(ssbClient)
  89. const idx = buildIndex(messages)
  90. let tip = id
  91. while (idx.child.has(tip)) tip = idx.child.get(tip)
  92. if (idx.tomb.has(tip)) throw new Error("Not found")
  93. let root = tip
  94. while (idx.parent.has(root)) root = idx.parent.get(root)
  95. return root
  96. },
  97. async resolveCurrentId(id) {
  98. const ssbClient = await openSsb()
  99. const messages = await readAll(ssbClient)
  100. const idx = buildIndex(messages)
  101. let tip = id
  102. while (idx.child.has(tip)) tip = idx.child.get(tip)
  103. if (idx.tomb.has(tip)) throw new Error("Not found")
  104. return tip
  105. },
  106. async createCalendar({ title, status, deadline, tags, firstDate, firstDateLabel, firstNote, intervalWeekly, intervalMonthly, intervalYearly, tribeId }) {
  107. const ssbClient = await openSsb()
  108. const userId = ssbClient.id
  109. const now = new Date().toISOString()
  110. const validStatus = ["OPEN", "CLOSED"].includes(String(status).toUpperCase()) ? String(status).toUpperCase() : "OPEN"
  111. if (deadline && new Date(deadline).getTime() <= Date.now()) throw new Error("Deadline must be in the future")
  112. if (!firstDate || new Date(firstDate).getTime() <= Date.now()) throw new Error("First date must be in the future")
  113. const content = {
  114. type: "calendar",
  115. title: safeText(title),
  116. status: validStatus,
  117. deadline: deadline || "",
  118. tags: normalizeTags(tags),
  119. author: userId,
  120. participants: [userId],
  121. createdAt: now,
  122. updatedAt: now,
  123. ...(tribeId ? { tribeId } : {})
  124. }
  125. const calMsg = await new Promise((resolve, reject) => {
  126. ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
  127. })
  128. const calendarId = calMsg.key
  129. const dates = expandRecurrence(firstDate, deadline, intervalWeekly, intervalMonthly, intervalYearly)
  130. const allDateMsgs = []
  131. for (const d of dates) {
  132. const dateMsg = await new Promise((resolve, reject) => {
  133. ssbClient.publish({
  134. type: "calendarDate",
  135. calendarId,
  136. date: d.toISOString(),
  137. label: safeText(firstDateLabel),
  138. author: userId,
  139. createdAt: new Date().toISOString()
  140. }, (err, msg) => err ? reject(err) : resolve(msg))
  141. })
  142. allDateMsgs.push(dateMsg)
  143. }
  144. if (firstNote && safeText(firstNote) && allDateMsgs.length > 0) {
  145. for (const dateMsg of allDateMsgs) {
  146. await new Promise((resolve, reject) => {
  147. ssbClient.publish({
  148. type: "calendarNote",
  149. calendarId,
  150. dateId: dateMsg.key,
  151. text: safeText(firstNote),
  152. author: userId,
  153. createdAt: new Date().toISOString()
  154. }, (err, msg) => err ? reject(err) : resolve(msg))
  155. })
  156. }
  157. }
  158. return calMsg
  159. },
  160. async updateCalendarById(id, data) {
  161. const tipId = await this.resolveCurrentId(id)
  162. const ssbClient = await openSsb()
  163. const userId = ssbClient.id
  164. return new Promise((resolve, reject) => {
  165. ssbClient.get(tipId, (err, item) => {
  166. if (err || !item?.content) return reject(new Error("Calendar not found"))
  167. if (item.content.author !== userId) return reject(new Error("Not the author"))
  168. const c = item.content
  169. const updated = {
  170. ...c,
  171. title: data.title !== undefined ? safeText(data.title) : c.title,
  172. status: data.status !== undefined ? (["OPEN","CLOSED"].includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : c.status) : c.status,
  173. deadline: data.deadline !== undefined ? data.deadline : c.deadline,
  174. tags: data.tags !== undefined ? normalizeTags(data.tags) : c.tags,
  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 deleteCalendarById(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("Calendar 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 joinCalendar(calendarId) {
  200. const tipId = await this.resolveCurrentId(calendarId)
  201. const ssbClient = await openSsb()
  202. const userId = ssbClient.id
  203. return new Promise((resolve, reject) => {
  204. ssbClient.get(tipId, (err, item) => {
  205. if (err || !item?.content) return reject(new Error("Calendar not found"))
  206. const c = item.content
  207. const participants = Array.isArray(c.participants) ? c.participants : []
  208. if (participants.includes(userId)) return resolve()
  209. const updated = { ...c, participants: [...participants, userId], updatedAt: new Date().toISOString(), replaces: tipId }
  210. const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
  211. ssbClient.publish(tombstone, (e1) => {
  212. if (e1) return reject(e1)
  213. ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res))
  214. })
  215. })
  216. })
  217. },
  218. async leaveCalendar(calendarId) {
  219. const tipId = await this.resolveCurrentId(calendarId)
  220. const ssbClient = await openSsb()
  221. const userId = ssbClient.id
  222. return new Promise((resolve, reject) => {
  223. ssbClient.get(tipId, (err, item) => {
  224. if (err || !item?.content) return reject(new Error("Calendar not found"))
  225. const c = item.content
  226. if (c.author === userId) return reject(new Error("Author cannot leave"))
  227. const participants = Array.isArray(c.participants) ? c.participants : []
  228. if (!participants.includes(userId)) return resolve()
  229. const updated = { ...c, participants: participants.filter(p => p !== userId), updatedAt: new Date().toISOString(), replaces: tipId }
  230. const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
  231. ssbClient.publish(tombstone, (e1) => {
  232. if (e1) return reject(e1)
  233. ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res))
  234. })
  235. })
  236. })
  237. },
  238. async getCalendarById(id) {
  239. const ssbClient = await openSsb()
  240. const messages = await readAll(ssbClient)
  241. const idx = buildIndex(messages)
  242. let tip = id
  243. while (idx.child.has(tip)) tip = idx.child.get(tip)
  244. if (idx.tomb.has(tip)) return null
  245. const node = idx.nodes.get(tip)
  246. if (!node || node.c.type !== "calendar") return null
  247. let root = tip
  248. while (idx.parent.has(root)) root = idx.parent.get(root)
  249. const cal = buildCalendar(node, root)
  250. if (!cal) return null
  251. cal.isClosed = isClosed(cal)
  252. return cal
  253. },
  254. async listAll({ filter = "all", viewerId } = {}) {
  255. const ssbClient = await openSsb()
  256. const uid = viewerId || ssbClient.id
  257. const messages = await readAll(ssbClient)
  258. const idx = buildIndex(messages)
  259. const items = []
  260. for (const [rootId, tipId] of idx.tipByRoot.entries()) {
  261. if (idx.tomb.has(tipId)) continue
  262. const node = idx.nodes.get(tipId)
  263. if (!node || node.c.type !== "calendar") continue
  264. const cal = buildCalendar(node, rootId)
  265. if (!cal) continue
  266. cal.isClosed = isClosed(cal)
  267. items.push(cal)
  268. }
  269. let list = items
  270. if (filter === "mine") list = list.filter(c => c.author === uid)
  271. else if (filter === "recent") {
  272. const now = Date.now()
  273. list = list.filter(c => new Date(c.createdAt).getTime() >= now - 86400000)
  274. }
  275. else if (filter === "open") list = list.filter(c => !c.isClosed)
  276. else if (filter === "closed") list = list.filter(c => c.isClosed)
  277. return list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
  278. },
  279. async addDate(calendarId, date, label, intervalWeekly, intervalMonthly, intervalYearly, intervalDeadline) {
  280. const ssbClient = await openSsb()
  281. const userId = ssbClient.id
  282. const rootId = await this.resolveRootId(calendarId)
  283. const cal = await this.getCalendarById(rootId)
  284. if (!cal) throw new Error("Calendar not found")
  285. if (cal.status === "CLOSED" && userId !== cal.author) throw new Error("Only the author can add dates to a CLOSED calendar")
  286. if (!date || new Date(date).getTime() <= Date.now()) throw new Error("Date must be in the future")
  287. const deadlineForExpansion = (intervalDeadline && hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly)) ? intervalDeadline : cal.deadline
  288. const dates = expandRecurrence(date, deadlineForExpansion, intervalWeekly, intervalMonthly, intervalYearly)
  289. const allMsgs = []
  290. for (const d of dates) {
  291. const msg = await new Promise((resolve, reject) => {
  292. ssbClient.publish({
  293. type: "calendarDate",
  294. calendarId: rootId,
  295. date: d.toISOString(),
  296. label: safeText(label),
  297. author: userId,
  298. createdAt: new Date().toISOString()
  299. }, (err, m) => err ? reject(err) : resolve(m))
  300. })
  301. allMsgs.push(msg)
  302. }
  303. return allMsgs
  304. },
  305. async getDatesForCalendar(calendarId) {
  306. const rootId = await this.resolveRootId(calendarId)
  307. const ssbClient = await openSsb()
  308. const messages = await readAll(ssbClient)
  309. const tombstoned = new Set()
  310. for (const m of messages) {
  311. const c = (m.value || {}).content
  312. if (c && c.type === "tombstone" && c.target) tombstoned.add(c.target)
  313. }
  314. const dates = []
  315. for (const m of messages) {
  316. if (tombstoned.has(m.key)) continue
  317. const v = m.value || {}
  318. const c = v.content
  319. if (!c || c.type !== "calendarDate") continue
  320. if (c.calendarId !== rootId) continue
  321. dates.push({
  322. key: m.key,
  323. calendarId: c.calendarId,
  324. date: c.date,
  325. label: c.label || "",
  326. author: c.author || v.author,
  327. createdAt: c.createdAt || new Date(v.timestamp || 0).toISOString()
  328. })
  329. }
  330. dates.sort((a, b) => new Date(a.date) - new Date(b.date))
  331. return dates
  332. },
  333. async deleteDate(dateId, calendarId) {
  334. const ssbClient = await openSsb()
  335. const userId = ssbClient.id
  336. const rootId = await this.resolveRootId(calendarId)
  337. const cal = await this.getCalendarById(rootId)
  338. if (!cal) throw new Error("Calendar not found")
  339. const messages = await readAll(ssbClient)
  340. const tombstoned = new Set()
  341. for (const m of messages) {
  342. const c = (m.value || {}).content
  343. if (c && c.type === "tombstone" && c.target) tombstoned.add(c.target)
  344. }
  345. let dateAuthor = null
  346. for (const m of messages) {
  347. if (m.key !== dateId) continue
  348. const c = (m.value || {}).content
  349. if (!c || c.type !== "calendarDate") continue
  350. if (tombstoned.has(m.key)) break
  351. dateAuthor = c.author || (m.value || {}).author
  352. break
  353. }
  354. if (!dateAuthor) throw new Error("Date not found")
  355. if (dateAuthor !== userId && cal.author !== userId) throw new Error("Not authorized")
  356. for (const m of messages) {
  357. const c = (m.value || {}).content
  358. if (!c || c.type !== "calendarNote") continue
  359. if (tombstoned.has(m.key)) continue
  360. if (c.dateId !== dateId) continue
  361. await new Promise((resolve, reject) => {
  362. ssbClient.publish({ type: "tombstone", target: m.key, deletedAt: new Date().toISOString(), author: userId }, (e) => e ? reject(e) : resolve())
  363. })
  364. }
  365. return new Promise((resolve, reject) => {
  366. ssbClient.publish({ type: "tombstone", target: dateId, deletedAt: new Date().toISOString(), author: userId }, (e) => e ? reject(e) : resolve())
  367. })
  368. },
  369. async addNote(calendarId, dateId, text) {
  370. const ssbClient = await openSsb()
  371. const userId = ssbClient.id
  372. const rootId = await this.resolveRootId(calendarId)
  373. const cal = await this.getCalendarById(rootId)
  374. if (!cal) throw new Error("Calendar not found")
  375. if (!cal.participants.includes(userId)) throw new Error("Only participants can add notes")
  376. return new Promise((resolve, reject) => {
  377. ssbClient.publish({
  378. type: "calendarNote",
  379. calendarId: rootId,
  380. dateId,
  381. text: safeText(text),
  382. author: userId,
  383. createdAt: new Date().toISOString()
  384. }, (err, msg) => err ? reject(err) : resolve(msg))
  385. })
  386. },
  387. async deleteNote(noteId) {
  388. const ssbClient = await openSsb()
  389. const userId = ssbClient.id
  390. return new Promise((resolve, reject) => {
  391. ssbClient.get(noteId, (err, item) => {
  392. if (err || !item?.content) return reject(new Error("Note not found"))
  393. if (item.content.author !== userId) return reject(new Error("Not the author"))
  394. ssbClient.publish({ type: "tombstone", target: noteId, deletedAt: new Date().toISOString(), author: userId }, (e, msg) => e ? reject(e) : resolve(msg))
  395. })
  396. })
  397. },
  398. async getNotesForDate(calendarId, dateId) {
  399. const rootId = await this.resolveRootId(calendarId)
  400. const ssbClient = await openSsb()
  401. const messages = await readAll(ssbClient)
  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) tombstoned.add(c.target)
  406. }
  407. const notes = []
  408. for (const m of messages) {
  409. const v = m.value || {}
  410. const c = v.content
  411. if (!c || c.type !== "calendarNote") continue
  412. if (tombstoned.has(m.key)) continue
  413. if (c.calendarId !== rootId || c.dateId !== dateId) continue
  414. notes.push({
  415. key: m.key,
  416. calendarId: c.calendarId,
  417. dateId: c.dateId,
  418. text: c.text || "",
  419. author: c.author || v.author,
  420. createdAt: c.createdAt || new Date(v.timestamp || 0).toISOString()
  421. })
  422. }
  423. notes.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
  424. return notes
  425. },
  426. async checkDueReminders() {
  427. if (!pmModel) return
  428. const ssbClient = await openSsb()
  429. const messages = await readAll(ssbClient)
  430. const now = Date.now()
  431. const sentMarkers = new Set()
  432. for (const m of messages) {
  433. const c = (m.value || {}).content
  434. if (!c || c.type !== "calendarReminderSent") continue
  435. sentMarkers.add(`${c.calendarId}::${c.dateId}`)
  436. }
  437. const tombstoned = new Set()
  438. for (const m of messages) {
  439. const c = (m.value || {}).content
  440. if (c && c.type === "tombstone" && c.target) tombstoned.add(c.target)
  441. }
  442. const dueByCalendar = new Map()
  443. for (const m of messages) {
  444. if (tombstoned.has(m.key)) continue
  445. const v = m.value || {}
  446. const c = v.content
  447. if (!c || c.type !== "calendarDate") continue
  448. if (new Date(c.date).getTime() > now) continue
  449. if (sentMarkers.has(`${c.calendarId}::${m.key}`)) continue
  450. const entry = { key: m.key, calendarId: c.calendarId, date: c.date, label: c.label || "" }
  451. const list = dueByCalendar.get(c.calendarId) || []
  452. list.push(entry)
  453. dueByCalendar.set(c.calendarId, list)
  454. }
  455. const publishMarker = (calendarId, dateId) => new Promise((resolve, reject) => {
  456. ssbClient.publish({
  457. type: "calendarReminderSent",
  458. calendarId,
  459. dateId,
  460. sentAt: new Date().toISOString()
  461. }, (err) => err ? reject(err) : resolve())
  462. })
  463. for (const [calendarId, list] of dueByCalendar.entries()) {
  464. try {
  465. list.sort((a, b) => new Date(b.date) - new Date(a.date))
  466. const primary = list[0]
  467. const cal = await this.getCalendarById(calendarId)
  468. if (!cal) continue
  469. const participants = cal.participants.filter(p => typeof p === "string" && p.length > 0)
  470. if (participants.length > 0) {
  471. const notesForDay = await this.getNotesForDate(calendarId, primary.key)
  472. const notesBlock = notesForDay.length > 0
  473. ? notesForDay.map(n => ` - ${n.text}`).join("\n\n")
  474. : " (no notes)"
  475. const subject = `Calendar Reminder: ${cal.title}`
  476. const text =
  477. `Reminder from: ${cal.author}\n` +
  478. `Title: ${cal.title}\n` +
  479. `Date: ${primary.label || primary.date}\n\n` +
  480. `Notes for this day:\n\n${notesBlock}\n\n` +
  481. `Visit Calendar: /calendars/${cal.rootId}`
  482. const chunkSize = 6
  483. for (let i = 0; i < participants.length; i += chunkSize) {
  484. await pmModel.sendMessage(participants.slice(i, i + chunkSize), subject, text)
  485. }
  486. }
  487. for (const dd of list) {
  488. try { await publishMarker(calendarId, dd.key) } catch (_) {}
  489. }
  490. } catch (_) {}
  491. }
  492. }
  493. }
  494. }