const path = require('path') const fs = require('fs-extra') const MessageModel = require('../models/MessageModel') const ALLOWED_SELF_DESTRUCT_SECONDS = [10, 30, 60, 300, 3600] const DELETE_WINDOW_MS = 24 * 60 * 60 * 1000 function parseSelfDestructSeconds(raw) { if (raw == null || raw === '' || raw === '0' || raw === 0) return null const n = parseInt(String(raw), 10) if (!Number.isFinite(n) || !ALLOWED_SELF_DESTRUCT_SECONDS.includes(n)) return null return n } function buildExpiresAt(seconds) { if (!seconds) return undefined return new Date(Date.now() + seconds * 1000) } function activeMessageFilter(baseFilter) { return { $and: [ baseFilter, { $or: [ { selfDestructSeconds: { $exists: true, $ne: null }, selfDestructStartedAt: { $exists: false } }, { selfDestructSeconds: { $exists: true, $ne: null }, selfDestructStartedAt: { $exists: true, $ne: null }, $or: [ { expiresAt: { $exists: false } }, { expiresAt: null }, { expiresAt: { $gt: new Date() } } ] }, { $and: [ { $or: [ { selfDestructSeconds: { $exists: false } }, { selfDestructSeconds: null } ] }, { $or: [ { expiresAt: { $exists: false } }, { expiresAt: null }, { expiresAt: { $gt: new Date() } } ] } ] } ] } ] } } function isWithinDeleteWindow(createdAt) { if (!createdAt) return false return Date.now() - new Date(createdAt).getTime() <= DELETE_WINDOW_MS } async function removeMessageFiles(messages) { for (const msg of messages) { if (!msg.file) continue const relative = String(msg.file).replace(/^\//, '') const filePath = path.join(__dirname, '../storage', relative) if (fs.existsSync(filePath)) { await fs.remove(filePath).catch(() => {}) } } } async function deleteMessagesAndNotify(io, messages, options = {}) { if (!messages?.length) return [] await removeMessageFiles(messages) const deletedIds = messages.map((m) => String(m._id)) await MessageModel.deleteMany({ _id: { $in: deletedIds } }) if (io) { const byThread = new Map() messages.forEach((m) => { const s = String(m.senderId) const r = String(m.receiverId) const key = [s, r].sort().join(':') if (!byThread.has(key)) byThread.set(key, { s, r, ids: [] }) byThread.get(key).ids.push(String(m._id)) }) byThread.forEach(({ s, r, ids }) => { io.to(`chat:${s}:${r}`) .to(`chat:${r}:${s}`) .emit('messagesDeleted', { messageIds: ids, deletedBy: options.deletedBy, reason: options.reason || (options.deletedBy ? 'manual' : 'expired') }) io.to(`user:${s}`).to(`user:${r}`).emit('chatListUpdate', { senderId: s, receiverId: r }) }) } return deletedIds } async function purgeExpiredMessages(io) { try { const expired = await MessageModel.find({ expiresAt: { $lte: new Date() } }) if (!expired.length) return [] return deleteMessagesAndNotify(io, expired) } catch (err) { console.error('purgeExpiredMessages error:', err) return [] } } const scheduledTimers = new Map() function scheduleMessageExpiry(io, message) { if (!message?.expiresAt || !message._id) return const id = String(message._id) if (scheduledTimers.has(id)) { clearTimeout(scheduledTimers.get(id)) } const ms = new Date(message.expiresAt).getTime() - Date.now() if (ms <= 0) { MessageModel.findById(id) .then((doc) => doc && deleteMessagesAndNotify(io, [doc])) .catch(() => {}) return } const timer = setTimeout(async () => { scheduledTimers.delete(id) try { const doc = await MessageModel.findById(id) if (doc && doc.expiresAt && new Date(doc.expiresAt) <= new Date()) { await deleteMessagesAndNotify(io, [doc]) } } catch (err) { console.error('scheduleMessageExpiry error:', err) } }, ms) scheduledTimers.set(id, timer) } async function startSelfDestructOnView(io, message, viewerId) { if (!message?.selfDestructSeconds || message.selfDestructStartedAt) { return message } const viewer = String(viewerId) const receiver = String(message.receiverId) if (viewer !== receiver) return message const expiresAt = buildExpiresAt(message.selfDestructSeconds) const updated = await MessageModel.findByIdAndUpdate( message._id, { $set: { selfDestructStartedAt: new Date(), expiresAt } }, { new: true } ) if (updated) { scheduleMessageExpiry(io, updated) if (io) { const sid = String(updated.senderId) const rid = String(updated.receiverId) io.to(`chat:${sid}:${rid}`) .to(`chat:${rid}:${sid}`) .emit('messageSelfDestructStarted', { messageId: String(updated._id), expiresAt: updated.expiresAt.toISOString(), selfDestructSeconds: updated.selfDestructSeconds }) } } return updated || message } async function markSelfDestructViewed(io, messageId, viewerId) { const msg = await MessageModel.findById(messageId) if (!msg || !msg.selfDestructSeconds || msg.selfDestructStartedAt) { return { message: msg } } const updated = await startSelfDestructOnView(io, msg, viewerId) return { message: updated, expiresAt: updated?.expiresAt ? new Date(updated.expiresAt).toISOString() : undefined } } async function startPendingSelfDestructForViewer(io, viewerId, peerId) { const viewer = String(viewerId) const peer = String(peerId) const pending = await MessageModel.find({ receiverId: viewer, senderId: peer, selfDestructSeconds: { $exists: true, $ne: null }, selfDestructStartedAt: { $exists: false } }) await Promise.all( pending.map((msg) => startSelfDestructOnView(io, msg, viewer)) ) } module.exports = { ALLOWED_SELF_DESTRUCT_SECONDS, DELETE_WINDOW_MS, parseSelfDestructSeconds, buildExpiresAt, activeMessageFilter, isWithinDeleteWindow, purgeExpiredMessages, scheduleMessageExpiry, deleteMessagesAndNotify, startSelfDestructOnView, markSelfDestructViewed, startPendingSelfDestructForViewer }