Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
492ab7711a | ||
|
|
c101ee1974 | ||
|
|
ca22d9e706 | ||
|
|
713f2d3c63 | ||
|
|
975d90b012 | ||
|
|
5c28ac5094 | ||
|
|
f9f7ab75d3 | ||
|
|
da0960967d | ||
|
|
e4d6dff1d7 | ||
|
|
178888c7ac | ||
|
|
bf0ebe1541 | ||
|
|
09205374f9 |
@@ -3,3 +3,21 @@
|
|||||||
<!-- govoplan-repository-type:start -->
|
<!-- govoplan-repository-type:start -->
|
||||||
**Repository type:** module (platform).
|
**Repository type:** module (platform).
|
||||||
<!-- govoplan-repository-type:end -->
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
|
## Git-source WebUI package
|
||||||
|
|
||||||
|
The repository root exposes `@govoplan/notifications-webui` for Git-tagged release
|
||||||
|
dependencies. It mirrors the owning `webui/package.json` version, public
|
||||||
|
TypeScript/CSS exports and peer requirements, with entry paths under
|
||||||
|
`webui/src`. Consumers provide the shared Core/React peers; the facade runs no
|
||||||
|
development or install scripts. The source archive contains `webui/src`, this
|
||||||
|
README and any repository license file. Run module development checks from `webui/`; Python
|
||||||
|
installation remains governed by `pyproject.toml`.
|
||||||
|
|
||||||
|
Das Repository stellt `@govoplan/notifications-webui` am Wurzelpfad für versionierte
|
||||||
|
Git-Abhängigkeiten bereit. Version, öffentliche TypeScript-/CSS-Exporte und
|
||||||
|
Peer-Anforderungen entsprechen `webui/package.json`; die Einstiegspfade liegen
|
||||||
|
unter `webui/src`. Gemeinsame Core-/React-Peers stellt die einbindende Anwendung
|
||||||
|
bereit. Die Fassade führt keine Entwicklungs- oder Installationsskripte aus.
|
||||||
|
Entwicklungsprüfungen bleiben in `webui/`, die Python-Installation weiterhin in
|
||||||
|
`pyproject.toml` definiert.
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "@govoplan/notifications-webui",
|
||||||
|
"version": "0.1.20",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "webui/src/index.ts",
|
||||||
|
"module": "webui/src/index.ts",
|
||||||
|
"types": "webui/src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./webui/src/index.ts",
|
||||||
|
"import": "./webui/src/index.ts"
|
||||||
|
},
|
||||||
|
"./styles/notifications.css": "./webui/src/styles/notifications.css"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.45",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": ">=19.2.7 <20",
|
||||||
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
"react-router": ">=8.3.0 <9",
|
||||||
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^7.3.6"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"webui/src",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
]
|
||||||
|
}
|
||||||
+2
-2
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-notifications"
|
name = "govoplan-notifications"
|
||||||
version = "0.1.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"]
|
||||||
@@ -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,143 @@ 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="Documentation books sit immediately beside the visible heading or contextual label for "
|
||||||
|
"Notifications, notification preferences and delivery details, not among operational action "
|
||||||
|
"buttons. Field help remains beside its label. "
|
||||||
|
"Open the notification center to read, acknowledge, or follow notifications from enabled modules. The status dropdown uses the same checkbox filter as tables: select multiple states to include any of them, select all to remove the restriction, or deselect all to show no notifications. Status filtering happens before the latest 200 matching notifications are loaded; this list is not a complete archive. Changing the filter does not change delivery or read state. Reload refreshes the current selection without a cached response. Personal source-muting preferences remove matching entries from the personal list and badge counts even when a producer addressed the actor through an account, membership, or identity identifier; tenant-administrator evidence views remain complete. Preferences also control eligible delivery channels and categories. Disabling an optional external channel does not remove an unmuted in-product notification unless the originating module's retention policy does so.",
|
||||||
documentation_types=("user",),
|
documentation_types=("user",),
|
||||||
audience=("user",),
|
audience=("user",),
|
||||||
|
conditions=(DocumentationCondition(required_scopes=(READ_SCOPE,)),),
|
||||||
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 +275,57 @@ 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": (
|
||||||
|
"Dokumentationsbücher stehen unmittelbar neben der sichtbaren Überschrift oder "
|
||||||
|
"Kontextbezeichnung für Benachrichtigungen, Benachrichtigungseinstellungen und "
|
||||||
|
"Zustelldetails, nicht zwischen ausführbaren Aktionsschaltflächen. Feldhilfe bleibt neben der "
|
||||||
|
"Feldbezeichnung. "
|
||||||
|
"Die Benachrichtigungszentrale zeigt Hinweise aktivierter Module zum Lesen, Bestätigen oder "
|
||||||
|
"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="Documentation books sit immediately beside the visible heading or contextual label for "
|
||||||
|
"Notifications, notification preferences and delivery details, not among operational action "
|
||||||
|
"buttons. Field help remains beside its label. "
|
||||||
|
"Producing modules emit notifications through the dispatch capability and do not own delivery credentials. In-product delivery is the baseline. Production email delivery is available only through an enabled Mail capability; file delivery remains development-only. Operators can inspect pending and failed attempts and retry only outcomes that are safe to repeat. Tenant module entitlement is checked before enqueue and again before worker delivery; disabling Notifications preserves accepted messages and exposes an operator action instead of silently consuming them. Notification lists batch-load delivery attempts for the already tenant- and recipient-filtered page, avoiding one additional database query per message. Attempt order and full evidence are preserved. Attempts whose tenant or notification reference does not match the parent are never projected, including already-loaded relationships; inconsistent stored evidence requires an authorized operator investigation rather than broader visibility. This is read optimization, not dispatch, and it does not truncate an individual notification's history.",
|
||||||
documentation_types=("admin",),
|
documentation_types=("admin",),
|
||||||
audience=("tenant_admin", "operator", "module_admin"),
|
audience=("tenant_admin", "operator", "module_admin"),
|
||||||
related_modules=("mail", "audit", "ops"),
|
related_modules=("mail", "audit", "ops"),
|
||||||
@@ -141,6 +341,45 @@ 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": (
|
||||||
|
"Dokumentationsbücher stehen unmittelbar neben der sichtbaren Überschrift oder "
|
||||||
|
"Kontextbezeichnung für Benachrichtigungen, Benachrichtigungseinstellungen und "
|
||||||
|
"Zustelldetails, nicht zwischen ausführbaren Aktionsschaltflächen. Feldhilfe bleibt neben der "
|
||||||
|
"Feldbezeichnung. "
|
||||||
|
"Erzeugende Module übergeben Benachrichtigungen über die Dispatch-Fähigkeit und verwalten keine "
|
||||||
|
"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 +394,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 +504,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 +522,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",),
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -78,7 +101,9 @@ class NotificationsInterfaceDocumentationContractTests(unittest.TestCase):
|
|||||||
"useUnsavedDraftGuard",
|
"useUnsavedDraftGuard",
|
||||||
):
|
):
|
||||||
self.assertIn(component, settings)
|
self.assertIn(component, settings)
|
||||||
self.assertIn("DocumentationHelpLink", widget)
|
contribution = (REPO_ROOT / "webui/src/module.ts").read_text(encoding="utf-8")
|
||||||
|
self.assertIn("documentation: NOTIFICATIONS_DOCUMENTATION", contribution)
|
||||||
|
self.assertNotIn("DocumentationHelpLink", widget)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, event
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_notifications.backend.db.models import (
|
||||||
|
NotificationDeliveryAttempt,
|
||||||
|
NotificationMessage,
|
||||||
|
)
|
||||||
|
from govoplan_notifications.backend.schemas import NotificationCreateRequest
|
||||||
|
from govoplan_notifications.backend.service import (
|
||||||
|
create_notification,
|
||||||
|
list_notifications,
|
||||||
|
notification_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationListEfficiencyTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(
|
||||||
|
self.engine,
|
||||||
|
tables=[
|
||||||
|
NotificationMessage.__table__,
|
||||||
|
NotificationDeliveryAttempt.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def seed(self, count: int) -> str:
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
first_id = ""
|
||||||
|
for index in range(count + 2):
|
||||||
|
row = create_notification(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-other" if index == count else "tenant-one",
|
||||||
|
payload=NotificationCreateRequest(
|
||||||
|
source_module="test",
|
||||||
|
source_resource_type="record",
|
||||||
|
event_kind="changed",
|
||||||
|
enqueue_delivery=False,
|
||||||
|
recipient_id="other-recipient"
|
||||||
|
if index == count + 1
|
||||||
|
else "reader",
|
||||||
|
subject=f"Fixture {index}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if not first_id:
|
||||||
|
first_id = row.id
|
||||||
|
session.add(
|
||||||
|
NotificationDeliveryAttempt(
|
||||||
|
notification_id=row.id,
|
||||||
|
tenant_id=row.tenant_id,
|
||||||
|
attempt_no=1,
|
||||||
|
channel="inbox",
|
||||||
|
status="failed",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return first_id
|
||||||
|
|
||||||
|
def test_list_and_full_attempt_projection_use_two_queries_independent_of_page_size(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
for count in (1, 40):
|
||||||
|
with self.subTest(count=count):
|
||||||
|
first_id = self.seed(count)
|
||||||
|
statements: list[str] = []
|
||||||
|
|
||||||
|
def count_selects(
|
||||||
|
_connection, _cursor, statement, _parameters, _context, _many
|
||||||
|
):
|
||||||
|
if statement.lstrip().upper().startswith("SELECT"):
|
||||||
|
statements.append(statement)
|
||||||
|
|
||||||
|
event.listen(self.engine, "before_cursor_execute", count_selects)
|
||||||
|
try:
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
rows = list_notifications(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-one",
|
||||||
|
recipient_ids=("reader",),
|
||||||
|
limit=count,
|
||||||
|
)
|
||||||
|
payloads = [notification_response(row) for row in rows]
|
||||||
|
self.assertEqual(count, len(payloads))
|
||||||
|
self.assertTrue(
|
||||||
|
all(len(item["attempts"]) == 1 for item in payloads)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
all(
|
||||||
|
item["tenant_id"] == "tenant-one"
|
||||||
|
and item["recipient_id"] == "reader"
|
||||||
|
for item in payloads
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
2,
|
||||||
|
len(statements),
|
||||||
|
"The list and attempt projection must not add a query per message.",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
event.remove(self.engine, "before_cursor_execute", count_selects)
|
||||||
|
self.assertTrue(first_id)
|
||||||
|
|
||||||
|
def test_attempts_with_inconsistent_tenant_are_never_projected_even_from_a_loaded_relationship(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
first_id = self.seed(1)
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
session.add(
|
||||||
|
NotificationDeliveryAttempt(
|
||||||
|
id="foreign-attempt",
|
||||||
|
notification_id=first_id,
|
||||||
|
tenant_id="tenant-other",
|
||||||
|
attempt_no=2,
|
||||||
|
channel="mail",
|
||||||
|
status="failed",
|
||||||
|
error="Foreign tenant evidence",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
row = session.get(NotificationMessage, first_id)
|
||||||
|
self.assertEqual(2, len(row.attempts))
|
||||||
|
self.assertEqual(1, len(notification_response(row)["attempts"]))
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
rows = list_notifications(
|
||||||
|
session, tenant_id="tenant-one", recipient_ids=("reader",)
|
||||||
|
)
|
||||||
|
self.assertEqual([1], [len(row.attempts) for row in rows])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -5,6 +5,7 @@ import unittest
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
@@ -24,6 +25,7 @@ from govoplan_notifications.backend.service import (
|
|||||||
NotificationError,
|
NotificationError,
|
||||||
create_notification,
|
create_notification,
|
||||||
deliver_pending,
|
deliver_pending,
|
||||||
|
list_notifications,
|
||||||
notification_preferences_response,
|
notification_preferences_response,
|
||||||
notification_response,
|
notification_response,
|
||||||
notification_summary,
|
notification_summary,
|
||||||
@@ -174,6 +176,51 @@ class NotificationServiceTests(unittest.TestCase):
|
|||||||
api_get_notification(other_tenant.id, view="personal", session=session, principal=principal)
|
api_get_notification(other_tenant.id, view="personal", session=session, principal=principal)
|
||||||
self.assertEqual(cross_tenant.exception.status_code, 404)
|
self.assertEqual(cross_tenant.exception.status_code, 404)
|
||||||
|
|
||||||
|
def test_status_multiselection_filters_before_limit_and_preserves_visibility(self) -> None:
|
||||||
|
with self.Session() as session:
|
||||||
|
for index, (state, recipient, tenant) in enumerate([
|
||||||
|
("pending", "user-1", "tenant-1"), ("failed", "user-1", "tenant-1"),
|
||||||
|
("sent", "user-1", "tenant-1"), ("sent", "user-1", "tenant-1"),
|
||||||
|
("failed", "user-2", "tenant-1"), ("failed", "user-1", "tenant-2"),
|
||||||
|
]):
|
||||||
|
item = create_notification(session, tenant_id=tenant,
|
||||||
|
payload=self._inbox_payload(recipient_id=recipient, subject=f"Message {index}"))
|
||||||
|
item.status = state
|
||||||
|
item.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc) + timedelta(minutes=index)
|
||||||
|
session.flush()
|
||||||
|
principal = self._principal(tenant_id="tenant-1", user_id="user-1", account_id="account-1",
|
||||||
|
scopes={"notifications:notification:read"})
|
||||||
|
result = api_list_notifications(status_filter=["pending", "failed"], channel=None,
|
||||||
|
source_module=None, recipient_id=None, view="personal", limit=2, session=session, principal=principal)
|
||||||
|
self.assertEqual([item.status for item in result.notifications], ["failed", "pending"])
|
||||||
|
self.assertEqual(list_notifications(session, tenant_id="tenant-1", status=[]), [])
|
||||||
|
single = list_notifications(session, tenant_id="tenant-1", status="pending")
|
||||||
|
self.assertEqual(len(single), 1)
|
||||||
|
|
||||||
|
def test_http_repeated_status_parameters_use_or_filter(self) -> None:
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from govoplan_core.auth import get_api_principal
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
from govoplan_notifications.backend.router import router
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(router)
|
||||||
|
principal = self._principal(tenant_id="tenant-1", user_id="user-1", account_id="account-1",
|
||||||
|
scopes={"notifications:notification:read"})
|
||||||
|
app.dependency_overrides[get_api_principal] = lambda: principal
|
||||||
|
app.dependency_overrides[get_session] = lambda: object()
|
||||||
|
with patch("govoplan_notifications.backend.router.get_notification_preferences", return_value=SimpleNamespace(muted_source_modules=[])), \
|
||||||
|
patch("govoplan_notifications.backend.router.list_notifications", return_value=[]) as listing, TestClient(app) as client:
|
||||||
|
response = client.get("/notifications?status=pending&status=failed")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(listing.call_args.kwargs["status"], ["pending", "failed"])
|
||||||
|
self.assertIn("user-1", listing.call_args.kwargs["recipient_ids"])
|
||||||
|
self.assertEqual(client.get("/notifications?status=sent").status_code, 200)
|
||||||
|
self.assertEqual(listing.call_args.kwargs["status"], ["sent"])
|
||||||
|
self.assertEqual(client.get("/notifications").status_code, 200)
|
||||||
|
self.assertIsNone(listing.call_args.kwargs["status"])
|
||||||
|
|
||||||
def test_tenant_notification_view_is_an_explicit_admin_operation(self) -> None:
|
def test_tenant_notification_view_is_an_explicit_admin_operation(self) -> None:
|
||||||
with self.Session() as session:
|
with self.Session() as session:
|
||||||
another_user = create_notification(
|
another_user = create_notification(
|
||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const page = readFileSync(pagePath, "utf8");
|
|||||||
const styles = readFileSync(stylesPath, "utf8");
|
const styles = readFileSync(stylesPath, "utf8");
|
||||||
|
|
||||||
assert.match(page, /SelectionList,[\s\S]*SelectionListItem,[\s\S]*from "@govoplan\/core-webui"/);
|
assert.match(page, /SelectionList,[\s\S]*SelectionListItem,[\s\S]*from "@govoplan\/core-webui"/);
|
||||||
assert.match(page, /<SelectionList label="i18n:govoplan-notifications\.notifications" className="notifications-selection-list">/);
|
assert.match(page, /<SelectionList\b(?=[^>]*\bvariant="navigation")(?=[^>]*\blabel="i18n:govoplan-notifications\.notifications")(?=[^>]*\bclassName="notifications-selection-list")[^>]*>/);
|
||||||
assert.match(page, /<SelectionListItem[\s\S]*selected=\{selected\?\.id === notification\.id\}/);
|
assert.match(page, /<SelectionListItem[\s\S]*selected=\{selected\?\.id === notification\.id\}/);
|
||||||
assert.match(page, /className=\{`notifications-list-item \$\{notification\.read_at \? "is-read" : ""\}`\}/);
|
assert.match(page, /className=\{`notifications-list-item \$\{notification\.read_at \? "is-read" : ""\}`\}/);
|
||||||
assert.doesNotMatch(page, /<button[\s\S]{0,160}notifications-list-item/);
|
assert.doesNotMatch(page, /<button[\s\S]{0,160}notifications-list-item/);
|
||||||
|
|||||||
@@ -84,16 +84,19 @@ export type NotificationDeliveryResult = {
|
|||||||
errors: string[];
|
errors: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function listNotifications(settings: ApiSettings, filters: { status?: string; channel?: string; source_module?: string; recipient_id?: string; view?: "personal" | "tenant"; limit?: number } = {}): Promise<NotificationListResponse> {
|
export function listNotifications(settings: ApiSettings, filters: { status?: string | string[]; channel?: string; source_module?: string; recipient_id?: string; view?: "personal" | "tenant"; limit?: number } = {}, signal?: AbortSignal): Promise<NotificationListResponse> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (filters.status) params.set("status", filters.status);
|
if (Array.isArray(filters.status)) {
|
||||||
|
if (filters.status.length === 0) return Promise.resolve({ notifications: [] });
|
||||||
|
for (const status of filters.status) params.append("status", status);
|
||||||
|
} else if (filters.status) params.set("status", filters.status);
|
||||||
if (filters.channel) params.set("channel", filters.channel);
|
if (filters.channel) params.set("channel", filters.channel);
|
||||||
if (filters.source_module) params.set("source_module", filters.source_module);
|
if (filters.source_module) params.set("source_module", filters.source_module);
|
||||||
if (filters.recipient_id) params.set("recipient_id", filters.recipient_id);
|
if (filters.recipient_id) params.set("recipient_id", filters.recipient_id);
|
||||||
if (filters.view) params.set("view", filters.view);
|
if (filters.view) params.set("view", filters.view);
|
||||||
if (filters.limit) params.set("limit", String(filters.limit));
|
if (filters.limit) params.set("limit", String(filters.limit));
|
||||||
const query = params.toString();
|
const query = params.toString();
|
||||||
return apiFetch<NotificationListResponse>(settings, `/api/v1/notifications${query ? `?${query}` : ""}`);
|
return apiFetch<NotificationListResponse>(settings, `/api/v1/notifications${query ? `?${query}` : ""}`, { signal, cache: "no-store" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function notificationSummary(settings: ApiSettings): Promise<NotificationSummary> {
|
export function notificationSummary(settings: ApiSettings): Promise<NotificationSummary> {
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
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,
|
TextWithHelp,
|
||||||
|
MultiSelectFilter,
|
||||||
SelectionList,
|
SelectionList,
|
||||||
SelectionListItem,
|
SelectionListItem,
|
||||||
|
StatePanel,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
|
WorkspaceActionBar,
|
||||||
|
WorkspaceFrame,
|
||||||
|
WorkspaceLayout,
|
||||||
hasScope,
|
hasScope,
|
||||||
i18nMessage,
|
i18nMessage,
|
||||||
type ApiSettings,
|
type ApiSettings,
|
||||||
@@ -26,7 +32,6 @@ import {
|
|||||||
} from "./interfacePatterns";
|
} from "./interfacePatterns";
|
||||||
|
|
||||||
type StatusFilter =
|
type StatusFilter =
|
||||||
| "all"
|
|
||||||
| "pending"
|
| "pending"
|
||||||
| "queued"
|
| "queued"
|
||||||
| "sending"
|
| "sending"
|
||||||
@@ -38,7 +43,6 @@ type StatusFilter =
|
|||||||
| "cancelled";
|
| "cancelled";
|
||||||
|
|
||||||
const statusFilters: StatusFilter[] = [
|
const statusFilters: StatusFilter[] = [
|
||||||
"all",
|
|
||||||
"pending",
|
"pending",
|
||||||
"queued",
|
"queued",
|
||||||
"sending",
|
"sending",
|
||||||
@@ -53,9 +57,22 @@ const statusFilters: StatusFilter[] = [
|
|||||||
const cancellableStatuses = new Set(["pending", "queued", "paused", "failed"]);
|
const cancellableStatuses = new Set(["pending", "queued", "paused", "failed"]);
|
||||||
|
|
||||||
export default function NotificationCenterPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
|
export default function NotificationCenterPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
|
||||||
const [notifications, setNotifications] = useState<NotificationMessage[]>([]);
|
const scopeKey = JSON.stringify([settings.apiBaseUrl, settings.apiKey, settings.accessToken, auth.tenant.id, auth.user.id, auth.scopes]);
|
||||||
|
const currentScope = useRef(scopeKey);
|
||||||
|
const scopeGeneration = useRef(0);
|
||||||
|
if (currentScope.current !== scopeKey) {
|
||||||
|
currentScope.current = scopeKey;
|
||||||
|
scopeGeneration.current += 1;
|
||||||
|
}
|
||||||
|
const generation = scopeGeneration.current;
|
||||||
|
const isCurrentScope = () => currentScope.current === scopeKey && scopeGeneration.current === generation;
|
||||||
|
const operationScope = useRef<string | null>(null);
|
||||||
|
const [loadedScope, setLoadedScope] = useState(scopeKey);
|
||||||
|
const [loadedNotifications, setNotifications] = useState<NotificationMessage[]>([]);
|
||||||
|
const notifications = loadedScope === scopeKey ? loadedNotifications : [];
|
||||||
const [selectedId, setSelectedId] = useState("");
|
const [selectedId, setSelectedId] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
const [statusFilter, setStatusFilter] = useState<string[] | null>(null);
|
||||||
|
const loadRequest = useRef<AbortController | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -68,6 +85,8 @@ export default function NotificationCenterPage({ settings, auth }: { settings: A
|
|||||||
const unreadCount = notifications.filter((item) => !item.read_at && !["cancelled", "skipped"].includes(item.status)).length;
|
const unreadCount = notifications.filter((item) => !item.read_at && !["cancelled", "skipped"].includes(item.status)).length;
|
||||||
const commonSelectionReason = busy
|
const commonSelectionReason = busy
|
||||||
? NOTIFICATIONS_I18N.actionActive
|
? NOTIFICATIONS_I18N.actionActive
|
||||||
|
: loading
|
||||||
|
? "i18n:govoplan-notifications.loading_notifications"
|
||||||
: !selected
|
: !selected
|
||||||
? NOTIFICATIONS_I18N.selectionRequired
|
? NOTIFICATIONS_I18N.selectionRequired
|
||||||
: !canWrite
|
: !canWrite
|
||||||
@@ -78,68 +97,100 @@ export default function NotificationCenterPage({ settings, auth }: { settings: A
|
|||||||
const cancelDisabledReason = commonSelectionReason ?? (selected && !cancellableStatuses.has(selected.status) ? NOTIFICATIONS_I18N.notCancellable : undefined);
|
const cancelDisabledReason = commonSelectionReason ?? (selected && !cancellableStatuses.has(selected.status) ? NOTIFICATIONS_I18N.notCancellable : undefined);
|
||||||
const dispatchDisabledReason = busy
|
const dispatchDisabledReason = busy
|
||||||
? NOTIFICATIONS_I18N.actionActive
|
? NOTIFICATIONS_I18N.actionActive
|
||||||
|
: loading
|
||||||
|
? "i18n:govoplan-notifications.loading_notifications"
|
||||||
: !canDispatch
|
: !canDispatch
|
||||||
? NOTIFICATIONS_I18N.dispatchPermissionRequired
|
? NOTIFICATIONS_I18N.dispatchPermissionRequired
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
operationScope.current = null;
|
||||||
|
setBusy(false);
|
||||||
|
setConfirmingAction(null);
|
||||||
|
}, [scopeKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canRead) {
|
if (!canRead) {
|
||||||
|
loadRequest.current?.abort();
|
||||||
|
setNotifications([]);
|
||||||
|
setSelectedId("");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void load();
|
void load();
|
||||||
}, [canRead, settings.apiBaseUrl, settings.apiKey, settings.accessToken, statusFilter]);
|
return () => loadRequest.current?.abort();
|
||||||
|
}, [canRead, scopeKey, statusFilter]);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
|
if (!isCurrentScope() || !canRead) return;
|
||||||
|
loadRequest.current?.abort();
|
||||||
|
const request = new AbortController();
|
||||||
|
loadRequest.current = request;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const response = await listNotifications(settings, {
|
const response = await listNotifications(settings, {
|
||||||
status: statusFilter === "all" ? undefined : statusFilter,
|
status: statusFilter ?? undefined,
|
||||||
limit: 200
|
limit: 200
|
||||||
});
|
}, request.signal);
|
||||||
|
if (request.signal.aborted || !isCurrentScope()) return;
|
||||||
|
setLoadedScope(scopeKey);
|
||||||
setNotifications(response.notifications);
|
setNotifications(response.notifications);
|
||||||
setSelectedId((current) => current && response.notifications.some((item) => item.id === current) ? current : response.notifications[0]?.id ?? "");
|
setSelectedId((current) => current && response.notifications.some((item) => item.id === current) ? current : response.notifications[0]?.id ?? "");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(errorMessage(err));
|
if (!request.signal.aborted && isCurrentScope()) setError(errorMessage(err));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (!request.signal.aborted && isCurrentScope()) setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markSelected(status: "read" | "acknowledged" | "cancelled"): Promise<boolean> {
|
async function markSelected(status: "read" | "acknowledged" | "cancelled"): Promise<boolean> {
|
||||||
if (!selected || !canWrite) return false;
|
if (!selected || !canWrite || loading || operationScope.current === scopeKey) return false;
|
||||||
|
operationScope.current = scopeKey;
|
||||||
|
loadRequest.current?.abort();
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const next = await updateNotification(settings, selected.id, { status });
|
const next = await updateNotification(settings, selected.id, { status });
|
||||||
setNotifications((items) => items.map((item) => item.id === next.id ? next : item));
|
if (!isCurrentScope()) return false;
|
||||||
|
setNotifications((items) => items.map((item) => item.id === next.id ? next : item)
|
||||||
|
.filter((item) => statusFilter === null || statusFilter.includes(item.status)));
|
||||||
notifyNotificationsChanged();
|
notifyNotificationsChanged();
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(errorMessage(err));
|
if (isCurrentScope()) setError(errorMessage(err));
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
|
if (isCurrentScope()) {
|
||||||
|
operationScope.current = null;
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function runDelivery(): Promise<boolean> {
|
async function runDelivery(): Promise<boolean> {
|
||||||
if (!canDispatch) return false;
|
if (!canDispatch || loading || operationScope.current === scopeKey) return false;
|
||||||
|
operationScope.current = scopeKey;
|
||||||
|
loadRequest.current?.abort();
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
await deliverPendingNotifications(settings, 50);
|
await deliverPendingNotifications(settings, 50);
|
||||||
|
if (!isCurrentScope()) return false;
|
||||||
await load();
|
await load();
|
||||||
|
if (!isCurrentScope()) return false;
|
||||||
notifyNotificationsChanged();
|
notifyNotificationsChanged();
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(errorMessage(err));
|
if (isCurrentScope()) setError(errorMessage(err));
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
|
if (isCurrentScope()) {
|
||||||
|
operationScope.current = null;
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function confirmAction() {
|
async function confirmAction() {
|
||||||
const succeeded = confirmingAction === "cancel"
|
const succeeded = confirmingAction === "cancel"
|
||||||
@@ -152,7 +203,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 +218,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"
|
|
||||||
icon={<RefreshCw size={16} aria-hidden="true" />}
|
|
||||||
onClick={() => void load()}
|
|
||||||
disabled={loading || busy}
|
|
||||||
disabledReason={loading ? NOTIFICATIONS_I18N.loading : busy ? NOTIFICATIONS_I18N.actionActive : undefined}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
<MultiSelectFilter
|
||||||
<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 +283,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"
|
||||||
<Bell size={18} />
|
variant="detail"
|
||||||
|
className="notifications-topbar"
|
||||||
|
title={<span className="notifications-title-line">
|
||||||
|
<Bell size={18} aria-hidden="true" />
|
||||||
<strong>{selected?.subject || selected?.event_kind || "i18n:govoplan-notifications.surface.center"}</strong>
|
<strong>{selected?.subject || selected?.event_kind || "i18n:govoplan-notifications.surface.center"}</strong>
|
||||||
</div>
|
</span>}
|
||||||
<div className="notifications-actions">
|
titleHelp={<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 +328,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 +350,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 +389,11 @@ 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">
|
||||||
|
<TextWithHelp as="div" help={<DocumentationHelpLink reference={NOTIFICATIONS_DELIVERY_DOCUMENTATION} />}>
|
||||||
<h2>i18n:govoplan-notifications.delivery_attempts</h2>
|
<h2>i18n:govoplan-notifications.delivery_attempts</h2>
|
||||||
<DocumentationHelpLink reference={NOTIFICATIONS_DELIVERY_DOCUMENTATION} />
|
</TextWithHelp>
|
||||||
</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,10 +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">
|
|
||||||
<DocumentationHelpLink reference={NOTIFICATIONS_DOCUMENTATION} />
|
|
||||||
</div>
|
|
||||||
{!canWrite ? (
|
{!canWrite ? (
|
||||||
<ActionBlockerHint
|
<ActionBlockerHint
|
||||||
tone="info"
|
tone="info"
|
||||||
@@ -200,8 +197,8 @@ export default function NotificationSettingsPanel({ settings, auth }: { settings
|
|||||||
documentation={NOTIFICATIONS_DOCUMENTATION}
|
documentation={NOTIFICATIONS_DOCUMENTATION}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<Card title="i18n:govoplan-notifications.notifications">
|
<Card title="i18n:govoplan-notifications.notifications" titleHelp={<DocumentationHelpLink reference={NOTIFICATIONS_DOCUMENTATION} />}>
|
||||||
<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,11 +225,11 @@ 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" titleHelp={<DocumentationHelpLink reference={NOTIFICATIONS_DELIVERY_DOCUMENTATION} />}>
|
||||||
<div className="form-grid">
|
<FormGrid columns={1} collapseAt="standard" className="">
|
||||||
<DocumentationHelpLink reference={NOTIFICATIONS_DELIVERY_DOCUMENTATION} />
|
|
||||||
<div className="notifications-settings-inline-title">
|
<div className="notifications-settings-inline-title">
|
||||||
<Mail size={16} />
|
<Mail size={16} />
|
||||||
<strong>i18n:govoplan-notifications.email_notifications</strong>
|
<strong>i18n:govoplan-notifications.email_notifications</strong>
|
||||||
@@ -265,8 +262,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,7 +1,7 @@
|
|||||||
|
import { MetricGrid } from "@govoplan/core-webui";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import {
|
import {
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
DocumentationHelpLink,
|
|
||||||
LoadingFrame,
|
LoadingFrame,
|
||||||
MetricCard,
|
MetricCard,
|
||||||
adminErrorMessage,
|
adminErrorMessage,
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
type DashboardWidgetConfiguration,
|
type DashboardWidgetConfiguration,
|
||||||
useSharedNotificationSummary
|
useSharedNotificationSummary
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import { NOTIFICATIONS_DOCUMENTATION } from "./interfacePatterns";
|
|
||||||
|
|
||||||
export default function NotificationSummaryWidget({
|
export default function NotificationSummaryWidget({
|
||||||
settings,
|
settings,
|
||||||
@@ -40,7 +39,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,9 +62,8 @@ 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} />
|
|
||||||
{showCenterLink && (
|
{showCenterLink && (
|
||||||
<Link className="btn btn-secondary" to="/notifications">
|
<Link className="btn btn-secondary" to="/notifications">
|
||||||
i18n:govoplan-notifications.open_center
|
i18n:govoplan-notifications.open_center
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
SettingsSectionsUiCapability
|
SettingsSectionsUiCapability
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import NotificationSummaryWidget from "./features/notifications/NotificationSummaryWidget";
|
import NotificationSummaryWidget from "./features/notifications/NotificationSummaryWidget";
|
||||||
|
import { NOTIFICATIONS_DOCUMENTATION } from "./features/notifications/interfacePatterns";
|
||||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||||
import "./styles/notifications.css";
|
import "./styles/notifications.css";
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ const notificationDashboardWidgets: DashboardWidgetsUiCapability = {
|
|||||||
id: "notifications.summary",
|
id: "notifications.summary",
|
||||||
surfaceId: "notifications.widget.summary",
|
surfaceId: "notifications.widget.summary",
|
||||||
title: "i18n:govoplan-notifications.surface.center",
|
title: "i18n:govoplan-notifications.surface.center",
|
||||||
|
documentation: NOTIFICATIONS_DOCUMENTATION,
|
||||||
description: "i18n:govoplan-notifications.widget_description",
|
description: "i18n:govoplan-notifications.widget_description",
|
||||||
moduleId: "notifications",
|
moduleId: "notifications",
|
||||||
category: "i18n:govoplan-notifications.communication",
|
category: "i18n:govoplan-notifications.communication",
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -139,7 +89,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.notifications-title-line {
|
.notifications-title-line {
|
||||||
min-width: 160px;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notifications-title-line strong {
|
.notifications-title-line strong {
|
||||||
@@ -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;
|
||||||
@@ -219,16 +169,10 @@
|
|||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notifications-settings-documentation,
|
|
||||||
.notifications-settings-panel > .action-blocker-hint {
|
.notifications-settings-panel > .action-blocker-hint {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notifications-settings-documentation {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notifications-settings-inline-title {
|
.notifications-settings-inline-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -244,17 +188,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 +216,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 +227,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 +240,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;
|
||||||
|
|||||||
Reference in New Issue
Block a user