-- Meldungen und Benachrichtigungen (Phase 14). -- -- Das Meldungswesen hat einen Feind, und es ist nicht der fehlende Alarm: Es ist -- der Alarm, den niemand mehr liest. Ein System, das jede Minute dieselbe -- Meldung erzeugt, wird nach drei Tagen weggeklickt — und dann fehlt die eine, -- auf die es ankam. -- -- Das Schema ist deshalb um zwei Eigenschaften herum gebaut: -- -- 1. **Eine Ursache, eine Meldung.** Der Fingerabdruck macht die Doppelung -- unmoeglich, nicht die Anwendungslogik. -- 2. **Meldungen loesen sich selbst auf.** Verschwindet die Ursache, schliesst -- sich die Meldung. Was von Hand geschlossen werden muss, sammelt sich an. -- --------------------------------------------------------------------------- -- Meldungen -- --------------------------------------------------------------------------- CREATE TABLE alerts ( -- id ist der oeffentliche Bezeichner. id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- rule_name benennt die ausloesende Regel. rule_name TEXT NOT NULL, -- severity ist der Schweregrad. severity TEXT NOT NULL, -- status ist der Bearbeitungszustand. status TEXT NOT NULL DEFAULT 'open', -- fingerprint kennzeichnet die Ursache eindeutig. -- -- Er entsteht aus Regel und betroffenem Gegenstand, nicht aus dem Zeitpunkt. -- Genau deshalb erzeugt derselbe Fehlschlag beim zweiten Durchgang keine -- zweite Meldung, sondern aktualisiert die erste. fingerprint TEXT NOT NULL, -- title ist die Ueberschrift. title TEXT NOT NULL, -- message erklaert den Befund und die naechste Handlung. -- -- Eine Meldung ohne Handlungsanweisung ist eine Beunruhigung. Der Text nennt -- deshalb immer, was zu tun ist. message TEXT NOT NULL, -- entity_type benennt die Art des betroffenen Gegenstands. entity_type TEXT, -- entity_id ist der betroffene Gegenstand. entity_id UUID, -- entity_name ist sein sprechender Name. -- -- Fest gespeichert und nicht ueber einen Fremdschluessel gelesen: Die -- Meldung muss auch dann noch lesbar sein, wenn der Auftrag geloescht wurde. entity_name TEXT, -- details traegt die Messwerte des Befunds. details JSONB, -- occurrence_count zaehlt, wie oft die Ursache seither auftrat. -- -- Statt zehn gleicher Meldungen gibt es eine mit einem Zaehler. Die Zahl ist -- die eigentliche Auskunft: Einmal ist ein Zwischenfall, zwanzigmal ein -- Zustand. occurrence_count INTEGER NOT NULL DEFAULT 1, -- first_seen_at ist das erste Auftreten in UTC. first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- last_seen_at ist das letzte Auftreten in UTC. last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- acknowledged_at ist der Zeitpunkt der Kenntnisnahme in UTC. acknowledged_at TIMESTAMPTZ, -- acknowledged_by benennt den Bestaetigenden. acknowledged_by UUID REFERENCES users(id) ON DELETE SET NULL, -- acknowledgement_note haelt eine Bemerkung des Bestaetigenden fest. acknowledgement_note TEXT, -- resolved_at ist der Zeitpunkt der Aufloesung in UTC. resolved_at TIMESTAMPTZ, -- resolved_by benennt den Aufloesenden; NULL bei automatischer Aufloesung. -- -- Der Unterschied ist wichtig: Eine von selbst verschwundene Ursache ist -- etwas anderes als eine weggeklickte Meldung. resolved_by UUID REFERENCES users(id) ON DELETE SET NULL, -- resolution_note begruendet die Aufloesung. resolution_note TEXT, -- created_at ist der Anlagezeitpunkt in UTC. created_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT alerts_severity_valid CHECK (severity IN ('information', 'warning', 'high', 'critical')), CONSTRAINT alerts_status_valid CHECK (status IN ('open', 'acknowledged', 'resolved')), -- Eine aufgeloeste Meldung braucht einen Zeitpunkt, sonst fehlt sie in jeder -- Auswertung. CONSTRAINT alerts_resolved_has_time CHECK ( status <> 'resolved' OR resolved_at IS NOT NULL ), -- Eine bestaetigte Meldung braucht einen Bestaetigenden. „Jemand hat es -- gesehen" ohne Namen ist keine Kenntnisnahme. CONSTRAINT alerts_acknowledged_has_actor CHECK ( acknowledged_at IS NULL OR acknowledged_by IS NOT NULL ), CONSTRAINT alerts_occurrence_positive CHECK (occurrence_count > 0) ); -- **Der wichtigste Index dieser Migration.** -- -- Er macht eine zweite offene Meldung zur selben Ursache unmoeglich. Ohne ihn -- muesste die Anwendung vor jedem Schreiben nachsehen — und zwischen Nachsehen -- und Schreiben passt ein zweiter Control-Server. CREATE UNIQUE INDEX alerts_single_open_idx ON alerts (fingerprint) WHERE status IN ('open', 'acknowledged'); CREATE INDEX alerts_status_severity_idx ON alerts (status, severity, last_seen_at DESC); CREATE INDEX alerts_entity_idx ON alerts (entity_type, entity_id); CREATE INDEX alerts_created_idx ON alerts (created_at DESC); -- --------------------------------------------------------------------------- -- Benachrichtigungskanaele -- --------------------------------------------------------------------------- CREATE TABLE notification_channels ( -- id ist der oeffentliche Bezeichner. id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- name ist die sprechende Bezeichnung. name TEXT NOT NULL UNIQUE, -- channel_type ist die Art der Zustellung. channel_type TEXT NOT NULL, -- enabled meldet einen aktiven Kanal. enabled BOOLEAN NOT NULL DEFAULT true, -- minimum_severity ist der niedrigste zugestellte Schweregrad. -- -- Ohne diese Grenze bekaeme der Bereitschaftsdienst nachts jede Information. -- Nach einer Woche schaltet er die Benachrichtigungen ab, und dann kommt -- auch die kritische nicht mehr an. minimum_severity TEXT NOT NULL DEFAULT 'high', -- configuration traegt die Zustelldaten. -- -- Zugangsdaten stehen **nicht** hier, sondern verschluesselt in -- credentials_ciphertext (PROMPT.md §140: keine Secrets im Klartext). configuration JSONB NOT NULL, -- credentials_ciphertext ist das verschluesselte Zugangsgeheimnis. credentials_ciphertext BYTEA, -- credentials_key_version benennt den verwendeten Schluessel. credentials_key_version TEXT, -- last_delivery_at ist der Zeitpunkt der letzten Zustellung in UTC. last_delivery_at TIMESTAMPTZ, -- last_delivery_error ist der Grund des letzten Fehlschlags. -- -- Ein Kanal, der seit Wochen nichts zustellt, ist genauso schlimm wie eine -- fehlende Meldung — und ohne dieses Feld faellt es niemandem auf. last_delivery_error TEXT, -- consecutive_failures zaehlt die Fehlschlaege in Folge. consecutive_failures INTEGER NOT NULL DEFAULT 0, -- created_by benennt den Anlegenden. created_by UUID REFERENCES users(id) ON DELETE SET NULL, -- created_at ist der Anlagezeitpunkt in UTC. created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- updated_at ist der Zeitpunkt der letzten Aenderung in UTC. updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT notification_channels_type_valid CHECK (channel_type IN ('email', 'webhook')), CONSTRAINT notification_channels_severity_valid CHECK (minimum_severity IN ('information', 'warning', 'high', 'critical')), CONSTRAINT notification_channels_failures_not_negative CHECK (consecutive_failures >= 0), -- Ein verschluesseltes Geheimnis ohne Schluesselversion liesse sich nicht -- mehr entschluesseln. CONSTRAINT notification_channels_credentials_have_version CHECK ( credentials_ciphertext IS NULL OR credentials_key_version IS NOT NULL ) ); CREATE INDEX notification_channels_enabled_idx ON notification_channels (enabled) WHERE enabled = true; -- --------------------------------------------------------------------------- -- Zustellungen -- --------------------------------------------------------------------------- -- Jede Zustellung wird festgehalten — auch die gescheiterte. Die Frage „ist die -- Meldung von gestern Nacht rausgegangen?" muss beantwortbar sein, und zwar -- ohne im Protokoll zu suchen. CREATE TABLE notification_deliveries ( -- id ist der fortlaufende Bezeichner. id BIGSERIAL PRIMARY KEY, -- alert_id ist die zugestellte Meldung. alert_id UUID NOT NULL REFERENCES alerts(id) ON DELETE CASCADE, -- channel_id ist der verwendete Kanal. channel_id UUID NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE, -- status ist der Ausgang der Zustellung. status TEXT NOT NULL, -- attempt_number ist die Nummer des Versuchs. attempt_number INTEGER NOT NULL DEFAULT 1, -- error_message ist der Grund eines Fehlschlags. error_message TEXT, -- delivered_at ist der Zeitpunkt in UTC. delivered_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT notification_deliveries_status_valid CHECK (status IN ('sent', 'failed', 'skipped')), -- Ein Fehlschlag ohne Grund liesse sich nicht beheben. CONSTRAINT notification_deliveries_failure_has_reason CHECK ( status <> 'failed' OR error_message IS NOT NULL ) ); CREATE INDEX notification_deliveries_alert_idx ON notification_deliveries (alert_id); CREATE INDEX notification_deliveries_channel_idx ON notification_deliveries (channel_id, delivered_at DESC); -- Dieselbe Meldung wird ueber denselben Kanal nur einmal erfolgreich zugestellt. -- Ohne diese Sperre erzeugte jeder Durchgang der Auswertung eine neue Mail zur -- selben, weiterhin offenen Meldung. CREATE UNIQUE INDEX notification_deliveries_once_idx ON notification_deliveries (alert_id, channel_id) WHERE status = 'sent';