Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
492ab7711a | ||
|
|
c101ee1974 | ||
|
|
ca22d9e706 |
@@ -3,3 +3,21 @@
|
|||||||
<!-- govoplan-repository-type:start -->
|
<!-- govoplan-repository-type:start -->
|
||||||
**Repository type:** module (platform).
|
**Repository type:** module (platform).
|
||||||
<!-- govoplan-repository-type:end -->
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
|
## Git-source WebUI package
|
||||||
|
|
||||||
|
The repository root exposes `@govoplan/notifications-webui` for Git-tagged release
|
||||||
|
dependencies. It mirrors the owning `webui/package.json` version, public
|
||||||
|
TypeScript/CSS exports and peer requirements, with entry paths under
|
||||||
|
`webui/src`. Consumers provide the shared Core/React peers; the facade runs no
|
||||||
|
development or install scripts. The source archive contains `webui/src`, this
|
||||||
|
README and any repository license file. Run module development checks from `webui/`; Python
|
||||||
|
installation remains governed by `pyproject.toml`.
|
||||||
|
|
||||||
|
Das Repository stellt `@govoplan/notifications-webui` am Wurzelpfad für versionierte
|
||||||
|
Git-Abhängigkeiten bereit. Version, öffentliche TypeScript-/CSS-Exporte und
|
||||||
|
Peer-Anforderungen entsprechen `webui/package.json`; die Einstiegspfade liegen
|
||||||
|
unter `webui/src`. Gemeinsame Core-/React-Peers stellt die einbindende Anwendung
|
||||||
|
bereit. Die Fassade führt keine Entwicklungs- oder Installationsskripte aus.
|
||||||
|
Entwicklungsprüfungen bleiben in `webui/`, die Python-Installation weiterhin in
|
||||||
|
`pyproject.toml` definiert.
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/notifications-webui",
|
||||||
|
"version": "0.1.20",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "webui/src/index.ts",
|
||||||
|
"module": "webui/src/index.ts",
|
||||||
|
"types": "webui/src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./webui/src/index.ts",
|
||||||
|
"import": "./webui/src/index.ts"
|
||||||
|
},
|
||||||
|
"./styles/notifications.css": "./webui/src/styles/notifications.css"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.45",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": ">=19.2.7 <20",
|
||||||
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
"react-router": ">=8.3.0 <9",
|
||||||
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^7.3.6"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"webui/src",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
]
|
||||||
|
}
|
||||||
+2
-2
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-notifications"
|
name = "govoplan-notifications"
|
||||||
version = "0.1.19"
|
version = "0.1.20"
|
||||||
description = "GovOPlaN notification inbox and delivery module."
|
description = "GovOPlaN notification inbox and delivery module."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.18",
|
"govoplan-core>=0.1.45",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ from govoplan_notifications.backend.dsar_provider import (
|
|||||||
|
|
||||||
MODULE_ID = "notifications"
|
MODULE_ID = "notifications"
|
||||||
MODULE_NAME = "Notifications"
|
MODULE_NAME = "Notifications"
|
||||||
MODULE_VERSION = "0.1.19"
|
MODULE_VERSION = "0.1.20"
|
||||||
READ_SCOPE = "notifications:notification:read"
|
READ_SCOPE = "notifications:notification:read"
|
||||||
WRITE_SCOPE = "notifications:notification:write"
|
WRITE_SCOPE = "notifications:notification:write"
|
||||||
DISPATCH_SCOPE = "notifications:delivery:dispatch"
|
DISPATCH_SCOPE = "notifications:delivery:dispatch"
|
||||||
@@ -250,7 +250,10 @@ manifest = ModuleManifest(
|
|||||||
id="notifications.center-and-preferences",
|
id="notifications.center-and-preferences",
|
||||||
title="Use the notification center",
|
title="Use the notification center",
|
||||||
summary="The title-bar badge and notification center collect durable notices that require attention outside an immediate request.",
|
summary="The title-bar badge and notification center collect durable notices that require attention outside an immediate request.",
|
||||||
body="Open the notification center to read, acknowledge, or follow notifications from enabled modules. Personal source-muting preferences remove matching entries from the personal list and badge counts even when a producer addressed the actor through an account, membership, or identity identifier; tenant-administrator evidence views remain complete. Preferences also control eligible delivery channels and categories. Disabling an optional external channel does not remove an unmuted in-product notification unless the originating module's retention policy does so.",
|
body="Documentation books sit immediately beside the visible heading or contextual label for "
|
||||||
|
"Notifications, notification preferences and delivery details, not among operational action "
|
||||||
|
"buttons. Field help remains beside its label. "
|
||||||
|
"Open the notification center to read, acknowledge, or follow notifications from enabled modules. The status dropdown uses the same checkbox filter as tables: select multiple states to include any of them, select all to remove the restriction, or deselect all to show no notifications. Status filtering happens before the latest 200 matching notifications are loaded; this list is not a complete archive. Changing the filter does not change delivery or read state. Reload refreshes the current selection without a cached response. Personal source-muting preferences remove matching entries from the personal list and badge counts even when a producer addressed the actor through an account, membership, or identity identifier; tenant-administrator evidence views remain complete. Preferences also control eligible delivery channels and categories. Disabling an optional external channel does not remove an unmuted in-product notification unless the originating module's retention policy does so.",
|
||||||
documentation_types=("user",),
|
documentation_types=("user",),
|
||||||
audience=("user",),
|
audience=("user",),
|
||||||
conditions=(DocumentationCondition(required_scopes=(READ_SCOPE,)),),
|
conditions=(DocumentationCondition(required_scopes=(READ_SCOPE,)),),
|
||||||
@@ -280,8 +283,17 @@ manifest = ModuleManifest(
|
|||||||
"die außerhalb einer unmittelbaren Anfrage Aufmerksamkeit erfordern."
|
"die außerhalb einer unmittelbaren Anfrage Aufmerksamkeit erfordern."
|
||||||
),
|
),
|
||||||
"body": (
|
"body": (
|
||||||
|
"Dokumentationsbücher stehen unmittelbar neben der sichtbaren Überschrift oder "
|
||||||
|
"Kontextbezeichnung für Benachrichtigungen, Benachrichtigungseinstellungen und "
|
||||||
|
"Zustelldetails, nicht zwischen ausführbaren Aktionsschaltflächen. Feldhilfe bleibt neben der "
|
||||||
|
"Feldbezeichnung. "
|
||||||
"Die Benachrichtigungszentrale zeigt Hinweise aktivierter Module zum Lesen, Bestätigen oder "
|
"Die Benachrichtigungszentrale zeigt Hinweise aktivierter Module zum Lesen, Bestätigen oder "
|
||||||
"Weiterverfolgen. Persönlich stummgeschaltete Quellmodule entfernen passende Einträge aus der "
|
"Weiterverfolgen. Der Statusfilter verwendet dieselben Kontrollkästchen wie Tabellen: mehrere "
|
||||||
|
"Zustände einschließen, mit Alle auswählen die Einschränkung aufheben oder mit Alle abwählen "
|
||||||
|
"keine Benachrichtigungen anzeigen. Die Filterung erfolgt vor dem Laden der neuesten 200 passenden "
|
||||||
|
"Benachrichtigungen; die Liste ist kein vollständiges Archiv. Filtern ändert weder Zustellung "
|
||||||
|
"noch Lesestatus. Neu laden aktualisiert die Auswahl ohne zwischengespeicherte Antwort. "
|
||||||
|
"Persönlich stummgeschaltete Quellmodule entfernen passende Einträge aus der "
|
||||||
"persönlichen Liste und der Kennzahl, auch wenn ein Erzeugermodul die Person über Konto, "
|
"persönlichen Liste und der Kennzahl, auch wenn ein Erzeugermodul die Person über Konto, "
|
||||||
"Mitgliedschaft oder Identität adressiert hat; Nachweisansichten für Mandantenadministratoren "
|
"Mitgliedschaft oder Identität adressiert hat; Nachweisansichten für Mandantenadministratoren "
|
||||||
"bleiben vollständig. Die Einstellungen steuern außerdem zulässige Zustellkanäle und Kategorien. "
|
"bleiben vollständig. Die Einstellungen steuern außerdem zulässige Zustellkanäle und Kategorien. "
|
||||||
@@ -310,7 +322,10 @@ manifest = ModuleManifest(
|
|||||||
id="notifications.delivery-operations",
|
id="notifications.delivery-operations",
|
||||||
title="Operate notification delivery",
|
title="Operate notification delivery",
|
||||||
summary="Notifications persists message intent and bounded per-channel attempts before workers dispatch optional delivery channels.",
|
summary="Notifications persists message intent and bounded per-channel attempts before workers dispatch optional delivery channels.",
|
||||||
body="Producing modules emit notifications through the dispatch capability and do not own delivery credentials. In-product delivery is the baseline. Production email delivery is available only through an enabled Mail capability; file delivery remains development-only. Operators can inspect pending and failed attempts and retry only outcomes that are safe to repeat. Tenant module entitlement is checked before enqueue and again before worker delivery; disabling Notifications preserves accepted messages and exposes an operator action instead of silently consuming them.",
|
body="Documentation books sit immediately beside the visible heading or contextual label for "
|
||||||
|
"Notifications, notification preferences and delivery details, not among operational action "
|
||||||
|
"buttons. Field help remains beside its label. "
|
||||||
|
"Producing modules emit notifications through the dispatch capability and do not own delivery credentials. In-product delivery is the baseline. Production email delivery is available only through an enabled Mail capability; file delivery remains development-only. Operators can inspect pending and failed attempts and retry only outcomes that are safe to repeat. Tenant module entitlement is checked before enqueue and again before worker delivery; disabling Notifications preserves accepted messages and exposes an operator action instead of silently consuming them. Notification lists batch-load delivery attempts for the already tenant- and recipient-filtered page, avoiding one additional database query per message. Attempt order and full evidence are preserved. Attempts whose tenant or notification reference does not match the parent are never projected, including already-loaded relationships; inconsistent stored evidence requires an authorized operator investigation rather than broader visibility. This is read optimization, not dispatch, and it does not truncate an individual notification's history.",
|
||||||
documentation_types=("admin",),
|
documentation_types=("admin",),
|
||||||
audience=("tenant_admin", "operator", "module_admin"),
|
audience=("tenant_admin", "operator", "module_admin"),
|
||||||
related_modules=("mail", "audit", "ops"),
|
related_modules=("mail", "audit", "ops"),
|
||||||
@@ -333,6 +348,10 @@ manifest = ModuleManifest(
|
|||||||
"Notifications speichert Nachrichtenabsicht und begrenzte kanalbezogene Versuche, bevor Worker optionale Zustellkanäle ausführen."
|
"Notifications speichert Nachrichtenabsicht und begrenzte kanalbezogene Versuche, bevor Worker optionale Zustellkanäle ausführen."
|
||||||
),
|
),
|
||||||
"body": (
|
"body": (
|
||||||
|
"Dokumentationsbücher stehen unmittelbar neben der sichtbaren Überschrift oder "
|
||||||
|
"Kontextbezeichnung für Benachrichtigungen, Benachrichtigungseinstellungen und "
|
||||||
|
"Zustelldetails, nicht zwischen ausführbaren Aktionsschaltflächen. Feldhilfe bleibt neben der "
|
||||||
|
"Feldbezeichnung. "
|
||||||
"Erzeugende Module übergeben Benachrichtigungen über die Dispatch-Fähigkeit und verwalten keine "
|
"Erzeugende Module übergeben Benachrichtigungen über die Dispatch-Fähigkeit und verwalten keine "
|
||||||
"Zugangsdaten für die Zustellung. Die interne Zustellung ist die Grundlage. Produktive "
|
"Zugangsdaten für die Zustellung. Die interne Zustellung ist die Grundlage. Produktive "
|
||||||
"E-Mail-Zustellung steht nur über eine aktivierte Mail-Fähigkeit bereit; Dateizustellung bleibt auf "
|
"E-Mail-Zustellung steht nur über eine aktivierte Mail-Fähigkeit bereit; Dateizustellung bleibt auf "
|
||||||
@@ -340,7 +359,11 @@ manifest = ModuleManifest(
|
|||||||
"Ergebnisse erneut versuchen, deren Wiederholung sicher ist. Die Modulberechtigung des Mandanten "
|
"Ergebnisse erneut versuchen, deren Wiederholung sicher ist. Die Modulberechtigung des Mandanten "
|
||||||
"wird vor dem Einreihen und erneut vor der Worker-Zustellung geprüft. Das Deaktivieren von "
|
"wird vor dem Einreihen und erneut vor der Worker-Zustellung geprüft. Das Deaktivieren von "
|
||||||
"Notifications bewahrt angenommene Nachrichten und bietet eine Betreiberaktion an, statt sie "
|
"Notifications bewahrt angenommene Nachrichten und bietet eine Betreiberaktion an, statt sie "
|
||||||
"unbemerkt zu verarbeiten."
|
"unbemerkt zu verarbeiten. Benachrichtigungslisten laden Zustellversuche gemeinsam für die bereits nach Mandant und Empfänger gefilterte Seite; "
|
||||||
|
"eine zusätzliche Datenbankabfrage je Nachricht entfällt. Reihenfolge und vollständige Nachweise bleiben erhalten. "
|
||||||
|
"Versuche mit abweichendem Mandanten oder Nachrichtenverweis werden auch bei bereits geladenen Beziehungen niemals ausgegeben. "
|
||||||
|
"Widersprüchliche gespeicherte Nachweise erfordern eine berechtigte Betreiberprüfung statt erweiterter Sichtbarkeit. "
|
||||||
|
"Dies optimiert das Lesen, löst keine Zustellung aus und kürzt nicht die Historie einer einzelnen Benachrichtigung."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ def _recipient_ids_for_view(principal: ApiPrincipal, view: Literal["personal", "
|
|||||||
|
|
||||||
@router.get("", response_model=NotificationListResponse)
|
@router.get("", response_model=NotificationListResponse)
|
||||||
def api_list_notifications(
|
def api_list_notifications(
|
||||||
status_filter: str | None = Query(default=None, alias="status"),
|
status_filter: list[str] | None = Query(default=None, alias="status", max_length=20),
|
||||||
channel: str | None = None,
|
channel: str | None = None,
|
||||||
source_module: str | None = None,
|
source_module: str | None = None,
|
||||||
recipient_id: str | None = None,
|
recipient_id: str | None = None,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import and_, case, event, func, or_
|
from sqlalchemy import and_, case, event, func, or_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
from govoplan_core.core.mail import (
|
from govoplan_core.core.mail import (
|
||||||
NotificationMailDeliveryRequest,
|
NotificationMailDeliveryRequest,
|
||||||
@@ -175,7 +175,7 @@ def list_notifications(
|
|||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
status: str | None = None,
|
status: str | Sequence[str] | None = None,
|
||||||
channel: str | None = None,
|
channel: str | None = None,
|
||||||
source_module: str | None = None,
|
source_module: str | None = None,
|
||||||
recipient_id: str | None = None,
|
recipient_id: str | None = None,
|
||||||
@@ -187,8 +187,10 @@ def list_notifications(
|
|||||||
NotificationMessage.tenant_id == tenant_id,
|
NotificationMessage.tenant_id == tenant_id,
|
||||||
NotificationMessage.deleted_at.is_(None),
|
NotificationMessage.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
if status:
|
if status is not None:
|
||||||
query = query.filter(NotificationMessage.status == status)
|
# Repeated status parameters form an OR filter, before ordering/limit.
|
||||||
|
# Keep single-status service callers compatible; [] matches nothing.
|
||||||
|
query = query.filter(NotificationMessage.status.in_([status] if isinstance(status, str) else status))
|
||||||
if channel:
|
if channel:
|
||||||
query = query.filter(NotificationMessage.channel == _clean_channel(channel))
|
query = query.filter(NotificationMessage.channel == _clean_channel(channel))
|
||||||
if source_module:
|
if source_module:
|
||||||
@@ -200,7 +202,18 @@ def list_notifications(
|
|||||||
muted_sources = _clean_source_modules(list(muted_source_modules))
|
muted_sources = _clean_source_modules(list(muted_source_modules))
|
||||||
if muted_sources:
|
if muted_sources:
|
||||||
query = query.filter(NotificationMessage.source_module.notin_(muted_sources))
|
query = query.filter(NotificationMessage.source_module.notin_(muted_sources))
|
||||||
return query.order_by(NotificationMessage.created_at.desc(), NotificationMessage.id.asc()).limit(limit).all()
|
return (
|
||||||
|
query.options(
|
||||||
|
selectinload(
|
||||||
|
NotificationMessage.attempts.and_(
|
||||||
|
NotificationDeliveryAttempt.tenant_id == tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(NotificationMessage.created_at.desc(), NotificationMessage.id.asc())
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def notification_summary(
|
def notification_summary(
|
||||||
@@ -694,7 +707,11 @@ def notification_response(notification: NotificationMessage) -> dict[str, Any]:
|
|||||||
"metadata": notification.metadata_ or {},
|
"metadata": notification.metadata_ or {},
|
||||||
"created_at": response_datetime(notification.created_at),
|
"created_at": response_datetime(notification.created_at),
|
||||||
"updated_at": response_datetime(notification.updated_at),
|
"updated_at": response_datetime(notification.updated_at),
|
||||||
"attempts": [notification_attempt_response(attempt) for attempt in notification.attempts],
|
"attempts": [
|
||||||
|
notification_attempt_response(attempt)
|
||||||
|
for attempt in notification.attempts
|
||||||
|
if attempt.tenant_id == notification.tenant_id and attempt.notification_id == notification.id
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -101,7 +101,9 @@ class NotificationsInterfaceDocumentationContractTests(unittest.TestCase):
|
|||||||
"useUnsavedDraftGuard",
|
"useUnsavedDraftGuard",
|
||||||
):
|
):
|
||||||
self.assertIn(component, settings)
|
self.assertIn(component, settings)
|
||||||
self.assertIn("DocumentationHelpLink", widget)
|
contribution = (REPO_ROOT / "webui/src/module.ts").read_text(encoding="utf-8")
|
||||||
|
self.assertIn("documentation: NOTIFICATIONS_DOCUMENTATION", contribution)
|
||||||
|
self.assertNotIn("DocumentationHelpLink", widget)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, event
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_notifications.backend.db.models import (
|
||||||
|
NotificationDeliveryAttempt,
|
||||||
|
NotificationMessage,
|
||||||
|
)
|
||||||
|
from govoplan_notifications.backend.schemas import NotificationCreateRequest
|
||||||
|
from govoplan_notifications.backend.service import (
|
||||||
|
create_notification,
|
||||||
|
list_notifications,
|
||||||
|
notification_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationListEfficiencyTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(
|
||||||
|
self.engine,
|
||||||
|
tables=[
|
||||||
|
NotificationMessage.__table__,
|
||||||
|
NotificationDeliveryAttempt.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def seed(self, count: int) -> str:
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
first_id = ""
|
||||||
|
for index in range(count + 2):
|
||||||
|
row = create_notification(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-other" if index == count else "tenant-one",
|
||||||
|
payload=NotificationCreateRequest(
|
||||||
|
source_module="test",
|
||||||
|
source_resource_type="record",
|
||||||
|
event_kind="changed",
|
||||||
|
enqueue_delivery=False,
|
||||||
|
recipient_id="other-recipient"
|
||||||
|
if index == count + 1
|
||||||
|
else "reader",
|
||||||
|
subject=f"Fixture {index}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if not first_id:
|
||||||
|
first_id = row.id
|
||||||
|
session.add(
|
||||||
|
NotificationDeliveryAttempt(
|
||||||
|
notification_id=row.id,
|
||||||
|
tenant_id=row.tenant_id,
|
||||||
|
attempt_no=1,
|
||||||
|
channel="inbox",
|
||||||
|
status="failed",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return first_id
|
||||||
|
|
||||||
|
def test_list_and_full_attempt_projection_use_two_queries_independent_of_page_size(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
for count in (1, 40):
|
||||||
|
with self.subTest(count=count):
|
||||||
|
first_id = self.seed(count)
|
||||||
|
statements: list[str] = []
|
||||||
|
|
||||||
|
def count_selects(
|
||||||
|
_connection, _cursor, statement, _parameters, _context, _many
|
||||||
|
):
|
||||||
|
if statement.lstrip().upper().startswith("SELECT"):
|
||||||
|
statements.append(statement)
|
||||||
|
|
||||||
|
event.listen(self.engine, "before_cursor_execute", count_selects)
|
||||||
|
try:
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
rows = list_notifications(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-one",
|
||||||
|
recipient_ids=("reader",),
|
||||||
|
limit=count,
|
||||||
|
)
|
||||||
|
payloads = [notification_response(row) for row in rows]
|
||||||
|
self.assertEqual(count, len(payloads))
|
||||||
|
self.assertTrue(
|
||||||
|
all(len(item["attempts"]) == 1 for item in payloads)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
all(
|
||||||
|
item["tenant_id"] == "tenant-one"
|
||||||
|
and item["recipient_id"] == "reader"
|
||||||
|
for item in payloads
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
2,
|
||||||
|
len(statements),
|
||||||
|
"The list and attempt projection must not add a query per message.",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
event.remove(self.engine, "before_cursor_execute", count_selects)
|
||||||
|
self.assertTrue(first_id)
|
||||||
|
|
||||||
|
def test_attempts_with_inconsistent_tenant_are_never_projected_even_from_a_loaded_relationship(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
first_id = self.seed(1)
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
session.add(
|
||||||
|
NotificationDeliveryAttempt(
|
||||||
|
id="foreign-attempt",
|
||||||
|
notification_id=first_id,
|
||||||
|
tenant_id="tenant-other",
|
||||||
|
attempt_no=2,
|
||||||
|
channel="mail",
|
||||||
|
status="failed",
|
||||||
|
error="Foreign tenant evidence",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
row = session.get(NotificationMessage, first_id)
|
||||||
|
self.assertEqual(2, len(row.attempts))
|
||||||
|
self.assertEqual(1, len(notification_response(row)["attempts"]))
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
rows = list_notifications(
|
||||||
|
session, tenant_id="tenant-one", recipient_ids=("reader",)
|
||||||
|
)
|
||||||
|
self.assertEqual([1], [len(row.attempts) for row in rows])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -5,6 +5,7 @@ import unittest
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
@@ -24,6 +25,7 @@ from govoplan_notifications.backend.service import (
|
|||||||
NotificationError,
|
NotificationError,
|
||||||
create_notification,
|
create_notification,
|
||||||
deliver_pending,
|
deliver_pending,
|
||||||
|
list_notifications,
|
||||||
notification_preferences_response,
|
notification_preferences_response,
|
||||||
notification_response,
|
notification_response,
|
||||||
notification_summary,
|
notification_summary,
|
||||||
@@ -174,6 +176,51 @@ class NotificationServiceTests(unittest.TestCase):
|
|||||||
api_get_notification(other_tenant.id, view="personal", session=session, principal=principal)
|
api_get_notification(other_tenant.id, view="personal", session=session, principal=principal)
|
||||||
self.assertEqual(cross_tenant.exception.status_code, 404)
|
self.assertEqual(cross_tenant.exception.status_code, 404)
|
||||||
|
|
||||||
|
def test_status_multiselection_filters_before_limit_and_preserves_visibility(self) -> None:
|
||||||
|
with self.Session() as session:
|
||||||
|
for index, (state, recipient, tenant) in enumerate([
|
||||||
|
("pending", "user-1", "tenant-1"), ("failed", "user-1", "tenant-1"),
|
||||||
|
("sent", "user-1", "tenant-1"), ("sent", "user-1", "tenant-1"),
|
||||||
|
("failed", "user-2", "tenant-1"), ("failed", "user-1", "tenant-2"),
|
||||||
|
]):
|
||||||
|
item = create_notification(session, tenant_id=tenant,
|
||||||
|
payload=self._inbox_payload(recipient_id=recipient, subject=f"Message {index}"))
|
||||||
|
item.status = state
|
||||||
|
item.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc) + timedelta(minutes=index)
|
||||||
|
session.flush()
|
||||||
|
principal = self._principal(tenant_id="tenant-1", user_id="user-1", account_id="account-1",
|
||||||
|
scopes={"notifications:notification:read"})
|
||||||
|
result = api_list_notifications(status_filter=["pending", "failed"], channel=None,
|
||||||
|
source_module=None, recipient_id=None, view="personal", limit=2, session=session, principal=principal)
|
||||||
|
self.assertEqual([item.status for item in result.notifications], ["failed", "pending"])
|
||||||
|
self.assertEqual(list_notifications(session, tenant_id="tenant-1", status=[]), [])
|
||||||
|
single = list_notifications(session, tenant_id="tenant-1", status="pending")
|
||||||
|
self.assertEqual(len(single), 1)
|
||||||
|
|
||||||
|
def test_http_repeated_status_parameters_use_or_filter(self) -> None:
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from govoplan_core.auth import get_api_principal
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
from govoplan_notifications.backend.router import router
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(router)
|
||||||
|
principal = self._principal(tenant_id="tenant-1", user_id="user-1", account_id="account-1",
|
||||||
|
scopes={"notifications:notification:read"})
|
||||||
|
app.dependency_overrides[get_api_principal] = lambda: principal
|
||||||
|
app.dependency_overrides[get_session] = lambda: object()
|
||||||
|
with patch("govoplan_notifications.backend.router.get_notification_preferences", return_value=SimpleNamespace(muted_source_modules=[])), \
|
||||||
|
patch("govoplan_notifications.backend.router.list_notifications", return_value=[]) as listing, TestClient(app) as client:
|
||||||
|
response = client.get("/notifications?status=pending&status=failed")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(listing.call_args.kwargs["status"], ["pending", "failed"])
|
||||||
|
self.assertIn("user-1", listing.call_args.kwargs["recipient_ids"])
|
||||||
|
self.assertEqual(client.get("/notifications?status=sent").status_code, 200)
|
||||||
|
self.assertEqual(listing.call_args.kwargs["status"], ["sent"])
|
||||||
|
self.assertEqual(client.get("/notifications").status_code, 200)
|
||||||
|
self.assertIsNone(listing.call_args.kwargs["status"])
|
||||||
|
|
||||||
def test_tenant_notification_view_is_an_explicit_admin_operation(self) -> None:
|
def test_tenant_notification_view_is_an_explicit_admin_operation(self) -> None:
|
||||||
with self.Session() as session:
|
with self.Session() as session:
|
||||||
another_user = create_notification(
|
another_user = create_notification(
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/notifications-webui",
|
"name": "@govoplan/notifications-webui",
|
||||||
"version": "0.1.19",
|
"version": "0.1.20",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
"test:ui-structure": "node scripts/test-notification-page-structure.mjs"
|
"test:ui-structure": "node scripts/test-notification-page-structure.mjs"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.18",
|
"@govoplan/core-webui": "^0.1.45",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": ">=19.2.7 <20",
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const page = readFileSync(pagePath, "utf8");
|
|||||||
const styles = readFileSync(stylesPath, "utf8");
|
const styles = readFileSync(stylesPath, "utf8");
|
||||||
|
|
||||||
assert.match(page, /SelectionList,[\s\S]*SelectionListItem,[\s\S]*from "@govoplan\/core-webui"/);
|
assert.match(page, /SelectionList,[\s\S]*SelectionListItem,[\s\S]*from "@govoplan\/core-webui"/);
|
||||||
assert.match(page, /<SelectionList label="i18n:govoplan-notifications\.notifications" className="notifications-selection-list">/);
|
assert.match(page, /<SelectionList\b(?=[^>]*\bvariant="navigation")(?=[^>]*\blabel="i18n:govoplan-notifications\.notifications")(?=[^>]*\bclassName="notifications-selection-list")[^>]*>/);
|
||||||
assert.match(page, /<SelectionListItem[\s\S]*selected=\{selected\?\.id === notification\.id\}/);
|
assert.match(page, /<SelectionListItem[\s\S]*selected=\{selected\?\.id === notification\.id\}/);
|
||||||
assert.match(page, /className=\{`notifications-list-item \$\{notification\.read_at \? "is-read" : ""\}`\}/);
|
assert.match(page, /className=\{`notifications-list-item \$\{notification\.read_at \? "is-read" : ""\}`\}/);
|
||||||
assert.doesNotMatch(page, /<button[\s\S]{0,160}notifications-list-item/);
|
assert.doesNotMatch(page, /<button[\s\S]{0,160}notifications-list-item/);
|
||||||
|
|||||||
@@ -84,16 +84,19 @@ export type NotificationDeliveryResult = {
|
|||||||
errors: string[];
|
errors: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function listNotifications(settings: ApiSettings, filters: { status?: string; channel?: string; source_module?: string; recipient_id?: string; view?: "personal" | "tenant"; limit?: number } = {}): Promise<NotificationListResponse> {
|
export function listNotifications(settings: ApiSettings, filters: { status?: string | string[]; channel?: string; source_module?: string; recipient_id?: string; view?: "personal" | "tenant"; limit?: number } = {}, signal?: AbortSignal): Promise<NotificationListResponse> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (filters.status) params.set("status", filters.status);
|
if (Array.isArray(filters.status)) {
|
||||||
|
if (filters.status.length === 0) return Promise.resolve({ notifications: [] });
|
||||||
|
for (const status of filters.status) params.append("status", status);
|
||||||
|
} else if (filters.status) params.set("status", filters.status);
|
||||||
if (filters.channel) params.set("channel", filters.channel);
|
if (filters.channel) params.set("channel", filters.channel);
|
||||||
if (filters.source_module) params.set("source_module", filters.source_module);
|
if (filters.source_module) params.set("source_module", filters.source_module);
|
||||||
if (filters.recipient_id) params.set("recipient_id", filters.recipient_id);
|
if (filters.recipient_id) params.set("recipient_id", filters.recipient_id);
|
||||||
if (filters.view) params.set("view", filters.view);
|
if (filters.view) params.set("view", filters.view);
|
||||||
if (filters.limit) params.set("limit", String(filters.limit));
|
if (filters.limit) params.set("limit", String(filters.limit));
|
||||||
const query = params.toString();
|
const query = params.toString();
|
||||||
return apiFetch<NotificationListResponse>(settings, `/api/v1/notifications${query ? `?${query}` : ""}`);
|
return apiFetch<NotificationListResponse>(settings, `/api/v1/notifications${query ? `?${query}` : ""}`, { signal, cache: "no-store" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function notificationSummary(settings: ApiSettings): Promise<NotificationSummary> {
|
export function notificationSummary(settings: ApiSettings): Promise<NotificationSummary> {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Bell, Check, ExternalLink, Send, XCircle } from "lucide-react";
|
import { Bell, Check, ExternalLink, Send, XCircle } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
ActionBlockerHint,
|
ActionBlockerHint,
|
||||||
@@ -8,7 +8,8 @@ import {
|
|||||||
CountBadge,
|
CountBadge,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
DocumentationHelpLink,
|
DocumentationHelpLink,
|
||||||
SegmentedControl,
|
TextWithHelp,
|
||||||
|
MultiSelectFilter,
|
||||||
SelectionList,
|
SelectionList,
|
||||||
SelectionListItem,
|
SelectionListItem,
|
||||||
StatePanel,
|
StatePanel,
|
||||||
@@ -31,7 +32,6 @@ import {
|
|||||||
} from "./interfacePatterns";
|
} from "./interfacePatterns";
|
||||||
|
|
||||||
type StatusFilter =
|
type StatusFilter =
|
||||||
| "all"
|
|
||||||
| "pending"
|
| "pending"
|
||||||
| "queued"
|
| "queued"
|
||||||
| "sending"
|
| "sending"
|
||||||
@@ -43,7 +43,6 @@ type StatusFilter =
|
|||||||
| "cancelled";
|
| "cancelled";
|
||||||
|
|
||||||
const statusFilters: StatusFilter[] = [
|
const statusFilters: StatusFilter[] = [
|
||||||
"all",
|
|
||||||
"pending",
|
"pending",
|
||||||
"queued",
|
"queued",
|
||||||
"sending",
|
"sending",
|
||||||
@@ -58,9 +57,22 @@ const statusFilters: StatusFilter[] = [
|
|||||||
const cancellableStatuses = new Set(["pending", "queued", "paused", "failed"]);
|
const cancellableStatuses = new Set(["pending", "queued", "paused", "failed"]);
|
||||||
|
|
||||||
export default function NotificationCenterPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
|
export default function NotificationCenterPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
|
||||||
const [notifications, setNotifications] = useState<NotificationMessage[]>([]);
|
const scopeKey = JSON.stringify([settings.apiBaseUrl, settings.apiKey, settings.accessToken, auth.tenant.id, auth.user.id, auth.scopes]);
|
||||||
|
const currentScope = useRef(scopeKey);
|
||||||
|
const scopeGeneration = useRef(0);
|
||||||
|
if (currentScope.current !== scopeKey) {
|
||||||
|
currentScope.current = scopeKey;
|
||||||
|
scopeGeneration.current += 1;
|
||||||
|
}
|
||||||
|
const generation = scopeGeneration.current;
|
||||||
|
const isCurrentScope = () => currentScope.current === scopeKey && scopeGeneration.current === generation;
|
||||||
|
const operationScope = useRef<string | null>(null);
|
||||||
|
const [loadedScope, setLoadedScope] = useState(scopeKey);
|
||||||
|
const [loadedNotifications, setNotifications] = useState<NotificationMessage[]>([]);
|
||||||
|
const notifications = loadedScope === scopeKey ? loadedNotifications : [];
|
||||||
const [selectedId, setSelectedId] = useState("");
|
const [selectedId, setSelectedId] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
const [statusFilter, setStatusFilter] = useState<string[] | null>(null);
|
||||||
|
const loadRequest = useRef<AbortController | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -73,6 +85,8 @@ export default function NotificationCenterPage({ settings, auth }: { settings: A
|
|||||||
const unreadCount = notifications.filter((item) => !item.read_at && !["cancelled", "skipped"].includes(item.status)).length;
|
const unreadCount = notifications.filter((item) => !item.read_at && !["cancelled", "skipped"].includes(item.status)).length;
|
||||||
const commonSelectionReason = busy
|
const commonSelectionReason = busy
|
||||||
? NOTIFICATIONS_I18N.actionActive
|
? NOTIFICATIONS_I18N.actionActive
|
||||||
|
: loading
|
||||||
|
? "i18n:govoplan-notifications.loading_notifications"
|
||||||
: !selected
|
: !selected
|
||||||
? NOTIFICATIONS_I18N.selectionRequired
|
? NOTIFICATIONS_I18N.selectionRequired
|
||||||
: !canWrite
|
: !canWrite
|
||||||
@@ -83,68 +97,100 @@ export default function NotificationCenterPage({ settings, auth }: { settings: A
|
|||||||
const cancelDisabledReason = commonSelectionReason ?? (selected && !cancellableStatuses.has(selected.status) ? NOTIFICATIONS_I18N.notCancellable : undefined);
|
const cancelDisabledReason = commonSelectionReason ?? (selected && !cancellableStatuses.has(selected.status) ? NOTIFICATIONS_I18N.notCancellable : undefined);
|
||||||
const dispatchDisabledReason = busy
|
const dispatchDisabledReason = busy
|
||||||
? NOTIFICATIONS_I18N.actionActive
|
? NOTIFICATIONS_I18N.actionActive
|
||||||
|
: loading
|
||||||
|
? "i18n:govoplan-notifications.loading_notifications"
|
||||||
: !canDispatch
|
: !canDispatch
|
||||||
? NOTIFICATIONS_I18N.dispatchPermissionRequired
|
? NOTIFICATIONS_I18N.dispatchPermissionRequired
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
operationScope.current = null;
|
||||||
|
setBusy(false);
|
||||||
|
setConfirmingAction(null);
|
||||||
|
}, [scopeKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canRead) {
|
if (!canRead) {
|
||||||
|
loadRequest.current?.abort();
|
||||||
|
setNotifications([]);
|
||||||
|
setSelectedId("");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void load();
|
void load();
|
||||||
}, [canRead, settings.apiBaseUrl, settings.apiKey, settings.accessToken, statusFilter]);
|
return () => loadRequest.current?.abort();
|
||||||
|
}, [canRead, scopeKey, statusFilter]);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
|
if (!isCurrentScope() || !canRead) return;
|
||||||
|
loadRequest.current?.abort();
|
||||||
|
const request = new AbortController();
|
||||||
|
loadRequest.current = request;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const response = await listNotifications(settings, {
|
const response = await listNotifications(settings, {
|
||||||
status: statusFilter === "all" ? undefined : statusFilter,
|
status: statusFilter ?? undefined,
|
||||||
limit: 200
|
limit: 200
|
||||||
});
|
}, request.signal);
|
||||||
|
if (request.signal.aborted || !isCurrentScope()) return;
|
||||||
|
setLoadedScope(scopeKey);
|
||||||
setNotifications(response.notifications);
|
setNotifications(response.notifications);
|
||||||
setSelectedId((current) => current && response.notifications.some((item) => item.id === current) ? current : response.notifications[0]?.id ?? "");
|
setSelectedId((current) => current && response.notifications.some((item) => item.id === current) ? current : response.notifications[0]?.id ?? "");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(errorMessage(err));
|
if (!request.signal.aborted && isCurrentScope()) setError(errorMessage(err));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (!request.signal.aborted && isCurrentScope()) setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markSelected(status: "read" | "acknowledged" | "cancelled"): Promise<boolean> {
|
async function markSelected(status: "read" | "acknowledged" | "cancelled"): Promise<boolean> {
|
||||||
if (!selected || !canWrite) return false;
|
if (!selected || !canWrite || loading || operationScope.current === scopeKey) return false;
|
||||||
|
operationScope.current = scopeKey;
|
||||||
|
loadRequest.current?.abort();
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const next = await updateNotification(settings, selected.id, { status });
|
const next = await updateNotification(settings, selected.id, { status });
|
||||||
setNotifications((items) => items.map((item) => item.id === next.id ? next : item));
|
if (!isCurrentScope()) return false;
|
||||||
|
setNotifications((items) => items.map((item) => item.id === next.id ? next : item)
|
||||||
|
.filter((item) => statusFilter === null || statusFilter.includes(item.status)));
|
||||||
notifyNotificationsChanged();
|
notifyNotificationsChanged();
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(errorMessage(err));
|
if (isCurrentScope()) setError(errorMessage(err));
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
|
if (isCurrentScope()) {
|
||||||
|
operationScope.current = null;
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function runDelivery(): Promise<boolean> {
|
async function runDelivery(): Promise<boolean> {
|
||||||
if (!canDispatch) return false;
|
if (!canDispatch || loading || operationScope.current === scopeKey) return false;
|
||||||
|
operationScope.current = scopeKey;
|
||||||
|
loadRequest.current?.abort();
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
await deliverPendingNotifications(settings, 50);
|
await deliverPendingNotifications(settings, 50);
|
||||||
|
if (!isCurrentScope()) return false;
|
||||||
await load();
|
await load();
|
||||||
|
if (!isCurrentScope()) return false;
|
||||||
notifyNotificationsChanged();
|
notifyNotificationsChanged();
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(errorMessage(err));
|
if (isCurrentScope()) setError(errorMessage(err));
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
|
if (isCurrentScope()) {
|
||||||
|
operationScope.current = null;
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function confirmAction() {
|
async function confirmAction() {
|
||||||
const succeeded = confirmingAction === "cancel"
|
const succeeded = confirmingAction === "cancel"
|
||||||
@@ -204,13 +250,13 @@ export default function NotificationCenterPage({ settings, auth }: { settings: A
|
|||||||
{unreadCount > 0 ? <CountBadge>{unreadCount}</CountBadge> : null}
|
{unreadCount > 0 ? <CountBadge>{unreadCount}</CountBadge> : null}
|
||||||
</div>}
|
</div>}
|
||||||
/>
|
/>
|
||||||
<SegmentedControl
|
<MultiSelectFilter
|
||||||
className="notifications-status-filter"
|
className="notifications-status-filter"
|
||||||
options={statusFilters.map((status) => ({ id: status, label: statusLabel(status) }))}
|
options={statusFilters.map((status) => ({ value: status, label: statusLabel(status) }))}
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={setStatusFilter}
|
onChange={setStatusFilter}
|
||||||
ariaLabel="i18n:govoplan-notifications.notification_status"
|
label="i18n:govoplan-notifications.notification_status"
|
||||||
width="fill"
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
<div className="notifications-list">
|
<div className="notifications-list">
|
||||||
{loading ? <div className="notifications-note">i18n:govoplan-notifications.loading_notifications</div> : null}
|
{loading ? <div className="notifications-note">i18n:govoplan-notifications.loading_notifications</div> : null}
|
||||||
@@ -243,11 +289,11 @@ export default function NotificationCenterPage({ settings, auth }: { settings: A
|
|||||||
scope="detail-pane"
|
scope="detail-pane"
|
||||||
variant="detail"
|
variant="detail"
|
||||||
className="notifications-topbar"
|
className="notifications-topbar"
|
||||||
contextActions={<div className="notifications-title-line">
|
title={<span className="notifications-title-line">
|
||||||
<Bell size={18} />
|
<Bell size={18} aria-hidden="true" />
|
||||||
<strong>{selected?.subject || selected?.event_kind || "i18n:govoplan-notifications.surface.center"}</strong>
|
<strong>{selected?.subject || selected?.event_kind || "i18n:govoplan-notifications.surface.center"}</strong>
|
||||||
</div>}
|
</span>}
|
||||||
helpAction={<DocumentationHelpLink reference={NOTIFICATIONS_DOCUMENTATION} />}
|
titleHelp={<DocumentationHelpLink reference={NOTIFICATIONS_DOCUMENTATION} />}
|
||||||
primaryActions={<div className="notifications-actions">
|
primaryActions={<div className="notifications-actions">
|
||||||
<Button onClick={() => void markSelected("read")} disabled={Boolean(markReadDisabledReason)} disabledReason={markReadDisabledReason}>
|
<Button onClick={() => void markSelected("read")} disabled={Boolean(markReadDisabledReason)} disabledReason={markReadDisabledReason}>
|
||||||
<Check size={16} /> i18n:govoplan-notifications.mark_read
|
<Check size={16} /> i18n:govoplan-notifications.mark_read
|
||||||
@@ -344,8 +390,9 @@ function NotificationDetails({ notification }: { notification: NotificationMessa
|
|||||||
|
|
||||||
<section className="notifications-attempts">
|
<section className="notifications-attempts">
|
||||||
<ActionToolbar surface="section-header" className="notifications-section-heading">
|
<ActionToolbar surface="section-header" className="notifications-section-heading">
|
||||||
|
<TextWithHelp as="div" help={<DocumentationHelpLink reference={NOTIFICATIONS_DELIVERY_DOCUMENTATION} />}>
|
||||||
<h2>i18n:govoplan-notifications.delivery_attempts</h2>
|
<h2>i18n:govoplan-notifications.delivery_attempts</h2>
|
||||||
<DocumentationHelpLink reference={NOTIFICATIONS_DELIVERY_DOCUMENTATION} />
|
</TextWithHelp>
|
||||||
</ActionToolbar>
|
</ActionToolbar>
|
||||||
{notification.attempts.length === 0 ? <p className="muted">i18n:govoplan-notifications.no_delivery_attempt</p> : null}
|
{notification.attempts.length === 0 ? <p className="muted">i18n:govoplan-notifications.no_delivery_attempt</p> : null}
|
||||||
{notification.attempts.map((attempt) => (
|
{notification.attempts.map((attempt) => (
|
||||||
|
|||||||
@@ -183,9 +183,6 @@ export default function NotificationSettingsPanel({ settings, auth }: { settings
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ContentGrid columns={2} collapseAt="workspace" className="notifications-settings-panel">
|
<ContentGrid columns={2} collapseAt="workspace" className="notifications-settings-panel">
|
||||||
<div className="notifications-settings-documentation">
|
|
||||||
<DocumentationHelpLink reference={NOTIFICATIONS_DOCUMENTATION} />
|
|
||||||
</div>
|
|
||||||
{!canWrite ? (
|
{!canWrite ? (
|
||||||
<ActionBlockerHint
|
<ActionBlockerHint
|
||||||
tone="info"
|
tone="info"
|
||||||
@@ -200,7 +197,7 @@ export default function NotificationSettingsPanel({ settings, auth }: { settings
|
|||||||
documentation={NOTIFICATIONS_DOCUMENTATION}
|
documentation={NOTIFICATIONS_DOCUMENTATION}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<Card title="i18n:govoplan-notifications.notifications">
|
<Card title="i18n:govoplan-notifications.notifications" titleHelp={<DocumentationHelpLink reference={NOTIFICATIONS_DOCUMENTATION} />}>
|
||||||
<FormGrid columns={1} collapseAt="standard" className="">
|
<FormGrid columns={1} collapseAt="standard" className="">
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
label="i18n:govoplan-notifications.unread_badge"
|
label="i18n:govoplan-notifications.unread_badge"
|
||||||
@@ -230,9 +227,9 @@ export default function NotificationSettingsPanel({ settings, auth }: { settings
|
|||||||
{message ? <DismissibleAlert tone={messageTone} resetKey={message} floating>{message}</DismissibleAlert> : null}
|
{message ? <DismissibleAlert tone={messageTone} resetKey={message} floating>{message}</DismissibleAlert> : null}
|
||||||
</FormGrid>
|
</FormGrid>
|
||||||
</Card>
|
</Card>
|
||||||
<Card title="i18n:govoplan-notifications.delivery">
|
<Card title="i18n:govoplan-notifications.delivery" titleHelp={<DocumentationHelpLink reference={NOTIFICATIONS_DELIVERY_DOCUMENTATION} />}>
|
||||||
<FormGrid columns={1} collapseAt="standard" className="">
|
<FormGrid columns={1} collapseAt="standard" className="">
|
||||||
<DocumentationHelpLink reference={NOTIFICATIONS_DELIVERY_DOCUMENTATION} />
|
|
||||||
<div className="notifications-settings-inline-title">
|
<div className="notifications-settings-inline-title">
|
||||||
<Mail size={16} />
|
<Mail size={16} />
|
||||||
<strong>i18n:govoplan-notifications.email_notifications</strong>
|
<strong>i18n:govoplan-notifications.email_notifications</strong>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { MetricGrid } from "@govoplan/core-webui";
|
|||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import {
|
import {
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
DocumentationHelpLink,
|
|
||||||
LoadingFrame,
|
LoadingFrame,
|
||||||
MetricCard,
|
MetricCard,
|
||||||
adminErrorMessage,
|
adminErrorMessage,
|
||||||
@@ -11,7 +10,6 @@ import {
|
|||||||
type DashboardWidgetConfiguration,
|
type DashboardWidgetConfiguration,
|
||||||
useSharedNotificationSummary
|
useSharedNotificationSummary
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import { NOTIFICATIONS_DOCUMENTATION } from "./interfacePatterns";
|
|
||||||
|
|
||||||
export default function NotificationSummaryWidget({
|
export default function NotificationSummaryWidget({
|
||||||
settings,
|
settings,
|
||||||
@@ -66,7 +64,6 @@ export default function NotificationSummaryWidget({
|
|||||||
)}
|
)}
|
||||||
</MetricGrid>
|
</MetricGrid>
|
||||||
<div className="notifications-widget-actions">
|
<div className="notifications-widget-actions">
|
||||||
<DocumentationHelpLink reference={NOTIFICATIONS_DOCUMENTATION} />
|
|
||||||
{showCenterLink && (
|
{showCenterLink && (
|
||||||
<Link className="btn btn-secondary" to="/notifications">
|
<Link className="btn btn-secondary" to="/notifications">
|
||||||
i18n:govoplan-notifications.open_center
|
i18n:govoplan-notifications.open_center
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
SettingsSectionsUiCapability
|
SettingsSectionsUiCapability
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import NotificationSummaryWidget from "./features/notifications/NotificationSummaryWidget";
|
import NotificationSummaryWidget from "./features/notifications/NotificationSummaryWidget";
|
||||||
|
import { NOTIFICATIONS_DOCUMENTATION } from "./features/notifications/interfacePatterns";
|
||||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||||
import "./styles/notifications.css";
|
import "./styles/notifications.css";
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ const notificationDashboardWidgets: DashboardWidgetsUiCapability = {
|
|||||||
id: "notifications.summary",
|
id: "notifications.summary",
|
||||||
surfaceId: "notifications.widget.summary",
|
surfaceId: "notifications.widget.summary",
|
||||||
title: "i18n:govoplan-notifications.surface.center",
|
title: "i18n:govoplan-notifications.surface.center",
|
||||||
|
documentation: NOTIFICATIONS_DOCUMENTATION,
|
||||||
description: "i18n:govoplan-notifications.widget_description",
|
description: "i18n:govoplan-notifications.widget_description",
|
||||||
moduleId: "notifications",
|
moduleId: "notifications",
|
||||||
category: "i18n:govoplan-notifications.communication",
|
category: "i18n:govoplan-notifications.communication",
|
||||||
|
|||||||
@@ -26,11 +26,11 @@
|
|||||||
.notifications-status-filter {
|
.notifications-status-filter {
|
||||||
width: calc(100% - 16px);
|
width: calc(100% - 16px);
|
||||||
margin: 8px;
|
margin: 8px;
|
||||||
overflow-x: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.notifications-status-filter .segmented-control-option {
|
.notifications-status-filter .multi-select-filter-trigger {
|
||||||
flex: 0 0 auto;
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notifications-list {
|
.notifications-list {
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.notifications-title-line {
|
.notifications-title-line {
|
||||||
min-width: 160px;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notifications-title-line strong {
|
.notifications-title-line strong {
|
||||||
@@ -169,16 +169,10 @@
|
|||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notifications-settings-documentation,
|
|
||||||
.notifications-settings-panel > .action-blocker-hint {
|
.notifications-settings-panel > .action-blocker-hint {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notifications-settings-documentation {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notifications-settings-inline-title {
|
.notifications-settings-inline-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user