150 lines
5.6 KiB
JavaScript
150 lines
5.6 KiB
JavaScript
/**
|
||
* 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 { assertKeysRegistered } = require('./notificationRegistry');
|
||
const { isEnabled } = require('./notificationEnabled');
|
||
|
||
// Alert key this module drives. Registered to fail fast on drift.
|
||
const UNCLAIMED_ALERT_KEYS = ['unclaimed_reminder'];
|
||
assertKeysRegistered('staffNotifications', UNCLAIMED_ALERT_KEYS);
|
||
|
||
const Ticket = mongoose.model('Ticket');
|
||
const StaffNotification = mongoose.model('StaffNotification');
|
||
|
||
// In-memory cooldown map: `${userId}:${ticketId}` -> last notified timestamp
|
||
const replyCooldowns = new Map();
|
||
|
||
const REPLY_COOLDOWN_SWEEP_TTL_MS = 48 * 60 * 60 * 1000;
|
||
const REPLY_COOLDOWN_SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
||
|
||
function sweepReplyCooldowns(now = Date.now()) {
|
||
const cutoff = now - REPLY_COOLDOWN_SWEEP_TTL_MS;
|
||
for (const [key, ts] of replyCooldowns.entries()) {
|
||
if (ts < cutoff) replyCooldowns.delete(key);
|
||
}
|
||
}
|
||
|
||
function startSweeps(trackInterval) {
|
||
const handle = setInterval(() => sweepReplyCooldowns(), REPLY_COOLDOWN_SWEEP_INTERVAL_MS);
|
||
if (typeof handle.unref === 'function') handle.unref();
|
||
if (typeof trackInterval === 'function') trackInterval(handle);
|
||
return handle;
|
||
}
|
||
|
||
/**
|
||
* 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) {
|
||
if (!isEnabled('unclaimed_reminder')) return;
|
||
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();
|
||
|
||
// Bounded per-tick: oldest-first, capped at 500. A backlog larger than 500
|
||
// gets drained in subsequent 30-minute ticks rather than one long run.
|
||
const unclaimedTickets = await Ticket.find({
|
||
status: 'open',
|
||
claimedBy: null,
|
||
createdAt: { $ne: null }
|
||
}).sort({ createdAt: 1 }).limit(500).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 alertMsg = `[${highest}h+ unclaimed] ${channelName}`;
|
||
|
||
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,
|
||
startSweeps,
|
||
sweepReplyCooldowns,
|
||
_internals: { replyCooldowns, REPLY_COOLDOWN_SWEEP_TTL_MS }
|
||
};
|