trim settings-site to match stripped bot
- delete public/js/notifications.js (521 LOC) - remove notifications/patterns/surge/chat/priority/bosscord/accountinfo/threads UI sections - remove 3 /api/notifications/* proxy routes from server.js - untrack settings-site backup files from git - ~926 LOC removed from settings-site
This commit is contained in:
@@ -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; }
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
<a href="/behavior">Ticket Behavior</a>
|
||||
<a href="/threads">Staff Threads</a>
|
||||
<a href="/pins">Pin Messages</a>
|
||||
<a href="/notifications">Notifications</a>
|
||||
<a href="/logging">Logging</a>
|
||||
<a href="/automation">Automation</a>
|
||||
<a href="/appearance">Appearance</a>
|
||||
@@ -58,7 +57,6 @@
|
||||
<a class="landing-card" href="/behavior"><div class="landing-card-body"><h3>Ticket Behavior</h3><p>Automation, limits, and messages</p></div><span class="chevron">›</span></a>
|
||||
<a class="landing-card" href="/threads"><div class="landing-card-body"><h3>Staff Threads</h3><p>Private staff discussion threads</p></div><span class="chevron">›</span></a>
|
||||
<a class="landing-card" href="/pins"><div class="landing-card-body"><h3>Pin Messages</h3><p>Auto-pin welcome and escalations</p></div><span class="chevron">›</span></a>
|
||||
<a class="landing-card" href="/notifications"><div class="landing-card-body"><h3>Notifications</h3><p>Surge, patterns, unclaimed, chat</p></div><span class="chevron">›</span></a>
|
||||
<a class="landing-card" href="/logging"><div class="landing-card-body"><h3>Logging</h3><p>Log channel configuration</p></div><span class="chevron">›</span></a>
|
||||
<a class="landing-card" href="/automation"><div class="landing-card-body"><h3>Automation</h3><p>Polling intervals and timers</p></div><span class="chevron">›</span></a>
|
||||
<a class="landing-card" href="/appearance"><div class="landing-card-body"><h3>Appearance</h3><p>Colors, labels, emojis</p></div><span class="chevron">›</span></a>
|
||||
@@ -84,21 +82,11 @@
|
||||
<div class="field"><label>Transcript Channel</label><input type="text" data-key="TRANSCRIPT_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Logging Channel</label><input type="text" data-key="LOGGING_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Debugging Channel</label><input type="text" data-key="DEBUGGING_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Backup/Export Channel</label><input type="text" data-key="BACKUP_EXPORT_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Account Info Channel</label><input type="text" data-key="ACCOUNT_INFO_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Gmail Log Channel</label><input type="text" data-key="GMAIL_LOG_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Automation Log Channel</label><input type="text" data-key="AUTOMATION_LOG_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Rename Log Channel</label><input type="text" data-key="RENAME_LOG_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Security Log Channel</label><input type="text" data-key="SECURITY_LOG_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>System Log Channel</label><input type="text" data-key="SYSTEM_LOG_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>All Staff Channel</label><input type="text" data-key="ALL_STAFF_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Chat Alert Channel</label><input type="text" data-key="ALL_STAFF_CHAT_ALERT_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>User Patterns Channel</label><input type="text" data-key="USER_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Game Patterns Channel</label><input type="text" data-key="GAME_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Tag Patterns Channel</label><input type="text" data-key="TAG_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Escalation Patterns Channel</label><input type="text" data-key="ESCALATION_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Staff Patterns Channel</label><input type="text" data-key="STAFF_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Combined Patterns Channel</label><input type="text" data-key="COMBINED_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
@@ -112,12 +100,9 @@
|
||||
<div class="field"><label>Discord T2 Category</label><input type="text" data-key="DISCORD_ESCALATED2_CHANNEL_ID" data-smart="category"></div>
|
||||
<div class="field"><label>Email T3 Category</label><input type="text" data-key="EMAIL_ESCALATED3_CHANNEL_ID" data-smart="category"></div>
|
||||
<div class="field"><label>Discord T3 Category</label><input type="text" data-key="DISCORD_ESCALATED3_CHANNEL_ID" data-smart="category"></div>
|
||||
<div class="field"><label>Staff Notification Category</label><input type="text" data-key="STAFF_NOTIFICATION_CATEGORY_ID" data-smart="category"></div>
|
||||
<div class="field"><label>Category Name</label><input type="text" data-key="TICKET_CATEGORY_NAME"></div>
|
||||
<div class="field"><label>T2 Category Name</label><input type="text" data-key="TICKET_T2_CATEGORY_NAME"></div>
|
||||
<div class="field"><label>T3 Category Name</label><input type="text" data-key="TICKET_T3_CATEGORY_NAME"></div>
|
||||
<div class="field"><label>Discord Thread Channel</label><input type="text" data-key="DISCORD_THREAD_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Email Thread Channel</label><input type="text" data-key="EMAIL_THREAD_CHANNEL_ID" data-smart="channel"></div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
@@ -140,9 +125,6 @@
|
||||
<div class="field"><label>Auto-Close Hours</label><input type="number" data-key="AUTO_CLOSE_AFTER_HOURS"></div>
|
||||
<div class="field"><label>Reminders</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="REMINDER_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||
<div class="field"><label>Reminder Hours</label><input type="number" data-key="REMINDER_AFTER_HOURS"></div>
|
||||
<div class="field"><label>Priority System</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="PRIORITY_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||
<div class="field"><label>Claim Timeout</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="CLAIM_TIMEOUT_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||
<div class="field"><label>Claim Timeout Hours</label><input type="number" data-key="CLAIM_TIMEOUT_HOURS"></div>
|
||||
<div class="field"><label>Auto-Unclaim</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="AUTO_UNCLAIM_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||
<div class="field"><label>Auto-Unclaim Hours</label><input type="number" data-key="AUTO_UNCLAIM_AFTER_HOURS"></div>
|
||||
<div class="field"><label>Allow Claim Overwrite</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="ALLOW_CLAIM_OVERWRITE"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||
@@ -179,216 +161,6 @@
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<!-- 8. Notifications -->
|
||||
<div class="section" id="s-notifications">
|
||||
<div class="section-header"><h2>Notifications</h2><p>Threshold milestones and trigger conditions by alert category</p><span class="chevron">▼</span></div>
|
||||
<div class="section-body">
|
||||
<input type="hidden" data-key="NOTIFICATION_THRESHOLDS_JSON">
|
||||
|
||||
<div class="notif-tabs" role="tablist" aria-label="Notification categories">
|
||||
<button type="button" class="notif-tab-btn active" data-notif-tab="surge">Surge</button>
|
||||
<button type="button" class="notif-tab-btn" data-notif-tab="patterns">Patterns</button>
|
||||
<button type="button" class="notif-tab-btn" data-notif-tab="unclaimed">Unclaimed</button>
|
||||
<button type="button" class="notif-tab-btn" data-notif-tab="chat">Chat</button>
|
||||
</div>
|
||||
|
||||
<div class="notif-panel" data-notif-panel="surge">
|
||||
<div class="notif-toggle-row">
|
||||
<div class="notif-toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-master>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label">Master (all categories)</span>
|
||||
</div>
|
||||
<div class="notif-toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-category-toggle="surge">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label">All in category</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">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.</p>
|
||||
<div class="notif-editor">
|
||||
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="surge"></select></div>
|
||||
<div class="hint notif-alert-description" data-notif-description="surge"></div>
|
||||
<div class="notif-per-alert-row">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-alert>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label" data-notif-alert-label>Alert disabled</span>
|
||||
</div>
|
||||
<div class="notif-chips" data-notif-chips="surge"></div>
|
||||
<div class="notif-input-row">
|
||||
<input type="text" class="notif-threshold-input" data-notif-input="surge" placeholder="15m, 1h, 1d6h, 2d6h, 5">
|
||||
<button type="button" class="notif-add-btn" data-notif-add="surge">Add</button>
|
||||
</div>
|
||||
<div class="notif-presets" data-notif-presets="surge"></div>
|
||||
</div>
|
||||
<details class="notif-trigger">
|
||||
<summary>Trigger conditions</summary>
|
||||
<div class="field-grid">
|
||||
<div class="field"><label>Surge Role</label><input type="text" data-key="SURGE_ROLE_ID" data-smart="role"></div>
|
||||
<div class="field"><label>Ticket Surge Count</label><input type="number" data-key="SURGE_TICKET_COUNT"></div>
|
||||
<div class="field"><label>Ticket Window (min)</label><input type="number" data-key="SURGE_TICKET_WINDOW_MINUTES"></div>
|
||||
<div class="field"><label>Game Surge Count</label><input type="number" data-key="SURGE_GAME_TICKET_COUNT"></div>
|
||||
<div class="field"><label>Game Window (min)</label><input type="number" data-key="SURGE_GAME_TICKET_WINDOW_MINUTES"></div>
|
||||
<div class="field"><label>Stale Count</label><input type="number" data-key="SURGE_STALE_COUNT"></div>
|
||||
<div class="field"><label>Stale Hours</label><input type="number" data-key="SURGE_STALE_HOURS"></div>
|
||||
<div class="field"><label>Needs Response Count</label><input type="number" data-key="SURGE_NEEDS_RESPONSE_COUNT"></div>
|
||||
<div class="field"><label>Needs Response Hours</label><input type="number" data-key="SURGE_NEEDS_RESPONSE_HOURS"></div>
|
||||
<div class="field"><label>Unclaimed Count</label><input type="number" data-key="SURGE_UNCLAIMED_COUNT"></div>
|
||||
<div class="field"><label>Unclaimed Minutes</label><input type="number" data-key="SURGE_UNCLAIMED_MINUTES"></div>
|
||||
<div class="field"><label>Tier 3 Unclaimed (min)</label><input type="number" data-key="SURGE_TIER3_UNCLAIMED_MINUTES"></div>
|
||||
<div class="field"><label>Cooldown (min)</label><input type="number" data-key="SURGE_COOLDOWN_MINUTES"></div>
|
||||
<div class="field"><label>DND = Available</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="STAFF_DND_COUNTS_AS_AVAILABLE"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||
<div class="field"><label>No-Staff Cooldown (min)</label><input type="number" data-key="SURGE_NO_STAFF_COOLDOWN_MINUTES"></div>
|
||||
<div class="field"><label>No-Staff Ticket Threshold</label><input type="number" data-key="SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD"></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="notif-panel hidden" data-notif-panel="patterns">
|
||||
<div class="notif-toggle-row">
|
||||
<div class="notif-toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-master>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label">Master (all categories)</span>
|
||||
</div>
|
||||
<div class="notif-toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-category-toggle="patterns">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label">All in category</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">Pattern alerts detect trends over time — surges by game, escalation rates, staff behavior. Each alert fires once per threshold crossed within its window (daily/weekly/monthly) and won't repeat until the next window resets.</p>
|
||||
<div class="notif-editor">
|
||||
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="patterns"></select></div>
|
||||
<div class="hint notif-alert-description" data-notif-description="patterns"></div>
|
||||
<div class="notif-per-alert-row">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-alert>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label" data-notif-alert-label>Alert disabled</span>
|
||||
</div>
|
||||
<div class="notif-chips" data-notif-chips="patterns"></div>
|
||||
<div class="notif-input-row">
|
||||
<input type="text" class="notif-threshold-input" data-notif-input="patterns" placeholder="15m, 1h, 1d6h, 2d6h, 5">
|
||||
<button type="button" class="notif-add-btn" data-notif-add="patterns">Add</button>
|
||||
</div>
|
||||
<div class="notif-presets" data-notif-presets="patterns"></div>
|
||||
</div>
|
||||
<details class="notif-trigger">
|
||||
<summary>Trigger conditions</summary>
|
||||
<div class="field-grid">
|
||||
<div class="field"><label>Check Interval (min)</label><input type="number" data-key="PATTERN_CHECK_INTERVAL_MINUTES"></div>
|
||||
<div class="field"><label>User Ticket Threshold</label><input type="number" data-key="PATTERN_USER_TICKET_THRESHOLD"></div>
|
||||
<div class="field"><label>Game Ticket Threshold</label><input type="number" data-key="PATTERN_GAME_TICKET_THRESHOLD"></div>
|
||||
<div class="field"><label>Staff Stale Ping Threshold</label><input type="number" data-key="PATTERN_STAFF_STALE_PING_THRESHOLD"></div>
|
||||
<div class="field"><label>Escalation Threshold</label><input type="number" data-key="PATTERN_ESCALATION_THRESHOLD"></div>
|
||||
<div class="field"><label>Rapid Close Seconds</label><input type="number" data-key="PATTERN_RAPID_CLOSE_SECONDS"></div>
|
||||
<div class="field"><label>Unclaimed Hours</label><input type="number" data-key="PATTERN_UNCLAIMED_HOURS"></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="notif-panel hidden" data-notif-panel="unclaimed">
|
||||
<div class="notif-toggle-row">
|
||||
<div class="notif-toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-master>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label">Master (all categories)</span>
|
||||
</div>
|
||||
<div class="notif-toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-category-toggle="unclaimed">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label">All in category</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">Per-ticket reminders sent to staff notification channels when a ticket remains unclaimed. Each threshold fires once per ticket. Escalating a ticket resets the threshold list so reminders restart for the new tier.</p>
|
||||
<div class="notif-editor">
|
||||
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="unclaimed"></select></div>
|
||||
<div class="hint notif-alert-description" data-notif-description="unclaimed"></div>
|
||||
<div class="notif-per-alert-row">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-alert>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label" data-notif-alert-label>Alert disabled</span>
|
||||
</div>
|
||||
<div class="notif-chips" data-notif-chips="unclaimed"></div>
|
||||
<div class="notif-input-row">
|
||||
<input type="text" class="notif-threshold-input" data-notif-input="unclaimed" placeholder="15m, 1h, 1d6h, 2d6h, 5">
|
||||
<button type="button" class="notif-add-btn" data-notif-add="unclaimed">Add</button>
|
||||
</div>
|
||||
<div class="notif-presets" data-notif-presets="unclaimed"></div>
|
||||
</div>
|
||||
<details class="notif-trigger">
|
||||
<summary>Trigger conditions</summary>
|
||||
<div class="field-grid">
|
||||
<div class="field full-width"><p class="hint">Unclaimed notifications use threshold milestones only.</p></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="notif-panel hidden" data-notif-panel="chat">
|
||||
<div class="notif-toggle-row">
|
||||
<div class="notif-toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-master>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label">Master (all categories)</span>
|
||||
</div>
|
||||
<div class="notif-toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-category-toggle="chat">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label">All in category</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">Monitors configured chat channels for unresponded user messages. Fires at escalating intervals while the condition persists. Resets when a staff member responds.</p>
|
||||
<div class="notif-editor">
|
||||
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="chat"></select></div>
|
||||
<div class="hint notif-alert-description" data-notif-description="chat"></div>
|
||||
<div class="notif-per-alert-row">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-alert>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label" data-notif-alert-label>Alert disabled</span>
|
||||
</div>
|
||||
<div class="notif-chips" data-notif-chips="chat"></div>
|
||||
<div class="notif-input-row">
|
||||
<input type="text" class="notif-threshold-input" data-notif-input="chat" placeholder="15m, 1h, 1d6h, 2d6h, 5">
|
||||
<button type="button" class="notif-add-btn" data-notif-add="chat">Add</button>
|
||||
</div>
|
||||
<div class="notif-presets" data-notif-presets="chat"></div>
|
||||
</div>
|
||||
<details class="notif-trigger">
|
||||
<summary>Trigger conditions</summary>
|
||||
<div class="field-grid">
|
||||
<div class="field"><label>Chat Alert Message Count</label><input type="number" data-key="CHAT_ALERT_MESSAGE_COUNT"></div>
|
||||
<div class="field"><label>Chat No-Response Hours</label><input type="number" data-key="CHAT_ALERT_HOURS_WITHOUT_RESPONSE"></div>
|
||||
<div class="field"><label>Chat Alert Cooldown (min)</label><input type="number" data-key="CHAT_ALERT_COOLDOWN_MINUTES"></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 10. Logging -->
|
||||
<div class="section" id="s-logging">
|
||||
<div class="section-header"><h2>Logging</h2><p>Log channel configuration (channels set in Channels section)</p><span class="chevron">▼</span></div>
|
||||
@@ -429,23 +201,16 @@
|
||||
<div class="field"><label>Close Emoji</label><input type="text" data-key="BUTTON_EMOJI_CLOSE"></div>
|
||||
<div class="field"><label>Claim Emoji</label><input type="text" data-key="BUTTON_EMOJI_CLAIM"></div>
|
||||
<div class="field"><label>Unclaim Emoji</label><input type="text" data-key="BUTTON_EMOJI_UNCLAIM"></div>
|
||||
<div class="field"><label>High Priority Emoji</label><input type="text" data-key="PRIORITY_HIGH_EMOJI"></div>
|
||||
<div class="field"><label>Medium Priority Emoji</label><input type="text" data-key="PRIORITY_MEDIUM_EMOJI"></div>
|
||||
<div class="field"><label>Low Priority Emoji</label><input type="text" data-key="PRIORITY_LOW_EMOJI"></div>
|
||||
<div class="field"><label>Claimer Emoji Fallback</label><input type="text" data-key="CLAIMER_EMOJI_FALLBACK"></div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<!-- 13. Staff -->
|
||||
<div class="section" id="s-staff">
|
||||
<div class="section-header"><h2>Staff</h2><p>Staff IDs, emojis, and admin settings</p><span class="chevron">▼</span></div>
|
||||
<div class="section-header"><h2>Staff</h2><p>Admin and staff role settings</p><span class="chevron">▼</span></div>
|
||||
<div class="section-body"><div class="field-grid">
|
||||
<div class="field full-width"><label>Staff IDs (comma-separated)</label><input type="text" data-key="STAFF_IDS" data-smart="multi-member"></div>
|
||||
<div class="field"><label>Admin ID</label><input type="text" data-key="ADMIN_ID" data-smart="member"></div>
|
||||
<div class="field full-width"><label>Staff Emojis (userId:emoji, comma-separated)</label><input type="text" data-key="STAFF_EMOJIS"><div class="hint">Format: 123456:emoji,789012:emoji</div></div>
|
||||
<div class="field full-width"><label>Additional Staff Roles (comma-separated)</label><input type="text" data-key="ADDITIONAL_STAFF_ROLES"><div class="hint">Role IDs with staff permissions</div></div>
|
||||
<div class="field full-width"><label>Blacklisted Roles (comma-separated)</label><input type="text" data-key="BLACKLISTED_ROLES"><div class="hint">Role IDs that cannot open tickets</div></div>
|
||||
<div class="field full-width"><label>Unclaimed Reminder Thresholds (hours, comma-separated)</label><input type="text" data-key="UNCLAIMED_REMINDER_THRESHOLDS"><div class="hint">e.g. 1,2,4</div></div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
@@ -497,7 +262,6 @@
|
||||
<script defer src="/js/util.js"></script>
|
||||
<script defer src="/js/router.js"></script>
|
||||
<script defer src="/js/fields.js"></script>
|
||||
<script defer src="/js/notifications.js"></script>
|
||||
<script defer src="/js/discord.js"></script>
|
||||
<script defer src="/js/app.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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; }
|
||||
};
|
||||
})();
|
||||
@@ -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',
|
||||
|
||||
@@ -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'));
|
||||
|
||||
Reference in New Issue
Block a user