diff --git a/.env.example b/.env.example index 787a36e..614d955 100644 --- a/.env.example +++ b/.env.example @@ -97,6 +97,29 @@ SMTP_ABSENDER= # true bei Port 465 (SMTPS), false bei 587 mit STARTTLS. SMTP_TLS=false +# --- Integrationen --------------------------------------------------------- +# Alles freiwillig. Was fehlt, erscheint unter "Integrationen" als nicht +# eingerichtet; der Rest laeuft weiter. +# Welche Integration eine Instanz ueberhaupt darf, entscheidet INSTANZ: +# Zammad und Wartungsplaner nur "firma", Telegram nur "daheim". + +# Zammad (nur firma). Token in Zammad unter Profil > Token-Zugriff anlegen; +# er sieht genau das, was der zugehoerige Benutzer sehen darf. +ZAMMAD_URL= +ZAMMAD_TOKEN= +# Suchausdruck in Zammads Syntax. Was darauf passt, wird zur Aufgabe. +ZAMMAD_ABFRAGE=owner_id:me AND (state.name:new OR state.name:open) + +# Telegram (nur daheim). Bot bei @BotFather anlegen, Token hier eintragen. +# Welche Chats mitreden duerfen, wird in der Anwendung gekoppelt. +TELEGRAM_TOKEN= + +# Spiegel: firma sendet, daheim empfaengt. Dasselbe Geheimnis auf beiden +# Seiten, mindestens 32 Zeichen: openssl rand -base64 32 +SPIEGEL_GEHEIMNIS= +# Nur auf der sendenden Seite (firma): die Adresse der anderen Instanz. +SPIEGEL_ZIEL= + # --- Sonstiges ------------------------------------------------------------- # Zeitzone fuer Anzeigen und Tagesgrenzen. TZ=Europe/Berlin diff --git a/CHANGELOG.md b/CHANGELOG.md index 73f382a..61b5963 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,49 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/). ## [Unveröffentlicht] +## [0.6.0] – 2026-09-12 + +### Hinzugefügt + +- **Zammad** (nur `firma`): Tickets, auf die der Suchausdruck passt, werden zu + Aufgaben — mit Verweis ins Ticket. Wird ein Ticket dort geschlossen, hakt + der Planer die Aufgabe ab. +- **Telegram** (nur `daheim`): Eine Nachricht an den Bot wird zur Aufgabe. + Meldungen gehen denselben Weg zurück, mit einem Knopf zum Abhaken. Ein Chat + meldet sich über einen Einmalcode an, der zehn Minuten gilt. +- **Telegram als dritter Meldeweg** neben Push und E-Mail. +- **Spiegel** zwischen den Instanzen: `firma` schickt Belegungen ohne Titel + und die einzeln dafür markierten Aufgaben, `daheim` empfängt. Gespiegelte + Belegung erscheint in der Wochenansicht als „Dienstlich belegt" und zählt + gegen die Tageskapazität. +- Seite **Integrationen**: je Integration Zustand, letzter Erfolg, letzter + Fehler und ein Knopf, der einen Lauf sofort anstößt. +- Handbuchseite zu den Integrationen. + +### Geändert + +- Was aus einem Fund wird, entscheidet für alle Integrationen dieselbe + Stelle. Geführt werden nur Titel, Notiz, Verweis und Fälligkeit — Bereich, + Projekt, Priorität und geschätzter Aufwand gehören der Planung und werden + nie von außen überschrieben. +- Eine klemmende Integration meldet sich wie ein klemmender Kalender: erst + nach dem dritten Fehlversuch in Folge. +- Die Demo-Instanz für die Bilder spricht nicht mehr nach außen. + +### Sicherheit + +- Wer dem Telegram-Bot schreibt, ohne verbunden zu sein, bekommt **keine + Antwort** — auch keine Fehlermeldung. Sonst ließe sich herausfinden, dass + es diesen Planer gibt. +- Jede Sendung des Spiegels trägt eine Signatur über den Rumpf und ist nach + fünf Minuten wertlos. Der Empfänger antwortet auf jeden Fehlschlag gleich: + falsches Geheimnis, verstellte Uhr und Unsinn im Inhalt sind von außen + nicht zu unterscheiden. +- Der Signaturvergleich läuft in gleichbleibender Zeit. +- Aufgaben verlassen die Instanz nur mit ausdrücklicher Freigabe je Aufgabe. + Ein Schalter für einen ganzen Bereich würde zu leicht Kundennamen nach + Hause schicken. + ## [0.5.0] – 2026-09-11 ### Hinzugefügt diff --git a/README.md b/README.md index 8fe37b7..8056d0e 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,12 @@ wird. Bereich, wie voll der heutige Tag schon ist - **Wochenplanung** per Ziehen und Ablegen: Aufgaben werden zu echten Kalenderterminen, Verschiebungen im Kalender kommen zurück -- **Benachrichtigungen** per Web-Push und E-Mail: Tagesüberblick, fällige - Aufgaben, Vorlauf vor einem Zeitblock — mit Ruhezeiten und ab Werk still +- **Benachrichtigungen** per Web-Push, E-Mail und Telegram: Tagesüberblick, + fällige Aufgaben, Vorlauf vor einem Zeitblock — mit Ruhezeiten und ab Werk + still +- **Integrationen**: Zammad-Tickets werden zu Aufgaben, Telegram erfasst und + hakt ab, und der Spiegel bringt dienstliche Belegung nach Hause — ohne + Titel - Anmeldung über einen OIDC-Anbieter, über Passkeys oder mit E-Mail und Passwort — je Instanz einzeln zuschaltbar - Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird @@ -45,7 +49,7 @@ wird. ### Geplant -Integrationen, Schnellerfassung vom Handy und ein MCP-Server. +Schnellerfassung vom Handy, eine öffentliche API und ein MCP-Server. Siehe [ROADMAP.md](ROADMAP.md). ## Bildergalerie @@ -63,6 +67,7 @@ Siehe [ROADMAP.md](ROADMAP.md). | Bereiche | ![Bereiche, hell](docs/bilder/bereiche-hell-schreibtisch.png) | ![Bereiche, dunkel](docs/bilder/bereiche-dunkel-schreibtisch.png) | | Einstellungen | ![Einstellungen, hell](docs/bilder/einstellungen-hell-schreibtisch.png) | ![Einstellungen, dunkel](docs/bilder/einstellungen-dunkel-schreibtisch.png) | | Meldungen | ![Benachrichtigungen, hell](docs/bilder/benachrichtigungen-hell-schreibtisch.png) | ![Benachrichtigungen, dunkel](docs/bilder/benachrichtigungen-dunkel-schreibtisch.png) | +| Integrationen | ![Integrationen, hell](docs/bilder/integrationen-hell-schreibtisch.png) | ![Integrationen, dunkel](docs/bilder/integrationen-dunkel-schreibtisch.png) | | Protokoll | ![Protokoll, hell](docs/bilder/protokoll-hell-schreibtisch.png) | ![Protokoll, dunkel](docs/bilder/protokoll-dunkel-schreibtisch.png) | Alle Bilder entstehen mit `npm run screenshots` in einer Wegwerf-Instanz mit @@ -171,6 +176,44 @@ Klartextadresse nicht. Was der Planer wann meldet, steht im [Handbuch](docs/handbuch/benachrichtigungen.md). +### Integrationen + +Welche Integration eine Instanz überhaupt darf, entscheidet `INSTANZ` — nicht +die Konfiguration. Ein Zammad-Token in der Instanz `daheim` bleibt wirkungslos. + +| Variable | Standard | Bedeutung | +| ------------------- | ------------------- | ---------------------------------------------------------------- | +| `ZAMMAD_URL` | _(leer)_ | Adresse der Zammad-Instanz, nur `firma` | +| `ZAMMAD_TOKEN` | _(leer)_ | Token aus **Profil → Token-Zugriff** | +| `ZAMMAD_ABFRAGE` | `owner_id:me AND …` | Suchausdruck; was darauf passt, wird zur Aufgabe | +| `TELEGRAM_TOKEN` | _(leer)_ | Bot-Token von [@BotFather](https://t.me/BotFather), nur `daheim` | +| `SPIEGEL_GEHEIMNIS` | _(leer)_ | Gemeinsames Geheimnis beider Instanzen | +| `SPIEGEL_ZIEL` | _(leer)_ | Adresse der empfangenden Instanz; nur auf der sendenden Seite | + +**Zammad** liest nur. Ein Ticket schließt man im Ticketsystem — sonst weiß die +Kollegin nicht, warum es plötzlich zu ist. Umgekehrt schließt der Planer eine +Aufgabe, sobald Zammad das Ticket als erledigt meldet. + +**Telegram** braucht keinen Webhook und damit keine von außen erreichbare +Adresse. Welche Chats mitreden dürfen, wird in der Anwendung unter +**Integrationen** gekoppelt: Knopf drücken, Code an den Bot schicken, fertig. +Nachrichten aus unbekannten Chats werden verworfen, ohne zu antworten. + +**Der Spiegel** geht in eine Richtung: `firma` sendet, `daheim` empfängt. +Hinüber gehen Belegungen **ohne Titel** — daheim ist zu sehen, _dass_ die Zeit +weg ist, nicht woran gearbeitet wird — und jene Aufgaben, die einzeln zum +Spiegeln markiert wurden. Jede Sendung ist mit dem gemeinsamen Geheimnis +signiert und älter als fünf Minuten wertlos. + +Das Geheimnis erzeugst du wie das Sitzungsgeheimnis: + +```bash +openssl rand -base64 32 +``` + +Was der Alltag damit anfängt, steht im +[Handbuch](docs/handbuch/integrationen.md). + ### Einrichtung und Sonstiges | Variable | Standard | Bedeutung | diff --git a/ROADMAP.md b/ROADMAP.md index 05b5db6..4b4f711 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,7 +10,7 @@ grünen Prüfungen, aktueller Dokumentation und einem Eintrag im DEVLOG. | [M3](#m3--kalender-lesen) | Kalender lesen | erledigt | | [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | erledigt | | [M5](#m5--benachrichtigungen) | Benachrichtigungen | erledigt | -| [M6](#m6--integrationen) | Integrationen | geplant | +| [M6](#m6--integrationen) | Integrationen | erledigt | | [M7](#m7--schnellerfassung-und-api) | Schnellerfassung und API | geplant | | [M8](#m8--mcp) | MCP | geplant | @@ -112,10 +112,25 @@ Erinnerung an einen Zeitblock, der längst begonnen hat, hilft niemandem. ## M6 – Integrationen -- [ ] Zammad und Wartungsplaner-API (nur `firma`) -- [ ] Telegram (nur `daheim`) -- [ ] Spiegel: `firma` sendet, `daheim` empfängt -- [ ] Jede Integration upsertet über `[quelle, quelleRef]` +**Erledigt am 12.09.2026 mit Version 0.6.0.** + +**Ziel:** Aufgaben dort abholen, wo sie entstehen — ohne dass die Planung +darunter leidet, die hier passiert. + +- [x] Gemeinsame Aufnahmestelle: jede Integration liefert nur Funde +- [x] Zammad (nur `firma`), lesend +- [ ] **Wartungsplaner-API** — Schnittstelle steht noch nicht fest +- [x] Telegram (nur `daheim`): erfassen, melden, abhaken +- [x] Spiegel: `firma` sendet, `daheim` empfängt +- [x] Jede Integration upsertet über `[quelle, quelleRef]` + +**Offen geblieben:** Der **Wartungsplaner** ist nicht angebunden. Die +Aufnahmestelle steht bereit und die Seite „Integrationen" führt ihn als +sichtbare Lücke; es fehlt allein das Abholen, und dafür muss erst feststehen, +was das Produkt an Schnittstelle hergibt. + +**Bewusst offen gelassen:** Zammad wird nur gelesen. Ein Ticket schließt man +im Ticketsystem — sonst weiß die Kollegin nicht, warum es plötzlich zu ist. ## M7 – Schnellerfassung und API diff --git a/devlog/DEVLOG.md b/devlog/DEVLOG.md index 631a68e..70a36a8 100644 --- a/devlog/DEVLOG.md +++ b/devlog/DEVLOG.md @@ -5,14 +5,122 @@ die Übersicht aus `scripts/zeit.sh summe`. ## Zeit je Meilenstein -| Meilenstein | Art | Minuten | Stunden | -| ----------- | ------ | ------- | --------- | -| M1 | claude | 161 | 2,68 | -| M2 | claude | 44 | 0,73 | -| M3 | claude | 41 | 0,68 | -| M4 | claude | 167 | 2,78 | -| M5 | claude | 420 | 7,00 | -| **Gesamt** | | **833** | **13,88** | +| Meilenstein | Art | Minuten | Stunden | +| ----------- | ------ | -------- | --------- | +| M1 | claude | 161 | 2,68 | +| M2 | claude | 44 | 0,73 | +| M3 | claude | 41 | 0,68 | +| M4 | claude | 167 | 2,78 | +| M5 | claude | 420 | 7,00 | +| M6 | claude | 733 | 12,22 | +| **Gesamt** | | **1566** | **26,10** | + +--- + +## 2026-09-12 – M6: Integrationen + +**Dauer:** 733 Minuten (20:47–09:00), Art `claude`. + +> Die Zahl täuscht. Ein großer Teil davon war Warten auf Docker-Bauten, die +> in der Nacht dreimal hängen blieben — einmal 39 Minuten ohne eine Zeile +> Ausgabe. Die eigentliche Arbeit war knapper. + +**Ziel der Sitzung:** Aufgaben dort abholen, wo sie entstehen — ohne dass die +Planung darunter leidet, die hier passiert. + +### Erledigt + +- Gemeinsame Aufnahmestelle für alle Integrationen (21 Tests) +- Zammad, lesend, mit Suchausdruck aus der Konfiguration +- Telegram: erfassen, melden, abhaken — mit Kopplung über Einmalcode +- Spiegel zwischen den Instanzen: Belegung ohne Titel, Aufgaben nur markiert +- Seite „Integrationen" mit Zustand, Fehler und Knopf für den Sofortlauf +- Gespiegelte Belegung in der Wochenansicht +- 4 neue E2E-Tests, 9 neue Integrationsprüfungen, Handbuchseite + +### Entscheidungen mit Begründung + +**Das Ursprungssystem führt Titel, Notiz, Verweis und Fälligkeit — mehr +nicht.** Bereich, Projekt, Priorität und geschätzter Aufwand gehören der +Planung. Wer ein Ticket einsortiert und schätzt, muss sich darauf verlassen +können, dass der nächste Abgleich das nicht wegräumt. Ohne diese Trennung +wäre die Wochenplanung nach jedem Lauf kaputt. + +**Zammad wird nur gelesen.** Ein Ticket schließt man im Ticketsystem, sonst +weiß die Kollegin nicht, warum es plötzlich zu ist. Umgekehrt darf Zammad +hier abhaken — dort ist die Wahrheit über den Zustand des Tickets. + +**Telegram holt ab, statt einen Webhook zu bekommen.** Ein Webhook verlangt +eine von außen erreichbare Adresse mit gültigem Zertifikat. Der Planer soll +auch hinter einem Anschluss ohne feste Adresse laufen, und der Preis dafür +sind zwei Minuten Verzögerung. + +**Unbekannte Chats bekommen keine Antwort — auch keine Fehlermeldung.** Wer +die Kennung des Bots kennt, könnte ihm sonst schreiben und daran erkennen, +dass es diesen Planer gibt. Die einzige Ausnahme ist ein gültiger +Kopplungscode. + +**Der Kopplungscode entstand aus einem Widerspruch.** Erst sollte die +Chatkennung von Hand eingetragen werden. Nur: Um sie zu erfahren, müsste der +Bot Unbekannten antworten — genau das, was er nicht darf. Der Code dreht die +Richtung um: Er kommt aus der Anwendung, in die man sich anmelden muss. + +**Der Spiegel schickt Belegung ohne Titel.** Daheim soll zu sehen sein, +_dass_ die Zeit weg ist, nicht woran gearbeitet wird. Aufgaben gehen nur +einzeln markiert mit — ein Schalter für einen ganzen Bereich würde zu leicht +Kundennamen nach Hause schicken. + +**Was der Sender nicht mehr schickt, verschwindet beim Empfänger.** Sonst +stünde daheim für immer eine Sperre, die es längst nicht mehr gibt. Für +Aufgaben gilt das nicht: Sie sind Planung, keine Sperre. + +**Jede Sendung ist fünf Minuten gültig.** Ohne Frist ließe sich ein +abgefangenes Paket beliebig oft erneut einspielen. Der Empfänger antwortet +auf jeden Fehlschlag gleich — falsches Geheimnis, verstellte Uhr und Unsinn +im Inhalt sind von außen nicht zu unterscheiden. + +**Die Demo-Instanz redet mit niemandem.** Sie ist mit erfundenen Daten +gefüllt und steht nur für Bilder. Solange `DEMO_MODUS` gilt, starten die +Jobs für Zammad, Telegram und den Spiegel gar nicht erst. + +### Probleme und Lösungen + +| Problem | Lösung | +| ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Die Kopfleiste verschluckte mit dem elften Eintrag „Einstellungen" und „Protokoll" | Sie ist jetzt breiter als die Inhaltsspalte. Eine Leiste, die Punkte stillschweigend abschneidet, ist schlimmer als eine, die nicht bündig mit dem Text steht. Gefunden im aufgenommenen Bild. | +| Der Kartentext erklärte noch den Weg von vor dem Kopplungscode | Ersetzt. Ebenfalls erst im Bild aufgefallen — im Code war beides für sich stimmig. | +| Ein Kürzungstest schlug an der Grenze fehl | Nicht der Test war falsch, sondern der Vergleich: Eine Worttrennung bei genau der halben Titellänge wurde abgelehnt. Jetzt `>=`. | +| Ein regulärer Ausdruck ging beim Schreiben über die Shell kaputt | Das Muster steht jetzt als benannte Konstante im Code, geschrieben mit dem Edit-Werkzeug statt über ein Skript. | +| Ein unsichtbares Zeichen (U+200B) landete mitten in einem Funktionsnamen | Entfernt und die Datei danach abgesucht. | +| Das Aufräumen im Integrationstest hätte fremde Telegram-Chats treffen können | Es trifft jetzt genau die Kennung, die der Lauf selbst angelegt hat. | +| Docker-Bauten blieben dreimal hängen, einmal 39 Minuten ohne Ausgabe | Kein Codeproblem: Nach Abbruch und Neustart lief derselbe Bau in Minuten durch. Die Bilder mussten deshalb viermal angestoßen werden. | + +### Offene Punkte + +- **Der Wartungsplaner ist nicht angebunden.** Die Aufnahmestelle steht + bereit und die Seite führt ihn als sichtbare Lücke; es fehlt allein das + Abholen. Dafür muss erst feststehen, was das Produkt an Schnittstelle + hergibt. +- Zammad und Telegram sind gegen die Logik geprüft, nicht gegen die echten + Dienste — dafür fehlen Zugangsdaten. Der Spiegel dagegen ist end-to-end + geprüft, weil beide Enden dieselbe Anwendung sind. +- Ein Telegram-Chat lässt sich nicht umbenennen. Fällt das im Alltag auf, + ist es ein Einzeiler. + +### Commits + +- 7f8f505 feat: Datenmodell und Umgebung für Integrationen +- e4d3547 feat: Aufnahmeregeln für Integrationen als reine Logik +- 6dad333 feat: Zammad, Telegram und den Spiegel anbinden +- bc60e5f feat: Integrationen takten und Telegram als dritten Meldeweg +- 1281c3e feat: Integrationen sichtbar und bedienbar machen +- 0d0f850 feat: Gespiegelte Belegung in der Wochenansicht zeigen +- 4da2f3d test: Spiegel-Eingang und Integrationstabellen absichern +- 3befcc1 feat: Aufgaben einzeln zum Spiegeln freigeben +- f5eadef feat: Lauf von Hand anstoßen und Spiegelschalter in der Liste +- 3c7cdbb chore: Demo-Daten, Bildaufnahme und Platz in der Kopfleiste +- 475b1d5 docs: Handbuchseite, README und Fahrplan für M6 +- 98d170b docs: Handbuchbilder für M6 neu aufnehmen --- diff --git a/devlog/zeiten.csv b/devlog/zeiten.csv index fa1b7eb..cf5cdc3 100644 --- a/devlog/zeiten.csv +++ b/devlog/zeiten.csv @@ -4,3 +4,4 @@ datum;start;ende;minuten;meilenstein;art;notiz 2026-09-10;17:48;18:29;41;M3;claude;M3 Kalender lesen fertig 2026-09-10;20:42;23:29;167;M4;claude;M4 Wochenplanung fertig 2026-09-11;06:35;13:35;420;M5;claude;M5 Benachrichtigungen fertig +2026-09-11;20:47;09:00;733;M6;claude;M6 Integrationen fertig diff --git a/docker-compose.screenshots.yml b/docker-compose.screenshots.yml index f226003..6f0f2f9 100644 --- a/docker-compose.screenshots.yml +++ b/docker-compose.screenshots.yml @@ -68,5 +68,10 @@ services: # moeglich, hinaus geht trotzdem nichts. SMTP_HOST: smtp.planer.invalid SMTP_ABSENDER: Planer + # Erfundene Werte, damit die Seite "Integrationen" den eingerichteten + # Fall zeigt. Nach draussen geht davon nichts: Die Jobs schweigen, + # solange DEMO_MODUS true ist. + TELEGRAM_TOKEN: 000000:demo-token-ohne-funktion + SPIEGEL_GEHEIMNIS: demo-geheimnis-ohne-funktion ports: - '${SCREENSHOT_APP_PORT:-3200}:3000' diff --git a/docs/bilder/anmelden-dunkel-handy.png b/docs/bilder/anmelden-dunkel-handy.png index d92cf07..4b0ee00 100644 Binary files a/docs/bilder/anmelden-dunkel-handy.png and b/docs/bilder/anmelden-dunkel-handy.png differ diff --git a/docs/bilder/anmelden-dunkel-schreibtisch.png b/docs/bilder/anmelden-dunkel-schreibtisch.png index 53e72a6..5615e64 100644 Binary files a/docs/bilder/anmelden-dunkel-schreibtisch.png and b/docs/bilder/anmelden-dunkel-schreibtisch.png differ diff --git a/docs/bilder/anmelden-hell-handy.png b/docs/bilder/anmelden-hell-handy.png index b4c69ea..0edadad 100644 Binary files a/docs/bilder/anmelden-hell-handy.png and b/docs/bilder/anmelden-hell-handy.png differ diff --git a/docs/bilder/anmelden-hell-schreibtisch.png b/docs/bilder/anmelden-hell-schreibtisch.png index 2e7d719..31ccffb 100644 Binary files a/docs/bilder/anmelden-hell-schreibtisch.png and b/docs/bilder/anmelden-hell-schreibtisch.png differ diff --git a/docs/bilder/aufgaben-dunkel-handy.png b/docs/bilder/aufgaben-dunkel-handy.png index 0549d56..bc87b7c 100644 Binary files a/docs/bilder/aufgaben-dunkel-handy.png and b/docs/bilder/aufgaben-dunkel-handy.png differ diff --git a/docs/bilder/aufgaben-dunkel-schreibtisch.png b/docs/bilder/aufgaben-dunkel-schreibtisch.png index 81cfa90..7021df2 100644 Binary files a/docs/bilder/aufgaben-dunkel-schreibtisch.png and b/docs/bilder/aufgaben-dunkel-schreibtisch.png differ diff --git a/docs/bilder/aufgaben-hell-handy.png b/docs/bilder/aufgaben-hell-handy.png index 432f52a..a12ba06 100644 Binary files a/docs/bilder/aufgaben-hell-handy.png and b/docs/bilder/aufgaben-hell-handy.png differ diff --git a/docs/bilder/aufgaben-hell-schreibtisch.png b/docs/bilder/aufgaben-hell-schreibtisch.png index a6bc40f..085e16b 100644 Binary files a/docs/bilder/aufgaben-hell-schreibtisch.png and b/docs/bilder/aufgaben-hell-schreibtisch.png differ diff --git a/docs/bilder/benachrichtigungen-dunkel-handy.png b/docs/bilder/benachrichtigungen-dunkel-handy.png index 3940c01..1106d77 100644 Binary files a/docs/bilder/benachrichtigungen-dunkel-handy.png and b/docs/bilder/benachrichtigungen-dunkel-handy.png differ diff --git a/docs/bilder/benachrichtigungen-dunkel-schreibtisch.png b/docs/bilder/benachrichtigungen-dunkel-schreibtisch.png index 9ecf428..1c9cce1 100644 Binary files a/docs/bilder/benachrichtigungen-dunkel-schreibtisch.png and b/docs/bilder/benachrichtigungen-dunkel-schreibtisch.png differ diff --git a/docs/bilder/benachrichtigungen-hell-handy.png b/docs/bilder/benachrichtigungen-hell-handy.png index 560b198..cd4139e 100644 Binary files a/docs/bilder/benachrichtigungen-hell-handy.png and b/docs/bilder/benachrichtigungen-hell-handy.png differ diff --git a/docs/bilder/benachrichtigungen-hell-schreibtisch.png b/docs/bilder/benachrichtigungen-hell-schreibtisch.png index c0ff8cf..482d321 100644 Binary files a/docs/bilder/benachrichtigungen-hell-schreibtisch.png and b/docs/bilder/benachrichtigungen-hell-schreibtisch.png differ diff --git a/docs/bilder/bereiche-dunkel-handy.png b/docs/bilder/bereiche-dunkel-handy.png index d70a1ab..f2a031a 100644 Binary files a/docs/bilder/bereiche-dunkel-handy.png and b/docs/bilder/bereiche-dunkel-handy.png differ diff --git a/docs/bilder/bereiche-dunkel-schreibtisch.png b/docs/bilder/bereiche-dunkel-schreibtisch.png index fef26a4..9782056 100644 Binary files a/docs/bilder/bereiche-dunkel-schreibtisch.png and b/docs/bilder/bereiche-dunkel-schreibtisch.png differ diff --git a/docs/bilder/bereiche-hell-handy.png b/docs/bilder/bereiche-hell-handy.png index 348e24a..ce2e811 100644 Binary files a/docs/bilder/bereiche-hell-handy.png and b/docs/bilder/bereiche-hell-handy.png differ diff --git a/docs/bilder/bereiche-hell-schreibtisch.png b/docs/bilder/bereiche-hell-schreibtisch.png index 3caf9aa..8646123 100644 Binary files a/docs/bilder/bereiche-hell-schreibtisch.png and b/docs/bilder/bereiche-hell-schreibtisch.png differ diff --git a/docs/bilder/einstellungen-dunkel-handy.png b/docs/bilder/einstellungen-dunkel-handy.png index fa2e246..05484d9 100644 Binary files a/docs/bilder/einstellungen-dunkel-handy.png and b/docs/bilder/einstellungen-dunkel-handy.png differ diff --git a/docs/bilder/einstellungen-dunkel-schreibtisch.png b/docs/bilder/einstellungen-dunkel-schreibtisch.png index d8d1f8f..15a3d13 100644 Binary files a/docs/bilder/einstellungen-dunkel-schreibtisch.png and b/docs/bilder/einstellungen-dunkel-schreibtisch.png differ diff --git a/docs/bilder/einstellungen-hell-handy.png b/docs/bilder/einstellungen-hell-handy.png index 15edef1..216fd0c 100644 Binary files a/docs/bilder/einstellungen-hell-handy.png and b/docs/bilder/einstellungen-hell-handy.png differ diff --git a/docs/bilder/einstellungen-hell-schreibtisch.png b/docs/bilder/einstellungen-hell-schreibtisch.png index b06cbec..051ad1e 100644 Binary files a/docs/bilder/einstellungen-hell-schreibtisch.png and b/docs/bilder/einstellungen-hell-schreibtisch.png differ diff --git a/docs/bilder/ideen-dunkel-handy.png b/docs/bilder/ideen-dunkel-handy.png index 92fea31..c2a3d8e 100644 Binary files a/docs/bilder/ideen-dunkel-handy.png and b/docs/bilder/ideen-dunkel-handy.png differ diff --git a/docs/bilder/ideen-dunkel-schreibtisch.png b/docs/bilder/ideen-dunkel-schreibtisch.png index b274868..a632d23 100644 Binary files a/docs/bilder/ideen-dunkel-schreibtisch.png and b/docs/bilder/ideen-dunkel-schreibtisch.png differ diff --git a/docs/bilder/ideen-hell-handy.png b/docs/bilder/ideen-hell-handy.png index 894c3f2..2e03b90 100644 Binary files a/docs/bilder/ideen-hell-handy.png and b/docs/bilder/ideen-hell-handy.png differ diff --git a/docs/bilder/ideen-hell-schreibtisch.png b/docs/bilder/ideen-hell-schreibtisch.png index c99801b..4e6705f 100644 Binary files a/docs/bilder/ideen-hell-schreibtisch.png and b/docs/bilder/ideen-hell-schreibtisch.png differ diff --git a/docs/bilder/inbox-dunkel-handy.png b/docs/bilder/inbox-dunkel-handy.png index 86833fb..17484c0 100644 Binary files a/docs/bilder/inbox-dunkel-handy.png and b/docs/bilder/inbox-dunkel-handy.png differ diff --git a/docs/bilder/inbox-dunkel-schreibtisch.png b/docs/bilder/inbox-dunkel-schreibtisch.png index 2b7efa4..aed3eed 100644 Binary files a/docs/bilder/inbox-dunkel-schreibtisch.png and b/docs/bilder/inbox-dunkel-schreibtisch.png differ diff --git a/docs/bilder/inbox-hell-handy.png b/docs/bilder/inbox-hell-handy.png index 24927eb..c9abefd 100644 Binary files a/docs/bilder/inbox-hell-handy.png and b/docs/bilder/inbox-hell-handy.png differ diff --git a/docs/bilder/inbox-hell-schreibtisch.png b/docs/bilder/inbox-hell-schreibtisch.png index 9cea323..0d7fc3c 100644 Binary files a/docs/bilder/inbox-hell-schreibtisch.png and b/docs/bilder/inbox-hell-schreibtisch.png differ diff --git a/docs/bilder/integrationen-dunkel-handy.png b/docs/bilder/integrationen-dunkel-handy.png new file mode 100644 index 0000000..879fe51 Binary files /dev/null and b/docs/bilder/integrationen-dunkel-handy.png differ diff --git a/docs/bilder/integrationen-dunkel-schreibtisch.png b/docs/bilder/integrationen-dunkel-schreibtisch.png new file mode 100644 index 0000000..06d6d0f Binary files /dev/null and b/docs/bilder/integrationen-dunkel-schreibtisch.png differ diff --git a/docs/bilder/integrationen-hell-handy.png b/docs/bilder/integrationen-hell-handy.png new file mode 100644 index 0000000..997dee7 Binary files /dev/null and b/docs/bilder/integrationen-hell-handy.png differ diff --git a/docs/bilder/integrationen-hell-schreibtisch.png b/docs/bilder/integrationen-hell-schreibtisch.png new file mode 100644 index 0000000..1f37fc2 Binary files /dev/null and b/docs/bilder/integrationen-hell-schreibtisch.png differ diff --git a/docs/bilder/kalenderkonten-dunkel-handy.png b/docs/bilder/kalenderkonten-dunkel-handy.png index bd22a6a..4c16933 100644 Binary files a/docs/bilder/kalenderkonten-dunkel-handy.png and b/docs/bilder/kalenderkonten-dunkel-handy.png differ diff --git a/docs/bilder/kalenderkonten-dunkel-schreibtisch.png b/docs/bilder/kalenderkonten-dunkel-schreibtisch.png index d83b49b..ffd14b7 100644 Binary files a/docs/bilder/kalenderkonten-dunkel-schreibtisch.png and b/docs/bilder/kalenderkonten-dunkel-schreibtisch.png differ diff --git a/docs/bilder/kalenderkonten-hell-handy.png b/docs/bilder/kalenderkonten-hell-handy.png index 9f960e7..7582588 100644 Binary files a/docs/bilder/kalenderkonten-hell-handy.png and b/docs/bilder/kalenderkonten-hell-handy.png differ diff --git a/docs/bilder/kalenderkonten-hell-schreibtisch.png b/docs/bilder/kalenderkonten-hell-schreibtisch.png index 2cc5c07..f23fa91 100644 Binary files a/docs/bilder/kalenderkonten-hell-schreibtisch.png and b/docs/bilder/kalenderkonten-hell-schreibtisch.png differ diff --git a/docs/bilder/projekte-dunkel-handy.png b/docs/bilder/projekte-dunkel-handy.png index c1a3ce0..b5bc992 100644 Binary files a/docs/bilder/projekte-dunkel-handy.png and b/docs/bilder/projekte-dunkel-handy.png differ diff --git a/docs/bilder/projekte-dunkel-schreibtisch.png b/docs/bilder/projekte-dunkel-schreibtisch.png index 29af3ef..454524b 100644 Binary files a/docs/bilder/projekte-dunkel-schreibtisch.png and b/docs/bilder/projekte-dunkel-schreibtisch.png differ diff --git a/docs/bilder/projekte-hell-handy.png b/docs/bilder/projekte-hell-handy.png index 214eb77..29c0bec 100644 Binary files a/docs/bilder/projekte-hell-handy.png and b/docs/bilder/projekte-hell-handy.png differ diff --git a/docs/bilder/projekte-hell-schreibtisch.png b/docs/bilder/projekte-hell-schreibtisch.png index 92c3374..49f49bc 100644 Binary files a/docs/bilder/projekte-hell-schreibtisch.png and b/docs/bilder/projekte-hell-schreibtisch.png differ diff --git a/docs/bilder/protokoll-dunkel-handy.png b/docs/bilder/protokoll-dunkel-handy.png index a47a390..50de351 100644 Binary files a/docs/bilder/protokoll-dunkel-handy.png and b/docs/bilder/protokoll-dunkel-handy.png differ diff --git a/docs/bilder/protokoll-dunkel-schreibtisch.png b/docs/bilder/protokoll-dunkel-schreibtisch.png index 0e41e63..c24ac35 100644 Binary files a/docs/bilder/protokoll-dunkel-schreibtisch.png and b/docs/bilder/protokoll-dunkel-schreibtisch.png differ diff --git a/docs/bilder/protokoll-hell-handy.png b/docs/bilder/protokoll-hell-handy.png index 8e07574..6e1eabd 100644 Binary files a/docs/bilder/protokoll-hell-handy.png and b/docs/bilder/protokoll-hell-handy.png differ diff --git a/docs/bilder/protokoll-hell-schreibtisch.png b/docs/bilder/protokoll-hell-schreibtisch.png index 63fa5a2..0faab38 100644 Binary files a/docs/bilder/protokoll-hell-schreibtisch.png and b/docs/bilder/protokoll-hell-schreibtisch.png differ diff --git a/docs/bilder/start-dunkel-handy.png b/docs/bilder/start-dunkel-handy.png index 4c36225..2646f48 100644 Binary files a/docs/bilder/start-dunkel-handy.png and b/docs/bilder/start-dunkel-handy.png differ diff --git a/docs/bilder/start-dunkel-schreibtisch.png b/docs/bilder/start-dunkel-schreibtisch.png index 3b8f860..7ce8ae1 100644 Binary files a/docs/bilder/start-dunkel-schreibtisch.png and b/docs/bilder/start-dunkel-schreibtisch.png differ diff --git a/docs/bilder/start-hell-handy.png b/docs/bilder/start-hell-handy.png index e12f579..346ee60 100644 Binary files a/docs/bilder/start-hell-handy.png and b/docs/bilder/start-hell-handy.png differ diff --git a/docs/bilder/start-hell-schreibtisch.png b/docs/bilder/start-hell-schreibtisch.png index 7a93277..a6448dd 100644 Binary files a/docs/bilder/start-hell-schreibtisch.png and b/docs/bilder/start-hell-schreibtisch.png differ diff --git a/docs/bilder/woche-dunkel-handy.png b/docs/bilder/woche-dunkel-handy.png index 66da12f..84653b8 100644 Binary files a/docs/bilder/woche-dunkel-handy.png and b/docs/bilder/woche-dunkel-handy.png differ diff --git a/docs/bilder/woche-dunkel-schreibtisch.png b/docs/bilder/woche-dunkel-schreibtisch.png index 18813da..cfb976e 100644 Binary files a/docs/bilder/woche-dunkel-schreibtisch.png and b/docs/bilder/woche-dunkel-schreibtisch.png differ diff --git a/docs/bilder/woche-hell-handy.png b/docs/bilder/woche-hell-handy.png index ae0f504..ec62a6b 100644 Binary files a/docs/bilder/woche-hell-handy.png and b/docs/bilder/woche-hell-handy.png differ diff --git a/docs/bilder/woche-hell-schreibtisch.png b/docs/bilder/woche-hell-schreibtisch.png index 7850618..0147196 100644 Binary files a/docs/bilder/woche-hell-schreibtisch.png and b/docs/bilder/woche-hell-schreibtisch.png differ diff --git a/docs/handbuch/README.md b/docs/handbuch/README.md index f3b7b5c..ba80942 100644 --- a/docs/handbuch/README.md +++ b/docs/handbuch/README.md @@ -20,12 +20,14 @@ Anleitung für den Alltag mit dem Planer. Eine Seite je Thema. verbinden, Belegung verstehen 7. **[Wochenplanung](woche.md)** – Aufgaben per Ziehen und Ablegen in den Kalender legen -8. **[Benachrichtigungen](benachrichtigungen.md)** – Push und E-Mail - einrichten, Ruhezeiten setzen +8. **[Benachrichtigungen](benachrichtigungen.md)** – Push, E-Mail und + Telegram einrichten, Ruhezeiten setzen +9. **[Integrationen](integrationen.md)** – Zammad, Telegram und der Spiegel + zwischen Firma und Daheim ## Verwaltung -9. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat +10. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat ## Was noch kommt @@ -33,7 +35,6 @@ Diese Seiten entstehen mit den nächsten Meilensteinen: | Thema | Meilenstein | | ------------------------------- | ----------- | -| Integrationen | M6 | | Schnellerfassung vom Handy, API | M7 | | MCP | M8 | diff --git a/docs/handbuch/integrationen.md b/docs/handbuch/integrationen.md new file mode 100644 index 0000000..e2f0624 --- /dev/null +++ b/docs/handbuch/integrationen.md @@ -0,0 +1,151 @@ +# Integrationen + +Was von außen hereinkommt — und was diese Instanz hinausgibt. Du findest den +Überblick im Menü unter **Integrationen**. + +![Die Seite Integrationen](../bilder/integrationen-hell-schreibtisch.png) + +Welche Integration es überhaupt gibt, hängt an der Instanz und ist nicht +verhandelbar: + +| | Firma | Daheim | +| -------------- | ------ | -------- | +| Zammad | ja | nein | +| Wartungsplaner | ja | nein | +| Telegram | nein | ja | +| Spiegel | sendet | empfängt | + +Eine Integration, die deine Instanz nicht vorsieht, lässt sich auch mit +vollständiger Konfiguration nicht anschalten. Das steckt in der Anwendung, +nicht in einer Einstellung, die sich versehentlich verstellen ließe. + +## Was jede Integration mit deinen Aufgaben darf + +Für alle gilt dieselbe Regel — und die ist wichtiger als jedes Detail +darunter: + +| Führt das Ursprungssystem | Führst du | +| --------------------------------- | ------------------------------------------------ | +| Titel, Notiz, Verweis, Fälligkeit | Bereich, Projekt, Priorität, geschätzter Aufwand | + +Wer ein Ticket in einen Bereich einsortiert und den Aufwand schätzt, muss +sich darauf verlassen können, dass der nächste Abgleich das nicht wegräumt. +Umgekehrt: Ändert sich drüben der Titel, zieht er hier mit. + +Dieselbe Sache ergibt nie eine zweite Aufgabe. Der Planer merkt sich zu jeder +Aufgabe, woher sie kam und unter welcher Kennung. + +## Zammad + +Tickets, auf die der Suchausdruck passt, werden zu Aufgaben. Voreingestellt +sind die dir zugewiesenen offenen Tickets; der Ausdruck steht in der +Konfiguration (`ZAMMAD_ABFRAGE`) und versteht Zammads Suchsyntax. + +- Der Titel wird `#Nummer Betreff`, der Verweis führt direkt ins Ticket. +- Ein Ticket, das Zammad als geschlossen meldet, hakt der Planer ab. +- **Geschrieben wird nach Zammad nichts.** Ein Ticket schließt man im + Ticketsystem — sonst weiß die Kollegin nicht, warum es plötzlich zu ist. + +Der Token gehört einem Benutzer und sieht genau das, was dieser Benutzer +sehen darf. Der Planer umgeht keine Rechte. + +## Wartungsplaner + +Noch nicht angebunden. Die Aufnahmestelle steht bereit — es fehlt allein das +Abholen, und dafür muss die Schnittstelle feststehen. Auf der Seite +**Integrationen** ist der Punkt als offene Lücke sichtbar, damit er nicht in +Vergessenheit gerät. + +## Telegram + +Drei Dinge in einem Kanal. + +### Chat verbinden + +1. Unter **Integrationen** auf **Chat verbinden**. +2. Der achtstellige Code erscheint. Er gilt **zehn Minuten** und genau + **einmal**. +3. Schick ihn dem Bot — als Nachricht oder als `/start ABCD2345`. +4. Der Bot bestätigt, und der Chat erscheint in der Liste. + +> **Warum der Umweg?** Wer nicht in der Liste steht, bekommt vom Bot keine +> Antwort — auch keine Fehlermeldung. Sonst könnte jeder, der die Kennung des +> Bots kennt, herausfinden, dass es dich gibt. Der Code ist der Beweis, dass +> jemand mit Zugang zur Anwendung diesen Chat wirklich will. + +### Erfassen + +Jede Nachricht an den Bot wird eine Aufgabe. Lange Texte werden gekürzt; der +Rest landet in der Notiz und geht nicht verloren. Die Aufgabe hat keinen +Bereich — sie liegt also in der **Inbox** und will einsortiert werden. + +### Melden und abhaken + +Ist Telegram in den [Benachrichtigungen](benachrichtigungen.md) als Weg +eingeschaltet, kommen Meldungen auch hier an. Geht es um genau eine Aufgabe, +hängt darunter ein Knopf **✓ Erledigt** — ein Druck, und sie ist abgehakt, +ohne die Anwendung zu öffnen. + +Mehr kann der Knopf nicht. Löschen oder Verschieben aus einem Chat heraus +wäre zu leicht versehentlich getroffen. + +Je Chat lässt sich einzeln einstellen, ob er Meldungen bekommt: Das Glöckchen +in der Liste schaltet um. + +## Der Spiegel + +Die Firma schickt nach Hause, nicht umgekehrt. Zwei Dinge gehen hinüber. + +**Belegungen.** Wann dienstlich Zeit verplant ist — ohne Titel, ohne Aufgabe, +ohne Kunde. Daheim erscheinen sie in der Wochenansicht als gestreifte Balken +mit der Aufschrift „Dienstlich belegt" und zählen gegen die Tageskapazität. +So kollidiert die private Planung nicht mit der dienstlichen, und trotzdem +verlässt kein Firmeninhalt die Firma. + +**Markierte Aufgaben.** Nur was du an der Aufgabe ausdrücklich zum Spiegeln +freigibst, geht mit Titel hinüber. Das ist eine Einzelentscheidung je +Aufgabe, kein Schalter für alles: Wer versehentlich einen ganzen Bereich +spiegelt, schickt Kundennamen nach Hause. + +Was der Sender nicht mehr schickt, verschwindet auch drüben. Wird ein +Zeitblock in der Firma ausgeplant, ist die Sperre daheim beim nächsten Lauf +weg — sonst stünde dort für immer eine Blockade, die es längst nicht mehr +gibt. + +## Wann läuft was + +| Integration | Takt | +| ----------- | --------------- | +| Telegram | alle 2 Minuten | +| Zammad | alle 15 Minuten | +| Spiegel | alle 15 Minuten | + +Wer dem Bot etwas schickt, erwartet die Aufgabe gleich in der Liste. In +Zammad ändert sich dagegen nichts im Minutentakt. + +Warten muss man trotzdem nicht: Auf der Seite **Integrationen** stößt +**Jetzt abrufen** einen Lauf von Hand an. Das lohnt vor allem direkt nach dem +Einrichten — sonst wüsstest du erst beim nächsten Takt, ob die Zugangsdaten +stimmen. + +## Wenn etwas klemmt + +Unter jeder Integration steht, wann sie zuletzt geglückt ist und woran sie +zuletzt gescheitert ist. Nach dem dritten Fehlschlag hintereinander gilt sie +als **gestört** — und dann meldet sich der Planer auch, sofern du den Anlass +„Ein Kalender lässt sich nicht abgleichen" eingeschaltet hast. + +Erst nach dem dritten Mal: Ein Netz hustet, ein Anbieter hat Wartung. Beim +ersten Fehlschlag zu melden hieße, dich für Dinge zu wecken, die sich von +selbst erledigen. + +| Symptom | Ursache | +| ---------------------------------------- | --------------------------------------------------------------------------------- | +| „Nicht eingerichtet" trotz Konfiguration | Die Instanz sieht diese Integration nicht vor | +| Zammad findet nichts | Der Suchausdruck passt auf kein Ticket — in Zammad selbst probieren | +| Der Bot antwortet nicht | Der Chat ist nicht verbunden, oder der Code war abgelaufen | +| Der Spiegel wird abgelehnt | Verschiedene Geheimnisse, oder die Uhren laufen mehr als fünf Minuten auseinander | + +--- + +Zurück zum [Handbuch](README.md). diff --git a/e2e/hilfe.ts b/e2e/hilfe.ts index d5b601b..965a681 100644 --- a/e2e/hilfe.ts +++ b/e2e/hilfe.ts @@ -6,6 +6,16 @@ export const EMAIL = process.env.ADMIN_EMAIL ?? '' export const PASSWORT = process.env.ADMIN_PASSWORT ?? '' export const VERBINDUNG = process.env.DATABASE_URL ?? '' +/** + * Geheimnis fuer den Spiegel im Test. + * + * Der Testserver bekommt denselben Wert ueber playwright.config.ts, sonst + * liesse sich die Tuer gar nicht pruefen — und eine Pruefung, die immer + * uebersprungen wird, prueft nichts. + */ +export const SPIEGEL_GEHEIMNIS = + process.env.SPIEGEL_GEHEIMNIS ?? 'e2e-spiegel-geheimnis-nur-lokal' + /** * Meldet das Testkonto mit Passwort an. * diff --git a/e2e/spiegel.spec.ts b/e2e/spiegel.spec.ts new file mode 100644 index 0000000..c000580 --- /dev/null +++ b/e2e/spiegel.spec.ts @@ -0,0 +1,138 @@ +import { createHmac } from 'node:crypto' +import { PrismaPg } from '@prisma/adapter-pg' +import { expect, test } from '@playwright/test' +import { PrismaClient } from '../src/generated/prisma/client.js' +import { SPIEGEL_GEHEIMNIS, VERBINDUNG } from './hilfe' + +/** + * Der Eingang des Spiegels. + * + * Hier meldet sich kein Mensch an — die Gegenstelle weist sich allein über + * eine Signatur aus. Deshalb wird hier nicht die Oberfläche geprüft, sondern + * genau diese Tür: Sie muss zu sein, wenn irgendetwas nicht stimmt. + */ + +const GEHEIMNIS = SPIEGEL_GEHEIMNIS +const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: VERBINDUNG }) }) + +const PRAEFIX = `E2E-S-${Date.now()}` + +function signieren(koerper: string, geheimnis = GEHEIMNIS): string { + return createHmac('sha256', geheimnis).update(koerper).digest('hex') +} + +function sendung(teile: Record = {}): string { + return JSON.stringify({ + gesendetAm: new Date().toISOString(), + belegungen: [], + aufgaben: [], + ...teile, + }) +} + +test.describe('Spiegel', () => { + test.afterAll(async () => { + await db.spiegelbelegung.deleteMany({ where: { quelleRef: { startsWith: PRAEFIX } } }) + await db.aufgabe.deleteMany({ where: { quelleRef: { startsWith: PRAEFIX } } }) + await db.auditEintrag.deleteMany({ where: { aktion: 'spiegel.empfangen' } }) + await db.$disconnect() + }) + + test('weist eine Sendung ohne Signatur ab', async ({ request }) => { + const antwort = await request.post('/api/spiegel', { + headers: { 'Content-Type': 'application/json' }, + data: sendung(), + }) + expect(antwort.status()).toBe(401) + }) + + test('weist eine falsch signierte Sendung ab', async ({ request }) => { + const koerper = sendung() + const antwort = await request.post('/api/spiegel', { + headers: { + 'Content-Type': 'application/json', + 'X-Planer-Signatur': signieren(koerper, 'das-ist-nicht-das-geheimnis'), + }, + data: koerper, + }) + expect(antwort.status()).toBe(401) + }) + + test('weist eine Sendung ab, die zu lange unterwegs war', async ({ request }) => { + test.skip(!GEHEIMNIS, 'Ohne SPIEGEL_GEHEIMNIS ist der Eingang ohnehin zu.') + + // Eine Stunde alt: richtig signiert, trotzdem wertlos. Sonst ließe sich + // ein abgefangenes Paket beliebig oft erneut einspielen. + const koerper = sendung({ + gesendetAm: new Date(Date.now() - 3_600_000).toISOString(), + }) + const antwort = await request.post('/api/spiegel', { + headers: { + 'Content-Type': 'application/json', + 'X-Planer-Signatur': signieren(koerper), + }, + data: koerper, + }) + expect(antwort.status()).toBe(401) + }) + + test('nimmt eine gültige Sendung an und räumt Veraltetes weg', async ({ request }) => { + test.skip( + !GEHEIMNIS, + 'Ohne SPIEGEL_GEHEIMNIS auf dem Testserver lässt sich das nicht prüfen.', + ) + + const beginn = new Date(Date.now() + 86_400_000) + const ende = new Date(beginn.getTime() + 3_600_000) + + const erste = sendung({ + belegungen: [ + { + ref: `${PRAEFIX}-a`, + beginn: beginn.toISOString(), + ende: ende.toISOString(), + }, + ], + aufgaben: [{ ref: `${PRAEFIX}-1`, titel: `${PRAEFIX} Angebot schreiben` }], + }) + + const antwort = await request.post('/api/spiegel', { + headers: { + 'Content-Type': 'application/json', + 'X-Planer-Signatur': signieren(erste), + }, + data: erste, + }) + expect(antwort.ok()).toBe(true) + + expect(await db.spiegelbelegung.count({ where: { quelleRef: `${PRAEFIX}-a` } })).toBe( + 1, + ) + const aufgabe = await db.aufgabe.findFirst({ + where: { quelle: 'SPIEGEL', quelleRef: `${PRAEFIX}-1` }, + select: { titel: true }, + }) + expect(aufgabe?.titel).toBe(`${PRAEFIX} Angebot schreiben`) + + // Zweite Runde ohne die Belegung: Sie wurde drüben ausgeplant und muss + // auch hier verschwinden — sonst stünde für immer eine Sperre, die es + // längst nicht mehr gibt. + const zweite = sendung({ aufgaben: [] }) + const nachher = await request.post('/api/spiegel', { + headers: { + 'Content-Type': 'application/json', + 'X-Planer-Signatur': signieren(zweite), + }, + data: zweite, + }) + expect(nachher.ok()).toBe(true) + expect(await db.spiegelbelegung.count({ where: { quelleRef: `${PRAEFIX}-a` } })).toBe( + 0, + ) + + // Die Aufgabe bleibt: Sie ist Planung, keine Sperre. + expect( + await db.aufgabe.count({ where: { quelle: 'SPIEGEL', quelleRef: `${PRAEFIX}-1` } }), + ).toBe(1) + }) +}) diff --git a/package-lock.json b/package-lock.json index 65df5ed..85dd85a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "planer", - "version": "0.5.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "planer", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { "@auth/prisma-adapter": "2.11.3", "@date-fns/tz": "1.5.0", diff --git a/package.json b/package.json index 6594f9c..79dac33 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "planer", - "version": "0.5.0", + "version": "0.6.0", "private": true, "description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender", "type": "module", diff --git a/playwright.config.ts b/playwright.config.ts index e7de6a3..6cf733a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,5 +1,6 @@ import { defineConfig, devices } from '@playwright/test' import 'dotenv/config' +import { SPIEGEL_GEHEIMNIS } from './e2e/hilfe' const PORT = Number(process.env.E2E_PORT ?? 3100) const BASIS = `http://127.0.0.1:${PORT}` @@ -41,6 +42,8 @@ export default defineConfig({ APP_URL: BASIS, WEBAUTHN_ORIGIN: BASIS, WEBAUTHN_RP_ID: '127.0.0.1', + // Ohne Geheimnis waere der Spiegel-Eingang zu und sein Test sinnlos. + SPIEGEL_GEHEIMNIS, }, }, }) diff --git a/prisma/demo-seed.ts b/prisma/demo-seed.ts index 7e81a9c..4830f74 100644 --- a/prisma/demo-seed.ts +++ b/prisma/demo-seed.ts @@ -429,6 +429,7 @@ async function main(): Promise { anlaesse: 'tagesueberblick,faelligkeit,zeitblock', perPush: true, perMail: true, + perTelegram: true, ueberblickUm: 7 * 60, ruheVon: 20 * 60, ruheBis: 7 * 60, @@ -442,6 +443,52 @@ async function main(): Promise { // nicht lesen kann. Ein erfundener Eintrag verschwände beim ersten Lauf // wieder — die Bilder zeigen deshalb den Zustand vor der ersten Anmeldung. + // --- Integrationen -------------------------------------------------------- + await db.integrationszustand.deleteMany({}) + await db.integrationszustand.create({ + data: { + quelle: 'TELEGRAM', + merker: '4711', + letzterLauf: new Date(Date.UTC(2026, 2, 17, 8, 18)), + letzterErfolg: new Date(Date.UTC(2026, 2, 17, 8, 18)), + }, + }) + await db.integrationszustand.create({ + data: { + quelle: 'SPIEGEL', + letzterLauf: new Date(Date.UTC(2026, 2, 17, 8, 15)), + letzterErfolg: new Date(Date.UTC(2026, 2, 17, 8, 15)), + }, + }) + + await db.telegramChat.deleteMany({}) + await db.telegramChat.create({ + data: { + // Erfunden. Der Bot ist ohnehin abgeschaltet, solange DEMO_MODUS gilt. + chatId: BigInt(147258369), + benutzerId: benutzer.id, + bezeichnung: 'Martina', + erstelltAm: new Date(Date.UTC(2026, 1, 3, 8, 25)), + zuletztBenutzt: new Date(Date.UTC(2026, 2, 17, 6, 40)), + }, + }) + + // Dienstliche Belegung, wie sie aus der Firma hereinkaeme: ohne Titel. + await db.spiegelbelegung.deleteMany({}) + for (const [tag, stunde, dauer] of [ + [17, 9, 2], + [18, 13, 3], + [19, 8, 1], + ] as const) { + await db.spiegelbelegung.create({ + data: { + quelleRef: `demo-spiegel-${tag}-${stunde}`, + beginn: new Date(Date.UTC(2026, 2, tag, stunde - 1, 0)), + ende: new Date(Date.UTC(2026, 2, tag, stunde - 1 + dauer, 0)), + }, + }) + } + console.log(`Demo-Daten stehen bereit. Anmeldung: ${DEMO_EMAIL}`) } diff --git a/prisma/migrations/20260911185221_integrationen/migration.sql b/prisma/migrations/20260911185221_integrationen/migration.sql new file mode 100644 index 0000000..7a0d340 --- /dev/null +++ b/prisma/migrations/20260911185221_integrationen/migration.sql @@ -0,0 +1,62 @@ +-- AlterTable +ALTER TABLE "aufgabe" ADD COLUMN "spiegeln" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "benachrichtigungseinstellung" ADD COLUMN "perTelegram" BOOLEAN NOT NULL DEFAULT false; + +-- CreateTable +CREATE TABLE "integrationszustand" ( + "id" TEXT NOT NULL, + "quelle" "Quelle" NOT NULL, + "merker" TEXT, + "letzterLauf" TIMESTAMP(3), + "letzterErfolg" TIMESTAMP(3), + "letzterFehler" TEXT, + "fehlversuche" INTEGER NOT NULL DEFAULT 0, + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "aktualisiertAm" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "integrationszustand_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "spiegelbelegung" ( + "id" TEXT NOT NULL, + "quelleRef" TEXT NOT NULL, + "beginn" TIMESTAMP(3) NOT NULL, + "ende" TIMESTAMP(3) NOT NULL, + "empfangenAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "spiegelbelegung_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "telegram_chat" ( + "id" TEXT NOT NULL, + "chatId" BIGINT NOT NULL, + "bezeichnung" TEXT, + "benutzerId" TEXT, + "meldungen" BOOLEAN NOT NULL DEFAULT true, + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "zuletztBenutzt" TIMESTAMP(3), + + CONSTRAINT "telegram_chat_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "integrationszustand_quelle_key" ON "integrationszustand"("quelle"); + +-- CreateIndex +CREATE UNIQUE INDEX "spiegelbelegung_quelleRef_key" ON "spiegelbelegung"("quelleRef"); + +-- CreateIndex +CREATE INDEX "spiegelbelegung_beginn_idx" ON "spiegelbelegung"("beginn"); + +-- CreateIndex +CREATE UNIQUE INDEX "telegram_chat_chatId_key" ON "telegram_chat"("chatId"); + +-- CreateIndex +CREATE INDEX "telegram_chat_benutzerId_idx" ON "telegram_chat"("benutzerId"); + +-- AddForeignKey +ALTER TABLE "telegram_chat" ADD CONSTRAINT "telegram_chat_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20260911204204_telegram_kopplung/migration.sql b/prisma/migrations/20260911204204_telegram_kopplung/migration.sql new file mode 100644 index 0000000..d0945b7 --- /dev/null +++ b/prisma/migrations/20260911204204_telegram_kopplung/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "telegram_kopplung" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "benutzerId" TEXT NOT NULL, + "gueltigBis" TIMESTAMP(3) NOT NULL, + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "telegram_kopplung_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "telegram_kopplung_code_key" ON "telegram_kopplung"("code"); + +-- CreateIndex +CREATE INDEX "telegram_kopplung_gueltigBis_idx" ON "telegram_kopplung"("gueltigBis"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dd7b924..772bf88 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -38,6 +38,7 @@ model Benutzer { benachrichtigung Benachrichtigungseinstellung? pushGeraete PushGeraet[] meldungen Meldungsnachweis[] + telegramChats TelegramChat[] @@index([aktiv]) @@map("benutzer") @@ -222,6 +223,10 @@ model Aufgabe { /// Kennung beim Ursprungssystem, z. B. die Ticketnummer. quelleRef String? + /// Ausdruecklich zum Spiegeln freigegeben. Nur was hier true ist, verlaesst + /// die Instanz mit Titel – alles andere bleibt, wo es ist. + spiegeln Boolean @default(false) + erstelltAm DateTime @default(now()) aktualisiertAm DateTime @updatedAt @@ -391,9 +396,10 @@ model Benachrichtigungseinstellung { benutzerId String @unique /// Kommagetrennte Anlaesse, siehe src/lib/benachrichtigung.ts. - anlaesse String @default("tagesueberblick,faelligkeit") - perPush Boolean @default(false) - perMail Boolean @default(false) + anlaesse String @default("tagesueberblick,faelligkeit") + perPush Boolean @default(false) + perMail Boolean @default(false) + perTelegram Boolean @default(false) /// Alles als Minuten seit Mitternacht, Europe/Berlin. ueberblickUm Int @default(420) @@ -448,3 +454,87 @@ model Meldungsnachweis { @@index([gesendetAm]) @@map("meldungsnachweis") } + +/// Zustand einer Integration: wie weit sie gelesen hat und ob es klappt. +/// +/// Eine Zeile je Quelle. Der Merker ist bewusst ein freier Text – jede +/// Integration legt darin ab, was sie zum Fortsetzen braucht: Zammad einen +/// Zeitstempel, Telegram den Zaehler der zuletzt geholten Nachricht. +model Integrationszustand { + id String @id @default(cuid()) + quelle Quelle @unique + + /// Woher es weitergeht. Bedeutung haengt an der Quelle. + merker String? + + letzterLauf DateTime? + letzterErfolg DateTime? + letzterFehler String? + /// Fehlschlaege in Folge; bei Erfolg wieder null. + fehlversuche Int @default(0) + + erstelltAm DateTime @default(now()) + aktualisiertAm DateTime @updatedAt + + @@map("integrationszustand") +} + +/// Dienstliche Belegung, die aus der anderen Instanz kommt. +/// +/// Bewusst ohne Titel: Daheim soll sehen, *dass* die Zeit weg ist, nicht +/// woran gearbeitet wird. Firmeninhalte verlassen die Firma nicht. +model Spiegelbelegung { + id String @id @default(cuid()) + + /// Kennung des Zeitblocks in der sendenden Instanz. + quelleRef String @unique + + beginn DateTime + ende DateTime + + empfangenAm DateTime @default(now()) + + @@index([beginn]) + @@map("spiegelbelegung") +} + +/// Ein Telegram-Chat, der mit dem Planer reden darf. +/// +/// Wer nicht hier steht, wird verworfen – ein Bot-Token laesst sich erraten +/// oder abgreifen, die Chatkennung ist der zweite Riegel. +model TelegramChat { + id String @id @default(cuid()) + /// Chatkennung bei Telegram; kann negativ sein (Gruppen). + chatId BigInt @unique + + bezeichnung String? + benutzerId String? + + /// Empfaengt dieser Chat auch Meldungen, nicht nur Aufgaben? + meldungen Boolean @default(true) + + erstelltAm DateTime @default(now()) + zuletztBenutzt DateTime? + + benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: SetNull) + + @@index([benutzerId]) + @@map("telegram_chat") +} + +/// Einmalcode, mit dem sich ein Telegram-Chat anmeldet. +/// +/// Ohne ihn muesste die Chatkennung von Hand eingetragen werden – und um sie +/// zu erfahren, muesste der Planer auf Nachrichten Unbekannter antworten. +/// Genau das soll er nicht. +model TelegramKopplung { + id String @id @default(cuid()) + code String @unique + benutzerId String + gueltigBis DateTime + + erstelltAm DateTime @default(now()) + + @@index([gueltigBis]) + @@map("telegram_kopplung") +} diff --git a/screenshots/bilder.spec.ts b/screenshots/bilder.spec.ts index 15f69a2..da824ef 100644 --- a/screenshots/bilder.spec.ts +++ b/screenshots/bilder.spec.ts @@ -43,6 +43,7 @@ const AUFNAHMEN: Aufnahme[] = [ { name: 'ideen', pfad: '/ideen' }, { name: 'bereiche', pfad: '/bereiche' }, { name: 'kalenderkonten', pfad: '/kalenderkonten' }, + { name: 'integrationen', pfad: '/integrationen' }, { name: 'einstellungen', pfad: '/einstellungen' }, { name: 'benachrichtigungen', diff --git a/scripts/integrationstest.ts b/scripts/integrationstest.ts index 58b4cd6..889f400 100644 --- a/scripts/integrationstest.ts +++ b/scripts/integrationstest.ts @@ -41,6 +41,9 @@ async function scheitert(name: string, tun: () => Promise): Promise { @@ -58,6 +61,12 @@ async function aufraeumen(): Promise { await db.webauthnChallenge.deleteMany({ where: { kennung } }) await db.pushGeraet.deleteMany({ where: { endpunkt: { contains: kennung } } }) await db.meldungsnachweis.deleteMany({ where: { schluessel: { contains: kennung } } }) + await db.spiegelbelegung.deleteMany({ where: { quelleRef: { contains: kennung } } }) + await db.telegramKopplung.deleteMany({ + where: { code: { contains: kennung.slice(-8) } }, + }) + await db.telegramChat.deleteMany({ where: { chatId: testChatId } }) + await db.integrationszustand.deleteMany({ where: { quelle: 'TELEGRAM' } }) } async function main(): Promise { @@ -316,6 +325,76 @@ async function main(): Promise { ).id.length > 0, ) + // --- Integrationen ------------------------------------------------------- + // Der Zustand haengt an der Quelle: eine Zeile je Integration, nicht mehr. + await db.integrationszustand.create({ + data: { quelle: 'TELEGRAM', merker: '42' }, + }) + await scheitert('Je Quelle nur ein Integrationszustand', () => + db.integrationszustand.create({ data: { quelle: 'TELEGRAM' } }), + ) + await db.integrationszustand.deleteMany({ where: { quelle: 'TELEGRAM' } }) + + // Zwei Aufgaben aus derselben Quelle mit derselben Kennung darf es nicht + // geben – sonst legte jeder Lauf dieselbe Sache erneut an. + const ausZammad = await db.aufgabe.create({ + data: { titel: `${kennung}-ticket`, quelle: 'ZAMMAD', quelleRef: `${kennung}-1` }, + }) + pruefe('Aufgabe aus einer Integration anlegen', ausZammad.id.length > 0) + pruefe('Ohne Freigabe wird nicht gespiegelt', ausZammad.spiegeln === false) + + await scheitert('Dieselbe Quelle mit derselben Kennung nur einmal (Integration)', () => + db.aufgabe.create({ + data: { + titel: `${kennung}-ticket-doppelt`, + quelle: 'ZAMMAD', + quelleRef: `${kennung}-1`, + }, + }), + ) + + await db.spiegelbelegung.create({ + data: { + quelleRef: `${kennung}-zb`, + beginn: new Date(Date.UTC(2026, 2, 17, 8, 0)), + ende: new Date(Date.UTC(2026, 2, 17, 9, 0)), + }, + }) + await scheitert('Dieselbe gespiegelte Belegung nur einmal', () => + db.spiegelbelegung.create({ + data: { + quelleRef: `${kennung}-zb`, + beginn: new Date(Date.UTC(2026, 2, 18, 8, 0)), + ende: new Date(Date.UTC(2026, 2, 18, 9, 0)), + }, + }), + ) + + const chat = await db.telegramChat.create({ + data: { chatId: testChatId, benutzerId: benutzer.id }, + }) + pruefe('Telegram-Chat empfaengt im Zweifel Meldungen', chat.meldungen === true) + await scheitert('Dieselbe Chatkennung nur einmal', () => + db.telegramChat.create({ data: { chatId: chat.chatId } }), + ) + + await db.telegramKopplung.create({ + data: { + code: `${kennung.slice(-8)}`, + benutzerId: benutzer.id, + gueltigBis: new Date(Date.now() + 600_000), + }, + }) + await scheitert('Denselben Kopplungscode nur einmal', () => + db.telegramKopplung.create({ + data: { + code: `${kennung.slice(-8)}`, + benutzerId: benutzer.id, + gueltigBis: new Date(Date.now() + 600_000), + }, + }), + ) + await db.benutzer.delete({ where: { id: benutzer.id } }) const nachher = await db.auditEintrag.findUnique({ where: { id: audit.id } }) @@ -341,6 +420,10 @@ async function main(): Promise { 'Push-Geräte verschwinden mit dem Benutzer', (await db.pushGeraet.count({ where: { endpunkt: { contains: kennung } } })) === 0, ) + pruefe( + 'Telegram-Chats verschwinden nicht, verlieren aber den Benutzer', + (await db.telegramChat.count({ where: { benutzerId: benutzer.id } })) === 0, + ) pruefe( 'Meldungsnachweise verschwinden mit dem Benutzer', (await db.meldungsnachweis.count({ diff --git a/src/app/(app)/aufgaben/page.tsx b/src/app/(app)/aufgaben/page.tsx index ee47777..d46ec4a 100644 --- a/src/app/(app)/aufgaben/page.tsx +++ b/src/app/(app)/aufgaben/page.tsx @@ -14,6 +14,7 @@ import { type AufgabenFilter, } from '@/server/abfragen/aufgaben' import { verlangeAnmeldung } from '@/server/berechtigung' +import { umgebung } from '@/server/umgebung' export const metadata: Metadata = { title: 'Aufgaben' } @@ -67,6 +68,7 @@ export default async function AufgabenSeite({ searchParams: Promise> }) { await verlangeAnmeldung('/aufgaben') + const spiegelt = umgebung().spiegelSendenAktiv const parameter = await searchParams const [aufgaben, bereiche, projekte] = await Promise.all([ @@ -101,11 +103,13 @@ export default async function AufgabenSeite({ + {/* Nur wo auch gespiegelt wird, hat der Schalter einen Sinn. */} ) diff --git a/src/app/(app)/einstellungen/benachrichtigungen.tsx b/src/app/(app)/einstellungen/benachrichtigungen.tsx index a5baa5e..c8eb0aa 100644 --- a/src/app/(app)/einstellungen/benachrichtigungen.tsx +++ b/src/app/(app)/einstellungen/benachrichtigungen.tsx @@ -34,6 +34,7 @@ export type EinstellungAnzeige = { anlaesse: Anlass[] perPush: boolean perMail: boolean + perTelegram: boolean ueberblickUm: string ruheVon: string ruheBis: string @@ -46,6 +47,9 @@ type Eigenschaften = { geraete: GeraetAnzeige[] pushMoeglich: boolean mailMoeglich: boolean + telegramMoeglich: boolean + /** Wie viele Chats Meldungen empfangen. Null heisst: noch keiner. */ + telegramChats: number } /** @@ -68,6 +72,8 @@ export function Benachrichtigungen({ geraete, pushMoeglich, mailMoeglich, + telegramMoeglich, + telegramChats, }: Eigenschaften) { const router = useRouter() const [laeuft, starte] = useTransition() @@ -311,6 +317,35 @@ export function Benachrichtigungen({ )} + +
diff --git a/src/app/(app)/einstellungen/page.tsx b/src/app/(app)/einstellungen/page.tsx index c1514a4..4562bec 100644 --- a/src/app/(app)/einstellungen/page.tsx +++ b/src/app/(app)/einstellungen/page.tsx @@ -23,13 +23,14 @@ export default async function EinstellungenSeite() { const einst = umgebung() - const [benachrichtigung, pushGeraete] = await Promise.all([ + const [benachrichtigung, telegramChats, pushGeraete] = await Promise.all([ db.benachrichtigungseinstellung.findUnique({ where: { benutzerId: benutzer.id }, select: { anlaesse: true, perPush: true, perMail: true, + perTelegram: true, ueberblickUm: true, ruheVon: true, ruheBis: true, @@ -37,6 +38,7 @@ export default async function EinstellungenSeite() { zeitblockVorlaufMinuten: true, }, }), + db.telegramChat.count({ where: { benutzerId: benutzer.id, meldungen: true } }), db.pushGeraet.findMany({ where: { benutzerId: benutzer.id }, orderBy: { erstelltAm: 'asc' }, @@ -119,10 +121,13 @@ export default async function EinstellungenSeite() { { + starte(async () => { + const daten = new FormData() + daten.set('quelle', quelle) + const antwort = await laufAnstossen(daten) + if (antwort.ok) { + toast.success(antwort.meldung) + router.refresh() + } else { + toast.error(antwort.fehler) + } + }) + }} + > + {laeuft ? ( + + ) : ( + + )} + Jetzt abrufen + + ) +} diff --git a/src/app/(app)/integrationen/page.tsx b/src/app/(app)/integrationen/page.tsx new file mode 100644 index 0000000..684f798 --- /dev/null +++ b/src/app/(app)/integrationen/page.tsx @@ -0,0 +1,192 @@ +import type { Metadata } from 'next' +import { Plug } from 'lucide-react' +import { LaufKnopf } from '@/app/(app)/integrationen/lauf-knopf' +import { TelegramChats } from '@/app/(app)/integrationen/telegram-chats' +import { + Card, + CardBeschreibung, + CardInhalt, + CardKopf, + CardTitel, +} from '@/components/ui/card' +import { Kennzeichen } from '@/components/ui/kennzeichen' +import type { Quelle } from '@/generated/prisma/enums' +import { INTEGRATION_FEHLER_AB_VERSUCH } from '@/lib/integration' +import { alsDatumUhrzeit } from '@/lib/zeit' +import { verlangeAnmeldung } from '@/server/berechtigung' +import { db } from '@/server/db' +import { umgebung } from '@/server/umgebung' + +export const metadata: Metadata = { title: 'Integrationen' } + +type Zeile = { + quelle: Quelle + name: string + beschreibung: string + /** In dieser Instanz überhaupt vorgesehen? */ + vorgesehen: boolean + /** Und auch eingerichtet? */ + aktiv: boolean + hinweis: string + /** Laesst sich der Lauf von Hand anstossen? Der Empfaenger wartet nur. */ + abrufbar?: boolean +} + +export default async function IntegrationenSeite() { + await verlangeAnmeldung('/integrationen') + const einst = umgebung() + + const [zustaende, chats] = await Promise.all([ + db.integrationszustand.findMany(), + db.telegramChat.findMany({ + orderBy: { erstelltAm: 'asc' }, + select: { + id: true, + chatId: true, + bezeichnung: true, + meldungen: true, + erstelltAm: true, + zuletztBenutzt: true, + }, + }), + ]) + + const nachQuelle = new Map(zustaende.map((z) => [z.quelle, z])) + + const zeilen: Zeile[] = [ + { + quelle: 'ZAMMAD', + name: 'Zammad', + beschreibung: + 'Tickets, auf die der Suchausdruck passt, werden zu Aufgaben. Geschrieben wird nach Zammad nichts — ein Ticket schließt man im Ticketsystem.', + vorgesehen: einst.instanz === 'firma', + aktiv: einst.zammadAktiv, + hinweis: 'ZAMMAD_URL und ZAMMAD_TOKEN fehlen.', + abrufbar: einst.zammadAktiv, + }, + { + quelle: 'WARTUNGSPLANER', + name: 'Wartungsplaner', + beschreibung: + 'Noch nicht angebunden: Die Schnittstelle ist offen. Die Aufnahmestelle steht bereit, es fehlt allein das Abholen.', + vorgesehen: einst.instanz === 'firma', + aktiv: false, + hinweis: 'Kommt, sobald die Schnittstelle feststeht.', + }, + { + quelle: 'TELEGRAM', + name: 'Telegram', + beschreibung: + 'Eine Nachricht an den Bot wird zur Aufgabe. Meldungen gehen denselben Weg zurück — mit einem Knopf zum Abhaken.', + vorgesehen: einst.instanz === 'daheim', + aktiv: einst.telegramAktiv, + hinweis: 'TELEGRAM_TOKEN fehlt.', + abrufbar: einst.telegramAktiv, + }, + { + quelle: 'SPIEGEL', + name: 'Spiegel', + beschreibung: + einst.instanz === 'firma' + ? 'Schickt Belegungen ohne Titel nach Hause, dazu die Aufgaben, die ausdrücklich zum Spiegeln markiert sind.' + : 'Empfängt dienstliche Belegungen. Sie erscheinen in der Wochenansicht als „Dienstlich belegt", ohne Betreff.', + vorgesehen: true, + aktiv: einst.spiegelSendenAktiv || einst.spiegelEmpfangenAktiv, + hinweis: 'SPIEGEL_GEHEIMNIS fehlt.', + // Die empfangende Seite ruft nichts ab – sie wartet. + abrufbar: einst.spiegelSendenAktiv, + }, + ] + + return ( +
+
+ +
+

Integrationen

+

+ Was von außen hereinkommt — und was diese Instanz hinausgibt. +

+
+
+ +
+ {zeilen.map((zeile) => { + const zustand = nachQuelle.get(zeile.quelle) + const gestoert = (zustand?.fehlversuche ?? 0) >= INTEGRATION_FEHLER_AB_VERSUCH + + return ( + + +
+ {zeile.name} + {!zeile.vorgesehen ? ( + + In dieser Instanz nicht vorgesehen + + ) : gestoert ? ( + Gestört + ) : zeile.aktiv ? ( + Läuft + ) : ( + Nicht eingerichtet + )} +
+ {zeile.beschreibung} +
+ + + {zeile.vorgesehen && !zeile.aktiv &&

{zeile.hinweis}

} + + {zustand?.letzterErfolg && ( +

Zuletzt geglückt: {alsDatumUhrzeit(zustand.letzterErfolg)}

+ )} + {zustand?.letzterFehler && ( +

+ Letzter Fehler: {zustand.letzterFehler} + {zustand.fehlversuche > 1 && ` (${zustand.fehlversuche}× in Folge)`} +

+ )} + {!zustand && zeile.aktiv &&

Noch kein Lauf.

} + + {zeile.abrufbar && ( +
+ +
+ )} +
+
+ ) + })} +
+ + {einst.telegramAktiv && ( + + + Telegram-Chats + + Nur wer hier steht, darf mit dem Bot reden — und nur diese Chats bekommen + Meldungen. Alles andere wird verworfen, ohne zu antworten. Zum Verbinden + holst du dir hier einen Code und schickst ihn dem Bot; er gilt zehn Minuten + und genau einmal. + + + + ({ + id: c.id, + chatId: c.chatId.toString(), + bezeichnung: c.bezeichnung, + meldungen: c.meldungen, + erstelltAm: alsDatumUhrzeit(c.erstelltAm), + zuletztBenutzt: c.zuletztBenutzt + ? alsDatumUhrzeit(c.zuletztBenutzt) + : null, + }))} + /> + + + )} +
+ ) +} diff --git a/src/app/(app)/integrationen/telegram-chats.tsx b/src/app/(app)/integrationen/telegram-chats.tsx new file mode 100644 index 0000000..50c349c --- /dev/null +++ b/src/app/(app)/integrationen/telegram-chats.tsx @@ -0,0 +1,139 @@ +'use client' + +import { useState, useTransition } from 'react' +import { useRouter } from 'next/navigation' +import { Bell, BellOff, Link2, Loader2, Trash2 } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { + chatEntfernen, + chatMeldungenUmschalten, + kopplungAnfordern, +} from '@/server/aktionen/integrationen' + +export type ChatAnzeige = { + id: string + chatId: string + bezeichnung: string | null + meldungen: boolean + erstelltAm: string + zuletztBenutzt: string | null +} + +export function TelegramChats({ chats }: { chats: ChatAnzeige[] }) { + const router = useRouter() + const [laeuft, starte] = useTransition() + const [code, setCode] = useState<{ code: string; minuten: number } | null>(null) + + function anfordern() { + starte(async () => { + const antwort = await kopplungAnfordern() + if (antwort.ok) { + setCode({ code: antwort.code, minuten: antwort.minuten }) + } else { + toast.error(antwort.fehler) + } + }) + } + + function mitRueckmeldung( + tun: () => Promise<{ ok: boolean; meldung?: string; fehler?: string }>, + ) { + starte(async () => { + const antwort = await tun() + if (antwort.ok) { + toast.success(antwort.meldung ?? 'Erledigt.') + router.refresh() + } else { + toast.error(antwort.fehler ?? 'Das hat nicht geklappt.') + } + }) + } + + return ( +
+ {chats.length === 0 ? ( +

+ Noch kein Chat verbunden. Ohne Verbindung nimmt der Bot nichts an. +

+ ) : ( +
    + {chats.map((chat) => ( +
  • +
    +

    {chat.bezeichnung ?? `Chat ${chat.chatId}`}

    +

    + Verbunden {chat.erstelltAm} + {chat.zuletztBenutzt + ? ` · zuletzt benutzt ${chat.zuletztBenutzt}` + : ' · noch nichts geschickt'} +

    +
    + + + + +
  • + ))} +
+ )} + + {code ? ( +
+

+ Schick dem Bot jetzt diesen Code — als Nachricht oder als{' '} + /start {code.code}: +

+

{code.code}

+

+ Gültig für {code.minuten} Minuten und nur ein einziges Mal. Danach erscheint + der Chat hier — spätestens nach dem nächsten Abholen. +

+
+ ) : ( +
+ +
+ )} +
+ ) +} diff --git a/src/app/(app)/woche/page.tsx b/src/app/(app)/woche/page.tsx index a7c7430..c869eb9 100644 --- a/src/app/(app)/woche/page.tsx +++ b/src/app/(app)/woche/page.tsx @@ -187,6 +187,7 @@ export default async function WocheSeite({ ganztaegig: t.ganztaegig, frei: t.zeigtAls === 'FREI', kalenderName: t.kalenderName, + gespiegelt: t.gespiegelt, }))} aufgaben={aufgaben.map((a) => ({ id: a.id, @@ -205,7 +206,8 @@ export default async function WocheSeite({

Aufgabe aus der linken Spalte in ein Zeitfenster ziehen, um sie einzuplanen. Ein Zeitblock lässt sich am Griff verschieben; das Kreuz plant ihn wieder aus. Graue - Balken sind Termine aus dem Kalender — sie lassen sich hier nicht verschieben. + Balken sind Termine aus dem Kalender, gestreifte kommen aus der anderen Instanz + — beide lassen sich hier nicht verschieben.

diff --git a/src/app/(app)/woche/wochenraster.tsx b/src/app/(app)/woche/wochenraster.tsx index d1de1ed..a4926cc 100644 --- a/src/app/(app)/woche/wochenraster.tsx +++ b/src/app/(app)/woche/wochenraster.tsx @@ -64,6 +64,8 @@ export type RasterTermin = { ganztaegig: boolean frei: boolean kalenderName: string + /** Aus der anderen Instanz gespiegelt: belegt, aber ohne Betreff. */ + gespiegelt?: boolean } export type RasterSpalte = { @@ -321,6 +323,11 @@ export function Wochenraster({ eintrag.frei ? 'bg-gedaempft/60 text-gedaempft-vordergrund' : 'bg-gedaempft text-gedaempft-vordergrund', + // Gespiegeltes gehört sichtbar in eine eigene + // Klasse: gestreift, damit niemand darin einen + // Termin mit fehlendem Betreff vermutet. + eintrag.gespiegelt && + 'border-rand border border-dashed bg-[repeating-linear-gradient(45deg,transparent,transparent_4px,color-mix(in_oklch,currentColor_12%,transparent)_4px,color-mix(in_oklch,currentColor_12%,transparent)_8px)]', )} style={{ top: lage.oben, @@ -328,10 +335,16 @@ export function Wochenraster({ left: `${(spur / spuren) * 100}%`, width: `${100 / spuren}%`, }} - title={`${eintrag.betreff ?? 'Termin'} · ${eintrag.kalenderName}`} + title={ + eintrag.gespiegelt + ? 'Dienstlich belegt – gespiegelt aus der anderen Instanz' + : `${eintrag.betreff ?? 'Termin'} · ${eintrag.kalenderName}` + } > - {eintrag.betreff ?? 'Termin'} + {eintrag.gespiegelt + ? 'Dienstlich belegt' + : (eintrag.betreff ?? 'Termin')} ) diff --git a/src/app/api/spiegel/route.ts b/src/app/api/spiegel/route.ts new file mode 100644 index 0000000..cbfeea9 --- /dev/null +++ b/src/app/api/spiegel/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from 'next/server' +import { auditSchreiben } from '@/server/audit' +import { sendungAufnehmen, sendungPruefen } from '@/server/integration/spiegel' +import { jetzt } from '@/server/zeitgeber' + +export const dynamic = 'force-dynamic' + +/** + * Der Eingang des Spiegels — nur in der Instanz `daheim`. + * + * Hier meldet sich kein Mensch an: Die Gegenstelle weist sich über eine + * Signatur aus, die sie mit dem gemeinsamen Geheimnis über den Rumpf bildet. + * + * Jede Zurückweisung sieht gleich aus. Ob das Geheimnis falsch war, die Uhr + * verstellt oder der Inhalt Unsinn — von außen ist das nicht zu unterscheiden, + * und genau so soll es sein. + */ + +export async function POST(anfrage: Request) { + // Der Rumpf muss als Text gelesen werden: Die Signatur gilt für genau diese + // Zeichen, nicht für das, was ein JSON-Parser daraus und wieder zurück macht. + const koerper = await anfrage.text() + const signatur = anfrage.headers.get('X-Planer-Signatur') + + const geprueft = sendungPruefen(koerper, signatur, jetzt()) + if (!geprueft.ok) { + return NextResponse.json({ fehler: 'Abgelehnt.' }, { status: 401 }) + } + + const bericht = await sendungAufnehmen(geprueft.sendung, { + id: null, + email: 'spiegel@intern', + }) + + await auditSchreiben({ + akteur: { id: null, email: 'spiegel@intern' }, + aktion: 'spiegel.empfangen', + objektTyp: 'Spiegelbelegung', + objektId: null, + // Nur Zahlen: Was gespiegelt wurde, steht in den Datensätzen selbst. + details: bericht, + }) + + return NextResponse.json({ ok: true, ...bericht }) +} diff --git a/src/components/aufgaben/aufgaben-liste.tsx b/src/components/aufgaben/aufgaben-liste.tsx index 4e3dfac..29784d4 100644 --- a/src/components/aufgaben/aufgaben-liste.tsx +++ b/src/components/aufgaben/aufgaben-liste.tsx @@ -14,6 +14,8 @@ type Eigenschaften = { leerText: string mitEinsortieren?: boolean mitUebernehmen?: boolean + /** Schalter zum Spiegeln zeigen; nur auf der sendenden Instanz sinnvoll. */ + mitSpiegeln?: boolean /** Bearbeiten direkt in der Liste erlauben. */ mitBearbeiten?: boolean } @@ -25,6 +27,7 @@ export function AufgabenListe({ leerText, mitEinsortieren = false, mitUebernehmen = false, + mitSpiegeln = false, mitBearbeiten = true, }: Eigenschaften) { const [bearbeitet, setBearbeitet] = useState(null) @@ -60,6 +63,7 @@ export function AufgabenListe({ bereiche={bereiche} mitEinsortieren={mitEinsortieren} mitUebernehmen={mitUebernehmen} + mitSpiegeln={mitSpiegeln} {...(mitBearbeiten ? { onBearbeiten: () => setBearbeitet(aufgabe.id) } : {})} diff --git a/src/components/aufgaben/aufgaben-zeile.tsx b/src/components/aufgaben/aufgaben-zeile.tsx index 73476a1..752adac 100644 --- a/src/components/aufgaben/aufgaben-zeile.tsx +++ b/src/components/aufgaben/aufgaben-zeile.tsx @@ -3,7 +3,15 @@ import { useState, useTransition } from 'react' import { useRouter } from 'next/navigation' import Link from 'next/link' -import { Check, ExternalLink, Loader2, Pencil, RotateCcw, Trash2 } from 'lucide-react' +import { + Check, + ExternalLink, + Loader2, + Pencil, + RotateCcw, + Share2, + Trash2, +} from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { Kennzeichen } from '@/components/ui/kennzeichen' @@ -22,6 +30,7 @@ import { aufgabeLoeschen, einsortieren, ideeUebernehmen, + spiegelnUmschalten, statusSetzen, type Rueckmeldung, } from '@/server/aktionen/aufgaben' @@ -36,6 +45,8 @@ type Eigenschaften = { mitEinsortieren?: boolean /** Zeigt den Knopf "Zur Aufgabe machen" (Ideen). */ mitUebernehmen?: boolean + /** Zeigt den Schalter zum Spiegeln; nur auf der sendenden Instanz. */ + mitSpiegeln?: boolean onBearbeiten?: (aufgabe: AufgabeAnzeige) => void } @@ -44,6 +55,7 @@ export function AufgabenZeile({ bereiche = [], mitEinsortieren = false, mitUebernehmen = false, + mitSpiegeln = false, onBearbeiten, }: Eigenschaften) { const router = useRouter() @@ -198,6 +210,29 @@ export function AufgabenZeile({
+ {mitSpiegeln && ( + + )} {onBearbeiten && (