diff --git a/settings-site/public/css/main.css b/settings-site/public/css/main.css index 5a1e726..74335de 100644 --- a/settings-site/public/css/main.css +++ b/settings-site/public/css/main.css @@ -649,164 +649,6 @@ body::before { } @keyframes spin { to { transform: rotate(360deg); } } -/* Notifications section */ -#s-notifications .notif-tabs { - display: flex; - gap: 4px; - flex-wrap: wrap; - margin-bottom: 22px; - border-bottom: 1px solid var(--border); -} -#s-notifications .notif-tab-btn { - border: none; - background: transparent; - color: var(--text-muted); - font-family: var(--font-title); - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.14em; - padding: 10px 16px; - cursor: pointer; - border-bottom: 2px solid transparent; - margin-bottom: -1px; - transition: color 160ms ease, border-color 160ms ease; -} -#s-notifications .notif-tab-btn:hover { color: var(--text); } -#s-notifications .notif-tab-btn.active { color: var(--primary); border-bottom-color: var(--primary); } -#s-notifications .notif-panel.hidden { display: none; } -#s-notifications .notif-editor { - border: 1px solid var(--border); - padding: 20px; - margin-bottom: 16px; - background: var(--surface-2); -} -#s-notifications .notif-chips { - display: flex; - gap: 8px; - flex-wrap: wrap; - margin: 14px 0; - min-height: 32px; -} -#s-notifications .notif-chip { - display: inline-flex; - align-items: center; - gap: 10px; - border: 1px solid var(--primary); - background: var(--primary-dim); - color: var(--primary); - padding: 5px 12px; - font-family: var(--font-title); - font-size: 11px; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; -} -#s-notifications .notif-chip button { - border: none; - background: transparent; - color: currentColor; - cursor: pointer; - padding: 0; - line-height: 1; - font-size: 14px; - opacity: 0.6; -} -#s-notifications .notif-chip button:hover { opacity: 1; } -#s-notifications .notif-input-row { - display: flex; - gap: 8px; - flex-wrap: wrap; - align-items: center; -} -#s-notifications .notif-input-row input { width: 220px; } -#s-notifications .notif-presets { - display: flex; - gap: 6px; - flex-wrap: wrap; - margin-top: 14px; -} -#s-notifications .notif-presets button, -#s-notifications .notif-add-btn { - padding: 8px 14px; - border: 1px solid var(--border-strong); - background: transparent; - color: var(--text-muted); - font-family: var(--font-title); - font-size: 11px; - font-weight: 600; - letter-spacing: 0.12em; - text-transform: uppercase; - cursor: pointer; - transition: border-color 160ms ease, color 160ms ease, background 160ms ease; -} -#s-notifications .notif-presets button:hover, -#s-notifications .notif-add-btn:hover { - border-color: var(--primary); - color: var(--primary); - background: var(--primary-dim-2); -} -#s-notifications .notif-trigger { margin-top: 16px; } -#s-notifications .notif-trigger summary { - cursor: pointer; - color: var(--text-muted); - font-family: var(--font-title); - font-size: 11px; - font-weight: 700; - letter-spacing: 0.16em; - text-transform: uppercase; - margin-bottom: 14px; - user-select: none; - list-style: none; - display: inline-flex; - align-items: center; - gap: 8px; -} -#s-notifications .notif-trigger summary::-webkit-details-marker { display: none; } -#s-notifications .notif-trigger summary::before { - content: '+'; - color: var(--primary); - font-weight: 700; - font-size: 14px; -} -#s-notifications .notif-trigger[open] summary::before { content: '−'; } -#s-notifications .notif-trigger[open] summary { color: var(--primary); } - -/* Phase 9 — notification enable toggles */ -#s-notifications .notif-toggle-row { - display: flex; - align-items: center; - justify-content: space-between; - flex-wrap: wrap; - gap: 12px; - padding-bottom: 14px; - margin-bottom: 14px; - border-bottom: 1px solid var(--border); -} -#s-notifications .notif-toggle-group { - display: flex; - align-items: center; - gap: 10px; -} -#s-notifications .notif-toggle-label { - font-family: var(--font-title); - font-size: 13px; - font-weight: 700; - color: var(--text); - letter-spacing: 0; -} -#s-notifications .notif-per-alert-row { - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 12px; -} -.notif-disabled { - opacity: 0.5; - pointer-events: none; - user-select: none; -} - /* Logging hint link */ .logging-hint { color: var(--text-muted); font-size: 13px; } .logging-hint a { @@ -923,12 +765,6 @@ body::before { .sidebar a { padding: 14px 20px; min-height: 44px; font-size: 12px; } .section-header { padding: 18px 20px; } .smart-select-display { min-height: 44px; } - #s-notifications .notif-chip { padding: 8px 12px; } - #s-notifications .notif-chip button { min-width: 28px; min-height: 28px; font-size: 18px; } - #s-notifications .notif-tab-btn, - #s-notifications .notif-add-btn, - #s-notifications .notif-presets button { min-height: 40px; padding: 10px 14px; } - #s-notifications .notif-input-row input { flex: 1 1 auto; width: auto; min-width: 0; } .modal-card { width: calc(100vw - 32px); min-width: 0; max-width: 420px; } diff --git a/settings-site/public/index.html b/settings-site/public/index.html index 03ac5a4..31c985b 100644 --- a/settings-site/public/index.html +++ b/settings-site/public/index.html @@ -22,7 +22,6 @@ Ticket Behavior Staff Threads Pin Messages - Notifications Logging Automation Appearance @@ -58,7 +57,6 @@

