Files
breadcrumb-the-shire/modules/notifications/web/js/components/app-notification-bell.js
fs 0c351f6aff refactor(audit): extract audit domain into self-contained module
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>
2026-03-25 21:12:49 +01:00

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();
},
};
}