Files
broccolini-bot/settings-site/public/js/notifications.js

522 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
'use strict';
const NOTIFICATION_PRESETS = ['15m', '30m', '1h', '2h', '4h', '8h', '1d'];
const FALLBACK_TAB_KEYS = {
surge: [
'surge_tickets',
'surge_game',
'surge_stale',
'surge_needs_response',
'surge_unclaimed',
'surge_tier3_unclaimed',
'surge_no_staff'
],
patterns: [
'user_tickets',
'user_reopen',
'user_crossgame',
'game_surge',
'game_backlog',
'game_resolution',
'game_spike',
'tag_top',
'tag_escalation',
'untagged_closes',
'tag_game_corr',
'user_esc',
'game_esc_rate',
'rapid_t2_t3',
'staff_no_close',
'staff_overloaded',
'staff_stale',
'staff_transfer_rate',
'staff_esc',
'staff_game_esc',
'game_tag_spike',
'overnight_gap',
'staff_always_esc'
],
unclaimed: ['unclaimed_reminder'],
chat: ['chat_messages', 'chat_time']
};
const FALLBACK_ALERT_DESCRIPTIONS = {
surge_tickets: 'Fires when total active ticket volume exceeds configured surge thresholds, signaling broad queue pressure that needs staffing attention.',
surge_game: 'Fires when one game accumulates tickets unusually fast within the configured window, indicating a localized incident that should be triaged.',
surge_stale: 'Fires when too many tickets stay unresolved past the stale-time threshold, prompting staff to clear aging backlog.',
surge_needs_response: 'Fires when tickets needing a staff reply exceed count and age limits, indicating response latency is building.',
surge_unclaimed: 'Fires when the unclaimed queue crosses configured count/age thresholds, signaling ownership gaps that need pickup.',
surge_tier3_unclaimed: "Fires when Tier 3 tickets have been sitting unclaimed past each threshold. Escalating intervals prevent spam while ensuring critical tickets don't go unnoticed.",
surge_no_staff: 'Fires when open-ticket load is high while no staff are detected as available, prompting immediate coverage.',
user_tickets: 'Detects users opening unusually high ticket counts in the active window, suggesting repeat-issue or abuse patterns.',
user_reopen: 'Detects users who repeatedly reopen or recreate issues after closure, signaling unresolved root-cause patterns.',
user_crossgame: 'Detects users reporting similar issues across multiple games in a short period, indicating broader account-level impact.',
game_surge: 'Detects game-specific ticket spikes crossing thresholds in the pattern window, signaling service instability for that title.',
game_backlog: 'Detects games accumulating unresolved backlog above threshold, implying triage capacity is lagging for that queue.',
game_resolution: 'Detects unusual drops in resolution rate for a game, indicating tickets are staying open longer than expected.',
game_spike: 'Detects abrupt short-window jumps in ticket volume for a game, flagging incidents that may need escalation.',
tag_top: 'Detects tag frequency leaders above threshold so recurring issue types can be prioritized for fixes or macros.',
tag_escalation: 'Detects tags with unusually high escalation rates, indicating categories that routinely require higher-tier handling.',
untagged_closes: 'Detects elevated counts of closed tickets without tags, prompting cleanup to preserve reporting quality.',
tag_game_corr: 'Detects strong tag-to-game concentration patterns, highlighting issue types tightly linked to specific games.',
user_esc: 'Detects users whose tickets escalate unusually often, implying complex cases that may need proactive follow-up.',
game_esc_rate: 'Detects games with escalating ticket-rate thresholds exceeded, signaling deeper technical issues for that title.',
rapid_t2_t3: 'Fires at ticket count milestones (e.g. 3, 5, 10) when tickets have reached Tier 3 this week. Each milestone fires once per week.',
staff_no_close: 'Detects staff with prolonged periods of claims but few closes, suggesting overloaded ownership or stuck work.',
staff_overloaded: 'Detects staff carrying ticket loads beyond threshold, indicating balancing or reassignment may be needed.',
staff_stale: 'Detects staff-owned tickets aging beyond stale limits, prompting review and unblock actions.',
staff_transfer_rate: 'Detects unusually high transfer/reassignment rates by staff, signaling ownership churn that may hurt throughput.',
staff_esc: 'Detects staff escalation counts above threshold, highlighting where extra support or training may be needed.',
staff_game_esc: 'Detects high escalation concentration for specific staff/game combinations, indicating targeted expertise gaps.',
game_tag_spike: 'Detects sudden spikes of specific tags within a game, flagging focused incident signatures.',
overnight_gap: 'Detects recurring unattended overnight windows with active demand, suggesting staffing coverage gaps.',
staff_always_esc: 'Detects staff whose handled tickets escalate at consistently high rates, implying sustained tier-fit issues.',
unclaimed_reminder: 'Reminds all staff notification channels about unclaimed tickets. Thresholds are per-ticket age — each threshold fires once per ticket and resets on escalation.',
chat_messages: 'Fires when pending user message volume in monitored chat channels crosses configured count thresholds without staff replies.',
chat_time: 'Fires when a monitored chat channel has had no staff response for the given duration with pending user messages. Resets when staff responds.'
};
let notificationThresholdsState = {};
// Phase 9: notification enable state. Fetched from /api/notifications/state
// on init. `master` is the global kill switch; `perKey` is a flat map of
// alertKey → boolean. Both default to off so a fresh deploy is silent.
let enableState = { master: false, perKey: {} };
// Active sources. Start as fallback; replaced/merged when the bot-side
// registry (GET /api/notifications/alerts) returns successfully. On 404 or
// network failure the fallbacks remain authoritative.
let activeTabKeys = FALLBACK_TAB_KEYS;
let activeAlertDescriptions = FALLBACK_ALERT_DESCRIPTIONS;
async function fetchAlertRegistry() {
try {
const res = await fetch('/api/notifications/alerts', { credentials: 'same-origin' });
if (!res.ok) return null;
const data = await res.json();
if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
// Accept only if at least one known category is a non-empty array
const hasShape = ['surge', 'patterns', 'unclaimed'].some(
cat => Array.isArray(data[cat]) && data[cat].length > 0
);
return hasShape ? data : null;
} catch (_) {
return null;
}
}
async function fetchEnableState() {
try {
const res = await fetch('/api/notifications/state', { credentials: 'same-origin' });
if (!res.ok) return null;
const data = await res.json();
if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
if (typeof data.master !== 'boolean') return null;
if (!data.perKey || typeof data.perKey !== 'object') return null;
return { master: data.master, perKey: { ...data.perKey } };
} catch (_) {
return null;
}
}
async function postToggle(body) {
const res = await fetch('/api/notifications/toggle', {
method: 'POST',
credentials: 'same-origin',
headers: Util.csrfHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(body)
});
if (!res.ok) throw new Error(`toggle ${res.status}`);
const data = await res.json();
if (!data || !data.state || typeof data.state.master !== 'boolean' || !data.state.perKey) {
throw new Error('bad toggle response');
}
return { master: data.state.master, perKey: { ...data.state.perKey } };
}
// Merge bot registry with fallback, preserving fallback order for existing
// keys (so rapid_t2_t3 and chat keys stay where the UI expects them).
// Registry-only keys get appended to their category; registry descriptions
// override fallback text.
function mergeRegistryWithFallback(registry) {
const tabKeys = {};
const alertDescriptions = { ...FALLBACK_ALERT_DESCRIPTIONS };
Object.keys(FALLBACK_TAB_KEYS).forEach(cat => { tabKeys[cat] = [...FALLBACK_TAB_KEYS[cat]]; });
Object.entries(registry).forEach(([category, entries]) => {
if (!Array.isArray(entries)) return;
if (!tabKeys[category]) tabKeys[category] = [];
const seen = new Set(tabKeys[category]);
for (const e of entries) {
if (!e || typeof e.key !== 'string') continue;
if (!seen.has(e.key)) {
tabKeys[category].push(e.key);
seen.add(e.key);
}
if (typeof e.description === 'string') {
alertDescriptions[e.key] = e.description;
}
}
});
return { tabKeys, alertDescriptions };
}
function applyMergedRegistry(section, registry) {
const merged = mergeRegistryWithFallback(registry);
activeTabKeys = merged.tabKeys;
activeAlertDescriptions = merged.alertDescriptions;
window.Notifications.registry = registry;
Object.entries(activeTabKeys).forEach(([category, keys]) => {
const select = section.querySelector(`[data-notif-category="${category}"]`);
if (!select) return;
const existing = new Set(Array.from(select.options).map(o => o.value));
keys.forEach(key => {
if (!existing.has(key)) {
const option = document.createElement('option');
option.value = key;
option.textContent = toHumanLabel(key);
select.appendChild(option);
}
});
renderAlertDescription(category);
});
}
function initNotificationsEditor(config) {
const section = document.getElementById('s-notifications');
if (!section) return;
const hiddenField = section.querySelector('[data-key="NOTIFICATION_THRESHOLDS_JSON"]');
if (!hiddenField) return;
notificationThresholdsState = parseNotificationThresholdsConfig(config);
hiddenField.value = serializeNotificationThresholds(notificationThresholdsState);
section.querySelectorAll('.notif-tab-btn').forEach(btn => {
btn.addEventListener('click', () => setNotificationTab(btn.dataset.notifTab));
});
Object.entries(activeTabKeys).forEach(([category, keys]) => {
const select = section.querySelector(`[data-notif-category="${category}"]`);
const chipsWrap = section.querySelector(`[data-notif-chips="${category}"]`);
const input = section.querySelector(`[data-notif-input="${category}"]`);
const addBtn = section.querySelector(`[data-notif-add="${category}"]`);
const presetsWrap = section.querySelector(`[data-notif-presets="${category}"]`);
if (!select || !chipsWrap || !input || !addBtn || !presetsWrap) return;
keys.forEach(key => {
const option = document.createElement('option');
option.value = key;
option.textContent = toHumanLabel(key);
select.appendChild(option);
});
if (keys.length) select.value = keys[0];
select.addEventListener('change', () => {
renderThresholdChips(category);
renderAlertDescription(category);
renderPerAlertToggle(category);
});
addBtn.addEventListener('click', () => addThresholdFromInput(category));
input.addEventListener('keydown', (evt) => {
if (evt.key === 'Enter') {
evt.preventDefault();
addThresholdFromInput(category);
}
});
NOTIFICATION_PRESETS.forEach(preset => {
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = preset;
btn.addEventListener('click', () => addThresholdValue(category, preset));
presetsWrap.appendChild(btn);
});
renderThresholdChips(category);
renderAlertDescription(category);
});
setNotificationTab('surge');
wireEnableToggles(section);
// Background: pull canonical registry from the bot, merge with fallback,
// append any registry-only keys and refresh descriptions. Fallback stays
// in use if the endpoint 404s (settings-site deployed ahead of bot) or
// the fetch fails (network/proxy error).
fetchAlertRegistry().then(registry => {
if (!registry) return;
applyMergedRegistry(section, registry);
renderAllEnableUI(section);
}).catch(() => {});
// Phase 9: pull enable state and paint the toggles. Runs in parallel with
// the registry fetch above; renderAll is idempotent so ordering is OK.
fetchEnableState().then(state => {
if (!state) return;
enableState = state;
window.Notifications.state = state;
renderAllEnableUI(section);
}).catch(() => {});
}
function keysForCategory(category) {
return activeTabKeys[category] || [];
}
function renderMasterCheckboxes(section) {
section.querySelectorAll('[data-notif-master]').forEach(cb => {
cb.checked = enableState.master === true;
});
}
function renderCategoryCheckbox(section, category) {
const cb = section.querySelector(`[data-notif-category-toggle="${category}"]`);
if (!cb) return;
const keys = keysForCategory(category);
const allOn = keys.length > 0 && keys.every(k => enableState.perKey[k] === true);
cb.checked = enableState.master === true && allOn;
}
function renderPerAlertToggle(category) {
const section = document.getElementById('s-notifications');
if (!section) return;
const panel = section.querySelector(`[data-notif-panel="${category}"]`);
if (!panel) return;
const alertCb = panel.querySelector('[data-notif-alert]');
const label = panel.querySelector('[data-notif-alert-label]');
const alertKey = getSelectedAlertKey(category);
const on = alertKey ? enableState.perKey[alertKey] === true : false;
if (alertCb) alertCb.checked = on;
if (label) label.textContent = on ? 'Alert enabled' : 'Alert disabled';
// Disable the chip/input editor when master is off or this alert is off.
const editor = panel.querySelector('.notif-editor');
if (editor) {
const disabled = !enableState.master || !on;
editor.classList.toggle('notif-disabled', disabled);
}
}
function renderAllEnableUI(section) {
renderMasterCheckboxes(section);
Object.keys(activeTabKeys).forEach(cat => renderCategoryCheckbox(section, cat));
Object.keys(activeTabKeys).forEach(cat => renderPerAlertToggle(cat));
}
function wireEnableToggles(section) {
// Master — multiple DOM copies; they share state via enableState and
// re-render after each mutation, so clicking any one updates all.
section.querySelectorAll('[data-notif-master]').forEach(cb => {
cb.addEventListener('change', async () => {
const prev = enableState.master;
const next = cb.checked;
try {
const newState = await postToggle({ master: true, enabled: next });
enableState = newState;
window.Notifications.state = newState;
renderAllEnableUI(section);
} catch (e) {
cb.checked = prev;
renderAllEnableUI(section);
Util.showToast('Failed to update master toggle.', 'error');
}
});
});
// Per-category "All in category" toggles.
section.querySelectorAll('[data-notif-category-toggle]').forEach(cb => {
const category = cb.dataset.notifCategoryToggle;
cb.addEventListener('change', async () => {
const next = cb.checked;
const prev = !next; // pre-toggle state, for revert on failure
try {
const newState = await postToggle({ category, enabled: next });
enableState = newState;
window.Notifications.state = newState;
renderAllEnableUI(section);
} catch (e) {
cb.checked = prev;
renderAllEnableUI(section);
Util.showToast(`Failed to update ${category} category toggle.`, 'error');
}
});
});
// Per-alert toggles — one per category panel, scoped to the currently
// selected alertKey in that panel's dropdown.
section.querySelectorAll('.notif-panel').forEach(panel => {
const category = panel.dataset.notifPanel;
const alertCb = panel.querySelector('[data-notif-alert]');
if (!alertCb) return;
alertCb.addEventListener('change', async () => {
const alertKey = getSelectedAlertKey(category);
if (!alertKey) {
alertCb.checked = false;
return;
}
const prev = enableState.perKey[alertKey] === true;
const next = alertCb.checked;
try {
const newState = await postToggle({ key: alertKey, enabled: next });
enableState = newState;
window.Notifications.state = newState;
renderAllEnableUI(section);
} catch (e) {
alertCb.checked = prev;
renderAllEnableUI(section);
Util.showToast(`Failed to update ${alertKey} toggle.`, 'error');
}
});
});
}
function parseNotificationThresholdsConfig(config) {
const rawJson = config.NOTIFICATION_THRESHOLDS_JSON;
if (rawJson && String(rawJson).trim()) {
try {
const parsed = JSON.parse(rawJson);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
} catch (_) {}
}
if (config.NOTIFICATION_THRESHOLDS && typeof config.NOTIFICATION_THRESHOLDS === 'object' && !Array.isArray(config.NOTIFICATION_THRESHOLDS)) {
return config.NOTIFICATION_THRESHOLDS;
}
return {};
}
function serializeNotificationThresholds(obj) {
const ordered = {};
Object.keys(obj).sort().forEach(key => {
const arr = Array.isArray(obj[key]) ? obj[key].map(v => String(v).trim()).filter(Boolean) : [];
ordered[key] = arr;
});
return JSON.stringify(ordered);
}
function setNotificationTab(category) {
document.querySelectorAll('#s-notifications .notif-tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.notifTab === category);
});
document.querySelectorAll('#s-notifications .notif-panel').forEach(panel => {
panel.classList.toggle('hidden', panel.dataset.notifPanel !== category);
});
}
function addThresholdFromInput(category) {
const input = document.querySelector(`#s-notifications [data-notif-input="${category}"]`);
if (!input) return;
const value = input.value.trim().toLowerCase();
if (addThresholdValue(category, value)) input.value = '';
}
function addThresholdValue(category, rawValue) {
const value = String(rawValue || '').trim().toLowerCase();
if (!isValidThresholdValue(value)) {
Util.showToast('Invalid threshold format. Use 15m, 1h, 1d6h, or whole numbers.', 'error');
return false;
}
const alertKey = getSelectedAlertKey(category);
if (!alertKey) return false;
const current = Array.isArray(notificationThresholdsState[alertKey]) ? [...notificationThresholdsState[alertKey]] : [];
if (current.includes(value)) return false;
current.push(value);
notificationThresholdsState[alertKey] = current;
syncNotificationThresholdsField();
renderThresholdChips(category);
return true;
}
function removeThresholdValue(category, valueToRemove) {
const alertKey = getSelectedAlertKey(category);
if (!alertKey) return;
const current = Array.isArray(notificationThresholdsState[alertKey]) ? [...notificationThresholdsState[alertKey]] : [];
notificationThresholdsState[alertKey] = current.filter(v => String(v) !== String(valueToRemove));
syncNotificationThresholdsField();
renderThresholdChips(category);
}
function renderThresholdChips(category) {
const chipsWrap = document.querySelector(`#s-notifications [data-notif-chips="${category}"]`);
if (!chipsWrap) return;
const alertKey = getSelectedAlertKey(category);
const thresholds = alertKey && Array.isArray(notificationThresholdsState[alertKey])
? notificationThresholdsState[alertKey]
: [];
chipsWrap.replaceChildren();
thresholds.forEach(value => {
const chip = document.createElement('span');
chip.className = 'notif-chip';
chip.textContent = value;
const remove = document.createElement('button');
remove.type = 'button';
remove.title = `Remove ${value}`;
remove.textContent = '×';
remove.addEventListener('click', () => removeThresholdValue(category, value));
chip.appendChild(remove);
chipsWrap.appendChild(chip);
});
}
function renderAlertDescription(category) {
const descriptionEl = document.querySelector(`#s-notifications [data-notif-description="${category}"]`);
if (!descriptionEl) return;
const alertKey = getSelectedAlertKey(category);
descriptionEl.textContent = activeAlertDescriptions[alertKey] || 'No description available for this alert key yet.';
}
function syncNotificationThresholdsField() {
const hiddenField = document.querySelector('#s-notifications [data-key="NOTIFICATION_THRESHOLDS_JSON"]');
if (!hiddenField) return;
const serialized = serializeNotificationThresholds(notificationThresholdsState);
hiddenField.value = serialized;
Fields.markChanged('NOTIFICATION_THRESHOLDS_JSON', serialized);
hiddenField.classList.toggle('changed', Fields.isChanged('NOTIFICATION_THRESHOLDS_JSON'));
}
function getSelectedAlertKey(category) {
const select = document.querySelector(`#s-notifications [data-notif-category="${category}"]`);
return select ? select.value : '';
}
function isValidThresholdValue(value) {
if (!value) return false;
if (/^\d+$/.test(value)) return true;
return /^(\d+[mhd])+$/.test(value);
}
function toHumanLabel(key) {
return String(key)
.split('_')
.map(part => part.toUpperCase() === 'T2' || part.toUpperCase() === 'T3'
? part.toUpperCase()
: part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
window.Notifications = {
initNotificationsEditor,
isValidThresholdValue,
toHumanLabel,
fetchAlertRegistry,
fetchEnableState,
NOTIFICATION_PRESETS,
FALLBACK_TAB_KEYS,
FALLBACK_ALERT_DESCRIPTIONS,
registry: null,
state: enableState,
get tabKeys() { return activeTabKeys; },
get alertDescriptions() { return activeAlertDescriptions; }
};
})();