/** * Staff notification service – reply alerts and unclaimed ticket reminders. * * notifyStaffOfReply: posts in the claimer's notification channel when a * non-staff user replies, respecting a per-staff cooldown. * * notifyAllStaffUnclaimed: background job that checks unclaimed tickets * against configurable hour thresholds and posts one alert per threshold * per ticket (highest newly-crossed threshold only). */ const { mongoose } = require('../db-connection'); const { CONFIG, parseThresholdString } = require('../config'); const { increment } = require('./patternStore'); const { enqueueSend } = require('./channelQueue'); const Ticket = mongoose.model('Ticket'); const StaffNotification = mongoose.model('StaffNotification'); // In-memory cooldown map: `${userId}:${ticketId}` -> last notified timestamp const replyCooldowns = new Map(); /** * Notify the claiming staff member when a non-staff user replies. * Respects the staff member's cooldownHours setting (default 1h). * Posts in their notification channel if one exists. */ async function notifyStaffOfReply(guild, ticket, message) { if (!ticket.claimerId) return; const staffRecord = await StaffNotification.findOne({ userId: ticket.claimerId }).lean(); if (!staffRecord?.channelId) return; const cooldownMs = (staffRecord.cooldownHours || 1) * 60 * 60 * 1000; const cooldownKey = `${ticket.claimerId}:${ticket.gmailThreadId}`; const lastNotified = replyCooldowns.get(cooldownKey) || 0; if (Date.now() - lastNotified < cooldownMs) return; const notifChannel = await guild.channels.fetch(staffRecord.channelId).catch(() => null); if (!notifChannel) return; const jumpLink = `https://discord.com/channels/${guild.id}/${message.channel.id}/${message.id}`; const snippet = message.content?.slice(0, 300) || '(no text)'; await enqueueSend( notifChannel, `New reply in **${message.channel.name}** from ${message.author.tag}:\n> ${snippet}\n[Jump to message](${jumpLink})` ); replyCooldowns.set(cooldownKey, Date.now()); } /** * Background job: check all open unclaimed tickets against hour thresholds. * For each ticket, find the highest threshold that has been crossed but not * yet recorded. Post one notification per ticket per run (the highest new * threshold) into every staff notification channel. */ async function notifyAllStaffUnclaimed(client) { const rawThresholds = (CONFIG.NOTIFICATION_THRESHOLDS && CONFIG.NOTIFICATION_THRESHOLDS.unclaimed_reminder) || []; const thresholds = rawThresholds .map(parseThresholdString) .filter(n => Number.isFinite(n) && n >= 0) .map(ms => ms / (60 * 60 * 1000)); if (thresholds.length === 0) return; const sorted = [...thresholds].sort((a, b) => a - b); const now = Date.now(); const unclaimedTickets = await Ticket.find({ status: 'open', claimedBy: null, createdAt: { $ne: null } }).lean(); if (unclaimedTickets.length === 0) return; const staffRecords = await StaffNotification.find({ channelId: { $ne: null } }).lean(); if (staffRecords.length === 0) return; const guild = CONFIG.DISCORD_GUILD_ID ? client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID) : client.guilds.cache.first(); if (!guild) return; for (const ticket of unclaimedTickets) { const ageMs = now - new Date(ticket.createdAt).getTime(); const ageHours = ageMs / (60 * 60 * 1000); const alreadySent = ticket.unclaimedRemindersSent || []; // Find thresholds crossed but not yet sent const crossedNew = sorted.filter(t => ageHours >= t && !alreadySent.includes(t)); if (crossedNew.length === 0) continue; // Only send the highest newly-crossed threshold const highest = crossedNew[crossedNew.length - 1]; const channelName = ticket.discordThreadId ? `<#${ticket.discordThreadId}>` : `ticket #${ticket.ticketNumber}`; const hoursAgo = Math.floor(ageHours); const alertMsg = `Unclaimed ticket alert: ${channelName} has been unclaimed for ${hoursAgo}+ hour(s) (${highest}h threshold).`; for (const rec of staffRecords) { const chan = await guild.channels.fetch(rec.channelId).catch(() => null); if (chan) { await enqueueSend(chan, alertMsg).catch(e => console.error('Unclaimed notify send:', e)); increment('staff_stale_pings', rec.userId, 'today'); increment('staff_stale_pings', rec.userId, 'week'); } } await Ticket.updateOne( { gmailThreadId: ticket.gmailThreadId }, { $addToSet: { unclaimedRemindersSent: highest } } ); } } module.exports = { notifyStaffOfReply, notifyAllStaffUnclaimed };