10 Commits
Author SHA1 Message Date
Florian WildeandClaude Opus 5 70ed3a5220 chore(release): 0.6.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 09:18:51 +02:00
Florian WildeandClaude Opus 5 928f841adb docs: DEVLOG-Eintrag fuer M6
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 09:02:58 +02:00
Florian WildeandClaude Opus 5 98d170b68a docs: Handbuchbilder fuer M6 neu aufnehmen
Der Hinweis unter dem Wochenraster erklaerte nur die grauen Balken; die
gestreiften aus der anderen Instanz fehlten darin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 09:00:49 +02:00
Florian WildeandClaude Opus 5 475b1d5c08 docs: Handbuchseite, README und Fahrplan fuer M6
Der Wartungsplaner steht als offener Punkt drin, nicht als Haken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 04:51:38 +02:00
Florian WildeandClaude Opus 5 3c7cdbbbf3 chore: Demo-Daten, Bildaufnahme und Platz in der Kopfleiste
Die Demo-Instanz redet mit niemandem mehr: Solange DEMO_MODUS gilt,
starten die Jobs fuer Zammad, Telegram und den Spiegel gar nicht erst.
Sie ist mit erfundenen Daten gefuellt und steht nur fuer Bilder — ein
Aufruf nach draussen waere bestenfalls sinnlos.

Die Kopfleiste ist breiter als die Inhaltsspalte. Mit dem elften Eintrag
passte sie dort nicht mehr, und "Einstellungen" verschwand
stillschweigend am rechten Rand. Aufgefallen ist das erst im
aufgenommenen Bild.

Der Kartentext beschrieb noch den Weg von vor dem Kopplungscode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 04:51:37 +02:00
Florian WildeandClaude Opus 5 f5eadefdbb feat: Lauf von Hand anstossen und Spiegelschalter in der Liste
Direkt nach dem Einrichten wuesste man sonst erst beim naechsten Takt,
ob die Zugangsdaten stimmen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 23:59:14 +02:00
Florian WildeandClaude Opus 5 3befcc19a3 feat: Aufgaben einzeln zum Spiegeln freigeben
Der Schalter erscheint nur, wo auch gespiegelt wird.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 23:58:46 +02:00
Florian WildeandClaude Opus 5 4da2f3d9c6 test: Spiegel-Eingang und Integrationstabellen absichern
Der Eingang des Spiegels ist die einzige Tuer, die ohne Anmeldung
aufgeht. Vier Faelle: ohne Signatur, falsch signiert, zu alt, und der
gute Fall — bei dem eine nicht mehr geschickte Belegung auch hier
verschwinden muss.

Der Testserver bekommt dafuer dasselbe Geheimnis wie der Test. Eine
Pruefung, die immer uebersprungen wird, prueft nichts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 23:58:43 +02:00
Florian WildeandClaude Opus 5 0d0f8503cc feat: Gespiegelte Belegung in der Wochenansicht zeigen
Sie zaehlt gegen die Tageskapazitaet wie jeder fremde Termin, traegt
aber keinen Betreff und ist gestreift gezeichnet — damit niemand darin
einen Termin vermutet, dem der Titel fehlt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 23:04:35 +02:00
Florian WildeandClaude Opus 5 1281c3ea96 feat: Integrationen sichtbar und bedienbar machen
Eine Seite zeigt je Integration, ob sie vorgesehen, eingerichtet oder
gestoert ist, wann sie zuletzt geglueckt ist und woran sie klemmt. Der
Wartungsplaner steht sichtbar als offene Luecke darin, nicht als
Leerstelle.