Ticket Behavior

Automation, limits, and messages

Staff Threads

Private staff discussion threads

Pin Messages

Auto-pin welcome and escalations

-

Notifications

Surge, patterns, unclaimed, chat

Logging

Log channel configuration

Automation

Polling intervals and timers

Appearance

Colors, labels, emojis

@@ -84,21 +82,11 @@
-
-
-
-
-
-
-
-
-
-
@@ -112,12 +100,9 @@
-
-
-
@@ -140,9 +125,6 @@
Enabled
-
Enabled
-
Enabled
-
Enabled
Enabled
@@ -179,216 +161,6 @@ - -
-

Notifications

Threshold milestones and trigger conditions by alert category

-
- - -
- - - - -
- -
-
-
- - Master (all categories) -
-
- - All in category -
-
-

Surge alerts fire when active ticket conditions cross thresholds — high volume, unclaimed queues, no staff online. Each alert escalates through its threshold list, spacing out pings as the condition persists. The counter resets when the condition clears.

-
-
-
-
- - Alert disabled -
-
-
- - -
-
-
-
- Trigger conditions -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Enabled
-
-
-
-
-
- - - - - - -
-
-

Logging

Log channel configuration (channels set in Channels section)

@@ -429,23 +201,16 @@
-
-
-
-
-

Staff

Staff IDs, emojis, and admin settings

+

Staff

Admin and staff role settings

-
-
Format: 123456:emoji,789012:emoji
Role IDs with staff permissions
Role IDs that cannot open tickets
-
e.g. 1,2,4
@@ -497,7 +262,6 @@ - diff --git a/settings-site/public/js/app.js b/settings-site/public/js/app.js index 909fbbd..fd5ace9 100644 --- a/settings-site/public/js/app.js +++ b/settings-site/public/js/app.js @@ -13,7 +13,6 @@ document.getElementById('bot-status-dot').className = 'dot online'; document.getElementById('bot-status-text').textContent = 'Connected'; Fields.populateFields(config); - Notifications.initNotificationsEditor(config); Fields.initSmartSelects(config); } catch (e) { document.getElementById('bot-status-dot').className = 'dot offline'; diff --git a/settings-site/public/js/notifications.js b/settings-site/public/js/notifications.js deleted file mode 100644 index e2982e1..0000000 --- a/settings-site/public/js/notifications.js +++ /dev/null @@ -1,521 +0,0 @@ -(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; } - }; -})(); diff --git a/settings-site/public/js/router.js b/settings-site/public/js/router.js index 44a4ee5..0cb14e5 100644 --- a/settings-site/public/js/router.js +++ b/settings-site/public/js/router.js @@ -10,7 +10,6 @@ '/behavior': 's-behavior', '/threads': 's-threads', '/pins': 's-pins', - '/notifications': 's-notifications', '/logging': 's-logging', '/automation': 's-automation', '/appearance': 's-appearance', diff --git a/settings-site/server.js b/settings-site/server.js index b1f656f..3187531 100644 --- a/settings-site/server.js +++ b/settings-site/server.js @@ -202,9 +202,6 @@ app.post('/api/config', apiLimiter, requireAuth, proxy('POST', '/config')); app.get('/api/discord/guild', apiLimiter, requireAuth, proxy('GET', '/discord/guild')); app.post('/api/restart', apiLimiter, requireAuth, proxy('POST', '/restart')); app.get('/api/restart/status', apiLimiter, requireAuth, proxy('GET', '/restart/status')); -app.get('/api/notifications/alerts', apiLimiter, requireAuth, proxy('GET', '/notifications/alerts')); -app.get('/api/notifications/state', apiLimiter, requireAuth, proxy('GET', '/notifications/state')); -app.post('/api/notifications/toggle', apiLimiter, requireAuth, proxy('POST', '/notifications/toggle')); app.get('/*splat', requireAuth, (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html'));