- 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.
433 lines
15 KiB
JavaScript
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;
|