| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537 |
- const pull = require("../server/node_modules/pull-stream")
- const { getConfig } = require("../configs/config-manager.js")
- const logLimit = getConfig().ssbLogStream?.limit || 1000
- const safeText = (v) => String(v || "").trim()
- const normalizeTags = (raw) => {
- if (!raw) return []
- if (Array.isArray(raw)) return raw.map(t => String(t || "").trim()).filter(Boolean)
- return String(raw).split(",").map(t => t.trim()).filter(Boolean)
- }
- const hasAnyInterval = (w, m, y) => !!(w || m || y)
- const expandRecurrence = (firstDate, deadline, weekly, monthly, yearly) => {
- const start = new Date(firstDate)
- const out = [start]
- if (!deadline || !hasAnyInterval(weekly, monthly, yearly)) return out
- const end = new Date(deadline).getTime()
- const seen = new Set([start.getTime()])
- const walk = (mutate) => {
- const n = new Date(start)
- mutate(n)
- while (n.getTime() <= end) {
- const t = n.getTime()
- if (!seen.has(t)) { seen.add(t); out.push(new Date(n)) }
- mutate(n)
- }
- }
- if (weekly) walk((d) => d.setDate(d.getDate() + 7))
- if (monthly) walk((d) => d.setMonth(d.getMonth() + 1))
- if (yearly) walk((d) => d.setFullYear(d.getFullYear() + 1))
- return out.sort((a, b) => a.getTime() - b.getTime())
- }
- module.exports = ({ cooler, pmModel }) => {
- let ssb
- const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
- const readAll = async (ssbClient) =>
- new Promise((resolve, reject) =>
- pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)))
- )
- const buildIndex = (messages) => {
- const tomb = new Set()
- const nodes = new Map()
- const parent = new Map()
- const child = new Map()
- for (const m of messages) {
- const k = m.key
- const v = m.value || {}
- const c = v.content
- if (!c) continue
- if (c.type === "tombstone" && c.target) { tomb.add(c.target); continue }
- if (c.type === "calendar") {
- nodes.set(k, { key: k, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
- if (c.replaces) { parent.set(k, c.replaces); child.set(c.replaces, k) }
- }
- }
- const rootOf = (id) => { let cur = id; while (parent.has(cur)) cur = parent.get(cur); return cur }
- const tipOf = (id) => { let cur = id; while (child.has(cur)) cur = child.get(cur); return cur }
- const roots = new Set()
- for (const id of nodes.keys()) roots.add(rootOf(id))
- const tipByRoot = new Map()
- for (const r of roots) tipByRoot.set(r, tipOf(r))
- return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot }
- }
- const buildCalendar = (node, rootId) => {
- const c = node.c || {}
- if (c.type !== "calendar") return null
- return {
- key: node.key,
- rootId,
- title: safeText(c.title),
- status: c.status || "OPEN",
- deadline: c.deadline || "",
- tags: Array.isArray(c.tags) ? c.tags : [],
- author: c.author || node.author,
- participants: Array.isArray(c.participants) ? c.participants : [],
- createdAt: c.createdAt || new Date(node.ts).toISOString(),
- updatedAt: c.updatedAt || null,
- tribeId: c.tribeId || null
- }
- }
- const isClosed = (calendar) => {
- if (calendar.status === "CLOSED") return true
- if (!calendar.deadline) return false
- return new Date(calendar.deadline).getTime() <= Date.now()
- }
- return {
- type: "calendar",
- async resolveRootId(id) {
- const ssbClient = await openSsb()
- const messages = await readAll(ssbClient)
- const idx = buildIndex(messages)
- let tip = id
- while (idx.child.has(tip)) tip = idx.child.get(tip)
- if (idx.tomb.has(tip)) throw new Error("Not found")
- let root = tip
- while (idx.parent.has(root)) root = idx.parent.get(root)
- return root
- },
- async resolveCurrentId(id) {
- const ssbClient = await openSsb()
- const messages = await readAll(ssbClient)
- const idx = buildIndex(messages)
- let tip = id
- while (idx.child.has(tip)) tip = idx.child.get(tip)
- if (idx.tomb.has(tip)) throw new Error("Not found")
- return tip
- },
- async createCalendar({ title, status, deadline, tags, firstDate, firstDateLabel, firstNote, intervalWeekly, intervalMonthly, intervalYearly, tribeId }) {
- const ssbClient = await openSsb()
- const userId = ssbClient.id
- const now = new Date().toISOString()
- const validStatus = ["OPEN", "CLOSED"].includes(String(status).toUpperCase()) ? String(status).toUpperCase() : "OPEN"
- if (deadline && new Date(deadline).getTime() <= Date.now()) throw new Error("Deadline must be in the future")
- if (!firstDate || new Date(firstDate).getTime() <= Date.now()) throw new Error("First date must be in the future")
- const content = {
- type: "calendar",
- title: safeText(title),
- status: validStatus,
- deadline: deadline || "",
- tags: normalizeTags(tags),
- author: userId,
- participants: [userId],
- createdAt: now,
- updatedAt: now,
- ...(tribeId ? { tribeId } : {})
- }
- const calMsg = await new Promise((resolve, reject) => {
- ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
- })
- const calendarId = calMsg.key
- const dates = expandRecurrence(firstDate, deadline, intervalWeekly, intervalMonthly, intervalYearly)
- const allDateMsgs = []
- for (const d of dates) {
- const dateMsg = await new Promise((resolve, reject) => {
- ssbClient.publish({
- type: "calendarDate",
- calendarId,
- date: d.toISOString(),
- label: safeText(firstDateLabel),
- author: userId,
- createdAt: new Date().toISOString()
- }, (err, msg) => err ? reject(err) : resolve(msg))
- })
- allDateMsgs.push(dateMsg)
- }
- if (firstNote && safeText(firstNote) && allDateMsgs.length > 0) {
- for (const dateMsg of allDateMsgs) {
- await new Promise((resolve, reject) => {
- ssbClient.publish({
- type: "calendarNote",
- calendarId,
- dateId: dateMsg.key,
- text: safeText(firstNote),
- author: userId,
- createdAt: new Date().toISOString()
- }, (err, msg) => err ? reject(err) : resolve(msg))
- })
- }
- }
- return calMsg
- },
- async updateCalendarById(id, data) {
- const tipId = await this.resolveCurrentId(id)
- const ssbClient = await openSsb()
- const userId = ssbClient.id
- return new Promise((resolve, reject) => {
- ssbClient.get(tipId, (err, item) => {
- if (err || !item?.content) return reject(new Error("Calendar not found"))
- if (item.content.author !== userId) return reject(new Error("Not the author"))
- const c = item.content
- const updated = {
- ...c,
- title: data.title !== undefined ? safeText(data.title) : c.title,
- status: data.status !== undefined ? (["OPEN","CLOSED"].includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : c.status) : c.status,
- deadline: data.deadline !== undefined ? data.deadline : c.deadline,
- tags: data.tags !== undefined ? normalizeTags(data.tags) : c.tags,
- updatedAt: new Date().toISOString(),
- replaces: tipId
- }
- const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
- ssbClient.publish(tombstone, (e1) => {
- if (e1) return reject(e1)
- ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res))
- })
- })
- })
- },
- async deleteCalendarById(id) {
- const tipId = await this.resolveCurrentId(id)
- const ssbClient = await openSsb()
- const userId = ssbClient.id
- return new Promise((resolve, reject) => {
- ssbClient.get(tipId, (err, item) => {
- if (err || !item?.content) return reject(new Error("Calendar not found"))
- if (item.content.author !== userId) return reject(new Error("Not the author"))
- const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
- ssbClient.publish(tombstone, (e) => e ? reject(e) : resolve())
- })
- })
- },
- async joinCalendar(calendarId) {
- const tipId = await this.resolveCurrentId(calendarId)
- const ssbClient = await openSsb()
- const userId = ssbClient.id
- return new Promise((resolve, reject) => {
- ssbClient.get(tipId, (err, item) => {
- if (err || !item?.content) return reject(new Error("Calendar not found"))
- const c = item.content
- const participants = Array.isArray(c.participants) ? c.participants : []
- if (participants.includes(userId)) return resolve()
- const updated = { ...c, participants: [...participants, userId], updatedAt: new Date().toISOString(), replaces: tipId }
- const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
- ssbClient.publish(tombstone, (e1) => {
- if (e1) return reject(e1)
- ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res))
- })
- })
- })
- },
- async leaveCalendar(calendarId) {
- const tipId = await this.resolveCurrentId(calendarId)
- const ssbClient = await openSsb()
- const userId = ssbClient.id
- return new Promise((resolve, reject) => {
- ssbClient.get(tipId, (err, item) => {
- if (err || !item?.content) return reject(new Error("Calendar not found"))
- const c = item.content
- if (c.author === userId) return reject(new Error("Author cannot leave"))
- const participants = Array.isArray(c.participants) ? c.participants : []
- if (!participants.includes(userId)) return resolve()
- const updated = { ...c, participants: participants.filter(p => p !== userId), updatedAt: new Date().toISOString(), replaces: tipId }
- const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
- ssbClient.publish(tombstone, (e1) => {
- if (e1) return reject(e1)
- ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res))
- })
- })
- })
- },
- async getCalendarById(id) {
- const ssbClient = await openSsb()
- const messages = await readAll(ssbClient)
- const idx = buildIndex(messages)
- let tip = id
- while (idx.child.has(tip)) tip = idx.child.get(tip)
- if (idx.tomb.has(tip)) return null
- const node = idx.nodes.get(tip)
- if (!node || node.c.type !== "calendar") return null
- let root = tip
- while (idx.parent.has(root)) root = idx.parent.get(root)
- const cal = buildCalendar(node, root)
- if (!cal) return null
- cal.isClosed = isClosed(cal)
- return cal
- },
- async listAll({ filter = "all", viewerId } = {}) {
- const ssbClient = await openSsb()
- const uid = viewerId || ssbClient.id
- const messages = await readAll(ssbClient)
- const idx = buildIndex(messages)
- const items = []
- for (const [rootId, tipId] of idx.tipByRoot.entries()) {
- if (idx.tomb.has(tipId)) continue
- const node = idx.nodes.get(tipId)
- if (!node || node.c.type !== "calendar") continue
- const cal = buildCalendar(node, rootId)
- if (!cal) continue
- cal.isClosed = isClosed(cal)
- items.push(cal)
- }
- let list = items
- if (filter === "mine") list = list.filter(c => c.author === uid)
- else if (filter === "recent") {
- const now = Date.now()
- list = list.filter(c => new Date(c.createdAt).getTime() >= now - 86400000)
- }
- else if (filter === "open") list = list.filter(c => !c.isClosed)
- else if (filter === "closed") list = list.filter(c => c.isClosed)
- return list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
- },
- async addDate(calendarId, date, label, intervalWeekly, intervalMonthly, intervalYearly, intervalDeadline) {
- const ssbClient = await openSsb()
- const userId = ssbClient.id
- const rootId = await this.resolveRootId(calendarId)
- const cal = await this.getCalendarById(rootId)
- if (!cal) throw new Error("Calendar not found")
- if (cal.status === "CLOSED" && userId !== cal.author) throw new Error("Only the author can add dates to a CLOSED calendar")
- if (!date || new Date(date).getTime() <= Date.now()) throw new Error("Date must be in the future")
- const deadlineForExpansion = (intervalDeadline && hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly)) ? intervalDeadline : cal.deadline
- const dates = expandRecurrence(date, deadlineForExpansion, intervalWeekly, intervalMonthly, intervalYearly)
- const allMsgs = []
- for (const d of dates) {
- const msg = await new Promise((resolve, reject) => {
- ssbClient.publish({
- type: "calendarDate",
- calendarId: rootId,
- date: d.toISOString(),
- label: safeText(label),
- author: userId,
- createdAt: new Date().toISOString()
- }, (err, m) => err ? reject(err) : resolve(m))
- })
- allMsgs.push(msg)
- }
- return allMsgs
- },
- async getDatesForCalendar(calendarId) {
- const rootId = await this.resolveRootId(calendarId)
- const ssbClient = await openSsb()
- const messages = await readAll(ssbClient)
- const tombstoned = new Set()
- for (const m of messages) {
- const c = (m.value || {}).content
- if (c && c.type === "tombstone" && c.target) tombstoned.add(c.target)
- }
- const dates = []
- for (const m of messages) {
- if (tombstoned.has(m.key)) continue
- const v = m.value || {}
- const c = v.content
- if (!c || c.type !== "calendarDate") continue
- if (c.calendarId !== rootId) continue
- dates.push({
- key: m.key,
- calendarId: c.calendarId,
- date: c.date,
- label: c.label || "",
- author: c.author || v.author,
- createdAt: c.createdAt || new Date(v.timestamp || 0).toISOString()
- })
- }
- dates.sort((a, b) => new Date(a.date) - new Date(b.date))
- return dates
- },
- async deleteDate(dateId, calendarId) {
- const ssbClient = await openSsb()
- const userId = ssbClient.id
- const rootId = await this.resolveRootId(calendarId)
- const cal = await this.getCalendarById(rootId)
- if (!cal) throw new Error("Calendar not found")
- const messages = await readAll(ssbClient)
- const tombstoned = new Set()
- for (const m of messages) {
- const c = (m.value || {}).content
- if (c && c.type === "tombstone" && c.target) tombstoned.add(c.target)
- }
- let dateAuthor = null
- for (const m of messages) {
- if (m.key !== dateId) continue
- const c = (m.value || {}).content
- if (!c || c.type !== "calendarDate") continue
- if (tombstoned.has(m.key)) break
- dateAuthor = c.author || (m.value || {}).author
- break
- }
- if (!dateAuthor) throw new Error("Date not found")
- if (dateAuthor !== userId && cal.author !== userId) throw new Error("Not authorized")
- for (const m of messages) {
- const c = (m.value || {}).content
- if (!c || c.type !== "calendarNote") continue
- if (tombstoned.has(m.key)) continue
- if (c.dateId !== dateId) continue
- await new Promise((resolve, reject) => {
- ssbClient.publish({ type: "tombstone", target: m.key, deletedAt: new Date().toISOString(), author: userId }, (e) => e ? reject(e) : resolve())
- })
- }
- return new Promise((resolve, reject) => {
- ssbClient.publish({ type: "tombstone", target: dateId, deletedAt: new Date().toISOString(), author: userId }, (e) => e ? reject(e) : resolve())
- })
- },
- async addNote(calendarId, dateId, text) {
- const ssbClient = await openSsb()
- const userId = ssbClient.id
- const rootId = await this.resolveRootId(calendarId)
- const cal = await this.getCalendarById(rootId)
- if (!cal) throw new Error("Calendar not found")
- if (!cal.participants.includes(userId)) throw new Error("Only participants can add notes")
- return new Promise((resolve, reject) => {
- ssbClient.publish({
- type: "calendarNote",
- calendarId: rootId,
- dateId,
- text: safeText(text),
- author: userId,
- createdAt: new Date().toISOString()
- }, (err, msg) => err ? reject(err) : resolve(msg))
- })
- },
- async deleteNote(noteId) {
- const ssbClient = await openSsb()
- const userId = ssbClient.id
- return new Promise((resolve, reject) => {
- ssbClient.get(noteId, (err, item) => {
- if (err || !item?.content) return reject(new Error("Note not found"))
- if (item.content.author !== userId) return reject(new Error("Not the author"))
- ssbClient.publish({ type: "tombstone", target: noteId, deletedAt: new Date().toISOString(), author: userId }, (e, msg) => e ? reject(e) : resolve(msg))
- })
- })
- },
- async getNotesForDate(calendarId, dateId) {
- const rootId = await this.resolveRootId(calendarId)
- const ssbClient = await openSsb()
- const messages = await readAll(ssbClient)
- const tombstoned = new Set()
- for (const m of messages) {
- const c = (m.value || {}).content
- if (c && c.type === "tombstone" && c.target) tombstoned.add(c.target)
- }
- const notes = []
- for (const m of messages) {
- const v = m.value || {}
- const c = v.content
- if (!c || c.type !== "calendarNote") continue
- if (tombstoned.has(m.key)) continue
- if (c.calendarId !== rootId || c.dateId !== dateId) continue
- notes.push({
- key: m.key,
- calendarId: c.calendarId,
- dateId: c.dateId,
- text: c.text || "",
- author: c.author || v.author,
- createdAt: c.createdAt || new Date(v.timestamp || 0).toISOString()
- })
- }
- notes.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
- return notes
- },
- async checkDueReminders() {
- if (!pmModel) return
- const ssbClient = await openSsb()
- const messages = await readAll(ssbClient)
- const now = Date.now()
- const sentMarkers = new Set()
- for (const m of messages) {
- const c = (m.value || {}).content
- if (!c || c.type !== "calendarReminderSent") continue
- sentMarkers.add(`${c.calendarId}::${c.dateId}`)
- }
- const tombstoned = new Set()
- for (const m of messages) {
- const c = (m.value || {}).content
- if (c && c.type === "tombstone" && c.target) tombstoned.add(c.target)
- }
- const dueByCalendar = new Map()
- for (const m of messages) {
- if (tombstoned.has(m.key)) continue
- const v = m.value || {}
- const c = v.content
- if (!c || c.type !== "calendarDate") continue
- if (new Date(c.date).getTime() > now) continue
- if (sentMarkers.has(`${c.calendarId}::${m.key}`)) continue
- const entry = { key: m.key, calendarId: c.calendarId, date: c.date, label: c.label || "" }
- const list = dueByCalendar.get(c.calendarId) || []
- list.push(entry)
- dueByCalendar.set(c.calendarId, list)
- }
- const publishMarker = (calendarId, dateId) => new Promise((resolve, reject) => {
- ssbClient.publish({
- type: "calendarReminderSent",
- calendarId,
- dateId,
- sentAt: new Date().toISOString()
- }, (err) => err ? reject(err) : resolve())
- })
- for (const [calendarId, list] of dueByCalendar.entries()) {
- try {
- list.sort((a, b) => new Date(b.date) - new Date(a.date))
- const primary = list[0]
- const cal = await this.getCalendarById(calendarId)
- if (!cal) continue
- const participants = cal.participants.filter(p => typeof p === "string" && p.length > 0)
- if (participants.length > 0) {
- const notesForDay = await this.getNotesForDate(calendarId, primary.key)
- const notesBlock = notesForDay.length > 0
- ? notesForDay.map(n => ` - ${n.text}`).join("\n\n")
- : " (no notes)"
- const subject = `Calendar Reminder: ${cal.title}`
- const text =
- `Reminder from: ${cal.author}\n` +
- `Title: ${cal.title}\n` +
- `Date: ${primary.label || primary.date}\n\n` +
- `Notes for this day:\n\n${notesBlock}\n\n` +
- `Visit Calendar: /calendars/${cal.rootId}`
- const chunkSize = 6
- for (let i = 0; i < participants.length; i += chunkSize) {
- await pmModel.sendMessage(participants.slice(i, i + chunkSize), subject, text)
- }
- }
- for (const dd of list) {
- try { await publishMarker(calendarId, dd.key) } catch (_) {}
- }
- } catch (_) {}
- }
- }
- }
- }
|