Compare commits
@@ -97,6 +97,29 @@ SMTP_ABSENDER=
|
|||||||
# true bei Port 465 (SMTPS), false bei 587 mit STARTTLS.
|
# true bei Port 465 (SMTPS), false bei 587 mit STARTTLS.
|
||||||
SMTP_TLS=false
|
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 -------------------------------------------------------------
|
# --- Sonstiges -------------------------------------------------------------
|
||||||
# Zeitzone fuer Anzeigen und Tagesgrenzen.
|
# Zeitzone fuer Anzeigen und Tagesgrenzen.
|
||||||
TZ=Europe/Berlin
|
TZ=Europe/Berlin
|
||||||
|
|||||||
@@ -6,6 +6,49 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
## [Unveröffentlicht]
|
## [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
|
## [0.5.0] – 2026-09-11
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
|
|||||||
@@ -33,8 +33,12 @@ wird.
|
|||||||
Bereich, wie voll der heutige Tag schon ist
|
Bereich, wie voll der heutige Tag schon ist
|
||||||
- **Wochenplanung** per Ziehen und Ablegen: Aufgaben werden zu echten
|
- **Wochenplanung** per Ziehen und Ablegen: Aufgaben werden zu echten
|
||||||
Kalenderterminen, Verschiebungen im Kalender kommen zurück
|
Kalenderterminen, Verschiebungen im Kalender kommen zurück
|
||||||
- **Benachrichtigungen** per Web-Push und E-Mail: Tagesüberblick, fällige
|
- **Benachrichtigungen** per Web-Push, E-Mail und Telegram: Tagesüberblick,
|
||||||
Aufgaben, Vorlauf vor einem Zeitblock — mit Ruhezeiten und ab Werk still
|
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
|
- Anmeldung über einen OIDC-Anbieter, über Passkeys oder mit E-Mail und
|
||||||
Passwort — je Instanz einzeln zuschaltbar
|
Passwort — je Instanz einzeln zuschaltbar
|
||||||
- Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird
|
- Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird
|
||||||
@@ -45,7 +49,7 @@ wird.
|
|||||||
|
|
||||||
### Geplant
|
### Geplant
|
||||||
|
|
||||||
Integrationen, Schnellerfassung vom Handy und ein MCP-Server.
|
Schnellerfassung vom Handy, eine öffentliche API und ein MCP-Server.
|
||||||
Siehe [ROADMAP.md](ROADMAP.md).
|
Siehe [ROADMAP.md](ROADMAP.md).
|
||||||
|
|
||||||
## Bildergalerie
|
## Bildergalerie
|
||||||
@@ -63,6 +67,7 @@ Siehe [ROADMAP.md](ROADMAP.md).
|
|||||||
| Bereiche |  |  |
|
| Bereiche |  |  |
|
||||||
| Einstellungen |  |  |
|
| Einstellungen |  |  |
|
||||||
| Meldungen |  |  |
|
| Meldungen |  |  |
|
||||||
|
| Integrationen |  |  |
|
||||||
| Protokoll |  |  |
|
| Protokoll |  |  |
|
||||||
|
|
||||||
Alle Bilder entstehen mit `npm run screenshots` in einer Wegwerf-Instanz mit
|
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
|
Was der Planer wann meldet, steht im
|
||||||
[Handbuch](docs/handbuch/benachrichtigungen.md).
|
[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
|
### Einrichtung und Sonstiges
|
||||||
|
|
||||||
| Variable | Standard | Bedeutung |
|
| Variable | Standard | Bedeutung |
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ grünen Prüfungen, aktueller Dokumentation und einem Eintrag im DEVLOG.
|
|||||||
| [M3](#m3--kalender-lesen) | Kalender lesen | erledigt |
|
| [M3](#m3--kalender-lesen) | Kalender lesen | erledigt |
|
||||||
| [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | erledigt |
|
| [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | erledigt |
|
||||||
| [M5](#m5--benachrichtigungen) | Benachrichtigungen | 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 |
|
| [M7](#m7--schnellerfassung-und-api) | Schnellerfassung und API | geplant |
|
||||||
| [M8](#m8--mcp) | MCP | geplant |
|
| [M8](#m8--mcp) | MCP | geplant |
|
||||||
|
|
||||||
@@ -112,10 +112,25 @@ Erinnerung an einen Zeitblock, der längst begonnen hat, hilft niemandem.
|
|||||||
|
|
||||||
## M6 – Integrationen
|
## M6 – Integrationen
|
||||||
|
|
||||||
- [ ] Zammad und Wartungsplaner-API (nur `firma`)
|
**Erledigt am 12.09.2026 mit Version 0.6.0.**
|
||||||
- [ ] Telegram (nur `daheim`)
|
|
||||||
- [ ] Spiegel: `firma` sendet, `daheim` empfängt
|
**Ziel:** Aufgaben dort abholen, wo sie entstehen — ohne dass die Planung
|
||||||
- [ ] Jede Integration upsertet über `[quelle, quelleRef]`
|
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
|
## M7 – Schnellerfassung und API
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,121 @@ die Übersicht aus `scripts/zeit.sh summe`.
|
|||||||
## Zeit je Meilenstein
|
## Zeit je Meilenstein
|
||||||
|
|
||||||
| Meilenstein | Art | Minuten | Stunden |
|
| Meilenstein | Art | Minuten | Stunden |
|
||||||
| ----------- | ------ | ------- | --------- |
|
| ----------- | ------ | -------- | --------- |
|
||||||
| M1 | claude | 161 | 2,68 |
|
| M1 | claude | 161 | 2,68 |
|
||||||
| M2 | claude | 44 | 0,73 |
|
| M2 | claude | 44 | 0,73 |
|
||||||
| M3 | claude | 41 | 0,68 |
|
| M3 | claude | 41 | 0,68 |
|
||||||
| M4 | claude | 167 | 2,78 |
|
| M4 | claude | 167 | 2,78 |
|
||||||
| M5 | claude | 420 | 7,00 |
|
| M5 | claude | 420 | 7,00 |
|
||||||
| **Gesamt** | | **833** | **13,88** |
|
| 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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;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-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;06:35;13:35;420;M5;claude;M5 Benachrichtigungen fertig
|
||||||
|
2026-09-11;20:47;09:00;733;M6;claude;M6 Integrationen fertig
|
||||||
|
|||||||
|
@@ -68,5 +68,10 @@ services:
|
|||||||
# moeglich, hinaus geht trotzdem nichts.
|
# moeglich, hinaus geht trotzdem nichts.
|
||||||
SMTP_HOST: smtp.planer.invalid
|
SMTP_HOST: smtp.planer.invalid
|
||||||
SMTP_ABSENDER: Planer <planer@planer.invalid>
|
SMTP_ABSENDER: Planer <planer@planer.invalid>
|
||||||
|
# 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:
|
ports:
|
||||||
- '${SCREENSHOT_APP_PORT:-3200}:3000'
|
- '${SCREENSHOT_APP_PORT:-3200}:3000'
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 267 KiB After Width: | Height: | Size: 267 KiB |
|
Before Width: | Height: | Size: 282 KiB After Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 272 KiB After Width: | Height: | Size: 272 KiB |
|
Before Width: | Height: | Size: 288 KiB After Width: | Height: | Size: 289 KiB |
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 217 KiB After Width: | Height: | Size: 217 KiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 238 KiB After Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 282 KiB After Width: | Height: | Size: 291 KiB |
|
Before Width: | Height: | Size: 277 KiB After Width: | Height: | Size: 286 KiB |
|
Before Width: | Height: | Size: 289 KiB After Width: | Height: | Size: 298 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 236 KiB After Width: | Height: | Size: 237 KiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 242 KiB After Width: | Height: | Size: 243 KiB |
|
Before Width: | Height: | Size: 269 KiB After Width: | Height: | Size: 286 KiB |
|
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 349 KiB |
|
Before Width: | Height: | Size: 268 KiB After Width: | Height: | Size: 284 KiB |
|
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 349 KiB |
@@ -20,12 +20,14 @@ Anleitung für den Alltag mit dem Planer. Eine Seite je Thema.
|
|||||||
verbinden, Belegung verstehen
|
verbinden, Belegung verstehen
|
||||||
7. **[Wochenplanung](woche.md)** – Aufgaben per Ziehen und Ablegen in den
|
7. **[Wochenplanung](woche.md)** – Aufgaben per Ziehen und Ablegen in den
|
||||||
Kalender legen
|
Kalender legen
|
||||||
8. **[Benachrichtigungen](benachrichtigungen.md)** – Push und E-Mail
|
8. **[Benachrichtigungen](benachrichtigungen.md)** – Push, E-Mail und
|
||||||
einrichten, Ruhezeiten setzen
|
Telegram einrichten, Ruhezeiten setzen
|
||||||
|
9. **[Integrationen](integrationen.md)** – Zammad, Telegram und der Spiegel
|
||||||
|
zwischen Firma und Daheim
|
||||||
|
|
||||||
## Verwaltung
|
## 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
|
## Was noch kommt
|
||||||
|
|
||||||
@@ -33,7 +35,6 @@ Diese Seiten entstehen mit den nächsten Meilensteinen:
|
|||||||
|
|
||||||
| Thema | Meilenstein |
|
| Thema | Meilenstein |
|
||||||
| ------------------------------- | ----------- |
|
| ------------------------------- | ----------- |
|
||||||
| Integrationen | M6 |
|
|
||||||
| Schnellerfassung vom Handy, API | M7 |
|
| Schnellerfassung vom Handy, API | M7 |
|
||||||
| MCP | M8 |
|
| MCP | M8 |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# Integrationen
|
||||||
|
|
||||||
|
Was von außen hereinkommt — und was diese Instanz hinausgibt. Du findest den
|
||||||
|
Überblick im Menü unter **Integrationen**.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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).
|
||||||
@@ -6,6 +6,16 @@ export const EMAIL = process.env.ADMIN_EMAIL ?? ''
|
|||||||
export const PASSWORT = process.env.ADMIN_PASSWORT ?? ''
|
export const PASSWORT = process.env.ADMIN_PASSWORT ?? ''
|
||||||
export const VERBINDUNG = process.env.DATABASE_URL ?? ''
|
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.
|
* Meldet das Testkonto mit Passwort an.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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, unknown> = {}): 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "planer",
|
"name": "planer",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "planer",
|
"name": "planer",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "2.11.3",
|
"@auth/prisma-adapter": "2.11.3",
|
||||||
"@date-fns/tz": "1.5.0",
|
"@date-fns/tz": "1.5.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "planer",
|
"name": "planer",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender",
|
"description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test'
|
import { defineConfig, devices } from '@playwright/test'
|
||||||
import 'dotenv/config'
|
import 'dotenv/config'
|
||||||
|
import { SPIEGEL_GEHEIMNIS } from './e2e/hilfe'
|
||||||
|
|
||||||
const PORT = Number(process.env.E2E_PORT ?? 3100)
|
const PORT = Number(process.env.E2E_PORT ?? 3100)
|
||||||
const BASIS = `http://127.0.0.1:${PORT}`
|
const BASIS = `http://127.0.0.1:${PORT}`
|
||||||
@@ -41,6 +42,8 @@ export default defineConfig({
|
|||||||
APP_URL: BASIS,
|
APP_URL: BASIS,
|
||||||
WEBAUTHN_ORIGIN: BASIS,
|
WEBAUTHN_ORIGIN: BASIS,
|
||||||
WEBAUTHN_RP_ID: '127.0.0.1',
|
WEBAUTHN_RP_ID: '127.0.0.1',
|
||||||
|
// Ohne Geheimnis waere der Spiegel-Eingang zu und sein Test sinnlos.
|
||||||
|
SPIEGEL_GEHEIMNIS,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -429,6 +429,7 @@ async function main(): Promise<void> {
|
|||||||
anlaesse: 'tagesueberblick,faelligkeit,zeitblock',
|
anlaesse: 'tagesueberblick,faelligkeit,zeitblock',
|
||||||
perPush: true,
|
perPush: true,
|
||||||
perMail: true,
|
perMail: true,
|
||||||
|
perTelegram: true,
|
||||||
ueberblickUm: 7 * 60,
|
ueberblickUm: 7 * 60,
|
||||||
ruheVon: 20 * 60,
|
ruheVon: 20 * 60,
|
||||||
ruheBis: 7 * 60,
|
ruheBis: 7 * 60,
|
||||||
@@ -442,6 +443,52 @@ async function main(): Promise<void> {
|
|||||||
// nicht lesen kann. Ein erfundener Eintrag verschwände beim ersten Lauf
|
// nicht lesen kann. Ein erfundener Eintrag verschwände beim ersten Lauf
|
||||||
// wieder — die Bilder zeigen deshalb den Zustand vor der ersten Anmeldung.
|
// 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}`)
|
console.log(`Demo-Daten stehen bereit. Anmeldung: ${DEMO_EMAIL}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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");
|
||||||
@@ -38,6 +38,7 @@ model Benutzer {
|
|||||||
benachrichtigung Benachrichtigungseinstellung?
|
benachrichtigung Benachrichtigungseinstellung?
|
||||||
pushGeraete PushGeraet[]
|
pushGeraete PushGeraet[]
|
||||||
meldungen Meldungsnachweis[]
|
meldungen Meldungsnachweis[]
|
||||||
|
telegramChats TelegramChat[]
|
||||||
|
|
||||||
@@index([aktiv])
|
@@index([aktiv])
|
||||||
@@map("benutzer")
|
@@map("benutzer")
|
||||||
@@ -222,6 +223,10 @@ model Aufgabe {
|
|||||||
/// Kennung beim Ursprungssystem, z. B. die Ticketnummer.
|
/// Kennung beim Ursprungssystem, z. B. die Ticketnummer.
|
||||||
quelleRef String?
|
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())
|
erstelltAm DateTime @default(now())
|
||||||
aktualisiertAm DateTime @updatedAt
|
aktualisiertAm DateTime @updatedAt
|
||||||
|
|
||||||
@@ -394,6 +399,7 @@ model Benachrichtigungseinstellung {
|
|||||||
anlaesse String @default("tagesueberblick,faelligkeit")
|
anlaesse String @default("tagesueberblick,faelligkeit")
|
||||||
perPush Boolean @default(false)
|
perPush Boolean @default(false)
|
||||||
perMail Boolean @default(false)
|
perMail Boolean @default(false)
|
||||||
|
perTelegram Boolean @default(false)
|
||||||
|
|
||||||
/// Alles als Minuten seit Mitternacht, Europe/Berlin.
|
/// Alles als Minuten seit Mitternacht, Europe/Berlin.
|
||||||
ueberblickUm Int @default(420)
|
ueberblickUm Int @default(420)
|
||||||
@@ -448,3 +454,87 @@ model Meldungsnachweis {
|
|||||||
@@index([gesendetAm])
|
@@index([gesendetAm])
|
||||||
@@map("meldungsnachweis")
|
@@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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const AUFNAHMEN: Aufnahme[] = [
|
|||||||
{ name: 'ideen', pfad: '/ideen' },
|
{ name: 'ideen', pfad: '/ideen' },
|
||||||
{ name: 'bereiche', pfad: '/bereiche' },
|
{ name: 'bereiche', pfad: '/bereiche' },
|
||||||
{ name: 'kalenderkonten', pfad: '/kalenderkonten' },
|
{ name: 'kalenderkonten', pfad: '/kalenderkonten' },
|
||||||
|
{ name: 'integrationen', pfad: '/integrationen' },
|
||||||
{ name: 'einstellungen', pfad: '/einstellungen' },
|
{ name: 'einstellungen', pfad: '/einstellungen' },
|
||||||
{
|
{
|
||||||
name: 'benachrichtigungen',
|
name: 'benachrichtigungen',
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ async function scheitert(name: string, tun: () => Promise<unknown>): Promise<voi
|
|||||||
}
|
}
|
||||||
|
|
||||||
const kennung = `zzz-test-${Date.now()}`
|
const kennung = `zzz-test-${Date.now()}`
|
||||||
|
// Eigene Chatkennung je Lauf, damit das Aufraeumen genau diese Zeile trifft
|
||||||
|
// und nicht irgendeine, die zufaellig aehnlich aussieht.
|
||||||
|
const testChatId = BigInt(Date.now())
|
||||||
const testEmail = `${kennung}@example.invalid`
|
const testEmail = `${kennung}@example.invalid`
|
||||||
|
|
||||||
async function aufraeumen(): Promise<void> {
|
async function aufraeumen(): Promise<void> {
|
||||||
@@ -58,6 +61,12 @@ async function aufraeumen(): Promise<void> {
|
|||||||
await db.webauthnChallenge.deleteMany({ where: { kennung } })
|
await db.webauthnChallenge.deleteMany({ where: { kennung } })
|
||||||
await db.pushGeraet.deleteMany({ where: { endpunkt: { contains: kennung } } })
|
await db.pushGeraet.deleteMany({ where: { endpunkt: { contains: kennung } } })
|
||||||
await db.meldungsnachweis.deleteMany({ where: { schluessel: { 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<void> {
|
async function main(): Promise<void> {
|
||||||
@@ -316,6 +325,76 @@ async function main(): Promise<void> {
|
|||||||
).id.length > 0,
|
).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 } })
|
await db.benutzer.delete({ where: { id: benutzer.id } })
|
||||||
|
|
||||||
const nachher = await db.auditEintrag.findUnique({ where: { id: audit.id } })
|
const nachher = await db.auditEintrag.findUnique({ where: { id: audit.id } })
|
||||||
@@ -341,6 +420,10 @@ async function main(): Promise<void> {
|
|||||||
'Push-Geräte verschwinden mit dem Benutzer',
|
'Push-Geräte verschwinden mit dem Benutzer',
|
||||||
(await db.pushGeraet.count({ where: { endpunkt: { contains: kennung } } })) === 0,
|
(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(
|
pruefe(
|
||||||
'Meldungsnachweise verschwinden mit dem Benutzer',
|
'Meldungsnachweise verschwinden mit dem Benutzer',
|
||||||
(await db.meldungsnachweis.count({
|
(await db.meldungsnachweis.count({
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type AufgabenFilter,
|
type AufgabenFilter,
|
||||||
} from '@/server/abfragen/aufgaben'
|
} from '@/server/abfragen/aufgaben'
|
||||||
import { verlangeAnmeldung } from '@/server/berechtigung'
|
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||||
|
import { umgebung } from '@/server/umgebung'
|
||||||
|
|
||||||
export const metadata: Metadata = { title: 'Aufgaben' }
|
export const metadata: Metadata = { title: 'Aufgaben' }
|
||||||
|
|
||||||
@@ -67,6 +68,7 @@ export default async function AufgabenSeite({
|
|||||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||||
}) {
|
}) {
|
||||||
await verlangeAnmeldung('/aufgaben')
|
await verlangeAnmeldung('/aufgaben')
|
||||||
|
const spiegelt = umgebung().spiegelSendenAktiv
|
||||||
const parameter = await searchParams
|
const parameter = await searchParams
|
||||||
|
|
||||||
const [aufgaben, bereiche, projekte] = await Promise.all([
|
const [aufgaben, bereiche, projekte] = await Promise.all([
|
||||||
@@ -101,11 +103,13 @@ export default async function AufgabenSeite({
|
|||||||
</CardInhalt>
|
</CardInhalt>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Nur wo auch gespiegelt wird, hat der Schalter einen Sinn. */}
|
||||||
<AufgabenListe
|
<AufgabenListe
|
||||||
aufgaben={aufgaben}
|
aufgaben={aufgaben}
|
||||||
bereiche={bereiche}
|
bereiche={bereiche}
|
||||||
projekte={projekte}
|
projekte={projekte}
|
||||||
leerText="Keine Aufgabe passt zu diesen Filtern."
|
leerText="Keine Aufgabe passt zu diesen Filtern."
|
||||||
|
mitSpiegeln={spiegelt}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export type EinstellungAnzeige = {
|
|||||||
anlaesse: Anlass[]
|
anlaesse: Anlass[]
|
||||||
perPush: boolean
|
perPush: boolean
|
||||||
perMail: boolean
|
perMail: boolean
|
||||||
|
perTelegram: boolean
|
||||||
ueberblickUm: string
|
ueberblickUm: string
|
||||||
ruheVon: string
|
ruheVon: string
|
||||||
ruheBis: string
|
ruheBis: string
|
||||||
@@ -46,6 +47,9 @@ type Eigenschaften = {
|
|||||||
geraete: GeraetAnzeige[]
|
geraete: GeraetAnzeige[]
|
||||||
pushMoeglich: boolean
|
pushMoeglich: boolean
|
||||||
mailMoeglich: boolean
|
mailMoeglich: boolean
|
||||||
|
telegramMoeglich: boolean
|
||||||
|
/** Wie viele Chats Meldungen empfangen. Null heisst: noch keiner. */
|
||||||
|
telegramChats: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,6 +72,8 @@ export function Benachrichtigungen({
|
|||||||
geraete,
|
geraete,
|
||||||
pushMoeglich,
|
pushMoeglich,
|
||||||
mailMoeglich,
|
mailMoeglich,
|
||||||
|
telegramMoeglich,
|
||||||
|
telegramChats,
|
||||||
}: Eigenschaften) {
|
}: Eigenschaften) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [laeuft, starte] = useTransition()
|
const [laeuft, starte] = useTransition()
|
||||||
@@ -311,6 +317,35 @@ export function Benachrichtigungen({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="perTelegram"
|
||||||
|
defaultChecked={einstellung.perTelegram}
|
||||||
|
className="accent-betont size-4"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">
|
||||||
|
Telegram
|
||||||
|
{!telegramMoeglich ? (
|
||||||
|
<span className="text-gedaempft-vordergrund">
|
||||||
|
{' '}
|
||||||
|
— auf diesem Server nicht eingerichtet
|
||||||
|
</span>
|
||||||
|
) : telegramChats === 0 ? (
|
||||||
|
<span className="text-gedaempft-vordergrund">
|
||||||
|
{' '}
|
||||||
|
— noch kein Chat eingetragen
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gedaempft-vordergrund">
|
||||||
|
{' '}
|
||||||
|
— an {telegramChats} Chat{telegramChats === 1 ? '' : 's'}, mit Knopf zum
|
||||||
|
Abhaken
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
|||||||
@@ -23,13 +23,14 @@ export default async function EinstellungenSeite() {
|
|||||||
|
|
||||||
const einst = umgebung()
|
const einst = umgebung()
|
||||||
|
|
||||||
const [benachrichtigung, pushGeraete] = await Promise.all([
|
const [benachrichtigung, telegramChats, pushGeraete] = await Promise.all([
|
||||||
db.benachrichtigungseinstellung.findUnique({
|
db.benachrichtigungseinstellung.findUnique({
|
||||||
where: { benutzerId: benutzer.id },
|
where: { benutzerId: benutzer.id },
|
||||||
select: {
|
select: {
|
||||||
anlaesse: true,
|
anlaesse: true,
|
||||||
perPush: true,
|
perPush: true,
|
||||||
perMail: true,
|
perMail: true,
|
||||||
|
perTelegram: true,
|
||||||
ueberblickUm: true,
|
ueberblickUm: true,
|
||||||
ruheVon: true,
|
ruheVon: true,
|
||||||
ruheBis: true,
|
ruheBis: true,
|
||||||
@@ -37,6 +38,7 @@ export default async function EinstellungenSeite() {
|
|||||||
zeitblockVorlaufMinuten: true,
|
zeitblockVorlaufMinuten: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
db.telegramChat.count({ where: { benutzerId: benutzer.id, meldungen: true } }),
|
||||||
db.pushGeraet.findMany({
|
db.pushGeraet.findMany({
|
||||||
where: { benutzerId: benutzer.id },
|
where: { benutzerId: benutzer.id },
|
||||||
orderBy: { erstelltAm: 'asc' },
|
orderBy: { erstelltAm: 'asc' },
|
||||||
@@ -119,10 +121,13 @@ export default async function EinstellungenSeite() {
|
|||||||
<Benachrichtigungen
|
<Benachrichtigungen
|
||||||
pushMoeglich={einst.pushAktiv}
|
pushMoeglich={einst.pushAktiv}
|
||||||
mailMoeglich={einst.mailAktiv}
|
mailMoeglich={einst.mailAktiv}
|
||||||
|
telegramMoeglich={einst.telegramAktiv}
|
||||||
|
telegramChats={telegramChats}
|
||||||
einstellung={{
|
einstellung={{
|
||||||
anlaesse: [...einstellung.anlaesse],
|
anlaesse: [...einstellung.anlaesse],
|
||||||
perPush: einstellung.perPush,
|
perPush: einstellung.perPush,
|
||||||
perMail: einstellung.perMail,
|
perMail: einstellung.perMail,
|
||||||
|
perTelegram: einstellung.perTelegram,
|
||||||
ueberblickUm: alsUhrzeit(einstellung.ueberblickUm),
|
ueberblickUm: alsUhrzeit(einstellung.ueberblickUm),
|
||||||
ruheVon: alsUhrzeit(einstellung.ruheVon),
|
ruheVon: alsUhrzeit(einstellung.ruheVon),
|
||||||
ruheBis: alsUhrzeit(einstellung.ruheBis),
|
ruheBis: alsUhrzeit(einstellung.ruheBis),
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useTransition } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { Loader2, RefreshCw } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { laufAnstossen } from '@/server/aktionen/integrationen'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stößt einen Lauf von Hand an.
|
||||||
|
*
|
||||||
|
* Vor allem direkt nach dem Einrichten: Sonst wüsste man erst beim nächsten
|
||||||
|
* Takt, ob die Zugangsdaten stimmen.
|
||||||
|
*/
|
||||||
|
export function LaufKnopf({ quelle }: { quelle: string }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [laeuft, starte] = useTransition()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
art="umriss"
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() => {
|
||||||
|
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 ? (
|
||||||
|
<Loader2 className="animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<RefreshCw aria-hidden />
|
||||||
|
)}
|
||||||
|
Jetzt abrufen
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Plug className="text-gedaempft-vordergrund size-6" aria-hidden />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Integrationen</h1>
|
||||||
|
<p className="text-gedaempft-vordergrund text-sm">
|
||||||
|
Was von außen hereinkommt — und was diese Instanz hinausgibt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{zeilen.map((zeile) => {
|
||||||
|
const zustand = nachQuelle.get(zeile.quelle)
|
||||||
|
const gestoert = (zustand?.fehlversuche ?? 0) >= INTEGRATION_FEHLER_AB_VERSUCH
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={zeile.quelle} className={zeile.vorgesehen ? '' : 'opacity-60'}>
|
||||||
|
<CardKopf>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<CardTitel className="text-base">{zeile.name}</CardTitel>
|
||||||
|
{!zeile.vorgesehen ? (
|
||||||
|
<Kennzeichen art="neutral">
|
||||||
|
In dieser Instanz nicht vorgesehen
|
||||||
|
</Kennzeichen>
|
||||||
|
) : gestoert ? (
|
||||||
|
<Kennzeichen art="gefahr">Gestört</Kennzeichen>
|
||||||
|
) : zeile.aktiv ? (
|
||||||
|
<Kennzeichen art="erfolg">Läuft</Kennzeichen>
|
||||||
|
) : (
|
||||||
|
<Kennzeichen art="warnung">Nicht eingerichtet</Kennzeichen>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<CardBeschreibung>{zeile.beschreibung}</CardBeschreibung>
|
||||||
|
</CardKopf>
|
||||||
|
|
||||||
|
<CardInhalt className="text-gedaempft-vordergrund flex flex-col gap-1 text-sm">
|
||||||
|
{zeile.vorgesehen && !zeile.aktiv && <p>{zeile.hinweis}</p>}
|
||||||
|
|
||||||
|
{zustand?.letzterErfolg && (
|
||||||
|
<p>Zuletzt geglückt: {alsDatumUhrzeit(zustand.letzterErfolg)}</p>
|
||||||
|
)}
|
||||||
|
{zustand?.letzterFehler && (
|
||||||
|
<p className="text-gefahr">
|
||||||
|
Letzter Fehler: {zustand.letzterFehler}
|
||||||
|
{zustand.fehlversuche > 1 && ` (${zustand.fehlversuche}× in Folge)`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!zustand && zeile.aktiv && <p>Noch kein Lauf.</p>}
|
||||||
|
|
||||||
|
{zeile.abrufbar && (
|
||||||
|
<div className="pt-2">
|
||||||
|
<LaufKnopf quelle={zeile.quelle} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{einst.telegramAktiv && (
|
||||||
|
<Card>
|
||||||
|
<CardKopf>
|
||||||
|
<CardTitel className="text-base">Telegram-Chats</CardTitel>
|
||||||
|
<CardBeschreibung>
|
||||||
|
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.
|
||||||
|
</CardBeschreibung>
|
||||||
|
</CardKopf>
|
||||||
|
<CardInhalt>
|
||||||
|
<TelegramChats
|
||||||
|
chats={chats.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
chatId: c.chatId.toString(),
|
||||||
|
bezeichnung: c.bezeichnung,
|
||||||
|
meldungen: c.meldungen,
|
||||||
|
erstelltAm: alsDatumUhrzeit(c.erstelltAm),
|
||||||
|
zuletztBenutzt: c.zuletztBenutzt
|
||||||
|
? alsDatumUhrzeit(c.zuletztBenutzt)
|
||||||
|
: null,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{chats.length === 0 ? (
|
||||||
|
<p className="text-gedaempft-vordergrund text-sm">
|
||||||
|
Noch kein Chat verbunden. Ohne Verbindung nimmt der Bot nichts an.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-rand divide-y">
|
||||||
|
{chats.map((chat) => (
|
||||||
|
<li key={chat.id} className="flex items-center gap-3 py-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm">{chat.bezeichnung ?? `Chat ${chat.chatId}`}</p>
|
||||||
|
<p className="text-gedaempft-vordergrund text-xs">
|
||||||
|
Verbunden {chat.erstelltAm}
|
||||||
|
{chat.zuletztBenutzt
|
||||||
|
? ` · zuletzt benutzt ${chat.zuletztBenutzt}`
|
||||||
|
: ' · noch nichts geschickt'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
art="leise"
|
||||||
|
groesse="quadrat"
|
||||||
|
title={
|
||||||
|
chat.meldungen ? 'Keine Meldungen mehr hierher' : 'Meldungen hierher'
|
||||||
|
}
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() => {
|
||||||
|
const daten = new FormData()
|
||||||
|
daten.set('id', chat.id)
|
||||||
|
mitRueckmeldung(() => chatMeldungenUmschalten(daten))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{chat.meldungen ? (
|
||||||
|
<Bell className="text-erfolg" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<BellOff aria-hidden />
|
||||||
|
)}
|
||||||
|
<span className="sr-only">Meldungen umschalten</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
art="leise"
|
||||||
|
groesse="quadrat"
|
||||||
|
title="Chat trennen"
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() => {
|
||||||
|
const daten = new FormData()
|
||||||
|
daten.set('id', chat.id)
|
||||||
|
mitRueckmeldung(() => chatEntfernen(daten))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden />
|
||||||
|
<span className="sr-only">Trennen</span>
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{code ? (
|
||||||
|
<div className="border-rand rounded-md border p-3">
|
||||||
|
<p className="text-sm">
|
||||||
|
Schick dem Bot jetzt diesen Code — als Nachricht oder als{' '}
|
||||||
|
<code className="text-xs">/start {code.code}</code>:
|
||||||
|
</p>
|
||||||
|
<p className="my-2 font-mono text-2xl tracking-widest">{code.code}</p>
|
||||||
|
<p className="text-gedaempft-vordergrund text-xs">
|
||||||
|
Gültig für {code.minuten} Minuten und nur ein einziges Mal. Danach erscheint
|
||||||
|
der Chat hier — spätestens nach dem nächsten Abholen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<Button art="umriss" disabled={laeuft} onClick={anfordern}>
|
||||||
|
{laeuft ? (
|
||||||
|
<Loader2 className="animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Link2 aria-hidden />
|
||||||
|
)}
|
||||||
|
Chat verbinden
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -187,6 +187,7 @@ export default async function WocheSeite({
|
|||||||
ganztaegig: t.ganztaegig,
|
ganztaegig: t.ganztaegig,
|
||||||
frei: t.zeigtAls === 'FREI',
|
frei: t.zeigtAls === 'FREI',
|
||||||
kalenderName: t.kalenderName,
|
kalenderName: t.kalenderName,
|
||||||
|
gespiegelt: t.gespiegelt,
|
||||||
}))}
|
}))}
|
||||||
aufgaben={aufgaben.map((a) => ({
|
aufgaben={aufgaben.map((a) => ({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
@@ -205,7 +206,8 @@ export default async function WocheSeite({
|
|||||||
<p className="text-gedaempft-vordergrund text-xs">
|
<p className="text-gedaempft-vordergrund text-xs">
|
||||||
Aufgabe aus der linken Spalte in ein Zeitfenster ziehen, um sie einzuplanen. Ein
|
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
|
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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ export type RasterTermin = {
|
|||||||
ganztaegig: boolean
|
ganztaegig: boolean
|
||||||
frei: boolean
|
frei: boolean
|
||||||
kalenderName: string
|
kalenderName: string
|
||||||
|
/** Aus der anderen Instanz gespiegelt: belegt, aber ohne Betreff. */
|
||||||
|
gespiegelt?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RasterSpalte = {
|
export type RasterSpalte = {
|
||||||
@@ -321,6 +323,11 @@ export function Wochenraster({
|
|||||||
eintrag.frei
|
eintrag.frei
|
||||||
? 'bg-gedaempft/60 text-gedaempft-vordergrund'
|
? 'bg-gedaempft/60 text-gedaempft-vordergrund'
|
||||||
: 'bg-gedaempft 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={{
|
style={{
|
||||||
top: lage.oben,
|
top: lage.oben,
|
||||||
@@ -328,10 +335,16 @@ export function Wochenraster({
|
|||||||
left: `${(spur / spuren) * 100}%`,
|
left: `${(spur / spuren) * 100}%`,
|
||||||
width: `${100 / spuren}%`,
|
width: `${100 / spuren}%`,
|
||||||
}}
|
}}
|
||||||
title={`${eintrag.betreff ?? 'Termin'} · ${eintrag.kalenderName}`}
|
title={
|
||||||
|
eintrag.gespiegelt
|
||||||
|
? 'Dienstlich belegt – gespiegelt aus der anderen Instanz'
|
||||||
|
: `${eintrag.betreff ?? 'Termin'} · ${eintrag.kalenderName}`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<span className="line-clamp-2">
|
<span className="line-clamp-2">
|
||||||
{eintrag.betreff ?? 'Termin'}
|
{eintrag.gespiegelt
|
||||||
|
? 'Dienstlich belegt'
|
||||||
|
: (eintrag.betreff ?? 'Termin')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ type Eigenschaften = {
|
|||||||
leerText: string
|
leerText: string
|
||||||
mitEinsortieren?: boolean
|
mitEinsortieren?: boolean
|
||||||
mitUebernehmen?: boolean
|
mitUebernehmen?: boolean
|
||||||
|
/** Schalter zum Spiegeln zeigen; nur auf der sendenden Instanz sinnvoll. */
|
||||||
|
mitSpiegeln?: boolean
|
||||||
/** Bearbeiten direkt in der Liste erlauben. */
|
/** Bearbeiten direkt in der Liste erlauben. */
|
||||||
mitBearbeiten?: boolean
|
mitBearbeiten?: boolean
|
||||||
}
|
}
|
||||||
@@ -25,6 +27,7 @@ export function AufgabenListe({
|
|||||||
leerText,
|
leerText,
|
||||||
mitEinsortieren = false,
|
mitEinsortieren = false,
|
||||||
mitUebernehmen = false,
|
mitUebernehmen = false,
|
||||||
|
mitSpiegeln = false,
|
||||||
mitBearbeiten = true,
|
mitBearbeiten = true,
|
||||||
}: Eigenschaften) {
|
}: Eigenschaften) {
|
||||||
const [bearbeitet, setBearbeitet] = useState<string | null>(null)
|
const [bearbeitet, setBearbeitet] = useState<string | null>(null)
|
||||||
@@ -60,6 +63,7 @@ export function AufgabenListe({
|
|||||||
bereiche={bereiche}
|
bereiche={bereiche}
|
||||||
mitEinsortieren={mitEinsortieren}
|
mitEinsortieren={mitEinsortieren}
|
||||||
mitUebernehmen={mitUebernehmen}
|
mitUebernehmen={mitUebernehmen}
|
||||||
|
mitSpiegeln={mitSpiegeln}
|
||||||
{...(mitBearbeiten
|
{...(mitBearbeiten
|
||||||
? { onBearbeiten: () => setBearbeitet(aufgabe.id) }
|
? { onBearbeiten: () => setBearbeitet(aufgabe.id) }
|
||||||
: {})}
|
: {})}
|
||||||
|
|||||||
@@ -3,7 +3,15 @@
|
|||||||
import { useState, useTransition } from 'react'
|
import { useState, useTransition } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
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 { toast } from 'sonner'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Kennzeichen } from '@/components/ui/kennzeichen'
|
import { Kennzeichen } from '@/components/ui/kennzeichen'
|
||||||
@@ -22,6 +30,7 @@ import {
|
|||||||
aufgabeLoeschen,
|
aufgabeLoeschen,
|
||||||
einsortieren,
|
einsortieren,
|
||||||
ideeUebernehmen,
|
ideeUebernehmen,
|
||||||
|
spiegelnUmschalten,
|
||||||
statusSetzen,
|
statusSetzen,
|
||||||
type Rueckmeldung,
|
type Rueckmeldung,
|
||||||
} from '@/server/aktionen/aufgaben'
|
} from '@/server/aktionen/aufgaben'
|
||||||
@@ -36,6 +45,8 @@ type Eigenschaften = {
|
|||||||
mitEinsortieren?: boolean
|
mitEinsortieren?: boolean
|
||||||
/** Zeigt den Knopf "Zur Aufgabe machen" (Ideen). */
|
/** Zeigt den Knopf "Zur Aufgabe machen" (Ideen). */
|
||||||
mitUebernehmen?: boolean
|
mitUebernehmen?: boolean
|
||||||
|
/** Zeigt den Schalter zum Spiegeln; nur auf der sendenden Instanz. */
|
||||||
|
mitSpiegeln?: boolean
|
||||||
onBearbeiten?: (aufgabe: AufgabeAnzeige) => void
|
onBearbeiten?: (aufgabe: AufgabeAnzeige) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +55,7 @@ export function AufgabenZeile({
|
|||||||
bereiche = [],
|
bereiche = [],
|
||||||
mitEinsortieren = false,
|
mitEinsortieren = false,
|
||||||
mitUebernehmen = false,
|
mitUebernehmen = false,
|
||||||
|
mitSpiegeln = false,
|
||||||
onBearbeiten,
|
onBearbeiten,
|
||||||
}: Eigenschaften) {
|
}: Eigenschaften) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -198,6 +210,29 @@ export function AufgabenZeile({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{mitSpiegeln && (
|
||||||
|
<Button
|
||||||
|
art="leise"
|
||||||
|
groesse="quadrat"
|
||||||
|
title={
|
||||||
|
aufgabe.spiegeln
|
||||||
|
? 'Geht mit nach Hause – hier abschalten'
|
||||||
|
: 'Mit nach Hause spiegeln'
|
||||||
|
}
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() => {
|
||||||
|
const daten = new FormData()
|
||||||
|
daten.set('id', aufgabe.id)
|
||||||
|
mitRueckmeldung(() => spiegelnUmschalten(daten))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Share2
|
||||||
|
className={aufgabe.spiegeln ? 'text-betont' : undefined}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Spiegeln</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{onBearbeiten && (
|
{onBearbeiten && (
|
||||||
<Button
|
<Button
|
||||||
art="leise"
|
art="leise"
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export function Kopfleiste({
|
|||||||
{ pfad: '/ideen', name: 'Ideen' },
|
{ pfad: '/ideen', name: 'Ideen' },
|
||||||
{ pfad: '/bereiche', name: 'Bereiche' },
|
{ pfad: '/bereiche', name: 'Bereiche' },
|
||||||
{ pfad: '/kalenderkonten', name: 'Kalender' },
|
{ pfad: '/kalenderkonten', name: 'Kalender' },
|
||||||
|
{ pfad: '/integrationen', name: 'Integrationen' },
|
||||||
{ pfad: '/einstellungen', name: 'Einstellungen' },
|
{ pfad: '/einstellungen', name: 'Einstellungen' },
|
||||||
{ pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true },
|
{ pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true },
|
||||||
]
|
]
|
||||||
@@ -49,7 +50,13 @@ export function Kopfleiste({
|
|||||||
Navigation waagerecht, statt umzubrechen und den Namen der Anwendung in
|
Navigation waagerecht, statt umzubrechen und den Namen der Anwendung in
|
||||||
die zweite Zeile zu drängen.
|
die zweite Zeile zu drängen.
|
||||||
*/}
|
*/}
|
||||||
<div className="mx-auto flex w-full max-w-5xl items-center gap-3 px-4 py-3 sm:gap-6">
|
{/*
|
||||||
|
Breiter als die Inhaltsspalte (max-w-5xl): Mit dem elften Eintrag
|
||||||
|
passte die Leiste dort nicht mehr, und "Einstellungen" verschwand
|
||||||
|
stillschweigend am rechten Rand. Eine Leiste, die Punkte verschluckt,
|
||||||
|
ist schlimmer als eine, die nicht buendig mit dem Text steht.
|
||||||
|
*/}
|
||||||
|
<div className="mx-auto flex w-full max-w-7xl items-center gap-3 px-4 py-3 sm:gap-5">
|
||||||
<Link href="/" className="shrink-0 text-base font-semibold tracking-tight">
|
<Link href="/" className="shrink-0 text-base font-semibold tracking-tight">
|
||||||
Planer
|
Planer
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ function roh(teil: Partial<AufgabeRoh> = {}): AufgabeRoh {
|
|||||||
faelligAm: null,
|
faelligAm: null,
|
||||||
quelle: 'MANUELL',
|
quelle: 'MANUELL',
|
||||||
quelleUrl: null,
|
quelleUrl: null,
|
||||||
|
spiegeln: false,
|
||||||
bereich: null,
|
bereich: null,
|
||||||
projekt: null,
|
projekt: null,
|
||||||
...teil,
|
...teil,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export type AufgabeRoh = {
|
|||||||
faelligAm: Date | null
|
faelligAm: Date | null
|
||||||
quelle: Quelle
|
quelle: Quelle
|
||||||
quelleUrl: string | null
|
quelleUrl: string | null
|
||||||
|
spiegeln: boolean
|
||||||
bereich: { id: string; name: string; farbe: string } | null
|
bereich: { id: string; name: string; farbe: string } | null
|
||||||
projekt: { id: string; name: string } | null
|
projekt: { id: string; name: string } | null
|
||||||
}
|
}
|
||||||
@@ -48,6 +49,8 @@ export type AufgabeAnzeige = {
|
|||||||
ueberfaellig: boolean
|
ueberfaellig: boolean
|
||||||
quelle: Quelle
|
quelle: Quelle
|
||||||
quelleUrl: string | null
|
quelleUrl: string | null
|
||||||
|
/** Ausdruecklich zum Spiegeln freigegeben. */
|
||||||
|
spiegeln: boolean
|
||||||
bereich: { id: string; name: string; farbe: string } | null
|
bereich: { id: string; name: string; farbe: string } | null
|
||||||
projekt: { id: string; name: string } | null
|
projekt: { id: string; name: string } | null
|
||||||
}
|
}
|
||||||
@@ -79,6 +82,7 @@ export function fuerAnzeige(aufgabe: AufgabeRoh, jetzt: Date): AufgabeAnzeige {
|
|||||||
ueberfaellig: istUeberfaellig(aufgabe.faelligAm, aufgabe.status, jetzt),
|
ueberfaellig: istUeberfaellig(aufgabe.faelligAm, aufgabe.status, jetzt),
|
||||||
quelle: aufgabe.quelle,
|
quelle: aufgabe.quelle,
|
||||||
quelleUrl: aufgabe.quelleUrl,
|
quelleUrl: aufgabe.quelleUrl,
|
||||||
|
spiegeln: aufgabe.spiegeln,
|
||||||
bereich: aufgabe.bereich,
|
bereich: aufgabe.bereich,
|
||||||
projekt: aufgabe.projekt,
|
projekt: aufgabe.projekt,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export type Einstellung = {
|
|||||||
anlaesse: readonly Anlass[]
|
anlaesse: readonly Anlass[]
|
||||||
perPush: boolean
|
perPush: boolean
|
||||||
perMail: boolean
|
perMail: boolean
|
||||||
|
perTelegram: boolean
|
||||||
/** Uhrzeit des Tagesüberblicks, Minuten seit Mitternacht. */
|
/** Uhrzeit des Tagesüberblicks, Minuten seit Mitternacht. */
|
||||||
ueberblickUm: number
|
ueberblickUm: number
|
||||||
/** Ruhezeit: von hier bis … nichts melden. */
|
/** Ruhezeit: von hier bis … nichts melden. */
|
||||||
@@ -68,6 +69,7 @@ export const VOREINSTELLUNG: Einstellung = {
|
|||||||
anlaesse: ['tagesueberblick', 'faelligkeit'],
|
anlaesse: ['tagesueberblick', 'faelligkeit'],
|
||||||
perPush: false,
|
perPush: false,
|
||||||
perMail: false,
|
perMail: false,
|
||||||
|
perTelegram: false,
|
||||||
ueberblickUm: 7 * 60,
|
ueberblickUm: 7 * 60,
|
||||||
ruheVon: 20 * 60,
|
ruheVon: 20 * 60,
|
||||||
ruheBis: 7 * 60,
|
ruheBis: 7 * 60,
|
||||||
@@ -103,6 +105,13 @@ export type Meldung = {
|
|||||||
text: string
|
text: string
|
||||||
/** Wohin der Klick führt. */
|
/** Wohin der Klick führt. */
|
||||||
pfad: string
|
pfad: string
|
||||||
|
/**
|
||||||
|
* Die Aufgabe, um die es geht — sofern es genau eine ist.
|
||||||
|
*
|
||||||
|
* Nur dann lässt sich unter die Meldung ein Knopf hängen, der sie abhakt.
|
||||||
|
* Ein Tagesüberblick hat keine.
|
||||||
|
*/
|
||||||
|
objektId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -124,7 +133,7 @@ export function sendenEntscheiden(
|
|||||||
if (!einstellung.anlaesse.includes(meldung.anlass)) {
|
if (!einstellung.anlaesse.includes(meldung.anlass)) {
|
||||||
return { senden: false, grund: 'abgeschaltet' }
|
return { senden: false, grund: 'abgeschaltet' }
|
||||||
}
|
}
|
||||||
if (!einstellung.perPush && !einstellung.perMail) {
|
if (!einstellung.perPush && !einstellung.perMail && !einstellung.perTelegram) {
|
||||||
return { senden: false, grund: 'kein-weg' }
|
return { senden: false, grund: 'kein-weg' }
|
||||||
}
|
}
|
||||||
if (bereitsGemeldet.has(meldung.schluessel)) {
|
if (bereitsGemeldet.has(meldung.schluessel)) {
|
||||||
@@ -202,6 +211,7 @@ export const einstellungSchema = z.object({
|
|||||||
anlaesse: z.array(z.enum(ANLAESSE)).default([]),
|
anlaesse: z.array(z.enum(ANLAESSE)).default([]),
|
||||||
perPush: z.coerce.boolean().default(false),
|
perPush: z.coerce.boolean().default(false),
|
||||||
perMail: z.coerce.boolean().default(false),
|
perMail: z.coerce.boolean().default(false),
|
||||||
|
perTelegram: z.coerce.boolean().default(false),
|
||||||
ueberblickUm: uhrzeit,
|
ueberblickUm: uhrzeit,
|
||||||
ruheVon: uhrzeit,
|
ruheVon: uhrzeit,
|
||||||
ruheBis: uhrzeit,
|
ruheBis: uhrzeit,
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { describe, expect, test } from 'vitest'
|
||||||
|
import {
|
||||||
|
alsTitel,
|
||||||
|
sendungFrisch,
|
||||||
|
spiegelSendung,
|
||||||
|
TITEL_MAX,
|
||||||
|
uebernahmeEntscheiden,
|
||||||
|
type Bestand,
|
||||||
|
type Fund,
|
||||||
|
} from '@/lib/integration'
|
||||||
|
|
||||||
|
function fund(teile: Partial<Fund> = {}): Fund {
|
||||||
|
return { ref: 'T-1', titel: 'Drucker klemmt', ...teile }
|
||||||
|
}
|
||||||
|
|
||||||
|
function bestand(teile: Partial<Bestand> = {}): Bestand {
|
||||||
|
return {
|
||||||
|
titel: 'Drucker klemmt',
|
||||||
|
notiz: null,
|
||||||
|
quelleUrl: null,
|
||||||
|
faelligAm: null,
|
||||||
|
prioritaet: 3,
|
||||||
|
aufwandMinuten: null,
|
||||||
|
erledigt: false,
|
||||||
|
...teile,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('uebernahmeEntscheiden', () => {
|
||||||
|
test('legt an, was es hier noch nicht gibt', () => {
|
||||||
|
expect(uebernahmeEntscheiden(fund(), null)).toEqual({ tun: 'anlegen' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('legt nichts an, was drüben schon erledigt ist', () => {
|
||||||
|
expect(uebernahmeEntscheiden(fund({ erledigt: true }), null)).toEqual({
|
||||||
|
tun: 'nichts',
|
||||||
|
grund: 'schon-erledigt',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('erkennt den unveränderten Fall', () => {
|
||||||
|
expect(uebernahmeEntscheiden(fund(), bestand())).toEqual({
|
||||||
|
tun: 'nichts',
|
||||||
|
grund: 'unveraendert',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('übernimmt einen geänderten Titel', () => {
|
||||||
|
const entscheidung = uebernahmeEntscheiden(
|
||||||
|
fund({ titel: 'Drucker klemmt weiterhin' }),
|
||||||
|
bestand(),
|
||||||
|
)
|
||||||
|
expect(entscheidung).toEqual({
|
||||||
|
tun: 'aktualisieren',
|
||||||
|
felder: { titel: 'Drucker klemmt weiterhin' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('übernimmt eine neue Fälligkeit', () => {
|
||||||
|
const faelligAm = new Date('2026-04-01T00:00:00.000Z')
|
||||||
|
expect(uebernahmeEntscheiden(fund({ faelligAm }), bestand())).toEqual({
|
||||||
|
tun: 'aktualisieren',
|
||||||
|
felder: { faelligAm },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('vergleicht Datumswerte nach Zeitpunkt, nicht nach Objekt', () => {
|
||||||
|
const faelligAm = new Date('2026-04-01T00:00:00.000Z')
|
||||||
|
const entscheidung = uebernahmeEntscheiden(
|
||||||
|
fund({ faelligAm: new Date(faelligAm.getTime()) }),
|
||||||
|
bestand({ faelligAm }),
|
||||||
|
)
|
||||||
|
expect(entscheidung).toEqual({ tun: 'nichts', grund: 'unveraendert' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rührt Priorität und Aufwand nicht an', () => {
|
||||||
|
// Beides wird hier geschätzt, nicht drüben. Ein Abgleich, der die
|
||||||
|
// Schätzung wegräumt, macht die Wochenplanung kaputt.
|
||||||
|
const entscheidung = uebernahmeEntscheiden(
|
||||||
|
fund({ prioritaet: 1, aufwandMinuten: 30 }),
|
||||||
|
bestand({ prioritaet: 4, aufwandMinuten: 90 }),
|
||||||
|
)
|
||||||
|
expect(entscheidung).toEqual({ tun: 'nichts', grund: 'unveraendert' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('schließt, was drüben erledigt wurde', () => {
|
||||||
|
expect(uebernahmeEntscheiden(fund({ erledigt: true }), bestand())).toEqual({
|
||||||
|
tun: 'schliessen',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('schließt nicht zweimal', () => {
|
||||||
|
expect(
|
||||||
|
uebernahmeEntscheiden(fund({ erledigt: true }), bestand({ erledigt: true })),
|
||||||
|
).toEqual({ tun: 'nichts', grund: 'schon-erledigt' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('holt eine hier erledigte Aufgabe zurück, wenn sie drüben wieder offen ist', () => {
|
||||||
|
const entscheidung = uebernahmeEntscheiden(fund(), bestand({ erledigt: true }))
|
||||||
|
// Das Ursprungssystem führt den Zustand; wer dort neu öffnet, meint es so.
|
||||||
|
expect(entscheidung).toEqual({ tun: 'nichts', grund: 'unveraendert' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('alsTitel', () => {
|
||||||
|
test('lässt kurze Texte in Ruhe', () => {
|
||||||
|
expect(alsTitel('Angebot schreiben')).toEqual({
|
||||||
|
titel: 'Angebot schreiben',
|
||||||
|
rest: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('macht aus Zeilenumbrüchen Leerzeichen', () => {
|
||||||
|
expect(alsTitel('Angebot\n\nschreiben ')).toEqual({
|
||||||
|
titel: 'Angebot schreiben',
|
||||||
|
rest: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('kürzt lange Texte und hebt den Rest auf', () => {
|
||||||
|
const lang = `${'wort '.repeat(60)}ende`
|
||||||
|
const { titel, rest } = alsTitel(lang)
|
||||||
|
expect(titel.length).toBeLessThanOrEqual(TITEL_MAX + 1)
|
||||||
|
expect(titel.endsWith('…')).toBe(true)
|
||||||
|
expect(rest).not.toBeNull()
|
||||||
|
expect(rest?.endsWith('ende')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('trennt nicht mitten im Wort, wenn eine Fuge in der Nähe ist', () => {
|
||||||
|
const lang = `${'a'.repeat(60)} ${'b'.repeat(80)}`
|
||||||
|
const { titel } = alsTitel(lang)
|
||||||
|
expect(titel).toBe(`${'a'.repeat(60)}…`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('trennt hart, wenn es keine Fuge gibt', () => {
|
||||||
|
const { titel } = alsTitel('x'.repeat(200))
|
||||||
|
expect(titel).toBe(`${'x'.repeat(TITEL_MAX)}…`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sendungFrisch', () => {
|
||||||
|
const jetzt = new Date('2026-03-17T08:20:00.000Z')
|
||||||
|
|
||||||
|
test('nimmt an, was gerade erst losgeschickt wurde', () => {
|
||||||
|
expect(sendungFrisch(new Date('2026-03-17T08:18:00.000Z'), jetzt)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lehnt ein altes Paket ab', () => {
|
||||||
|
expect(sendungFrisch(new Date('2026-03-17T08:10:00.000Z'), jetzt)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lehnt auch ein Paket aus der Zukunft ab', () => {
|
||||||
|
// Eine verstellte Uhr auf der Gegenseite ist genauso verdächtig.
|
||||||
|
expect(sendungFrisch(new Date('2026-03-17T08:40:00.000Z'), jetzt)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('spiegelSendung', () => {
|
||||||
|
test('nimmt ein vollständiges Paket an', () => {
|
||||||
|
const gelesen = spiegelSendung.safeParse({
|
||||||
|
gesendetAm: '2026-03-17T08:20:00.000Z',
|
||||||
|
belegungen: [
|
||||||
|
{
|
||||||
|
ref: 'zb-1',
|
||||||
|
beginn: '2026-03-17T09:00:00.000Z',
|
||||||
|
ende: '2026-03-17T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
aufgaben: [{ ref: 'a-1', titel: 'Angebot schreiben' }],
|
||||||
|
})
|
||||||
|
expect(gelesen.success).toBe(true)
|
||||||
|
expect(gelesen.success && gelesen.data.aufgaben[0]?.erledigt).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('weist ein Paket ohne Zeitstempel ab', () => {
|
||||||
|
const gelesen = spiegelSendung.safeParse({ belegungen: [], aufgaben: [] })
|
||||||
|
expect(gelesen.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('weist eine Belegung mit kaputtem Datum ab', () => {
|
||||||
|
const gelesen = spiegelSendung.safeParse({
|
||||||
|
gesendetAm: '2026-03-17T08:20:00.000Z',
|
||||||
|
belegungen: [{ ref: 'zb-1', beginn: 'morgen', ende: 'später' }],
|
||||||
|
aufgaben: [],
|
||||||
|
})
|
||||||
|
expect(gelesen.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gemeinsame Regeln für alle Integrationen.
|
||||||
|
*
|
||||||
|
* Der Kern jeder Integration ist derselbe: ein fremdes System liefert Zeug,
|
||||||
|
* daraus wird eine Aufgabe, und beim nächsten Lauf darf dieselbe Sache keine
|
||||||
|
* zweite Aufgabe ergeben. Was sich unterscheidet, ist allein das Abholen.
|
||||||
|
*
|
||||||
|
* Reine Logik ohne Datenbank, ohne Netz und ohne Systemzeit.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Was eine Integration liefert, bevor daraus eine Aufgabe wird. */
|
||||||
|
export type Fund = {
|
||||||
|
/** Kennung beim Ursprungssystem. Bleibt über die Lebensdauer gleich. */
|
||||||
|
ref: string
|
||||||
|
titel: string
|
||||||
|
notiz?: string | null
|
||||||
|
url?: string | null
|
||||||
|
faelligAm?: Date | null
|
||||||
|
/** 1 = dringend, 5 = irgendwann. */
|
||||||
|
prioritaet?: number | null
|
||||||
|
aufwandMinuten?: number | null
|
||||||
|
/** Im Ursprungssystem abgeschlossen: die Aufgabe darf zu. */
|
||||||
|
erledigt?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was beim Übernehmen eines Fundes passieren soll.
|
||||||
|
*
|
||||||
|
* Der heikle Teil ist nicht das Anlegen, sondern das zweite Mal: Eine Aufgabe,
|
||||||
|
* an der hier gearbeitet wurde, darf ein Abgleich nicht zurücksetzen.
|
||||||
|
*/
|
||||||
|
export type Uebernahme =
|
||||||
|
| { tun: 'anlegen' }
|
||||||
|
| { tun: 'aktualisieren'; felder: Partial<Bestand> }
|
||||||
|
| { tun: 'schliessen' }
|
||||||
|
| { tun: 'nichts'; grund: 'unveraendert' | 'von-hand-geaendert' | 'schon-erledigt' }
|
||||||
|
|
||||||
|
/** Der Stand, wie er in der Datenbank liegt. */
|
||||||
|
export type Bestand = {
|
||||||
|
titel: string
|
||||||
|
notiz: string | null
|
||||||
|
quelleUrl: string | null
|
||||||
|
faelligAm: Date | null
|
||||||
|
prioritaet: number
|
||||||
|
aufwandMinuten: number | null
|
||||||
|
erledigt: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Felder, die das Ursprungssystem führt — und nur diese.
|
||||||
|
*
|
||||||
|
* Bereich, Projekt und Zeitblöcke gehören der Planung und werden nie aus
|
||||||
|
* einer Integration überschrieben. Wer ein Ticket einem Bereich zuordnet,
|
||||||
|
* will nicht, dass der nächste Lauf das wieder wegräumt.
|
||||||
|
*/
|
||||||
|
const GEFUEHRT = ['titel', 'notiz', 'quelleUrl', 'faelligAm'] as const
|
||||||
|
|
||||||
|
function gleich(a: unknown, b: unknown): boolean {
|
||||||
|
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime()
|
||||||
|
return a === b
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uebernahmeEntscheiden(fund: Fund, bestand: Bestand | null): Uebernahme {
|
||||||
|
if (!bestand) {
|
||||||
|
// Ein Fund, der drüben schon erledigt ist, braucht hier nicht erst zu
|
||||||
|
// entstehen, nur um sofort wieder zuzugehen.
|
||||||
|
return fund.erledigt ? { tun: 'nichts', grund: 'schon-erledigt' } : { tun: 'anlegen' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fund.erledigt) {
|
||||||
|
return bestand.erledigt
|
||||||
|
? { tun: 'nichts', grund: 'schon-erledigt' }
|
||||||
|
: { tun: 'schliessen' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const neu: Partial<Bestand> = {}
|
||||||
|
const gewuenscht: Bestand = {
|
||||||
|
titel: fund.titel,
|
||||||
|
notiz: fund.notiz ?? null,
|
||||||
|
quelleUrl: fund.url ?? null,
|
||||||
|
faelligAm: fund.faelligAm ?? null,
|
||||||
|
prioritaet: bestand.prioritaet,
|
||||||
|
aufwandMinuten: bestand.aufwandMinuten,
|
||||||
|
erledigt: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const feld of GEFUEHRT) {
|
||||||
|
if (!gleich(bestand[feld], gewuenscht[feld])) {
|
||||||
|
// @ts-expect-error – der Index ist über GEFUEHRT eingegrenzt.
|
||||||
|
neu[feld] = gewuenscht[feld]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(neu).length === 0
|
||||||
|
? { tun: 'nichts', grund: 'unveraendert' }
|
||||||
|
: { tun: 'aktualisieren', felder: neu }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ab wann ein Fehlschlag gemeldet wird — dieselbe Schwelle wie beim
|
||||||
|
* Kalenderabgleich. Ein Aussetzer ist keine Nachricht wert.
|
||||||
|
*/
|
||||||
|
export const INTEGRATION_FEHLER_AB_VERSUCH = 3
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kürzt einen Text auf eine Länge, die als Aufgabentitel taugt.
|
||||||
|
*
|
||||||
|
* Fremde Systeme kennen keine Grenze; ein Titel über mehrere Zeilen macht
|
||||||
|
* jede Liste unlesbar. Der Rest wandert in die Notiz, geht also nicht
|
||||||
|
* verloren.
|
||||||
|
*/
|
||||||
|
export const TITEL_MAX = 120
|
||||||
|
|
||||||
|
export function alsTitel(text: string): { titel: string; rest: string | null } {
|
||||||
|
const sauber = text.replace(/\s+/g, ' ').trim()
|
||||||
|
if (sauber.length <= TITEL_MAX) return { titel: sauber, rest: null }
|
||||||
|
|
||||||
|
// Lieber an einem Wort trennen als mitten hinein – aber nur, solange
|
||||||
|
// dabei nicht mehr als die Haelfte des Titels wegfaellt.
|
||||||
|
const schnitt = sauber.lastIndexOf(' ', TITEL_MAX)
|
||||||
|
const bei = schnitt >= TITEL_MAX / 2 ? schnitt : TITEL_MAX
|
||||||
|
return { titel: `${sauber.slice(0, bei).trimEnd()}…`, rest: sauber.slice(bei).trim() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Spiegel ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was von einer Instanz zur anderen geht.
|
||||||
|
*
|
||||||
|
* Belegungen tragen keinen Titel: Daheim soll sehen, *dass* die Zeit weg ist,
|
||||||
|
* nicht woran gearbeitet wird. Aufgaben gehen nur mit, wenn sie ausdrücklich
|
||||||
|
* dafür markiert sind.
|
||||||
|
*/
|
||||||
|
export const spiegelBelegung = z.object({
|
||||||
|
ref: z.string().min(1).max(64),
|
||||||
|
beginn: z.iso.datetime(),
|
||||||
|
ende: z.iso.datetime(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const spiegelAufgabe = z.object({
|
||||||
|
ref: z.string().min(1).max(64),
|
||||||
|
titel: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(TITEL_MAX + 1),
|
||||||
|
faelligAm: z.iso.datetime().nullable().default(null),
|
||||||
|
erledigt: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const spiegelSendung = z.object({
|
||||||
|
/** Wann der Sender das Paket geschnürt hat, für die Frische. */
|
||||||
|
gesendetAm: z.iso.datetime(),
|
||||||
|
belegungen: z.array(spiegelBelegung).max(500),
|
||||||
|
aufgaben: z.array(spiegelAufgabe).max(500),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type SpiegelSendung = z.infer<typeof spiegelSendung>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie alt eine Sendung höchstens sein darf.
|
||||||
|
*
|
||||||
|
* Ohne Frist ließe sich ein abgefangenes Paket beliebig oft erneut
|
||||||
|
* einspielen. Fünf Minuten sind genug für eine träge Leitung und zu wenig
|
||||||
|
* für ein Archiv.
|
||||||
|
*/
|
||||||
|
export const SENDUNG_FRIST_MINUTEN = 5
|
||||||
|
|
||||||
|
export function sendungFrisch(
|
||||||
|
gesendetAm: Date,
|
||||||
|
jetzt: Date,
|
||||||
|
fristMinuten = SENDUNG_FRIST_MINUTEN,
|
||||||
|
): boolean {
|
||||||
|
const abstandMinuten = Math.abs(jetzt.getTime() - gesendetAm.getTime()) / 60_000
|
||||||
|
return abstandMinuten <= fristMinuten
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ const AUSWAHL = {
|
|||||||
faelligAm: true,
|
faelligAm: true,
|
||||||
quelle: true,
|
quelle: true,
|
||||||
quelleUrl: true,
|
quelleUrl: true,
|
||||||
|
spiegeln: true,
|
||||||
bereich: { select: { id: true, name: true, farbe: true } },
|
bereich: { select: { id: true, name: true, farbe: true } },
|
||||||
projekt: { select: { id: true, name: true } },
|
projekt: { select: { id: true, name: true } },
|
||||||
} satisfies Prisma.AufgabeSelect
|
} satisfies Prisma.AufgabeSelect
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ export type WochenTermin = {
|
|||||||
ganztaegig: boolean
|
ganztaegig: boolean
|
||||||
zeigtAls: ZeigtAls
|
zeigtAls: ZeigtAls
|
||||||
kalenderName: string
|
kalenderName: string
|
||||||
|
/**
|
||||||
|
* Kommt aus der anderen Instanz und trägt darum keinen Betreff.
|
||||||
|
*
|
||||||
|
* Für die Ansicht ist so eine Belegung ein fremder Termin wie jeder andere:
|
||||||
|
* Sie blockiert Zeit und lässt sich hier nicht verschieben.
|
||||||
|
*/
|
||||||
|
gespiegelt: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WocheDaten = {
|
export type WocheDaten = {
|
||||||
@@ -55,7 +62,7 @@ export async function wocheLaden(
|
|||||||
): Promise<WocheDaten> {
|
): Promise<WocheDaten> {
|
||||||
const fenster = wochenFenster(zeitpunkt)
|
const fenster = wochenFenster(zeitpunkt)
|
||||||
|
|
||||||
const [zeitbloecke, termine] = await Promise.all([
|
const [zeitbloecke, termine, belegungen] = await Promise.all([
|
||||||
db.zeitblock.findMany({
|
db.zeitblock.findMany({
|
||||||
where: {
|
where: {
|
||||||
kalenderkonto: { benutzerId },
|
kalenderkonto: { benutzerId },
|
||||||
@@ -97,6 +104,11 @@ export async function wocheLaden(
|
|||||||
kalenderkonto: { select: { anzeigename: true } },
|
kalenderkonto: { select: { anzeigename: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
db.spiegelbelegung.findMany({
|
||||||
|
where: { beginn: { lt: fenster.bis }, ende: { gt: fenster.von } },
|
||||||
|
orderBy: { beginn: 'asc' },
|
||||||
|
select: { id: true, beginn: true, ende: true },
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
// Ein Zeitblock steht auch im Kalender und damit im Spiegel. Er würde sonst
|
// Ein Zeitblock steht auch im Kalender und damit im Spiegel. Er würde sonst
|
||||||
@@ -118,7 +130,8 @@ export async function wocheLaden(
|
|||||||
letzterFehler: z.letzterFehler,
|
letzterFehler: z.letzterFehler,
|
||||||
offen: z.extId === null,
|
offen: z.extId === null,
|
||||||
})),
|
})),
|
||||||
termine: termine
|
termine: [
|
||||||
|
...termine
|
||||||
.filter((t) => !eigeneExtIds.has(t.extId))
|
.filter((t) => !eigeneExtIds.has(t.extId))
|
||||||
.map((t) => ({
|
.map((t) => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
@@ -128,7 +141,20 @@ export async function wocheLaden(
|
|||||||
ganztaegig: t.ganztaegig,
|
ganztaegig: t.ganztaegig,
|
||||||
zeigtAls: t.zeigtAls,
|
zeigtAls: t.zeigtAls,
|
||||||
kalenderName: t.kalenderkonto.anzeigename,
|
kalenderName: t.kalenderkonto.anzeigename,
|
||||||
|
gespiegelt: false,
|
||||||
})),
|
})),
|
||||||
|
...belegungen.map((b) => ({
|
||||||
|
id: b.id,
|
||||||
|
beginn: b.beginn,
|
||||||
|
ende: b.ende,
|
||||||
|
// Kein Betreff: Die andere Instanz schickt bewusst keinen.
|
||||||
|
betreff: null,
|
||||||
|
ganztaegig: false,
|
||||||
|
zeigtAls: 'GEBUCHT' as const,
|
||||||
|
kalenderName: 'Dienstlich',
|
||||||
|
gespiegelt: true,
|
||||||
|
})),
|
||||||
|
].sort((a, b) => a.beginn.getTime() - b.beginn.getTime()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { auditSchreiben } from '@/server/audit'
|
import { auditSchreiben } from '@/server/audit'
|
||||||
import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
|
import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
|
||||||
import { db } from '@/server/db'
|
import { db } from '@/server/db'
|
||||||
|
import { umgebung } from '@/server/umgebung'
|
||||||
import { jetzt } from '@/server/zeitgeber'
|
import { jetzt } from '@/server/zeitgeber'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -347,6 +348,54 @@ export async function einsortieren(daten: FormData): Promise<Rueckmeldung> {
|
|||||||
return { ok: true, meldung: `„${aufgabe.titel}" liegt jetzt in ${bereich.name}.` }
|
return { ok: true, meldung: `„${aufgabe.titel}" liegt jetzt in ${bereich.name}.` }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt eine Aufgabe zum Spiegeln frei – oder nimmt die Freigabe zurück.
|
||||||
|
*
|
||||||
|
* Nur was hier ausdrücklich markiert ist, verlässt die Instanz mit Titel.
|
||||||
|
* Deshalb ein eigener Handgriff je Aufgabe und kein Schalter für alles: Wer
|
||||||
|
* versehentlich einen Bereich spiegelt, schickt Kundennamen nach Hause.
|
||||||
|
*/
|
||||||
|
export async function spiegelnUmschalten(daten: FormData): Promise<Rueckmeldung> {
|
||||||
|
const geprueft = await akteurOder((fehler) => ({ ok: false, fehler }))
|
||||||
|
if ('antwort' in geprueft) return geprueft.antwort
|
||||||
|
const { akteur } = geprueft
|
||||||
|
|
||||||
|
if (!umgebung().spiegelSendenAktiv) {
|
||||||
|
return { ok: false, fehler: 'Diese Instanz spiegelt nicht.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = daten.get('id')
|
||||||
|
if (typeof id !== 'string' || id.length === 0) {
|
||||||
|
return { ok: false, fehler: 'Ungültige Anfrage.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const aufgabe = await db.aufgabe.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true, titel: true, spiegeln: true },
|
||||||
|
})
|
||||||
|
if (!aufgabe) return { ok: false, fehler: 'Diese Aufgabe gibt es nicht mehr.' }
|
||||||
|
|
||||||
|
const kuenftig = !aufgabe.spiegeln
|
||||||
|
await db.aufgabe.update({ where: { id: aufgabe.id }, data: { spiegeln: kuenftig } })
|
||||||
|
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: { id: akteur.id, email: akteur.email },
|
||||||
|
aktion: kuenftig ? 'aufgabe.spiegeln-an' : 'aufgabe.spiegeln-aus',
|
||||||
|
objektTyp: 'Aufgabe',
|
||||||
|
objektId: aufgabe.id,
|
||||||
|
details: { titel: aufgabe.titel },
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath('/aufgaben')
|
||||||
|
revalidatePath('/inbox')
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
meldung: kuenftig
|
||||||
|
? `„${aufgabe.titel}" geht künftig mit nach Hause.`
|
||||||
|
: `„${aufgabe.titel}" bleibt hier.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Aus einer Idee wird eine Aufgabe. */
|
/** Aus einer Idee wird eine Aufgabe. */
|
||||||
export async function ideeUebernehmen(daten: FormData): Promise<Rueckmeldung> {
|
export async function ideeUebernehmen(daten: FormData): Promise<Rueckmeldung> {
|
||||||
const geprueft = await akteurOder((fehler) => ({ ok: false, fehler }))
|
const geprueft = await akteurOder((fehler) => ({ ok: false, fehler }))
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
|
|||||||
import { mailSenden } from '@/server/benachrichtigung/mail'
|
import { mailSenden } from '@/server/benachrichtigung/mail'
|
||||||
import { pushSenden } from '@/server/benachrichtigung/push'
|
import { pushSenden } from '@/server/benachrichtigung/push'
|
||||||
import { alsEinstellung } from '@/server/benachrichtigung/versand'
|
import { alsEinstellung } from '@/server/benachrichtigung/versand'
|
||||||
|
import { telegramMeldung } from '@/server/integration/telegram'
|
||||||
import { db } from '@/server/db'
|
import { db } from '@/server/db'
|
||||||
import { umgebung } from '@/server/umgebung'
|
import { umgebung } from '@/server/umgebung'
|
||||||
import { jetzt } from '@/server/zeitgeber'
|
import { jetzt } from '@/server/zeitgeber'
|
||||||
@@ -37,6 +38,7 @@ export async function einstellungSpeichern(daten: FormData): Promise<Rueckmeldun
|
|||||||
anlaesse: daten.getAll('anlaesse'),
|
anlaesse: daten.getAll('anlaesse'),
|
||||||
perPush: daten.get('perPush') !== null,
|
perPush: daten.get('perPush') !== null,
|
||||||
perMail: daten.get('perMail') !== null,
|
perMail: daten.get('perMail') !== null,
|
||||||
|
perTelegram: daten.get('perTelegram') !== null,
|
||||||
ueberblickUm: daten.get('ueberblickUm'),
|
ueberblickUm: daten.get('ueberblickUm'),
|
||||||
ruheVon: daten.get('ruheVon'),
|
ruheVon: daten.get('ruheVon'),
|
||||||
ruheBis: daten.get('ruheBis'),
|
ruheBis: daten.get('ruheBis'),
|
||||||
@@ -57,6 +59,7 @@ export async function einstellungSpeichern(daten: FormData): Promise<Rueckmeldun
|
|||||||
anlaesse: gelesen.data.anlaesse.join(','),
|
anlaesse: gelesen.data.anlaesse.join(','),
|
||||||
perPush: gelesen.data.perPush,
|
perPush: gelesen.data.perPush,
|
||||||
perMail: gelesen.data.perMail,
|
perMail: gelesen.data.perMail,
|
||||||
|
perTelegram: gelesen.data.perTelegram,
|
||||||
ueberblickUm: gelesen.data.ueberblickUm,
|
ueberblickUm: gelesen.data.ueberblickUm,
|
||||||
ruheVon: gelesen.data.ruheVon,
|
ruheVon: gelesen.data.ruheVon,
|
||||||
ruheBis: gelesen.data.ruheBis,
|
ruheBis: gelesen.data.ruheBis,
|
||||||
@@ -79,6 +82,7 @@ export async function einstellungSpeichern(daten: FormData): Promise<Rueckmeldun
|
|||||||
anlaesse: werte.anlaesse,
|
anlaesse: werte.anlaesse,
|
||||||
perPush: werte.perPush,
|
perPush: werte.perPush,
|
||||||
perMail: werte.perMail,
|
perMail: werte.perMail,
|
||||||
|
perTelegram: werte.perTelegram,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -103,6 +107,7 @@ export async function probeSenden(): Promise<Rueckmeldung> {
|
|||||||
anlaesse: true,
|
anlaesse: true,
|
||||||
perPush: true,
|
perPush: true,
|
||||||
perMail: true,
|
perMail: true,
|
||||||
|
perTelegram: true,
|
||||||
ueberblickUm: true,
|
ueberblickUm: true,
|
||||||
ruheVon: true,
|
ruheVon: true,
|
||||||
ruheBis: true,
|
ruheBis: true,
|
||||||
@@ -112,8 +117,11 @@ export async function probeSenden(): Promise<Rueckmeldung> {
|
|||||||
})
|
})
|
||||||
const einstellung = alsEinstellung(zeile)
|
const einstellung = alsEinstellung(zeile)
|
||||||
|
|
||||||
if (!einstellung.perPush && !einstellung.perMail) {
|
if (!einstellung.perPush && !einstellung.perMail && !einstellung.perTelegram) {
|
||||||
return { ok: false, fehler: 'Erst einen Weg einschalten — Push oder E-Mail.' }
|
return {
|
||||||
|
ok: false,
|
||||||
|
fehler: 'Erst einen Weg einschalten — Push, E-Mail oder Telegram.',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const meldung = {
|
const meldung = {
|
||||||
@@ -153,6 +161,19 @@ export async function probeSenden(): Promise<Rueckmeldung> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (einstellung.perTelegram) {
|
||||||
|
if (!einst.telegramAktiv) {
|
||||||
|
wege.push('Telegram ist auf diesem Server nicht eingerichtet')
|
||||||
|
} else {
|
||||||
|
const zugestellt = await telegramMeldung(akteur.id, meldung)
|
||||||
|
wege.push(
|
||||||
|
zugestellt > 0
|
||||||
|
? `Telegram an ${zugestellt} Chat(s)`
|
||||||
|
: 'kein Telegram-Chat eingetragen',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await auditSchreiben({
|
await auditSchreiben({
|
||||||
akteur: { id: akteur.id, email: akteur.email },
|
akteur: { id: akteur.id, email: akteur.email },
|
||||||
aktion: 'benachrichtigung.probe',
|
aktion: 'benachrichtigung.probe',
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import { auditSchreiben } from '@/server/audit'
|
||||||
|
import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
import {
|
||||||
|
KOPPLUNG_MINUTEN,
|
||||||
|
kopplungscodeBauen,
|
||||||
|
telegramAbholen,
|
||||||
|
} from '@/server/integration/telegram'
|
||||||
|
import { zammadAbgleichen } from '@/server/integration/zammad'
|
||||||
|
import { spiegelSenden } from '@/server/integration/spiegel'
|
||||||
|
import { umgebung } from '@/server/umgebung'
|
||||||
|
import { jetzt } from '@/server/zeitgeber'
|
||||||
|
|
||||||
|
export type Rueckmeldung = { ok: true; meldung: string } | { ok: false; fehler: string }
|
||||||
|
|
||||||
|
async function akteurOderFehler() {
|
||||||
|
try {
|
||||||
|
return { akteur: await pruefeRolle('BENUTZER') }
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof NichtErlaubt) {
|
||||||
|
return { antwort: { ok: false as const, fehler: fehler.message } }
|
||||||
|
}
|
||||||
|
throw fehler
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt einen Kopplungscode für einen Telegram-Chat.
|
||||||
|
*
|
||||||
|
* Alte Codes desselben Benutzers fallen dabei weg: Zwei gültige Codes
|
||||||
|
* gleichzeitig sind einer zu viel.
|
||||||
|
*/
|
||||||
|
export async function kopplungAnfordern(): Promise<
|
||||||
|
{ ok: true; code: string; minuten: number } | { ok: false; fehler: string }
|
||||||
|
> {
|
||||||
|
const geprueft = await akteurOderFehler()
|
||||||
|
if ('antwort' in geprueft) return geprueft.antwort
|
||||||
|
const { akteur } = geprueft
|
||||||
|
|
||||||
|
if (!umgebung().telegramAktiv) {
|
||||||
|
return { ok: false, fehler: 'Telegram ist auf diesem Server nicht eingerichtet.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.telegramKopplung.deleteMany({ where: { benutzerId: akteur.id } })
|
||||||
|
|
||||||
|
const code = kopplungscodeBauen()
|
||||||
|
await db.telegramKopplung.create({
|
||||||
|
data: {
|
||||||
|
code,
|
||||||
|
benutzerId: akteur.id,
|
||||||
|
gueltigBis: new Date(jetzt().getTime() + KOPPLUNG_MINUTEN * 60_000),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: { id: akteur.id, email: akteur.email },
|
||||||
|
aktion: 'telegram.kopplung-angefordert',
|
||||||
|
objektTyp: 'TelegramKopplung',
|
||||||
|
objektId: null,
|
||||||
|
// Nie der Code selbst: Wer das Protokoll liest, könnte sonst koppeln.
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ok: true, code, minuten: KOPPLUNG_MINUTEN }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trennt einen Chat wieder. */
|
||||||
|
export async function chatEntfernen(daten: FormData): Promise<Rueckmeldung> {
|
||||||
|
const geprueft = await akteurOderFehler()
|
||||||
|
if ('antwort' in geprueft) return geprueft.antwort
|
||||||
|
const { akteur } = geprueft
|
||||||
|
|
||||||
|
const id = daten.get('id')
|
||||||
|
if (typeof id !== 'string' || id.length === 0) {
|
||||||
|
return { ok: false, fehler: 'Ungültige Anfrage.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const chat = await db.telegramChat.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true, bezeichnung: true },
|
||||||
|
})
|
||||||
|
if (!chat) return { ok: false, fehler: 'Diesen Chat gibt es nicht.' }
|
||||||
|
|
||||||
|
await db.telegramChat.delete({ where: { id: chat.id } })
|
||||||
|
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: { id: akteur.id, email: akteur.email },
|
||||||
|
aktion: 'telegram.getrennt',
|
||||||
|
objektTyp: 'TelegramChat',
|
||||||
|
objektId: chat.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath('/integrationen')
|
||||||
|
return { ok: true, meldung: 'Chat getrennt.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schaltet um, ob ein Chat Meldungen bekommt. */
|
||||||
|
export async function chatMeldungenUmschalten(daten: FormData): Promise<Rueckmeldung> {
|
||||||
|
const geprueft = await akteurOderFehler()
|
||||||
|
if ('antwort' in geprueft) return geprueft.antwort
|
||||||
|
const { akteur } = geprueft
|
||||||
|
|
||||||
|
const id = daten.get('id')
|
||||||
|
if (typeof id !== 'string' || id.length === 0) {
|
||||||
|
return { ok: false, fehler: 'Ungültige Anfrage.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const chat = await db.telegramChat.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true, meldungen: true },
|
||||||
|
})
|
||||||
|
if (!chat) return { ok: false, fehler: 'Diesen Chat gibt es nicht.' }
|
||||||
|
|
||||||
|
const kuenftig = !chat.meldungen
|
||||||
|
await db.telegramChat.update({
|
||||||
|
where: { id: chat.id },
|
||||||
|
data: { meldungen: kuenftig },
|
||||||
|
})
|
||||||
|
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: { id: akteur.id, email: akteur.email },
|
||||||
|
aktion: kuenftig ? 'telegram.meldungen-an' : 'telegram.meldungen-aus',
|
||||||
|
objektTyp: 'TelegramChat',
|
||||||
|
objektId: chat.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath('/integrationen')
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
meldung: kuenftig
|
||||||
|
? 'Dieser Chat bekommt Meldungen.'
|
||||||
|
: 'Dieser Chat bekommt keine Meldungen mehr.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stößt einen Lauf von Hand an.
|
||||||
|
*
|
||||||
|
* Nützlich nach dem Einrichten: Sonst wüsste man erst beim nächsten Takt, ob
|
||||||
|
* die Zugangsdaten stimmen.
|
||||||
|
*/
|
||||||
|
export async function laufAnstossen(daten: FormData): Promise<Rueckmeldung> {
|
||||||
|
const geprueft = await akteurOderFehler()
|
||||||
|
if ('antwort' in geprueft) return geprueft.antwort
|
||||||
|
const { akteur } = geprueft
|
||||||
|
|
||||||
|
const quelle = daten.get('quelle')
|
||||||
|
const anstoss = { id: akteur.id, email: akteur.email }
|
||||||
|
const einst = umgebung()
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (quelle === 'ZAMMAD' && einst.zammadAktiv) {
|
||||||
|
const bericht = await zammadAbgleichen(anstoss)
|
||||||
|
revalidatePath('/integrationen')
|
||||||
|
revalidatePath('/aufgaben')
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
meldung: `${bericht.angelegt} neu, ${bericht.aktualisiert} geändert, ${bericht.geschlossen} geschlossen.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quelle === 'TELEGRAM' && einst.telegramAktiv) {
|
||||||
|
const bericht = await telegramAbholen(anstoss)
|
||||||
|
revalidatePath('/integrationen')
|
||||||
|
revalidatePath('/aufgaben')
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
meldung: `${bericht.aufgaben} Aufgabe(n), ${bericht.gekoppelt} Kopplung(en), ${bericht.abgehakt} abgehakt.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quelle === 'SPIEGEL' && einst.spiegelSendenAktiv) {
|
||||||
|
const bericht = await spiegelSenden()
|
||||||
|
revalidatePath('/integrationen')
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
meldung: `${bericht.belegungen} Belegung(en) und ${bericht.aufgaben} Aufgabe(n) geschickt.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, fehler: 'Diese Integration ist hier nicht eingerichtet.' }
|
||||||
|
} catch (fehler) {
|
||||||
|
// Die Meldung des fremden Systems, nicht dessen Antwort im Ganzen.
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
fehler: fehler instanceof Error ? fehler.message : 'Der Lauf ist gescheitert.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,9 +17,13 @@ import {
|
|||||||
tagesBeginn,
|
tagesBeginn,
|
||||||
tagesEnde,
|
tagesEnde,
|
||||||
} from '@/lib/zeit'
|
} from '@/lib/zeit'
|
||||||
|
import type { Quelle } from '@/generated/prisma/enums'
|
||||||
|
import { INTEGRATION_FEHLER_AB_VERSUCH } from '@/lib/integration'
|
||||||
import { db } from '@/server/db'
|
import { db } from '@/server/db'
|
||||||
import { mailSenden } from '@/server/benachrichtigung/mail'
|
import { mailSenden } from '@/server/benachrichtigung/mail'
|
||||||
import { pushSenden } from '@/server/benachrichtigung/push'
|
import { pushSenden } from '@/server/benachrichtigung/push'
|
||||||
|
import { telegramMeldung } from '@/server/integration/telegram'
|
||||||
|
import { umgebung } from '@/server/umgebung'
|
||||||
import { jetzt } from '@/server/zeitgeber'
|
import { jetzt } from '@/server/zeitgeber'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,6 +50,7 @@ export function alsEinstellung(
|
|||||||
anlaesse: string
|
anlaesse: string
|
||||||
perPush: boolean
|
perPush: boolean
|
||||||
perMail: boolean
|
perMail: boolean
|
||||||
|
perTelegram: boolean
|
||||||
ueberblickUm: number
|
ueberblickUm: number
|
||||||
ruheVon: number
|
ruheVon: number
|
||||||
ruheBis: number
|
ruheBis: number
|
||||||
@@ -66,6 +71,7 @@ export function alsEinstellung(
|
|||||||
anlaesse,
|
anlaesse,
|
||||||
perPush: zeile.perPush,
|
perPush: zeile.perPush,
|
||||||
perMail: zeile.perMail,
|
perMail: zeile.perMail,
|
||||||
|
perTelegram: zeile.perTelegram,
|
||||||
ueberblickUm: zeile.ueberblickUm,
|
ueberblickUm: zeile.ueberblickUm,
|
||||||
ruheVon: zeile.ruheVon,
|
ruheVon: zeile.ruheVon,
|
||||||
ruheBis: zeile.ruheBis,
|
ruheBis: zeile.ruheBis,
|
||||||
@@ -86,6 +92,7 @@ async function empfaengerLaden(): Promise<BenutzerMitEinstellung[]> {
|
|||||||
anlaesse: true,
|
anlaesse: true,
|
||||||
perPush: true,
|
perPush: true,
|
||||||
perMail: true,
|
perMail: true,
|
||||||
|
perTelegram: true,
|
||||||
ueberblickUm: true,
|
ueberblickUm: true,
|
||||||
ruheVon: true,
|
ruheVon: true,
|
||||||
ruheBis: true,
|
ruheBis: true,
|
||||||
@@ -141,6 +148,9 @@ export async function meldungZustellen(
|
|||||||
|
|
||||||
if (benutzer.einstellung.perPush) await pushSenden(benutzer.id, meldung)
|
if (benutzer.einstellung.perPush) await pushSenden(benutzer.id, meldung)
|
||||||
if (benutzer.einstellung.perMail) await mailSenden(benutzer.email, meldung)
|
if (benutzer.einstellung.perMail) await mailSenden(benutzer.email, meldung)
|
||||||
|
if (benutzer.einstellung.perTelegram && umgebung().telegramAktiv) {
|
||||||
|
await telegramMeldung(benutzer.id, meldung)
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -232,6 +242,8 @@ async function faelligkeitenBauen(zeitpunkt: Date): Promise<Meldung[]> {
|
|||||||
titel: 'Heute fällig',
|
titel: 'Heute fällig',
|
||||||
text: aufgabe.titel,
|
text: aufgabe.titel,
|
||||||
pfad: '/aufgaben',
|
pfad: '/aufgaben',
|
||||||
|
// Genau eine Aufgabe: Telegram kann einen Knopf zum Abhaken anbieten.
|
||||||
|
objektId: aufgabe.id,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,7 +278,15 @@ async function zeitblockErinnerungenBauen(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Kalender, die mehrfach hintereinander nicht abgleichbar waren. */
|
/** Lesbare Namen für die Meldung; die Aufzählung selbst ist technisch. */
|
||||||
|
const INTEGRATION_NAME: Partial<Record<Quelle, string>> = {
|
||||||
|
ZAMMAD: 'Zammad',
|
||||||
|
WARTUNGSPLANER: 'Wartungsplaner',
|
||||||
|
TELEGRAM: 'Telegram',
|
||||||
|
SPIEGEL: 'Spiegel',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kalender und Integrationen, die mehrfach hintereinander klemmten. */
|
||||||
async function abgleichfehlerBauen(
|
async function abgleichfehlerBauen(
|
||||||
benutzerId: string,
|
benutzerId: string,
|
||||||
zeitpunkt: Date,
|
zeitpunkt: Date,
|
||||||
@@ -283,14 +303,34 @@ async function abgleichfehlerBauen(
|
|||||||
select: { id: true, anzeigename: true, letzterFehler: true },
|
select: { id: true, anzeigename: true, letzterFehler: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Eine klemmende Integration wiegt genauso schwer wie ein klemmender
|
||||||
|
// Kalender: In beiden Fällen steht die Liste auf einem alten Stand, ohne
|
||||||
|
// dass es jemandem auffiele.
|
||||||
|
const integrationen = await db.integrationszustand.findMany({
|
||||||
|
where: {
|
||||||
|
letzterFehler: { not: null },
|
||||||
|
fehlversuche: { gte: INTEGRATION_FEHLER_AB_VERSUCH },
|
||||||
|
},
|
||||||
|
select: { quelle: true, letzterFehler: true },
|
||||||
|
})
|
||||||
|
|
||||||
const tag = alsTagesschluessel(zeitpunkt)
|
const tag = alsTagesschluessel(zeitpunkt)
|
||||||
return konten.map((konto) => ({
|
return [
|
||||||
|
...konten.map((konto) => ({
|
||||||
anlass: 'abgleichfehler' as const,
|
anlass: 'abgleichfehler' as const,
|
||||||
schluessel: meldungsSchluessel('abgleichfehler', konto.id, tag),
|
schluessel: meldungsSchluessel('abgleichfehler', konto.id, tag),
|
||||||
titel: 'Ein Kalender lässt sich nicht abgleichen',
|
titel: 'Ein Kalender lässt sich nicht abgleichen',
|
||||||
text: `„${konto.anzeigename}": ${konto.letzterFehler ?? 'unbekannter Fehler'}`,
|
text: `„${konto.anzeigename}": ${konto.letzterFehler ?? 'unbekannter Fehler'}`,
|
||||||
pfad: '/kalenderkonten',
|
pfad: '/kalenderkonten',
|
||||||
}))
|
})),
|
||||||
|
...integrationen.map((i) => ({
|
||||||
|
anlass: 'abgleichfehler' as const,
|
||||||
|
schluessel: meldungsSchluessel('abgleichfehler', i.quelle, tag),
|
||||||
|
titel: 'Eine Integration klemmt',
|
||||||
|
text: `${INTEGRATION_NAME[i.quelle] ?? i.quelle}: ${i.letzterFehler ?? 'unbekannter Fehler'}`,
|
||||||
|
pfad: '/integrationen',
|
||||||
|
})),
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type VersandBericht = { geprueft: number; gesendet: number }
|
export type VersandBericht = { geprueft: number; gesendet: number }
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import 'server-only'
|
||||||
|
import type { Quelle } from '@/generated/prisma/enums'
|
||||||
|
import { uebernahmeEntscheiden, type Bestand, type Fund } from '@/lib/integration'
|
||||||
|
import { auditSchreiben } from '@/server/audit'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
import { jetzt } from '@/server/zeitgeber'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die gemeinsame Aufnahmestelle für alle Integrationen.
|
||||||
|
*
|
||||||
|
* Jede Integration liefert eine Liste von Funden; was daraus wird, entscheidet
|
||||||
|
* für alle dieselbe Stelle. Damit verhält sich Zammad wie Telegram wie der
|
||||||
|
* Spiegel — und eine neue Integration muss nur noch abholen können.
|
||||||
|
*
|
||||||
|
* Hier hängt später auch der Wartungsplaner ein: Er braucht eine Funktion, die
|
||||||
|
* seine Daten in `Fund` übersetzt, sonst nichts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Aufnahmebericht = {
|
||||||
|
angelegt: number
|
||||||
|
aktualisiert: number
|
||||||
|
geschlossen: number
|
||||||
|
unveraendert: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEER: Aufnahmebericht = {
|
||||||
|
angelegt: 0,
|
||||||
|
aktualisiert: 0,
|
||||||
|
geschlossen: 0,
|
||||||
|
unveraendert: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wer die Aufnahme angestoßen hat – für das Protokoll. */
|
||||||
|
export type Anstoss = { id: string | null; email: string }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nimmt eine Runde Funde auf.
|
||||||
|
*
|
||||||
|
* Anschließend gilt: Zu jedem Fund gibt es genau eine Aufgabe mit
|
||||||
|
* `[quelle, ref]`, und keine Aufgabe wurde in Feldern angefasst, die hier
|
||||||
|
* gepflegt werden statt drüben.
|
||||||
|
*/
|
||||||
|
export async function fundeAufnehmen(
|
||||||
|
quelle: Quelle,
|
||||||
|
funde: readonly Fund[],
|
||||||
|
anstoss: Anstoss,
|
||||||
|
): Promise<Aufnahmebericht> {
|
||||||
|
const bericht = { ...LEER }
|
||||||
|
const zeitpunkt = jetzt()
|
||||||
|
|
||||||
|
for (const fund of funde) {
|
||||||
|
const vorhanden = await db.aufgabe.findUnique({
|
||||||
|
where: { quelle_quelleRef: { quelle, quelleRef: fund.ref } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
titel: true,
|
||||||
|
notiz: true,
|
||||||
|
quelleUrl: true,
|
||||||
|
faelligAm: true,
|
||||||
|
prioritaet: true,
|
||||||
|
aufwandMinuten: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const bestand: Bestand | null = vorhanden
|
||||||
|
? {
|
||||||
|
titel: vorhanden.titel,
|
||||||
|
notiz: vorhanden.notiz,
|
||||||
|
quelleUrl: vorhanden.quelleUrl,
|
||||||
|
faelligAm: vorhanden.faelligAm,
|
||||||
|
prioritaet: vorhanden.prioritaet,
|
||||||
|
aufwandMinuten: vorhanden.aufwandMinuten,
|
||||||
|
erledigt: vorhanden.status === 'ERLEDIGT' || vorhanden.status === 'VERWORFEN',
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
|
||||||
|
const entscheidung = uebernahmeEntscheiden(fund, bestand)
|
||||||
|
|
||||||
|
if (entscheidung.tun === 'nichts') {
|
||||||
|
bericht.unveraendert++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entscheidung.tun === 'anlegen') {
|
||||||
|
const angelegt = await db.aufgabe.create({
|
||||||
|
data: {
|
||||||
|
titel: fund.titel,
|
||||||
|
notiz: fund.notiz ?? null,
|
||||||
|
quelle,
|
||||||
|
quelleRef: fund.ref,
|
||||||
|
quelleUrl: fund.url ?? null,
|
||||||
|
faelligAm: fund.faelligAm ?? null,
|
||||||
|
...(fund.prioritaet ? { prioritaet: fund.prioritaet } : {}),
|
||||||
|
...(fund.aufwandMinuten ? { aufwandMinuten: fund.aufwandMinuten } : {}),
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
bericht.angelegt++
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: anstoss,
|
||||||
|
aktion: 'integration.angelegt',
|
||||||
|
objektTyp: 'Aufgabe',
|
||||||
|
objektId: angelegt.id,
|
||||||
|
// Der Titel steht schon in der Aufgabe; hier genügt die Herkunft.
|
||||||
|
details: { quelle, ref: fund.ref },
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entscheidung.tun === 'schliessen') {
|
||||||
|
await db.aufgabe.update({
|
||||||
|
where: { id: vorhanden?.id ?? '' },
|
||||||
|
data: { status: 'ERLEDIGT', erledigtAm: zeitpunkt },
|
||||||
|
})
|
||||||
|
bericht.geschlossen++
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: anstoss,
|
||||||
|
aktion: 'integration.geschlossen',
|
||||||
|
objektTyp: 'Aufgabe',
|
||||||
|
objektId: vorhanden?.id ?? null,
|
||||||
|
details: { quelle, ref: fund.ref },
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.aufgabe.update({
|
||||||
|
where: { id: vorhanden?.id ?? '' },
|
||||||
|
data: entscheidung.felder,
|
||||||
|
})
|
||||||
|
bericht.aktualisiert++
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: anstoss,
|
||||||
|
aktion: 'integration.aktualisiert',
|
||||||
|
objektTyp: 'Aufgabe',
|
||||||
|
objektId: vorhanden?.id ?? null,
|
||||||
|
details: { quelle, ref: fund.ref, felder: Object.keys(entscheidung.felder) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return bericht
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Zustand je Integration ------------------------------------------------
|
||||||
|
|
||||||
|
/** Liest den Merker, an dem eine Integration weitermacht. */
|
||||||
|
export async function merkerLesen(quelle: Quelle): Promise<string | null> {
|
||||||
|
const zeile = await db.integrationszustand.findUnique({
|
||||||
|
where: { quelle },
|
||||||
|
select: { merker: true },
|
||||||
|
})
|
||||||
|
return zeile?.merker ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hält fest, dass ein Lauf geklappt hat. */
|
||||||
|
export async function laufGeglueckt(quelle: Quelle, merker?: string): Promise<void> {
|
||||||
|
const zeitpunkt = jetzt()
|
||||||
|
const werte = {
|
||||||
|
letzterLauf: zeitpunkt,
|
||||||
|
letzterErfolg: zeitpunkt,
|
||||||
|
letzterFehler: null,
|
||||||
|
fehlversuche: 0,
|
||||||
|
...(merker === undefined ? {} : { merker }),
|
||||||
|
}
|
||||||
|
await db.integrationszustand.upsert({
|
||||||
|
where: { quelle },
|
||||||
|
update: werte,
|
||||||
|
create: { quelle, ...werte },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hält einen Fehlschlag fest und zählt hoch.
|
||||||
|
*
|
||||||
|
* Nur die Meldung, nie die Antwort des fremden Systems: Darin stehen gern
|
||||||
|
* Tokens, Kopfzeilen und ganze Datensätze.
|
||||||
|
*/
|
||||||
|
export async function laufGescheitert(quelle: Quelle, fehler: unknown): Promise<number> {
|
||||||
|
const meldung = fehler instanceof Error ? fehler.message : 'unbekannter Fehler'
|
||||||
|
const zeitpunkt = jetzt()
|
||||||
|
const zeile = await db.integrationszustand.upsert({
|
||||||
|
where: { quelle },
|
||||||
|
update: {
|
||||||
|
letzterLauf: zeitpunkt,
|
||||||
|
letzterFehler: meldung.slice(0, 500),
|
||||||
|
fehlversuche: { increment: 1 },
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
quelle,
|
||||||
|
letzterLauf: zeitpunkt,
|
||||||
|
letzterFehler: meldung.slice(0, 500),
|
||||||
|
fehlversuche: 1,
|
||||||
|
},
|
||||||
|
select: { fehlversuche: true },
|
||||||
|
})
|
||||||
|
return zeile.fehlversuche
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import 'server-only'
|
||||||
|
import { createHmac, timingSafeEqual } from 'node:crypto'
|
||||||
|
import {
|
||||||
|
sendungFrisch,
|
||||||
|
spiegelSendung,
|
||||||
|
type Fund,
|
||||||
|
type SpiegelSendung,
|
||||||
|
} from '@/lib/integration'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
import { umgebung } from '@/server/umgebung'
|
||||||
|
import { jetzt } from '@/server/zeitgeber'
|
||||||
|
import {
|
||||||
|
fundeAufnehmen,
|
||||||
|
laufGescheitert,
|
||||||
|
laufGeglueckt,
|
||||||
|
type Anstoss,
|
||||||
|
} from '@/server/integration/aufnahme'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Spiegel: `firma` sendet, `daheim` empfängt.
|
||||||
|
*
|
||||||
|
* Zwei Dinge gehen hinüber. **Belegungen** sagen nur, wann Zeit weg ist —
|
||||||
|
* ohne Titel, ohne Aufgabe, ohne Kunde. So kollidiert die private Planung
|
||||||
|
* nicht mit der dienstlichen, ohne dass Firmeninhalte das Haus verlassen.
|
||||||
|
* **Aufgaben** gehen nur mit, wenn sie ausdrücklich dafür markiert sind; das
|
||||||
|
* ist eine bewusste Einzelentscheidung, kein Schalter für alles.
|
||||||
|
*
|
||||||
|
* Die Richtung ist fest verdrahtet: Was in der Firma passiert, darf daheim
|
||||||
|
* sichtbar sein, umgekehrt nicht. Das ist keine Technik, sondern eine
|
||||||
|
* Hausregel — und deshalb steckt sie in den Instanzfähigkeiten, nicht in
|
||||||
|
* einer Einstellung, die sich verstellen ließe.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Wie weit voraus gespiegelt wird. Weiter zurück interessiert niemanden. */
|
||||||
|
const FENSTER_TAGE_VORAUS = 30
|
||||||
|
|
||||||
|
export function signieren(koerper: string, geheimnis: string): string {
|
||||||
|
return createHmac('sha256', geheimnis).update(koerper).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft die Signatur in gleichbleibender Zeit.
|
||||||
|
*
|
||||||
|
* Ein einfacher Vergleich verrät über die Laufzeit, wie viele Zeichen schon
|
||||||
|
* stimmen — damit ließe sich die Signatur Stück für Stück erraten.
|
||||||
|
*/
|
||||||
|
export function signaturStimmt(
|
||||||
|
koerper: string,
|
||||||
|
signatur: string,
|
||||||
|
geheimnis: string,
|
||||||
|
): boolean {
|
||||||
|
const erwartet = Buffer.from(signieren(koerper, geheimnis), 'utf8')
|
||||||
|
const bekommen = Buffer.from(signatur, 'utf8')
|
||||||
|
if (erwartet.length !== bekommen.length) return false
|
||||||
|
return timingSafeEqual(erwartet, bekommen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Senden (firma) --------------------------------------------------------
|
||||||
|
|
||||||
|
/** Stellt zusammen, was hinübergeht. */
|
||||||
|
export async function sendungBauen(zeitpunkt: Date): Promise<SpiegelSendung> {
|
||||||
|
const bis = new Date(zeitpunkt.getTime() + FENSTER_TAGE_VORAUS * 86_400_000)
|
||||||
|
|
||||||
|
const [zeitbloecke, aufgaben] = await Promise.all([
|
||||||
|
db.zeitblock.findMany({
|
||||||
|
where: { beginn: { gte: zeitpunkt, lte: bis } },
|
||||||
|
orderBy: { beginn: 'asc' },
|
||||||
|
take: 500,
|
||||||
|
// Kein Titel, keine Aufgabe: Nur wann die Zeit weg ist.
|
||||||
|
select: { id: true, beginn: true, ende: true },
|
||||||
|
}),
|
||||||
|
db.aufgabe.findMany({
|
||||||
|
where: { spiegeln: true },
|
||||||
|
orderBy: { erstelltAm: 'asc' },
|
||||||
|
take: 500,
|
||||||
|
select: { id: true, titel: true, faelligAm: true, status: true },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
gesendetAm: zeitpunkt.toISOString(),
|
||||||
|
belegungen: zeitbloecke.map((z) => ({
|
||||||
|
ref: z.id,
|
||||||
|
beginn: z.beginn.toISOString(),
|
||||||
|
ende: z.ende.toISOString(),
|
||||||
|
})),
|
||||||
|
aufgaben: aufgaben.map((a) => ({
|
||||||
|
ref: a.id,
|
||||||
|
titel: a.titel,
|
||||||
|
faelligAm: a.faelligAm ? a.faelligAm.toISOString() : null,
|
||||||
|
erledigt: a.status === 'ERLEDIGT' || a.status === 'VERWORFEN',
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schickt eine Runde an die andere Instanz. */
|
||||||
|
export async function spiegelSenden(): Promise<{ belegungen: number; aufgaben: number }> {
|
||||||
|
const einst = umgebung()
|
||||||
|
try {
|
||||||
|
const sendung = await sendungBauen(jetzt())
|
||||||
|
const koerper = JSON.stringify(sendung)
|
||||||
|
|
||||||
|
const gelesen = await fetch(`${einst.SPIEGEL_ZIEL.replace(/\/+$/, '')}/api/spiegel`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Planer-Signatur': signieren(koerper, einst.SPIEGEL_GEHEIMNIS),
|
||||||
|
},
|
||||||
|
body: koerper,
|
||||||
|
signal: AbortSignal.timeout(20_000),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!gelesen.ok) {
|
||||||
|
throw new Error(`Der Spiegel antwortete mit HTTP ${gelesen.status}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await laufGeglueckt('SPIEGEL')
|
||||||
|
return { belegungen: sendung.belegungen.length, aufgaben: sendung.aufgaben.length }
|
||||||
|
} catch (fehler) {
|
||||||
|
await laufGescheitert('SPIEGEL', fehler)
|
||||||
|
throw fehler
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Empfangen (daheim) ----------------------------------------------------
|
||||||
|
|
||||||
|
export type Empfangsbericht = {
|
||||||
|
belegungen: number
|
||||||
|
entfernt: number
|
||||||
|
aufgaben: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nimmt eine geprüfte Sendung auf.
|
||||||
|
*
|
||||||
|
* Belegungen werden abgeglichen, nicht angehäuft: Was der Sender nicht mehr
|
||||||
|
* schickt, ist drüben ausgeplant worden und verschwindet auch hier. Sonst
|
||||||
|
* stünde daheim für immer eine Sperre, die es längst nicht mehr gibt.
|
||||||
|
*/
|
||||||
|
export async function sendungAufnehmen(
|
||||||
|
sendung: SpiegelSendung,
|
||||||
|
anstoss: Anstoss,
|
||||||
|
): Promise<Empfangsbericht> {
|
||||||
|
const gesehen = new Set(sendung.belegungen.map((b) => b.ref))
|
||||||
|
|
||||||
|
for (const belegung of sendung.belegungen) {
|
||||||
|
const werte = {
|
||||||
|
beginn: new Date(belegung.beginn),
|
||||||
|
ende: new Date(belegung.ende),
|
||||||
|
empfangenAm: jetzt(),
|
||||||
|
}
|
||||||
|
await db.spiegelbelegung.upsert({
|
||||||
|
where: { quelleRef: belegung.ref },
|
||||||
|
update: werte,
|
||||||
|
create: { quelleRef: belegung.ref, ...werte },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const veraltet = await db.spiegelbelegung.findMany({
|
||||||
|
where: { quelleRef: { notIn: [...gesehen] } },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (veraltet.length > 0) {
|
||||||
|
await db.spiegelbelegung.deleteMany({
|
||||||
|
where: { id: { in: veraltet.map((v) => v.id) } },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const funde: Fund[] = sendung.aufgaben.map((a) => ({
|
||||||
|
ref: a.ref,
|
||||||
|
titel: a.titel,
|
||||||
|
faelligAm: a.faelligAm ? new Date(a.faelligAm) : null,
|
||||||
|
erledigt: a.erledigt,
|
||||||
|
}))
|
||||||
|
const bericht = await fundeAufnehmen('SPIEGEL', funde, anstoss)
|
||||||
|
|
||||||
|
await laufGeglueckt('SPIEGEL')
|
||||||
|
|
||||||
|
return {
|
||||||
|
belegungen: sendung.belegungen.length,
|
||||||
|
entfernt: veraltet.length,
|
||||||
|
aufgaben: bericht.angelegt + bericht.aktualisiert + bericht.geschlossen,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft Signatur und Frische und gibt die Sendung frei.
|
||||||
|
*
|
||||||
|
* Gibt bei jedem Fehlschlag dieselbe knappe Auskunft: Wer von außen probiert,
|
||||||
|
* soll nicht erfahren, ob das Geheimnis stimmte und nur die Uhr falsch ging.
|
||||||
|
*/
|
||||||
|
export function sendungPruefen(
|
||||||
|
koerper: string,
|
||||||
|
signatur: string | null,
|
||||||
|
zeitpunkt: Date,
|
||||||
|
): { ok: true; sendung: SpiegelSendung } | { ok: false } {
|
||||||
|
const einst = umgebung()
|
||||||
|
if (!einst.spiegelEmpfangenAktiv || !signatur) return { ok: false }
|
||||||
|
if (!signaturStimmt(koerper, signatur, einst.SPIEGEL_GEHEIMNIS)) return { ok: false }
|
||||||
|
|
||||||
|
let inhalt: unknown
|
||||||
|
try {
|
||||||
|
inhalt = JSON.parse(koerper)
|
||||||
|
} catch {
|
||||||
|
return { ok: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const gelesen = spiegelSendung.safeParse(inhalt)
|
||||||
|
if (!gelesen.success) return { ok: false }
|
||||||
|
if (!sendungFrisch(new Date(gelesen.data.gesendetAm), zeitpunkt)) return { ok: false }
|
||||||
|
|
||||||
|
return { ok: true, sendung: gelesen.data }
|
||||||
|
}
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
import 'server-only'
|
||||||
|
import { randomBytes } from 'node:crypto'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { alsTitel, type Fund } from '@/lib/integration'
|
||||||
|
import type { Meldung } from '@/lib/benachrichtigung'
|
||||||
|
import { auditSchreiben } from '@/server/audit'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
import { umgebung } from '@/server/umgebung'
|
||||||
|
import { jetzt } from '@/server/zeitgeber'
|
||||||
|
import {
|
||||||
|
fundeAufnehmen,
|
||||||
|
laufGescheitert,
|
||||||
|
laufGeglueckt,
|
||||||
|
merkerLesen,
|
||||||
|
type Anstoss,
|
||||||
|
} from '@/server/integration/aufnahme'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram – nur in der Instanz „daheim".
|
||||||
|
*
|
||||||
|
* Drei Dinge in einem Kanal: erfassen (Nachricht wird Aufgabe), melden
|
||||||
|
* (dritter Weg neben Push und Mail) und abhaken (Knopf unter der Meldung).
|
||||||
|
*
|
||||||
|
* Geholt wird per `getUpdates`, nicht per Webhook. Ein Webhook verlangt eine
|
||||||
|
* von außen erreichbare Adresse mit gültigem Zertifikat; der Planer soll auch
|
||||||
|
* hinter einem Anschluss ohne feste Adresse laufen.
|
||||||
|
*
|
||||||
|
* **Wer nicht im Adressbuch steht, wird verworfen.** Ein Bot-Token lässt sich
|
||||||
|
* abgreifen, und wer die Kennung des Bots kennt, kann ihm schreiben. Die
|
||||||
|
* erlaubten Chats stehen in der Datenbank; alles andere wird stillschweigend
|
||||||
|
* weggeworfen — eine Antwort wäre schon die Auskunft, dass es den Bot gibt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const HOECHSTENS = 50
|
||||||
|
|
||||||
|
const nachricht = z.object({
|
||||||
|
message_id: z.number().int(),
|
||||||
|
date: z.number().int(),
|
||||||
|
chat: z.object({ id: z.number().int() }),
|
||||||
|
text: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const rueckfrage = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
data: z.string().optional(),
|
||||||
|
message: z
|
||||||
|
.object({ message_id: z.number().int(), chat: z.object({ id: z.number().int() }) })
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const aktualisierung = z.object({
|
||||||
|
update_id: z.number().int(),
|
||||||
|
message: nachricht.optional(),
|
||||||
|
callback_query: rueckfrage.optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const antwort = z.object({
|
||||||
|
ok: z.boolean(),
|
||||||
|
result: z.array(z.unknown()).optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
async function rufen(
|
||||||
|
methode: string,
|
||||||
|
koerper: Record<string, unknown>,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const einst = umgebung()
|
||||||
|
const gelesen = await fetch(
|
||||||
|
`https://api.telegram.org/bot${einst.TELEGRAM_TOKEN}/${methode}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(koerper),
|
||||||
|
signal: AbortSignal.timeout(70_000),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!gelesen.ok) {
|
||||||
|
// Der Körper enthält im Fehlerfall den Token nicht, wohl aber die
|
||||||
|
// Nachricht – deshalb nur der Status.
|
||||||
|
throw new Error(`Telegram antwortete mit HTTP ${gelesen.status}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inhalt: unknown = await gelesen.json()
|
||||||
|
const geprueft = antwort.safeParse(inhalt)
|
||||||
|
if (!geprueft.success || !geprueft.data.ok) {
|
||||||
|
throw new Error('Telegram lehnte die Anfrage ab.')
|
||||||
|
}
|
||||||
|
return geprueft.data.result
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Senden ----------------------------------------------------------------
|
||||||
|
|
||||||
|
export type Knopf = { text: string; daten: string }
|
||||||
|
|
||||||
|
/** Schickt eine Nachricht in einen Chat. Gibt zurück, ob es geklappt hat. */
|
||||||
|
export async function telegramSenden(
|
||||||
|
chatId: bigint,
|
||||||
|
text: string,
|
||||||
|
knoepfe: readonly Knopf[] = [],
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await rufen('sendMessage', {
|
||||||
|
chat_id: Number(chatId),
|
||||||
|
text,
|
||||||
|
...(knoepfe.length > 0
|
||||||
|
? {
|
||||||
|
reply_markup: {
|
||||||
|
inline_keyboard: [
|
||||||
|
knoepfe.map((k) => ({ text: k.text, callback_data: k.daten })),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch (fehler) {
|
||||||
|
console.error(
|
||||||
|
'Telegram-Versand fehlgeschlagen:',
|
||||||
|
fehler instanceof Error ? fehler.message : 'unbekannter Fehler',
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schickt eine Meldung an alle Chats eines Benutzers, die Meldungen wollen.
|
||||||
|
*
|
||||||
|
* Hängt eine Aufgabe daran, kommt ein Knopf mit: abhaken, ohne die Anwendung
|
||||||
|
* zu öffnen. Genau dafür lohnt sich der Umweg über Telegram.
|
||||||
|
*/
|
||||||
|
export async function telegramMeldung(
|
||||||
|
benutzerId: string,
|
||||||
|
meldung: Meldung,
|
||||||
|
): Promise<number> {
|
||||||
|
const chats = await db.telegramChat.findMany({
|
||||||
|
where: { benutzerId, meldungen: true },
|
||||||
|
select: { id: true, chatId: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const einst = umgebung()
|
||||||
|
const text = `${meldung.titel}\n\n${meldung.text}\n\n${einst.APP_URL}${meldung.pfad}`
|
||||||
|
const knoepfe: Knopf[] = meldung.objektId
|
||||||
|
? [{ text: '✓ Erledigt', daten: `erledigt:${meldung.objektId}` }]
|
||||||
|
: []
|
||||||
|
|
||||||
|
let zugestellt = 0
|
||||||
|
for (const chat of chats) {
|
||||||
|
if (await telegramSenden(chat.chatId, text, knoepfe)) {
|
||||||
|
zugestellt++
|
||||||
|
await db.telegramChat.update({
|
||||||
|
where: { id: chat.id },
|
||||||
|
data: { zuletztBenutzt: jetzt() },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return zugestellt
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Kopplung --------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie lange ein Kopplungscode gilt.
|
||||||
|
*
|
||||||
|
* Kurz genug, dass ein abgeschriebener Code nichts mehr nützt; lang genug,
|
||||||
|
* um in Ruhe das Handy zu suchen.
|
||||||
|
*/
|
||||||
|
export const KOPPLUNG_MINUTEN = 10
|
||||||
|
|
||||||
|
/** Ziffern und Großbuchstaben ohne die, die sich verwechseln lassen. */
|
||||||
|
const ZEICHEN = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Code, allein oder hinter `/start`.
|
||||||
|
*
|
||||||
|
* Telegram schickt beim ersten Öffnen eines Bots `/start` mit; wer den Code
|
||||||
|
* abtippt, schickt ihn nackt. Beides muss gehen.
|
||||||
|
*/
|
||||||
|
const KOPPLUNGSBEFEHL = /^(?:\/start\s+)?([A-Za-z0-9]{8})$/
|
||||||
|
|
||||||
|
export function kopplungscodeBauen(): string {
|
||||||
|
const bytes = randomBytes(8)
|
||||||
|
return [...bytes]
|
||||||
|
.map((b) => ZEICHEN[b % ZEICHEN.length])
|
||||||
|
.join('')
|
||||||
|
.slice(0, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löst einen Code ein und trägt den Chat ein.
|
||||||
|
*
|
||||||
|
* Der Code gilt genau einmal: Er verschwindet mit dem ersten Einlösen, auch
|
||||||
|
* wenn dabei etwas schiefgeht.
|
||||||
|
*/
|
||||||
|
async function kopplungEinloesen(
|
||||||
|
code: string,
|
||||||
|
chatId: number,
|
||||||
|
): Promise<'gekoppelt' | 'schon-da' | 'unbekannt'> {
|
||||||
|
const kopplung = await db.telegramKopplung.findUnique({
|
||||||
|
where: { code },
|
||||||
|
select: { id: true, benutzerId: true, gueltigBis: true },
|
||||||
|
})
|
||||||
|
if (!kopplung) return 'unbekannt'
|
||||||
|
|
||||||
|
await db.telegramKopplung.delete({ where: { id: kopplung.id } })
|
||||||
|
if (kopplung.gueltigBis.getTime() < jetzt().getTime()) return 'unbekannt'
|
||||||
|
|
||||||
|
const vorhanden = await db.telegramChat.findUnique({
|
||||||
|
where: { chatId: BigInt(chatId) },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (vorhanden) return 'schon-da'
|
||||||
|
|
||||||
|
const chat = await db.telegramChat.create({
|
||||||
|
data: { chatId: BigInt(chatId), benutzerId: kopplung.benutzerId },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: { id: kopplung.benutzerId, email: 'telegram@intern' },
|
||||||
|
aktion: 'telegram.gekoppelt',
|
||||||
|
objektTyp: 'TelegramChat',
|
||||||
|
objektId: chat.id,
|
||||||
|
// Die Chatkennung gehört zu einer Person und bleibt draußen.
|
||||||
|
})
|
||||||
|
return 'gekoppelt'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entfernt abgelaufene Codes. */
|
||||||
|
export async function alteKopplungenEntfernen(zeitpunkt: Date): Promise<number> {
|
||||||
|
const weg = await db.telegramKopplung.deleteMany({
|
||||||
|
where: { gueltigBis: { lt: zeitpunkt } },
|
||||||
|
})
|
||||||
|
return weg.count
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Holen -----------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Ein bekannter Chat, oder null. Unbekannte werden nicht beantwortet. */
|
||||||
|
async function chatPruefen(chatId: number) {
|
||||||
|
return db.telegramChat.findUnique({
|
||||||
|
where: { chatId: BigInt(chatId) },
|
||||||
|
select: { id: true, benutzerId: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hakt eine Aufgabe über den Knopf unter einer Meldung ab.
|
||||||
|
*
|
||||||
|
* Nur abhaken, nichts anderes: Ein Knopf, der löscht oder verschiebt, wäre
|
||||||
|
* aus einem Chat heraus zu leicht versehentlich getroffen.
|
||||||
|
*/
|
||||||
|
async function erledigtMelden(aufgabeId: string, anstoss: Anstoss): Promise<string> {
|
||||||
|
const aufgabe = await db.aufgabe.findUnique({
|
||||||
|
where: { id: aufgabeId },
|
||||||
|
select: { id: true, titel: true, status: true },
|
||||||
|
})
|
||||||
|
if (!aufgabe) return 'Diese Aufgabe gibt es nicht mehr.'
|
||||||
|
if (aufgabe.status === 'ERLEDIGT') return 'War schon erledigt.'
|
||||||
|
|
||||||
|
await db.aufgabe.update({
|
||||||
|
where: { id: aufgabe.id },
|
||||||
|
data: { status: 'ERLEDIGT', erledigtAm: jetzt() },
|
||||||
|
})
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: anstoss,
|
||||||
|
aktion: 'aufgabe.erledigt',
|
||||||
|
objektTyp: 'Aufgabe',
|
||||||
|
objektId: aufgabe.id,
|
||||||
|
details: { ueber: 'telegram' },
|
||||||
|
})
|
||||||
|
return 'Erledigt.'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holt alles Neue ab und arbeitet es ab.
|
||||||
|
*
|
||||||
|
* Der Merker ist die Kennung der zuletzt geholten Aktualisierung. Telegram
|
||||||
|
* löscht alles darunter, sobald wir mit dem nächsten Offset fragen — das ist
|
||||||
|
* zugleich die Bestätigung, dass wir es haben.
|
||||||
|
*/
|
||||||
|
export async function telegramAbholen(anstoss: Anstoss): Promise<{
|
||||||
|
aufgaben: number
|
||||||
|
abgehakt: number
|
||||||
|
gekoppelt: number
|
||||||
|
verworfen: number
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const merker = await merkerLesen('TELEGRAM')
|
||||||
|
const offset = merker ? Number(merker) : undefined
|
||||||
|
|
||||||
|
const roh = await rufen('getUpdates', {
|
||||||
|
...(offset === undefined || Number.isNaN(offset) ? {} : { offset }),
|
||||||
|
limit: HOECHSTENS,
|
||||||
|
// Kein Long-Polling: Der Job läuft ohnehin im Takt und soll nicht
|
||||||
|
// minutenlang eine Verbindung offen halten.
|
||||||
|
timeout: 0,
|
||||||
|
allowed_updates: ['message', 'callback_query'],
|
||||||
|
})
|
||||||
|
|
||||||
|
const eintraege = z.array(aktualisierung).safeParse(roh)
|
||||||
|
if (!eintraege.success) throw new Error('Telegram antwortete in unbekannter Form.')
|
||||||
|
|
||||||
|
const funde: Fund[] = []
|
||||||
|
let abgehakt = 0
|
||||||
|
let gekoppelt = 0
|
||||||
|
let verworfen = 0
|
||||||
|
let hoechste = offset ? offset - 1 : 0
|
||||||
|
|
||||||
|
for (const eintrag of eintraege.data) {
|
||||||
|
hoechste = Math.max(hoechste, eintrag.update_id)
|
||||||
|
|
||||||
|
if (eintrag.callback_query) {
|
||||||
|
const chatId = eintrag.callback_query.message?.chat.id
|
||||||
|
const bekannt = chatId === undefined ? null : await chatPruefen(chatId)
|
||||||
|
if (!bekannt) {
|
||||||
|
verworfen++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const daten = eintrag.callback_query.data ?? ''
|
||||||
|
const [was, id] = daten.split(':')
|
||||||
|
const meldung =
|
||||||
|
was === 'erledigt' && id
|
||||||
|
? await erledigtMelden(id, {
|
||||||
|
id: bekannt.benutzerId,
|
||||||
|
email: anstoss.email,
|
||||||
|
})
|
||||||
|
: 'Unbekannter Knopf.'
|
||||||
|
abgehakt += meldung === 'Erledigt.' ? 1 : 0
|
||||||
|
|
||||||
|
await rufen('answerCallbackQuery', {
|
||||||
|
callback_query_id: eintrag.callback_query.id,
|
||||||
|
text: meldung,
|
||||||
|
}).catch(() => undefined)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const nachricht = eintrag.message
|
||||||
|
if (!nachricht?.text) continue
|
||||||
|
|
||||||
|
const bekannt = await chatPruefen(nachricht.chat.id)
|
||||||
|
if (!bekannt) {
|
||||||
|
// Die einzige Ausnahme: ein gültiger Kopplungscode. Er ist der
|
||||||
|
// Beweis, dass jemand mit Zugang zur Anwendung diesen Chat will.
|
||||||
|
const code = KOPPLUNGSBEFEHL.exec(nachricht.text.trim())?.[1]?.toUpperCase()
|
||||||
|
if (code) {
|
||||||
|
const ergebnis = await kopplungEinloesen(code, nachricht.chat.id)
|
||||||
|
if (ergebnis !== 'unbekannt') {
|
||||||
|
gekoppelt++
|
||||||
|
await telegramSenden(
|
||||||
|
BigInt(nachricht.chat.id),
|
||||||
|
ergebnis === 'gekoppelt'
|
||||||
|
? 'Verbunden. Schick mir eine Nachricht, und daraus wird eine Aufgabe.'
|
||||||
|
: 'Dieser Chat war schon verbunden.',
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sonst kein Wort zurück. Wer nicht eingetragen ist, soll nicht
|
||||||
|
// erfahren, dass hier etwas läuft.
|
||||||
|
verworfen++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const { titel, rest } = alsTitel(nachricht.text)
|
||||||
|
if (titel.length === 0) continue
|
||||||
|
|
||||||
|
funde.push({
|
||||||
|
ref: `${nachricht.chat.id}:${nachricht.message_id}`,
|
||||||
|
titel,
|
||||||
|
notiz: rest,
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.telegramChat.update({
|
||||||
|
where: { id: bekannt.id },
|
||||||
|
data: { zuletztBenutzt: jetzt() },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const bericht = await fundeAufnehmen('TELEGRAM', funde, anstoss)
|
||||||
|
|
||||||
|
// Erst bestätigen, wenn alles verarbeitet ist: Ein Offset, der zu früh
|
||||||
|
// hochgezählt wird, verliert Nachrichten unwiederbringlich.
|
||||||
|
await laufGeglueckt('TELEGRAM', String(hoechste + 1))
|
||||||
|
|
||||||
|
return { aufgaben: bericht.angelegt, abgehakt, gekoppelt, verworfen }
|
||||||
|
} catch (fehler) {
|
||||||
|
await laufGescheitert('TELEGRAM', fehler)
|
||||||
|
throw fehler
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import 'server-only'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { alsTitel, type Fund } from '@/lib/integration'
|
||||||
|
import { umgebung } from '@/server/umgebung'
|
||||||
|
import {
|
||||||
|
fundeAufnehmen,
|
||||||
|
laufGescheitert,
|
||||||
|
laufGeglueckt,
|
||||||
|
type Anstoss,
|
||||||
|
type Aufnahmebericht,
|
||||||
|
} from '@/server/integration/aufnahme'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zammad – nur in der Instanz „firma".
|
||||||
|
*
|
||||||
|
* Gelesen wird über die Suche mit einem Ausdruck aus der Konfiguration; was
|
||||||
|
* darauf passt, wird zur Aufgabe. Der Token gehört einem Benutzer und sieht
|
||||||
|
* genau das, was dieser Benutzer sehen darf — der Planer umgeht keine Rechte.
|
||||||
|
*
|
||||||
|
* Geschrieben wird nach Zammad **nicht**. Ein Ticket schließt man im
|
||||||
|
* Ticketsystem, sonst weiß die Kollegin nicht, warum es plötzlich zu ist.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Wie viele Tickets höchstens je Lauf. Mehr wäre ohnehin keine Planung. */
|
||||||
|
const HOECHSTENS = 100
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zammads Zuständen ist nicht anzusehen, ob sie „fertig" bedeuten. Der
|
||||||
|
* `state_type` sagt es; die Suche liefert ihn nicht immer mit, deshalb
|
||||||
|
* zusätzlich die üblichen Namen.
|
||||||
|
*/
|
||||||
|
const ERLEDIGT_ZUSTAENDE = ['closed', 'merged', 'removed']
|
||||||
|
|
||||||
|
const ticket = z.object({
|
||||||
|
id: z.number().int(),
|
||||||
|
number: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
state: z.string().optional(),
|
||||||
|
/** 1 low, 2 normal, 3 high – Zammads Voreinstellung. */
|
||||||
|
priority: z.string().optional(),
|
||||||
|
pending_time: z.string().nullable().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
type Ticket = z.infer<typeof ticket>
|
||||||
|
|
||||||
|
const antwort = z.object({
|
||||||
|
assets: z.object({ Ticket: z.record(z.string(), z.unknown()).optional() }).optional(),
|
||||||
|
tickets: z.array(z.number().int()).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Übersetzt Zammads Priorität in unsere Skala.
|
||||||
|
*
|
||||||
|
* Zammad: 1 niedrig, 2 normal, 3 hoch. Hier: 1 dringend … 5 irgendwann.
|
||||||
|
* Die Zahlen laufen also gegenläufig, was beim Lesen gern übersehen wird.
|
||||||
|
*/
|
||||||
|
function prioritaetVon(zammad: string | undefined): number | null {
|
||||||
|
if (!zammad) return null
|
||||||
|
if (zammad.startsWith('3')) return 2
|
||||||
|
if (zammad.startsWith('1')) return 4
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
|
||||||
|
function alsFund(t: Ticket, basis: string): Fund {
|
||||||
|
const { titel, rest } = alsTitel(`#${t.number} ${t.title}`)
|
||||||
|
const zustand = (t.state ?? '').toLowerCase()
|
||||||
|
|
||||||
|
return {
|
||||||
|
ref: String(t.id),
|
||||||
|
titel,
|
||||||
|
notiz: rest,
|
||||||
|
url: `${basis}/#ticket/zoom/${t.id}`,
|
||||||
|
faelligAm: t.pending_time ? new Date(t.pending_time) : null,
|
||||||
|
prioritaet: prioritaetVon(t.priority),
|
||||||
|
erledigt: ERLEDIGT_ZUSTAENDE.some((z) => zustand.includes(z)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Holt die Tickets, auf die der Suchausdruck passt. */
|
||||||
|
export async function ticketsHolen(): Promise<Fund[]> {
|
||||||
|
const einst = umgebung()
|
||||||
|
const basis = einst.ZAMMAD_URL.replace(/\/+$/, '')
|
||||||
|
|
||||||
|
const adresse = new URL(`${basis}/api/v1/tickets/search`)
|
||||||
|
adresse.searchParams.set('query', einst.ZAMMAD_ABFRAGE)
|
||||||
|
adresse.searchParams.set('limit', String(HOECHSTENS))
|
||||||
|
// Ohne "full" liefert die Suche nur Kennungen und wir müssten jedes Ticket
|
||||||
|
// einzeln nachladen.
|
||||||
|
adresse.searchParams.set('expand', 'true')
|
||||||
|
|
||||||
|
const gelesen = await fetch(adresse, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Token token=${einst.ZAMMAD_TOKEN}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
// Ein hängendes Ticketsystem darf den Job nicht festhalten.
|
||||||
|
signal: AbortSignal.timeout(20_000),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!gelesen.ok) {
|
||||||
|
// Nie den Antwortkörper: Zammad schickt im Fehlerfall gern den ganzen
|
||||||
|
// Datensatz mit.
|
||||||
|
throw new Error(`Zammad antwortete mit HTTP ${gelesen.status}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inhalt: unknown = await gelesen.json()
|
||||||
|
|
||||||
|
// Mit expand=true ist die Antwort eine Liste, ohne ein Objekt mit assets.
|
||||||
|
if (Array.isArray(inhalt)) {
|
||||||
|
return inhalt
|
||||||
|
.map((eintrag) => ticket.safeParse(eintrag))
|
||||||
|
.filter((g) => g.success)
|
||||||
|
.map((g) => alsFund(g.data, basis))
|
||||||
|
}
|
||||||
|
|
||||||
|
const huelle = antwort.safeParse(inhalt)
|
||||||
|
if (!huelle.success) throw new Error('Zammad antwortete in unbekannter Form.')
|
||||||
|
|
||||||
|
const werte = Object.values(huelle.data.assets?.Ticket ?? {})
|
||||||
|
return werte
|
||||||
|
.map((eintrag) => ticket.safeParse(eintrag))
|
||||||
|
.filter((g) => g.success)
|
||||||
|
.map((g) => alsFund(g.data, basis))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Durchlauf: holen, aufnehmen, Zustand fortschreiben.
|
||||||
|
*
|
||||||
|
* Scheitert das Holen, bleibt der bisherige Stand stehen. Eine leere Antwort
|
||||||
|
* darf niemals als „alles erledigt" durchgehen — deshalb schließt diese
|
||||||
|
* Integration nur, was Zammad ausdrücklich als geschlossen liefert.
|
||||||
|
*/
|
||||||
|
export async function zammadAbgleichen(anstoss: Anstoss): Promise<Aufnahmebericht> {
|
||||||
|
try {
|
||||||
|
const funde = await ticketsHolen()
|
||||||
|
const bericht = await fundeAufnehmen('ZAMMAD', funde, anstoss)
|
||||||
|
await laufGeglueckt('ZAMMAD')
|
||||||
|
return bericht
|
||||||
|
} catch (fehler) {
|
||||||
|
await laufGescheitert('ZAMMAD', fehler)
|
||||||
|
throw fehler
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,10 @@ import {
|
|||||||
alteNachweiseEntfernen,
|
alteNachweiseEntfernen,
|
||||||
benachrichtigungenVerschicken,
|
benachrichtigungenVerschicken,
|
||||||
} from '@/server/benachrichtigung/versand'
|
} from '@/server/benachrichtigung/versand'
|
||||||
|
import { zammadAbgleichen } from '@/server/integration/zammad'
|
||||||
|
import { alteKopplungenEntfernen, telegramAbholen } from '@/server/integration/telegram'
|
||||||
|
import { spiegelSenden } from '@/server/integration/spiegel'
|
||||||
|
import { umgebung } from '@/server/umgebung'
|
||||||
import { jetzt } from '@/server/zeitgeber'
|
import { jetzt } from '@/server/zeitgeber'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,6 +35,24 @@ const AUFRAEUM_PLAN = '17 3 * * *'
|
|||||||
* dank Wiederholungssperre nur einmal gemeldet.
|
* dank Wiederholungssperre nur einmal gemeldet.
|
||||||
*/
|
*/
|
||||||
const MELDUNGS_PLAN = '*/10 * * * *'
|
const MELDUNGS_PLAN = '*/10 * * * *'
|
||||||
|
/**
|
||||||
|
* Integrationen laufen im eigenen Takt.
|
||||||
|
*
|
||||||
|
* Telegram oefter als der Rest: Wer dem Bot etwas schickt, erwartet die
|
||||||
|
* Aufgabe gleich in der Liste, nicht in einer Viertelstunde. Zammad und der
|
||||||
|
* Spiegel duerfen traeger sein — dort aendert sich nichts im Minutentakt.
|
||||||
|
*/
|
||||||
|
const TELEGRAM_PLAN = '*/2 * * * *'
|
||||||
|
const ZAMMAD_PLAN = '*/15 * * * *'
|
||||||
|
const SPIEGEL_PLAN = '*/15 * * * *'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wer im Protokoll steht, wenn niemand geklickt hat.
|
||||||
|
*
|
||||||
|
* Ein Job hat keinen Benutzer. Statt den Eintrag wegzulassen, traegt er
|
||||||
|
* sichtbar den Dienst ein — sonst sieht es aus, als sei nichts passiert.
|
||||||
|
*/
|
||||||
|
const DIENST = { id: null, email: 'dienst@intern' }
|
||||||
|
|
||||||
async function alleKontenAbgleichen(): Promise<void> {
|
async function alleKontenAbgleichen(): Promise<void> {
|
||||||
const zeitpunkt = jetzt()
|
const zeitpunkt = jetzt()
|
||||||
@@ -87,11 +109,58 @@ export function jobsStarten(): void {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const einst = umgebung()
|
||||||
|
|
||||||
|
// Die Demo-Instanz redet mit niemandem. Sie ist mit erfundenen Daten
|
||||||
|
// gefuellt und steht nur fuer Bilder – ein Aufruf nach draussen waere
|
||||||
|
// bestenfalls sinnlos und schlimmstenfalls peinlich.
|
||||||
|
const nachDraussen = !einst.DEMO_MODUS
|
||||||
|
|
||||||
|
if (einst.zammadAktiv && nachDraussen) {
|
||||||
|
cron.schedule(ZAMMAD_PLAN, () => {
|
||||||
|
void zammadAbgleichen(DIENST)
|
||||||
|
.then((bericht) => {
|
||||||
|
if (bericht.angelegt > 0 || bericht.geschlossen > 0) {
|
||||||
|
console.warn(
|
||||||
|
`Zammad: ${bericht.angelegt} neu, ${bericht.geschlossen} geschlossen.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((fehler: unknown) => {
|
||||||
|
console.error('Zammad-Abgleich abgebrochen:', fehler)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (einst.telegramAktiv && nachDraussen) {
|
||||||
|
cron.schedule(TELEGRAM_PLAN, () => {
|
||||||
|
void telegramAbholen(DIENST)
|
||||||
|
.then((bericht) => {
|
||||||
|
if (bericht.verworfen > 0) {
|
||||||
|
// Nie die Chatkennung: Sie gehoert zu einer Person.
|
||||||
|
console.warn(`Telegram: ${bericht.verworfen} Nachricht(en) verworfen.`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((fehler: unknown) => {
|
||||||
|
console.error('Telegram-Abholen abgebrochen:', fehler)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (einst.spiegelSendenAktiv && nachDraussen) {
|
||||||
|
cron.schedule(SPIEGEL_PLAN, () => {
|
||||||
|
void spiegelSenden().catch((fehler: unknown) => {
|
||||||
|
console.error('Spiegel-Versand abgebrochen:', fehler)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
cron.schedule(AUFRAEUM_PLAN, () => {
|
cron.schedule(AUFRAEUM_PLAN, () => {
|
||||||
void Promise.all([
|
void Promise.all([
|
||||||
alteTermineEntfernen(),
|
alteTermineEntfernen(),
|
||||||
abgelaufeneChallengesEntfernen(jetzt()),
|
abgelaufeneChallengesEntfernen(jetzt()),
|
||||||
alteNachweiseEntfernen(jetzt()),
|
alteNachweiseEntfernen(jetzt()),
|
||||||
|
alteKopplungenEntfernen(jetzt()),
|
||||||
])
|
])
|
||||||
.then(() => undefined)
|
.then(() => undefined)
|
||||||
.catch((fehler: unknown) => {
|
.catch((fehler: unknown) => {
|
||||||
|
|||||||