/** * Notification storage operations * @module storage/notifications */ /** * Add a notification (uses atomic upsert for deduplication) * @param {object} collections - MongoDB collections * @param {object} notification - Notification data * @param {string} notification.uid - Activity ID or constructed dedup key * @param {string} notification.type - "like" | "boost" | "follow" | "mention" | "reply" * @param {string} notification.actorUrl - Remote actor URL * @param {string} notification.actorName - Display name * @param {string} notification.actorPhoto - Avatar URL * @param {string} notification.actorHandle - @user@instance * @param {string} [notification.targetUrl] - The post they liked/boosted/replied to * @param {string} [notification.targetName] - Post title * @param {object} [notification.content] - { text, html } for mentions/replies * @param {Date} notification.published - Activity timestamp (kept as Date for sort) * @param {string} notification.createdAt - ISO string creation timestamp * @returns {Promise} Created or existing notification */ export async function addNotification(collections, notification) { const { ap_notifications } = collections; const result = await ap_notifications.updateOne( { uid: notification.uid }, { $setOnInsert: { ...notification, read: false, }, }, { upsert: true }, ); if (result.upsertedCount > 0) { return await ap_notifications.findOne({ uid: notification.uid }); } // Return existing document if it was a duplicate return await ap_notifications.findOne({ uid: notification.uid }); } /** * Get notifications with cursor-based pagination * @param {object} collections - MongoDB collections * @param {object} options - Query options * @param {string} [options.before] - Before cursor (published date) * @param {number} [options.limit=20] - Items per page * @param {boolean} [options.unreadOnly=false] - Show only unread notifications * @param {string} [options.type] - Filter by notification type (like, boost, follow, reply, mention) * @returns {Promise} { items, before } */ export async function getNotifications(collections, options = {}) { const { ap_notifications } = collections; const parsedLimit = Number.parseInt(options.limit, 10); const limit = Math.min( Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 20, 100, ); const query = {}; // Type filter if (options.type) { // "reply" tab shows replies only; mentions have their own "mention" tab // "follow" tab shows both follows and follow_requests if (options.type === "reply") { query.type = "reply"; } else if (options.type === "follow") { query.type = { $in: ["follow", "follow_request"] }; } else { query.type = options.type; } } // Unread filter if (options.unreadOnly) { query.read = false; } // Cursor pagination — published is stored as ISO string, so compare // as strings (lexicographic ISO 8601 comparison is correct for dates) if (options.before) { query.published = { $lt: options.before }; } const rawItems = await ap_notifications .find(query) .sort({ published: -1 }) .limit(limit) .toArray(); // Normalize published dates to ISO strings for Nunjucks | date filter const items = rawItems.map((item) => ({ ...item, published: item.published instanceof Date ? item.published.toISOString() : item.published, })); // Generate cursor for next page (only if full page returned = more may exist) const before = items.length === limit ? items[items.length - 1].published : null; return { items, before, }; } /** * Get notification counts grouped by type * @param {object} collections - MongoDB collections * @param {boolean} [unreadOnly=false] - Count only unread notifications * @returns {Promise} Counts per type { all, reply, like, boost, follow } */ export async function getNotificationCountsByType(collections, unreadOnly = false) { const { ap_notifications } = collections; const matchStage = unreadOnly ? { $match: { read: false } } : { $match: {} }; const pipeline = [ matchStage, { $group: { _id: "$type", count: { $sum: 1 } } }, ]; const results = await ap_notifications.aggregate(pipeline).toArray(); const counts = { all: 0, reply: 0, mention: 0, like: 0, boost: 0, follow: 0, dm: 0, report: 0 }; for (const { _id, count } of results) { counts.all += count; if (_id === "reply") { counts.reply += count; } else if (_id === "mention") { counts.mention += count; } else if (_id === "follow_request") { counts.follow += count; } else if (counts[_id] !== undefined) { counts[_id] = count; } } return counts; } /** * Get count of unread notifications * @param {object} collections - MongoDB collections * @returns {Promise} Unread notification count */ export async function getUnreadNotificationCount(collections) { const { ap_notifications } = collections; return await ap_notifications.countDocuments({ read: false }); } /** * Mark notifications as read * @param {object} collections - MongoDB collections * @param {string[]} uids - Notification UIDs to mark read * @returns {Promise} Update result */ export async function markNotificationsRead(collections, uids) { const { ap_notifications } = collections; return await ap_notifications.updateMany( { uid: { $in: uids } }, { $set: { read: true } }, ); } /** * Mark all notifications as read * @param {object} collections - MongoDB collections * @returns {Promise} Update result */ export async function markAllNotificationsRead(collections) { const { ap_notifications } = collections; return await ap_notifications.updateMany({}, { $set: { read: true } }); } /** * Delete all notifications * @param {object} collections - MongoDB collections * @returns {Promise} Delete result */ export async function clearAllNotifications(collections) { const { ap_notifications } = collections; return await ap_notifications.deleteMany({}); } /** * Delete a single notification by UID * @param {object} collections - MongoDB collections * @param {string} uid - Notification UID * @returns {Promise} Delete result */ export async function deleteNotification(collections, uid) { const { ap_notifications } = collections; return await ap_notifications.deleteOne({ uid }); } /** * Get direct message conversations grouped by peer actor. * Returns conversations sorted by most-recent message, newest first. * @param {object} collections - MongoDB collections * @returns {Promise} Array of conversation objects */ export async function getDirectConversations(collections) { const { ap_notifications } = collections; const rawItems = await ap_notifications .find({ isDirect: true }) .sort({ published: -1 }) .limit(200) .toArray(); // Group by peer (senderActorUrl or actorUrl) const byPeer = new Map(); for (const item of rawItems) { const peerUrl = item.senderActorUrl || item.actorUrl || ""; if (!peerUrl) continue; if (!byPeer.has(peerUrl)) { byPeer.set(peerUrl, { peerUrl, peerName: item.actorName || "", peerHandle: item.actorHandle || "", peerPhoto: item.actorPhoto || "", hasUnread: false, messages: [], }); } const conv = byPeer.get(peerUrl); if (!item.read) conv.hasUnread = true; conv.messages.push({ direction: "inbound", url: item.url || "", content: item.content || {}, published: item.published instanceof Date ? item.published.toISOString() : item.published || "", }); } // Each conversation's messages arrived newest-first; reverse to chrono order const conversations = [...byPeer.values()]; for (const conv of conversations) { conv.messages.reverse(); } // Sort conversations by most-recent message (newest first) conversations.sort((a, b) => { const aLast = a.messages.at(-1)?.published || ""; const bLast = b.messages.at(-1)?.published || ""; return bLast < aLast ? -1 : bLast > aLast ? 1 : 0; }); return conversations; }