11 Commits
Author SHA1 Message Date
zemion c101ee1974 fix(packaging): expose immutable WebUI Git package for v0.1.20
Module Package Release / publish-packages (push) Successful in 11s
2026-09-08 02:06:10 +02:00
zemion ca22d9e706 Release govoplan-notifications v0.1.20: batch attempts and unify multi-select filters 2026-09-08 01:32:46 +02:00
zemion 713f2d3c63 docs(notifications): complete German reference coverage
Module Package Release / publish-packages (push) Successful in 11s
2026-08-23 19:31:22 +02:00
zemion 975d90b012 feat(notifications): add governed DSAR coverage 2026-08-21 04:08:21 +02:00
zemion 5c28ac5094 fix(notifications): enforce personal source mutes 2026-08-20 04:58:34 +02:00
zemion f9f7ab75d3 refactor(webui): adopt semantic workspace actions 2026-08-19 18:47:45 +02:00
zemion da0960967d feat: align notifications with shared UI foundations 2026-08-18 21:32:42 +02:00
zemion e4d6dff1d7 Adopt shared WebUI structural primitives 2026-08-18 13:17:31 +02:00
zemion 178888c7ac Adopt shared WebUI layout primitives 2026-08-18 11:30:40 +02:00
zemion bf0ebe1541 Adopt shared WebUI layout primitives 2026-08-18 10:42:52 +02:00
zemion 09205374f9 Release v0.1.18
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 21:07:52 +02:00
17 changed files with 1967 additions and 216 deletions
+18
View File
@@ -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.
+36
View File
@@ -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
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-notifications" name = "govoplan-notifications"
version = "0.1.17" 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.17", "govoplan-core>=0.1.45",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
@@ -0,0 +1,655 @@
from __future__ import annotations
import math
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
from govoplan_notifications.backend.db.models import (
NotificationMessage,
NotificationPreference,
)
NOTIFICATIONS_DSAR_CAPABILITY = dsar_capability_name("notifications")
_MAX_RECORDS = 1_000
_MAX_ATTEMPTS_PER_MESSAGE = 100
_MAX_BODY_CHARS = 100_000
_MAX_JSON_NODES = 10_000
_MAX_JSON_CHARS = 100_000
_CONFLICT = object()
_SENSITIVE_KEY_PARTS = (
"authorization",
"cookie",
"credential",
"password",
"secret",
"token",
"api_key",
"apikey",
)
@dataclass(frozen=True, slots=True)
class _SubjectSelectors:
actor_ids: tuple[str, ...]
email: str | None
notification_id: str | None
preference_id: str | None
@dataclass(slots=True)
class _JsonBudget:
nodes: int = 0
characters: int = 0
class NotificationsDsarProvider:
provider_id = "notifications"
module_id = "notifications"
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]:
db = _session(session)
selectors = _subject_selectors(subject)
if selectors is None:
return ()
records: list[DsarRecordRef] = []
if selectors.preference_id is None:
messages = db.query(NotificationMessage).filter(
NotificationMessage.tenant_id == tenant_id,
_recipient_filter(selectors),
)
if selectors.notification_id:
messages = messages.filter(
NotificationMessage.id == selectors.notification_id
)
rows = (
messages.order_by(
NotificationMessage.created_at,
NotificationMessage.id,
)
.limit(_MAX_RECORDS + 1)
.all()
)
if len(rows) > _MAX_RECORDS:
raise ValueError(
"Notifications DSAR result limit exceeded; narrow the selectors."
)
records.extend(_message_record(row) for row in rows)
if selectors.notification_id is None and selectors.actor_ids:
preferences = db.query(NotificationPreference).filter(
NotificationPreference.tenant_id == tenant_id,
NotificationPreference.user_id.in_(selectors.actor_ids),
)
if selectors.preference_id:
preferences = preferences.filter(
NotificationPreference.id == selectors.preference_id
)
rows = (
preferences.order_by(NotificationPreference.id)
.limit(_MAX_RECORDS + 1)
.all()
)
if len(rows) > _MAX_RECORDS:
raise ValueError(
"Notification preference DSAR result limit exceeded; narrow the selectors."
)
records.extend(_preference_record(row) for row in rows)
if len(records) > _MAX_RECORDS:
raise ValueError(
"Notifications DSAR combined result limit exceeded; narrow the selectors."
)
order = {"notification_preference": 10, "notification_message": 20}
return tuple(
sorted(
records,
key=lambda item: (order[item.resource_type], item.resource_id),
)
)
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]:
db = _session(session)
selectors = _subject_selectors(subject)
if selectors is None:
raise ValueError("Notifications DSAR subject selectors conflict.")
actions: list[DsarErasureActionRef] = []
for record in records:
_validate_record(record)
if record.resource_type == "notification_preference":
preference = (
db.query(NotificationPreference)
.filter(
NotificationPreference.tenant_id == tenant_id,
NotificationPreference.id == record.resource_id,
)
.one_or_none()
)
if (
preference is None
or preference.user_id not in selectors.actor_ids
or (
selectors.preference_id
and preference.id != selectors.preference_id
)
):
actions.append(
_manual_action(record, "preference ownership mismatch")
)
continue
actions.append(_delete_action(record, preference.updated_at))
continue
notification = (
db.query(NotificationMessage)
.filter(
NotificationMessage.tenant_id == tenant_id,
NotificationMessage.id == record.resource_id,
_recipient_filter(selectors),
)
.one_or_none()
)
if notification is None or (
selectors.notification_id
and notification.id != selectors.notification_id
):
actions.append(_manual_action(record, "recipient or selector mismatch"))
elif notification.channel == "inbox":
actions.append(_delete_action(record, notification.updated_at))
else:
actions.append(
_manual_action(
record,
"external-channel delivery and provider evidence require retention review",
)
)
return tuple(actions)
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]:
db = _session(session)
selectors = _subject_selectors(subject)
if selectors is None:
raise ValueError("Notifications DSAR subject selectors conflict.")
results: list[DsarExecutionResultRef] = []
for action in actions:
_validate_action(action)
if not action.executable or action.kind != "delete":
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"External notification delivery evidence requires an "
"authorized retention review."
),
)
)
continue
if action.resource_type == "notification_preference":
row = (
db.query(NotificationPreference)
.filter(
NotificationPreference.tenant_id == tenant_id,
NotificationPreference.id == action.resource_id,
)
.one_or_none()
)
if row is None:
results.append(_unchanged(action, request_id, "preference"))
continue
if (
row.user_id not in selectors.actor_ids
or str(action.metadata.get("owner_id") or "") != row.user_id
or (selectors.preference_id and row.id != selectors.preference_id)
):
results.append(_blocked(action, "preference ownership mismatch"))
continue
elif action.resource_type == "notification_message":
row = (
db.query(NotificationMessage)
.filter(
NotificationMessage.tenant_id == tenant_id,
NotificationMessage.id == action.resource_id,
)
.one_or_none()
)
if row is None:
results.append(_unchanged(action, request_id, "notification"))
continue
if (
row.channel != "inbox"
or not _recipient_matches(row, selectors)
or (
selectors.notification_id
and row.id != selectors.notification_id
)
):
results.append(_blocked(action, "recipient or channel mismatch"))
continue
else:
raise ValueError("Unsupported executable Notifications DSAR action.")
if action.metadata.get("updated_at") != _iso(row.updated_at):
results.append(_blocked(action, "the record changed after planning"))
continue
resource_id = str(row.id)
db.delete(row)
db.flush()
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="executed",
summary=(
"Deleted the personal Notifications record without "
"traversing or changing its source-module data."
),
evidence={
"request_id": request_id,
"resource_type": action.resource_type,
"resource_id": resource_id,
},
)
)
return tuple(results)
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
references = subject.external_references
values = {
"account_id": _coalesce(
subject.account_id,
references.get("notifications.account"),
references.get("access.account"),
),
"membership_id": _coalesce(
subject.membership_id,
references.get("notifications.membership"),
references.get("tenancy.membership"),
),
"identity_id": _coalesce(
subject.identity_id,
references.get("notifications.identity"),
references.get("identity.id"),
),
"user_id": _coalesce(
references.get("notifications.user"),
references.get("idm.user"),
),
"email": _coalesce(
_normalized_email(subject.email),
_normalized_email(references.get("notifications.email")),
),
"notification_id": _coalesce(
references.get("notifications.notification"),
references.get("notifications.message"),
),
"preference_id": _coalesce(
references.get("notifications.preference"),
references.get("notifications.preference_id"),
),
}
if any(value is _CONFLICT for value in values.values()):
return None
actor_ids = tuple(
dict.fromkeys(
value
for key in ("account_id", "membership_id", "identity_id", "user_id")
if (value := _optional_string(values[key]))
)
)
email = _optional_string(values["email"])
if not actor_ids and email is None:
return None
return _SubjectSelectors(
actor_ids=actor_ids,
email=email,
notification_id=_optional_string(values["notification_id"]),
preference_id=_optional_string(values["preference_id"]),
)
def _coalesce(*values: str | None) -> str | None | object:
normalized = {str(value).strip() for value in values if str(value or "").strip()}
if len(normalized) > 1:
return _CONFLICT
return next(iter(normalized), None)
def _optional_string(value: object) -> str | None:
return value if isinstance(value, str) and value else None
def _normalized_email(value: str | None) -> str | None:
normalized = (value or "").strip().casefold()
return normalized or None
def _recipient_filter(selectors: _SubjectSelectors):
filters = []
if selectors.actor_ids:
filters.append(NotificationMessage.recipient_id.in_(selectors.actor_ids))
if selectors.email:
filters.extend(
(
func.lower(NotificationMessage.recipient) == selectors.email,
func.lower(NotificationMessage.recipient_id) == selectors.email,
)
)
return or_(*filters)
def _recipient_matches(
notification: NotificationMessage,
selectors: _SubjectSelectors,
) -> bool:
if notification.recipient_id in selectors.actor_ids:
return True
return bool(
selectors.email
and selectors.email
in {
_normalized_email(notification.recipient),
_normalized_email(notification.recipient_id),
}
)
def _message_record(notification: NotificationMessage) -> DsarRecordRef:
attempts = list(notification.attempts)
if len(attempts) > _MAX_ATTEMPTS_PER_MESSAGE:
raise ValueError(
"Notifications DSAR delivery-attempt limit exceeded; narrow the selectors."
)
return DsarRecordRef(
provider_id="notifications",
module_id="notifications",
resource_type="notification_message",
resource_id=notification.id,
category="direct_notification",
title=f"Notification: {(notification.subject or notification.event_kind)[:200]}",
data={
"source_module": notification.source_module,
"source_resource_type": notification.source_resource_type,
"source_resource_id": notification.source_resource_id,
"event_kind": notification.event_kind,
"channel": notification.channel,
"recipient": notification.recipient,
"recipient_type": notification.recipient_type,
"recipient_id": notification.recipient_id,
"recipient_label": notification.recipient_label,
"subject": notification.subject,
"body_text": _bounded_text(notification.body_text, "body text"),
"body_html": _bounded_text(notification.body_html, "body HTML"),
"action_url": notification.action_url,
"priority": notification.priority,
"status": notification.status,
"not_before_at": _iso(notification.not_before_at),
"queued_at": _iso(notification.queued_at),
"sent_at": _iso(notification.sent_at),
"failed_at": _iso(notification.failed_at),
"read_at": _iso(notification.read_at),
"acknowledged_at": _iso(notification.acknowledged_at),
"cancelled_at": _iso(notification.cancelled_at),
"attempt_count": notification.attempt_count,
"last_error": _bounded_text(notification.last_error, "last error"),
"external_message_id": notification.external_message_id,
"payload": _bounded_json(notification.payload, label="payload"),
"metadata": _bounded_json(notification.metadata_, label="metadata"),
"deleted_at": _iso(notification.deleted_at),
"attempts": [
{
"id": attempt.id,
"attempt_no": attempt.attempt_no,
"channel": attempt.channel,
"provider": attempt.provider,
"status": attempt.status,
"started_at": _iso(attempt.started_at),
"finished_at": _iso(attempt.finished_at),
"external_message_id": attempt.external_message_id,
"error": _bounded_text(attempt.error, "attempt error"),
"details": _bounded_json(
attempt.details,
label="attempt details",
),
}
for attempt in attempts
],
},
observed_at=_aware(notification.updated_at),
retention_reason=(
"External-channel messages require delivery and source-record retention review."
if notification.channel != "inbox"
else None
),
)
def _preference_record(preference: NotificationPreference) -> DsarRecordRef:
muted = preference.muted_source_modules
if not isinstance(muted, list) or len(muted) > 500:
raise ValueError("Notification muted-source preference exceeds the DSAR bound.")
return DsarRecordRef(
provider_id="notifications",
module_id="notifications",
resource_type="notification_preference",
resource_id=preference.id,
category="personal_communication_preference",
title="Personal notification preferences",
data={
"user_id": preference.user_id,
"show_unread_badge": preference.show_unread_badge,
"email_enabled": preference.email_enabled,
"email_digest_enabled": preference.email_digest_enabled,
"muted_source_modules": [str(value)[:100] for value in muted],
"metadata": _bounded_json(
preference.metadata_,
label="preference metadata",
),
},
observed_at=_aware(preference.updated_at),
)
def _bounded_text(value: str | None, label: str) -> str | None:
if value is None:
return None
if len(value) > _MAX_BODY_CHARS:
raise ValueError(
f"Notification {label} exceeds the DSAR export bound; narrow and review the record."
)
return value
def _bounded_json(value: object, *, label: str) -> object:
budget = _JsonBudget()
def project(item: object, depth: int) -> object:
budget.nodes += 1
if budget.nodes > _MAX_JSON_NODES or depth > 12:
raise ValueError(f"Notification {label} exceeds the DSAR structure bound.")
if item is None or isinstance(item, (bool, int)):
return item
if isinstance(item, float):
if not math.isfinite(item):
raise ValueError(f"Notification {label} contains a non-finite number.")
return item
if isinstance(item, str):
budget.characters += len(item)
if budget.characters > _MAX_JSON_CHARS:
raise ValueError(
f"Notification {label} exceeds the DSAR character bound."
)
return item
if isinstance(item, Mapping):
if len(item) > 1_000:
raise ValueError(f"Notification {label} contains too many fields.")
projected: dict[str, object] = {}
for raw_key, raw_value in item.items():
key = str(raw_key)
if len(key) > 500:
raise ValueError(f"Notification {label} contains an oversized key.")
budget.characters += len(key)
if budget.characters > _MAX_JSON_CHARS:
raise ValueError(
f"Notification {label} exceeds the DSAR character bound."
)
projected[key] = (
"[redacted]"
if _is_sensitive_key(key)
else project(raw_value, depth + 1)
)
return projected
if isinstance(item, (list, tuple)):
if len(item) > 1_000:
raise ValueError(f"Notification {label} contains too many items.")
return [project(entry, depth + 1) for entry in item]
raise ValueError(f"Notification {label} contains an unsupported value.")
return project(value, 0)
def _is_sensitive_key(value: str) -> bool:
normalized = value.strip().casefold().replace("-", "_")
return any(part in normalized for part in _SENSITIVE_KEY_PARTS)
def _delete_action(
record: DsarRecordRef,
updated_at: datetime | None,
) -> DsarErasureActionRef:
metadata: dict[str, object] = {"updated_at": _iso(updated_at)}
if record.resource_type == "notification_preference":
metadata["owner_id"] = str(record.data.get("user_id") or "")
return DsarErasureActionRef(
action_id=f"notifications:delete:{record.resource_type}:{record.resource_id}",
provider_id="notifications",
module_id="notifications",
kind="delete",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=f"Delete {record.title}",
rationale=(
"The record is a subject-owned preference or derived in-product "
"notification copy; source-module data remains unchanged."
),
executable=True,
irreversible=True,
metadata=metadata,
)
def _manual_action(record: DsarRecordRef, reason: str) -> DsarErasureActionRef:
return DsarErasureActionRef(
action_id=(
f"notifications:manual_review:{record.resource_type}:{record.resource_id}"
),
provider_id="notifications",
module_id="notifications",
kind="manual_review",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=f"Review {record.title}",
rationale=f"Automatic deletion stopped because {reason}.",
executable=False,
)
def _unchanged(
action: DsarErasureActionRef,
request_id: str,
label: str,
) -> DsarExecutionResultRef:
return DsarExecutionResultRef(
action_id=action.action_id,
status="unchanged",
summary=f"The personal notification {label} was already absent.",
evidence={"request_id": request_id},
)
def _blocked(
action: DsarErasureActionRef,
reason: str,
) -> DsarExecutionResultRef:
return DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=f"Notifications erasure stopped because of {reason}.",
)
def _iso(value: datetime | None) -> str | None:
aware = _aware(value)
return aware.isoformat() if aware else None
def _aware(value: datetime | None) -> datetime | None:
if value is None or value.tzinfo is not None:
return value
return value.replace(tzinfo=timezone.utc)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Notifications DSAR requires a SQLAlchemy Session.")
return value
def _validate_record(record: DsarRecordRef) -> None:
if record.provider_id != "notifications" or record.module_id != "notifications":
raise ValueError("Notifications DSAR cannot plan a foreign provider record.")
if (
record.resource_type
not in {
"notification_message",
"notification_preference",
}
or not record.resource_id
):
raise ValueError("Notifications DSAR record identity is invalid.")
def _validate_action(action: DsarErasureActionRef) -> None:
if action.provider_id != "notifications" or action.module_id != "notifications":
raise ValueError("Notifications DSAR cannot execute a foreign provider action.")
if not action.action_id.startswith("notifications:"):
raise ValueError("Notifications DSAR action identity is invalid.")
__all__ = ["NOTIFICATIONS_DSAR_CAPABILITY", "NotificationsDsarProvider"]
+270 -17
View File
@@ -2,9 +2,17 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER from govoplan_core.core.access import (
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
)
from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
persistent_table_uninstall_guard,
)
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationCondition,
DocumentationTopic, DocumentationTopic,
FrontendModule, FrontendModule,
FrontendRoute, FrontendRoute,
@@ -13,6 +21,7 @@ from govoplan_core.core.modules import (
ModuleInterfaceProvider, ModuleInterfaceProvider,
ModuleManifest, ModuleManifest,
PermissionDefinition, PermissionDefinition,
ProductAreaContribution,
RoleTemplate, RoleTemplate,
) )
from govoplan_core.core.provider_governance import declared_module_architecture from govoplan_core.core.provider_governance import declared_module_architecture
@@ -20,11 +29,15 @@ from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH
from govoplan_core.core.views import ViewSurface from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_notifications.backend.db import models as notification_models # noqa: F401 - populate Notifications ORM metadata from govoplan_notifications.backend.db import models as notification_models # noqa: F401 - populate Notifications ORM metadata
from govoplan_notifications.backend.dsar_provider import (
NOTIFICATIONS_DSAR_CAPABILITY,
NotificationsDsarProvider,
)
MODULE_ID = "notifications" MODULE_ID = "notifications"
MODULE_NAME = "Notifications" MODULE_NAME = "Notifications"
MODULE_VERSION = "0.1.17" 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"
@@ -46,10 +59,26 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
PERMISSIONS = ( PERMISSIONS = (
_permission(READ_SCOPE, "View notifications", "Read the authenticated actor's notification inbox and delivery state."), _permission(
_permission(WRITE_SCOPE, "Manage notifications", "Create, update, cancel, read, and acknowledge notifications."), READ_SCOPE,
_permission(DISPATCH_SCOPE, "Dispatch notifications", "Run notification delivery attempts and delivery workers."), "View notifications",
_permission(ADMIN_SCOPE, "Administer notifications", "Explicitly read and manage tenant-wide notification delivery state."), "Read the authenticated actor's notification inbox and delivery state.",
),
_permission(
WRITE_SCOPE,
"Manage notifications",
"Create, update, cancel, read, and acknowledge notifications.",
),
_permission(
DISPATCH_SCOPE,
"Dispatch notifications",
"Run notification delivery attempts and delivery workers.",
),
_permission(
ADMIN_SCOPE,
"Administer notifications",
"Explicitly read and manage tenant-wide notification delivery state.",
),
) )
ROLE_TEMPLATES = ( ROLE_TEMPLATES = (
@@ -72,8 +101,19 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
from govoplan_notifications.backend.db.models import NotificationMessage from govoplan_notifications.backend.db.models import NotificationMessage
return { return {
"notifications": session.query(NotificationMessage).filter(NotificationMessage.tenant_id == tenant_id, NotificationMessage.deleted_at.is_(None)).count(), "notifications": session.query(NotificationMessage)
"pending_notifications": session.query(NotificationMessage).filter(NotificationMessage.tenant_id == tenant_id, NotificationMessage.status.in_(("pending", "queued", "failed")), NotificationMessage.deleted_at.is_(None)).count(), .filter(
NotificationMessage.tenant_id == tenant_id,
NotificationMessage.deleted_at.is_(None),
)
.count(),
"pending_notifications": session.query(NotificationMessage)
.filter(
NotificationMessage.tenant_id == tenant_id,
NotificationMessage.status.in_(("pending", "queued", "failed")),
NotificationMessage.deleted_at.is_(None),
)
.count(),
} }
@@ -83,28 +123,140 @@ def _notifications_router(_context: ModuleContext):
return router return router
def _dsar_provider(_context: ModuleContext) -> NotificationsDsarProvider:
return NotificationsDsarProvider()
manifest = ModuleManifest( manifest = ModuleManifest(
id=MODULE_ID, id=MODULE_ID,
name=MODULE_NAME, name=MODULE_NAME,
version=MODULE_VERSION, version=MODULE_VERSION,
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), required_capabilities=(
optional_dependencies=("mail", "tasks", "portal", "workflow_engine", "calendar", "scheduling"), CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
provides_interfaces=(ModuleInterfaceProvider(name="notifications.dispatch", version="0.1.8"),), CAPABILITY_AUTH_PERMISSION_EVALUATOR,
),
optional_dependencies=(
"mail",
"tasks",
"portal",
"workflow_engine",
"calendar",
"scheduling",
),
provides_interfaces=(
ModuleInterfaceProvider(name="notifications.dispatch", version="0.1.8"),
ModuleInterfaceProvider(
name=NOTIFICATIONS_DSAR_CAPABILITY,
version="0.1.0",
),
),
permissions=PERMISSIONS, permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES, role_templates=ROLE_TEMPLATES,
route_factory=_notifications_router, route_factory=_notifications_router,
tenant_summary_providers=(_tenant_summary,), tenant_summary_providers=(_tenant_summary,),
documentation=( documentation=(
DocumentationTopic(
id="notifications.data-subject-requests",
title="Notification data-subject requests",
summary=(
"Export subject-addressed notifications and preferences, delete "
"personal inbox copies, and review external delivery evidence."
),
body=(
"Notifications correlates verified account, membership, identity, "
"user, and email selectors only inside the active tenant. The access "
"package contains bounded message content, recipient fields, lifecycle "
"timestamps, source references, payload and metadata, and sanitized "
"delivery attempts. Personal badge, email, digest, and source-muting "
"preferences are also included. Explicit notification or preference "
"references can narrow a request and must still match the subject. "
"In-product inbox messages are derived copies, so erasure can delete "
"them after recipient and change checks without following the source "
"reference into its owning module. Personal preferences can also be "
"deleted and then return to defaults. Mail and other external-channel "
"records require manual retention review because provider acceptance "
"and delivery evidence may remain outside Notifications. Repeated "
"execution is unchanged; unrelated tenants and recipients are never "
"included."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "tenant_admin", "operator", "auditor"),
related_modules=("core", "mail", "tasks", "workflow_engine"),
metadata={
"help_contexts": [
"notifications.page.inbox",
"notifications.page.detail",
"notifications.page.delivery",
"notifications.settings.preferences",
"privacy.data-subject-requests",
],
"consequence_classes": {
"delete_inbox_copy": (
"Removes the derived in-product notification and attempts, "
"not the source-module record."
),
"delete_preferences": (
"Removes personal overrides; notification defaults apply again."
),
"review_external_delivery": (
"Requires an authorized retention review before changing "
"provider-delivery evidence."
),
},
},
translations={
"de": {
"title": "Datenschutzanfragen zu Benachrichtigungen",
"summary": (
"An eine betroffene Person adressierte Benachrichtigungen und Einstellungen ausgeben, "
"persönliche Posteingangskopien löschen und externe Zustellnachweise prüfen."
),
"body": (
"Notifications gleicht verifizierte Konto-, Mitgliedschafts-, Identitäts-, Benutzer- und "
"E-Mail-Selektoren ausschließlich innerhalb des aktiven Mandanten ab. Das Auskunftspaket "
"enthält begrenzte Nachrichteninhalte, Empfängerfelder, Lebenszykluszeitpunkte, Quellverweise, "
"Nutzdaten und Metadaten sowie bereinigte Zustellversuche. Persönliche Einstellungen für "
"Kennzeichen, E-Mail, Zusammenfassungen und stummgeschaltete Quellmodule werden ebenfalls "
"einbezogen. Ausdrückliche Benachrichtigungs- oder Einstellungsverweise dürfen eine Anfrage nur "
"einschränken und müssen weiterhin zur betroffenen Person gehören. Nachrichten im internen "
"Posteingang sind abgeleitete Kopien und können deshalb nach Empfänger- und Änderungsprüfung "
"gelöscht werden, ohne dem Quellverweis in das Eigentümermodul zu folgen. Persönliche "
"Einstellungen können ebenfalls gelöscht werden; anschließend gelten wieder die Standardwerte. "
"Mail- und andere externe Kanalnachweise erfordern eine manuelle Aufbewahrungsprüfung, weil beim "
"Anbieter Annahme- und Zustellnachweise verbleiben können. Eine wiederholte Ausführung bleibt "
"unverändert; fremde Mandanten und Empfänger werden nie einbezogen."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"consequence_classes": {
"delete_inbox_copy": (
"Entfernt die abgeleitete interne Benachrichtigung und ihre Versuche, nicht den Datensatz des Quellmoduls."
),
"delete_preferences": (
"Entfernt persönliche Abweichungen; anschließend gelten wieder die Benachrichtigungsstandardwerte."
),
"review_external_delivery": (
"Erfordert eine autorisierte Aufbewahrungsprüfung, bevor externe Zustellnachweise verändert werden."
),
}
}
},
),
DocumentationTopic( DocumentationTopic(
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. Preferences control eligible delivery channels and categories. Disabling an optional external channel does not remove the in-product notification unless the originating module's retention policy does so.", body="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,)),),
related_modules=("mail", "calendar", "scheduling", "workflow_engine"), related_modules=("mail", "calendar", "scheduling", "workflow_engine"),
metadata={ metadata={
"kind": "reference", "kind": "workflow",
"help_contexts": [ "help_contexts": [
"notifications.route.notifications", "notifications.route.notifications",
"notifications.page.inbox", "notifications.page.inbox",
@@ -120,12 +272,50 @@ manifest = ModuleManifest(
"update_preferences": "replace the current user's badge, channel, digest, and source-muting preferences", "update_preferences": "replace the current user's badge, channel, digest, and source-muting preferences",
}, },
}, },
translations={
"de": {
"title": "Benachrichtigungszentrale verwenden",
"summary": (
"Das Kennzeichen in der Titelleiste und die Benachrichtigungszentrale bündeln dauerhafte Hinweise, "
"die außerhalb einer unmittelbaren Anfrage Aufmerksamkeit erfordern."
),
"body": (
"Die Benachrichtigungszentrale zeigt Hinweise aktivierter Module zum Lesen, Bestätigen oder "
"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, "
"Mitgliedschaft oder Identität adressiert hat; Nachweisansichten für Mandantenadministratoren "
"bleiben vollständig. Die Einstellungen steuern außerdem zulässige Zustellkanäle und Kategorien. "
"Das Abschalten eines optionalen externen Kanals entfernt keine nicht stummgeschaltete interne "
"Benachrichtigung, sofern dies nicht die Aufbewahrungsregel des Ursprungsmoduls vorsieht."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"consequence_classes": {
"mark_read": "Markiert die Benachrichtigung für den aktuellen Empfänger als gelesen.",
"acknowledge": "Hält zusätzlich zum Lesestatus eine ausdrückliche Bestätigung des Empfängers fest.",
"cancel": (
"Stoppt eine geeignete Benachrichtigung vor der Anbieterannahme, ohne einen Fernrückruf zu behaupten."
),
"update_preferences": (
"Ersetzt die Einstellungen des aktuellen Benutzers für Kennzeichen, Kanäle, Zusammenfassungen und stummgeschaltete Quellen."
),
}
}
},
), ),
DocumentationTopic( DocumentationTopic(
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="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"),
@@ -141,6 +331,41 @@ manifest = ModuleManifest(
"inspect_attempts": "read sanitized provider, status, timing, and error evidence for the selected notification", "inspect_attempts": "read sanitized provider, status, timing, and error evidence for the selected notification",
}, },
}, },
translations={
"de": {
"title": "Benachrichtigungszustellung betreiben",
"summary": (
"Notifications speichert Nachrichtenabsicht und begrenzte kanalbezogene Versuche, bevor Worker optionale Zustellkanäle ausführen."
),
"body": (
"Erzeugende Module übergeben Benachrichtigungen über die Dispatch-Fähigkeit und verwalten keine "
"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 "
"Entwicklung beschränkt. Betreiber können ausstehende und fehlgeschlagene Versuche prüfen und nur "
"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 "
"Notifications bewahrt angenommene Nachrichten und bietet eine Betreiberaktion an, statt sie "
"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."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"consequence_classes": {
"dispatch_pending": (
"Versucht die Zustellung für höchstens die angeforderte Anzahl geeigneter Mandantenbenachrichtigungen."
),
"inspect_attempts": (
"Liest bereinigte Anbieter-, Status-, Zeit- und Fehlernachweise der ausgewählten Benachrichtigung."
),
}
}
},
), ),
), ),
frontend=FrontendModule( frontend=FrontendModule(
@@ -155,6 +380,17 @@ manifest = ModuleManifest(
surface_id="notifications.route.notifications", surface_id="notifications.route.notifications",
), ),
), ),
product_areas=(
ProductAreaContribution(
id="communication",
module_id=MODULE_ID,
label="i18n:govoplan-core.product_area.communication",
icon="mail",
description="i18n:govoplan-core.product_area.communication_description",
surface_ids=("notifications.route.notifications",),
order=40,
),
),
view_surfaces=( view_surfaces=(
ViewSurface( ViewSurface(
id="notifications.page.inbox", id="notifications.page.inbox",
@@ -254,6 +490,17 @@ manifest = ModuleManifest(
"govoplan_notifications.backend.capabilities", "govoplan_notifications.backend.capabilities",
fromlist=["dispatch_capability"], fromlist=["dispatch_capability"],
).dispatch_capability(context), ).dispatch_capability(context),
NOTIFICATIONS_DSAR_CAPABILITY: _dsar_provider,
},
capability_documentation={
NOTIFICATIONS_DSAR_CAPABILITY: CapabilityDocumentation(
label="Notifications data-subject request provider",
summary=(
"Exports subject-addressed notification data, deletes personal "
"inbox copies and preferences, and isolates external delivery evidence."
),
contract_version="0.1.0",
),
}, },
architecture=declared_module_architecture( architecture=declared_module_architecture(
layer="communication_participation", layer="communication_participation",
@@ -261,8 +508,14 @@ manifest = ModuleManifest(
maturity="vertical_slice", maturity="vertical_slice",
documentation_ref="docs/NOTIFICATION_INBOX_BOUNDARY.md", documentation_ref="docs/NOTIFICATION_INBOX_BOUNDARY.md",
test_ref="tests/test_notifications.py", test_ref="tests/test_notifications.py",
known_limits=("Production email delivery depends on the optional Mail capability and does not provide an independent transport.",), known_limits=(
owned_concepts=("notification", "notification preference", "notification delivery attempt"), "Production email delivery depends on the optional Mail capability and does not provide an independent transport.",
),
owned_concepts=(
"notification",
"notification preference",
"notification delivery attempt",
),
non_owned_concepts=("mail transport", "domain event", "portal message"), non_owned_concepts=("mail transport", "domain event", "portal message"),
recovery_docs=("docs/EMAIL_DELIVERY.md",), recovery_docs=("docs/EMAIL_DELIVERY.md",),
operations_docs=("docs/EMAIL_DELIVERY.md",), operations_docs=("docs/EMAIL_DELIVERY.md",),
+11 -2
View File
@@ -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,
@@ -95,6 +95,14 @@ def api_list_notifications(
recipient_ids = _recipient_ids_for_view(principal, view) recipient_ids = _recipient_ids_for_view(principal, view)
if recipient_id is not None and recipient_ids is not None and recipient_id not in recipient_ids: if recipient_id is not None and recipient_ids is not None and recipient_id not in recipient_ids:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot read another recipient's notifications") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot read another recipient's notifications")
muted_source_modules: tuple[str, ...] = ()
if view == "personal":
preference = get_notification_preferences(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
)
muted_source_modules = tuple(preference.muted_source_modules or ())
notifications = list_notifications( notifications = list_notifications(
session, session,
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
@@ -103,6 +111,7 @@ def api_list_notifications(
source_module=source_module, source_module=source_module,
recipient_id=recipient_id, recipient_id=recipient_id,
recipient_ids=recipient_ids, recipient_ids=recipient_ids,
muted_source_modules=muted_source_modules,
limit=limit, limit=limit,
) )
return NotificationListResponse(notifications=[_response(notification) for notification in notifications]) return NotificationListResponse(notifications=[_response(notification) for notification in notifications])
@@ -119,7 +128,7 @@ def api_notification_summary(
notification_summary( notification_summary(
session, session,
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
user_id=principal.user.id, user_id=principal.user.id if view == "personal" else None,
recipient_ids=_recipient_ids_for_view(principal, view), recipient_ids=_recipient_ids_for_view(principal, view),
) )
) )
+47 -10
View File
@@ -1,13 +1,13 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping, Sequence
from datetime import datetime, timezone from datetime import datetime, timezone
from email.message import EmailMessage from email.message import EmailMessage
from pathlib import Path 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,19 +175,22 @@ 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,
recipient_ids: tuple[str, ...] | None = None, recipient_ids: tuple[str, ...] | None = None,
muted_source_modules: Sequence[str] = (),
limit: int = 100, limit: int = 100,
) -> list[NotificationMessage]: ) -> list[NotificationMessage]:
query = session.query(NotificationMessage).filter( query = session.query(NotificationMessage).filter(
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:
@@ -196,7 +199,21 @@ def list_notifications(
query = query.filter(NotificationMessage.recipient_id.in_(recipient_ids)) query = query.filter(NotificationMessage.recipient_id.in_(recipient_ids))
if recipient_id: if recipient_id:
query = query.filter(NotificationMessage.recipient_id == recipient_id) query = query.filter(NotificationMessage.recipient_id == recipient_id)
return query.order_by(NotificationMessage.created_at.desc(), NotificationMessage.id.asc()).limit(limit).all() muted_sources = _clean_source_modules(list(muted_source_modules))
if muted_sources:
query = query.filter(NotificationMessage.source_module.notin_(muted_sources))
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(
@@ -206,12 +223,28 @@ def notification_summary(
user_id: str | None = None, user_id: str | None = None,
recipient_ids: tuple[str, ...] | None = None, recipient_ids: tuple[str, ...] | None = None,
) -> dict[str, int | bool]: ) -> dict[str, int | bool]:
preference = (
get_notification_preferences(
session,
tenant_id=tenant_id,
user_id=user_id,
)
if user_id
else None
)
filters = [ filters = [
NotificationMessage.tenant_id == tenant_id, NotificationMessage.tenant_id == tenant_id,
NotificationMessage.deleted_at.is_(None), NotificationMessage.deleted_at.is_(None),
] ]
if recipient_ids is not None: if recipient_ids is not None:
filters.append(NotificationMessage.recipient_id.in_(recipient_ids)) filters.append(NotificationMessage.recipient_id.in_(recipient_ids))
muted_sources = _clean_source_modules(
list(preference.muted_source_modules or [])
if preference is not None
else []
)
if muted_sources:
filters.append(NotificationMessage.source_module.notin_(muted_sources))
active = NotificationMessage.status.notin_(["cancelled", "skipped"]) active = NotificationMessage.status.notin_(["cancelled", "skipped"])
total, unread, pending, failed = ( total, unread, pending, failed = (
session.query( session.query(
@@ -267,9 +300,9 @@ def notification_summary(
.filter(*filters) .filter(*filters)
.one() .one()
) )
show_unread_badge = True show_unread_badge = (
if user_id: preference.show_unread_badge if preference is not None else True
show_unread_badge = get_notification_preferences(session, tenant_id=tenant_id, user_id=user_id).show_unread_badge )
return { return {
"total": int(total or 0), "total": int(total or 0),
"unread": int(unread or 0), "unread": int(unread or 0),
@@ -674,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
],
} }
+476
View File
@@ -0,0 +1,476 @@
from __future__ import annotations
import json
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarProvider,
DsarRecordRef,
DsarSubjectRef,
)
from govoplan_core.db.base import Base
from govoplan_core.privacy.dsar_workflow import (
create_data_subject_request,
search_data_subject_request,
)
from govoplan_notifications.backend.db.models import (
NotificationDeliveryAttempt,
NotificationMessage,
NotificationPreference,
)
from govoplan_notifications.backend.dsar_provider import (
NOTIFICATIONS_DSAR_CAPABILITY,
NotificationsDsarProvider,
)
from govoplan_notifications.backend.manifest import manifest
class _Registry:
def __init__(
self,
provider: NotificationsDsarProvider,
*,
active: bool = True,
) -> None:
self.provider = provider
self.active = active
def capability_names(self):
return (NOTIFICATIONS_DSAR_CAPABILITY,)
def capability_owner(self, name):
self._assert_capability(name)
return "notifications"
def tenant_entitlement_resolver(self):
active = self.active
class _Resolver:
@staticmethod
def resolve(session, tenant_id):
del session, tenant_id
return type(
"State",
(),
{"effective_modules": ("notifications",) if active else ()},
)()
return _Resolver()
def require_tenant_capability(self, name, session, **kwargs):
del session, kwargs
self._assert_capability(name)
return self.provider
def manifests(self):
return (type("Manifest", (), {"id": "notifications"})(),)
@staticmethod
def _assert_capability(name: str) -> None:
if name != NOTIFICATIONS_DSAR_CAPABILITY:
raise KeyError(name)
class NotificationsDsarProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.session = Session(self.engine)
self.provider = NotificationsDsarProvider()
self.assertIsInstance(self.provider, DsarProvider)
self._seed()
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def _message(
self,
notification_id: str,
*,
tenant_id: str = "tenant-1",
channel: str = "inbox",
recipient: str | None = None,
recipient_id: str | None = "membership-1",
subject: str,
) -> NotificationMessage:
return NotificationMessage(
id=notification_id,
tenant_id=tenant_id,
source_module="tasks",
source_resource_type="task",
source_resource_id=f"task-{notification_id}",
event_kind="task_due",
channel=channel,
recipient=recipient,
recipient_type="email" if recipient else "membership",
recipient_id=recipient_id,
recipient_label="Resident Example",
subject=subject,
body_text=f"Private body for {notification_id}",
body_html=f"<p>Private HTML for {notification_id}</p>",
action_url="/tasks",
priority=2,
status="sent",
attempt_count=0,
payload={
"case_reference": f"CASE-{notification_id}",
"access_token": "provider-secret-do-not-export",
},
metadata_={"classification": "personal"},
)
def _seed(self) -> None:
inbox = self._message(
"notification-inbox",
subject="Personal inbox notice",
)
mail = self._message(
"notification-mail",
channel="mail",
recipient="Resident@Example.test",
recipient_id=None,
subject="Personal mail notice",
)
mail.attempt_count = 1
mail.attempts.append(
NotificationDeliveryAttempt(
id="attempt-mail",
tenant_id="tenant-1",
attempt_no=1,
channel="mail",
provider="mail.delivery_outbox",
status="accepted",
external_message_id="mail-command-1",
details={
"provider_state": "accepted",
"authorization": "Bearer provider-secret-do-not-export",
},
)
)
self.session.add_all(
(
inbox,
mail,
self._message(
"notification-other-recipient",
recipient_id="membership-other",
subject="Other recipient private notice",
),
self._message(
"notification-other-tenant",
tenant_id="tenant-2",
subject="Other tenant private notice",
),
NotificationPreference(
id="preference-personal",
tenant_id="tenant-1",
user_id="membership-1",
show_unread_badge=False,
email_enabled=True,
email_digest_enabled=True,
muted_source_modules=["campaign", "calendar"],
metadata_={"digest_hour": 8},
),
NotificationPreference(
id="preference-other",
tenant_id="tenant-1",
user_id="membership-other",
show_unread_badge=True,
email_enabled=False,
email_digest_enabled=False,
muted_source_modules=[],
metadata_={},
),
)
)
@staticmethod
def _subject() -> DsarSubjectRef:
return DsarSubjectRef(
account_id="account-1",
membership_id="membership-1",
identity_id="identity-1",
email="resident@example.test",
)
def test_search_is_tenant_recipient_scoped_and_exports_bounded_content(
self,
) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=self._subject(),
)
self.assertEqual(
[
"preference-personal",
"notification-inbox",
"notification-mail",
],
[record.resource_id for record in records],
)
exported = json.dumps([record.to_dict() for record in records])
self.assertIn("Private body for notification-inbox", exported)
self.assertIn("CASE-notification-mail", exported)
self.assertIn("mail-command-1", exported)
self.assertIn("digest_hour", exported)
self.assertIn("[redacted]", exported)
self.assertNotIn("provider-secret-do-not-export", exported)
self.assertNotIn("notification-other-recipient", exported)
self.assertNotIn("notification-other-tenant", exported)
def test_resource_references_narrow_and_alias_conflicts_fail_closed(self) -> None:
notification = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
membership_id="membership-1",
external_references={
"notifications.notification": "notification-inbox"
},
),
)
preference = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
membership_id="membership-1",
external_references={"notifications.preference": "preference-personal"},
),
)
conflict = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={"notifications.account": "account-other"},
),
)
reference_only = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
external_references={"notifications.notification": "notification-inbox"}
),
)
self.assertEqual(
["notification-inbox"], [item.resource_id for item in notification]
)
self.assertEqual(
["preference-personal"], [item.resource_id for item in preference]
)
self.assertEqual((), conflict)
self.assertEqual((), reference_only)
def test_erasure_deletes_preferences_and_inbox_but_reviews_mail(self) -> None:
subject = self._subject()
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=subject,
)
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=records,
)
self.assertEqual(
["delete", "delete", "manual_review"],
[action.kind for action in actions],
)
first = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=actions,
request_id="dsar-1",
)
second = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=actions,
request_id="dsar-1",
)
self.assertEqual(
["executed", "executed", "blocked"],
[result.status for result in first],
)
self.assertEqual(
["unchanged", "unchanged", "blocked"],
[result.status for result in second],
)
self.assertIsNone(
self.session.get(NotificationPreference, "preference-personal")
)
self.assertIsNone(self.session.get(NotificationMessage, "notification-inbox"))
self.assertIsNotNone(self.session.get(NotificationMessage, "notification-mail"))
self.assertIsNotNone(
self.session.get(NotificationMessage, "notification-other-recipient")
)
def test_changed_and_foreign_resources_are_blocked(self) -> None:
subject = self._subject()
record = next(
item
for item in self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=subject,
)
if item.resource_id == "notification-inbox"
)
action = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=(record,),
)[0]
notification = self.session.get(NotificationMessage, "notification-inbox")
notification.subject = "Changed after planning"
self.session.flush()
changed = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=(action,),
request_id="dsar-1",
)
self.assertEqual("blocked", changed[0].status)
with self.assertRaisesRegex(ValueError, "foreign provider record"):
self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=(
DsarRecordRef(
provider_id="mail",
module_id="mail",
resource_type="notification_message",
resource_id="notification-inbox",
category="message",
title="Foreign message",
),
),
)
with self.assertRaisesRegex(ValueError, "foreign provider action"):
self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
actions=(
DsarErasureActionRef(
action_id="mail:delete:notification:notification-inbox",
provider_id="mail",
module_id="mail",
kind="delete",
resource_type="notification_message",
resource_id="notification-inbox",
title="Delete notification",
rationale="Foreign action",
executable=True,
),
),
request_id="dsar-1",
)
def test_oversized_content_fails_closed(self) -> None:
notification = self.session.get(
NotificationMessage,
"notification-inbox",
)
notification.body_text = "x" * 100_001
self.session.flush()
with self.assertRaisesRegex(ValueError, "body text exceeds"):
self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
membership_id="membership-1",
external_references={
"notifications.notification": "notification-inbox"
},
),
)
def test_core_workflow_and_manifest_register_provider(self) -> None:
row = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-NOTIFICATIONS-1",
request_kind="access",
subject=self._subject(),
purpose="Respond to a verified request.",
legal_basis="Article 15 GDPR",
due_at=None,
requested_by_account_id="privacy-officer",
)
self.session.commit()
search_data_subject_request(
self.session,
registry=_Registry(self.provider),
row=row,
expected_revision=1,
)
self.assertEqual(
[NOTIFICATIONS_DSAR_CAPABILITY],
row.coverage["provider_capabilities"],
)
self.assertEqual(3, row.search_result["record_count"])
inactive = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-NOTIFICATIONS-2",
request_kind="access",
subject=self._subject(),
purpose="Respond to a verified request.",
legal_basis="Article 15 GDPR",
due_at=None,
requested_by_account_id="privacy-officer",
)
self.session.commit()
search_data_subject_request(
self.session,
registry=_Registry(self.provider, active=False),
row=inactive,
expected_revision=1,
)
self.assertEqual(
[NOTIFICATIONS_DSAR_CAPABILITY],
inactive.coverage["inactive_provider_capabilities"],
)
self.assertIn(NOTIFICATIONS_DSAR_CAPABILITY, manifest.capability_factories)
self.assertIn(
NOTIFICATIONS_DSAR_CAPABILITY,
manifest.capability_documentation,
)
self.assertIn(
NOTIFICATIONS_DSAR_CAPABILITY,
{item.name for item in manifest.provides_interfaces},
)
self.assertTrue(
any(
topic.id == "notifications.data-subject-requests"
and {"admin", "user"}.issubset(topic.documentation_types)
for topic in manifest.documentation
)
)
if __name__ == "__main__":
unittest.main()
@@ -3,6 +3,10 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
import unittest import unittest
from govoplan_core.core.modules import (
documentation_structured_translation_issues,
localizable_documentation_metadata_keys,
)
from govoplan_notifications.backend.manifest import get_manifest from govoplan_notifications.backend.manifest import get_manifest
@@ -59,6 +63,25 @@ class NotificationsInterfaceDocumentationContractTests(unittest.TestCase):
self.assertIn("notifications.action.dispatch", delivery.metadata["help_contexts"]) self.assertIn("notifications.action.dispatch", delivery.metadata["help_contexts"])
self.assertIn("dispatch_pending", delivery.metadata["consequence_classes"]) self.assertIn("dispatch_pending", delivery.metadata["consequence_classes"])
def test_german_reference_documentation_is_complete(self) -> None:
topics = get_manifest().documentation
self.assertEqual(3, len(topics))
for topic in topics:
translation = topic.translations.get("de", {})
self.assertTrue(translation.get("title"), topic.id)
self.assertTrue(translation.get("summary"), topic.id)
self.assertTrue(translation.get("body"), topic.id)
if localizable_documentation_metadata_keys(topic):
self.assertEqual("1", topic.structured_translation_version, topic.id)
self.assertIn("de", topic.structured_translations, topic.id)
self.assertEqual((), documentation_structured_translation_issues(topic))
center = next(topic for topic in topics if topic.id == "notifications.center-and-preferences")
delivery = next(topic for topic in topics if topic.id == "notifications.delivery-operations")
self.assertEqual("workflow", center.metadata["kind"])
self.assertTrue(center.conditions)
self.assertEqual("reference", delivery.metadata["kind"])
def test_webui_uses_shared_consequence_and_draft_patterns(self) -> None: def test_webui_uses_shared_consequence_and_draft_patterns(self) -> None:
center = (REPO_ROOT / "webui/src/features/notifications/NotificationCenterPage.tsx").read_text(encoding="utf-8") center = (REPO_ROOT / "webui/src/features/notifications/NotificationCenterPage.tsx").read_text(encoding="utf-8")
settings = (REPO_ROOT / "webui/src/features/notifications/NotificationSettingsPanel.tsx").read_text(encoding="utf-8") settings = (REPO_ROOT / "webui/src/features/notifications/NotificationSettingsPanel.tsx").read_text(encoding="utf-8")
+140
View File
@@ -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()
+134
View File
@@ -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(
@@ -551,6 +598,93 @@ class NotificationServiceTests(unittest.TestCase):
self.assertEqual(["calendar", "campaign"], response["muted_source_modules"]) self.assertEqual(["calendar", "campaign"], response["muted_source_modules"])
self.assertFalse(summary["show_unread_badge"]) self.assertFalse(summary["show_unread_badge"])
def test_personal_source_mutes_apply_across_recipient_identifiers(self) -> None:
with self.Session() as session:
postbox = create_notification(
session,
tenant_id="tenant-1",
payload=NotificationCreateRequest(
source_module="postbox",
source_resource_type="postbox",
event_kind="postbox.assignment.newly_visible.v1",
channel="inbox",
recipient_type="account",
recipient_id="account-1",
subject="Postbox responsibility changed",
enqueue_delivery=False,
),
)
calendar = create_notification(
session,
tenant_id="tenant-1",
payload=NotificationCreateRequest(
source_module="calendar",
source_resource_type="calendar_event",
event_kind="calendar.reminder.v1",
channel="inbox",
recipient_type="account",
recipient_id="account-1",
subject="Calendar reminder",
enqueue_delivery=False,
),
)
update_notification_preferences(
session,
tenant_id="tenant-1",
user_id="user-1",
payload=NotificationPreferencesUpdateRequest(
muted_source_modules=["postbox"],
),
)
principal = self._principal(
tenant_id="tenant-1",
user_id="user-1",
account_id="account-1",
scopes={"notifications:notification:read"},
)
personal = api_list_notifications(
status_filter=None,
channel=None,
source_module=None,
recipient_id=None,
view="personal",
limit=100,
session=session,
principal=principal,
)
personal_summary = api_notification_summary(
view="personal",
session=session,
principal=principal,
)
tenant_admin = self._principal(
tenant_id="tenant-1",
user_id="user-1",
account_id="account-1",
scopes={
"notifications:notification:read",
"notifications:notification:admin",
},
)
tenant_view = api_list_notifications(
status_filter=None,
channel=None,
source_module=None,
recipient_id=None,
view="tenant",
limit=100,
session=session,
principal=tenant_admin,
)
self.assertEqual([calendar.id], [item.id for item in personal.notifications])
self.assertEqual(1, personal_summary.total)
self.assertEqual(
{postbox.id, calendar.id},
{item.id for item in tenant_view.notifications},
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/notifications-webui", "name": "@govoplan/notifications-webui",
"version": "0.1.17", "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.17", "@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",
+6 -3
View File
@@ -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,16 +1,21 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Bell, Check, ExternalLink, RefreshCw, Send, XCircle } from "lucide-react"; import { Bell, Check, ExternalLink, Send, XCircle } from "lucide-react";
import { import {
ActionBlockerHint, ActionBlockerHint,
AdminIconButton, ActionToolbar,
Button, Button,
ConfirmDialog, ConfirmDialog,
CountBadge,
DismissibleAlert, DismissibleAlert,
DocumentationHelpLink, DocumentationHelpLink,
SegmentedControl, MultiSelectFilter,
SelectionList, SelectionList,
SelectionListItem, SelectionListItem,
StatePanel,
StatusBadge, StatusBadge,
WorkspaceActionBar,
WorkspaceFrame,
WorkspaceLayout,
hasScope, hasScope,
i18nMessage, i18nMessage,
type ApiSettings, type ApiSettings,
@@ -26,7 +31,6 @@ import {
} from "./interfacePatterns"; } from "./interfacePatterns";
type StatusFilter = type StatusFilter =
| "all"
| "pending" | "pending"
| "queued" | "queued"
| "sending" | "sending"
@@ -38,7 +42,6 @@ type StatusFilter =
| "cancelled"; | "cancelled";
const statusFilters: StatusFilter[] = [ const statusFilters: StatusFilter[] = [
"all",
"pending", "pending",
"queued", "queued",
"sending", "sending",
@@ -53,9 +56,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("");
@@ -68,76 +84,110 @@ 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
: !selected : loading
? NOTIFICATIONS_I18N.selectionRequired ? "i18n:govoplan-notifications.loading_notifications"
: !canWrite : !selected
? NOTIFICATIONS_I18N.writePermissionRequired ? NOTIFICATIONS_I18N.selectionRequired
: undefined; : !canWrite
? NOTIFICATIONS_I18N.writePermissionRequired
: undefined;
const markReadDisabledReason = commonSelectionReason ?? (selected?.read_at ? NOTIFICATIONS_I18N.alreadyRead : undefined); const markReadDisabledReason = commonSelectionReason ?? (selected?.read_at ? NOTIFICATIONS_I18N.alreadyRead : undefined);
const acknowledgeDisabledReason = commonSelectionReason ?? (selected?.acknowledged_at ? NOTIFICATIONS_I18N.alreadyAcknowledged : undefined); const acknowledgeDisabledReason = commonSelectionReason ?? (selected?.acknowledged_at ? NOTIFICATIONS_I18N.alreadyAcknowledged : undefined);
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
: !canDispatch : loading
? NOTIFICATIONS_I18N.dispatchPermissionRequired ? "i18n:govoplan-notifications.loading_notifications"
: undefined; : !canDispatch
? NOTIFICATIONS_I18N.dispatchPermissionRequired
: 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 {
setBusy(false); if (isCurrentScope()) {
operationScope.current = null;
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 {
setBusy(false); if (isCurrentScope()) {
operationScope.current = null;
setBusy(false);
}
} }
} }
@@ -152,7 +202,7 @@ export default function NotificationCenterPage({ settings, auth }: { settings: A
if (!canRead) { if (!canRead) {
return ( return (
<main className="notifications-page"> <WorkspaceFrame as="main" height="viewport" surface="plain" className="notifications-page" label="Notification center">
<div className="notifications-permission-state"> <div className="notifications-permission-state">
<ActionBlockerHint <ActionBlockerHint
tone="warning" tone="warning"
@@ -167,41 +217,51 @@ export default function NotificationCenterPage({ settings, auth }: { settings: A
documentation={NOTIFICATIONS_DOCUMENTATION} documentation={NOTIFICATIONS_DOCUMENTATION}
/> />
</div> </div>
</main> </WorkspaceFrame>
); );
} }
return ( return (
<main className="notifications-page"> <WorkspaceFrame as="main" height="viewport" surface="plain" className="notifications-page" label="Notification center">
<div className="notifications-shell"> <WorkspaceLayout
<aside className="notifications-sidebar"> variant="split"
<div className="notifications-sidebar-bar"> primarySize="default"
<div className="notifications-title"> primaryScrollable={false}
contentScrollable={false}
surface="contained"
primaryClassName="notifications-sidebar"
contentClassName="notifications-workspace"
primaryLabel="i18n:govoplan-notifications.notifications"
contentLabel="i18n:govoplan-notifications.surface.center"
interfaceId="notifications.center.workspace"
helpContextId="notifications.page.center"
helpModuleId="notifications"
primary={<>
<WorkspaceActionBar
scope="collection-pane"
variant="collection"
refreshable
reloadAction={{ onReload: () => void load(), loading: loading || busy, label: "i18n:govoplan-notifications.refresh" }}
className="notifications-sidebar-bar"
contextActions={<div className="notifications-title">
<Bell size={17} /> <Bell size={17} />
<strong>i18n:govoplan-notifications.notifications</strong> <strong>i18n:govoplan-notifications.notifications</strong>
{unreadCount > 0 ? <span className="notifications-count">{unreadCount}</span> : null} {unreadCount > 0 ? <CountBadge>{unreadCount}</CountBadge> : null}
</div> </div>}
<AdminIconButton />
label="i18n:govoplan-notifications.refresh" <MultiSelectFilter
icon={<RefreshCw size={16} aria-hidden="true" />}
onClick={() => void load()}
disabled={loading || busy}
disabledReason={loading ? NOTIFICATIONS_I18N.loading : busy ? NOTIFICATIONS_I18N.actionActive : undefined}
/>
</div>
<SegmentedControl
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}
{!loading && notifications.length === 0 ? <div className="notifications-note">i18n:govoplan-notifications.no_notifications</div> : null} {!loading && notifications.length === 0 ? <div className="notifications-note">i18n:govoplan-notifications.no_notifications</div> : null}
{notifications.length > 0 ? ( {notifications.length > 0 ? (
<SelectionList label="i18n:govoplan-notifications.notifications" className="notifications-selection-list"> <SelectionList variant="navigation" label="i18n:govoplan-notifications.notifications" className="notifications-selection-list">
{notifications.map((notification) => ( {notifications.map((notification) => (
<SelectionListItem <SelectionListItem
key={notification.id} key={notification.id}
@@ -222,29 +282,32 @@ export default function NotificationCenterPage({ settings, auth }: { settings: A
</SelectionList> </SelectionList>
) : null} ) : null}
</div> </div>
</aside> </>}
<section className="notifications-workspace"> >
<div className="notifications-topbar"> <WorkspaceActionBar
<div className="notifications-title-line"> scope="detail-pane"
variant="detail"
className="notifications-topbar"
contextActions={<div className="notifications-title-line">
<Bell size={18} /> <Bell size={18} />
<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> </div>}
<div className="notifications-actions"> helpAction={<DocumentationHelpLink reference={NOTIFICATIONS_DOCUMENTATION} />}
<DocumentationHelpLink reference={NOTIFICATIONS_DOCUMENTATION} /> 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
</Button> </Button>
<Button onClick={() => void markSelected("acknowledged")} disabled={Boolean(acknowledgeDisabledReason)} disabledReason={acknowledgeDisabledReason}> <Button onClick={() => void markSelected("acknowledged")} disabled={Boolean(acknowledgeDisabledReason)} disabledReason={acknowledgeDisabledReason}>
<Check size={16} /> i18n:govoplan-notifications.acknowledge <Check size={16} /> i18n:govoplan-notifications.acknowledge
</Button> </Button>
<Button variant="danger" onClick={() => setConfirmingAction("cancel")} disabled={Boolean(cancelDisabledReason)} disabledReason={cancelDisabledReason}>
<XCircle size={16} /> i18n:govoplan-notifications.cancel_delivery
</Button>
<Button onClick={() => setConfirmingAction("dispatch")} disabled={Boolean(dispatchDisabledReason)} disabledReason={dispatchDisabledReason}> <Button onClick={() => setConfirmingAction("dispatch")} disabled={Boolean(dispatchDisabledReason)} disabledReason={dispatchDisabledReason}>
<Send size={16} /> i18n:govoplan-notifications.dispatch_pending <Send size={16} /> i18n:govoplan-notifications.dispatch_pending
</Button> </Button>
</div> </div>}
</div> destructiveActions={<Button variant="danger" onClick={() => setConfirmingAction("cancel")} disabled={Boolean(cancelDisabledReason)} disabledReason={cancelDisabledReason}>
<XCircle size={16} /> i18n:govoplan-notifications.cancel_delivery
</Button>}
/>
{error ? <DismissibleAlert tone="danger" compact resetKey={error}>{error}</DismissibleAlert> : null} {error ? <DismissibleAlert tone="danger" compact resetKey={error}>{error}</DismissibleAlert> : null}
@@ -264,14 +327,9 @@ export default function NotificationCenterPage({ settings, auth }: { settings: A
) : null} ) : null}
{selected ? <NotificationDetails notification={selected} /> : ( {selected ? <NotificationDetails notification={selected} /> : (
<div className="notifications-empty-state"> <StatePanel size="fill" icon={<Bell size={22} />} title="i18n:govoplan-notifications.notifications" description="i18n:govoplan-notifications.select_notification_help" />
<Bell size={22} />
<h1>i18n:govoplan-notifications.notifications</h1>
<p>i18n:govoplan-notifications.select_notification_help</p>
</div>
)} )}
</section> </WorkspaceLayout>
</div>
<ConfirmDialog <ConfirmDialog
open={confirmingAction === "cancel"} open={confirmingAction === "cancel"}
title="i18n:govoplan-notifications.cancel_delivery_title" title="i18n:govoplan-notifications.cancel_delivery_title"
@@ -291,7 +349,7 @@ export default function NotificationCenterPage({ settings, auth }: { settings: A
onCancel={() => setConfirmingAction(null)} onCancel={() => setConfirmingAction(null)}
onConfirm={() => void confirmAction()} onConfirm={() => void confirmAction()}
/> />
</main> </WorkspaceFrame>
); );
} }
@@ -330,10 +388,10 @@ function NotificationDetails({ notification }: { notification: NotificationMessa
</section> </section>
<section className="notifications-attempts"> <section className="notifications-attempts">
<div className="notifications-section-heading"> <ActionToolbar surface="section-header" className="notifications-section-heading">
<h2>i18n:govoplan-notifications.delivery_attempts</h2> <h2>i18n:govoplan-notifications.delivery_attempts</h2>
<DocumentationHelpLink reference={NOTIFICATIONS_DELIVERY_DOCUMENTATION} /> <DocumentationHelpLink reference={NOTIFICATIONS_DELIVERY_DOCUMENTATION} />
</div> </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) => (
<div className="notifications-attempt" key={attempt.id}> <div className="notifications-attempt" key={attempt.id}>
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Mail, Save } from "lucide-react"; import { Mail, Save } from "lucide-react";
import { import { FormGrid, ContentGrid,
ActionBlockerHint, ActionBlockerHint,
Button, Button,
Card, Card,
@@ -182,7 +182,7 @@ export default function NotificationSettingsPanel({ settings, auth }: { settings
const digestToggleDisabled = preferenceControlsDisabled || !mailAvailable || !draft.email_enabled; const digestToggleDisabled = preferenceControlsDisabled || !mailAvailable || !draft.email_enabled;
return ( return (
<div className="dashboard-grid settings-dashboard-grid notifications-settings-panel"> <ContentGrid columns={2} collapseAt="workspace" className="notifications-settings-panel">
<div className="notifications-settings-documentation"> <div className="notifications-settings-documentation">
<DocumentationHelpLink reference={NOTIFICATIONS_DOCUMENTATION} /> <DocumentationHelpLink reference={NOTIFICATIONS_DOCUMENTATION} />
</div> </div>
@@ -201,7 +201,7 @@ export default function NotificationSettingsPanel({ settings, auth }: { settings
/> />
) : null} ) : null}
<Card title="i18n:govoplan-notifications.notifications"> <Card title="i18n:govoplan-notifications.notifications">
<div className="form-grid"> <FormGrid columns={1} collapseAt="standard" className="">
<ToggleSwitch <ToggleSwitch
label="i18n:govoplan-notifications.unread_badge" label="i18n:govoplan-notifications.unread_badge"
help="i18n:govoplan-notifications.unread_badge_help" help="i18n:govoplan-notifications.unread_badge_help"
@@ -228,10 +228,10 @@ export default function NotificationSettingsPanel({ settings, auth }: { settings
</Button> </Button>
</div> </div>
{message ? <DismissibleAlert tone={messageTone} resetKey={message} floating>{message}</DismissibleAlert> : null} {message ? <DismissibleAlert tone={messageTone} resetKey={message} floating>{message}</DismissibleAlert> : null}
</div> </FormGrid>
</Card> </Card>
<Card title="i18n:govoplan-notifications.delivery"> <Card title="i18n:govoplan-notifications.delivery">
<div className="form-grid"> <FormGrid columns={1} collapseAt="standard" className="">
<DocumentationHelpLink reference={NOTIFICATIONS_DELIVERY_DOCUMENTATION} /> <DocumentationHelpLink reference={NOTIFICATIONS_DELIVERY_DOCUMENTATION} />
<div className="notifications-settings-inline-title"> <div className="notifications-settings-inline-title">
<Mail size={16} /> <Mail size={16} />
@@ -265,8 +265,8 @@ export default function NotificationSettingsPanel({ settings, auth }: { settings
disabled={digestToggleDisabled} disabled={digestToggleDisabled}
onChange={(value) => setDraft((current) => ({ ...current, email_digest_enabled: value }))} onChange={(value) => setDraft((current) => ({ ...current, email_digest_enabled: value }))}
/> />
</div> </FormGrid>
</Card> </Card>
</div> </ContentGrid>
); );
} }
@@ -1,3 +1,4 @@
import { MetricGrid } from "@govoplan/core-webui";
import { Link } from "react-router"; import { Link } from "react-router";
import { import {
DismissibleAlert, DismissibleAlert,
@@ -40,7 +41,7 @@ export default function NotificationSummaryWidget({
{error} {error}
</DismissibleAlert> </DismissibleAlert>
)} )}
<div className="metric-grid inside dashboard-widget-metrics"> <MetricGrid columns={3} spacing="none">
<MetricCard <MetricCard
label="i18n:govoplan-notifications.unread" label="i18n:govoplan-notifications.unread"
value={summary?.unread ?? 0} value={summary?.unread ?? 0}
@@ -63,7 +64,7 @@ export default function NotificationSummaryWidget({
detail="i18n:govoplan-notifications.delivery_failures" detail="i18n:govoplan-notifications.delivery_failures"
/> />
)} )}
</div> </MetricGrid>
<div className="notifications-widget-actions"> <div className="notifications-widget-actions">
<DocumentationHelpLink reference={NOTIFICATIONS_DOCUMENTATION} /> <DocumentationHelpLink reference={NOTIFICATIONS_DOCUMENTATION} />
{showCenterLink && ( {showCenterLink && (
+7 -99
View File
@@ -1,18 +1,3 @@
.notifications-page {
box-sizing: border-box;
height: calc(100vh - 115px);
min-height: 0;
overflow: hidden;
color: var(--text);
background: var(--bg);
}
.notifications-page *,
.notifications-page *::before,
.notifications-page *::after {
box-sizing: border-box;
}
.notifications-widget-actions { .notifications-widget-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -21,28 +6,14 @@
margin-top: 14px; margin-top: 14px;
} }
.notifications-shell {
height: 100%;
min-height: 0;
display: grid;
grid-template-columns: minmax(270px, 340px) minmax(0, 1fr);
border: var(--border-line);
background: var(--panel);
overflow: hidden;
}
.notifications-sidebar { .notifications-sidebar {
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border-right: var(--border-line);
background: var(--panel-soft);
} }
.notifications-sidebar-bar,
.notifications-title, .notifications-title,
.notifications-topbar,
.notifications-title-line, .notifications-title-line,
.notifications-actions, .notifications-actions,
.notifications-message-meta, .notifications-message-meta,
@@ -52,35 +23,14 @@
gap: 8px; gap: 8px;
} }
.notifications-sidebar-bar,
.notifications-topbar {
min-height: 54px;
justify-content: space-between;
border-bottom: var(--border-line);
background: var(--panel-header);
padding: 9px 12px;
}
.notifications-count {
min-width: 21px;
height: 21px;
display: inline-grid;
place-items: center;
border-radius: 999px;
background: var(--accent);
color: var(--on-accent);
font-size: 12px;
font-weight: 800;
}
.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 {
@@ -191,7 +141,7 @@
} }
.notifications-message p { .notifications-message p {
max-width: 840px; max-width: 900px;
margin: 0 0 14px; margin: 0 0 14px;
line-height: 1.6; line-height: 1.6;
white-space: pre-line; white-space: pre-line;
@@ -244,17 +194,6 @@
font-size: 15px; font-size: 15px;
} }
.notifications-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.notifications-section-heading h2 {
margin: 0;
}
.notifications-properties dl { .notifications-properties dl {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(180px, 1fr)); grid-template-columns: repeat(2, minmax(180px, 1fr));
@@ -283,7 +222,7 @@
width: max-content; width: max-content;
max-width: 100%; max-width: 100%;
margin: 14px 0 0; margin: 14px 0 0;
border-radius: 6px; border-radius: var(--radius-compact);
background: var(--danger-soft); background: var(--danger-soft);
color: var(--danger-text); color: var(--danger-text);
padding: 8px 10px; padding: 8px 10px;
@@ -294,7 +233,7 @@
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
gap: 3px 12px; gap: 3px 12px;
border: var(--border-line); border: var(--border-line);
border-radius: 6px; border-radius: var(--radius-compact);
background: var(--surface); background: var(--surface);
margin-bottom: 8px; margin-bottom: 8px;
padding: 10px; padding: 10px;
@@ -307,44 +246,13 @@
color: var(--muted); color: var(--muted);
} }
.notifications-empty-state {
min-height: 100%;
display: grid;
place-items: center;
align-content: center;
gap: 8px;
padding: 32px;
text-align: center;
}
.notifications-permission-state { .notifications-permission-state {
max-width: 860px; max-width: 900px;
margin: 0 auto; margin: 0 auto;
padding: 32px 20px; padding: 32px 20px;
} }
.notifications-empty-state h1 {
margin: 0;
color: var(--text-strong);
}
.notifications-empty-state p {
max-width: 520px;
margin: 0;
color: var(--muted);
}
@media (max-width: 900px) { @media (max-width: 900px) {
.notifications-shell {
grid-template-columns: 1fr;
}
.notifications-sidebar {
min-height: 220px;
border-right: 0;
border-bottom: var(--border-line);
}
.notifications-topbar { .notifications-topbar {
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;