Telegram-Chats werden hier gekoppelt und wieder getrennt, Meldungen je
Chat einzeln geschaltet. Aufgaben bekommen einen Schalter zum Spiegeln —
einzeln und je Aufgabe, denn wer versehentlich einen ganzen Bereich
spiegelt, schickt Kundennamen nach Hause.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 23:04:33 +02:00
87 changed files with 1474 additions and 45 deletions
+43
View File
@@ -6,6 +6,49 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht]
## [0.6.0] – 2026-09-12
### Hinzugefügt
- **Zammad** (nur `firma`): Tickets, auf die der Suchausdruck passt, werden zu
Aufgaben — mit Verweis ins Ticket. Wird ein Ticket dort geschlossen, hakt
der Planer die Aufgabe ab.
- **Telegram** (nur `daheim`): Eine Nachricht an den Bot wird zur Aufgabe.
Meldungen gehen denselben Weg zurück, mit einem Knopf zum Abhaken. Ein Chat
meldet sich über einen Einmalcode an, der zehn Minuten gilt.
- **Telegram als dritter Meldeweg** neben Push und E-Mail.
- **Spiegel** zwischen den Instanzen: `firma` schickt Belegungen ohne Titel
und die einzeln dafür markierten Aufgaben, `daheim` empfängt. Gespiegelte
Belegung erscheint in der Wochenansicht als „Dienstlich belegt" und zählt
gegen die Tageskapazität.
- Seite **Integrationen**: je Integration Zustand, letzter Erfolg, letzter
Fehler und ein Knopf, der einen Lauf sofort anstößt.
- Handbuchseite zu den Integrationen.
### Geändert
- Was aus einem Fund wird, entscheidet für alle Integrationen dieselbe
Stelle. Geführt werden nur Titel, Notiz, Verweis und Fälligkeit — Bereich,
Projekt, Priorität und geschätzter Aufwand gehören der Planung und werden
nie von außen überschrieben.
- Eine klemmende Integration meldet sich wie ein klemmender Kalender: erst
nach dem dritten Fehlversuch in Folge.
- Die Demo-Instanz für die Bilder spricht nicht mehr nach außen.
### Sicherheit
- Wer dem Telegram-Bot schreibt, ohne verbunden zu sein, bekommt **keine
Antwort** — auch keine Fehlermeldung. Sonst ließe sich herausfinden, dass
es diesen Planer gibt.
- Jede Sendung des Spiegels trägt eine Signatur über den Rumpf und ist nach
fünf Minuten wertlos. Der Empfänger antwortet auf jeden Fehlschlag gleich:
falsches Geheimnis, verstellte Uhr und Unsinn im Inhalt sind von außen
nicht zu unterscheiden.
- Der Signaturvergleich läuft in gleichbleibender Zeit.
- Aufgaben verlassen die Instanz nur mit ausdrücklicher Freigabe je Aufgabe.
Ein Schalter für einen ganzen Bereich würde zu leicht Kundennamen nach
Hause schicken.
## [0.5.0] – 2026-09-11
### Hinzugefügt
+46 -3
View File
@@ -33,8 +33,12 @@ wird.
Bereich, wie voll der heutige Tag schon ist
- **Wochenplanung** per Ziehen und Ablegen: Aufgaben werden zu echten
Kalenderterminen, Verschiebungen im Kalender kommen zurück
- **Benachrichtigungen** per Web-Push und E-Mail: Tagesüberblick, fällige
Aufgaben, Vorlauf vor einem Zeitblock — mit Ruhezeiten und ab Werk still
- **Benachrichtigungen** per Web-Push, E-Mail und Telegram: Tagesüberblick,
fällige Aufgaben, Vorlauf vor einem Zeitblock — mit Ruhezeiten und ab Werk
still
- **Integrationen**: Zammad-Tickets werden zu Aufgaben, Telegram erfasst und
hakt ab, und der Spiegel bringt dienstliche Belegung nach Hause — ohne
Titel
- Anmeldung über einen OIDC-Anbieter, über Passkeys oder mit E-Mail und
Passwort — je Instanz einzeln zuschaltbar
- Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird
@@ -45,7 +49,7 @@ wird.
### Geplant
Integrationen, Schnellerfassung vom Handy und ein MCP-Server.
Schnellerfassung vom Handy, eine öffentliche API und ein MCP-Server.
Siehe [ROADMAP.md](ROADMAP.md).
## Bildergalerie
@@ -63,6 +67,7 @@ Siehe [ROADMAP.md](ROADMAP.md).
| Bereiche | ![Bereiche, hell](docs/bilder/bereiche-hell-schreibtisch.png) | ![Bereiche, dunkel](docs/bilder/bereiche-dunkel-schreibtisch.png) |
| Einstellungen | ![Einstellungen, hell](docs/bilder/einstellungen-hell-schreibtisch.png) | ![Einstellungen, dunkel](docs/bilder/einstellungen-dunkel-schreibtisch.png) |
| Meldungen | ![Benachrichtigungen, hell](docs/bilder/benachrichtigungen-hell-schreibtisch.png) | ![Benachrichtigungen, dunkel](docs/bilder/benachrichtigungen-dunkel-schreibtisch.png) |
| Integrationen | ![Integrationen, hell](docs/bilder/integrationen-hell-schreibtisch.png) | ![Integrationen, dunkel](docs/bilder/integrationen-dunkel-schreibtisch.png) |
| Protokoll | ![Protokoll, hell](docs/bilder/protokoll-hell-schreibtisch.png) | ![Protokoll, dunkel](docs/bilder/protokoll-dunkel-schreibtisch.png) |
Alle Bilder entstehen mit `npm run screenshots` in einer Wegwerf-Instanz mit
@@ -171,6 +176,44 @@ Klartextadresse nicht.
Was der Planer wann meldet, steht im
[Handbuch](docs/handbuch/benachrichtigungen.md).
### Integrationen
Welche Integration eine Instanz überhaupt darf, entscheidet `INSTANZ` — nicht
die Konfiguration. Ein Zammad-Token in der Instanz `daheim` bleibt wirkungslos.
| Variable | Standard | Bedeutung |
| ------------------- | ------------------- | ---------------------------------------------------------------- |
| `ZAMMAD_URL` | _(leer)_ | Adresse der Zammad-Instanz, nur `firma` |
| `ZAMMAD_TOKEN` | _(leer)_ | Token aus **Profil → Token-Zugriff** |
| `ZAMMAD_ABFRAGE` | `owner_id:me AND …` | Suchausdruck; was darauf passt, wird zur Aufgabe |
| `TELEGRAM_TOKEN` | _(leer)_ | Bot-Token von [@BotFather](https://t.me/BotFather), nur `daheim` |
| `SPIEGEL_GEHEIMNIS` | _(leer)_ | Gemeinsames Geheimnis beider Instanzen |
| `SPIEGEL_ZIEL` | _(leer)_ | Adresse der empfangenden Instanz; nur auf der sendenden Seite |
**Zammad** liest nur. Ein Ticket schließt man im Ticketsystem — sonst weiß die
Kollegin nicht, warum es plötzlich zu ist. Umgekehrt schließt der Planer eine
Aufgabe, sobald Zammad das Ticket als erledigt meldet.
**Telegram** braucht keinen Webhook und damit keine von außen erreichbare
Adresse. Welche Chats mitreden dürfen, wird in der Anwendung unter
**Integrationen** gekoppelt: Knopf drücken, Code an den Bot schicken, fertig.
Nachrichten aus unbekannten Chats werden verworfen, ohne zu antworten.
**Der Spiegel** geht in eine Richtung: `firma` sendet, `daheim` empfängt.
Hinüber gehen Belegungen **ohne Titel** — daheim ist zu sehen, _dass_ die Zeit
weg ist, nicht woran gearbeitet wird — und jene Aufgaben, die einzeln zum
Spiegeln markiert wurden. Jede Sendung ist mit dem gemeinsamen Geheimnis
signiert und älter als fünf Minuten wertlos.
Das Geheimnis erzeugst du wie das Sitzungsgeheimnis:
```bash
openssl rand -base64 32
```
Was der Alltag damit anfängt, steht im
[Handbuch](docs/handbuch/integrationen.md).
### Einrichtung und Sonstiges
| Variable | Standard | Bedeutung |
+20 -5
View File
@@ -10,7 +10,7 @@ grünen Prüfungen, aktueller Dokumentation und einem Eintrag im DEVLOG.
| [M3](#m3--kalender-lesen) | Kalender lesen | erledigt |
| [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | erledigt |
| [M5](#m5--benachrichtigungen) | Benachrichtigungen | erledigt |
| [M6](#m6--integrationen) | Integrationen | geplant |
| [M6](#m6--integrationen) | Integrationen | erledigt |
| [M7](#m7--schnellerfassung-und-api) | Schnellerfassung und API | geplant |
| [M8](#m8--mcp) | MCP | geplant |
@@ -112,10 +112,25 @@ Erinnerung an einen Zeitblock, der längst begonnen hat, hilft niemandem.
## M6 – Integrationen
- [ ] Zammad und Wartungsplaner-API (nur `firma`)
- [ ] Telegram (nur `daheim`)
- [ ] Spiegel: `firma` sendet, `daheim` empfängt
- [ ] Jede Integration upsertet über `[quelle, quelleRef]`
**Erledigt am 12.09.2026 mit Version 0.6.0.**
**Ziel:** Aufgaben dort abholen, wo sie entstehen — ohne dass die Planung
darunter leidet, die hier passiert.
- [x] Gemeinsame Aufnahmestelle: jede Integration liefert nur Funde
- [x] Zammad (nur `firma`), lesend
- [ ] **Wartungsplaner-API** — Schnittstelle steht noch nicht fest
- [x] Telegram (nur `daheim`): erfassen, melden, abhaken
- [x] Spiegel: `firma` sendet, `daheim` empfängt
- [x] Jede Integration upsertet über `[quelle, quelleRef]`
**Offen geblieben:** Der **Wartungsplaner** ist nicht angebunden. Die
Aufnahmestelle steht bereit und die Seite „Integrationen" führt ihn als
sichtbare Lücke; es fehlt allein das Abholen, und dafür muss erst feststehen,
was das Produkt an Schnittstelle hergibt.
**Bewusst offen gelassen:** Zammad wird nur gelesen. Ein Ticket schließt man
im Ticketsystem — sonst weiß die Kollegin nicht, warum es plötzlich zu ist.
## M7 – Schnellerfassung und API
+116 -8
View File
@@ -5,14 +5,122 @@ die Übersicht aus `scripts/zeit.sh summe`.
## Zeit je Meilenstein
| Meilenstein | Art | Minuten | Stunden |
| ----------- | ------ | ------- | --------- |
| M1 | claude | 161 | 2,68 |
| M2 | claude | 44 | 0,73 |
| M3 | claude | 41 | 0,68 |
| M4 | claude | 167 | 2,78 |
| M5 | claude | 420 | 7,00 |
| **Gesamt** | | **833** | **13,88** |
| Meilenstein | Art | Minuten | Stunden |
| ----------- | ------ | -------- | --------- |
| M1 | claude | 161 | 2,68 |
| M2 | claude | 44 | 0,73 |
| M3 | claude | 41 | 0,68 |
| M4 | claude | 167 | 2,78 |
| M5 | claude | 420 | 7,00 |
| M6 | claude | 733 | 12,22 |
| **Gesamt** | | **1566** | **26,10** |
---
## 2026-09-12 – M6: Integrationen
**Dauer:** 733 Minuten (20:47–09:00), Art `claude`.
> Die Zahl täuscht. Ein großer Teil davon war Warten auf Docker-Bauten, die
> in der Nacht dreimal hängen blieben — einmal 39 Minuten ohne eine Zeile
> Ausgabe. Die eigentliche Arbeit war knapper.
**Ziel der Sitzung:** Aufgaben dort abholen, wo sie entstehen — ohne dass die
Planung darunter leidet, die hier passiert.
### Erledigt
- Gemeinsame Aufnahmestelle für alle Integrationen (21 Tests)
- Zammad, lesend, mit Suchausdruck aus der Konfiguration
- Telegram: erfassen, melden, abhaken — mit Kopplung über Einmalcode
- Spiegel zwischen den Instanzen: Belegung ohne Titel, Aufgaben nur markiert
- Seite „Integrationen" mit Zustand, Fehler und Knopf für den Sofortlauf
- Gespiegelte Belegung in der Wochenansicht
- 4 neue E2E-Tests, 9 neue Integrationsprüfungen, Handbuchseite
### Entscheidungen mit Begründung
**Das Ursprungssystem führt Titel, Notiz, Verweis und Fälligkeit — mehr
nicht.** Bereich, Projekt, Priorität und geschätzter Aufwand gehören der
Planung. Wer ein Ticket einsortiert und schätzt, muss sich darauf verlassen
können, dass der nächste Abgleich das nicht wegräumt. Ohne diese Trennung
wäre die Wochenplanung nach jedem Lauf kaputt.
**Zammad wird nur gelesen.** Ein Ticket schließt man im Ticketsystem, sonst
weiß die Kollegin nicht, warum es plötzlich zu ist. Umgekehrt darf Zammad
hier abhaken — dort ist die Wahrheit über den Zustand des Tickets.
**Telegram holt ab, statt einen Webhook zu bekommen.** Ein Webhook verlangt
eine von außen erreichbare Adresse mit gültigem Zertifikat. Der Planer soll
auch hinter einem Anschluss ohne feste Adresse laufen, und der Preis dafür
sind zwei Minuten Verzögerung.
**Unbekannte Chats bekommen keine Antwort — auch keine Fehlermeldung.** Wer
die Kennung des Bots kennt, könnte ihm sonst schreiben und daran erkennen,
dass es diesen Planer gibt. Die einzige Ausnahme ist ein gültiger
Kopplungscode.
**Der Kopplungscode entstand aus einem Widerspruch.** Erst sollte die
Chatkennung von Hand eingetragen werden. Nur: Um sie zu erfahren, müsste der
Bot Unbekannten antworten — genau das, was er nicht darf. Der Code dreht die
Richtung um: Er kommt aus der Anwendung, in die man sich anmelden muss.
**Der Spiegel schickt Belegung ohne Titel.** Daheim soll zu sehen sein,
_dass_ die Zeit weg ist, nicht woran gearbeitet wird. Aufgaben gehen nur
einzeln markiert mit — ein Schalter für einen ganzen Bereich würde zu leicht
Kundennamen nach Hause schicken.
**Was der Sender nicht mehr schickt, verschwindet beim Empfänger.** Sonst
stünde daheim für immer eine Sperre, die es längst nicht mehr gibt. Für
Aufgaben gilt das nicht: Sie sind Planung, keine Sperre.
**Jede Sendung ist fünf Minuten gültig.** Ohne Frist ließe sich ein
abgefangenes Paket beliebig oft erneut einspielen. Der Empfänger antwortet
auf jeden Fehlschlag gleich — falsches Geheimnis, verstellte Uhr und Unsinn
im Inhalt sind von außen nicht zu unterscheiden.
**Die Demo-Instanz redet mit niemandem.** Sie ist mit erfundenen Daten
gefüllt und steht nur für Bilder. Solange `DEMO_MODUS` gilt, starten die
Jobs für Zammad, Telegram und den Spiegel gar nicht erst.
### Probleme und Lösungen
| Problem | Lösung |
| ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Die Kopfleiste verschluckte mit dem elften Eintrag „Einstellungen" und „Protokoll" | Sie ist jetzt breiter als die Inhaltsspalte. Eine Leiste, die Punkte stillschweigend abschneidet, ist schlimmer als eine, die nicht bündig mit dem Text steht. Gefunden im aufgenommenen Bild. |
| Der Kartentext erklärte noch den Weg von vor dem Kopplungscode | Ersetzt. Ebenfalls erst im Bild aufgefallen — im Code war beides für sich stimmig. |
| Ein Kürzungstest schlug an der Grenze fehl | Nicht der Test war falsch, sondern der Vergleich: Eine Worttrennung bei genau der halben Titellänge wurde abgelehnt. Jetzt `>=`. |
| Ein regulärer Ausdruck ging beim Schreiben über die Shell kaputt | Das Muster steht jetzt als benannte Konstante im Code, geschrieben mit dem Edit-Werkzeug statt über ein Skript. |
| Ein unsichtbares Zeichen (U+200B) landete mitten in einem Funktionsnamen | Entfernt und die Datei danach abgesucht. |
| Das Aufräumen im Integrationstest hätte fremde Telegram-Chats treffen können | Es trifft jetzt genau die Kennung, die der Lauf selbst angelegt hat. |
| Docker-Bauten blieben dreimal hängen, einmal 39 Minuten ohne Ausgabe | Kein Codeproblem: Nach Abbruch und Neustart lief derselbe Bau in Minuten durch. Die Bilder mussten deshalb viermal angestoßen werden. |
### Offene Punkte
- **Der Wartungsplaner ist nicht angebunden.** Die Aufnahmestelle steht
bereit und die Seite führt ihn als sichtbare Lücke; es fehlt allein das
Abholen. Dafür muss erst feststehen, was das Produkt an Schnittstelle
hergibt.
- Zammad und Telegram sind gegen die Logik geprüft, nicht gegen die echten
Dienste — dafür fehlen Zugangsdaten. Der Spiegel dagegen ist end-to-end
geprüft, weil beide Enden dieselbe Anwendung sind.
- Ein Telegram-Chat lässt sich nicht umbenennen. Fällt das im Alltag auf,
ist es ein Einzeiler.
### Commits
- 7f8f505 feat: Datenmodell und Umgebung für Integrationen
- e4d3547 feat: Aufnahmeregeln für Integrationen als reine Logik
- 6dad333 feat: Zammad, Telegram und den Spiegel anbinden
- bc60e5f feat: Integrationen takten und Telegram als dritten Meldeweg
- 1281c3e feat: Integrationen sichtbar und bedienbar machen
- 0d0f850 feat: Gespiegelte Belegung in der Wochenansicht zeigen
- 4da2f3d test: Spiegel-Eingang und Integrationstabellen absichern
- 3befcc1 feat: Aufgaben einzeln zum Spiegeln freigeben
- f5eadef feat: Lauf von Hand anstoßen und Spiegelschalter in der Liste
- 3c7cdbb chore: Demo-Daten, Bildaufnahme und Platz in der Kopfleiste
- 475b1d5 docs: Handbuchseite, README und Fahrplan für M6
- 98d170b docs: Handbuchbilder für M6 neu aufnehmen
---
+1
View File
@@ -4,3 +4,4 @@ datum;start;ende;minuten;meilenstein;art;notiz
2026-09-10;17:48;18:29;41;M3;claude;M3 Kalender lesen fertig
2026-09-10;20:42;23:29;167;M4;claude;M4 Wochenplanung fertig
2026-09-11;06:35;13:35;420;M5;claude;M5 Benachrichtigungen fertig
2026-09-11;20:47;09:00;733;M6;claude;M6 Integrationen fertig
1 datum start ende minuten meilenstein art notiz
4 2026-09-10 17:48 18:29 41 M3 claude M3 Kalender lesen fertig
5 2026-09-10 20:42 23:29 167 M4 claude M4 Wochenplanung fertig
6 2026-09-11 06:35 13:35 420 M5 claude M5 Benachrichtigungen fertig
7 2026-09-11 20:47 09:00 733 M6 claude M6 Integrationen fertig
+5
View File
@@ -68,5 +68,10 @@ services:
# moeglich, hinaus geht trotzdem nichts.
SMTP_HOST: smtp.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:
- '${SCREENSHOT_APP_PORT:-3200}:3000'
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 KiB

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 217 KiB

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 KiB

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 289 KiB

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 KiB

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

After

Width:  |  Height:  |  Size: 349 KiB

+5 -4
View File
@@ -20,12 +20,14 @@ Anleitung für den Alltag mit dem Planer. Eine Seite je Thema.
verbinden, Belegung verstehen
7. **[Wochenplanung](woche.md)** – Aufgaben per Ziehen und Ablegen in den
Kalender legen
8. **[Benachrichtigungen](benachrichtigungen.md)** – Push und E-Mail
einrichten, Ruhezeiten setzen
8. **[Benachrichtigungen](benachrichtigungen.md)** – Push, E-Mail und
Telegram einrichten, Ruhezeiten setzen
9. **[Integrationen](integrationen.md)** – Zammad, Telegram und der Spiegel
zwischen Firma und Daheim
## Verwaltung
9. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat
10. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat
## Was noch kommt
@@ -33,7 +35,6 @@ Diese Seiten entstehen mit den nächsten Meilensteinen:
| Thema | Meilenstein |
| ------------------------------- | ----------- |
| Integrationen | M6 |
| Schnellerfassung vom Handy, API | M7 |
| MCP | M8 |
+151
View File
@@ -0,0 +1,151 @@
# Integrationen
Was von außen hereinkommt — und was diese Instanz hinausgibt. Du findest den
Überblick im Menü unter **Integrationen**.
![Die Seite Integrationen](../bilder/integrationen-hell-schreibtisch.png)
Welche Integration es überhaupt gibt, hängt an der Instanz und ist nicht
verhandelbar:
| | Firma | Daheim |
| -------------- | ------ | -------- |
| Zammad | ja | nein |
| Wartungsplaner | ja | nein |
| Telegram | nein | ja |
| Spiegel | sendet | empfängt |
Eine Integration, die deine Instanz nicht vorsieht, lässt sich auch mit
vollständiger Konfiguration nicht anschalten. Das steckt in der Anwendung,
nicht in einer Einstellung, die sich versehentlich verstellen ließe.
## Was jede Integration mit deinen Aufgaben darf
Für alle gilt dieselbe Regel — und die ist wichtiger als jedes Detail
darunter:
| Führt das Ursprungssystem | Führst du |
| --------------------------------- | ------------------------------------------------ |
| Titel, Notiz, Verweis, Fälligkeit | Bereich, Projekt, Priorität, geschätzter Aufwand |
Wer ein Ticket in einen Bereich einsortiert und den Aufwand schätzt, muss
sich darauf verlassen können, dass der nächste Abgleich das nicht wegräumt.
Umgekehrt: Ändert sich drüben der Titel, zieht er hier mit.
Dieselbe Sache ergibt nie eine zweite Aufgabe. Der Planer merkt sich zu jeder
Aufgabe, woher sie kam und unter welcher Kennung.
## Zammad
Tickets, auf die der Suchausdruck passt, werden zu Aufgaben. Voreingestellt
sind die dir zugewiesenen offenen Tickets; der Ausdruck steht in der
Konfiguration (`ZAMMAD_ABFRAGE`) und versteht Zammads Suchsyntax.
- Der Titel wird `#Nummer Betreff`, der Verweis führt direkt ins Ticket.
- Ein Ticket, das Zammad als geschlossen meldet, hakt der Planer ab.
- **Geschrieben wird nach Zammad nichts.** Ein Ticket schließt man im
Ticketsystem — sonst weiß die Kollegin nicht, warum es plötzlich zu ist.
Der Token gehört einem Benutzer und sieht genau das, was dieser Benutzer
sehen darf. Der Planer umgeht keine Rechte.
## Wartungsplaner
Noch nicht angebunden. Die Aufnahmestelle steht bereit — es fehlt allein das
Abholen, und dafür muss die Schnittstelle feststehen. Auf der Seite
**Integrationen** ist der Punkt als offene Lücke sichtbar, damit er nicht in
Vergessenheit gerät.
## Telegram
Drei Dinge in einem Kanal.
### Chat verbinden
1. Unter **Integrationen** auf **Chat verbinden**.
2. Der achtstellige Code erscheint. Er gilt **zehn Minuten** und genau
**einmal**.
3. Schick ihn dem Bot — als Nachricht oder als `/start ABCD2345`.
4. Der Bot bestätigt, und der Chat erscheint in der Liste.
> **Warum der Umweg?** Wer nicht in der Liste steht, bekommt vom Bot keine
> Antwort — auch keine Fehlermeldung. Sonst könnte jeder, der die Kennung des
> Bots kennt, herausfinden, dass es dich gibt. Der Code ist der Beweis, dass
> jemand mit Zugang zur Anwendung diesen Chat wirklich will.
### Erfassen
Jede Nachricht an den Bot wird eine Aufgabe. Lange Texte werden gekürzt; der
Rest landet in der Notiz und geht nicht verloren. Die Aufgabe hat keinen
Bereich — sie liegt also in der **Inbox** und will einsortiert werden.
### Melden und abhaken
Ist Telegram in den [Benachrichtigungen](benachrichtigungen.md) als Weg
eingeschaltet, kommen Meldungen auch hier an. Geht es um genau eine Aufgabe,
hängt darunter ein Knopf **✓ Erledigt** — ein Druck, und sie ist abgehakt,
ohne die Anwendung zu öffnen.
Mehr kann der Knopf nicht. Löschen oder Verschieben aus einem Chat heraus
wäre zu leicht versehentlich getroffen.
Je Chat lässt sich einzeln einstellen, ob er Meldungen bekommt: Das Glöckchen
in der Liste schaltet um.
## Der Spiegel
Die Firma schickt nach Hause, nicht umgekehrt. Zwei Dinge gehen hinüber.
**Belegungen.** Wann dienstlich Zeit verplant ist — ohne Titel, ohne Aufgabe,
ohne Kunde. Daheim erscheinen sie in der Wochenansicht als gestreifte Balken
mit der Aufschrift „Dienstlich belegt" und zählen gegen die Tageskapazität.
So kollidiert die private Planung nicht mit der dienstlichen, und trotzdem
verlässt kein Firmeninhalt die Firma.
**Markierte Aufgaben.** Nur was du an der Aufgabe ausdrücklich zum Spiegeln
freigibst, geht mit Titel hinüber. Das ist eine Einzelentscheidung je
Aufgabe, kein Schalter für alles: Wer versehentlich einen ganzen Bereich
spiegelt, schickt Kundennamen nach Hause.
Was der Sender nicht mehr schickt, verschwindet auch drüben. Wird ein
Zeitblock in der Firma ausgeplant, ist die Sperre daheim beim nächsten Lauf
weg — sonst stünde dort für immer eine Blockade, die es längst nicht mehr
gibt.
## Wann läuft was
| Integration | Takt |
| ----------- | --------------- |
| Telegram | alle 2 Minuten |
| Zammad | alle 15 Minuten |
| Spiegel | alle 15 Minuten |
Wer dem Bot etwas schickt, erwartet die Aufgabe gleich in der Liste. In
Zammad ändert sich dagegen nichts im Minutentakt.
Warten muss man trotzdem nicht: Auf der Seite **Integrationen** stößt
**Jetzt abrufen** einen Lauf von Hand an. Das lohnt vor allem direkt nach dem
Einrichten — sonst wüsstest du erst beim nächsten Takt, ob die Zugangsdaten
stimmen.
## Wenn etwas klemmt
Unter jeder Integration steht, wann sie zuletzt geglückt ist und woran sie
zuletzt gescheitert ist. Nach dem dritten Fehlschlag hintereinander gilt sie
als **gestört** — und dann meldet sich der Planer auch, sofern du den Anlass
„Ein Kalender lässt sich nicht abgleichen" eingeschaltet hast.
Erst nach dem dritten Mal: Ein Netz hustet, ein Anbieter hat Wartung. Beim
ersten Fehlschlag zu melden hieße, dich für Dinge zu wecken, die sich von
selbst erledigen.
| Symptom | Ursache |
| ---------------------------------------- | --------------------------------------------------------------------------------- |
| „Nicht eingerichtet" trotz Konfiguration | Die Instanz sieht diese Integration nicht vor |
| Zammad findet nichts | Der Suchausdruck passt auf kein Ticket — in Zammad selbst probieren |
| Der Bot antwortet nicht | Der Chat ist nicht verbunden, oder der Code war abgelaufen |
| Der Spiegel wird abgelehnt | Verschiedene Geheimnisse, oder die Uhren laufen mehr als fünf Minuten auseinander |
---
Zurück zum [Handbuch](README.md).
+10
View File
@@ -6,6 +6,16 @@ export const EMAIL = process.env.ADMIN_EMAIL ?? ''
export const PASSWORT = process.env.ADMIN_PASSWORT ?? ''
export const VERBINDUNG = process.env.DATABASE_URL ?? ''
/**
* Geheimnis fuer den Spiegel im Test.
*
* Der Testserver bekommt denselben Wert ueber playwright.config.ts, sonst
* liesse sich die Tuer gar nicht pruefen — und eine Pruefung, die immer
* uebersprungen wird, prueft nichts.
*/
export const SPIEGEL_GEHEIMNIS =
process.env.SPIEGEL_GEHEIMNIS ?? 'e2e-spiegel-geheimnis-nur-lokal'
/**
* Meldet das Testkonto mit Passwort an.
*
+138
View File
@@ -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)
})
})
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "planer",
"version": "0.5.0",
"version": "0.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "planer",
"version": "0.5.0",
"version": "0.6.0",
"dependencies": {
"@auth/prisma-adapter": "2.11.3",
"@date-fns/tz": "1.5.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "planer",
"version": "0.5.0",
"version": "0.6.0",
"private": true,
"description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender",
"type": "module",
+3
View File
@@ -1,5 +1,6 @@
import { defineConfig, devices } from '@playwright/test'
import 'dotenv/config'
import { SPIEGEL_GEHEIMNIS } from './e2e/hilfe'
const PORT = Number(process.env.E2E_PORT ?? 3100)
const BASIS = `http://127.0.0.1:${PORT}`
@@ -41,6 +42,8 @@ export default defineConfig({
APP_URL: BASIS,
WEBAUTHN_ORIGIN: BASIS,
WEBAUTHN_RP_ID: '127.0.0.1',
// Ohne Geheimnis waere der Spiegel-Eingang zu und sein Test sinnlos.
SPIEGEL_GEHEIMNIS,
},
},
})
+47
View File
@@ -429,6 +429,7 @@ async function main(): Promise<void> {
anlaesse: 'tagesueberblick,faelligkeit,zeitblock',
perPush: true,
perMail: true,
perTelegram: true,
ueberblickUm: 7 * 60,
ruheVon: 20 * 60,
ruheBis: 7 * 60,
@@ -442,6 +443,52 @@ async function main(): Promise<void> {
// nicht lesen kann. Ein erfundener Eintrag verschwände beim ersten Lauf
// wieder — die Bilder zeigen deshalb den Zustand vor der ersten Anmeldung.
// --- Integrationen --------------------------------------------------------
await db.integrationszustand.deleteMany({})
await db.integrationszustand.create({
data: {
quelle: 'TELEGRAM',
merker: '4711',
letzterLauf: new Date(Date.UTC(2026, 2, 17, 8, 18)),
letzterErfolg: new Date(Date.UTC(2026, 2, 17, 8, 18)),
},
})
await db.integrationszustand.create({
data: {
quelle: 'SPIEGEL',
letzterLauf: new Date(Date.UTC(2026, 2, 17, 8, 15)),
letzterErfolg: new Date(Date.UTC(2026, 2, 17, 8, 15)),
},
})
await db.telegramChat.deleteMany({})
await db.telegramChat.create({
data: {
// Erfunden. Der Bot ist ohnehin abgeschaltet, solange DEMO_MODUS gilt.
chatId: BigInt(147258369),
benutzerId: benutzer.id,
bezeichnung: 'Martina',
erstelltAm: new Date(Date.UTC(2026, 1, 3, 8, 25)),
zuletztBenutzt: new Date(Date.UTC(2026, 2, 17, 6, 40)),
},
})
// Dienstliche Belegung, wie sie aus der Firma hereinkaeme: ohne Titel.
await db.spiegelbelegung.deleteMany({})
for (const [tag, stunde, dauer] of [
[17, 9, 2],
[18, 13, 3],
[19, 8, 1],
] as const) {
await db.spiegelbelegung.create({
data: {
quelleRef: `demo-spiegel-${tag}-${stunde}`,
beginn: new Date(Date.UTC(2026, 2, tag, stunde - 1, 0)),
ende: new Date(Date.UTC(2026, 2, tag, stunde - 1 + dauer, 0)),
},
})
}
console.log(`Demo-Daten stehen bereit. Anmeldung: ${DEMO_EMAIL}`)
}
+1
View File
@@ -43,6 +43,7 @@ const AUFNAHMEN: Aufnahme[] = [
{ name: 'ideen', pfad: '/ideen' },
{ name: 'bereiche', pfad: '/bereiche' },
{ name: 'kalenderkonten', pfad: '/kalenderkonten' },
{ name: 'integrationen', pfad: '/integrationen' },
{ name: 'einstellungen', pfad: '/einstellungen' },
{
name: 'benachrichtigungen',
+83
View File
@@ -41,6 +41,9 @@ async function scheitert(name: string, tun: () => Promise<unknown>): Promise<voi
}
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`
async function aufraeumen(): Promise<void> {
@@ -58,6 +61,12 @@ async function aufraeumen(): Promise<void> {
await db.webauthnChallenge.deleteMany({ where: { kennung } })
await db.pushGeraet.deleteMany({ where: { endpunkt: { contains: kennung } } })
await db.meldungsnachweis.deleteMany({ where: { schluessel: { contains: kennung } } })
await db.spiegelbelegung.deleteMany({ where: { quelleRef: { contains: kennung } } })
await db.telegramKopplung.deleteMany({
where: { code: { contains: kennung.slice(-8) } },
})
await db.telegramChat.deleteMany({ where: { chatId: testChatId } })
await db.integrationszustand.deleteMany({ where: { quelle: 'TELEGRAM' } })
}
async function main(): Promise<void> {
@@ -316,6 +325,76 @@ async function main(): Promise<void> {
).id.length > 0,
)
// --- Integrationen -------------------------------------------------------
// Der Zustand haengt an der Quelle: eine Zeile je Integration, nicht mehr.
await db.integrationszustand.create({
data: { quelle: 'TELEGRAM', merker: '42' },
})
await scheitert('Je Quelle nur ein Integrationszustand', () =>
db.integrationszustand.create({ data: { quelle: 'TELEGRAM' } }),
)
await db.integrationszustand.deleteMany({ where: { quelle: 'TELEGRAM' } })
// Zwei Aufgaben aus derselben Quelle mit derselben Kennung darf es nicht
// geben – sonst legte jeder Lauf dieselbe Sache erneut an.
const ausZammad = await db.aufgabe.create({
data: { titel: `${kennung}-ticket`, quelle: 'ZAMMAD', quelleRef: `${kennung}-1` },
})
pruefe('Aufgabe aus einer Integration anlegen', ausZammad.id.length > 0)
pruefe('Ohne Freigabe wird nicht gespiegelt', ausZammad.spiegeln === false)
await scheitert('Dieselbe Quelle mit derselben Kennung nur einmal (Integration)', () =>
db.aufgabe.create({
data: {
titel: `${kennung}-ticket-doppelt`,
quelle: 'ZAMMAD',
quelleRef: `${kennung}-1`,
},
}),
)
await db.spiegelbelegung.create({
data: {
quelleRef: `${kennung}-zb`,
beginn: new Date(Date.UTC(2026, 2, 17, 8, 0)),
ende: new Date(Date.UTC(2026, 2, 17, 9, 0)),
},
})
await scheitert('Dieselbe gespiegelte Belegung nur einmal', () =>
db.spiegelbelegung.create({
data: {
quelleRef: `${kennung}-zb`,
beginn: new Date(Date.UTC(2026, 2, 18, 8, 0)),
ende: new Date(Date.UTC(2026, 2, 18, 9, 0)),
},
}),
)
const chat = await db.telegramChat.create({
data: { chatId: testChatId, benutzerId: benutzer.id },
})
pruefe('Telegram-Chat empfaengt im Zweifel Meldungen', chat.meldungen === true)
await scheitert('Dieselbe Chatkennung nur einmal', () =>
db.telegramChat.create({ data: { chatId: chat.chatId } }),
)
await db.telegramKopplung.create({
data: {
code: `${kennung.slice(-8)}`,
benutzerId: benutzer.id,
gueltigBis: new Date(Date.now() + 600_000),
},
})
await scheitert('Denselben Kopplungscode nur einmal', () =>
db.telegramKopplung.create({
data: {
code: `${kennung.slice(-8)}`,
benutzerId: benutzer.id,
gueltigBis: new Date(Date.now() + 600_000),
},
}),
)
await db.benutzer.delete({ where: { id: benutzer.id } })
const nachher = await db.auditEintrag.findUnique({ where: { id: audit.id } })
@@ -341,6 +420,10 @@ async function main(): Promise<void> {
'Push-Geräte verschwinden mit dem Benutzer',
(await db.pushGeraet.count({ where: { endpunkt: { contains: kennung } } })) === 0,
)
pruefe(
'Telegram-Chats verschwinden nicht, verlieren aber den Benutzer',
(await db.telegramChat.count({ where: { benutzerId: benutzer.id } })) === 0,
)
pruefe(
'Meldungsnachweise verschwinden mit dem Benutzer',
(await db.meldungsnachweis.count({
+4
View File
@@ -14,6 +14,7 @@ import {
type AufgabenFilter,
} from '@/server/abfragen/aufgaben'
import { verlangeAnmeldung } from '@/server/berechtigung'
import { umgebung } from '@/server/umgebung'
export const metadata: Metadata = { title: 'Aufgaben' }
@@ -67,6 +68,7 @@ export default async function AufgabenSeite({
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
await verlangeAnmeldung('/aufgaben')
const spiegelt = umgebung().spiegelSendenAktiv
const parameter = await searchParams
const [aufgaben, bereiche, projekte] = await Promise.all([
@@ -101,11 +103,13 @@ export default async function AufgabenSeite({
</CardInhalt>
</Card>
{/* Nur wo auch gespiegelt wird, hat der Schalter einen Sinn. */}
<AufgabenListe
aufgaben={aufgaben}
bereiche={bereiche}
projekte={projekte}
leerText="Keine Aufgabe passt zu diesen Filtern."
mitSpiegeln={spiegelt}
/>
</div>
)
@@ -34,6 +34,7 @@ export type EinstellungAnzeige = {
anlaesse: Anlass[]
perPush: boolean
perMail: boolean
perTelegram: boolean
ueberblickUm: string
ruheVon: string
ruheBis: string
@@ -46,6 +47,9 @@ type Eigenschaften = {
geraete: GeraetAnzeige[]
pushMoeglich: boolean
mailMoeglich: boolean
telegramMoeglich: boolean
/** Wie viele Chats Meldungen empfangen. Null heisst: noch keiner. */
telegramChats: number
}
/**
@@ -68,6 +72,8 @@ export function Benachrichtigungen({
geraete,
pushMoeglich,
mailMoeglich,
telegramMoeglich,
telegramChats,
}: Eigenschaften) {
const router = useRouter()
const [laeuft, starte] = useTransition()
@@ -311,6 +317,35 @@ export function Benachrichtigungen({
)}
</span>
</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>
<div className="grid gap-4 sm:grid-cols-2">
+6 -1
View File
@@ -23,13 +23,14 @@ export default async function EinstellungenSeite() {
const einst = umgebung()
const [benachrichtigung, pushGeraete] = await Promise.all([
const [benachrichtigung, telegramChats, pushGeraete] = await Promise.all([
db.benachrichtigungseinstellung.findUnique({
where: { benutzerId: benutzer.id },
select: {
anlaesse: true,
perPush: true,
perMail: true,
perTelegram: true,
ueberblickUm: true,
ruheVon: true,
ruheBis: true,
@@ -37,6 +38,7 @@ export default async function EinstellungenSeite() {
zeitblockVorlaufMinuten: true,
},
}),
db.telegramChat.count({ where: { benutzerId: benutzer.id, meldungen: true } }),
db.pushGeraet.findMany({
where: { benutzerId: benutzer.id },
orderBy: { erstelltAm: 'asc' },
@@ -119,10 +121,13 @@ export default async function EinstellungenSeite() {
<Benachrichtigungen
pushMoeglich={einst.pushAktiv}
mailMoeglich={einst.mailAktiv}
telegramMoeglich={einst.telegramAktiv}
telegramChats={telegramChats}
einstellung={{
anlaesse: [...einstellung.anlaesse],
perPush: einstellung.perPush,
perMail: einstellung.perMail,
perTelegram: einstellung.perTelegram,
ueberblickUm: alsUhrzeit(einstellung.ueberblickUm),
ruheVon: alsUhrzeit(einstellung.ruheVon),
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>
)
}
+192
View File
@@ -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>
)
}
+3 -1
View File
@@ -187,6 +187,7 @@ export default async function WocheSeite({
ganztaegig: t.ganztaegig,
frei: t.zeigtAls === 'FREI',
kalenderName: t.kalenderName,
gespiegelt: t.gespiegelt,
}))}
aufgaben={aufgaben.map((a) => ({
id: a.id,
@@ -205,7 +206,8 @@ export default async function WocheSeite({
<p className="text-gedaempft-vordergrund text-xs">
Aufgabe aus der linken Spalte in ein Zeitfenster ziehen, um sie einzuplanen. Ein
Zeitblock lässt sich am Griff verschieben; das Kreuz plant ihn wieder aus. Graue
Balken sind Termine aus dem Kalender — sie lassen sich hier nicht verschieben.
Balken sind Termine aus dem Kalender, gestreifte kommen aus der anderen Instanz
— beide lassen sich hier nicht verschieben.
</p>
</div>
</div>
+15 -2
View File
@@ -64,6 +64,8 @@ export type RasterTermin = {
ganztaegig: boolean
frei: boolean
kalenderName: string
/** Aus der anderen Instanz gespiegelt: belegt, aber ohne Betreff. */
gespiegelt?: boolean
}
export type RasterSpalte = {
@@ -321,6 +323,11 @@ export function Wochenraster({
eintrag.frei
? 'bg-gedaempft/60 text-gedaempft-vordergrund'
: 'bg-gedaempft text-gedaempft-vordergrund',
// Gespiegeltes gehört sichtbar in eine eigene
// Klasse: gestreift, damit niemand darin einen
// Termin mit fehlendem Betreff vermutet.
eintrag.gespiegelt &&
'border-rand border border-dashed bg-[repeating-linear-gradient(45deg,transparent,transparent_4px,color-mix(in_oklch,currentColor_12%,transparent)_4px,color-mix(in_oklch,currentColor_12%,transparent)_8px)]',
)}
style={{
top: lage.oben,
@@ -328,10 +335,16 @@ export function Wochenraster({
left: `${(spur / spuren) * 100}%`,
width: `${100 / spuren}%`,
}}
title={`${eintrag.betreff ?? 'Termin'} · ${eintrag.kalenderName}`}
title={
eintrag.gespiegelt
? 'Dienstlich belegt – gespiegelt aus der anderen Instanz'
: `${eintrag.betreff ?? 'Termin'} · ${eintrag.kalenderName}`
}
>
<span className="line-clamp-2">
{eintrag.betreff ?? 'Termin'}
{eintrag.gespiegelt
? 'Dienstlich belegt'
: (eintrag.betreff ?? 'Termin')}
</span>
</div>
)
@@ -14,6 +14,8 @@ type Eigenschaften = {
leerText: string
mitEinsortieren?: boolean
mitUebernehmen?: boolean
/** Schalter zum Spiegeln zeigen; nur auf der sendenden Instanz sinnvoll. */
mitSpiegeln?: boolean
/** Bearbeiten direkt in der Liste erlauben. */
mitBearbeiten?: boolean
}
@@ -25,6 +27,7 @@ export function AufgabenListe({
leerText,
mitEinsortieren = false,
mitUebernehmen = false,
mitSpiegeln = false,
mitBearbeiten = true,
}: Eigenschaften) {
const [bearbeitet, setBearbeitet] = useState<string | null>(null)
@@ -60,6 +63,7 @@ export function AufgabenListe({
bereiche={bereiche}
mitEinsortieren={mitEinsortieren}
mitUebernehmen={mitUebernehmen}
mitSpiegeln={mitSpiegeln}
{...(mitBearbeiten
? { onBearbeiten: () => setBearbeitet(aufgabe.id) }
: {})}
+36 -1
View File
@@ -3,7 +3,15 @@
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { Check, ExternalLink, Loader2, Pencil, RotateCcw, Trash2 } from 'lucide-react'
import {
Check,
ExternalLink,
Loader2,
Pencil,
RotateCcw,
Share2,
Trash2,
} from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Kennzeichen } from '@/components/ui/kennzeichen'
@@ -22,6 +30,7 @@ import {
aufgabeLoeschen,
einsortieren,
ideeUebernehmen,
spiegelnUmschalten,
statusSetzen,
type Rueckmeldung,
} from '@/server/aktionen/aufgaben'
@@ -36,6 +45,8 @@ type Eigenschaften = {
mitEinsortieren?: boolean
/** Zeigt den Knopf "Zur Aufgabe machen" (Ideen). */
mitUebernehmen?: boolean
/** Zeigt den Schalter zum Spiegeln; nur auf der sendenden Instanz. */
mitSpiegeln?: boolean
onBearbeiten?: (aufgabe: AufgabeAnzeige) => void
}
@@ -44,6 +55,7 @@ export function AufgabenZeile({
bereiche = [],
mitEinsortieren = false,
mitUebernehmen = false,
mitSpiegeln = false,
onBearbeiten,
}: Eigenschaften) {
const router = useRouter()
@@ -198,6 +210,29 @@ export function AufgabenZeile({
</div>
<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 && (
<Button
art="leise"
+8 -1
View File
@@ -35,6 +35,7 @@ export function Kopfleiste({
{ pfad: '/ideen', name: 'Ideen' },
{ pfad: '/bereiche', name: 'Bereiche' },
{ pfad: '/kalenderkonten', name: 'Kalender' },
{ pfad: '/integrationen', name: 'Integrationen' },
{ pfad: '/einstellungen', name: 'Einstellungen' },
{ pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true },
]
@@ -49,7 +50,13 @@ export function Kopfleiste({
Navigation waagerecht, statt umzubrechen und den Namen der Anwendung in
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">
Planer
</Link>
+1
View File
@@ -16,6 +16,7 @@ function roh(teil: Partial<AufgabeRoh> = {}): AufgabeRoh {
faelligAm: null,
quelle: 'MANUELL',
quelleUrl: null,
spiegeln: false,
bereich: null,
projekt: null,
...teil,
+4
View File
@@ -26,6 +26,7 @@ export type AufgabeRoh = {
faelligAm: Date | null
quelle: Quelle
quelleUrl: string | null
spiegeln: boolean
bereich: { id: string; name: string; farbe: string } | null
projekt: { id: string; name: string } | null
}
@@ -48,6 +49,8 @@ export type AufgabeAnzeige = {
ueberfaellig: boolean
quelle: Quelle
quelleUrl: string | null
/** Ausdruecklich zum Spiegeln freigegeben. */
spiegeln: boolean
bereich: { id: string; name: string; farbe: 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),
quelle: aufgabe.quelle,
quelleUrl: aufgabe.quelleUrl,
spiegeln: aufgabe.spiegeln,
bereich: aufgabe.bereich,
projekt: aufgabe.projekt,
}
+1
View File
@@ -25,6 +25,7 @@ const AUSWAHL = {
faelligAm: true,
quelle: true,
quelleUrl: true,
spiegeln: true,
bereich: { select: { id: true, name: true, farbe: true } },
projekt: { select: { id: true, name: true } },
} satisfies Prisma.AufgabeSelect
+37 -11
View File
@@ -33,6 +33,13 @@ export type WochenTermin = {
ganztaegig: boolean
zeigtAls: ZeigtAls
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 = {
@@ -55,7 +62,7 @@ export async function wocheLaden(
): Promise<WocheDaten> {
const fenster = wochenFenster(zeitpunkt)
const [zeitbloecke, termine] = await Promise.all([
const [zeitbloecke, termine, belegungen] = await Promise.all([
db.zeitblock.findMany({
where: {
kalenderkonto: { benutzerId },
@@ -97,6 +104,11 @@ export async function wocheLaden(
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
@@ -118,17 +130,31 @@ export async function wocheLaden(
letzterFehler: z.letzterFehler,
offen: z.extId === null,
})),
termine: termine
.filter((t) => !eigeneExtIds.has(t.extId))
.map((t) => ({
id: t.id,
beginn: t.beginn,
ende: t.ende,
betreff: t.betreff,
ganztaegig: t.ganztaegig,
zeigtAls: t.zeigtAls,
kalenderName: t.kalenderkonto.anzeigename,
termine: [
...termine
.filter((t) => !eigeneExtIds.has(t.extId))
.map((t) => ({
id: t.id,
beginn: t.beginn,
ende: t.ende,
betreff: t.betreff,
ganztaegig: t.ganztaegig,
zeigtAls: t.zeigtAls,
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()),
}
}
+49
View File
@@ -12,6 +12,7 @@ import {
import { auditSchreiben } from '@/server/audit'
import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
import { db } from '@/server/db'
import { umgebung } from '@/server/umgebung'
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}.` }
}
/**
* 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. */
export async function ideeUebernehmen(daten: FormData): Promise<Rueckmeldung> {
const geprueft = await akteurOder((fehler) => ({ ok: false, fehler }))
+23 -2
View File
@@ -8,6 +8,7 @@ import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
import { mailSenden } from '@/server/benachrichtigung/mail'
import { pushSenden } from '@/server/benachrichtigung/push'
import { alsEinstellung } from '@/server/benachrichtigung/versand'
import { telegramMeldung } from '@/server/integration/telegram'
import { db } from '@/server/db'
import { umgebung } from '@/server/umgebung'
import { jetzt } from '@/server/zeitgeber'
@@ -37,6 +38,7 @@ export async function einstellungSpeichern(daten: FormData): Promise<Rueckmeldun
anlaesse: daten.getAll('anlaesse'),
perPush: daten.get('perPush') !== null,
perMail: daten.get('perMail') !== null,
perTelegram: daten.get('perTelegram') !== null,
ueberblickUm: daten.get('ueberblickUm'),
ruheVon: daten.get('ruheVon'),
ruheBis: daten.get('ruheBis'),
@@ -57,6 +59,7 @@ export async function einstellungSpeichern(daten: FormData): Promise<Rueckmeldun
anlaesse: gelesen.data.anlaesse.join(','),
perPush: gelesen.data.perPush,
perMail: gelesen.data.perMail,
perTelegram: gelesen.data.perTelegram,
ueberblickUm: gelesen.data.ueberblickUm,
ruheVon: gelesen.data.ruheVon,
ruheBis: gelesen.data.ruheBis,
@@ -79,6 +82,7 @@ export async function einstellungSpeichern(daten: FormData): Promise<Rueckmeldun
anlaesse: werte.anlaesse,
perPush: werte.perPush,
perMail: werte.perMail,
perTelegram: werte.perTelegram,
},
})
@@ -103,6 +107,7 @@ export async function probeSenden(): Promise<Rueckmeldung> {
anlaesse: true,
perPush: true,
perMail: true,
perTelegram: true,
ueberblickUm: true,
ruheVon: true,
ruheBis: true,
@@ -112,8 +117,11 @@ export async function probeSenden(): Promise<Rueckmeldung> {
})
const einstellung = alsEinstellung(zeile)
if (!einstellung.perPush && !einstellung.perMail) {
return { ok: false, fehler: 'Erst einen Weg einschalten — Push oder E-Mail.' }
if (!einstellung.perPush && !einstellung.perMail && !einstellung.perTelegram) {
return {
ok: false,
fehler: 'Erst einen Weg einschalten — Push, E-Mail oder Telegram.',
}
}
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({
akteur: { id: akteur.id, email: akteur.email },
aktion: 'benachrichtigung.probe',
+191
View File
@@ -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.',
}
}
}
+8 -3
View File
@@ -111,7 +111,12 @@ export function jobsStarten(): void {
const einst = umgebung()
if (einst.zammadAktiv) {
// 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) => {
@@ -127,7 +132,7 @@ export function jobsStarten(): void {
})
}
if (einst.telegramAktiv) {
if (einst.telegramAktiv && nachDraussen) {
cron.schedule(TELEGRAM_PLAN, () => {
void telegramAbholen(DIENST)
.then((bericht) => {
@@ -142,7 +147,7 @@ export function jobsStarten(): void {
})
}
if (einst.spiegelSendenAktiv) {
if (einst.spiegelSendenAktiv && nachDraussen) {
cron.schedule(SPIEGEL_PLAN, () => {
void spiegelSenden().catch((fehler: unknown) => {
console.error('Spiegel-Versand abgebrochen:', fehler)