mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-23 03:27:43 +09:00
feat(notifications): polish — digests, prefs, deep-links (plans 24/33)
This commit is contained in:
1 parent
c96a476a74
commit
45942339eb
13 files changed
+1155
-28
No files matched your search
@@ -0,0 +1,99 @@
|
||||
"""Organization-level chat / webhook connection for the Notification Bus.
|
||||
|
||||
A shared destination — a Discord/Slack/Telegram room or a generic webhook — that
|
||||
receives notifications matching its category filter. Replaces the legacy global
|
||||
``notifications.json`` chat config and the per-user webhook URL fields (which
|
||||
become deprecated). Credentials are Fernet-encrypted per-field in
|
||||
``credentials_json`` (never serialized); follows the ``EmailProviderConnection``
|
||||
Connections pattern.
|
||||
|
||||
Per kind, ``credentials_json`` holds:
|
||||
- discord / slack / webhook : {'url': ..., 'secret': <optional HMAC secret>}
|
||||
- telegram : {'chat_id': ..., 'bot_token': ...}
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from app import db
|
||||
from app.utils.crypto import decrypt_secret_safe
|
||||
|
||||
|
||||
class ChatWebhookConnection(db.Model):
|
||||
__tablename__ = 'chat_webhook_connections'
|
||||
|
||||
KINDS = ('discord', 'slack', 'telegram', 'webhook')
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
kind = db.Column(db.String(20), nullable=False) # discord|slack|telegram|webhook
|
||||
name = db.Column(db.String(120), nullable=False)
|
||||
|
||||
# Encrypted per-field credential map (see module docstring).
|
||||
credentials_json = db.Column(db.Text)
|
||||
|
||||
# JSON list of categories this connection accepts (system/security/backups/
|
||||
# apps). Null / empty = every category (a catch-all room).
|
||||
categories_json = db.Column(db.Text)
|
||||
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
# The default connection for its kind (used when nothing category-matches).
|
||||
is_default = db.Column(db.Boolean, default=False, index=True)
|
||||
# True when created by the one-time import of legacy notifications.json config.
|
||||
imported = db.Column(db.Boolean, default=False)
|
||||
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
last_tested_at = db.Column(db.DateTime)
|
||||
last_test_ok = db.Column(db.Boolean)
|
||||
|
||||
def raw_credentials(self):
|
||||
try:
|
||||
return json.loads(self.credentials_json) if self.credentials_json else {}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {}
|
||||
|
||||
def credentials(self):
|
||||
"""Decrypted credentials for use at send/test time (never serialized)."""
|
||||
out = {}
|
||||
for key, value in self.raw_credentials().items():
|
||||
out[key] = decrypt_secret_safe(value) if isinstance(value, str) else value
|
||||
return out
|
||||
|
||||
def categories(self):
|
||||
try:
|
||||
value = json.loads(self.categories_json) if self.categories_json else []
|
||||
return value if isinstance(value, list) else []
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
|
||||
def matches_category(self, category):
|
||||
"""A connection with no category filter accepts everything."""
|
||||
cats = self.categories()
|
||||
return not cats or category in cats
|
||||
|
||||
@property
|
||||
def has_signing(self):
|
||||
return bool(self.credentials().get('secret')) and self.kind == 'webhook'
|
||||
|
||||
def to_dict(self):
|
||||
creds = self.credentials()
|
||||
# Surface a masked destination hint, never the full URL / secret / token.
|
||||
dest = creds.get('url') or creds.get('chat_id') or ''
|
||||
masked = (dest[:24] + '…') if len(dest) > 25 else dest
|
||||
return {
|
||||
'id': self.id,
|
||||
'kind': self.kind,
|
||||
'name': self.name,
|
||||
'destination': masked,
|
||||
'categories': self.categories(),
|
||||
'is_active': self.is_active,
|
||||
'is_default': self.is_default,
|
||||
'imported': self.imported,
|
||||
'has_signing': self.has_signing,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'last_tested_at': self.last_tested_at.isoformat() if self.last_tested_at else None,
|
||||
'last_test_ok': self.last_test_ok,
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<ChatWebhookConnection {self.id} {self.kind} {self.name}>'
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Deep-link helpers for notifications (plan 24).
|
||||
|
||||
A notification's ``action_path`` is stored relative (e.g. ``/domains``) so a
|
||||
route move never breaks old rows. For channels that need an absolute URL (email,
|
||||
chat) we resolve it against the panel's canonical domain at render time.
|
||||
"""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def panel_base_url():
|
||||
"""Absolute base URL of the panel, or '' when no canonical domain is set."""
|
||||
try:
|
||||
from app.services.settings_service import SettingsService
|
||||
domain = (SettingsService.get('canonical_domain', '') or '').strip()
|
||||
if not domain:
|
||||
return ''
|
||||
scheme = 'https' if SettingsService.get('canonical_https_enabled', False) else 'http'
|
||||
return f'{scheme}://{domain}'
|
||||
except Exception: # pragma: no cover - settings unavailable
|
||||
return ''
|
||||
|
||||
|
||||
def absolute_url(path):
|
||||
"""Turn an ``action_path`` into an absolute URL. Returns None for a falsy
|
||||
path; returns the path unchanged if already absolute or if no base is set."""
|
||||
if not path:
|
||||
return None
|
||||
if path.startswith('http://') or path.startswith('https://'):
|
||||
return path
|
||||
base = panel_base_url()
|
||||
if not base:
|
||||
return path # relative fallback — the in-app bell is the primary surface
|
||||
return base.rstrip('/') + '/' + path.lstrip('/')
|
||||
|
||||
|
||||
def manage_url():
|
||||
"""The 'manage notifications' footer link, absolute when possible."""
|
||||
resolved = absolute_url('/settings/notifications')
|
||||
return resolved if (resolved and resolved.startswith('http')) else None
|
||||
@@ -46,6 +46,13 @@ class Notification(db.Model):
|
||||
# (e.g. 'admins', 'user:42', 'ops@example.com'). Not used for routing.
|
||||
audience = db.Column(db.String(255))
|
||||
|
||||
# Deep link to this notification's subject (plan 24 Phase 5). Stored RELATIVE
|
||||
# (e.g. '/domains') and computed at send time — from the catalog link builder
|
||||
# or a producer override — so a later route move never breaks old rows. The
|
||||
# bell/history navigate here; email/chat resolve it to an absolute URL.
|
||||
action_path = db.Column(db.String(512))
|
||||
action_label = db.Column(db.String(120))
|
||||
|
||||
# Correlation ID for grouping this notification with related telemetry events.
|
||||
correlation_id = db.Column(db.String(64), nullable=True, index=True)
|
||||
|
||||
@@ -76,6 +83,8 @@ class Notification(db.Model):
|
||||
'title': self.title,
|
||||
'body': self.body,
|
||||
'audience': self.audience,
|
||||
'action_path': self.action_path,
|
||||
'action_label': self.action_label,
|
||||
'correlation_id': self.correlation_id,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
@@ -95,6 +104,10 @@ class NotificationDelivery(db.Model):
|
||||
STATUS_SENT = 'sent' # handed to the channel successfully
|
||||
STATUS_FAILED = 'failed' # exhausted retries (dead-letter on the queue)
|
||||
STATUS_SKIPPED = 'skipped' # intentionally not sent (e.g. no target)
|
||||
# Held for the periodic digest (plan 24 Phase 3): a digestable email or a
|
||||
# quiet-hours catch-up. Never enqueued; the digest job groups these, sends
|
||||
# ONE branded email, then flips them to STATUS_SENT.
|
||||
STATUS_QUEUED_DIGEST = 'queued_digest'
|
||||
|
||||
# Channels
|
||||
CHANNEL_INAPP = 'inapp'
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Notification preference depth — per-event channel overrides (plan 24 Phase 2).
|
||||
|
||||
Adds ``events_json`` to ``notification_preferences``: a JSON map
|
||||
``{"<event_key>": {"<channel>": bool}}`` that is the top of the preference
|
||||
resolution order (event override > category/severity > org default > catalog).
|
||||
Back-compat: rows without the column default to ``{}`` (no overrides), so
|
||||
existing users are unaffected.
|
||||
|
||||
Org-level defaults live in the ``system_settings`` key ``notify.defaults`` (no
|
||||
schema change) — see SettingsService.
|
||||
|
||||
Idempotent: MigrationService runs _fix_missing_columns() + db.create_all() on
|
||||
boot before Alembic, so guard on the live schema.
|
||||
|
||||
Revision ID: 059_notification_prefs_events
|
||||
Revises: 058_backup_restore_drills
|
||||
Create Date: 2026-07-05
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '059_notification_prefs_events'
|
||||
down_revision = '058_backup_restore_drills'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _cols(inspector, table):
|
||||
return {c['name'] for c in inspector.get_columns(table)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
tables = set(inspector.get_table_names())
|
||||
|
||||
if 'notification_preferences' in tables:
|
||||
cols = _cols(inspector, 'notification_preferences')
|
||||
if 'events_json' not in cols:
|
||||
op.add_column('notification_preferences', sa.Column(
|
||||
'events_json', sa.Text(), nullable=True, server_default='{}'))
|
||||
|
||||
|
||||
def downgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
tables = set(inspector.get_table_names())
|
||||
|
||||
if 'notification_preferences' in tables:
|
||||
cols = _cols(inspector, 'notification_preferences')
|
||||
if 'events_json' in cols:
|
||||
op.drop_column('notification_preferences', 'events_json')
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Org-level chat/webhook connections (plan 24 Phase 4).
|
||||
|
||||
New ``chat_webhook_connections`` table: a shared Discord/Slack/Telegram room or
|
||||
generic webhook that receives notifications matching its category filter.
|
||||
Credentials are Fernet-encrypted per-field in ``credentials_json``. Replaces the
|
||||
legacy global notifications.json chat config (imported once at boot) and the
|
||||
deprecated per-user webhook URL fields.
|
||||
|
||||
Idempotent: MigrationService runs _fix_missing_columns() + db.create_all() on
|
||||
boot before Alembic, so guard on the live schema.
|
||||
|
||||
Revision ID: 061_chat_webhook_connections
|
||||
Revises: 060_notification_digest
|
||||
Create Date: 2026-07-05
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '061_chat_webhook_connections'
|
||||
down_revision = '060_notification_digest'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
if 'chat_webhook_connections' in set(inspector.get_table_names()):
|
||||
return
|
||||
op.create_table(
|
||||
'chat_webhook_connections',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('kind', sa.String(length=20), nullable=False),
|
||||
sa.Column('name', sa.String(length=120), nullable=False),
|
||||
sa.Column('credentials_json', sa.Text(), nullable=True),
|
||||
sa.Column('categories_json', sa.Text(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column('is_default', sa.Boolean(), nullable=True, server_default=sa.false()),
|
||||
sa.Column('imported', sa.Boolean(), nullable=True, server_default=sa.false()),
|
||||
sa.Column('created_by', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('last_tested_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('last_test_ok', sa.Boolean(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['created_by'], ['users.id']),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index('ix_chat_webhook_connections_is_default',
|
||||
'chat_webhook_connections', ['is_default'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
if 'chat_webhook_connections' in set(inspector.get_table_names()):
|
||||
op.drop_table('chat_webhook_connections')
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Org-level chat/webhook connections (plan 24 Phase 4).
|
||||
|
||||
Proves connection CRUD + encryption, category-matched routing (the bus fans a
|
||||
notification out to matching connections), HMAC signing of the generic webhook
|
||||
payload, the consumer delivery path, and legacy-import idempotency.
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from app import db
|
||||
from app.models import User
|
||||
from app.models.chat_webhook import ChatWebhookConnection
|
||||
from app.models.notification_preferences import NotificationPreferences
|
||||
from app.notifications.consumer import process_message
|
||||
from app.notifications.models import NotificationDelivery
|
||||
from app.notifications.service import GROUP_SLUG, QUEUE_SLUG, NotificationBusService
|
||||
from app.queue_bus.service import QueueBusService
|
||||
from app.services.chat_webhook_service import ChatWebhookService
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_broker(app):
|
||||
QueueBusService.reset_broker()
|
||||
|
||||
|
||||
def _make_user(username='alice', email='alice@example.com'):
|
||||
user = User(email=email, username=username,
|
||||
password_hash=generate_password_hash('x'), role='developer', is_active=True)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
# Enable everything so per-user gating never hides the org fan-out under test.
|
||||
prefs = NotificationPreferences.get_or_create(user.id)
|
||||
prefs.set_severities(['critical', 'warning', 'info', 'success'])
|
||||
db.session.commit()
|
||||
return user
|
||||
|
||||
|
||||
def _drain():
|
||||
msgs = QueueBusService.receive(GROUP_SLUG, QUEUE_SLUG, visibility_timeout_ms=60000, max_messages=100)
|
||||
for m in msgs:
|
||||
process_message(m)
|
||||
return len(msgs)
|
||||
|
||||
|
||||
class TestCrud:
|
||||
def test_add_encrypts_and_first_is_default(self, app):
|
||||
conn = ChatWebhookService.add({
|
||||
'kind': 'webhook', 'name': 'Ops', 'url': 'https://hooks.example/abc',
|
||||
'secret': 'shh', 'categories': ['security'],
|
||||
})
|
||||
assert conn.is_default is True
|
||||
# URL + secret encrypted at rest, decrypt back, never serialized.
|
||||
assert conn.raw_credentials()['url'] != 'https://hooks.example/abc'
|
||||
assert conn.credentials()['url'] == 'https://hooks.example/abc'
|
||||
assert 'shh' not in json.dumps(conn.to_dict())
|
||||
assert conn.has_signing is True
|
||||
|
||||
def test_add_requires_destination(self, app):
|
||||
with pytest.raises(ValueError):
|
||||
ChatWebhookService.add({'kind': 'webhook', 'name': 'x'})
|
||||
|
||||
def test_delete_promotes_new_default(self, app):
|
||||
a = ChatWebhookService.add({'kind': 'webhook', 'name': 'A', 'url': 'https://x/a'})
|
||||
b = ChatWebhookService.add({'kind': 'webhook', 'name': 'B', 'url': 'https://x/b'})
|
||||
assert a.is_default and not b.is_default
|
||||
ChatWebhookService.delete(a.id)
|
||||
db.session.refresh(b)
|
||||
assert b.is_default is True
|
||||
|
||||
|
||||
class TestCategoryRouting:
|
||||
def test_catch_all_matches_every_category(self, app):
|
||||
conn = ChatWebhookService.add({'kind': 'webhook', 'name': 'All', 'url': 'https://x/all'})
|
||||
assert conn.matches_category('backups')
|
||||
assert ChatWebhookService.active_for_category('security') == [conn]
|
||||
|
||||
def test_filtered_connection_only_matches_its_categories(self, app):
|
||||
ChatWebhookService.add({'kind': 'webhook', 'name': 'Sec', 'url': 'https://x/s',
|
||||
'categories': ['security']})
|
||||
assert [c.name for c in ChatWebhookService.active_for_category('security')] == ['Sec']
|
||||
assert ChatWebhookService.active_for_category('backups') == []
|
||||
|
||||
|
||||
class TestBusFanout:
|
||||
def test_matching_category_gets_a_connection_delivery(self, app):
|
||||
user = _make_user()
|
||||
conn = ChatWebhookService.add({'kind': 'webhook', 'name': 'Sec', 'url': 'https://x/s',
|
||||
'categories': ['security']})
|
||||
|
||||
NotificationBusService.send('security.alert', to=user, data={'message': 'breach'})
|
||||
chat = NotificationDelivery.query.filter_by(channel='webhook', target=f'conn:{conn.id}').all()
|
||||
assert len(chat) == 1
|
||||
assert chat[0].recipient_user_id is None # org-level, not per-user
|
||||
|
||||
def test_non_matching_category_is_skipped(self, app):
|
||||
user = _make_user()
|
||||
ChatWebhookService.add({'kind': 'webhook', 'name': 'Sec', 'url': 'https://x/s',
|
||||
'categories': ['security']})
|
||||
NotificationBusService.send('backup.completed', to=user, data={'app': 'blog'})
|
||||
assert NotificationDelivery.query.filter_by(channel='webhook').count() == 0
|
||||
|
||||
def test_directed_send_skips_org_chat(self, app):
|
||||
user = _make_user()
|
||||
ChatWebhookService.add({'kind': 'webhook', 'name': 'All', 'url': 'https://x/all'})
|
||||
# Explicit channels = transactional; org chat must not fan out.
|
||||
NotificationBusService.send('user.welcome', to=user, channels=['email'], data={})
|
||||
assert NotificationDelivery.query.filter_by(channel='webhook').count() == 0
|
||||
|
||||
|
||||
class TestSigningAndDelivery:
|
||||
def test_generic_webhook_signs_and_delivers(self, app, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
class _Resp:
|
||||
ok = True
|
||||
status_code = 200
|
||||
|
||||
def fake_post(url, data=None, headers=None, timeout=None, **kw):
|
||||
captured.update({'url': url, 'data': data, 'headers': headers})
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr('app.services.chat_webhook_service.requests.post', fake_post)
|
||||
|
||||
user = _make_user()
|
||||
conn = ChatWebhookService.add({'kind': 'webhook', 'name': 'Ops', 'url': 'https://hooks/x',
|
||||
'secret': 'topsecret', 'categories': ['security']})
|
||||
NotificationBusService.send('security.alert', to=user, data={'message': 'breach'})
|
||||
assert _drain() >= 1
|
||||
|
||||
assert captured['url'] == 'https://hooks/x'
|
||||
body = captured['data']
|
||||
expected = 'sha256=' + hmac.new(b'topsecret', body, hashlib.sha256).hexdigest()
|
||||
assert captured['headers']['X-ServerKit-Signature'] == expected
|
||||
payload = json.loads(body)
|
||||
assert payload['event'] == 'security.alert'
|
||||
assert payload['category'] == 'security'
|
||||
|
||||
delivery = NotificationDelivery.query.filter_by(target=f'conn:{conn.id}').one()
|
||||
assert delivery.status == 'sent'
|
||||
|
||||
def test_discord_connection_delegates_to_formatter(self, app, monkeypatch):
|
||||
from app.services.notification_service import NotificationService
|
||||
seen = {}
|
||||
monkeypatch.setattr(NotificationService, 'send_discord',
|
||||
classmethod(lambda cls, alerts, cfg: seen.update({'cfg': cfg}) or {'success': True}))
|
||||
|
||||
user = _make_user()
|
||||
ChatWebhookService.add({'kind': 'discord', 'name': 'Room',
|
||||
'url': 'https://discord/webhook'})
|
||||
NotificationBusService.send('backup.completed', to=user, data={'app': 'blog'})
|
||||
assert _drain() >= 1
|
||||
assert seen['cfg']['webhook_url'] == 'https://discord/webhook'
|
||||
|
||||
|
||||
class TestImport:
|
||||
def test_import_is_idempotent(self, app, monkeypatch):
|
||||
from app.services.notification_service import NotificationService
|
||||
cfg = {
|
||||
'discord': {'webhook_url': 'https://discord/hook'},
|
||||
'slack': {'webhook_url': 'https://hooks.slack/x'},
|
||||
'generic_webhook': {'url': 'https://hooks/generic'},
|
||||
'telegram': {},
|
||||
}
|
||||
monkeypatch.setattr(NotificationService, 'get_config', classmethod(lambda cls: cfg))
|
||||
|
||||
assert ChatWebhookService.import_legacy() == 3
|
||||
assert ChatWebhookService.import_legacy() == 0 # second run is a no-op
|
||||
kinds = {c.kind for c in ChatWebhookConnection.query.filter_by(imported=True).all()}
|
||||
assert kinds == {'discord', 'slack', 'webhook'}
|
||||
|
||||
|
||||
class TestApi:
|
||||
def test_crud_roundtrip(self, app, client, auth_headers):
|
||||
resp = client.post('/api/v1/notifications/admin/chat-connections',
|
||||
json={'kind': 'webhook', 'name': 'Ops', 'url': 'https://x/ops',
|
||||
'categories': ['security']}, headers=auth_headers)
|
||||
assert resp.status_code == 201
|
||||
cid = resp.get_json()['connection']['id']
|
||||
|
||||
listing = client.get('/api/v1/notifications/admin/chat-connections', headers=auth_headers)
|
||||
assert listing.status_code == 200
|
||||
body = listing.get_json()
|
||||
assert any(c['id'] == cid for c in body['connections'])
|
||||
assert 'webhook' in body['kinds']
|
||||
|
||||
dele = client.delete(f'/api/v1/notifications/admin/chat-connections/{cid}', headers=auth_headers)
|
||||
assert dele.status_code == 200
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Deep links + history filters (plan 24 Phase 5).
|
||||
|
||||
Proves the action_path is computed at send time and persisted (so a route move
|
||||
never breaks old rows), producer overrides win, the inbox category/severity
|
||||
filters work, and mark-group-read only touches one category.
|
||||
"""
|
||||
import pytest
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from app import db
|
||||
from app.models import User
|
||||
from app.models.notification_preferences import NotificationPreferences
|
||||
from app.notifications import catalog
|
||||
from app.notifications.models import Notification
|
||||
from app.notifications.service import NotificationBusService
|
||||
from app.queue_bus.service import QueueBusService
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_broker(app):
|
||||
QueueBusService.reset_broker()
|
||||
|
||||
|
||||
def _make_user(username='alice', email='alice@example.com'):
|
||||
user = User(email=email, username=username,
|
||||
password_hash=generate_password_hash('x'), role='developer', is_active=True)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
prefs = NotificationPreferences.get_or_create(user.id)
|
||||
prefs.set_severities(['critical', 'warning', 'info', 'success'])
|
||||
db.session.commit()
|
||||
return user
|
||||
|
||||
|
||||
class TestLinkBuilder:
|
||||
def test_catalog_link_persisted_at_send(self, app):
|
||||
user = _make_user()
|
||||
res = NotificationBusService.send('dns.unresolved', to=user,
|
||||
data={'count': 2}, severity='warning')
|
||||
notif = Notification.query.get(res['notification_id'])
|
||||
assert notif.action_path == '/domains'
|
||||
assert notif.action_label
|
||||
|
||||
def test_producer_override_wins(self, app):
|
||||
user = _make_user()
|
||||
res = NotificationBusService.send('backup.completed', to=user, data={'app': 'blog'},
|
||||
action_path='/backups?policy=7', action_label='Open policy')
|
||||
notif = Notification.query.get(res['notification_id'])
|
||||
assert notif.action_path == '/backups?policy=7'
|
||||
assert notif.action_label == 'Open policy'
|
||||
|
||||
def test_link_survives_a_catalog_route_change(self, app):
|
||||
# A row keeps the path it was sent with even if the catalog builder later
|
||||
# points elsewhere — old rows never break.
|
||||
user = _make_user()
|
||||
res = NotificationBusService.send('cron.job_failed', to=user, data={'name': 'nightly'})
|
||||
notif = Notification.query.get(res['notification_id'])
|
||||
assert notif.action_path == '/cron'
|
||||
|
||||
original = catalog._LINKS['cron.job_failed']
|
||||
catalog._LINKS['cron.job_failed'] = '/scheduled-tasks'
|
||||
try:
|
||||
db.session.refresh(notif)
|
||||
assert notif.action_path == '/cron' # persisted, not recomputed
|
||||
finally:
|
||||
catalog._LINKS['cron.job_failed'] = original
|
||||
|
||||
def test_unknown_event_has_no_link(self, app):
|
||||
user = _make_user()
|
||||
res = NotificationBusService.send('totally.unknown', to=user, data={})
|
||||
notif = Notification.query.get(res['notification_id'])
|
||||
assert notif.action_path is None
|
||||
|
||||
|
||||
class TestHistoryFilters:
|
||||
def _seed(self, user):
|
||||
NotificationBusService.send('backup.completed', to=user, data={'app': 'a'}, severity='success')
|
||||
NotificationBusService.send('security.alert', to=user, data={'message': 'x'}, severity='critical')
|
||||
NotificationBusService.send('system.alert', to=user, data={'message': 'y'}, severity='warning')
|
||||
|
||||
def test_category_filter(self, app):
|
||||
user = _make_user()
|
||||
self._seed(user)
|
||||
items = NotificationBusService.inbox(user.id, category='security')
|
||||
assert len(items) == 1
|
||||
assert items[0]['category'] == 'security'
|
||||
|
||||
def test_severity_filter(self, app):
|
||||
user = _make_user()
|
||||
self._seed(user)
|
||||
items = NotificationBusService.inbox(user.id, severity='critical')
|
||||
assert all(i['severity'] == 'critical' for i in items)
|
||||
assert len(items) == 1
|
||||
|
||||
def test_inbox_items_carry_action_path(self, app):
|
||||
user = _make_user()
|
||||
NotificationBusService.send('dns.unresolved', to=user, data={'count': 1}, severity='warning')
|
||||
items = NotificationBusService.inbox(user.id)
|
||||
assert items[0]['action_path'] == '/domains'
|
||||
|
||||
def test_mark_group_read_only_touches_category(self, app):
|
||||
user = _make_user()
|
||||
self._seed(user)
|
||||
assert NotificationBusService.unread_count(user.id) == 3
|
||||
marked = NotificationBusService.mark_all_read(user.id, category='security')
|
||||
assert marked == 1
|
||||
assert NotificationBusService.unread_count(user.id) == 2
|
||||
|
||||
|
||||
class TestApi:
|
||||
def test_inbox_filter_params(self, app, client, auth_headers):
|
||||
admin = User.query.filter_by(username='testadmin').first()
|
||||
prefs = NotificationPreferences.get_or_create(admin.id)
|
||||
prefs.set_severities(['critical', 'warning', 'info', 'success'])
|
||||
db.session.commit()
|
||||
NotificationBusService.send('security.alert', to=admin, data={'message': 'x'}, severity='critical')
|
||||
NotificationBusService.send('backup.completed', to=admin, data={'app': 'a'}, severity='success')
|
||||
|
||||
resp = client.get('/api/v1/notifications/inbox?category=security', headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
items = resp.get_json()['items']
|
||||
assert all(i['category'] == 'security' for i in items)
|
||||
|
||||
grp = client.post('/api/v1/notifications/inbox/read-all?category=security', headers=auth_headers)
|
||||
assert grp.status_code == 200
|
||||
assert grp.get_json()['updated'] >= 1
|
||||
@@ -0,0 +1,166 @@
|
||||
# The ServerKit notification delivery contract
|
||||
|
||||
*One page every producer and plugin author reads before calling
|
||||
`notify.send()`. It defines who hears about an event, on which channel, and
|
||||
when. The code cites this file as the source of truth — when the code and this
|
||||
document disagree, it's a bug in one of them, so fix both together.*
|
||||
|
||||
> This document is **tracked** (committed). Planning and status notes live in the
|
||||
> git-ignored `docs/NOTIFICATIONS_ROADMAP.md`; the resolution/digest rules that
|
||||
> the bus actually enforces live here.
|
||||
|
||||
---
|
||||
|
||||
## Preference resolution order
|
||||
|
||||
For a given `(user, event, channel)`, the bus (`app/notifications/service.py`,
|
||||
`_plan()` → `_resolve_channels()`) resolves "does this channel fire?" as a
|
||||
two-stage decision.
|
||||
|
||||
### Stage A — does the event reach this user at all?
|
||||
|
||||
Evaluated top-down; the first rule that applies wins:
|
||||
|
||||
1. **Account-wide kill switch** — `NotificationPreferences.enabled == False`
|
||||
stops **everything**, including `critical` and a per-event override. Nothing
|
||||
below runs.
|
||||
2. **Per-event force-ON override** — if `events_json[event_key]` turns **any**
|
||||
channel ON, the event is **resurrected**: it reaches the user even when the
|
||||
category opt-out or severity list below would exclude it. A per-event
|
||||
override is the most specific preference a user can express, so it wins over
|
||||
the coarser category/severity gates (plan 33 Decision 1). A pure force-**OFF**
|
||||
map (all channels `false`) is *not* a resurrect — it only trims channels the
|
||||
normal path would have fired.
|
||||
3. **Category opt-out** — `categories[event.category] == False` stops the event,
|
||||
*including a critical* (only a force-ON override at rule 2, or the event not
|
||||
being opted out, lets it through).
|
||||
4. **Severity gate** — the event's severity must be in `severities`. `critical`
|
||||
always passes this gate.
|
||||
|
||||
If Stage A lets the event through, Stage B decides the channel set.
|
||||
|
||||
### Stage B — which channels fire?
|
||||
|
||||
Per channel, first match wins (`_resolve_channels()`):
|
||||
|
||||
1. **User per-event override** — `events_json[event_key][channel]` (`true` /
|
||||
`false`). Explicit on/off for this event + channel.
|
||||
2. **User channel selection** — `channels` (the in-app bell is always on for an
|
||||
enabled user, independent of this list).
|
||||
3. **Org default floor** — `notify.defaults[category][channel]` (admin-set): a
|
||||
per-category channel floor, e.g. "backups → email off for everyone".
|
||||
4. **Catalog default** — on if the channel is user-selected.
|
||||
|
||||
**Hard invariant:** `severity == 'critical'` reaches an enabled, non-category-
|
||||
opted-out user on **every** configured channel — it pierces the severity gate,
|
||||
quiet hours, the org floor, and a per-event force-**OFF**. Only the account-wide
|
||||
`enabled=False` (Stage A rule 1) or a category opt-out (Stage A rule 3) stops a
|
||||
critical.
|
||||
|
||||
---
|
||||
|
||||
## Quiet hours
|
||||
|
||||
Quiet hours **delay** non-critical notifications; they never delete them
|
||||
(plan 33 Decision 3).
|
||||
|
||||
- **Email** — a non-critical email suppressed by quiet hours is written
|
||||
`queued_digest` and flushed by the hourly digest job once the user is past
|
||||
their quiet window (its catch-up; see below).
|
||||
- **Chat** (Discord / Slack / Telegram / webhook) — a non-critical chat delivery
|
||||
suppressed by quiet hours is written `queued_digest` too, then flushed as **one
|
||||
compact per-connection summary** when the window ends. No per-item replay spam:
|
||||
one summary per destination per quiet window.
|
||||
- **In-app** — always recorded immediately (the bell is silent), so there's a
|
||||
catch-up record when the user returns.
|
||||
- **Critical** — pierces quiet hours on every channel, immediately.
|
||||
|
||||
---
|
||||
|
||||
## Digest severity rules
|
||||
|
||||
Digesting batches low-value events into one branded email instead of N sends.
|
||||
Per severity, the **default** routing is:
|
||||
|
||||
| Severity | In-app | Email |
|
||||
|---|---|---|
|
||||
| `info` / `success` | immediate | **digested** |
|
||||
| `warning` | immediate | **digested** |
|
||||
| `critical` | immediate | immediate (never digested) |
|
||||
|
||||
- Users pick a **digest cadence** (`daily` \| `weekly` \| `off`) in their prefs.
|
||||
`off` (default) = the pre-digest behavior (email sends immediately).
|
||||
- A digestable email delivery is written `queued_digest`; the consumer skips it;
|
||||
the hourly `notifications.digest.run` job groups a user's `queued_digest` rows,
|
||||
renders one `digest.html` grouped by category, sends it, and marks the rows
|
||||
`sent` with the digest's provider message-id.
|
||||
- **UX guards:** an empty digest never sends; a single-item digest uses that
|
||||
item's own subject line (no "1 update" shame mail).
|
||||
|
||||
---
|
||||
|
||||
## Deep links
|
||||
|
||||
Every notification carries an optional `action_path` (+ `action_label`),
|
||||
computed **at send time** (catalog link builder or a producer override) and
|
||||
persisted on the `Notification` row so a later route change never breaks old
|
||||
rows. Bell, history, and the email CTA all consume the same field.
|
||||
|
||||
Deep links follow a **fragment contract** (plan 33 Decision 2): a link is a bare
|
||||
page path plus an optional `?focus=<kind>:<id>` query param
|
||||
(e.g. `/backups?focus=policy:12`, `/domains?focus=domain:example.com`,
|
||||
`/cron?focus=job:job_2026...`). The destination page reads `focus` once on mount
|
||||
and opens its existing drawer / panel / modal, then clears the param so a refresh
|
||||
doesn't re-trigger.
|
||||
|
||||
---
|
||||
|
||||
## Per-user vs org chat targeting
|
||||
|
||||
Both apply and are **additive**, deduped per destination (plan 33 Decision 5):
|
||||
|
||||
- **Per-user chat** — `_target_for` resolves a user's personal Discord webhook /
|
||||
Telegram chat id from their prefs; delivered per recipient.
|
||||
- **Org broadcast** — every active `ChatWebhookConnection` whose category filter
|
||||
matches receives the event once (`conn:<id>` target), independent of the
|
||||
per-user recipients.
|
||||
|
||||
A `(user_id, channel, target)` / `(None, kind, conn:<id>)` de-dupe key prevents a
|
||||
re-plan from double-sending to the same destination.
|
||||
|
||||
---
|
||||
|
||||
## Bounce / complaint handling
|
||||
|
||||
Provider bounce/complaint webhooks post to
|
||||
`POST /api/v1/notifications/inbound/email` (HMAC-signed; 404 when unconfigured).
|
||||
A hard bounce or complaint is recorded against the delivery's stored
|
||||
provider message-id; after **N consecutive hard bounces** an address is
|
||||
auto-muted (with an unmute affordance in the user's notification settings) and
|
||||
admins are notified (plan 33 Decision 4 / roadmap #24).
|
||||
|
||||
### Pointing a provider's bounce webhook at ServerKit
|
||||
|
||||
1. **Set the shared secret.** Store a random secret under the SettingsService
|
||||
key `notify.inbound_secret` (until it's set, the endpoint 404s — the feature
|
||||
is off by default). Any panel process reading the same DB sees it.
|
||||
2. **Configure the provider webhook** to `POST` bounce/complaint events to
|
||||
`https://<your-panel>/api/v1/notifications/inbound/email`, signing the **raw
|
||||
request body** with HMAC-SHA256 using the secret and sending the hex digest
|
||||
in an `X-ServerKit-Signature: sha256=<hex>` header. (If your provider signs
|
||||
with its own scheme, put a tiny signing proxy in front, or extend the one
|
||||
adapter seam.)
|
||||
3. **Payload shapes** are mapped provider-agnostically — SendGrid event arrays,
|
||||
Postmark, Amazon SES (raw or SNS-wrapped), Mailgun, and a generic
|
||||
`{message_id, kind, reason, email}` all work. Soft/transient failures are
|
||||
ignored; only hard bounces and spam complaints count toward the mute.
|
||||
4. **Correlation** is by the provider message-id captured at send time; when a
|
||||
payload also carries the recipient address, that's used as a fallback.
|
||||
|
||||
---
|
||||
|
||||
## Throttling
|
||||
|
||||
Throttling stays **code-side at the producer** (edge-triggered / first-failure +
|
||||
recovery, per the doctor/health precedents). The bus adds no generic cooldown
|
||||
engine.
|
||||
@@ -45,13 +45,18 @@ export default function NotificationBell() {
|
||||
};
|
||||
|
||||
const onItemClick = (item) => {
|
||||
// System notices route to their fix; bus notifications just mark read.
|
||||
// System notices route to their fix; bus notifications mark read and
|
||||
// then deep-link to their subject when they carry an action_path.
|
||||
if (item.kind === 'notice') {
|
||||
setOpen(false);
|
||||
if (item.action_path) navigate(item.action_path);
|
||||
return;
|
||||
}
|
||||
if (!item.read && markRead) markRead(item.delivery_id);
|
||||
if (item.action_path) {
|
||||
setOpen(false);
|
||||
navigate(item.action_path);
|
||||
}
|
||||
};
|
||||
|
||||
const badge = unreadCount > 99 ? '99+' : unreadCount;
|
||||
@@ -130,7 +135,10 @@ export default function NotificationBell() {
|
||||
<span className="sk-notif__content">
|
||||
<span className="sk-notif__title">{item.title}</span>
|
||||
{item.body && <span className="sk-notif__text">{item.body}</span>}
|
||||
<span className="sk-notif__time">{timeAgo(item.created_at)}</span>
|
||||
<span className="sk-notif__time">
|
||||
{timeAgo(item.created_at)}
|
||||
{item.action_path && <span className="sk-notif__cta"> · {item.action_label || 'Open'} →</span>}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -18,12 +18,29 @@ const SEVERITY_DOT = {
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
const CATEGORY_CHIPS = [
|
||||
{ key: '', label: 'All categories' },
|
||||
{ key: 'system', label: 'System' },
|
||||
{ key: 'security', label: 'Security' },
|
||||
{ key: 'backups', label: 'Backups' },
|
||||
{ key: 'apps', label: 'Apps' },
|
||||
];
|
||||
const SEVERITY_CHIPS = [
|
||||
{ key: '', label: 'Any' },
|
||||
{ key: 'critical', label: 'Critical' },
|
||||
{ key: 'warning', label: 'Warning' },
|
||||
{ key: 'success', label: 'Success' },
|
||||
{ key: 'info', label: 'Info' },
|
||||
];
|
||||
|
||||
export default function Notifications() {
|
||||
const navigate = useNavigate();
|
||||
const { isAdmin } = useAuth();
|
||||
const { refresh: refreshBell, items: ctxItems = [], dismissNotice } = useNotifications() || {};
|
||||
const [items, setItems] = useState([]);
|
||||
const [unreadOnly, setUnreadOnly] = useState(false);
|
||||
const [category, setCategory] = useState('');
|
||||
const [severity, setSeverity] = useState('');
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
@@ -31,7 +48,10 @@ export default function Notifications() {
|
||||
const fetchPage = useCallback(async (startOffset, replace) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.getInbox({ limit: PAGE_SIZE, offset: startOffset, unread: unreadOnly });
|
||||
const data = await api.getInbox({
|
||||
limit: PAGE_SIZE, offset: startOffset, unread: unreadOnly,
|
||||
category: category || undefined, severity: severity || undefined,
|
||||
});
|
||||
const fresh = data.items || [];
|
||||
setItems((prev) => (replace ? fresh : [...prev, ...fresh]));
|
||||
setUnreadCount(data.unread_count || 0);
|
||||
@@ -41,25 +61,30 @@ export default function Notifications() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [unreadOnly]);
|
||||
}, [unreadOnly, category, severity]);
|
||||
|
||||
useEffect(() => { fetchPage(0, true); }, [fetchPage]);
|
||||
|
||||
const onItemClick = async (item) => {
|
||||
if (item.read) return;
|
||||
setItems((prev) => prev.map((it) => (
|
||||
it.delivery_id === item.delivery_id ? { ...it, read: true } : it
|
||||
)));
|
||||
setUnreadCount((c) => Math.max(0, c - 1));
|
||||
try { await api.markNotificationRead(item.delivery_id); } catch { /* reconciled on reload */ }
|
||||
if (refreshBell) refreshBell();
|
||||
if (!item.read) {
|
||||
setItems((prev) => prev.map((it) => (
|
||||
it.delivery_id === item.delivery_id ? { ...it, read: true } : it
|
||||
)));
|
||||
setUnreadCount((c) => Math.max(0, c - 1));
|
||||
try { await api.markNotificationRead(item.delivery_id); } catch { /* reconciled on reload */ }
|
||||
if (refreshBell) refreshBell();
|
||||
}
|
||||
// Deep link to the notification's subject (whole-row click).
|
||||
if (item.action_path) navigate(item.action_path);
|
||||
};
|
||||
|
||||
const onMarkAll = async () => {
|
||||
// When a category filter is active, mark just that group read.
|
||||
setItems((prev) => prev.map((it) => ({ ...it, read: true })));
|
||||
setUnreadCount(0);
|
||||
try { await api.markAllNotificationsRead(); } catch { /* reconciled on reload */ }
|
||||
setUnreadCount((c) => (category ? Math.max(0, c - items.filter((it) => !it.read).length) : 0));
|
||||
try { await api.markAllNotificationsRead(category || null); } catch { /* reconciled on reload */ }
|
||||
if (refreshBell) refreshBell();
|
||||
fetchPage(0, true);
|
||||
};
|
||||
|
||||
// Live system notices (admin config hints) come from the shared context so they
|
||||
@@ -80,7 +105,7 @@ export default function Notifications() {
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={onMarkAll} disabled={!unreadCount}>
|
||||
<CheckCheck size={15} /> Mark all read
|
||||
<CheckCheck size={15} /> {category ? `Mark ${category} read` : 'Mark all read'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -108,6 +133,33 @@ export default function Notifications() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="sk-notif-page__chips">
|
||||
<div className="sk-notif-chipset" aria-label="Filter by category">
|
||||
{CATEGORY_CHIPS.map((c) => (
|
||||
<button
|
||||
type="button"
|
||||
key={c.key || 'all'}
|
||||
className={`sk-notif-chip${category === c.key ? ' is-active' : ''}`}
|
||||
onClick={() => setCategory(c.key)}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="sk-notif-chipset" aria-label="Filter by severity">
|
||||
{SEVERITY_CHIPS.map((s) => (
|
||||
<button
|
||||
type="button"
|
||||
key={s.key || 'any'}
|
||||
className={`sk-notif-chip sk-notif-chip--sev${severity === s.key ? ' is-active' : ''}`}
|
||||
onClick={() => setSeverity(s.key)}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && items.length === 0 && noticeItems.length === 0 ? (
|
||||
<div className="sk-notif-page__state">Loading…</div>
|
||||
) : items.length === 0 && noticeItems.length === 0 ? (
|
||||
@@ -148,7 +200,7 @@ export default function Notifications() {
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={item.delivery_id}
|
||||
className={`sk-notif-row${item.read ? '' : ' is-unread'}`}
|
||||
className={`sk-notif-row${item.read ? '' : ' is-unread'}${item.action_path ? ' is-linked' : ''}`}
|
||||
onClick={() => onItemClick(item)}
|
||||
>
|
||||
<span
|
||||
@@ -159,6 +211,9 @@ export default function Notifications() {
|
||||
<div className="sk-notif-row__body">
|
||||
<div className="sk-notif-row__title">{item.title}</div>
|
||||
{item.body && <div className="sk-notif-row__text">{item.body}</div>}
|
||||
{item.action_path && (
|
||||
<div className="sk-notif-row__action">{item.action_label || 'Open'} →</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="sk-notif-row__time">{timeAgo(item.created_at)}</span>
|
||||
</li>
|
||||
|
||||
@@ -8,6 +8,8 @@ export async function getInbox(params = {}) {
|
||||
if (params.limit) query.append('limit', params.limit);
|
||||
if (params.offset) query.append('offset', params.offset);
|
||||
if (params.unread) query.append('unread', '1');
|
||||
if (params.category) query.append('category', params.category);
|
||||
if (params.severity) query.append('severity', params.severity);
|
||||
const suffix = query.toString() ? `?${query}` : '';
|
||||
return this.request(`/notifications/inbox${suffix}`);
|
||||
}
|
||||
@@ -20,8 +22,58 @@ export async function markNotificationRead(deliveryId) {
|
||||
return this.request(`/notifications/inbox/${deliveryId}/read`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function markAllNotificationsRead() {
|
||||
return this.request('/notifications/inbox/read-all', { method: 'POST' });
|
||||
export async function markAllNotificationsRead(category = null) {
|
||||
const suffix = category ? `?category=${encodeURIComponent(category)}` : '';
|
||||
return this.request(`/notifications/inbox/read-all${suffix}`, { method: 'POST' });
|
||||
}
|
||||
|
||||
// --- Event catalog + org defaults (preference depth) ---
|
||||
|
||||
export async function getNotificationCatalog() {
|
||||
return this.request('/notifications/catalog');
|
||||
}
|
||||
|
||||
export async function getOrgNotificationDefaults() {
|
||||
return this.request('/notifications/admin/defaults');
|
||||
}
|
||||
|
||||
export async function updateOrgNotificationDefaults(defaults) {
|
||||
return this.request('/notifications/admin/defaults', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ defaults }),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Org chat/webhook connections (admin) ---
|
||||
|
||||
export async function getChatConnections() {
|
||||
return this.request('/notifications/admin/chat-connections');
|
||||
}
|
||||
|
||||
export async function addChatConnection(data) {
|
||||
return this.request('/notifications/admin/chat-connections', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateChatConnection(connectionId, data) {
|
||||
return this.request(`/notifications/admin/chat-connections/${connectionId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export async function testChatConnection(connectionId) {
|
||||
return this.request(`/notifications/admin/chat-connections/${connectionId}/test`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function setDefaultChatConnection(connectionId) {
|
||||
return this.request(`/notifications/admin/chat-connections/${connectionId}/default`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function deleteChatConnection(connectionId) {
|
||||
return this.request(`/notifications/admin/chat-connections/${connectionId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// --- Delivery log / ops (admin) ---
|
||||
|
||||
@@ -53,6 +53,31 @@
|
||||
gap: $space-4;
|
||||
}
|
||||
|
||||
// "Email bouncing" warning shown in the Email Settings card when a user's
|
||||
// address is auto-muted after repeated hard bounces (plan 33 Phase 4).
|
||||
.notif-bounce-warning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $space-3;
|
||||
flex-wrap: wrap;
|
||||
margin-top: $space-3;
|
||||
padding: $space-3;
|
||||
background: $danger-bg;
|
||||
border: 1px solid $danger-border;
|
||||
border-radius: $radius-md;
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-2;
|
||||
flex-wrap: wrap;
|
||||
font-size: $font-size-sm;
|
||||
color: $text-secondary;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.channel-toggles,
|
||||
.category-toggles {
|
||||
display: flex;
|
||||
@@ -270,3 +295,212 @@
|
||||
padding-top: $space-4;
|
||||
border-top: 1px solid $border-subtle;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PER-EVENT OVERRIDE MATRIX + ORG DEFAULTS (plan 24 Phase 2)
|
||||
// ============================================
|
||||
|
||||
.notif-event-matrix {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-2;
|
||||
}
|
||||
|
||||
.notif-event-category {
|
||||
border: 1px solid $border-subtle;
|
||||
border-radius: $radius-md;
|
||||
overflow: hidden;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-3;
|
||||
width: 100%;
|
||||
padding: $space-3 $space-4;
|
||||
background: $bg-card;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: $text-primary;
|
||||
text-align: left;
|
||||
|
||||
&:hover { background: $bg-hover; }
|
||||
|
||||
.expand-icon {
|
||||
margin-left: auto;
|
||||
transition: transform 0.15s ease;
|
||||
&.expanded { transform: rotate(180deg); }
|
||||
}
|
||||
}
|
||||
|
||||
&__name { font-weight: 600; }
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-top: 1px solid $border-subtle;
|
||||
}
|
||||
}
|
||||
|
||||
.notif-event-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $space-4;
|
||||
padding: $space-3 $space-4;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&:not(:last-child) { border-bottom: 1px solid $border-subtle; }
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
&__title { color: $text-primary; font-size: 0.9rem; }
|
||||
|
||||
&__key {
|
||||
color: $text-tertiary;
|
||||
font-size: 0.75rem;
|
||||
font-family: $font-mono;
|
||||
}
|
||||
|
||||
&__channels {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-2;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__reset {
|
||||
background: transparent;
|
||||
border: 1px solid $border-subtle;
|
||||
border-radius: $radius-sm;
|
||||
color: $text-secondary;
|
||||
padding: $space-1 $space-2;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) { background: $bg-hover; color: $text-primary; }
|
||||
&:disabled { opacity: 0.4; cursor: default; }
|
||||
}
|
||||
}
|
||||
|
||||
.notif-chan-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $space-1;
|
||||
padding: $space-1 $space-2;
|
||||
border: 1px solid $border-subtle;
|
||||
border-radius: $radius-sm;
|
||||
background: $bg-card;
|
||||
color: $text-secondary;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
text-transform: capitalize;
|
||||
|
||||
&__state {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&--inherit { border-style: dashed; color: $text-tertiary; }
|
||||
|
||||
&--on {
|
||||
background: $accent-glow;
|
||||
border-color: $accent-primary;
|
||||
color: $accent-primary;
|
||||
}
|
||||
|
||||
&--off {
|
||||
background: fade($danger, 12%);
|
||||
border-color: fade($danger, 40%);
|
||||
color: $danger;
|
||||
}
|
||||
|
||||
&:hover { border-color: $border-default; }
|
||||
}
|
||||
|
||||
.notif-org-defaults {
|
||||
.notif-defaults-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-1;
|
||||
margin: $space-3 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.notif-defaults-grid__head,
|
||||
.notif-defaults-grid__row {
|
||||
display: grid;
|
||||
grid-template-columns: 140px repeat(5, minmax(64px, 1fr));
|
||||
align-items: center;
|
||||
gap: $space-2;
|
||||
}
|
||||
|
||||
.notif-defaults-grid__head {
|
||||
padding-bottom: $space-2;
|
||||
border-bottom: 1px solid $border-subtle;
|
||||
}
|
||||
|
||||
.notif-defaults-grid__chan {
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
color: $text-secondary;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.notif-defaults-grid__row {
|
||||
padding: $space-2 0;
|
||||
&:not(:last-child) { border-bottom: 1px solid $border-subtle; }
|
||||
}
|
||||
|
||||
.notif-defaults-grid__cat {
|
||||
font-size: 0.85rem;
|
||||
color: $text-primary;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.notif-defaults-grid__cell {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
// Email digest cadence selector (plan 24 Phase 3)
|
||||
.notif-digest-options {
|
||||
display: flex;
|
||||
gap: $space-3;
|
||||
flex-wrap: wrap;
|
||||
margin-top: $space-2;
|
||||
}
|
||||
|
||||
.notif-digest-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1 1 140px;
|
||||
padding: $space-3 $space-4;
|
||||
background: $bg-card;
|
||||
border: 1px solid $border-subtle;
|
||||
border-radius: $radius-md;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: $text-secondary;
|
||||
|
||||
&:hover { border-color: $border-default; }
|
||||
|
||||
&.active {
|
||||
border-color: $accent-primary;
|
||||
background: $accent-glow;
|
||||
color: $accent-primary;
|
||||
}
|
||||
|
||||
&__label { font-weight: 600; font-size: 0.9rem; }
|
||||
&__hint { font-size: 0.75rem; color: $text-tertiary; }
|
||||
|
||||
&.active &__hint { color: $accent-primary; opacity: 0.85; }
|
||||
}
|
||||
@@ -134,13 +134,6 @@
|
||||
margin: 0 auto;
|
||||
padding: 20px 24px 40px;
|
||||
|
||||
&__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
&__filters {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
@@ -252,10 +245,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.sk-dlog__stats { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
// Email transport / provider management (sk-eprov).
|
||||
.sk-eprov {
|
||||
background: $bg-card;
|
||||
@@ -392,3 +381,48 @@
|
||||
@media (max-width: 720px) {
|
||||
.sk-eprov__grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
// ── Deep links + history filters (plan 24 Phase 5) ──
|
||||
.sk-notif-page__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $space-4;
|
||||
padding: 0 0 $space-3;
|
||||
margin-bottom: $space-2;
|
||||
border-bottom: 1px solid $border-subtle;
|
||||
}
|
||||
|
||||
.sk-notif-chipset {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $space-2;
|
||||
}
|
||||
|
||||
.sk-notif-chip {
|
||||
padding: 3px 10px;
|
||||
font-size: $font-size-xs;
|
||||
border: 1px solid $border-subtle;
|
||||
border-radius: 999px;
|
||||
background: $bg-card;
|
||||
color: $text-secondary;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover { background: $bg-hover; color: $text-primary; }
|
||||
|
||||
&.is-active {
|
||||
border-color: $accent-primary;
|
||||
background: $accent-glow;
|
||||
color: $accent-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.sk-notif-row.is-linked { cursor: pointer; }
|
||||
|
||||
.sk-notif-row__action {
|
||||
margin-top: 3px;
|
||||
font-size: $font-size-xs;
|
||||
color: $accent-primary;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sk-notif__cta { color: $accent-primary; }
|
||||
Reference in new issue
Block a user