const fs = require('fs'); const path = require('path'); const pull = require('../server/node_modules/pull-stream'); const moment = require('../server/node_modules/moment'); const agendaConfigPath = require('../configs/state-manager').statePath('agenda-config.json'); const { getConfig } = require('../configs/config-manager.js'); const { buildValidatedTombstoneSet } = require('./tombstone_validator'); const { readTyped } = require('./typed_log'); const { versionGroups, versionEntry } = require('../backend/dedupe'); const logLimit = getConfig().ssbLogStream?.limit || 1000; function readAgendaConfig() { if (!fs.existsSync(agendaConfigPath)) { fs.writeFileSync(agendaConfigPath, JSON.stringify({ discardedItems: [] })); } return JSON.parse(fs.readFileSync(agendaConfigPath)); } function writeAgendaConfig(cfg) { fs.writeFileSync(agendaConfigPath, JSON.stringify(cfg, null, 2)); } module.exports = ({ cooler, campaignsModel = null, logisticsModel = null, calendarsModel, eventsModel, tasksModel, marketModel, jobsModel, projectsModel, industryModel, housingModel, schoolModel }) => { let ssb; const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; }; const STATUS_ORDER = ['FOR SALE', 'OPEN', 'RESERVED', 'CLOSED', 'SOLD']; const sIdx = s => STATUS_ORDER.indexOf(String(s || '').toUpperCase()); const fetchItems = async (targetType) => { const ssbClient = await openSsb(); const msgs = await readTyped(ssbClient, [targetType, 'tombstone'], { limit: logLimit }); const tomb = buildValidatedTombstoneSet(msgs); const nodes = new Map(); const versions = []; for (const m of msgs) { const k = m.key; const v = m.value; const c = v?.content; if (!c) continue; if (c.type === 'tombstone') continue; if (c.type !== targetType) continue; if (c.encryptedPayload) continue; nodes.set(k, { key: k, ts: v.timestamp || 0, author: v.author, content: c }); versions.push(versionEntry(m)); } const vg = versionGroups(versions); const groups = new Map(); for (const r of vg.roots) groups.set(r, new Set(vg.groups.get(r).filter(id => !vg.isForeign(id)))); const statusOrder = ['FOR SALE', 'OPEN', 'RESERVED', 'CLOSED', 'SOLD']; const sIdx = s => statusOrder.indexOf(String(s || '').toUpperCase()); const out = []; for (const [root, ids] of groups.entries()) { const items = Array.from(ids).map(id => nodes.get(id)).filter(n => n && !tomb.has(n.key)); if (!items.length) continue; const tip = nodes.get(vg.tipOf(root)); if (tomb.has(tip.key)) continue; if (targetType === 'market') { let chosen = items[0]; for (const n of items) { const a = sIdx(n.content.status); const b = sIdx(chosen.content.status); if (a > b || (a === b && n.ts > chosen.ts)) chosen = n; } const c = chosen.content; let status = c.status; if (c.deadline) { const dl = moment(c.deadline); if (dl.isValid() && dl.isBefore(moment()) && String(status).toUpperCase() !== 'SOLD') status = 'DISCARDED'; } if (status === 'FOR SALE' && (c.stock || 0) === 0) continue; out.push({ ...c, status, author: chosen.author, id: chosen.key, tipId: chosen.key, createdAt: c.createdAt || chosen.ts }); continue; } if (targetType === 'job') { const latest = items.sort((a, b) => b.ts - a.ts)[0]; const withSubsNode = items .filter(n => Array.isArray(n.content.subscribers)) .sort((a, b) => b.ts - a.ts)[0]; const subscribers = withSubsNode ? withSubsNode.content.subscribers : []; const latestWithStatus = items .filter(n => typeof n.content.status !== 'undefined') .sort((a, b) => b.ts - a.ts)[0]; const resolvedStatus = latestWithStatus ? latestWithStatus.content.status : latest.content.status; const c = { ...latest.content, status: resolvedStatus, subscribers }; out.push({ ...c, author: latest.author, id: latest.key, tipId: latest.key, createdAt: c.createdAt || latest.ts }); continue; } out.push({ ...tip.content, author: tip.author, id: tip.key, tipId: tip.key, createdAt: tip.content.createdAt || tip.ts }); } return out; }; return { async listAgenda(filter = 'all') { const agendaConfig = readAgendaConfig(); const discardedItems = agendaConfig.discardedItems || []; const removedItems = agendaConfig.removedItems || []; const ssbClient = await openSsb(); const userId = ssbClient.id; const normalize = (arr) => arr.map(it => ({ ...it, key: it.id || it.key, createdAt: it.createdAt || new Date().toISOString(), tipId: it.tipId || it.id || it.key })); const eventsViaModel = eventsModel && typeof eventsModel.listAll === 'function' ? eventsModel.listAll(null, 'all').then(normalize).catch(() => []) : fetchItems('event'); const calendarsViaModel = calendarsModel && typeof calendarsModel.listAll === 'function' ? calendarsModel.listAll({ filter: 'all', viewerId: userId }).then(normalize).catch(() => []) : fetchItems('calendar'); const tasksViaModel = tasksModel && typeof tasksModel.listAll === 'function' ? tasksModel.listAll().then(normalize).catch(() => []) : fetchItems('task'); const marketViaModel = marketModel && typeof marketModel.listAllItems === 'function' ? marketModel.listAllItems('all').then(normalize).catch(() => []) : fetchItems('market'); const jobsViaModel = jobsModel && typeof jobsModel.listJobs === 'function' ? jobsModel.listJobs('ALL', userId).then(normalize).catch(() => []) : fetchItems('job'); const projectsViaModel = projectsModel && typeof projectsModel.listProjects === 'function' ? projectsModel.listProjects('all').then(normalize).catch(() => []) : fetchItems('project'); const industryViaModel = industryModel && typeof industryModel.listMyBuilds === 'function' ? industryModel.listMyBuilds().then(normalize).catch(() => []) : []; const housingViaModel = housingModel && typeof housingModel.listHousing === 'function' ? housingModel.listHousing('ALL', userId).then(normalize).catch(() => []) : []; const schoolViaModel = schoolModel && typeof schoolModel.listCourses === 'function' ? schoolModel.listCourses('ALL', userId, {}).then(normalize).catch(() => []) : []; const campaignsViaModel = campaignsModel && typeof campaignsModel.listAll === 'function' ? campaignsModel.listAll({ filter: 'all' }).catch(() => []) : []; const logisticsViaModel = logisticsModel && typeof logisticsModel.listAll === 'function' ? logisticsModel.listAll({ filter: 'all' }).catch(() => []) : []; const [tasksAll, eventsAll, transfersAll, tribesAll, marketAll, reportsAll, jobsAll, projectsAll, calendarsAll, industryAll, housingAll, schoolAll, campaignsAll, logisticsAll] = await Promise.all([ tasksViaModel, eventsViaModel, fetchItems('transfer'), fetchItems('tribe'), marketViaModel, fetchItems('report'), jobsViaModel, projectsViaModel, calendarsViaModel, industryViaModel, housingViaModel, schoolViaModel, campaignsViaModel, logisticsViaModel ]); const tasks = tasksAll.filter(c => Array.isArray(c.assignees) && c.assignees.includes(userId)).map(t => ({ ...t, type: 'task' })); const events = eventsAll.filter(c => Array.isArray(c.attendees) && c.attendees.includes(userId)).map(e => ({ ...e, type: 'event' })); const transfers = transfersAll.filter(c => c.from === userId || c.to === userId).map(tr => ({ ...tr, type: 'transfer' })); const tribes = tribesAll.filter(c => Array.isArray(c.members) && c.members.includes(userId)).map(t => ({ ...t, type: 'tribe', title: t.title })); const marketItems = marketAll.filter(c => c.seller === userId || (Array.isArray(c.auctions_poll) && c.auctions_poll.some(b => String(b).split(':')[0] === userId)) ).map(m => ({ ...m, type: 'market' })); const reports = reportsAll.filter(c => c.author === userId || (Array.isArray(c.confirmations) && c.confirmations.includes(userId))).map(r => ({ ...r, type: 'report' })); const jobs = jobsAll.filter(c => c.author === userId || (Array.isArray(c.subscribers) && c.subscribers.includes(userId))).map(j => ({ ...j, type: 'job', title: j.title })); const projects = projectsAll.filter(p => p.author === userId || (Array.isArray(p.followers) && p.followers.includes(userId)) || (Array.isArray(p.backers) && p.backers.some(b => b && b.userId === userId)) || (Array.isArray(p.bounties) && p.bounties.some(b => b && b.claimedBy === userId))).map(p => ({ ...p, type: 'project' })); const industryBuilds = industryAll.map(b => ({ ...b, type: 'industry', title: b.title })); const schoolCourses = schoolAll .filter(c => c.author === userId || (Array.isArray(c.students) && c.students.includes(userId)) || (Array.isArray(c.pending) && c.pending.some(pn => pn.author === userId))) .map(c => ({ ...c, type: 'schoolCourse', title: c.title, date: c.startDate || c.createdAt, teaching: c.author === userId })); const campaigns = (Array.isArray(campaignsAll) ? campaignsAll : []) .filter(c => c && c.deadline && (c.author === userId || (Array.isArray(c.signers) && c.signers.includes(userId)))) .map(c => ({ ...c, type: 'campaign', date: c.deadline, status: c.closed ? 'CLOSED' : 'OPEN' })); const routes = (Array.isArray(logisticsAll) ? logisticsAll : []) .filter(r => r && r.date && (r.author === userId || !!r.myBooking)) .map(r => ({ ...r, type: 'logisticsRoute', status: r.status === 'CLOSED' ? 'CLOSED' : 'OPEN' })); const housingPlaces = housingAll .filter(h => h.author === userId || (Array.isArray(h.requests) && h.requests.includes(userId))) .map(h => ({ ...h, type: 'housing', date: h.availableFrom || h.createdAt, requested: Array.isArray(h.requests) && h.requests.includes(userId) })); const myCalendars = calendarsAll .filter(c => c.author === userId || (Array.isArray(c.participants) && c.participants.includes(userId))); const calendars = myCalendars.map(c => ({ ...c, id: c.rootId || c.id || c.key, type: 'calendar' })); const calendarDates = []; if (calendarsModel && typeof calendarsModel.getDatesForCalendar === 'function') { const datesByCal = await Promise.all(myCalendars.map(cal => calendarsModel.getDatesForCalendar(cal.rootId || cal.id || cal.key).catch(() => []))); for (const [ci, cal] of myCalendars.entries()) { try { const dates = datesByCal[ci]; for (const d of (dates || [])) { calendarDates.push({ type: 'calendarDate', id: d.key || d.id, title: d.label || cal.title || 'Calendar date', calendarTitle: cal.title || '', calendarId: cal.rootId || cal.id || cal.key, date: d.date || d.createdAt, label: d.label || '', author: cal.author, createdAt: d.createdAt || cal.createdAt, status: 'OPEN' }); } } catch (_) {} } } let combined = [ ...tasks, ...events, ...transfers, ...tribes, ...marketItems, ...reports, ...jobs, ...projects, ...industryBuilds, ...housingPlaces, ...schoolCourses, ...campaigns, ...routes, ...calendars, ...calendarDates ].filter(i => !removedItems.includes(i.id)); let filtered; if (filter === 'discarded') { filtered = combined.filter(i => discardedItems.includes(i.id)); } else { filtered = combined.filter(i => !discardedItems.includes(i.id)); if (filter === 'tasks') filtered = filtered.filter(i => i.type === 'task'); else if (filter === 'events') filtered = filtered.filter(i => i.type === 'event'); else if (filter === 'transfers') filtered = filtered.filter(i => i.type === 'transfer'); else if (filter === 'tribes') filtered = filtered.filter(i => i.type === 'tribe'); else if (filter === 'market') filtered = filtered.filter(i => i.type === 'market'); else if (filter === 'reports') filtered = filtered.filter(i => i.type === 'report'); else if (filter === 'open') filtered = filtered.filter(i => String(i.status).toUpperCase() === 'OPEN'); else if (filter === 'closed') filtered = filtered.filter(i => String(i.status).toUpperCase() === 'CLOSED'); else if (filter === 'jobs') filtered = filtered.filter(i => i.type === 'job'); else if (filter === 'projects') filtered = filtered.filter(i => i.type === 'project'); else if (filter === 'industry') filtered = filtered.filter(i => i.type === 'industry'); else if (filter === 'housing') filtered = filtered.filter(i => i.type === 'housing'); else if (filter === 'school') filtered = filtered.filter(i => i.type === 'schoolCourse'); else if (filter === 'campaigns') filtered = filtered.filter(i => i.type === 'campaign'); else if (filter === 'logistics') filtered = filtered.filter(i => i.type === 'logisticsRoute'); else if (filter === 'calendars') filtered = filtered.filter(i => i.type === 'calendar' || i.type === 'calendarDate'); else if (filter === 'today') { const startOfDay = new Date(); startOfDay.setHours(0, 0, 0, 0); const endOfDay = new Date(); endOfDay.setHours(23, 59, 59, 999); filtered = filtered.filter(i => { const d = new Date(i.date || i.startTime || i.deadline || 0).getTime(); return d >= startOfDay.getTime() && d <= endOfDay.getTime(); }); } else if (filter === 'upcoming') { const now = Date.now(); filtered = filtered.filter(i => { const d = new Date(i.date || i.startTime || i.deadline || 0).getTime(); return d > now; }); } else if (filter === 'overdue') { const startOfDay = new Date(); startOfDay.setHours(0, 0, 0, 0); filtered = filtered.filter(i => { const d = new Date(i.date || i.startTime || i.deadline || 0).getTime(); const open = String(i.status || 'OPEN').toUpperCase() === 'OPEN'; return d > 0 && d < startOfDay.getTime() && open; }); } } filtered.sort((a, b) => { const dateA = a.date || a.startTime || a.deadline || a.createdAt || 0; const dateB = b.date || b.startTime || b.deadline || b.createdAt || 0; return new Date(dateA) - new Date(dateB); }); const mainItems = combined.filter(i => !discardedItems.includes(i.id)); const discarded = combined.filter(i => discardedItems.includes(i.id)); const startOfDay = new Date(); startOfDay.setHours(0, 0, 0, 0); const endOfDay = new Date(); endOfDay.setHours(23, 59, 59, 999); const now = Date.now(); const itemTs = (i) => new Date(i.date || i.startTime || i.deadline || 0).getTime(); return { items: filtered, counts: { all: mainItems.length, open: mainItems.filter(i => String(i.status).toUpperCase() === 'OPEN').length, closed: mainItems.filter(i => String(i.status).toUpperCase() === 'CLOSED').length, tasks: mainItems.filter(i => i.type === 'task').length, events: mainItems.filter(i => i.type === 'event').length, transfers: mainItems.filter(i => i.type === 'transfer').length, tribes: mainItems.filter(i => i.type === 'tribe').length, market: mainItems.filter(i => i.type === 'market').length, reports: mainItems.filter(i => i.type === 'report').length, jobs: mainItems.filter(i => i.type === 'job').length, projects: mainItems.filter(i => i.type === 'project').length, industry: mainItems.filter(i => i.type === 'industry').length, housing: mainItems.filter(i => i.type === 'housing').length, school: mainItems.filter(i => i.type === 'schoolCourse').length, campaigns: mainItems.filter(i => i.type === 'campaign').length, logistics: mainItems.filter(i => i.type === 'logisticsRoute').length, calendars: mainItems.filter(i => i.type === 'calendar' || i.type === 'calendarDate').length, today: mainItems.filter(i => { const d = itemTs(i); return d >= startOfDay.getTime() && d <= endOfDay.getTime(); }).length, upcoming: mainItems.filter(i => itemTs(i) > now).length, overdue: mainItems.filter(i => { const d = itemTs(i); return d > 0 && d < startOfDay.getTime() && String(i.status || 'OPEN').toUpperCase() === 'OPEN'; }).length, discarded: discarded.length } }; }, async discardItem(itemId) { const agendaConfig = readAgendaConfig(); if (!agendaConfig.discardedItems.includes(itemId)) { agendaConfig.discardedItems.push(itemId); writeAgendaConfig(agendaConfig); } }, async restoreItem(itemId) { const agendaConfig = readAgendaConfig(); agendaConfig.discardedItems = agendaConfig.discardedItems.filter(id => id !== itemId); writeAgendaConfig(agendaConfig); }, async removeItem(itemId) { const agendaConfig = readAgendaConfig(); agendaConfig.discardedItems = (agendaConfig.discardedItems || []).filter(id => id !== itemId); agendaConfig.removedItems = agendaConfig.removedItems || []; if (!agendaConfig.removedItems.includes(itemId)) agendaConfig.removedItems.push(itemId); writeAgendaConfig(agendaConfig); } }; };