Move the entire audit subsystem (system audit, API audit, import audit, user lifecycle audit, frontend telemetry) from core into modules/audit/. Core decoupling via interface-based injection: - AuditRecorderInterface replaces SystemAuditService in 10+ core services - UserLifecycleAuditInterface / ImportAuditInterface for specialized flows - NullAuditRecorder fallback when audit module is disabled - ApiBootstrap/ApiResponse use null-safe callable resolvers Module structure (modules/audit/): - Manifest with routes, permissions, scheduler jobs, authorization policy - 9 services, 8 repositories, 6 domain enums, 4 job handlers - 33 page files, 4 JS files, 8 test files, migration scripts, i18n Core cleanup: - OperationsAuthorizationPolicy, UiCapabilityMap, PermissionService surgically cleaned of audit-specific constants - Sidebar template cleared of hardcoded audit navigation - AuditModuleIsolationContractTest ensures no future core→module coupling All quality gates pass: 1346 tests (19,276 assertions), PHPStan level 5 clean, architecture contracts verified. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
336 lines
12 KiB
JavaScript
336 lines
12 KiB
JavaScript
/**
|
|
* Notification bell runtime component.
|
|
*
|
|
* Works with the native <details> dropdown. Handles polling,
|
|
* notification list rendering, mark-read and dismiss actions.
|
|
*/
|
|
|
|
import { telemetry } from '../../../../js/core/app-telemetry.js';
|
|
import { getAppBase } from '../../../../js/pages/app-list-utils.js';
|
|
|
|
const POLL_INTERVAL_MS = 60_000;
|
|
const TYPE_ICONS = {
|
|
'user.created': 'bi-person-plus',
|
|
'user.deleted': 'bi-person-dash',
|
|
'user.activated': 'bi-person-check',
|
|
'user.deactivated': 'bi-person-x',
|
|
'user.assignment_changed': 'bi-diagram-3',
|
|
'system': 'bi-gear',
|
|
};
|
|
|
|
function getCsrf() {
|
|
const root = document.documentElement;
|
|
return {
|
|
key: root.dataset.csrfKey || '',
|
|
token: root.dataset.csrfToken || '',
|
|
};
|
|
}
|
|
|
|
function timeAgo(dateStr) {
|
|
const now = Date.now();
|
|
const then = new Date(dateStr).getTime();
|
|
const diffSec = Math.floor((now - then) / 1000);
|
|
|
|
if (diffSec < 60) return '< 1m';
|
|
if (diffSec < 3600) return Math.floor(diffSec / 60) + 'm';
|
|
if (diffSec < 86400) return Math.floor(diffSec / 3600) + 'h';
|
|
return Math.floor(diffSec / 86400) + 'd';
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
const d = document.createElement('div');
|
|
d.textContent = str;
|
|
return d.innerHTML;
|
|
}
|
|
|
|
async function fetchJson(url, options = {}) {
|
|
const resp = await fetch(url, options);
|
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
return resp.json();
|
|
}
|
|
|
|
export function initNotificationBell(root = document, config = {}) {
|
|
const details = root.querySelector('[data-notification-bell]');
|
|
const dropdown = root.querySelector('[data-notification-dropdown]');
|
|
if (!details || !dropdown) return { destroy() {} };
|
|
const appBase = getAppBase();
|
|
const endpoint = (path) => new URL(path, appBase).toString();
|
|
|
|
const badge = details.querySelector('[data-notification-badge]');
|
|
const list = dropdown.querySelector('[data-notification-list]');
|
|
const emptyEl = list?.querySelector('.app-empty-state');
|
|
const loadingEl = list?.querySelector('[data-notification-loading]');
|
|
const errorEl = list?.querySelector('[data-notification-error]');
|
|
const markAllBtn = dropdown.querySelector('[data-notification-mark-all]');
|
|
|
|
const ac = new AbortController();
|
|
const signal = ac.signal;
|
|
let pollTimer = null;
|
|
|
|
// --- Badge ---
|
|
function updateBadge(count) {
|
|
if (!badge) return;
|
|
if (count > 0) {
|
|
badge.textContent = count > 99 ? '99+' : String(count);
|
|
badge.style.display = '';
|
|
} else {
|
|
badge.textContent = '';
|
|
badge.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
// --- State management ---
|
|
function showState(state) {
|
|
if (loadingEl) loadingEl.style.display = state === 'loading' ? '' : 'none';
|
|
if (errorEl) errorEl.style.display = state === 'error' ? '' : 'none';
|
|
if (emptyEl) emptyEl.style.display = state === 'empty' ? '' : 'none';
|
|
}
|
|
|
|
// --- Render ---
|
|
function renderNotifications(notifications) {
|
|
if (!list) return;
|
|
|
|
// Remove rendered items, keep state elements
|
|
list.querySelectorAll('.app-notification-item').forEach(el => el.remove());
|
|
|
|
if (notifications.length === 0) {
|
|
showState('empty');
|
|
return;
|
|
}
|
|
showState('success');
|
|
|
|
const frag = document.createDocumentFragment();
|
|
for (const n of notifications) {
|
|
frag.appendChild(createNotificationItem(n));
|
|
}
|
|
list.insertBefore(frag, emptyEl);
|
|
}
|
|
|
|
function createNotificationItem(n) {
|
|
const item = document.createElement('div');
|
|
item.className = 'app-notification-item' + (n.is_read === 0 ? ' app-notification-item--unread' : '');
|
|
item.dataset.notificationId = n.id;
|
|
|
|
const iconClass = TYPE_ICONS[n.type] || 'bi-bell';
|
|
const bodyHtml = n.body ? `<p class="app-notification-item__body">${escapeHtml(n.body)}</p>` : '';
|
|
|
|
item.innerHTML =
|
|
`<span class="app-notification-item__icon"><i class="bi ${escapeHtml(iconClass)}"></i></span>` +
|
|
`<div class="app-notification-item__content">` +
|
|
`<p class="app-notification-item__title">${escapeHtml(n.title)}</p>` +
|
|
bodyHtml +
|
|
`<span class="app-notification-item__time">${escapeHtml(timeAgo(n.created))}</span>` +
|
|
`</div>` +
|
|
`<div class="app-notification-item__actions">` +
|
|
(n.is_read === 0 ? `<button type="button" class="app-notification-item__action-btn" data-action="mark-read" title="${escapeHtml(dropdown.dataset.labelMarkRead || '')}" aria-label="${escapeHtml(dropdown.dataset.labelMarkRead || '')}"><i class="bi bi-check2"></i></button>` : '') +
|
|
`<button type="button" class="app-notification-item__action-btn" data-action="dismiss" title="${escapeHtml(dropdown.dataset.labelDismiss || '')}" aria-label="${escapeHtml(dropdown.dataset.labelDismiss || '')}"><i class="bi bi-x"></i></button>` +
|
|
`</div>`;
|
|
|
|
// Keyboard + click navigation for items with a link
|
|
if (n.link) {
|
|
item.classList.add('app-notification-item--link');
|
|
item.setAttribute('role', 'link');
|
|
item.setAttribute('tabindex', '0');
|
|
|
|
const navigate = () => {
|
|
if (n.is_read === 0) markRead(n.id);
|
|
window.location.href = endpoint(String(n.link));
|
|
};
|
|
|
|
item.addEventListener('click', (e) => {
|
|
if (e.target.closest('[data-action]')) return;
|
|
navigate();
|
|
}, { signal });
|
|
|
|
item.addEventListener('keydown', (e) => {
|
|
if (e.target.closest('[data-action]')) return;
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
navigate();
|
|
}
|
|
}, { signal });
|
|
} else {
|
|
item.addEventListener('click', (e) => {
|
|
if (e.target.closest('[data-action]')) return;
|
|
}, { signal });
|
|
}
|
|
|
|
// Action buttons
|
|
item.addEventListener('click', (e) => {
|
|
const actionBtn = e.target.closest('[data-action]');
|
|
if (!actionBtn) return;
|
|
e.stopPropagation();
|
|
const action = actionBtn.dataset.action;
|
|
if (action === 'mark-read') markRead(n.id);
|
|
if (action === 'dismiss') dismiss(n.id);
|
|
}, { signal });
|
|
|
|
return item;
|
|
}
|
|
|
|
// --- API ---
|
|
async function loadNotifications() {
|
|
showState('loading');
|
|
list?.querySelectorAll('.app-notification-item').forEach(el => el.remove());
|
|
try {
|
|
const data = await fetchJson(endpoint('notifications/list-data'), { signal });
|
|
if (data.ok) {
|
|
renderNotifications(data.notifications || []);
|
|
updateBadge(data.unread_count ?? 0);
|
|
} else {
|
|
showState('error');
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof DOMException && err.name === 'AbortError') return;
|
|
showState('error');
|
|
telemetry.capture('warn_once', {
|
|
message: 'Notification bell: load failed',
|
|
meta: { module: 'notifications', component: 'notification-bell', action: 'load' },
|
|
});
|
|
}
|
|
}
|
|
|
|
async function pollUnreadCount() {
|
|
try {
|
|
const data = await fetchJson(endpoint('notifications/unread-count-data'), { signal });
|
|
if (data.ok) {
|
|
updateBadge(data.unread_count ?? 0);
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof DOMException && err.name === 'AbortError') return;
|
|
telemetry.capture('warn_once', {
|
|
message: 'Notification bell: poll failed',
|
|
meta: { module: 'notifications', component: 'notification-bell', action: 'poll' },
|
|
});
|
|
}
|
|
}
|
|
|
|
async function markRead(id) {
|
|
try {
|
|
const csrf = getCsrf();
|
|
const body = new URLSearchParams();
|
|
body.set('id', String(id));
|
|
if (csrf.key) body.set(csrf.key, csrf.token);
|
|
|
|
const data = await fetchJson(endpoint('notifications/mark-read-data'), {
|
|
method: 'POST',
|
|
body,
|
|
signal,
|
|
});
|
|
if (data.ok) {
|
|
updateBadge(data.unread_count ?? 0);
|
|
if (details.open) loadNotifications();
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof DOMException && err.name === 'AbortError') return;
|
|
telemetry.capture('warn_once', {
|
|
message: 'Notification bell: mark-read failed',
|
|
meta: { module: 'notifications', component: 'notification-bell', action: 'mark-read' },
|
|
});
|
|
}
|
|
}
|
|
|
|
async function markAllRead() {
|
|
try {
|
|
const csrf = getCsrf();
|
|
const body = new URLSearchParams();
|
|
body.set('all', '1');
|
|
if (csrf.key) body.set(csrf.key, csrf.token);
|
|
|
|
const data = await fetchJson(endpoint('notifications/mark-read-data'), {
|
|
method: 'POST',
|
|
body,
|
|
signal,
|
|
});
|
|
if (data.ok) {
|
|
updateBadge(data.unread_count ?? 0);
|
|
if (details.open) loadNotifications();
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof DOMException && err.name === 'AbortError') return;
|
|
telemetry.capture('warn_once', {
|
|
message: 'Notification bell: mark-all-read failed',
|
|
meta: { module: 'notifications', component: 'notification-bell', action: 'mark-all-read' },
|
|
});
|
|
}
|
|
}
|
|
|
|
async function dismiss(id) {
|
|
try {
|
|
const csrf = getCsrf();
|
|
const body = new URLSearchParams();
|
|
body.set('id', String(id));
|
|
if (csrf.key) body.set(csrf.key, csrf.token);
|
|
|
|
const data = await fetchJson(endpoint('notifications/delete-data'), {
|
|
method: 'POST',
|
|
body,
|
|
signal,
|
|
});
|
|
if (data.ok) {
|
|
updateBadge(data.unread_count ?? 0);
|
|
const el = list?.querySelector(`[data-notification-id="${id}"]`);
|
|
if (el) {
|
|
el.remove();
|
|
if (list && !list.querySelector('.app-notification-item') && emptyEl) {
|
|
emptyEl.style.display = '';
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof DOMException && err.name === 'AbortError') return;
|
|
telemetry.capture('warn_once', {
|
|
message: 'Notification bell: dismiss failed',
|
|
meta: { module: 'notifications', component: 'notification-bell', action: 'dismiss' },
|
|
});
|
|
}
|
|
}
|
|
|
|
// --- Events ---
|
|
details.addEventListener('toggle', () => {
|
|
if (details.open) loadNotifications();
|
|
}, { signal });
|
|
|
|
if (markAllBtn) {
|
|
markAllBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
markAllRead();
|
|
}, { signal });
|
|
}
|
|
|
|
// --- Polling ---
|
|
function startPolling() {
|
|
stopPolling();
|
|
pollTimer = setInterval(() => {
|
|
if (document.visibilityState !== 'hidden') {
|
|
pollUnreadCount();
|
|
}
|
|
}, POLL_INTERVAL_MS);
|
|
}
|
|
|
|
function stopPolling() {
|
|
if (pollTimer !== null) {
|
|
clearInterval(pollTimer);
|
|
pollTimer = null;
|
|
}
|
|
}
|
|
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.visibilityState === 'visible') {
|
|
pollUnreadCount();
|
|
startPolling();
|
|
} else {
|
|
stopPolling();
|
|
}
|
|
}, { signal });
|
|
|
|
startPolling();
|
|
|
|
return {
|
|
destroy() {
|
|
ac.abort();
|
|
stopPolling();
|
|
},
|
|
};
|
|
}
|