bmc_hub/static/js/notifications.js
Christian b43e9f797d feat: Add reminder system for sag cases with user preferences and notification channels
- Implemented user notification preferences table for managing default notification settings.
- Created sag_reminders table to define reminder rules with various trigger types and recipient configurations.
- Developed sag_reminder_queue for processing reminder events triggered by status changes or scheduled times.
- Added sag_reminder_logs to track reminder notifications and user interactions.
- Introduced frontend notification system using Bootstrap 5 Toast for displaying reminders.
- Created email template for sending reminders with case details and action links.
- Implemented rate limiting for user notifications to prevent spamming.
- Added triggers and functions for automatic updates and reminder processing.
2026-02-06 10:47:14 +01:00

433 lines
15 KiB
JavaScript

/**
* BMC Hub Reminder Notifications System
*
* Frontend system for displaying reminder popups using Bootstrap 5 Toast
* Polls API endpoint for pending reminders every 30 seconds
*/
class ReminderNotifications {
constructor() {
this.pollingInterval = 30000; // 30 seconds
this.isPolling = false;
this.userId = this._getUserId();
this.toastContainer = null;
this.shownTtlMs = 10 * 60 * 1000; // 10 minutes
this.shownCache = new Map();
this.priorityColors = {
'low': '#6c757d',
'normal': '#0f4c75',
'high': '#ffc107',
'urgent': '#dc3545'
};
this.priorityLabels = {
'low': 'Lav',
'normal': 'Normal',
'high': 'Høj',
'urgent': 'Kritisk'
};
this.snoozeOptions = [
{ label: '15 min', minutes: 15 },
{ label: '30 min', minutes: 30 },
{ label: '1 time', minutes: 60 },
{ label: '4 timer', minutes: 240 },
{ label: '1 dag', minutes: 1440 },
{ label: 'Skjul...', minutes: 'custom' }
];
this._initToastContainer();
}
/**
* Initialize the toast container on page load
*/
_initToastContainer() {
// Check if container already exists
let container = document.getElementById('reminder-toast-container');
if (!container) {
container = document.createElement('div');
container.id = 'reminder-toast-container';
container.setAttribute('aria-live', 'polite');
container.setAttribute('aria-atomic', 'true');
container.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
width: 400px;
max-width: 90%;
`;
document.body.appendChild(container);
}
this.toastContainer = container;
}
/**
* Start polling for reminders
*/
startPolling() {
if (this.isPolling) return;
this.isPolling = true;
console.log('🔔 Reminder polling started');
// Check immediately first
this._checkReminders();
// Then poll at intervals
this.pollingIntervalId = setInterval(() => {
this._checkReminders();
}, this.pollingInterval);
}
/**
* Stop polling for reminders
*/
stopPolling() {
if (this.pollingIntervalId) {
clearInterval(this.pollingIntervalId);
}
this.isPolling = false;
console.log('🛑 Reminder polling stopped');
}
/**
* Fetch pending reminders from API
*/
async _checkReminders() {
try {
const response = await fetch(`/api/v1/reminders/pending/me?user_id=${this.userId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
credentials: 'include'
});
if (!response.ok) {
if (response.status !== 401) { // Don't log 401 errors
console.error(`Reminder check failed: ${response.status}`);
}
return;
}
const reminders = await response.json();
if (reminders && reminders.length > 0) {
reminders.forEach(reminder => {
if (this._shouldShowReminder(reminder)) {
this.showReminder(reminder);
this._markShown(reminder);
}
});
}
} catch (error) {
console.error('❌ Error checking reminders:', error);
}
}
_shouldShowReminder(reminder) {
if (!reminder || !reminder.id) return false;
const now = Date.now();
// In-memory cache check
const lastShown = this.shownCache.get(reminder.id);
if (lastShown && (now - lastShown) < this.shownTtlMs) {
return false;
}
// Cross-tab/localStorage check
try {
const stored = localStorage.getItem(`reminder_shown_${reminder.id}`);
if (stored) {
const ts = parseInt(stored, 10);
if (!Number.isNaN(ts) && (now - ts) < this.shownTtlMs) {
return false;
}
}
} catch (e) {
// ignore storage errors
}
return true;
}
_markShown(reminder) {
if (!reminder || !reminder.id) return;
const now = Date.now();
this.shownCache.set(reminder.id, now);
try {
localStorage.setItem(`reminder_shown_${reminder.id}`, String(now));
} catch (e) {
// ignore storage errors
}
}
/**
* Display a single reminder as a toast
*/
showReminder(reminder) {
const toastId = `reminder-${reminder.id}-${Date.now()}`;
const priorityColor = this.priorityColors[reminder.priority] || this.priorityColors['normal'];
const priorityLabel = this.priorityLabels[reminder.priority] || 'Info';
// Build snooze dropdown HTML
const snoozeOptionsHtml = this.snoozeOptions
.map(opt => `<button class="dropdown-item" data-snooze-minutes="${opt.minutes}">${opt.label}</button>`)
.join('');
const toastHTML = `
<div id="${toastId}" class="toast" role="alert" aria-live="assertive" aria-atomic="true"
style="background: white; border-left: 4px solid ${priorityColor}; box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);">
<div class="toast-header" style="border-bottom: 1px solid #e9ecef; padding: 12px 16px;">
<div style="flex: 1;">
<strong class="me-2" style="color: ${priorityColor};">
🔔 ${reminder.title}
</strong>
<small class="text-muted">${priorityLabel}</small>
</div>
<div class="ms-2 text-muted" style="font-size: 12px;">
${new Date().toLocaleTimeString('da-DK', { hour: '2-digit', minute: '2-digit' })}
</div>
<button type="button" class="btn-close btn-close-sm ms-2" data-bs-dismiss="toast" aria-label="Luk"></button>
</div>
<div class="toast-body" style="padding: 12px 16px;">
<div class="mb-3">
<p class="mb-1"><strong>${reminder.case_title}</strong> (#${reminder.sag_id})</p>
<p class="mb-1 text-muted" style="font-size: 14px;">${reminder.customer_name}</p>
${reminder.message ? `<p class="mb-2" style="font-size: 14px;">${reminder.message}</p>` : ''}
</div>
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<a href="/sag/${reminder.sag_id}" class="btn btn-sm btn-primary" style="background: ${priorityColor}; border-color: ${priorityColor};">
➜ Åbn sag
</a>
<div class="btn-group btn-group-sm" role="group">
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="dropdown" aria-expanded="false">
💤 Slumre
</button>
<ul class="dropdown-menu dropdown-menu-end" style="min-width: 150px;">
${snoozeOptionsHtml}
<li><hr class="dropdown-divider"></li>
<li><input type="number" class="form-control form-control-sm mx-2 my-1"
id="custom-snooze-${toastId}" min="1" max="1440" placeholder="Minutter"></li>
<li><button class="dropdown-item btn-custom-snooze" data-toast-id="${toastId}">
Slumre (custom)
</button></li>
</ul>
</div>
<button class="btn btn-sm btn-outline-danger" data-dismiss-reminder="${reminder.id}">
✕ Afvis
</button>
</div>
</div>
</div>
`;
// Add toast to container
const parser = new DOMParser();
const toastElement = parser.parseFromString(toastHTML, 'text/html').body.firstChild;
this.toastContainer.appendChild(toastElement);
// Initialize Bootstrap toast
const bootstrapToast = new bootstrap.Toast(toastElement, {
autohide: false,
delay: 10000
});
bootstrapToast.show();
// Attach event listeners
this._attachReminderEventListeners(toastElement, reminder.id, toastId);
// Auto-remove after dismissal
toastElement.addEventListener('hidden.bs.toast', () => {
toastElement.remove();
});
}
/**
* Attach event listeners to reminder toast
*/
_attachReminderEventListeners(toastElement, reminderId, toastId) {
// Snooze buttons
toastElement.querySelectorAll('[data-snooze-minutes]').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
const minutes = btn.dataset.snoozeMinutes;
if (minutes === 'custom') {
// Show custom input
toastElement.querySelector(`#custom-snooze-${toastId}`)?.focus();
} else {
await this._snoozeReminder(reminderId, parseInt(minutes), toastElement);
}
});
});
// Custom snooze button
toastElement.querySelectorAll('.btn-custom-snooze').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
const input = toastElement.querySelector(`#custom-snooze-${toastId}`);
const minutes = parseInt(input.value);
if (minutes >= 1 && minutes <= 1440) {
await this._snoozeReminder(reminderId, minutes, toastElement);
} else {
alert('Indtast venligst mellem 1 og 1440 minutter');
}
});
});
// Dismiss button
toastElement.querySelectorAll('[data-dismiss-reminder]').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
await this._dismissReminder(reminderId, toastElement);
});
});
}
/**
* Snooze a reminder
*/
async _snoozeReminder(reminderId, minutes, toastElement) {
try {
const response = await fetch(`/api/v1/sag/reminders/${reminderId}/snooze`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
credentials: 'include',
body: JSON.stringify({
duration_minutes: minutes
})
});
if (response.ok) {
console.log(`✅ Reminder ${reminderId} snoozed for ${minutes} minutes`);
// Hide the toast
const toast = bootstrap.Toast.getInstance(toastElement);
if (toast) toast.hide();
} else {
console.error(`Failed to snooze reminder: ${response.status}`);
alert('Kunne ikke slumre reminder');
}
} catch (error) {
console.error('❌ Error snoozing reminder:', error);
alert('Fejl ved slumre');
}
}
/**
* Dismiss a reminder permanently
*/
async _dismissReminder(reminderId, toastElement) {
try {
const response = await fetch(`/api/v1/sag/reminders/${reminderId}/dismiss`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
credentials: 'include',
body: JSON.stringify({
reason: 'Dismissed by user'
})
});
if (response.ok) {
console.log(`✅ Reminder ${reminderId} dismissed`);
// Hide the toast
const toast = bootstrap.Toast.getInstance(toastElement);
if (toast) toast.hide();
} else {
console.error(`Failed to dismiss reminder: ${response.status}`);
alert('Kunne ikke afvise reminder');
}
} catch (error) {
console.error('❌ Error dismissing reminder:', error);
alert('Fejl ved afvisning');
}
}
/**
* Extract user ID from auth token or page context
*/
_getUserId() {
// Try to get from localStorage/sessionStorage if available
const token = localStorage.getItem('access_token') || sessionStorage.getItem('access_token');
if (token) {
try {
// Decode JWT payload
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.sub || payload.user_id;
} catch (e) {
console.warn('Could not decode token for user_id');
}
}
// Try to get from page meta tag
const metaTag = document.querySelector('meta[name="user-id"]');
if (metaTag) {
return metaTag.getAttribute('content');
}
console.warn('⚠️ Could not determine user_id for reminders');
return null;
}
}
// Global instance
let reminderNotifications = null;
/**
* Initialize reminder system when DOM is ready
*/
function initReminderNotifications() {
if (!reminderNotifications) {
reminderNotifications = new ReminderNotifications();
// Only start polling if user is authenticated
if (reminderNotifications.userId) {
reminderNotifications.startPolling();
console.log('✅ Reminder system initialized');
} else {
console.warn('⚠️ Reminder system not initialized - user not authenticated');
}
}
}
// Auto-init when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initReminderNotifications);
} else {
initReminderNotifications();
}
// Handle page visibility (pause polling when tab not visible)
document.addEventListener('visibilitychange', () => {
if (reminderNotifications) {
if (document.hidden) {
reminderNotifications.stopPolling();
console.log('💤 Reminder polling paused (tab hidden)');
} else {
reminderNotifications.startPolling();
console.log('👀 Reminder polling resumed');
}
}
});
// Export for manual control if needed
window.ReminderNotifications = ReminderNotifications;