9 Commits
Author SHA1 Message Date
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
13 changed files with 1608 additions and 172 deletions
+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.19"
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.18",
] ]
[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"]
+260 -16
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.19"
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. 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,6 +272,39 @@ 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. 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",
@@ -141,6 +326,37 @@ 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."
),
}
},
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 +371,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 +481,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 +499,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",),
+10 -1
View File
@@ -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),
) )
) )
+24 -4
View File
@@ -1,6 +1,6 @@
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
@@ -180,6 +180,7 @@ def list_notifications(
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(
@@ -196,6 +197,9 @@ 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)
muted_sources = _clean_source_modules(list(muted_source_modules))
if muted_sources:
query = query.filter(NotificationMessage.source_module.notin_(muted_sources))
return query.order_by(NotificationMessage.created_at.desc(), NotificationMessage.id.asc()).limit(limit).all() return query.order_by(NotificationMessage.created_at.desc(), NotificationMessage.id.asc()).limit(limit).all()
@@ -206,12 +210,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 +287,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),
+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")
+87
View File
@@ -551,6 +551,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.19",
"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.18",
"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",
@@ -1,16 +1,21 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, 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, SegmentedControl,
SelectionList, SelectionList,
SelectionListItem, SelectionListItem,
StatePanel,
StatusBadge, StatusBadge,
WorkspaceActionBar,
WorkspaceFrame,
WorkspaceLayout,
hasScope, hasScope,
i18nMessage, i18nMessage,
type ApiSettings, type ApiSettings,
@@ -152,7 +157,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,28 +172,38 @@ 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"
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 <SegmentedControl
className="notifications-status-filter" className="notifications-status-filter"
options={statusFilters.map((status) => ({ id: status, label: statusLabel(status) }))} options={statusFilters.map((status) => ({ id: status, label: statusLabel(status) }))}
@@ -201,7 +216,7 @@ export default function NotificationCenterPage({ settings, auth }: { settings: A
{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 +237,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 +282,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 +304,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 +343,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 && (
+4 -96
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,27 +23,6 @@
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;
@@ -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;