diff --git a/db/init/init.sql b/db/init/init.sql index 2571a8a..8ac269d 100644 --- a/db/init/init.sql +++ b/db/init/init.sql @@ -338,6 +338,9 @@ CREATE TABLE IF NOT EXISTS `user_notifications` ( `body` VARCHAR(500) NULL, `link` VARCHAR(500) NULL, `data` JSON NULL, + `dedupe_fingerprint` CHAR(64) NULL, + `dedupe_bucket` INT UNSIGNED NULL, + `dedupe_until` DATETIME NULL DEFAULT NULL, `is_read` TINYINT(1) NOT NULL DEFAULT 0, `read_at` DATETIME NULL DEFAULT NULL, `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -346,6 +349,7 @@ CREATE TABLE IF NOT EXISTS `user_notifications` ( KEY `idx_un_recipient_created` (`recipient_user_id`, `created` DESC), KEY `idx_un_tenant_created` (`tenant_id`, `created` DESC), KEY `idx_un_cleanup` (`is_read`, `read_at`), + UNIQUE KEY `uniq_un_dedupe_bucket` (`recipient_user_id`, `dedupe_fingerprint`, `dedupe_bucket`), CONSTRAINT `fk_un_recipient` FOREIGN KEY (`recipient_user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, CONSTRAINT `fk_un_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; @@ -1048,7 +1052,8 @@ VALUES ('system_audit.purge', 'Can purge system audit logs', 1, 1), ('api_tokens.manage', 'Can manage user API tokens', 1, 1), ('stats.view', 'Can view statistics', 1, 1), - ('roles.assign_all', 'Can assign all roles (bypass assignable-roles check)', 1, 1) + ('roles.assign_all', 'Can assign all roles (bypass assignable-roles check)', 1, 1), + ('notifications.view', 'Can view and manage own notifications', 1, 1) ON DUPLICATE KEY UPDATE `description` = VALUES(`description`), `active` = VALUES(`active`), @@ -1154,6 +1159,14 @@ JOIN permissions p ON p.`key` IN ( WHERE r.description IN ('Admin', 'Administrator') OR r.id = 1 ON DUPLICATE KEY UPDATE role_id = role_id; +-- Notifications are user-scoped and should be visible to all active roles by default. +INSERT INTO `role_permissions` (`role_id`, `permission_id`, `created`) +SELECT r.id, p.id, NOW() +FROM roles r +JOIN permissions p ON p.`key` = 'notifications.view' +WHERE r.active = 1 +ON DUPLICATE KEY UPDATE role_id = role_id; + INSERT INTO `role_permissions` (`role_id`, `permission_id`, `created`) SELECT r.id, p.id, NOW() FROM roles r @@ -1277,3 +1290,63 @@ VALUES ('remember_token_lifetime_days', '30', 'setting.remember_token_lifetime_days') ON DUPLICATE KEY UPDATE `key` = `key`; + +-- ============================================= +-- Seed: sample notifications for demo user +-- ============================================= +INSERT INTO `user_notifications` ( + `recipient_user_id`, `tenant_id`, `type`, `title`, `body`, `link`, `data`, `is_read`, `read_at`, `created` +) +SELECT + u.id, + (SELECT t.id FROM tenants t ORDER BY t.id LIMIT 1), + n.type, n.title, n.body, n.link, n.data, n.is_read, n.read_at, n.created +FROM users u +CROSS JOIN ( + SELECT 'user.created' AS type, + 'Neuer Benutzer: Max Mustermann' AS title, + NULL AS body, + 'admin/users/edit/00000000-0000-0000-0000-000000000001' AS link, + '{"user_id":99}' AS data, + 0 AS is_read, + NULL AS read_at, + DATE_SUB(NOW(), INTERVAL 5 MINUTE) AS created + UNION ALL + SELECT 'user.created', + 'Neuer Benutzer: Erika Musterfrau', + NULL, + 'admin/users/edit/00000000-0000-0000-0000-000000000002', + '{"user_id":98}', + 0, + NULL, + DATE_SUB(NOW(), INTERVAL 30 MINUTE) + UNION ALL + SELECT 'user.deleted', + 'Benutzer geloescht: Karl Schmidt', + NULL, + NULL, + '{"user_id":97}', + 0, + NULL, + DATE_SUB(NOW(), INTERVAL 2 HOUR) + UNION ALL + SELECT 'system', + 'Systemwartung abgeschlossen', + 'Alle Dienste laufen wieder normal.', + NULL, + NULL, + 1, + DATE_SUB(NOW(), INTERVAL 3 HOUR), + DATE_SUB(NOW(), INTERVAL 4 HOUR) + UNION ALL + SELECT 'user.created', + 'Neuer Benutzer: Anna Weber', + NULL, + 'admin/users/edit/00000000-0000-0000-0000-000000000003', + '{"user_id":96}', + 1, + DATE_SUB(NOW(), INTERVAL 1 DAY), + DATE_SUB(NOW(), INTERVAL 2 DAY) +) n +WHERE u.email = 'demo@user.com' + AND NOT EXISTS (SELECT 1 FROM user_notifications un WHERE un.recipient_user_id = u.id); diff --git a/docs/index.md b/docs/index.md index d5bde34..bcf77f6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,6 @@ # Dokumentation -Letzte Aktualisierung: 2026-03-18 +Letzte Aktualisierung: 2026-03-19 Diese Dokumentation ist nach dem Diataxis-Modell in vier Quadranten gegliedert: Tutorials zum Lernen, How-to Guides zum Nachschlagen von Aufgaben, Explanation zum Verstehen und Reference zum Nachschlagen von Fakten. @@ -101,4 +101,6 @@ Diese Dokumentation ist nach dem Diataxis-Modell in vier Quadranten gegliedert: - Alle CLI-Kommandos (`php bin/console`), Bootstrap-Helfer, Anleitung neue Kommandos. - `/docs/reference-module-manifest-contract.md` - Verbindlicher Runtime- und Manifest-Contract fuer Module (V1.1). +- `/docs/reference-notifications-module.md` + - Verbindliche Referenz fuer Notifications (Events, Guardrails, Dedupe, Bell-Vertrag, Tests). - Agent-Workflow (Rollen, State-Machine, Contracts): siehe `/.agents/workflow.md` diff --git a/docs/reference-frontend-css.md b/docs/reference-frontend-css.md index a4b5db0..5c669d7 100644 --- a/docs/reference-frontend-css.md +++ b/docs/reference-frontend-css.md @@ -1,6 +1,6 @@ # Frontend CSS -Letzte Aktualisierung: 2026-03-06 +Letzte Aktualisierung: 2026-03-19 ## Ziel @@ -76,3 +76,10 @@ Regel: Vendor-Overrides nur für Bibliotheks-Selektoren; projektspezifische UI-S 3. Wird die Gruppe auf der Zielseite wirklich geladen (`style_groups`)? 4. Gibt es ungewollte Seiteneffekte auf anderen Seiten? 5. Sind bestehende Variablen/Konventionen eingehalten? + +## Module-CSS Token-Regel + +1. Modul-CSS nutzt primär Core-Tokens (`--app-*`) statt eigener Farbwerte. +2. Modul-spezifische Variablen sind als Alias erlaubt, muessen aber auf Core-Tokens mappen. +3. Keine Legacy-/Phantom-Tokens (`--app-text`, `--app-hover`, `--app-border-light`) neu einführen. +4. Harte Hex-Fallbacks nur als Ausnahmefall, nicht als Standard. diff --git a/docs/reference-notifications-module.md b/docs/reference-notifications-module.md new file mode 100644 index 0000000..2d22886 --- /dev/null +++ b/docs/reference-notifications-module.md @@ -0,0 +1,115 @@ +# Notifications Modul (V1) + +Letzte Aktualisierung: 2026-03-19 + +## Ziel + +Referenz fuer das Modul `notifications`: Event-Registrierung, Erzeugungsregeln, Guardrails, Frontend-Vertrag und Testbarkeit. + +## Scope v1 + +- In-App Notifications (Bell + Dropdown), kein E-Mail-Channel. +- Core-5 Eventtypen: + - `user.created` + - `user.deleted` + - `user.activated` + - `user.deactivated` + - `user.assignment_changed` +- Dedupe fuer diese Typen mit 30 Minuten Fenster. + +## Modul-Verkabelung + +Kanonische Quelle: `modules/notifications/module.php` + +- `event_listeners` + - mappt Eventtyp -> Listenerklasse + Methode `handle`. +- `ui_slots` + - `topbar.right_item`: Bell-Template + - `layout.head_style`: Modul-CSS fuer Topbar + Dropdown + - `runtime.component`: Bell-JS (`initNotificationBell`) +- `permissions` + - `notifications.view` +- `authorization_policies` + - `NotificationsAuthorizationPolicy` +- `layout_context_providers` + - liefert `notifications.nav.unread_count` in den Layout-Context +- `session_providers` + - synchronisiert `module.notifications.unread_count` aus DB + +## Event -> Notification Fluss + +1. Ein User-Lifecycle-Workflow dispatcht ein Domain-Event (z. B. `user.activated`). +2. Der in `module.php` registrierte Listener verarbeitet das Payload. +3. Listener ruft `NotificationService` auf. +4. `NotificationService` orchestriert Empfaenger/Fan-out und Dedupe-Metadaten. +5. `NotificationRepository` persistiert in `user_notifications`. +6. Bell-Endpunkte lesen tenant-scoped Daten und aktualisieren den Session-`unread_count`. + +## Datenvertrag Notification + +Persistierter Kernvertrag: + +- `type` (string) +- `title` (string) +- `body` (nullable string) +- `link` (nullable string) +- `data` (nullable JSON) + +Dedupe-Metadaten (intern): + +- `dedupe_fingerprint` +- `dedupe_bucket` +- `dedupe_until` + +## Guardrails (Server) + +- Alle Bell-Endpunkte erzwingen: + - Login + - Ability `notifications.view` +- POST-Endpunkte (`mark-read`, `delete`) pruefen CSRF. +- Tenant-Scope wird serverseitig aus `current_tenant` erzwungen. +- SQL ausschliesslich im Repository (`NotificationRepository`), Service nur Orchestrierung. + +## Guardrails (Frontend) + +- Endpunkte werden mit App-Base-URL gebaut (kein harter Root-Pfad). +- Fehlerpfade laufen ueber zentrale Telemetrie (`warn_once`). +- A11y-Klickzeilen im Dropdown sind lokal gestylt, damit globale `[role="button"]` Regeln nicht durchschlagen. +- Default-`details/summary`-Chevron wird pro Komponente ueber `data-summary-chevron="none"` deaktiviert. + +## Template/Partial Best Practice + +- Core-Partials immer ueber `templatePath('partials/...')` einbinden. +- Keine fragilen relativen Tiefenpfade auf Core-Templates verwenden. +- Modulinterne Templates bleiben lokal relativ zum Modul. + +## CSS Token Best Practice (Module) + +- Modul-CSS soll auf Core-Variablen aufsetzen (z. B. `--app-color`, `--app-muted-color`, `--app-dropdown-*`, `--app-action-danger-*`). +- Modul-spezifische Aliase sind ok (`--app-notification-*`), solange sie auf Core-Tokens mappen. +- Harte Hex-Fallbacks nur als Ausnahme; Standard ist tokenbasiert. + +## Tests + +### Unit + +- Listener-Tests fuer alle Core-5 Typen: + - Empfaengerregeln + - Actor-Exclusion + - ungultige/leere Payloads +- Service-Tests fuer Dedupe (created vs suppressed) +- Policy-Tests fuer `notifications.view` + +### Integration/Flow + +- End-to-End fuer create/delete/activate/deactivate/assignment-change. +- Bell-Endpunkte mit Tenant-Scope und Permission-Pruefung. +- Bulk-Flows dispatchen konsistent. + +### Manueller Smoke + +1. Zwei Admins im selben Tenant, einer als Actor. +2. Actor fuehrt User-Aktion aus. +3. Zweiter Admin sieht Bell-Badge + Listeneintrag. +4. Wiederholung innerhalb 30 Minuten erzeugt kein Duplikat. + diff --git a/lib/Repository/Tenant/UserTenantRepository.php b/lib/Repository/Tenant/UserTenantRepository.php index 56da46f..1ff6d26 100644 --- a/lib/Repository/Tenant/UserTenantRepository.php +++ b/lib/Repository/Tenant/UserTenantRepository.php @@ -85,6 +85,33 @@ class UserTenantRepository implements UserTenantRepositoryInterface return $result; } + /** @return list */ + public function listActiveUserIdsByTenantId(int $tenantId): array + { + if ($tenantId <= 0) { + return []; + } + + $rows = DB::select( + 'select ut.user_id from user_tenants ut join users u on u.id = ut.user_id and u.active = 1 where ut.tenant_id = ?', + (string) $tenantId + ); + + if (!is_array($rows)) { + return []; + } + + $ids = []; + foreach ($rows as $row) { + $data = $row['user_tenants'] ?? $row; + if (is_array($data) && isset($data['user_id'])) { + $ids[] = (int) $data['user_id']; + } + } + + return array_values(array_unique($ids)); + } + private function extractIntField(array $row, string $field): int { foreach ($row as $value) { diff --git a/lib/Repository/Tenant/UserTenantRepositoryInterface.php b/lib/Repository/Tenant/UserTenantRepositoryInterface.php index 5ace2f4..4ff6c02 100644 --- a/lib/Repository/Tenant/UserTenantRepositoryInterface.php +++ b/lib/Repository/Tenant/UserTenantRepositoryInterface.php @@ -12,4 +12,7 @@ interface UserTenantRepositoryInterface public function countUsersByTenantIds(array $tenantIds): array; public function countActiveUsersByTenantIds(array $tenantIds): array; + + /** @return list */ + public function listActiveUserIdsByTenantId(int $tenantId): array; } diff --git a/lib/Service/User/UserAccountService.php b/lib/Service/User/UserAccountService.php index 60dfa81..e6ece9c 100644 --- a/lib/Service/User/UserAccountService.php +++ b/lib/Service/User/UserAccountService.php @@ -103,10 +103,8 @@ class UserAccountService } // Capture tenant associations before CASCADE deletes them - $preDeletionTenantIds = array_column( - $this->userAssignmentService->buildAssignmentsForUser($userId)['tenants'] ?? [], - 'id' - ); + $preDeletionAssignments = $this->safeAssignmentsForUser($userId); + $preDeletionTenantIds = $this->extractTenantIdsFromAssignments($preDeletionAssignments); $deleted = $this->userWriteRepository->delete($userId); if (!$deleted) { @@ -156,6 +154,27 @@ class UserAccountService } } + $deletedUsers = []; + foreach ($uuids as $deleteUuid) { + $user = $this->userReadRepository->findByUuid((string) $deleteUuid); + if (!$user || !isset($user['id'])) { + continue; + } + + $deleteUserId = (int) $user['id']; + if ($deleteUserId <= 0) { + continue; + } + + $assignments = $this->safeAssignmentsForUser($deleteUserId); + $deletedUsers[] = [ + 'id' => $deleteUserId, + 'uuid' => (string) ($user['uuid'] ?? ''), + 'display_name' => trim((string) ($user['display_name'] ?? '')), + 'tenant_ids' => $this->extractTenantIdsFromAssignments($assignments), + ]; + } + $deleted = $this->userWriteRepository->deleteByUuids($uuids); if (!$deleted) { return ['ok' => false, 'error' => 'delete_failed']; @@ -171,6 +190,16 @@ class UserAccountService ], ]); + foreach ($deletedUsers as $deletedUser) { + $this->eventDispatcher?->dispatch('user.deleted', [ + 'user_id' => (int) ($deletedUser['id'] ?? 0), + 'uuid' => (string) ($deletedUser['uuid'] ?? ''), + 'actor_user_id' => $currentUserId, + 'display_name' => (string) ($deletedUser['display_name'] ?? ''), + 'tenant_ids' => is_array($deletedUser['tenant_ids'] ?? null) ? $deletedUser['tenant_ids'] : [], + ]); + } + return ['ok' => true, 'count' => count($uuids)]; } @@ -434,6 +463,20 @@ class UserAccountService ], ]); + if ($activeChanged) { + $tenantIds = $this->extractTenantIdsFromAssignments( + $this->safeAssignmentsForUser($userId) + ); + $eventType = ((int) $form['active'] === 1) ? 'user.activated' : 'user.deactivated'; + $this->eventDispatcher?->dispatch($eventType, [ + 'user_id' => $userId, + 'uuid' => (string) ($existing['uuid'] ?? ''), + 'display_name' => trim((string) ($existing['display_name'] ?? '')), + 'actor_user_id' => $currentUserId, + 'tenant_ids' => $tenantIds, + ]); + } + return ['ok' => true, 'form' => $form]; } @@ -475,6 +518,17 @@ class UserAccountService 'after' => ['active' => $active ? 1 : 0], ]); + $tenantIds = $this->extractTenantIdsFromAssignments( + $this->safeAssignmentsForUser($userId) + ); + $this->eventDispatcher?->dispatch($active ? 'user.activated' : 'user.deactivated', [ + 'user_id' => $userId, + 'uuid' => (string) ($user['uuid'] ?? ''), + 'display_name' => trim((string) ($user['display_name'] ?? '')), + 'actor_user_id' => $currentUserId, + 'tenant_ids' => $tenantIds, + ]); + return ['ok' => true, 'user' => $user]; } @@ -519,6 +573,29 @@ class UserAccountService ], ]); + foreach ($uuids as $uuid) { + $user = $this->userReadRepository->findByUuid((string) $uuid); + if (!$user || !isset($user['id'])) { + continue; + } + + $targetUserId = (int) ($user['id'] ?? 0); + if ($targetUserId <= 0) { + continue; + } + + $tenantIds = $this->extractTenantIdsFromAssignments( + $this->safeAssignmentsForUser($targetUserId) + ); + $this->eventDispatcher?->dispatch($active ? 'user.activated' : 'user.deactivated', [ + 'user_id' => $targetUserId, + 'uuid' => (string) ($user['uuid'] ?? ''), + 'display_name' => trim((string) ($user['display_name'] ?? '')), + 'actor_user_id' => $currentUserId, + 'tenant_ids' => $tenantIds, + ]); + } + return ['ok' => true, 'count' => count($uuids)]; } @@ -695,6 +772,49 @@ class UserAccountService return ['ok' => true, 'form' => $form]; } + public function dispatchAssignmentChangedIfNeeded( + int $userId, + array $beforeAssignments, + array $afterAssignments, + int $currentUserId = 0 + ): void { + if ($userId <= 0) { + return; + } + + $beforeTenantIds = $this->extractTenantIdsFromAssignments($beforeAssignments); + $afterTenantIds = $this->extractTenantIdsFromAssignments($afterAssignments); + $beforeRoleIds = $this->extractAssignmentIds($beforeAssignments, 'roles'); + $afterRoleIds = $this->extractAssignmentIds($afterAssignments, 'roles'); + $beforeDepartmentIds = $this->extractAssignmentIds($beforeAssignments, 'departments'); + $afterDepartmentIds = $this->extractAssignmentIds($afterAssignments, 'departments'); + + $tenantsChanged = $beforeTenantIds !== $afterTenantIds; + $rolesChanged = $beforeRoleIds !== $afterRoleIds; + $departmentsChanged = $beforeDepartmentIds !== $afterDepartmentIds; + if (!$tenantsChanged && !$rolesChanged && !$departmentsChanged) { + return; + } + + $user = $this->userReadRepository->find($userId); + if (!$user) { + return; + } + + $this->eventDispatcher?->dispatch('user.assignment_changed', [ + 'user_id' => $userId, + 'uuid' => (string) ($user['uuid'] ?? ''), + 'display_name' => trim((string) ($user['display_name'] ?? '')), + 'actor_user_id' => $currentUserId, + 'tenant_ids' => array_values(array_unique(array_merge($beforeTenantIds, $afterTenantIds))), + 'changes' => [ + 'tenants' => ['before' => $beforeTenantIds, 'after' => $afterTenantIds], + 'roles' => ['before' => $beforeRoleIds, 'after' => $afterRoleIds], + 'departments' => ['before' => $beforeDepartmentIds, 'after' => $afterDepartmentIds], + ], + ]); + } + private function filterUuidsByTenantScope(array $uuids, int $currentUserId): array { $allowed = []; @@ -715,6 +835,37 @@ class UserAccountService return array_values(array_unique($allowed)); } + private function extractTenantIdsFromAssignments(array $assignments): array + { + return $this->extractAssignmentIds($assignments, 'tenants'); + } + + private function safeAssignmentsForUser(int $userId): array + { + $assignments = $this->userAssignmentService->buildAssignmentsForUser($userId); + return is_array($assignments) ? $assignments : []; + } + + private function extractAssignmentIds(array $assignments, string $key): array + { + $rows = is_array($assignments[$key] ?? null) ? $assignments[$key] : []; + $ids = []; + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + $id = (int) ($row['id'] ?? 0); + if ($id > 0) { + $ids[] = $id; + } + } + + $ids = array_values(array_unique($ids)); + sort($ids); + + return $ids; + } + private function sanitizeBase(array $input): array { return [ diff --git a/modules/bookmarks/web/css/components/app-bookmark-form.css b/modules/bookmarks/web/css/components/app-bookmark-form.css index 953c2ef..1ac51d3 100644 --- a/modules/bookmarks/web/css/components/app-bookmark-form.css +++ b/modules/bookmarks/web/css/components/app-bookmark-form.css @@ -57,14 +57,14 @@ } .app-bookmark-delete-btn { - color: var(--app-del-color, #c62828); - border-color: var(--app-del-color, #c62828); + color: var(--app-action-danger-background); + border-color: var(--app-action-danger-background); width: 100%; } .app-bookmark-delete-btn:hover { - background: var(--app-del-color, #c62828); - color: #fff; + background: var(--app-action-danger-background); + color: var(--app-action-danger-color); } .app-bookmark-new-group-btn { @@ -102,7 +102,7 @@ .app-bookmark-group-icon-option.app-bookmark-icon-active { border-color: var(--app-primary); background: var(--app-primary); - color: #fff; + color: var(--app-primary-inverse); } .app-bookmark-group-icon-option > i { diff --git a/modules/bookmarks/web/css/layout/app-sidebar-bookmarks.css b/modules/bookmarks/web/css/layout/app-sidebar-bookmarks.css index cc4a1b4..7838581 100644 --- a/modules/bookmarks/web/css/layout/app-sidebar-bookmarks.css +++ b/modules/bookmarks/web/css/layout/app-sidebar-bookmarks.css @@ -130,10 +130,8 @@ max-width: calc(220px - 2 * var(--app-spacing)); border: 1px solid var(--app-border); border-radius: var(--app-border-radius); - background: var(--app-card-bg, var(--app-background-color)); - box-shadow: - 0 4px 16px rgba(0, 0, 0, 0.14), - 0 1px 3px rgba(0, 0, 0, 0.08); + background: var(--app-card-background-color); + box-shadow: var(--app-dropdown-box-shadow); padding: 0.25rem; z-index: 10; } @@ -175,11 +173,11 @@ } .app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu-list .app-sidebar-bookmark-action-danger { - color: var(--app-del-color, #c62828); + color: var(--app-action-danger-background); } .app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu-list .app-sidebar-bookmark-action-danger:hover, .app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu-list .app-sidebar-bookmark-action-danger:focus-visible { - background: rgba(198, 40, 40, 0.12); + background: color-mix(in srgb, var(--app-action-danger-background) 12%, transparent); } } diff --git a/modules/notifications/db/updates/2026-03-19-notifications-permission-defaults.sql b/modules/notifications/db/updates/2026-03-19-notifications-permission-defaults.sql new file mode 100644 index 0000000..0f975ea --- /dev/null +++ b/modules/notifications/db/updates/2026-03-19-notifications-permission-defaults.sql @@ -0,0 +1,13 @@ +INSERT INTO `permissions` (`key`, `description`, `active`, `is_system`) +VALUES ('notifications.view', 'Can view and manage own notifications', 1, 1) +ON DUPLICATE KEY UPDATE + `description` = VALUES(`description`), + `active` = VALUES(`active`), + `is_system` = VALUES(`is_system`); + +INSERT INTO `role_permissions` (`role_id`, `permission_id`, `created`) +SELECT r.id, p.id, NOW() +FROM roles r +JOIN permissions p ON p.`key` = 'notifications.view' +WHERE r.active = 1 +ON DUPLICATE KEY UPDATE role_id = role_id; diff --git a/modules/notifications/db/updates/2026-03-19-user-notifications.sql b/modules/notifications/db/updates/2026-03-19-user-notifications.sql index 4b2bc03..30eab22 100644 --- a/modules/notifications/db/updates/2026-03-19-user-notifications.sql +++ b/modules/notifications/db/updates/2026-03-19-user-notifications.sql @@ -7,6 +7,9 @@ CREATE TABLE IF NOT EXISTS `user_notifications` ( `body` VARCHAR(500) NULL, `link` VARCHAR(500) NULL, `data` JSON NULL, + `dedupe_fingerprint` CHAR(64) NULL, + `dedupe_bucket` INT UNSIGNED NULL, + `dedupe_until` DATETIME NULL DEFAULT NULL, `is_read` TINYINT(1) NOT NULL DEFAULT 0, `read_at` DATETIME NULL DEFAULT NULL, `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -15,6 +18,15 @@ CREATE TABLE IF NOT EXISTS `user_notifications` ( KEY `idx_un_recipient_created` (`recipient_user_id`, `created` DESC), KEY `idx_un_tenant_created` (`tenant_id`, `created` DESC), KEY `idx_un_cleanup` (`is_read`, `read_at`), + UNIQUE KEY `uniq_un_dedupe_bucket` (`recipient_user_id`, `dedupe_fingerprint`, `dedupe_bucket`), CONSTRAINT `fk_un_recipient` FOREIGN KEY (`recipient_user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, CONSTRAINT `fk_un_tenant` FOREIGN KEY (`tenant_id`) REFERENCES `tenants` (`id`) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +ALTER TABLE `user_notifications` + ADD COLUMN IF NOT EXISTS `dedupe_fingerprint` CHAR(64) NULL AFTER `data`, + ADD COLUMN IF NOT EXISTS `dedupe_bucket` INT UNSIGNED NULL AFTER `dedupe_fingerprint`, + ADD COLUMN IF NOT EXISTS `dedupe_until` DATETIME NULL DEFAULT NULL AFTER `dedupe_bucket`; + +CREATE UNIQUE INDEX IF NOT EXISTS `uniq_un_dedupe_bucket` + ON `user_notifications` (`recipient_user_id`, `dedupe_fingerprint`, `dedupe_bucket`); diff --git a/modules/notifications/i18n/default_de.json b/modules/notifications/i18n/default_de.json index a31062c..c5002ad 100644 --- a/modules/notifications/i18n/default_de.json +++ b/modules/notifications/i18n/default_de.json @@ -6,5 +6,9 @@ "Dismiss": "Verwerfen", "Action failed": "Aktion fehlgeschlagen", "New user: %s": "Neuer Benutzer: %s", - "User deleted: %s": "Benutzer gelöscht: %s" + "User deleted: %s": "Benutzer gelöscht: %s", + "User activated: %s": "Benutzer aktiviert: %s", + "User deactivated: %s": "Benutzer deaktiviert: %s", + "User assignments updated: %s": "Benutzer-Zuweisungen aktualisiert: %s", + "Tenant, role, or department assignments were changed.": "Mandanten-, Rollen- oder Abteilungszuweisungen wurden geändert." } diff --git a/modules/notifications/i18n/default_en.json b/modules/notifications/i18n/default_en.json index a0dba59..5a2c464 100644 --- a/modules/notifications/i18n/default_en.json +++ b/modules/notifications/i18n/default_en.json @@ -6,5 +6,9 @@ "Dismiss": "Dismiss", "Action failed": "Action failed", "New user: %s": "New user: %s", - "User deleted: %s": "User deleted: %s" + "User deleted: %s": "User deleted: %s", + "User activated: %s": "User activated: %s", + "User deactivated: %s": "User deactivated: %s", + "User assignments updated: %s": "User assignments updated: %s", + "Tenant, role, or department assignments were changed.": "Tenant, role, or department assignments were changed." } diff --git a/modules/notifications/lib/Module/Notifications/Listeners/UserActivatedNotificationListener.php b/modules/notifications/lib/Module/Notifications/Listeners/UserActivatedNotificationListener.php new file mode 100644 index 0000000..5ca87f5 --- /dev/null +++ b/modules/notifications/lib/Module/Notifications/Listeners/UserActivatedNotificationListener.php @@ -0,0 +1,90 @@ +userReadRepository->find($userId); + $displayName = trim((string) ($payload['display_name'] ?? '')); + if ($displayName === '' && is_array($user)) { + $displayName = trim((string) ($user['display_name'] ?? '')); + if ($displayName === '') { + $displayName = trim((string) (($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? ''))); + } + } + if ($displayName === '') { + $displayName = '#' . $userId; + } + + $uuid = trim((string) ($payload['uuid'] ?? '')); + if ($uuid === '' && is_array($user)) { + $uuid = trim((string) ($user['uuid'] ?? '')); + } + + $tenantIds = $this->sanitizeTenantIds($payload['tenant_ids'] ?? []); + if ($tenantIds === []) { + $tenantIds = $this->userTenantRepository->listTenantIdsByUserId($userId); + } + if ($tenantIds === []) { + return; + } + + $adminUserIds = $this->userReadRepository->listPrivilegedUserIdsByPermissionKeys( + [PermissionService::USERS_UPDATE] + ); + if ($adminUserIds === []) { + return; + } + $adminSet = array_fill_keys(array_map('intval', $adminUserIds), true); + $actorUserId = isset($payload['actor_user_id']) ? (int) $payload['actor_user_id'] : null; + + $title = sprintf(t('User activated: %s'), $displayName); + $link = $uuid !== '' ? 'admin/users/edit/' . $uuid : null; + + foreach ($tenantIds as $tenantId) { + $this->notificationService->createForTenantAdminUsers( + $tenantId, + 'user.activated', + $title, + null, + $link, + ['user_id' => $userId, 'uuid' => $uuid], + $actorUserId, + $adminSet + ); + } + } + + /** + * @return list + */ + private function sanitizeTenantIds(mixed $tenantIds): array + { + if (!is_array($tenantIds)) { + return []; + } + + $ids = array_values(array_unique(array_map('intval', $tenantIds))); + return array_values(array_filter($ids, static fn (int $tenantId): bool => $tenantId > 0)); + } +} diff --git a/modules/notifications/lib/Module/Notifications/Listeners/UserAssignmentChangedNotificationListener.php b/modules/notifications/lib/Module/Notifications/Listeners/UserAssignmentChangedNotificationListener.php new file mode 100644 index 0000000..e9aab1a --- /dev/null +++ b/modules/notifications/lib/Module/Notifications/Listeners/UserAssignmentChangedNotificationListener.php @@ -0,0 +1,104 @@ +userReadRepository->find($userId); + $displayName = trim((string) ($payload['display_name'] ?? '')); + if ($displayName === '' && is_array($user)) { + $displayName = trim((string) ($user['display_name'] ?? '')); + if ($displayName === '') { + $displayName = trim((string) (($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? ''))); + } + } + if ($displayName === '') { + $displayName = '#' . $userId; + } + + $uuid = trim((string) ($payload['uuid'] ?? '')); + if ($uuid === '' && is_array($user)) { + $uuid = trim((string) ($user['uuid'] ?? '')); + } + + $tenantIds = $this->sanitizeTenantIds($payload['tenant_ids'] ?? []); + $changes = is_array($payload['changes'] ?? null) ? $payload['changes'] : []; + $title = sprintf(t('User assignments updated: %s'), $displayName); + $body = t('Tenant, role, or department assignments were changed.'); + $link = $uuid !== '' ? 'admin/users/edit/' . $uuid : null; + $data = [ + 'user_id' => $userId, + 'uuid' => $uuid, + 'changes' => $changes, + ]; + + if ($actorUserId === null || $actorUserId !== $userId) { + $this->notificationService->createForUser( + $userId, + null, + 'user.assignment_changed', + $title, + $body, + $link, + $data + ); + } + + if ($tenantIds === []) { + return; + } + + $adminUserIds = $this->userReadRepository->listPrivilegedUserIdsByPermissionKeys( + [PermissionService::USERS_UPDATE_ASSIGNMENTS] + ); + if ($adminUserIds === []) { + return; + } + $adminSet = array_fill_keys(array_map('intval', $adminUserIds), true); + + foreach ($tenantIds as $tenantId) { + $this->notificationService->createForTenantAdminUsers( + $tenantId, + 'user.assignment_changed', + $title, + $body, + $link, + $data, + $actorUserId, + $adminSet + ); + } + } + + /** + * @return list + */ + private function sanitizeTenantIds(mixed $tenantIds): array + { + if (!is_array($tenantIds)) { + return []; + } + + $ids = array_values(array_unique(array_map('intval', $tenantIds))); + return array_values(array_filter($ids, static fn (int $tenantId): bool => $tenantId > 0)); + } +} diff --git a/modules/notifications/lib/Module/Notifications/Listeners/UserDeactivatedNotificationListener.php b/modules/notifications/lib/Module/Notifications/Listeners/UserDeactivatedNotificationListener.php new file mode 100644 index 0000000..b55b2a4 --- /dev/null +++ b/modules/notifications/lib/Module/Notifications/Listeners/UserDeactivatedNotificationListener.php @@ -0,0 +1,90 @@ +userReadRepository->find($userId); + $displayName = trim((string) ($payload['display_name'] ?? '')); + if ($displayName === '' && is_array($user)) { + $displayName = trim((string) ($user['display_name'] ?? '')); + if ($displayName === '') { + $displayName = trim((string) (($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? ''))); + } + } + if ($displayName === '') { + $displayName = '#' . $userId; + } + + $uuid = trim((string) ($payload['uuid'] ?? '')); + if ($uuid === '' && is_array($user)) { + $uuid = trim((string) ($user['uuid'] ?? '')); + } + + $tenantIds = $this->sanitizeTenantIds($payload['tenant_ids'] ?? []); + if ($tenantIds === []) { + $tenantIds = $this->userTenantRepository->listTenantIdsByUserId($userId); + } + if ($tenantIds === []) { + return; + } + + $adminUserIds = $this->userReadRepository->listPrivilegedUserIdsByPermissionKeys( + [PermissionService::USERS_UPDATE] + ); + if ($adminUserIds === []) { + return; + } + $adminSet = array_fill_keys(array_map('intval', $adminUserIds), true); + $actorUserId = isset($payload['actor_user_id']) ? (int) $payload['actor_user_id'] : null; + + $title = sprintf(t('User deactivated: %s'), $displayName); + $link = $uuid !== '' ? 'admin/users/edit/' . $uuid : null; + + foreach ($tenantIds as $tenantId) { + $this->notificationService->createForTenantAdminUsers( + $tenantId, + 'user.deactivated', + $title, + null, + $link, + ['user_id' => $userId, 'uuid' => $uuid], + $actorUserId, + $adminSet + ); + } + } + + /** + * @return list + */ + private function sanitizeTenantIds(mixed $tenantIds): array + { + if (!is_array($tenantIds)) { + return []; + } + + $ids = array_values(array_unique(array_map('intval', $tenantIds))); + return array_values(array_filter($ids, static fn (int $tenantId): bool => $tenantId > 0)); + } +} diff --git a/modules/notifications/lib/Module/Notifications/NotificationsAuthorizationPolicy.php b/modules/notifications/lib/Module/Notifications/NotificationsAuthorizationPolicy.php index 22a2bc2..7c43dbb 100644 --- a/modules/notifications/lib/Module/Notifications/NotificationsAuthorizationPolicy.php +++ b/modules/notifications/lib/Module/Notifications/NotificationsAuthorizationPolicy.php @@ -4,12 +4,18 @@ namespace MintyPHP\Module\Notifications; use MintyPHP\Service\Access\AuthorizationDecision; use MintyPHP\Service\Access\AuthorizationPolicyInterface; +use MintyPHP\Service\Access\PermissionService; final class NotificationsAuthorizationPolicy implements AuthorizationPolicyInterface { public const ABILITY_VIEW = 'notifications.view'; public const PERMISSION_KEY = 'notifications.view'; + public function __construct( + private readonly PermissionService $permissionService + ) { + } + public function supports(string $ability): bool { return $ability === self::ABILITY_VIEW; @@ -22,6 +28,10 @@ final class NotificationsAuthorizationPolicy implements AuthorizationPolicyInter return AuthorizationDecision::deny(403, 'forbidden'); } + if (!$this->permissionService->userHas($actorUserId, self::PERMISSION_KEY)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + return AuthorizationDecision::allow(); } } diff --git a/modules/notifications/lib/Module/Notifications/NotificationsContainerRegistrar.php b/modules/notifications/lib/Module/Notifications/NotificationsContainerRegistrar.php index ef28fec..dde3fa4 100644 --- a/modules/notifications/lib/Module/Notifications/NotificationsContainerRegistrar.php +++ b/modules/notifications/lib/Module/Notifications/NotificationsContainerRegistrar.php @@ -5,13 +5,16 @@ namespace MintyPHP\Module\Notifications; use MintyPHP\App\AppContainer; use MintyPHP\App\Container\ContainerRegistrar; use MintyPHP\Module\Notifications\Handler\NotificationCleanupJobHandler; +use MintyPHP\Module\Notifications\Listeners\UserActivatedNotificationListener; +use MintyPHP\Module\Notifications\Listeners\UserAssignmentChangedNotificationListener; use MintyPHP\Module\Notifications\Listeners\UserCreatedNotificationListener; +use MintyPHP\Module\Notifications\Listeners\UserDeactivatedNotificationListener; use MintyPHP\Module\Notifications\Listeners\UserDeletedNotificationListener; use MintyPHP\Module\Notifications\Service\NotificationRepositoryFactory; use MintyPHP\Module\Notifications\Service\NotificationService; use MintyPHP\Module\Notifications\Service\NotificationServicesFactory; -use MintyPHP\Repository\Tenant\UserTenantRepositoryInterface; -use MintyPHP\Repository\User\UserReadRepositoryInterface; +use MintyPHP\Repository\Tenant\UserTenantRepository; +use MintyPHP\Repository\User\UserReadRepository; final class NotificationsContainerRegistrar implements ContainerRegistrar { @@ -23,7 +26,9 @@ final class NotificationsContainerRegistrar implements ContainerRegistrar $c->get(NotificationRepositoryFactory::class) )); - $container->set(NotificationService::class, static fn (AppContainer $c): NotificationService => $c->get(NotificationServicesFactory::class)->createNotificationService()); + $container->set(NotificationService::class, static fn (AppContainer $c): NotificationService => $c->get(NotificationServicesFactory::class)->createNotificationService( + $c->get(UserTenantRepository::class) + )); $container->set(NotificationCleanupJobHandler::class, static fn (AppContainer $c): NotificationCleanupJobHandler => new NotificationCleanupJobHandler( $c->get(NotificationService::class) @@ -31,13 +36,30 @@ final class NotificationsContainerRegistrar implements ContainerRegistrar $container->set(UserCreatedNotificationListener::class, static fn (AppContainer $c): UserCreatedNotificationListener => new UserCreatedNotificationListener( $c->get(NotificationService::class), - $c->get(UserReadRepositoryInterface::class), - $c->get(UserTenantRepositoryInterface::class) + $c->get(UserReadRepository::class), + $c->get(UserTenantRepository::class) )); $container->set(UserDeletedNotificationListener::class, static fn (AppContainer $c): UserDeletedNotificationListener => new UserDeletedNotificationListener( $c->get(NotificationService::class), - $c->get(UserReadRepositoryInterface::class) + $c->get(UserReadRepository::class) + )); + + $container->set(UserActivatedNotificationListener::class, static fn (AppContainer $c): UserActivatedNotificationListener => new UserActivatedNotificationListener( + $c->get(NotificationService::class), + $c->get(UserReadRepository::class), + $c->get(UserTenantRepository::class) + )); + + $container->set(UserDeactivatedNotificationListener::class, static fn (AppContainer $c): UserDeactivatedNotificationListener => new UserDeactivatedNotificationListener( + $c->get(NotificationService::class), + $c->get(UserReadRepository::class), + $c->get(UserTenantRepository::class) + )); + + $container->set(UserAssignmentChangedNotificationListener::class, static fn (AppContainer $c): UserAssignmentChangedNotificationListener => new UserAssignmentChangedNotificationListener( + $c->get(NotificationService::class), + $c->get(UserReadRepository::class) )); } } diff --git a/modules/notifications/lib/Module/Notifications/Providers/NotificationsSessionProvider.php b/modules/notifications/lib/Module/Notifications/Providers/NotificationsSessionProvider.php index 837b3af..324823b 100644 --- a/modules/notifications/lib/Module/Notifications/Providers/NotificationsSessionProvider.php +++ b/modules/notifications/lib/Module/Notifications/Providers/NotificationsSessionProvider.php @@ -25,7 +25,9 @@ final class NotificationsSessionProvider implements SessionProvider return; } - $sessionStore->set(self::SESSION_KEY, $service->unreadCount($userId)); + $currentTenant = $sessionStore->get('current_tenant', []); + $tenantId = is_array($currentTenant) ? (int) ($currentTenant['id'] ?? 0) : 0; + $sessionStore->set(self::SESSION_KEY, $service->unreadCount($userId, $tenantId > 0 ? $tenantId : null)); } public function clear(): void diff --git a/modules/notifications/lib/Module/Notifications/Repository/NotificationRepository.php b/modules/notifications/lib/Module/Notifications/Repository/NotificationRepository.php index 855fa39..2aa5f14 100644 --- a/modules/notifications/lib/Module/Notifications/Repository/NotificationRepository.php +++ b/modules/notifications/lib/Module/Notifications/Repository/NotificationRepository.php @@ -7,6 +7,8 @@ use MintyPHP\Repository\Support\RepositoryArrayHelper; class NotificationRepository implements NotificationRepositoryInterface { + private const DEDUPE_WINDOW_SECONDS = 1800; + private function unwrapList(mixed $rows): array { return RepositoryArrayHelper::unwrapList($rows, 'user_notifications'); @@ -14,21 +16,29 @@ class NotificationRepository implements NotificationRepositoryInterface public function create(array $data): int|false { + $dedupeFingerprint = trim((string) ($data['dedupe_fingerprint'] ?? '')); + $dedupeBucket = (int) ($data['dedupe_bucket'] ?? 0); + $hasDedupe = $dedupeFingerprint !== '' && $dedupeBucket > 0; + $result = DB::insert( - 'insert into user_notifications (recipient_user_id, tenant_id, type, title, body, link, data, created) values (?, ?, ?, ?, ?, ?, ?, NOW())', + ($hasDedupe ? 'insert ignore' : 'insert') . + ' into user_notifications (recipient_user_id, tenant_id, type, title, body, link, data, dedupe_fingerprint, dedupe_bucket, dedupe_until, created) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())', (string) ($data['recipient_user_id'] ?? 0), $data['tenant_id'] !== null ? (string) $data['tenant_id'] : null, (string) ($data['type'] ?? ''), (string) ($data['title'] ?? ''), $data['body'] ?? null, $data['link'] ?? null, - isset($data['data']) ? json_encode($data['data'], JSON_THROW_ON_ERROR) : null + isset($data['data']) ? json_encode($data['data'], JSON_THROW_ON_ERROR) : null, + $hasDedupe ? $dedupeFingerprint : null, + $hasDedupe ? (string) $dedupeBucket : null, + $hasDedupe ? gmdate('Y-m-d H:i:s', time() + self::DEDUPE_WINDOW_SECONDS) : null ); return is_int($result) && $result > 0 ? $result : false; } - public function listByUser(int $userId, int $limit, int $offset): array + public function listByUser(int $userId, ?int $tenantId, int $limit, int $offset): array { if ($userId <= 0) { return []; @@ -37,69 +47,83 @@ class NotificationRepository implements NotificationRepositoryInterface $limit = max(1, min($limit, 100)); $offset = max(0, $offset); + $tenantParams = []; $rows = DB::select( - 'select id, type, title, body, link, is_read, created from user_notifications where recipient_user_id = ? order by created desc limit ? offset ?', + 'select id, type, title, body, link, is_read, created from user_notifications where recipient_user_id = ?' . + $this->tenantScopeClause($tenantId, true, $tenantParams) . + ' order by created desc limit ' . $limit . ' offset ' . $offset, (string) $userId, - (string) $limit, - (string) $offset + ...$tenantParams ); return $this->unwrapList($rows); } - public function countUnreadByUser(int $userId): int + public function countUnreadByUser(int $userId, ?int $tenantId): int { if ($userId <= 0) { return 0; } + $tenantParams = []; $count = DB::selectValue( - 'select count(*) from user_notifications where recipient_user_id = ? and is_read = 0', - (string) $userId + 'select count(*) from user_notifications where recipient_user_id = ? and is_read = 0' . + $this->tenantScopeClause($tenantId, true, $tenantParams), + (string) $userId, + ...$tenantParams ); return (int) $count; } - public function markRead(int $id, int $userId): bool + public function markRead(int $id, int $userId, ?int $tenantId): bool { if ($id <= 0 || $userId <= 0) { return false; } + $tenantParams = []; $affected = DB::update( - 'update user_notifications set is_read = 1, read_at = NOW() where id = ? and recipient_user_id = ? and is_read = 0', + 'update user_notifications set is_read = 1, read_at = NOW() where id = ? and recipient_user_id = ? and is_read = 0' . + $this->tenantScopeClause($tenantId, true, $tenantParams), (string) $id, - (string) $userId + (string) $userId, + ...$tenantParams ); return is_int($affected) && $affected > 0; } - public function markAllReadByUser(int $userId): int + public function markAllReadByUser(int $userId, ?int $tenantId): int { if ($userId <= 0) { return 0; } + $tenantParams = []; $affected = DB::update( - 'update user_notifications set is_read = 1, read_at = NOW() where recipient_user_id = ? and is_read = 0', - (string) $userId + 'update user_notifications set is_read = 1, read_at = NOW() where recipient_user_id = ? and is_read = 0' . + $this->tenantScopeClause($tenantId, true, $tenantParams), + (string) $userId, + ...$tenantParams ); return is_int($affected) ? $affected : 0; } - public function delete(int $id, int $userId): bool + public function delete(int $id, int $userId, ?int $tenantId): bool { if ($id <= 0 || $userId <= 0) { return false; } + $tenantParams = []; $affected = DB::delete( - 'delete from user_notifications where id = ? and recipient_user_id = ?', + 'delete from user_notifications where id = ? and recipient_user_id = ?' . + $this->tenantScopeClause($tenantId, true, $tenantParams), (string) $id, - (string) $userId + (string) $userId, + ...$tenantParams ); return is_int($affected) && $affected > 0; @@ -132,4 +156,25 @@ class NotificationRepository implements NotificationRepositoryInterface return is_int($affected) ? $affected : 0; } + + /** + * Builds a tenant scope SQL condition for a notification recipient query. + * Scope always includes global notifications (`tenant_id is null`). + * + * @param array $params + */ + private function tenantScopeClause(?int $tenantId, bool $includeGlobal, array &$params): string + { + $scopedTenantId = (int) ($tenantId ?? 0); + if ($scopedTenantId > 0) { + if ($includeGlobal) { + $params[] = (string) $scopedTenantId; + return ' and (tenant_id is null or tenant_id = ?)'; + } + $params[] = (string) $scopedTenantId; + return ' and tenant_id = ?'; + } + + return $includeGlobal ? ' and tenant_id is null' : ''; + } } diff --git a/modules/notifications/lib/Module/Notifications/Repository/NotificationRepositoryInterface.php b/modules/notifications/lib/Module/Notifications/Repository/NotificationRepositoryInterface.php index 8dcc612..1f4d7b8 100644 --- a/modules/notifications/lib/Module/Notifications/Repository/NotificationRepositoryInterface.php +++ b/modules/notifications/lib/Module/Notifications/Repository/NotificationRepositoryInterface.php @@ -10,15 +10,15 @@ interface NotificationRepositoryInterface /** * @return list> */ - public function listByUser(int $userId, int $limit, int $offset): array; + public function listByUser(int $userId, ?int $tenantId, int $limit, int $offset): array; - public function countUnreadByUser(int $userId): int; + public function countUnreadByUser(int $userId, ?int $tenantId): int; - public function markRead(int $id, int $userId): bool; + public function markRead(int $id, int $userId, ?int $tenantId): bool; - public function markAllReadByUser(int $userId): int; + public function markAllReadByUser(int $userId, ?int $tenantId): int; - public function delete(int $id, int $userId): bool; + public function delete(int $id, int $userId, ?int $tenantId): bool; public function deleteAllByUser(int $userId): int; diff --git a/modules/notifications/lib/Module/Notifications/Service/NotificationService.php b/modules/notifications/lib/Module/Notifications/Service/NotificationService.php index 05c22e6..ce5ae3d 100644 --- a/modules/notifications/lib/Module/Notifications/Service/NotificationService.php +++ b/modules/notifications/lib/Module/Notifications/Service/NotificationService.php @@ -3,58 +3,69 @@ namespace MintyPHP\Module\Notifications\Service; use MintyPHP\Module\Notifications\Repository\NotificationRepositoryInterface; +use MintyPHP\Repository\Tenant\UserTenantRepositoryInterface; class NotificationService { + private const DEDUPE_WINDOW_MINUTES = 30; + private const DEDUPE_TYPES = [ + 'user.created' => true, + 'user.deleted' => true, + 'user.activated' => true, + 'user.deactivated' => true, + 'user.assignment_changed' => true, + ]; + public function __construct( - private readonly NotificationRepositoryInterface $notificationRepository + private readonly NotificationRepositoryInterface $notificationRepository, + private readonly UserTenantRepositoryInterface $userTenantRepository ) { } /** @return list> */ - public function listForUser(int $userId, int $limit = 50): array + public function listForUser(int $userId, ?int $tenantId = null, int $limit = 50): array { if ($userId <= 0) { return []; } - return $this->notificationRepository->listByUser($userId, $limit, 0); + return $this->notificationRepository->listByUser($userId, $tenantId, $limit, 0); } - public function unreadCount(int $userId): int + public function unreadCount(int $userId, ?int $tenantId = null): int { if ($userId <= 0) { return 0; } - return $this->notificationRepository->countUnreadByUser($userId); + return $this->notificationRepository->countUnreadByUser($userId, $tenantId); } - public function markAsRead(int $userId, int $notificationId): bool + public function markAsRead(int $userId, int $notificationId, ?int $tenantId = null): bool { if ($userId <= 0 || $notificationId <= 0) { return false; } - return $this->notificationRepository->markRead($notificationId, $userId); + return $this->notificationRepository->markRead($notificationId, $userId, $tenantId); } - public function markAllAsRead(int $userId): int + public function markAllAsRead(int $userId, ?int $tenantId = null): int { if ($userId <= 0) { return 0; } - return $this->notificationRepository->markAllReadByUser($userId); + return $this->notificationRepository->markAllReadByUser($userId, $tenantId); } - public function dismiss(int $userId, int $notificationId): bool + public function dismiss(int $userId, int $notificationId, ?int $tenantId = null): bool { if ($userId <= 0 || $notificationId <= 0) { return false; } - return $this->notificationRepository->delete($notificationId, $userId); + return $this->notificationRepository->delete($notificationId, $userId, $tenantId); } public function createForUser( @@ -70,6 +81,8 @@ class NotificationService return false; } + $dedupeMeta = $this->buildDedupeMeta($recipientUserId, $tenantId, $type, $data); + return $this->notificationRepository->create([ 'recipient_user_id' => $recipientUserId, 'tenant_id' => $tenantId, @@ -78,6 +91,8 @@ class NotificationService 'body' => $body, 'link' => $link, 'data' => $data ?: null, + 'dedupe_fingerprint' => $dedupeMeta['fingerprint'] ?? null, + 'dedupe_bucket' => $dedupeMeta['bucket'] ?? null, ]); } @@ -99,7 +114,7 @@ class NotificationService return 0; } - $userIds = $this->listUserIdsForTenant($tenantId); + $userIds = $this->userTenantRepository->listActiveUserIdsByTenantId($tenantId); $created = 0; foreach ($userIds as $userId) { @@ -136,7 +151,7 @@ class NotificationService return 0; } - $userIds = $this->listUserIdsForTenant($tenantId); + $userIds = $this->userTenantRepository->listActiveUserIdsByTenantId($tenantId); $created = 0; foreach ($userIds as $userId) { @@ -171,33 +186,55 @@ class NotificationService } /** - * Look up active user IDs belonging to a tenant. - * - * @return list + * @param array $data + * @return array{fingerprint: string, bucket: int}|array{} */ - private function listUserIdsForTenant(int $tenantId): array + private function buildDedupeMeta(int $recipientUserId, ?int $tenantId, string $type, array $data): array { - if ($tenantId <= 0) { + if (!isset(self::DEDUPE_TYPES[$type])) { return []; } - $rows = \MintyPHP\DB::select( - 'select ut.user_id from user_tenants ut join users u on u.id = ut.user_id and u.active = 1 where ut.tenant_id = ?', - (string) $tenantId - ); - - if (!is_array($rows)) { + $target = $this->dedupeTargetFromData($data); + if ($target === '') { return []; } - $ids = []; - foreach ($rows as $row) { - $data = $row['user_tenants'] ?? $row; - if (is_array($data) && isset($data['user_id'])) { - $ids[] = (int) $data['user_id']; - } - } + $tenantScope = $tenantId !== null && $tenantId > 0 ? $tenantId : 0; + $raw = implode('|', [ + 'recipient:' . $recipientUserId, + 'tenant:' . $tenantScope, + 'type:' . $type, + 'target:' . $target, + ]); - return array_values(array_unique($ids)); + return [ + 'fingerprint' => hash('sha256', $raw), + 'bucket' => intdiv(time(), self::DEDUPE_WINDOW_MINUTES * 60), + ]; } + + /** + * @param array $data + */ + private function dedupeTargetFromData(array $data): string + { + $explicit = trim((string) ($data['dedupe_target'] ?? '')); + if ($explicit !== '') { + return $explicit; + } + + $targetUserId = (int) ($data['user_id'] ?? 0); + if ($targetUserId > 0) { + return 'user:' . $targetUserId; + } + + $targetUuid = trim((string) ($data['uuid'] ?? '')); + if ($targetUuid !== '') { + return 'uuid:' . $targetUuid; + } + + return ''; + } + } diff --git a/modules/notifications/lib/Module/Notifications/Service/NotificationServicesFactory.php b/modules/notifications/lib/Module/Notifications/Service/NotificationServicesFactory.php index e7335ef..31db294 100644 --- a/modules/notifications/lib/Module/Notifications/Service/NotificationServicesFactory.php +++ b/modules/notifications/lib/Module/Notifications/Service/NotificationServicesFactory.php @@ -2,6 +2,8 @@ namespace MintyPHP\Module\Notifications\Service; +use MintyPHP\Repository\Tenant\UserTenantRepositoryInterface; + class NotificationServicesFactory { private ?NotificationService $notificationService = null; @@ -11,10 +13,11 @@ class NotificationServicesFactory ) { } - public function createNotificationService(): NotificationService + public function createNotificationService(UserTenantRepositoryInterface $userTenantRepository): NotificationService { return $this->notificationService ??= new NotificationService( - $this->notificationRepositoryFactory->createNotificationRepository() + $this->notificationRepositoryFactory->createNotificationRepository(), + $userTenantRepository ); } } diff --git a/modules/notifications/module.php b/modules/notifications/module.php index 7e11510..3f41ce0 100644 --- a/modules/notifications/module.php +++ b/modules/notifications/module.php @@ -82,6 +82,15 @@ return [ 'user.deleted' => [ ['class' => \MintyPHP\Module\Notifications\Listeners\UserDeletedNotificationListener::class, 'method' => 'handle'], ], + 'user.activated' => [ + ['class' => \MintyPHP\Module\Notifications\Listeners\UserActivatedNotificationListener::class, 'method' => 'handle'], + ], + 'user.deactivated' => [ + ['class' => \MintyPHP\Module\Notifications\Listeners\UserDeactivatedNotificationListener::class, 'method' => 'handle'], + ], + 'user.assignment_changed' => [ + ['class' => \MintyPHP\Module\Notifications\Listeners\UserAssignmentChangedNotificationListener::class, 'method' => 'handle'], + ], ], 'scheduler_jobs' => [ diff --git a/modules/notifications/pages/notifications/delete-data().php b/modules/notifications/pages/notifications/delete-data().php index 629a377..ba9a48c 100644 --- a/modules/notifications/pages/notifications/delete-data().php +++ b/modules/notifications/pages/notifications/delete-data().php @@ -1,12 +1,14 @@ method() !== 'POST') { http_response_code(405); @@ -22,6 +24,7 @@ if (!Session::checkCsrfToken()) { $session = app(SessionStoreInterface::class)->all(); $userId = (int) ($session['user']['id'] ?? 0); +$tenantId = (int) (($session['current_tenant']['id'] ?? 0)); if ($userId <= 0) { http_response_code(401); Router::json(['ok' => false, 'error' => 'unauthorized']); @@ -36,9 +39,9 @@ if ($id <= 0) { } $service = app(NotificationService::class); -$service->dismiss($userId, $id); +$service->dismiss($userId, $id, $tenantId > 0 ? $tenantId : null); -$unreadCount = $service->unreadCount($userId); +$unreadCount = $service->unreadCount($userId, $tenantId > 0 ? $tenantId : null); app(SessionStoreInterface::class)->set('module.notifications.unread_count', $unreadCount); Router::json(['ok' => true, 'unread_count' => $unreadCount]); diff --git a/modules/notifications/pages/notifications/list-data().php b/modules/notifications/pages/notifications/list-data().php index fccbd11..0fa966b 100644 --- a/modules/notifications/pages/notifications/list-data().php +++ b/modules/notifications/pages/notifications/list-data().php @@ -1,11 +1,13 @@ method() !== 'GET') { http_response_code(405); @@ -15,6 +17,7 @@ if (requestInput()->method() !== 'GET') { $session = app(SessionStoreInterface::class)->all(); $userId = (int) ($session['user']['id'] ?? 0); +$tenantId = (int) (($session['current_tenant']['id'] ?? 0)); if ($userId <= 0) { http_response_code(401); Router::json(['ok' => false, 'error' => 'unauthorized']); @@ -22,8 +25,8 @@ if ($userId <= 0) { } $service = app(NotificationService::class); -$notifications = $service->listForUser($userId, 50); -$unreadCount = $service->unreadCount($userId); +$notifications = $service->listForUser($userId, $tenantId > 0 ? $tenantId : null, 50); +$unreadCount = $service->unreadCount($userId, $tenantId > 0 ? $tenantId : null); app(SessionStoreInterface::class)->set('module.notifications.unread_count', $unreadCount); diff --git a/modules/notifications/pages/notifications/mark-read-data().php b/modules/notifications/pages/notifications/mark-read-data().php index c71f440..8e82f83 100644 --- a/modules/notifications/pages/notifications/mark-read-data().php +++ b/modules/notifications/pages/notifications/mark-read-data().php @@ -1,12 +1,14 @@ method() !== 'POST') { http_response_code(405); @@ -22,6 +24,7 @@ if (!Session::checkCsrfToken()) { $session = app(SessionStoreInterface::class)->all(); $userId = (int) ($session['user']['id'] ?? 0); +$tenantId = (int) (($session['current_tenant']['id'] ?? 0)); if ($userId <= 0) { http_response_code(401); Router::json(['ok' => false, 'error' => 'unauthorized']); @@ -33,16 +36,16 @@ $all = requestInput()->body('all'); $id = (int) requestInput()->body('id'); if ($all) { - $service->markAllAsRead($userId); + $service->markAllAsRead($userId, $tenantId > 0 ? $tenantId : null); } elseif ($id > 0) { - $service->markAsRead($userId, $id); + $service->markAsRead($userId, $id, $tenantId > 0 ? $tenantId : null); } else { http_response_code(400); Router::json(['ok' => false, 'error' => 'invalid_request']); return; } -$unreadCount = $service->unreadCount($userId); +$unreadCount = $service->unreadCount($userId, $tenantId > 0 ? $tenantId : null); app(SessionStoreInterface::class)->set('module.notifications.unread_count', $unreadCount); Router::json(['ok' => true, 'unread_count' => $unreadCount]); diff --git a/modules/notifications/pages/notifications/unread-count-data().php b/modules/notifications/pages/notifications/unread-count-data().php index 7024058..f3aaa5e 100644 --- a/modules/notifications/pages/notifications/unread-count-data().php +++ b/modules/notifications/pages/notifications/unread-count-data().php @@ -1,11 +1,13 @@ method() !== 'GET') { http_response_code(405); @@ -15,6 +17,7 @@ if (requestInput()->method() !== 'GET') { $session = app(SessionStoreInterface::class)->all(); $userId = (int) ($session['user']['id'] ?? 0); +$tenantId = (int) (($session['current_tenant']['id'] ?? 0)); if ($userId <= 0) { http_response_code(401); Router::json(['ok' => false, 'error' => 'unauthorized']); @@ -22,7 +25,7 @@ if ($userId <= 0) { } $service = app(NotificationService::class); -$unreadCount = $service->unreadCount($userId); +$unreadCount = $service->unreadCount($userId, $tenantId > 0 ? $tenantId : null); app(SessionStoreInterface::class)->set('module.notifications.unread_count', $unreadCount); diff --git a/modules/notifications/templates/topbar-notification-bell.phtml b/modules/notifications/templates/topbar-notification-bell.phtml index d1b0146..9edaba0 100644 --- a/modules/notifications/templates/topbar-notification-bell.phtml +++ b/modules/notifications/templates/topbar-notification-bell.phtml @@ -5,7 +5,7 @@ $notifNav = is_array($layoutNav['notifications.nav'] ?? null) ? $layoutNav['noti $unreadCount = (int) ($notifNav['unread_count'] ?? 0); ?>
  • -
  • diff --git a/modules/notifications/tests/Module/Notifications/Listeners/UserActivatedNotificationListenerTest.php b/modules/notifications/tests/Module/Notifications/Listeners/UserActivatedNotificationListenerTest.php new file mode 100644 index 0000000..4fe0353 --- /dev/null +++ b/modules/notifications/tests/Module/Notifications/Listeners/UserActivatedNotificationListenerTest.php @@ -0,0 +1,99 @@ +notificationService = $this->createMock(NotificationService::class); + $this->userReadRepository = $this->createMock(UserReadRepositoryInterface::class); + $this->userTenantRepository = $this->createMock(UserTenantRepositoryInterface::class); + $this->listener = new UserActivatedNotificationListener( + $this->notificationService, + $this->userReadRepository, + $this->userTenantRepository + ); + } + + public function testHandleNotifiesTenantAdmins(): void + { + $this->userReadRepository->expects($this->once())->method('find')->with(10)->willReturn([ + 'id' => 10, + 'uuid' => 'user-uuid', + 'display_name' => 'Alice', + ]); + $this->userReadRepository->expects($this->once()) + ->method('listPrivilegedUserIdsByPermissionKeys') + ->with([PermissionService::USERS_UPDATE]) + ->willReturn([20, 30]); + + $this->notificationService->expects($this->exactly(2)) + ->method('createForTenantAdminUsers') + ->with( + $this->callback(static fn (int $tenantId): bool => in_array($tenantId, [1, 2], true)), + 'user.activated', + $this->stringContains('Alice'), + null, + 'admin/users/edit/user-uuid', + ['user_id' => 10, 'uuid' => 'user-uuid'], + 5, + $this->isType('array') + ); + + $this->listener->handle('user.activated', [ + 'user_id' => 10, + 'tenant_ids' => [1, 2], + 'actor_user_id' => 5, + ]); + } + + public function testHandleFallsBackToUserTenantAssignments(): void + { + $this->userReadRepository->expects($this->once())->method('find')->with(10)->willReturn([ + 'id' => 10, + 'uuid' => 'user-uuid', + 'display_name' => 'Alice', + ]); + $this->userTenantRepository->expects($this->once())->method('listTenantIdsByUserId')->with(10)->willReturn([7]); + $this->userReadRepository->expects($this->once()) + ->method('listPrivilegedUserIdsByPermissionKeys') + ->willReturn([20]); + + $this->notificationService->expects($this->once()) + ->method('createForTenantAdminUsers') + ->with( + 7, + 'user.activated', + $this->anything(), + null, + 'admin/users/edit/user-uuid', + $this->anything(), + null, + $this->anything() + ); + + $this->listener->handle('user.activated', ['user_id' => 10]); + } + + public function testHandleSkipsInvalidPayload(): void + { + $this->userReadRepository->expects($this->never())->method('find'); + $this->notificationService->expects($this->never())->method('createForTenantAdminUsers'); + + $this->listener->handle('user.activated', ['user_id' => 0]); + } +} diff --git a/modules/notifications/tests/Module/Notifications/Listeners/UserAssignmentChangedNotificationListenerTest.php b/modules/notifications/tests/Module/Notifications/Listeners/UserAssignmentChangedNotificationListenerTest.php new file mode 100644 index 0000000..d28fab0 --- /dev/null +++ b/modules/notifications/tests/Module/Notifications/Listeners/UserAssignmentChangedNotificationListenerTest.php @@ -0,0 +1,114 @@ +notificationService = $this->createMock(NotificationService::class); + $this->userReadRepository = $this->createMock(UserReadRepositoryInterface::class); + $this->listener = new UserAssignmentChangedNotificationListener( + $this->notificationService, + $this->userReadRepository + ); + } + + public function testHandleNotifiesAffectedUserAndTenantAdmins(): void + { + $this->userReadRepository->expects($this->once())->method('find')->with(10)->willReturn([ + 'id' => 10, + 'uuid' => 'user-uuid', + 'display_name' => 'Alice', + ]); + $this->userReadRepository->expects($this->once()) + ->method('listPrivilegedUserIdsByPermissionKeys') + ->with([PermissionService::USERS_UPDATE_ASSIGNMENTS]) + ->willReturn([20, 30]); + + $this->notificationService->expects($this->once()) + ->method('createForUser') + ->with( + 10, + null, + 'user.assignment_changed', + $this->stringContains('Alice'), + $this->isType('string'), + 'admin/users/edit/user-uuid', + $this->isType('array') + ) + ->willReturn(99); + + $this->notificationService->expects($this->once()) + ->method('createForTenantAdminUsers') + ->with( + 1, + 'user.assignment_changed', + $this->stringContains('Alice'), + $this->isType('string'), + 'admin/users/edit/user-uuid', + $this->isType('array'), + 7, + $this->isType('array') + ) + ->willReturn(1); + + $this->listener->handle('user.assignment_changed', [ + 'user_id' => 10, + 'tenant_ids' => [1], + 'actor_user_id' => 7, + 'changes' => ['roles' => ['before' => [1], 'after' => [2]]], + ]); + } + + public function testHandleExcludesAffectedUserWhenActorEqualsTarget(): void + { + $this->userReadRepository->expects($this->once())->method('find')->with(10)->willReturn([ + 'id' => 10, + 'uuid' => 'user-uuid', + 'display_name' => 'Alice', + ]); + $this->userReadRepository->expects($this->once()) + ->method('listPrivilegedUserIdsByPermissionKeys') + ->willReturn([20]); + + $this->notificationService->expects($this->never())->method('createForUser'); + $this->notificationService->expects($this->once()) + ->method('createForTenantAdminUsers') + ->with( + 1, + 'user.assignment_changed', + $this->anything(), + $this->anything(), + 'admin/users/edit/user-uuid', + $this->anything(), + 10, + $this->anything() + ); + + $this->listener->handle('user.assignment_changed', [ + 'user_id' => 10, + 'tenant_ids' => [1], + 'actor_user_id' => 10, + ]); + } + + public function testHandleSkipsInvalidPayload(): void + { + $this->notificationService->expects($this->never())->method('createForUser'); + $this->notificationService->expects($this->never())->method('createForTenantAdminUsers'); + + $this->listener->handle('user.assignment_changed', ['user_id' => 0]); + } +} diff --git a/modules/notifications/tests/Module/Notifications/Listeners/UserCreatedNotificationListenerTest.php b/modules/notifications/tests/Module/Notifications/Listeners/UserCreatedNotificationListenerTest.php index 860f0c6..084f5ad 100644 --- a/modules/notifications/tests/Module/Notifications/Listeners/UserCreatedNotificationListenerTest.php +++ b/modules/notifications/tests/Module/Notifications/Listeners/UserCreatedNotificationListenerTest.php @@ -27,14 +27,14 @@ class UserCreatedNotificationListenerTest extends TestCase public function testHandleCreatesNotificationsForTenantAdminUsers(): void { - $this->userRepo->method('find')->with(10)->willReturn([ + $this->userRepo->expects($this->any())->method('find')->with(10)->willReturn([ 'id' => 10, 'display_name' => 'Alice Smith', 'first_name' => 'Alice', 'last_name' => 'Smith', ]); - $this->utRepo->method('listTenantIdsByUserId')->with(10)->willReturn([1, 2]); - $this->userRepo->method('listPrivilegedUserIdsByPermissionKeys') + $this->utRepo->expects($this->any())->method('listTenantIdsByUserId')->with(10)->willReturn([1, 2]); + $this->userRepo->expects($this->any())->method('listPrivilegedUserIdsByPermissionKeys') ->with([PermissionService::USERS_CREATE]) ->willReturn([20, 30]); @@ -60,12 +60,12 @@ class UserCreatedNotificationListenerTest extends TestCase public function testHandleExcludesActorFromRecipients(): void { - $this->userRepo->method('find')->with(10)->willReturn([ + $this->userRepo->expects($this->any())->method('find')->with(10)->willReturn([ 'id' => 10, 'display_name' => 'Bob', ]); - $this->utRepo->method('listTenantIdsByUserId')->with(10)->willReturn([1]); - $this->userRepo->method('listPrivilegedUserIdsByPermissionKeys')->willReturn([20]); + $this->utRepo->expects($this->any())->method('listTenantIdsByUserId')->with(10)->willReturn([1]); + $this->userRepo->expects($this->any())->method('listPrivilegedUserIdsByPermissionKeys')->willReturn([20]); $this->notifService->expects($this->once()) ->method('createForTenantAdminUsers') @@ -99,7 +99,7 @@ class UserCreatedNotificationListenerTest extends TestCase public function testHandleSkipsWhenUserNotFound(): void { - $this->userRepo->method('find')->with(99)->willReturn(null); + $this->userRepo->expects($this->any())->method('find')->with(99)->willReturn(null); $this->notifService->expects($this->never())->method('createForTenantAdminUsers'); $this->listener->handle('user.created', [ @@ -111,11 +111,11 @@ class UserCreatedNotificationListenerTest extends TestCase public function testHandleSkipsWhenNoTenants(): void { - $this->userRepo->method('find')->with(10)->willReturn([ + $this->userRepo->expects($this->any())->method('find')->with(10)->willReturn([ 'id' => 10, 'display_name' => 'Alice', ]); - $this->utRepo->method('listTenantIdsByUserId')->with(10)->willReturn([]); + $this->utRepo->expects($this->any())->method('listTenantIdsByUserId')->with(10)->willReturn([]); $this->notifService->expects($this->never())->method('createForTenantAdminUsers'); $this->listener->handle('user.created', [ @@ -127,12 +127,12 @@ class UserCreatedNotificationListenerTest extends TestCase public function testHandleSkipsWhenNoAdminUsers(): void { - $this->userRepo->method('find')->with(10)->willReturn([ + $this->userRepo->expects($this->any())->method('find')->with(10)->willReturn([ 'id' => 10, 'display_name' => 'Alice', ]); - $this->utRepo->method('listTenantIdsByUserId')->with(10)->willReturn([1]); - $this->userRepo->method('listPrivilegedUserIdsByPermissionKeys')->willReturn([]); + $this->utRepo->expects($this->any())->method('listTenantIdsByUserId')->with(10)->willReturn([1]); + $this->userRepo->expects($this->any())->method('listPrivilegedUserIdsByPermissionKeys')->willReturn([]); $this->notifService->expects($this->never())->method('createForTenantAdminUsers'); diff --git a/modules/notifications/tests/Module/Notifications/Listeners/UserDeactivatedNotificationListenerTest.php b/modules/notifications/tests/Module/Notifications/Listeners/UserDeactivatedNotificationListenerTest.php new file mode 100644 index 0000000..7573de0 --- /dev/null +++ b/modules/notifications/tests/Module/Notifications/Listeners/UserDeactivatedNotificationListenerTest.php @@ -0,0 +1,76 @@ +notificationService = $this->createMock(NotificationService::class); + $this->userReadRepository = $this->createMock(UserReadRepositoryInterface::class); + $this->userTenantRepository = $this->createMock(UserTenantRepositoryInterface::class); + $this->listener = new UserDeactivatedNotificationListener( + $this->notificationService, + $this->userReadRepository, + $this->userTenantRepository + ); + } + + public function testHandleNotifiesTenantAdmins(): void + { + $this->userReadRepository->expects($this->once())->method('find')->with(10)->willReturn([ + 'id' => 10, + 'uuid' => 'user-uuid', + 'display_name' => 'Alice', + ]); + $this->userReadRepository->expects($this->once()) + ->method('listPrivilegedUserIdsByPermissionKeys') + ->with([PermissionService::USERS_UPDATE]) + ->willReturn([20, 30]); + + $this->notificationService->expects($this->exactly(2)) + ->method('createForTenantAdminUsers') + ->with( + $this->callback(static fn (int $tenantId): bool => in_array($tenantId, [1, 2], true)), + 'user.deactivated', + $this->stringContains('Alice'), + null, + 'admin/users/edit/user-uuid', + ['user_id' => 10, 'uuid' => 'user-uuid'], + 5, + $this->isType('array') + ); + + $this->listener->handle('user.deactivated', [ + 'user_id' => 10, + 'tenant_ids' => [1, 2], + 'actor_user_id' => 5, + ]); + } + + public function testHandleSkipsWhenNoTenantIsResolved(): void + { + $this->userReadRepository->expects($this->once())->method('find')->with(10)->willReturn([ + 'id' => 10, + 'uuid' => 'user-uuid', + 'display_name' => 'Alice', + ]); + $this->userTenantRepository->expects($this->once())->method('listTenantIdsByUserId')->with(10)->willReturn([]); + $this->notificationService->expects($this->never())->method('createForTenantAdminUsers'); + + $this->listener->handle('user.deactivated', ['user_id' => 10]); + } +} diff --git a/modules/notifications/tests/Module/Notifications/Listeners/UserDeletedNotificationListenerTest.php b/modules/notifications/tests/Module/Notifications/Listeners/UserDeletedNotificationListenerTest.php new file mode 100644 index 0000000..f8efcb5 --- /dev/null +++ b/modules/notifications/tests/Module/Notifications/Listeners/UserDeletedNotificationListenerTest.php @@ -0,0 +1,142 @@ +notifService = $this->createMock(NotificationService::class); + $this->userRepo = $this->createMock(UserReadRepositoryInterface::class); + $this->listener = new UserDeletedNotificationListener($this->notifService, $this->userRepo); + } + + public function testHandleCleansUpAndNotifiesAdmins(): void + { + $this->notifService->expects($this->once()) + ->method('deleteAllForUser') + ->with(10); + + $this->userRepo->expects($this->once()) + ->method('listPrivilegedUserIdsByPermissionKeys') + ->with([PermissionService::USERS_DELETE]) + ->willReturn([20, 30]); + + $this->notifService->expects($this->exactly(2)) + ->method('createForTenantAdminUsers') + ->willReturnCallback(function (int $tenantId, string $type, string $title, ?string $body, ?string $link, array $data, ?int $excludeUserId, array $adminSet): int { + $this->assertContains($tenantId, [1, 2]); + $this->assertSame('user.deleted', $type); + $this->assertStringContainsString('Alice', $title); + $this->assertNull($link); + $this->assertSame(5, $excludeUserId); + $this->assertArrayHasKey(20, $adminSet); + $this->assertArrayHasKey(30, $adminSet); + return 1; + }); + + $this->listener->handle('user.deleted', [ + 'user_id' => 10, + 'display_name' => 'Alice', + 'tenant_ids' => [1, 2], + 'actor_user_id' => 5, + ]); + } + + public function testHandleSkipsOnInvalidUserId(): void + { + $this->notifService->expects($this->never())->method('deleteAllForUser'); + $this->notifService->expects($this->never())->method('createForTenantAdminUsers'); + + $this->listener->handle('user.deleted', ['user_id' => 0]); + $this->listener->handle('user.deleted', []); + } + + public function testHandleStillCleansUpWhenDisplayNameEmpty(): void + { + $this->notifService->expects($this->once()) + ->method('deleteAllForUser') + ->with(10); + + $this->notifService->expects($this->never())->method('createForTenantAdminUsers'); + + $this->listener->handle('user.deleted', [ + 'user_id' => 10, + 'display_name' => '', + 'tenant_ids' => [1], + ]); + } + + public function testHandleStillCleansUpWhenTenantIdsEmpty(): void + { + $this->notifService->expects($this->once()) + ->method('deleteAllForUser') + ->with(10); + + $this->notifService->expects($this->never())->method('createForTenantAdminUsers'); + + $this->listener->handle('user.deleted', [ + 'user_id' => 10, + 'display_name' => 'Alice', + 'tenant_ids' => [], + ]); + } + + public function testHandleExcludesActorFromRecipients(): void + { + $this->notifService->expects($this->once())->method('deleteAllForUser')->with(10); + + $this->userRepo->expects($this->once()) + ->method('listPrivilegedUserIdsByPermissionKeys') + ->willReturn([20]); + + $this->notifService->expects($this->once()) + ->method('createForTenantAdminUsers') + ->with( + 1, + 'user.deleted', + $this->anything(), + null, + null, + $this->anything(), + 7, // actor excluded + $this->anything() + ) + ->willReturn(1); + + $this->listener->handle('user.deleted', [ + 'user_id' => 10, + 'display_name' => 'Alice', + 'tenant_ids' => [1], + 'actor_user_id' => 7, + ]); + } + + public function testHandleSkipsNotificationWhenNoAdminUsers(): void + { + $this->notifService->expects($this->once())->method('deleteAllForUser')->with(10); + + $this->userRepo->expects($this->once()) + ->method('listPrivilegedUserIdsByPermissionKeys') + ->willReturn([]); + + $this->notifService->expects($this->never())->method('createForTenantAdminUsers'); + + $this->listener->handle('user.deleted', [ + 'user_id' => 10, + 'display_name' => 'Alice', + 'tenant_ids' => [1], + ]); + } +} diff --git a/modules/notifications/tests/Module/Notifications/NotificationsAuthorizationPolicyTest.php b/modules/notifications/tests/Module/Notifications/NotificationsAuthorizationPolicyTest.php new file mode 100644 index 0000000..35e53ba --- /dev/null +++ b/modules/notifications/tests/Module/Notifications/NotificationsAuthorizationPolicyTest.php @@ -0,0 +1,55 @@ +permissionService = $this->createMock(PermissionService::class); + $this->policy = new NotificationsAuthorizationPolicy($this->permissionService); + } + + public function testSupportsNotificationsAbilityOnly(): void + { + self::assertTrue($this->policy->supports(NotificationsAuthorizationPolicy::ABILITY_VIEW)); + self::assertFalse($this->policy->supports('users.view')); + } + + public function testAuthorizeAllowsUserWithPermission(): void + { + $this->permissionService->expects($this->once()) + ->method('userHas') + ->with(11, NotificationsAuthorizationPolicy::PERMISSION_KEY) + ->willReturn(true); + + $decision = $this->policy->authorize(NotificationsAuthorizationPolicy::ABILITY_VIEW, [ + 'actor_user_id' => 11, + ]); + + self::assertTrue($decision->isAllowed()); + } + + public function testAuthorizeDeniesWithoutPermission(): void + { + $this->permissionService->expects($this->once()) + ->method('userHas') + ->with(11, NotificationsAuthorizationPolicy::PERMISSION_KEY) + ->willReturn(false); + + $decision = $this->policy->authorize(NotificationsAuthorizationPolicy::ABILITY_VIEW, [ + 'actor_user_id' => 11, + ]); + + self::assertFalse($decision->isAllowed()); + self::assertSame(403, $decision->status()); + } +} diff --git a/modules/notifications/tests/Module/Notifications/Service/NotificationServiceTest.php b/modules/notifications/tests/Module/Notifications/Service/NotificationServiceTest.php index 49a54a7..0e15baa 100644 --- a/modules/notifications/tests/Module/Notifications/Service/NotificationServiceTest.php +++ b/modules/notifications/tests/Module/Notifications/Service/NotificationServiceTest.php @@ -4,18 +4,21 @@ namespace MintyPHP\Tests\Module\Notifications\Service; use MintyPHP\Module\Notifications\Repository\NotificationRepositoryInterface; use MintyPHP\Module\Notifications\Service\NotificationService; +use MintyPHP\Repository\Tenant\UserTenantRepositoryInterface; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; class NotificationServiceTest extends TestCase { private NotificationRepositoryInterface&MockObject $notifRepo; + private UserTenantRepositoryInterface&MockObject $utRepo; private NotificationService $service; protected function setUp(): void { $this->notifRepo = $this->createMock(NotificationRepositoryInterface::class); - $this->service = new NotificationService($this->notifRepo); + $this->utRepo = $this->createMock(UserTenantRepositoryInterface::class); + $this->service = new NotificationService($this->notifRepo, $this->utRepo); } public function testCreateForUserInsertsNotification(): void @@ -46,7 +49,7 @@ class NotificationServiceTest extends TestCase public function testUnreadCountReturnsCorrectCount(): void { - $this->notifRepo->method('countUnreadByUser')->with(3)->willReturn(7); + $this->notifRepo->expects($this->any())->method('countUnreadByUser')->with(3, null)->willReturn(7); $this->assertSame(7, $this->service->unreadCount(3)); } @@ -62,7 +65,7 @@ class NotificationServiceTest extends TestCase { $this->notifRepo->expects($this->once()) ->method('markRead') - ->with(10, 3) + ->with(10, 3, null) ->willReturn(true); $this->assertTrue($this->service->markAsRead(3, 10)); @@ -80,7 +83,7 @@ class NotificationServiceTest extends TestCase { $this->notifRepo->expects($this->once()) ->method('markAllReadByUser') - ->with(3) + ->with(3, null) ->willReturn(5); $this->assertSame(5, $this->service->markAllAsRead(3)); @@ -90,7 +93,7 @@ class NotificationServiceTest extends TestCase { $this->notifRepo->expects($this->once()) ->method('delete') - ->with(10, 3) + ->with(10, 3, null) ->willReturn(true); $this->assertTrue($this->service->dismiss(3, 10)); @@ -122,8 +125,161 @@ class NotificationServiceTest extends TestCase ['id' => 1, 'title' => 'Test', 'is_read' => 0], ['id' => 2, 'title' => 'Test 2', 'is_read' => 1], ]; - $this->notifRepo->method('listByUser')->with(3, 50, 0)->willReturn($expected); + $this->notifRepo->expects($this->any())->method('listByUser')->with(3, null, 50, 0)->willReturn($expected); $this->assertSame($expected, $this->service->listForUser(3)); } + + public function testListForUserPassesTenantScope(): void + { + $this->notifRepo->expects($this->once()) + ->method('listByUser') + ->with(3, 9, 25, 0) + ->willReturn([]); + + $this->assertSame([], $this->service->listForUser(3, 9, 25)); + } + + // --- createForTenantUsers --- + + public function testCreateForTenantUsersCreatesForAllUsersExcludingActor(): void + { + $this->utRepo->expects($this->once()) + ->method('listActiveUserIdsByTenantId') + ->with(1) + ->willReturn([10, 20, 30]); + + $this->notifRepo->expects($this->exactly(2)) + ->method('create') + ->willReturn(1); + + $result = $this->service->createForTenantUsers(1, 'system', 'Hello', null, null, [], 20); + + $this->assertSame(2, $result); + } + + public function testCreateForTenantUsersSkipsSuppressedDuplicates(): void + { + $this->utRepo->expects($this->once()) + ->method('listActiveUserIdsByTenantId') + ->with(1) + ->willReturn([10, 20, 30]); + + $this->notifRepo->expects($this->exactly(3)) + ->method('create') + ->willReturnOnConsecutiveCalls(11, false, 13); + + $result = $this->service->createForTenantUsers(1, 'user.activated', 'Activated', null, null, ['user_id' => 99], null); + + $this->assertSame(2, $result); + } + + public function testCreateForTenantUsersCreatesForAllWhenNoExclude(): void + { + $this->utRepo->expects($this->once()) + ->method('listActiveUserIdsByTenantId') + ->with(1) + ->willReturn([10, 20]); + + $this->notifRepo->expects($this->exactly(2)) + ->method('create') + ->willReturn(1); + + $result = $this->service->createForTenantUsers(1, 'system', 'Hello', null, null, [], null); + + $this->assertSame(2, $result); + } + + public function testCreateForTenantUsersRejectsInvalidInputs(): void + { + $this->utRepo->expects($this->never())->method('listActiveUserIdsByTenantId'); + $this->notifRepo->expects($this->never())->method('create'); + + $this->assertSame(0, $this->service->createForTenantUsers(0, 'system', 'Hello', null, null, [], null)); + $this->assertSame(0, $this->service->createForTenantUsers(1, '', 'Hello', null, null, [], null)); + $this->assertSame(0, $this->service->createForTenantUsers(1, 'system', '', null, null, [], null)); + } + + // --- createForTenantAdminUsers --- + + public function testCreateForTenantAdminUsersFiltersToAllowedUsers(): void + { + $this->utRepo->expects($this->once()) + ->method('listActiveUserIdsByTenantId') + ->with(1) + ->willReturn([10, 20, 30, 40]); + + $this->notifRepo->expects($this->exactly(2)) + ->method('create') + ->willReturn(1); + + $allowedUserIds = array_flip([20, 30]); + $result = $this->service->createForTenantAdminUsers(1, 'system', 'Hello', null, null, [], null, $allowedUserIds); + + $this->assertSame(2, $result); + } + + public function testCreateForTenantAdminUsersExcludesActor(): void + { + $this->utRepo->expects($this->once()) + ->method('listActiveUserIdsByTenantId') + ->with(1) + ->willReturn([10, 20, 30]); + + $this->notifRepo->expects($this->once()) + ->method('create') + ->willReturn(1); + + $allowedUserIds = array_flip([20, 30]); + $result = $this->service->createForTenantAdminUsers(1, 'system', 'Hello', null, null, [], 20, $allowedUserIds); + + $this->assertSame(1, $result); + } + + public function testCreateForTenantAdminUsersRejectsEmptyAllowedUsers(): void + { + $this->utRepo->expects($this->never())->method('listActiveUserIdsByTenantId'); + $this->notifRepo->expects($this->never())->method('create'); + + $this->assertSame(0, $this->service->createForTenantAdminUsers(1, 'system', 'Hello', null, null, [], null, [])); + } + + public function testCreateForTenantAdminUsersRejectsInvalidInputs(): void + { + $this->utRepo->expects($this->never())->method('listActiveUserIdsByTenantId'); + $this->notifRepo->expects($this->never())->method('create'); + + $allowed = array_flip([10]); + $this->assertSame(0, $this->service->createForTenantAdminUsers(0, 'system', 'Hello', null, null, [], null, $allowed)); + $this->assertSame(0, $this->service->createForTenantAdminUsers(1, '', 'Hello', null, null, [], null, $allowed)); + $this->assertSame(0, $this->service->createForTenantAdminUsers(1, 'system', '', null, null, [], null, $allowed)); + } + + public function testCreateForUserAddsDedupeMetadataForCoreTypes(): void + { + $this->notifRepo->expects($this->once()) + ->method('create') + ->with($this->callback(function (array $data): bool { + return $data['type'] === 'user.deleted' + && !empty($data['dedupe_fingerprint']) + && !empty($data['dedupe_bucket']); + })) + ->willReturn(55); + + $result = $this->service->createForUser(5, 1, 'user.deleted', 'User deleted: Bob', null, null, ['user_id' => 33]); + $this->assertSame(55, $result); + } + + public function testCreateForUserSkipsDedupeMetadataForNonCoreTypes(): void + { + $this->notifRepo->expects($this->once()) + ->method('create') + ->with($this->callback(function (array $data): bool { + return !isset($data['dedupe_fingerprint']) || $data['dedupe_fingerprint'] === null; + })) + ->willReturn(56); + + $result = $this->service->createForUser(5, 1, 'system', 'System', null, null, ['user_id' => 33]); + $this->assertSame(56, $result); + } } diff --git a/modules/notifications/web/css/components/app-notification-dropdown.css b/modules/notifications/web/css/components/app-notification-dropdown.css index 5e4aa19..53cd520 100644 --- a/modules/notifications/web/css/components/app-notification-dropdown.css +++ b/modules/notifications/web/css/components/app-notification-dropdown.css @@ -1,125 +1,159 @@ @layer components { - ul.app-notification-dropdown { - width: 360px; - padding: 0; - } + ul.app-notification-dropdown { + width: 360px; + padding: 0; + --app-notification-text: var(--app-dropdown-color); + --app-notification-muted: var(--app-muted-color); + --app-notification-hover: var(--app-dropdown-hover-background-color); + --app-notification-border: var(--app-dropdown-border-color); + --app-notification-focus: var(--app-primary-focus); + } - .app-notification-dropdown__header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 16px; - border-bottom: 1px solid var(--app-border, #dee2e6); - } + .app-notification-dropdown__header { + padding: 12px 16px; + border-bottom: 1px solid var(--app-notification-border); + } - .app-notification-dropdown__title { - font-size: 14px; - font-weight: 600; - } + .app-notification-dropdown__title { + font-size: 14px; + font-weight: 600; + color: var(--app-notification-text); + } - .app-notification-dropdown__mark-all { - background: none; - border: none; - color: var(--app-primary, #105433); - font-size: 12px; - cursor: pointer; - padding: 2px 4px; - } + .app-notification-dropdown__mark-all { + background: none; + border: none; + color: var(--app-primary); + font-size: 11px; + cursor: pointer; + padding: 0; + } - .app-notification-dropdown__mark-all:hover { - text-decoration: underline; - } + .app-notification-dropdown__mark-all:hover { + text-decoration: underline; + } - .app-notification-dropdown__body { - overflow-y: auto; - max-height: 400px; - padding: 0; - } + .app-notification-dropdown__body { + overflow-y: auto; + max-height: 400px; + padding: 0; + } - .app-notification-item { - display: flex; - align-items: flex-start; - gap: 10px; - padding: 10px 16px; - border-bottom: 1px solid var(--app-border-light, #f0f0f0); - cursor: pointer; - transition: background 0.15s; - } + .app-notification-item { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 16px; + border-bottom: 1px solid var(--app-notification-border); + cursor: pointer; + transition: background 0.15s; + color: var(--app-notification-text); + } - .app-notification-item:hover { - background: var(--app-hover, #f8f9fa); - } + /* Keep row-link notifications visually stable despite global role-based styles. */ + .app-notification-item--link { + margin: 0; + border: 0; + border-radius: 0; + border-bottom: 1px solid var(--app-notification-border); + outline: 0; + background: transparent; + box-shadow: none; + color: var(--app-notification-text); + font: inherit; + font-weight: inherit; + line-height: inherit; + text-align: left; + text-decoration: none; + } - .app-notification-item--unread { - border-left: 3px solid var(--app-primary, #105433); - } + .app-notification-item--link:is(:hover, :active, :focus) { + background: var(--app-notification-hover); + box-shadow: none; + color: var(--app-notification-text); + } - .app-notification-item__icon { - flex-shrink: 0; - font-size: 16px; - margin-top: 2px; - color: var(--app-muted, #6c757d); - } + .app-notification-item--link:focus-visible { + outline: 2px solid var(--app-notification-focus); + outline-offset: -2px; + } - .app-notification-item--unread .app-notification-item__icon { - color: var(--app-primary, #105433); - } + .app-notification-item:hover { + background: var(--app-notification-hover); + } - .app-notification-item__content { - flex: 1; - min-width: 0; - } + .app-notification-item--unread { + border-left: 3px solid var(--app-primary); + } - .app-notification-item__title { - font-size: 13px; - font-weight: 500; - line-height: 1.3; - margin: 0 0 2px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } + .app-notification-item__icon { + flex-shrink: 0; + font-size: 16px; + margin-top: 2px; + color: var(--app-notification-muted); + } - .app-notification-item__body { - font-size: 12px; - color: var(--app-muted, #6c757d); - line-height: 1.3; - margin: 0 0 2px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } + .app-notification-item--unread .app-notification-item__icon { + color: var(--app-primary); + } - .app-notification-item__time { - font-size: 11px; - color: var(--app-muted, #6c757d); - } + .app-notification-item__content { + flex: 1; + min-width: 0; + } - .app-notification-item__actions { - flex-shrink: 0; - display: flex; - gap: 4px; - opacity: 0; - transition: opacity 0.15s; - } + .app-notification-item__title { + font-size: 13px; + font-weight: 500; + color: var(--app-notification-text); + line-height: 1.3; + margin: 0 0 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } - .app-notification-item:hover .app-notification-item__actions { - opacity: 1; - } + .app-notification-item__body { + font-size: 12px; + color: var(--app-notification-muted); + line-height: 1.3; + margin: 0 0 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } - .app-notification-item__action-btn { - background: none; - border: none; - color: var(--app-muted, #6c757d); - font-size: 14px; - cursor: pointer; - padding: 2px; - line-height: 1; - border-radius: 4px; - } + .app-notification-item__time { + font-size: 11px; + color: var(--app-notification-muted); + } - .app-notification-item__action-btn:hover { - color: var(--app-text, #212529); - background: var(--app-border-light, #f0f0f0); - } + .app-notification-item__actions { + flex-shrink: 0; + display: flex; + gap: 4px; + opacity: 0; + transition: opacity 0.15s; + } + + .app-notification-item:hover .app-notification-item__actions, + .app-notification-item:focus-within .app-notification-item__actions { + opacity: 1; + } + + .app-notification-item__action-btn { + background: none; + border: none; + color: var(--app-notification-muted); + font-size: 14px; + cursor: pointer; + padding: 2px; + line-height: 1; + border-radius: 4px; + } + + .app-notification-item__action-btn:hover { + color: var(--app-notification-text); + background: var(--app-notification-border); + } } diff --git a/modules/notifications/web/css/layout/app-topbar-notifications.css b/modules/notifications/web/css/layout/app-topbar-notifications.css index b06f2eb..7e26610 100644 --- a/modules/notifications/web/css/layout/app-topbar-notifications.css +++ b/modules/notifications/web/css/layout/app-topbar-notifications.css @@ -1,22 +1,40 @@ @layer layout { - .app-topbar-notification-menu > summary { - position: relative; - } + .app-topbar-notification-menu { + --app-notification-badge-bg: var(--app-action-danger-background); + --app-notification-badge-color: var(--app-action-danger-color); + } - .app-notification-badge { - position: absolute; - top: 2px; - right: 2px; - min-width: 18px; - height: 18px; - padding: 0 4px; - border-radius: 9px; - background: var(--app-danger, #dc3545); - color: #fff; - font-size: 11px; - font-weight: 600; - line-height: 18px; - text-align: center; - pointer-events: none; - } + .app-topbar-notification-menu > summary { + position: relative; + } + + .app-topbar-notification-menu > summary::after { + display: none; + content: none; + } + + .app-topbar-notification-menu > summary::-webkit-details-marker { + display: none; + } + + .app-topbar-notification-menu > summary::marker { + content: ""; + } + + .app-notification-badge { + position: absolute; + top: 5px; + right: 0px; + min-width: 13px; + height: 13px; + padding: 0 3px; + border-radius: 999px; + background: var(--app-notification-badge-bg); + color: var(--app-notification-badge-color); + font-size: 7px; + font-weight: 600; + line-height: 13px; + text-align: center; + pointer-events: none; + } } diff --git a/modules/notifications/web/js/components/app-notification-bell.js b/modules/notifications/web/js/components/app-notification-bell.js index 1b9373c..d279ebd 100644 --- a/modules/notifications/web/js/components/app-notification-bell.js +++ b/modules/notifications/web/js/components/app-notification-bell.js @@ -5,10 +5,16 @@ * 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', }; @@ -47,6 +53,8 @@ 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]'); @@ -109,15 +117,34 @@ export function initNotificationBell(root = document, config = {}) { `` + ``; - // Click on item navigates if link exists - item.addEventListener('click', (e) => { - if (e.target.closest('[data-action]')) return; - if (n.link) { + // 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); - const base = document.querySelector('base')?.getAttribute('href') || '/'; - window.location.href = base + n.link; - } - }, { signal }); + 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) => { @@ -135,21 +162,33 @@ export function initNotificationBell(root = document, config = {}) { // --- API --- async function loadNotifications() { try { - const data = await fetchJson('notifications/list-data', { signal }); + const data = await fetchJson(endpoint('notifications/list-data'), { signal }); if (data.ok) { renderNotifications(data.notifications || []); updateBadge(data.unread_count ?? 0); } - } catch { /* swallow */ } + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return; + 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('notifications/unread-count-data', { signal }); + const data = await fetchJson(endpoint('notifications/unread-count-data'), { signal }); if (data.ok) { updateBadge(data.unread_count ?? 0); } - } catch { /* swallow */ } + } 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) { @@ -159,7 +198,7 @@ export function initNotificationBell(root = document, config = {}) { body.set('id', String(id)); if (csrf.key) body.set(csrf.key, csrf.token); - const data = await fetchJson('notifications/mark-read-data', { + const data = await fetchJson(endpoint('notifications/mark-read-data'), { method: 'POST', body, signal, @@ -168,7 +207,13 @@ export function initNotificationBell(root = document, config = {}) { updateBadge(data.unread_count ?? 0); if (details.open) loadNotifications(); } - } catch { /* swallow */ } + } 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() { @@ -178,7 +223,7 @@ export function initNotificationBell(root = document, config = {}) { body.set('all', '1'); if (csrf.key) body.set(csrf.key, csrf.token); - const data = await fetchJson('notifications/mark-read-data', { + const data = await fetchJson(endpoint('notifications/mark-read-data'), { method: 'POST', body, signal, @@ -187,7 +232,13 @@ export function initNotificationBell(root = document, config = {}) { updateBadge(data.unread_count ?? 0); if (details.open) loadNotifications(); } - } catch { /* swallow */ } + } 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) { @@ -197,7 +248,7 @@ export function initNotificationBell(root = document, config = {}) { body.set('id', String(id)); if (csrf.key) body.set(csrf.key, csrf.token); - const data = await fetchJson('notifications/delete-data', { + const data = await fetchJson(endpoint('notifications/delete-data'), { method: 'POST', body, signal, @@ -212,7 +263,13 @@ export function initNotificationBell(root = document, config = {}) { } } } - } catch { /* swallow */ } + } 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 --- diff --git a/pages/admin/users/edit($id).php b/pages/admin/users/edit($id).php index 95486c1..2fab850 100644 --- a/pages/admin/users/edit($id).php +++ b/pages/admin/users/edit($id).php @@ -233,6 +233,7 @@ if ($request->hasBody('email')) { if ($result['ok'] ?? false) { if ($canEditAssignments) { + $assignmentsBefore = $userAssignmentService->buildAssignmentsForUser($userId); $assignmentsSynced = $userAssignmentService->syncAllAssignments( $userId, $tenantIdsForSync, @@ -242,6 +243,14 @@ if ($request->hasBody('email')) { ); if (!$assignmentsSynced) { $errorBag->addGlobal(t('User assignments can not be updated')); + } else { + $assignmentsAfter = $userAssignmentService->buildAssignmentsForUser($userId); + $userAccountService->dispatchAssignmentChangedIfNeeded( + $userId, + $assignmentsBefore, + $assignmentsAfter, + $currentUserId + ); } if ($currentUserId === $userId && !$errorBag->hasAny()) { diff --git a/web/css/layout/app-shell.css b/web/css/layout/app-shell.css index e6c4c40..ad19053 100644 --- a/web/css/layout/app-shell.css +++ b/web/css/layout/app-shell.css @@ -2196,6 +2196,14 @@ transition: transform var(--app-transition); } + /* Opt-out hook for components that should not render the default summary chevron. */ + details[data-summary-chevron="none"] > summary::after, + details > summary[data-summary-chevron="none"]::after { + display: none !important; + content: none !important; + background-image: none !important; + } + details summary:focus { outline: 0; } @@ -2303,6 +2311,13 @@ content: ""; } + details.dropdown[data-summary-chevron="none"] > summary::after, + details.dropdown > summary[data-summary-chevron="none"]::after { + display: none !important; + content: none !important; + background-image: none !important; + } + nav details.dropdown { margin-bottom: 0; }