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,78 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
## [Unveröffentlicht]
|
## [Unveröffentlicht]
|
||||||
|
|
||||||
|
## [0.7.0] – 2026-09-13
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- **Schnellerfassung vom Handy** unter _Erfassen_: ein Feld, ein Knopf. Was
|
||||||
|
ohne Netz erfasst wird, bleibt im Gerät und geht hinaus, sobald wieder eine
|
||||||
|
Verbindung da ist — mit einer Kennung je Erfassung, damit eine doppelt
|
||||||
|
zugestellte Nachricht keine zweite Aufgabe ergibt.
|
||||||
|
- Der Planer lässt sich als **App auf dem Startbildschirm ablegen**
|
||||||
|
(Manifest, eigenes Symbol, eigener Startpunkt).
|
||||||
|
- **Öffentliche Schnittstelle** unter `/api/v1`: Aufgaben lesen, anlegen,
|
||||||
|
ändern und abhaken. Beschreibung in [docs/API.md](docs/API.md).
|
||||||
|
- **Tokens je Anwendung** mit Rechten je Token, wahlweise mit Ablauf,
|
||||||
|
verwaltbar unter _API_.
|
||||||
|
- Handbuchseite zur Schnellerfassung.
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
|
||||||
|
- Von einem Token liegt nur der Fingerabdruck in der Datenbank. Der Wert
|
||||||
|
erscheint genau einmal — beim Anlegen.
|
||||||
|
- Die drei Rechte schließen einander nicht ein: Ein Dienst, der nur erfasst,
|
||||||
|
kann den Bestand nicht lesen.
|
||||||
|
- Jeder Fehlschlag beim Ausweisen sieht gleich aus. Ob ein Token unbekannt,
|
||||||
|
abgelaufen oder zurückgezogen ist, verrät die Schnittstelle nicht.
|
||||||
|
- Jede schreibende Nutzung steht im Protokoll, mitsamt dem Token — auch jeder
|
||||||
|
abgewiesene Zugriff.
|
||||||
|
- Die Schnittstelle kann weder löschen noch Bereiche, Projekte, Zeitblöcke
|
||||||
|
oder Kalender anfassen.
|
||||||
|
|
||||||
|
## [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,16 @@ 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
|
||||||
|
- **Schnellerfassung vom Handy**, die auch im Funkloch nichts verliert; der
|
||||||
|
Planer lässt sich als App auf dem Startbildschirm ablegen
|
||||||
|
- **Öffentliche Schnittstelle** mit Tokens je Anwendung, Rechten je Token und
|
||||||
|
jeder schreibenden Nutzung im Protokoll ([Beschreibung](docs/API.md))
|
||||||
- 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,8 +53,7 @@ wird.
|
|||||||
|
|
||||||
### Geplant
|
### Geplant
|
||||||
|
|
||||||
Integrationen, Schnellerfassung vom Handy und ein MCP-Server.
|
Ein MCP-Server. Siehe [ROADMAP.md](ROADMAP.md).
|
||||||
Siehe [ROADMAP.md](ROADMAP.md).
|
|
||||||
|
|
||||||
## Bildergalerie
|
## Bildergalerie
|
||||||
|
|
||||||
@@ -63,6 +70,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 +179,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,8 +10,8 @@ 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 | erledigt |
|
||||||
| [M8](#m8--mcp) | MCP | geplant |
|
| [M8](#m8--mcp) | MCP | geplant |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -112,16 +112,47 @@ 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
|
||||||
|
|
||||||
- [ ] Erfassung vom Handy, auch offline
|
**Erledigt am 12.09.2026 mit Version 0.7.0.**
|
||||||
- [ ] Öffentliche API mit Token je Anwendung
|
|
||||||
- [ ] Rechte je Token, jede Nutzung im Protokoll
|
**Ziel:** Etwas festhalten können, wo man gerade steht — und andere
|
||||||
|
Anwendungen hereinlassen, ohne ihnen die Schlüssel zum ganzen Haus zu geben.
|
||||||
|
|
||||||
|
- [x] Erfassung vom Handy, auch offline
|
||||||
|
- [x] Öffentliche API mit Token je Anwendung
|
||||||
|
- [x] Rechte je Token, jede schreibende Nutzung im Protokoll
|
||||||
|
- [x] Als App ablegbar (Manifest, eigenes Symbol)
|
||||||
|
|
||||||
|
**Offen geblieben:** Die Bilder der Erfassungsseite im Handbuch zeigen noch
|
||||||
|
eine falsche Inbox-Zahl; der Fehler selbst ist behoben, nur der Bildlauf
|
||||||
|
kam danach nicht mehr durch.
|
||||||
|
|
||||||
|
**Bewusst offen gelassen:** Die Schnittstelle kann keine Bereiche, Projekte
|
||||||
|
oder Zeitblöcke anfassen und nichts löschen. Was von außen kommt, landet in
|
||||||
|
der Inbox; die Einordnung bleibt bei dem, der plant. Auch eine Begrenzung der
|
||||||
|
Aufrufzahl gibt es nicht — bei einer Handvoll eigener Tokens wäre sie mehr
|
||||||
|
Zeremonie als Schutz.
|
||||||
|
|
||||||
## M8 – MCP
|
## M8 – MCP
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,222 @@ 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 |
|
||||||
|
| M7 | claude | 886 | 14,77 |
|
||||||
|
| **Gesamt** | | **2452** | **40,87** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-13 – M7: Schnellerfassung und API
|
||||||
|
|
||||||
|
**Dauer:** 886 Minuten (10:15–01:01), Art `claude`.
|
||||||
|
|
||||||
|
> Wie schon bei M6 sagt die Zahl mehr über den Rechner als über die Arbeit:
|
||||||
|
> Fünf hängende Docker-Bauten, einer davon mit einem einzelnen Schritt über
|
||||||
|
> drei Stunden. Die Bildaufnahme musste achtmal angestoßen werden.
|
||||||
|
|
||||||
|
**Ziel der Sitzung:** Etwas festhalten können, wo man gerade steht — und
|
||||||
|
andere Anwendungen hereinlassen, ohne ihnen die Schlüssel zum ganzen Haus zu
|
||||||
|
geben.
|
||||||
|
|
||||||
|
### Erledigt
|
||||||
|
|
||||||
|
- Regeln der Schnittstelle als reine Logik: Rechte, Tokenaufbau, Eingaben
|
||||||
|
(30 Tests)
|
||||||
|
- `/api/v1`: Aufgaben lesen, anlegen, ändern, abhaken
|
||||||
|
- Tokens mit Rechten und wahlweise Ablauf, verwaltbar in der Anwendung
|
||||||
|
- Schnellerfassung fürs Handy mit Warteschlange in IndexedDB
|
||||||
|
- Manifest und Symbol: der Planer lässt sich als App ablegen
|
||||||
|
- 10 neue E2E-Tests, 6 neue Integrationsprüfungen, Handbuchseite, API-Doku
|
||||||
|
|
||||||
|
### Entscheidungen mit Begründung
|
||||||
|
|
||||||
|
**Von einem Token liegt nur der Hash in der Datenbank.** Ein Token ist ein
|
||||||
|
Passwort; es gibt keinen Grund, es zurücklesen zu können, und jeden Grund,
|
||||||
|
es nicht zu können. SHA-256 ohne Salz ist hier richtig — der Wert hat 32
|
||||||
|
Byte Zufall, gegen so etwas hilft kein Wörterbuch, und die Prüfung muss bei
|
||||||
|
jeder Anfrage schnell sein.
|
||||||
|
|
||||||
|
**Kein Recht schließt ein anderes ein.** „Erfassen" darf nicht lesen. Ein
|
||||||
|
Dienst, der nur meldet „hier ist etwas Neues", braucht den Bestand nicht zu
|
||||||
|
sehen — und das ist der häufigste Fall.
|
||||||
|
|
||||||
|
**Der sichtbare Anfang heißt `planer_`.** Wer so einen Wert versehentlich in
|
||||||
|
ein öffentliches Verzeichnis schiebt, soll ihn beim Durchsehen erkennen.
|
||||||
|
|
||||||
|
**`/selbst` braucht kein Recht.** Sonst müsste man beim Einrichten raten, ob
|
||||||
|
der Token falsch ist oder nur zu wenig darf.
|
||||||
|
|
||||||
|
**Die Schnittstelle kann Bereich und Projekt nicht setzen.** Was von außen
|
||||||
|
kommt, landet in der Inbox. Die Einordnung ist Sache dessen, der plant,
|
||||||
|
nicht dessen, der meldet. Löschen kann sie auch nicht: Ein Fehler im fremden
|
||||||
|
Skript soll keine Planung vernichten.
|
||||||
|
|
||||||
|
**Erst anstellen, dann senden.** Die Schnellerfassung schreibt zuerst in die
|
||||||
|
Warteschlange des Geräts und versucht dann zu senden. Andersherum wäre der
|
||||||
|
Eintrag weg, wenn der Browser mitten im Senden geschlossen wird.
|
||||||
|
|
||||||
|
**IndexedDB statt localStorage.** Das sind echte Daten des Benutzers. Ein
|
||||||
|
Eintrag, der beim Aufräumen des Speichers verschwindet, ist eine verlorene
|
||||||
|
Aufgabe — genau das, was der Planer verhindern soll.
|
||||||
|
|
||||||
|
**Der Dienstarbeiter hält genau eine Seite vor.** Ein halb veralteter Planer
|
||||||
|
wäre schlimmer als gar keiner; ein leeres Eingabefeld dagegen veraltet
|
||||||
|
nicht. Und ohne die Seite nützte die Warteschlange niemandem: Man käme gar
|
||||||
|
nicht erst hin.
|
||||||
|
|
||||||
|
**Die Navigation wurde geteilt statt weiter gedehnt.** Nach M6 hatte ich die
|
||||||
|
Leiste verbreitert; mit dem dreizehnten Eintrag verschluckte sie erneut
|
||||||
|
Punkte. Das war die falsche Antwort: Das Problem ist nicht die Breite,
|
||||||
|
sondern die Vermischung. Alltag steht in der Leiste, Einrichtung und
|
||||||
|
Verwaltung liegen hinter „Mehr".
|
||||||
|
|
||||||
|
### Probleme und Lösungen
|
||||||
|
|
||||||
|
| Problem | Lösung |
|
||||||
|
| -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Die Erfassungsseite zählte die Inbox anders als die Kopfleiste | Sie zählte Ideen mit. Jetzt benutzt sie dieselbe Abfrage. Derselbe Fehlertyp wie in M2 — und wieder nur im Bild aufgefallen, im Code sah jede Seite für sich richtig aus. |
|
||||||
|
| Die Leiste verschluckte mit dem dreizehnten Eintrag wieder Punkte | Aufgeteilt statt verbreitert, siehe oben. Zwei E2E-Tests greifen den neuen Weg über „Mehr" auf. |
|
||||||
|
| Ein E2E-Test brach mit `ERR_ABORTED` ab | Die Erfolgsmeldung kommt, bevor das Neuladen der Liste fertig ist; wer dann navigiert, bricht es ab. Der Test wartet jetzt darauf. |
|
||||||
|
| `exactOptionalPropertyTypes` und Prismas Update-Typen vertragen kein `undefined` | Das Datenobjekt wird Feld für Feld gebaut statt per Spread. Ein gesetztes `undefined` ist etwas anderes als ein fehlendes Feld. |
|
||||||
|
| Ein Devanagari-Zeichen war in ein Token-Präfix der Demo-Daten geraten | Entfernt. Beim Schreiben über die Shell passiert so etwas; die Datei danach durchsucht. |
|
||||||
|
| Fünf hängende Docker-Bauten, einer über drei Stunden | Kein Codeproblem: Nach Abbruch und Neustart lief derselbe Bau in Minuten durch. Zweimal scheiterte zusätzlich das Anlegen des Compose-Netzes. |
|
||||||
|
|
||||||
|
### Offene Punkte
|
||||||
|
|
||||||
|
- **Die Bilder der Erfassungsseite zeigen die alte Inbox-Zahl.** Der Bildlauf
|
||||||
|
ließ sich nach der Korrektur nicht mehr durchbringen. Ein Lauf, wenn Docker
|
||||||
|
wieder mitspielt, räumt das auf.
|
||||||
|
- Keine Begrenzung der Aufrufzahl je Token. Bei einer Handvoll eigener
|
||||||
|
Tokens wäre sie mehr Zeremonie als Schutz — bei einem Token, der außer
|
||||||
|
Haus geht, sähe das anders aus.
|
||||||
|
- Die Warteschlange kennt keinen Abbruch: Ein Eintrag, der dauerhaft
|
||||||
|
scheitert, wird ewig erneut versucht. Bisher gibt es keinen Fall, in dem
|
||||||
|
das passieren könnte, ohne dass gleichzeitig die Anmeldung fehlt.
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
|
||||||
|
- d5c7974 feat: Datenmodell und Regeln für die öffentliche Schnittstelle
|
||||||
|
- 6d3ebd0 feat: API v1 für Aufgaben
|
||||||
|
- b17c42a feat: Tokens verwalten
|
||||||
|
- 9565b9a feat: Schnellerfassung vom Handy, auch ohne Netz
|
||||||
|
- 10b49cf test: Die Tür der Schnittstelle scharf prüfen
|
||||||
|
- 2e57713 docs: Handbuchseite zum Erfassen und Beschreibung der Schnittstelle
|
||||||
|
- 7919149 fix: Navigation aufteilen, statt sie weiter zu dehnen
|
||||||
|
- f9a616e docs: Handbuchbilder für M7 aufnehmen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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,5 @@ 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
|
||||||
|
2026-09-12;10:15;01:01;886;M7;claude;M7 Schnellerfassung und API 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'
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# Öffentliche Schnittstelle
|
||||||
|
|
||||||
|
Für fremde Anwendungen, Skripte und Automaten. Wer den Planer selbst bedient,
|
||||||
|
braucht sie nicht.
|
||||||
|
|
||||||
|
Basisadresse: `<APP_URL>/api/v1`
|
||||||
|
|
||||||
|
## Ausweisen
|
||||||
|
|
||||||
|
Jede Anfrage trägt einen Token:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer planer_ab12cd34_…" \
|
||||||
|
https://planer.example.de/api/v1/selbst
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternativ `X-Planer-Token: planer_…`, falls das Werkzeug keine
|
||||||
|
Authorization-Kopfzeile kann.
|
||||||
|
|
||||||
|
Tokens legst du in der Anwendung unter **API** an (nur Verwaltung). Der Wert
|
||||||
|
erscheint **einmal**; danach liegt in der Datenbank nur sein Fingerabdruck.
|
||||||
|
Wer ihn verliert, legt einen neuen an.
|
||||||
|
|
||||||
|
## Rechte
|
||||||
|
|
||||||
|
Drei Stück, und **keines schließt ein anderes ein**:
|
||||||
|
|
||||||
|
| Recht | Was damit geht |
|
||||||
|
| ----------- | -------------------------------------- |
|
||||||
|
| `lesen` | Aufgaben abfragen |
|
||||||
|
| `erfassen` | Neue Aufgaben anlegen — mehr nicht |
|
||||||
|
| `schreiben` | Bestehende Aufgaben ändern und abhaken |
|
||||||
|
|
||||||
|
Ein Dienst, der nur meldet „hier ist etwas Neues", bekommt `erfassen` und
|
||||||
|
sonst nichts. Das ist der häufigste Fall und der, bei dem ein mitgelesener
|
||||||
|
Token am wenigsten anrichtet.
|
||||||
|
|
||||||
|
## Fehler
|
||||||
|
|
||||||
|
| Status | Bedeutung |
|
||||||
|
| ------ | ----------------------------------------------------------- |
|
||||||
|
| 400 | Die Anfrage war fehlerhaft (Feldliste steht in der Antwort) |
|
||||||
|
| 401 | Kein gültiger Token |
|
||||||
|
| 403 | Dieser Token darf das nicht |
|
||||||
|
| 404 | Gibt es nicht |
|
||||||
|
|
||||||
|
401 sieht immer gleich aus — ob ein Token unbekannt, abgelaufen oder
|
||||||
|
zurückgezogen ist, verrät die Schnittstelle nicht. Von außen soll sich nicht
|
||||||
|
abfragen lassen, welche Tokens es gibt.
|
||||||
|
|
||||||
|
## Endpunkte
|
||||||
|
|
||||||
|
### `GET /selbst`
|
||||||
|
|
||||||
|
Wer bin ich, was darf ich? Braucht **kein** Recht — sonst müsste man raten,
|
||||||
|
ob der Token falsch ist oder nur zu wenig darf.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "name": "Wartungsplaner-Import", "rechte": ["erfassen"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /aufgaben` · Recht `lesen`
|
||||||
|
|
||||||
|
| Parameter | Standard | Bedeutung |
|
||||||
|
| --------- | -------- | ------------------- |
|
||||||
|
| `grenze` | 25 | Höchstens 100 |
|
||||||
|
| `versatz` | 0 | Zum Blättern |
|
||||||
|
| `offen` | — | `true` oder `false` |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"aufgaben": [
|
||||||
|
{
|
||||||
|
"id": "clx…",
|
||||||
|
"titel": "#4711 Drucker klemmt",
|
||||||
|
"notiz": null,
|
||||||
|
"status": "OFFEN",
|
||||||
|
"prioritaet": 3,
|
||||||
|
"aufwandMinuten": null,
|
||||||
|
"faelligAm": null,
|
||||||
|
"quelle": "API",
|
||||||
|
"ref": "ticket-4711",
|
||||||
|
"url": "https://…",
|
||||||
|
"erstelltAm": "2026-03-17T08:20:00.000Z",
|
||||||
|
"bereich": null,
|
||||||
|
"projekt": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"gesamt": 42,
|
||||||
|
"grenze": 25,
|
||||||
|
"versatz": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /aufgaben` · Recht `erfassen`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://planer.example.de/api/v1/aufgaben \
|
||||||
|
-H "Authorization: Bearer planer_…" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"titel":"Drucker klemmt","ref":"ticket-4711"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
| Feld | Pflicht | Bemerkung |
|
||||||
|
| ---------------- | ------- | ----------------------------------- |
|
||||||
|
| `titel` | ja | bis 200 Zeichen |
|
||||||
|
| `notiz` | nein | bis 4000 Zeichen |
|
||||||
|
| `ref` | nein | Kennung beim Aufrufer — siehe unten |
|
||||||
|
| `url` | nein | Verweis ins Ursprungssystem |
|
||||||
|
| `faelligAm` | nein | `JJJJ-MM-TT` |
|
||||||
|
| `prioritaet` | nein | 1 (dringend) bis 5 (irgendwann) |
|
||||||
|
| `aufwandMinuten` | nein | 1 bis 1440 |
|
||||||
|
|
||||||
|
Antwort: `201` mit der angelegten Aufgabe.
|
||||||
|
|
||||||
|
**`ref` macht den Aufruf wiederholbar.** Zweimal dieselbe Kennung ergibt
|
||||||
|
**eine** Aufgabe, nicht zwei — der zweite Aufruf frischt Titel, Notiz und
|
||||||
|
Verweis auf. Ohne `ref` entsteht jedes Mal eine neue Aufgabe; dann hast du es
|
||||||
|
so gewollt.
|
||||||
|
|
||||||
|
Benutze `ref`, wann immer es drüben eine stabile Kennung gibt. Das ist der
|
||||||
|
Unterschied zwischen einer Schnittstelle, die man nach einem Netzhänger
|
||||||
|
gefahrlos wiederholen kann, und einer, die dabei Karteileichen anlegt.
|
||||||
|
|
||||||
|
### `GET /aufgaben/<id>` · Recht `lesen`
|
||||||
|
|
||||||
|
### `PATCH /aufgaben/<id>` · Recht `schreiben`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PATCH https://planer.example.de/api/v1/aufgaben/clx… \
|
||||||
|
-H "Authorization: Bearer planer_…" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"erledigt":true}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Änderbar sind `titel`, `notiz`, `faelligAm`, `prioritaet`, `aufwandMinuten`
|
||||||
|
und `erledigt`. Mindestens ein Feld muss dabei sein.
|
||||||
|
|
||||||
|
## Was die Schnittstelle bewusst **nicht** kann
|
||||||
|
|
||||||
|
- **Bereich und Projekt setzen.** Was von außen kommt, landet in der Inbox.
|
||||||
|
Die Einordnung ist Sache dessen, der plant, nicht dessen, der meldet —
|
||||||
|
sonst räumt ein fremder Dienst die eigene Ordnung um.
|
||||||
|
- **Aufgaben löschen.** Abhaken ja, löschen nein. Ein Fehler im fremden
|
||||||
|
Skript soll keine Planung vernichten.
|
||||||
|
- **Zeitblöcke, Bereiche, Projekte oder Kalender anfassen.**
|
||||||
|
|
||||||
|
Fehlt dir etwas davon, ist das eine Entscheidung und kein Versehen — sag
|
||||||
|
Bescheid, dann wird darüber geredet.
|
||||||
|
|
||||||
|
## Protokoll
|
||||||
|
|
||||||
|
Jede **schreibende** Nutzung landet im Protokoll, mit dem Token, über den sie
|
||||||
|
kam. Lesen nicht: Ein Dienst, der im Minutentakt abfragt, würde das Protokoll
|
||||||
|
unbrauchbar machen.
|
||||||
|
|
||||||
|
Auch ein abgewiesener Zugriff wird vermerkt. Ein Dienst, der immer wieder an
|
||||||
|
dieselbe Wand läuft, ist entweder falsch eingerichtet oder versucht etwas.
|
||||||
|
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 |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 267 KiB After Width: | Height: | Size: 266 KiB |
|
Before Width: | Height: | Size: 282 KiB After Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 272 KiB After Width: | Height: | Size: 271 KiB |
|
Before Width: | Height: | Size: 288 KiB After Width: | Height: | Size: 285 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: 232 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 238 KiB After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 282 KiB After Width: | Height: | Size: 288 KiB |
|
Before Width: | Height: | Size: 277 KiB After Width: | Height: | Size: 286 KiB |
|
Before Width: | Height: | Size: 289 KiB After Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 236 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 242 KiB After Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 269 KiB After Width: | Height: | Size: 291 KiB |
|
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 350 KiB |
|
Before Width: | Height: | Size: 268 KiB After Width: | Height: | Size: 288 KiB |
|
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 350 KiB |
@@ -13,33 +13,36 @@ Anleitung für den Alltag mit dem Planer. Eine Seite je Thema.
|
|||||||
|
|
||||||
3. **[Aufgaben erfassen und wiederfinden](aufgaben.md)** – Schnellerfassung,
|
3. **[Aufgaben erfassen und wiederfinden](aufgaben.md)** – Schnellerfassung,
|
||||||
Inbox leeren, Filter, Status und Fälligkeit
|
Inbox leeren, Filter, Status und Fälligkeit
|
||||||
4. **[Ideen](ideen.md)** – festhalten, was noch keine Aufgabe ist
|
4. **[Schnellerfassung vom Handy](erfassen.md)** – als App ablegen, auch
|
||||||
5. **[Projekte](projekte.md)** – mehrere Aufgaben unter einem Ziel, mit
|
ohne Netz erfassen
|
||||||
|
5. **[Ideen](ideen.md)** – festhalten, was noch keine Aufgabe ist
|
||||||
|
6. **[Projekte](projekte.md)** – mehrere Aufgaben unter einem Ziel, mit
|
||||||
Fortschritt
|
Fortschritt
|
||||||
6. **[Kalenderkonten](kalenderkonten.md)** – Microsoft 365 und Google
|
7. **[Kalenderkonten](kalenderkonten.md)** – Microsoft 365 und Google
|
||||||
verbinden, Belegung verstehen
|
verbinden, Belegung verstehen
|
||||||
7. **[Wochenplanung](woche.md)** – Aufgaben per Ziehen und Ablegen in den
|
8. **[Wochenplanung](woche.md)** – Aufgaben per Ziehen und Ablegen in den
|
||||||
Kalender legen
|
Kalender legen
|
||||||
8. **[Benachrichtigungen](benachrichtigungen.md)** – Push und E-Mail
|
9. **[Benachrichtigungen](benachrichtigungen.md)** – Push, E-Mail und
|
||||||
einrichten, Ruhezeiten setzen
|
Telegram einrichten, Ruhezeiten setzen
|
||||||
|
10. **[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
|
11. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat
|
||||||
|
|
||||||
## Was noch kommt
|
## Was noch kommt
|
||||||
|
|
||||||
Diese Seiten entstehen mit den nächsten Meilensteinen:
|
Diese Seiten entstehen mit den nächsten Meilensteinen:
|
||||||
|
|
||||||
| Thema | Meilenstein |
|
| Thema | Meilenstein |
|
||||||
| ------------------------------- | ----------- |
|
| ----- | ----------- |
|
||||||
| Integrationen | M6 |
|
|
||||||
| Schnellerfassung vom Handy, API | M7 |
|
|
||||||
| MCP | M8 |
|
| MCP | M8 |
|
||||||
|
|
||||||
Der [Fahrplan](../../ROADMAP.md) sagt, was in welchem Meilenstein steckt.
|
Der [Fahrplan](../../ROADMAP.md) sagt, was in welchem Meilenstein steckt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
Für fremde Anwendungen und Skripte: [Schnittstelle](../API.md).
|
||||||
Zur Einrichtung einer neuen Instanz: [Schnellstart](../SCHNELLSTART.md).
|
Zur Einrichtung einer neuen Instanz: [Schnellstart](../SCHNELLSTART.md).
|
||||||
Für Betrieb, Sicherung und alle Umgebungsvariablen: [README](../../README.md).
|
Für Betrieb, Sicherung und alle Umgebungsvariablen: [README](../../README.md).
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Schnellerfassung vom Handy
|
||||||
|
|
||||||
|
Die Seite **Erfassen** ist für den Moment gemacht, in dem dir etwas einfällt
|
||||||
|
und du keine Zeit hast, es einzusortieren: ein Feld, ein Knopf, fertig.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Alles, was hier hereinkommt, landet in der **Inbox** — ohne Bereich, ohne
|
||||||
|
Termin. Das Einsortieren passiert später am Rechner, wenn Ruhe dafür ist.
|
||||||
|
|
||||||
|
## Als Anwendung ablegen
|
||||||
|
|
||||||
|
Der Planer lässt sich auf dem Startbildschirm ablegen und verhält sich dann
|
||||||
|
wie eine App: eigenes Symbol, kein Browserrahmen, direkt auf dieser Seite.
|
||||||
|
|
||||||
|
| Gerät | Weg |
|
||||||
|
| --------------------- | -------------------------------------------- |
|
||||||
|
| Android (Chrome) | Menü ⋮ → **App installieren** |
|
||||||
|
| iPhone (Safari) | Teilen-Symbol → **Zum Home-Bildschirm** |
|
||||||
|
| Rechner (Chrome/Edge) | Symbol in der Adresszeile → **Installieren** |
|
||||||
|
|
||||||
|
## Ohne Netz
|
||||||
|
|
||||||
|
**Das ist der eigentliche Punkt dieser Seite.** Im Keller, im Aufzug, im Zug
|
||||||
|
zwischen zwei Funkzellen: Erfasst wird trotzdem.
|
||||||
|
|
||||||
|
Was passiert:
|
||||||
|
|
||||||
|
1. Das Erfasste wird **zuerst im Gerät gemerkt**, dann verschickt. Nicht
|
||||||
|
andersherum — sonst wäre es weg, wenn du den Browser mitten im Senden
|
||||||
|
schließt.
|
||||||
|
2. Ohne Netz bleibt es liegen und steht unter dem Feld als „wartet".
|
||||||
|
3. Sobald wieder Netz da ist, geht es von selbst hinaus. Du bekommst eine
|
||||||
|
kurze Rückmeldung.
|
||||||
|
|
||||||
|
Der Knopf **Jetzt versuchen** stößt das von Hand an, wenn du nicht warten
|
||||||
|
magst.
|
||||||
|
|
||||||
|
> **Geht dabei etwas verloren?** Nein. Jede Erfassung bekommt eine eigene
|
||||||
|
> Kennung, die mitgeschickt wird. Kommt dieselbe Nachricht zweimal an — weil
|
||||||
|
> die Verbindung beim ersten Versuch abbrach, die Anfrage aber trotzdem
|
||||||
|
> durchging —, entsteht trotzdem nur **eine** Aufgabe.
|
||||||
|
|
||||||
|
Die Warteschlange liegt in der Datenbank des Browsers, nicht im flüchtigen
|
||||||
|
Speicher. Sie übersteht das Schließen des Tabs und einen Neustart des
|
||||||
|
Geräts.
|
||||||
|
|
||||||
|
Damit die Seite ohne Netz überhaupt aufgeht, hält der Planer genau **diese
|
||||||
|
eine Seite** vor. Alle anderen Seiten holt er immer frisch: Ein halb
|
||||||
|
veralteter Planer wäre schlimmer als gar keiner — ein leeres Eingabefeld
|
||||||
|
dagegen veraltet nicht.
|
||||||
|
|
||||||
|
## Warum nicht die Erfassung auf der Startseite?
|
||||||
|
|
||||||
|
Die gibt es weiterhin, und sie ist am Rechner meist der kürzere Weg. Der
|
||||||
|
Unterschied ist die Verlässlichkeit: Die Schnellerfassung ist die einzige
|
||||||
|
Stelle, die ohne Netz nichts verliert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Weiter zur [Inbox](aufgaben.md) oder zurück zum [Handbuch](README.md).
|
||||||
@@ -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).
|
||||||
@@ -50,7 +50,11 @@ test.describe('Anmeldung', () => {
|
|||||||
await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
|
await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
|
await expect(page.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
|
||||||
// exact, weil die Startseite zusätzlich einen Link "Bereiche verwalten" hat.
|
// Alltagspunkte stehen in der Leiste, Einrichtung liegt hinter "Mehr".
|
||||||
|
await expect(page.getByRole('link', { name: 'Aufgaben', exact: true })).toBeVisible()
|
||||||
|
await expect(page.getByRole('link', { name: 'Bereiche', exact: true })).toBeHidden()
|
||||||
|
|
||||||
|
await page.getByText('Mehr', { exact: true }).click()
|
||||||
await expect(page.getByRole('link', { name: 'Bereiche', exact: true })).toBeVisible()
|
await expect(page.getByRole('link', { name: 'Bereiche', exact: true })).toBeVisible()
|
||||||
|
|
||||||
await page.getByTitle('Abmelden').click()
|
await page.getByTitle('Abmelden').click()
|
||||||
@@ -64,6 +68,8 @@ test.describe('Anmeldung', () => {
|
|||||||
await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
|
await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click()
|
||||||
await expect(page.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
|
await expect(page.getByRole('heading', { name: /Guten Tag/ })).toBeVisible()
|
||||||
|
|
||||||
|
// Das Protokoll liegt hinter "Mehr" – aufklappen, dann hin.
|
||||||
|
await page.getByText('Mehr', { exact: true }).click()
|
||||||
await page.getByRole('link', { name: 'Protokoll' }).click()
|
await page.getByRole('link', { name: 'Protokoll' }).click()
|
||||||
await expect(page.getByRole('heading', { name: 'Protokoll' })).toBeVisible()
|
await expect(page.getByRole('heading', { name: 'Protokoll' })).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { createHash, randomBytes } from 'node:crypto'
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg'
|
||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
import { PrismaClient } from '../src/generated/prisma/client.js'
|
||||||
|
import { EMAIL, VERBINDUNG } from './hilfe'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die öffentliche Schnittstelle.
|
||||||
|
*
|
||||||
|
* Geprüft wird vor allem, was **nicht** geht: ohne Token, mit falschem
|
||||||
|
* Token, mit zu wenig Rechten, mit abgelaufenem oder zurückgezogenem Token.
|
||||||
|
* Der gute Fall ist schnell erzählt — die Tür ist das Interessante.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: VERBINDUNG }) })
|
||||||
|
const PRAEFIX = `E2E-API-${Date.now()}`
|
||||||
|
|
||||||
|
/** Legt einen Token direkt in der Datenbank an und gibt den Klartext zurück. */
|
||||||
|
async function tokenBauen(
|
||||||
|
benutzerId: string,
|
||||||
|
rechte: string,
|
||||||
|
zusatz: { gueltigBis?: Date; widerrufenAm?: Date } = {},
|
||||||
|
): Promise<string> {
|
||||||
|
const praefix = randomBytes(4).toString('hex')
|
||||||
|
const klartext = `planer_${praefix}_${randomBytes(32).toString('base64url')}`
|
||||||
|
await db.apiToken.create({
|
||||||
|
data: {
|
||||||
|
benutzerId,
|
||||||
|
name: `${PRAEFIX} ${rechte}`,
|
||||||
|
praefix,
|
||||||
|
hash: createHash('sha256').update(klartext, 'utf8').digest('hex'),
|
||||||
|
rechte,
|
||||||
|
...zusatz,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return klartext
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('API v1', () => {
|
||||||
|
test.skip(!EMAIL || !VERBINDUNG, 'Ohne ADMIN_EMAIL und DATABASE_URL kein Testkonto.')
|
||||||
|
|
||||||
|
let benutzerId = ''
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
const benutzer = await db.benutzer.findUniqueOrThrow({ where: { email: EMAIL } })
|
||||||
|
benutzerId = benutzer.id
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await db.aufgabe.deleteMany({ where: { titel: { startsWith: PRAEFIX } } })
|
||||||
|
await db.aufgabe.deleteMany({ where: { quelleRef: { startsWith: PRAEFIX } } })
|
||||||
|
await db.apiToken.deleteMany({ where: { name: { startsWith: PRAEFIX } } })
|
||||||
|
await db.auditEintrag.deleteMany({ where: { aktion: { startsWith: 'api.' } } })
|
||||||
|
await db.$disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('weist eine Anfrage ohne Token ab', async ({ request }) => {
|
||||||
|
const antwort = await request.get('/api/v1/aufgaben')
|
||||||
|
expect(antwort.status()).toBe(401)
|
||||||
|
// Sagt Werkzeugen, wie sie sich ausweisen sollen.
|
||||||
|
expect(antwort.headers()['www-authenticate']).toBe('Bearer')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('weist einen erfundenen Token ab', async ({ request }) => {
|
||||||
|
const antwort = await request.get('/api/v1/aufgaben', {
|
||||||
|
headers: { Authorization: `Bearer planer_abcdefgh_${'x'.repeat(43)}` },
|
||||||
|
})
|
||||||
|
expect(antwort.status()).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sagt einem gültigen Token, was er darf', async ({ request }) => {
|
||||||
|
const token = await tokenBauen(benutzerId, 'lesen,erfassen')
|
||||||
|
const antwort = await request.get('/api/v1/selbst', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(antwort.ok()).toBe(true)
|
||||||
|
expect(await antwort.json()).toMatchObject({ rechte: ['lesen', 'erfassen'] })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lässt einen Nur-Lesen-Token nicht erfassen', async ({ request }) => {
|
||||||
|
const token = await tokenBauen(benutzerId, 'lesen')
|
||||||
|
const antwort = await request.post('/api/v1/aufgaben', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
data: { titel: `${PRAEFIX} darf nicht` },
|
||||||
|
})
|
||||||
|
expect(antwort.status()).toBe(403)
|
||||||
|
expect(await db.aufgabe.count({ where: { titel: `${PRAEFIX} darf nicht` } })).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lässt einen Nur-Erfassen-Token nicht lesen', async ({ request }) => {
|
||||||
|
// Erfassen schließt Lesen ausdrücklich nicht ein.
|
||||||
|
const token = await tokenBauen(benutzerId, 'erfassen')
|
||||||
|
const antwort = await request.get('/api/v1/aufgaben', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(antwort.status()).toBe(403)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('weist einen abgelaufenen Token ab', async ({ request }) => {
|
||||||
|
const token = await tokenBauen(benutzerId, 'lesen', {
|
||||||
|
gueltigBis: new Date(Date.now() - 60_000),
|
||||||
|
})
|
||||||
|
const antwort = await request.get('/api/v1/aufgaben', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(antwort.status()).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('weist einen zurückgezogenen Token ab', async ({ request }) => {
|
||||||
|
const token = await tokenBauen(benutzerId, 'lesen', { widerrufenAm: new Date() })
|
||||||
|
const antwort = await request.get('/api/v1/aufgaben', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(antwort.status()).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('erfasst, wiederholt sich nicht und ändert', async ({ request }) => {
|
||||||
|
const token = await tokenBauen(benutzerId, 'lesen,erfassen,schreiben')
|
||||||
|
const kopf = { Authorization: `Bearer ${token}` }
|
||||||
|
const ref = `${PRAEFIX}-ref`
|
||||||
|
|
||||||
|
const erste = await request.post('/api/v1/aufgaben', {
|
||||||
|
headers: kopf,
|
||||||
|
data: { titel: `${PRAEFIX} Angebot schreiben`, ref },
|
||||||
|
})
|
||||||
|
expect(erste.status()).toBe(201)
|
||||||
|
const angelegt = (await erste.json()) as { id: string; titel: string }
|
||||||
|
|
||||||
|
// Dasselbe noch einmal: eine Aufgabe, nicht zwei.
|
||||||
|
const zweite = await request.post('/api/v1/aufgaben', {
|
||||||
|
headers: kopf,
|
||||||
|
data: { titel: `${PRAEFIX} Angebot schreiben`, ref },
|
||||||
|
})
|
||||||
|
expect(zweite.ok()).toBe(true)
|
||||||
|
expect(await db.aufgabe.count({ where: { quelle: 'API', quelleRef: ref } })).toBe(1)
|
||||||
|
|
||||||
|
// Abhaken über die Schnittstelle.
|
||||||
|
const geaendert = await request.patch(`/api/v1/aufgaben/${angelegt.id}`, {
|
||||||
|
headers: kopf,
|
||||||
|
data: { erledigt: true },
|
||||||
|
})
|
||||||
|
expect(geaendert.ok()).toBe(true)
|
||||||
|
expect((await geaendert.json()) as { status: string }).toMatchObject({
|
||||||
|
status: 'ERLEDIGT',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Und im Protokoll steht, dass es über die Schnittstelle kam.
|
||||||
|
const protokoll = await db.auditEintrag.findFirst({
|
||||||
|
where: { aktion: 'api.geaendert', objektId: angelegt.id },
|
||||||
|
select: { details: true },
|
||||||
|
})
|
||||||
|
expect(protokoll).not.toBeNull()
|
||||||
|
expect(JSON.stringify(protokoll?.details)).toContain('api')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('lehnt eine Aufgabe ohne Titel ab', async ({ request }) => {
|
||||||
|
const token = await tokenBauen(benutzerId, 'erfassen')
|
||||||
|
const antwort = await request.post('/api/v1/aufgaben', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
data: { titel: ' ' },
|
||||||
|
})
|
||||||
|
expect(antwort.status()).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('setzt weder Status noch Bereich aus der Anfrage', async ({ request }) => {
|
||||||
|
const token = await tokenBauen(benutzerId, 'erfassen')
|
||||||
|
const bereich = await db.bereich.findFirst({ select: { id: true } })
|
||||||
|
|
||||||
|
const antwort = await request.post('/api/v1/aufgaben', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
data: {
|
||||||
|
titel: `${PRAEFIX} Einordnung bleibt hier`,
|
||||||
|
status: 'ERLEDIGT',
|
||||||
|
bereichId: bereich?.id ?? null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(antwort.status()).toBe(201)
|
||||||
|
|
||||||
|
const angelegt = await db.aufgabe.findFirstOrThrow({
|
||||||
|
where: { titel: `${PRAEFIX} Einordnung bleibt hier` },
|
||||||
|
select: { status: true, bereichId: true },
|
||||||
|
})
|
||||||
|
// Was von außen kommt, landet offen in der Inbox.
|
||||||
|
expect(angelegt.status).toBe('OFFEN')
|
||||||
|
expect(angelegt.bereichId).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -52,6 +52,10 @@ test.describe('Aufgaben', () => {
|
|||||||
await zeile.getByLabel('Einsortieren nach').selectOption(bereich!.id)
|
await zeile.getByLabel('Einsortieren nach').selectOption(bereich!.id)
|
||||||
await expect(page.getByText(`liegt jetzt in ${bereich!.name}`)).toBeVisible()
|
await expect(page.getByText(`liegt jetzt in ${bereich!.name}`)).toBeVisible()
|
||||||
|
|
||||||
|
// Die Meldung kommt, bevor das Neuladen der Liste fertig ist. Wer jetzt
|
||||||
|
// navigiert, bricht es ab — und Chromium meldet ERR_ABORTED.
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
|
||||||
// Aus der Inbox ist sie damit verschwunden.
|
// Aus der Inbox ist sie damit verschwunden.
|
||||||
await page.goto('/inbox')
|
await page.goto('/inbox')
|
||||||
await expect(page.locator('li').filter({ hasText: titel })).toHaveCount(0)
|
await expect(page.locator('li').filter({ hasText: titel })).toHaveCount(0)
|
||||||
|
|||||||
@@ -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.7.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "planer",
|
"name": "planer",
|
||||||
"version": "0.5.0",
|
"version": "0.7.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.7.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,90 @@ 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)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API-Tokens -----------------------------------------------------------
|
||||||
|
// Erfundene Hashes: Mit diesen Zeilen laesst sich nichts aufrufen, sie
|
||||||
|
// zeigen nur, wie die Liste aussieht.
|
||||||
|
await db.apiToken.deleteMany({})
|
||||||
|
await db.apiToken.create({
|
||||||
|
data: {
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
name: 'Wartungsplaner-Import',
|
||||||
|
praefix: 'k7m2xq4b',
|
||||||
|
hash: 'demo-1',
|
||||||
|
rechte: 'erfassen',
|
||||||
|
erstelltAm: new Date(Date.UTC(2026, 0, 14, 9, 30)),
|
||||||
|
letzteNutzung: new Date(Date.UTC(2026, 2, 17, 5, 12)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await db.apiToken.create({
|
||||||
|
data: {
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
name: 'Auswertung Homelab',
|
||||||
|
praefix: 'r3wp9dt4',
|
||||||
|
hash: 'demo-2',
|
||||||
|
rechte: 'lesen',
|
||||||
|
gueltigBis: new Date(Date.UTC(2026, 8, 30, 22, 0)),
|
||||||
|
erstelltAm: new Date(Date.UTC(2026, 1, 2, 18, 45)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await db.apiToken.create({
|
||||||
|
data: {
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
name: 'Altes Skript',
|
||||||
|
praefix: 'z8n4vt6c',
|
||||||
|
hash: 'demo-3',
|
||||||
|
rechte: 'lesen,schreiben',
|
||||||
|
erstelltAm: new Date(Date.UTC(2025, 10, 3, 11, 0)),
|
||||||
|
widerrufenAm: new Date(Date.UTC(2026, 1, 20, 8, 15)),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
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");
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "api_token" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"benutzerId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"praefix" TEXT NOT NULL,
|
||||||
|
"hash" TEXT NOT NULL,
|
||||||
|
"rechte" TEXT NOT NULL,
|
||||||
|
"gueltigBis" TIMESTAMP(3),
|
||||||
|
"letzteNutzung" TIMESTAMP(3),
|
||||||
|
"widerrufenAm" TIMESTAMP(3),
|
||||||
|
"erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "api_token_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "api_token_praefix_key" ON "api_token"("praefix");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "api_token_hash_key" ON "api_token"("hash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "api_token_benutzerId_idx" ON "api_token"("benutzerId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "api_token" ADD CONSTRAINT "api_token_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -38,6 +38,8 @@ model Benutzer {
|
|||||||
benachrichtigung Benachrichtigungseinstellung?
|
benachrichtigung Benachrichtigungseinstellung?
|
||||||
pushGeraete PushGeraet[]
|
pushGeraete PushGeraet[]
|
||||||
meldungen Meldungsnachweis[]
|
meldungen Meldungsnachweis[]
|
||||||
|
telegramChats TelegramChat[]
|
||||||
|
apiTokens ApiToken[]
|
||||||
|
|
||||||
@@index([aktiv])
|
@@index([aktiv])
|
||||||
@@map("benutzer")
|
@@map("benutzer")
|
||||||
@@ -222,6 +224,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 +400,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 +455,118 @@ 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zugangstoken fuer die oeffentliche Schnittstelle.
|
||||||
|
///
|
||||||
|
/// Gespeichert wird nur der Hash. Der Klartext existiert genau einmal – beim
|
||||||
|
/// Anlegen, auf dem Bildschirm. Ein Token ist ein Passwort; wer es verliert,
|
||||||
|
/// legt ein neues an, statt das alte irgendwo nachzuschlagen.
|
||||||
|
model ApiToken {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
benutzerId String
|
||||||
|
|
||||||
|
name String
|
||||||
|
/// Sichtbarer Anfang, macht die Zeile auffindbar und wiedererkennbar.
|
||||||
|
praefix String @unique
|
||||||
|
/// SHA-256 des vollstaendigen Tokens.
|
||||||
|
hash String @unique
|
||||||
|
|
||||||
|
/// Kommagetrennt, siehe src/lib/api.ts.
|
||||||
|
rechte String
|
||||||
|
|
||||||
|
gueltigBis DateTime?
|
||||||
|
letzteNutzung DateTime?
|
||||||
|
/// Gesetzt heisst: gilt nicht mehr. Die Zeile bleibt fuer das Protokoll.
|
||||||
|
widerrufenAm DateTime?
|
||||||
|
|
||||||
|
erstelltAm DateTime @default(now())
|
||||||
|
|
||||||
|
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([benutzerId])
|
||||||
|
@@map("api_token")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "Planer",
|
||||||
|
"short_name": "Planer",
|
||||||
|
"description": "Aufgaben, Ideen, Projekte und Zeitblöcke.",
|
||||||
|
"lang": "de",
|
||||||
|
"dir": "ltr",
|
||||||
|
"start_url": "/erfassen",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"background_color": "#f7f8fa",
|
||||||
|
"theme_color": "#f7f8fa",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/symbol.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/symbol-flaeche.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "Erfassen",
|
||||||
|
"short_name": "Erfassen",
|
||||||
|
"url": "/erfassen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Heute",
|
||||||
|
"short_name": "Heute",
|
||||||
|
"url": "/"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,18 +1,72 @@
|
|||||||
/*
|
/*
|
||||||
* Service Worker des Planers.
|
* Service Worker des Planers.
|
||||||
*
|
*
|
||||||
* Er tut genau eine Sache: Push-Nachrichten anzeigen und beim Klick die
|
* Zwei Aufgaben: Push-Nachrichten anzeigen, und die Seite "Erfassen" auch
|
||||||
* richtige Seite öffnen. Kein Zwischenspeichern von Seiten — ein halb
|
* ohne Netz bereithalten.
|
||||||
* veralteter Planer wäre schlimmer als gar keiner.
|
*
|
||||||
|
* Zwischengespeichert wird **genau diese eine Seite** und sonst nichts. Ein
|
||||||
|
* halb veralteter Planer wäre schlimmer als gar keiner — ein Eingabefeld
|
||||||
|
* dagegen veraltet nicht, und ohne die Seite nützte die Warteschlange im
|
||||||
|
* Gerät niemandem: Man käme gar nicht erst hin.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
self.addEventListener('install', () => {
|
const LAGER = 'planer-erfassen-v1'
|
||||||
|
const OFFLINE_SEITE = '/erfassen'
|
||||||
|
|
||||||
|
self.addEventListener('install', (ereignis) => {
|
||||||
|
ereignis.waitUntil(
|
||||||
|
caches
|
||||||
|
.open(LAGER)
|
||||||
|
.then((lager) => lager.add(new Request(OFFLINE_SEITE, { cache: 'reload' })))
|
||||||
|
.catch(() => undefined),
|
||||||
|
)
|
||||||
// Sofort übernehmen, nicht auf das Schließen aller Tabs warten.
|
// Sofort übernehmen, nicht auf das Schließen aller Tabs warten.
|
||||||
self.skipWaiting()
|
self.skipWaiting()
|
||||||
})
|
})
|
||||||
|
|
||||||
self.addEventListener('activate', (ereignis) => {
|
self.addEventListener('activate', (ereignis) => {
|
||||||
ereignis.waitUntil(self.clients.claim())
|
ereignis.waitUntil(
|
||||||
|
caches
|
||||||
|
.keys()
|
||||||
|
.then((namen) =>
|
||||||
|
Promise.all(namen.filter((n) => n !== LAGER).map((n) => caches.delete(n))),
|
||||||
|
)
|
||||||
|
.then(() => self.clients.claim()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (ereignis) => {
|
||||||
|
const anfrage = ereignis.request
|
||||||
|
if (anfrage.method !== 'GET') return
|
||||||
|
|
||||||
|
const adresse = new URL(anfrage.url)
|
||||||
|
if (adresse.origin !== self.location.origin) return
|
||||||
|
if (adresse.pathname !== OFFLINE_SEITE) return
|
||||||
|
|
||||||
|
// Zuerst das Netz: Wer online ist, bekommt die frische Seite. Erst wenn das
|
||||||
|
// scheitert, kommt die aufgehobene Fassung.
|
||||||
|
ereignis.respondWith(
|
||||||
|
fetch(anfrage)
|
||||||
|
.then((antwort) => {
|
||||||
|
if (antwort.ok) {
|
||||||
|
const kopie = antwort.clone()
|
||||||
|
void caches.open(LAGER).then((lager) => lager.put(OFFLINE_SEITE, kopie))
|
||||||
|
}
|
||||||
|
return antwort
|
||||||
|
})
|
||||||
|
.catch(() =>
|
||||||
|
caches
|
||||||
|
.match(OFFLINE_SEITE)
|
||||||
|
.then(
|
||||||
|
(aufgehoben) =>
|
||||||
|
aufgehoben ??
|
||||||
|
new Response('Offline und nichts aufgehoben.', {
|
||||||
|
status: 503,
|
||||||
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
self.addEventListener('push', (ereignis) => {
|
self.addEventListener('push', (ereignis) => {
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Planer">
|
||||||
|
<!-- Maskierbare Fassung: Das Motiv sitzt in der sicheren Zone, damit ein
|
||||||
|
Betriebssystem daraus einen Kreis oder ein Quadrat schneiden kann. -->
|
||||||
|
<rect width="64" height="64" fill="#2563eb" />
|
||||||
|
<path d="M20 34l8 8 16-18" fill="none" stroke="#ffffff" stroke-width="6"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 435 B |
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Planer">
|
||||||
|
<!-- Ein Blatt mit Haken: Liste plus Erledigtes. Bewusst ohne Farbverlauf,
|
||||||
|
damit es auch als kleines Symbol lesbar bleibt. -->
|
||||||
|
<rect x="10" y="6" width="44" height="52" rx="8" fill="#2563eb" />
|
||||||
|
<rect x="20" y="2" width="8" height="10" rx="4" fill="#1e40af" />
|
||||||
|
<rect x="36" y="2" width="8" height="10" rx="4" fill="#1e40af" />
|
||||||
|
<path d="M21 33l8 8 15-16" fill="none" stroke="#ffffff" stroke-width="6"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 573 B |
@@ -37,18 +37,21 @@ const AUFNAHMEN: Aufnahme[] = [
|
|||||||
{ name: 'anmelden', pfad: '/anmelden' },
|
{ name: 'anmelden', pfad: '/anmelden' },
|
||||||
{ name: 'start', pfad: '/' },
|
{ name: 'start', pfad: '/' },
|
||||||
{ name: 'inbox', pfad: '/inbox' },
|
{ name: 'inbox', pfad: '/inbox' },
|
||||||
|
{ name: 'erfassen', pfad: '/erfassen' },
|
||||||
{ name: 'aufgaben', pfad: '/aufgaben' },
|
{ name: 'aufgaben', pfad: '/aufgaben' },
|
||||||
{ name: 'woche', pfad: '/woche' },
|
{ name: 'woche', pfad: '/woche' },
|
||||||
{ name: 'projekte', pfad: '/projekte' },
|
{ name: 'projekte', pfad: '/projekte' },
|
||||||
{ 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',
|
||||||
pfad: '/einstellungen',
|
pfad: '/einstellungen',
|
||||||
ausschnitt: '[data-bild="benachrichtigungen"]',
|
ausschnitt: '[data-bild="benachrichtigungen"]',
|
||||||
},
|
},
|
||||||
|
{ name: 'api', pfad: '/verwaltung/api' },
|
||||||
{ name: 'protokoll', pfad: '/verwaltung/protokoll' },
|
{ name: 'protokoll', pfad: '/verwaltung/protokoll' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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,13 @@ 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' } })
|
||||||
|
await db.apiToken.deleteMany({ where: { name: { startsWith: kennung } } })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
@@ -316,6 +326,113 @@ 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),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- API-Tokens ----------------------------------------------------------
|
||||||
|
const token = await db.apiToken.create({
|
||||||
|
data: {
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
name: `${kennung}-token`,
|
||||||
|
praefix: kennung.slice(-8),
|
||||||
|
hash: `hash-${kennung}`,
|
||||||
|
rechte: 'lesen',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
pruefe('API-Token anlegen', token.id.length > 0)
|
||||||
|
pruefe('Ein frischer Token gilt unbegrenzt', token.gueltigBis === null)
|
||||||
|
pruefe('Ein frischer Token ist nicht widerrufen', token.widerrufenAm === null)
|
||||||
|
|
||||||
|
await scheitert('Dasselbe Praefix nur einmal', () =>
|
||||||
|
db.apiToken.create({
|
||||||
|
data: {
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
name: `${kennung}-zweiter`,
|
||||||
|
praefix: kennung.slice(-8),
|
||||||
|
hash: `anderer-hash-${kennung}`,
|
||||||
|
rechte: 'lesen',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await scheitert('Denselben Hash nur einmal', () =>
|
||||||
|
db.apiToken.create({
|
||||||
|
data: {
|
||||||
|
benutzerId: benutzer.id,
|
||||||
|
name: `${kennung}-dritter`,
|
||||||
|
praefix: 'anders12',
|
||||||
|
hash: `hash-${kennung}`,
|
||||||
|
rechte: 'lesen',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
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 +458,14 @@ 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(
|
||||||
|
'API-Tokens verschwinden mit dem Benutzer',
|
||||||
|
(await db.apiToken.count({ where: { name: { startsWith: 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,187 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { CloudOff, Loader2, Plus, RefreshCw } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { abarbeiten, alle, anstellen, type Eintrag } from '@/lib/warteschlange'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schnellerfassung fürs Handy.
|
||||||
|
*
|
||||||
|
* Ein Feld, ein Knopf, große Fläche. Was erfasst wird, geht sofort hinaus —
|
||||||
|
* und wenn kein Netz da ist, wartet es im Gerät, bis wieder eines da ist.
|
||||||
|
*
|
||||||
|
* Der Unterschied zur Erfassung auf der Startseite ist nicht die Optik,
|
||||||
|
* sondern die Verlässlichkeit: Hier darf nichts verloren gehen, auch nicht
|
||||||
|
* im Funkloch.
|
||||||
|
*/
|
||||||
|
export function Erfassung() {
|
||||||
|
const [titel, setTitel] = useState('')
|
||||||
|
const [laeuft, setLaeuft] = useState(false)
|
||||||
|
const [wartend, setWartend] = useState<Eintrag[]>([])
|
||||||
|
// Vor der Hydratation weiss der Server nichts vom Netz des Geraets;
|
||||||
|
// "null" heisst schlicht: noch nicht nachgesehen.
|
||||||
|
const [online, setOnline] = useState<boolean | null>(null)
|
||||||
|
const feld = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
const nachsehen = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setWartend(await alle())
|
||||||
|
} catch {
|
||||||
|
// Ohne IndexedDB gibt es keine Warteschlange; erfassen geht trotzdem.
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const leeren = useCallback(async () => {
|
||||||
|
const bericht = await abarbeiten()
|
||||||
|
if (bericht.gesendet > 0) {
|
||||||
|
toast.success(
|
||||||
|
bericht.gesendet === 1
|
||||||
|
? 'Eine gemerkte Aufgabe ist raus.'
|
||||||
|
: `${bericht.gesendet} gemerkte Aufgaben sind raus.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
await nachsehen()
|
||||||
|
}, [nachsehen])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function wiederDa() {
|
||||||
|
setOnline(true)
|
||||||
|
void leeren()
|
||||||
|
}
|
||||||
|
function weg() {
|
||||||
|
setOnline(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('online', wiederDa)
|
||||||
|
window.addEventListener('offline', weg)
|
||||||
|
|
||||||
|
// Den Anfangszustand nicht im Rumpf setzen, sondern als Antwort auf das
|
||||||
|
// Aussenwelt-Ereignis "die Seite ist jetzt da".
|
||||||
|
const ersteRunde = window.setTimeout(() => {
|
||||||
|
setOnline(navigator.onLine)
|
||||||
|
void nachsehen()
|
||||||
|
void leeren()
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(ersteRunde)
|
||||||
|
window.removeEventListener('online', wiederDa)
|
||||||
|
window.removeEventListener('offline', weg)
|
||||||
|
}
|
||||||
|
}, [leeren, nachsehen])
|
||||||
|
|
||||||
|
// Der Dienstarbeiter hält die Seite selbst offline bereit. Ohne ihn wäre
|
||||||
|
// die Warteschlange sinnlos: Man käme gar nicht erst auf die Seite.
|
||||||
|
useEffect(() => {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
void navigator.serviceWorker.register('/sw.js').catch(() => undefined)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function erfassen(ereignis: React.FormEvent) {
|
||||||
|
ereignis.preventDefault()
|
||||||
|
const text = titel.trim()
|
||||||
|
if (text.length === 0) return
|
||||||
|
|
||||||
|
setLaeuft(true)
|
||||||
|
try {
|
||||||
|
// Erst anstellen, dann senden. Andersherum ginge der Eintrag verloren,
|
||||||
|
// wenn der Browser mitten im Senden geschlossen wird.
|
||||||
|
const eintrag = await anstellen(text)
|
||||||
|
setTitel('')
|
||||||
|
feld.current?.focus()
|
||||||
|
|
||||||
|
const bericht = await abarbeiten()
|
||||||
|
await nachsehen()
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
bericht.gesendet > 0
|
||||||
|
? 'Erfasst. Liegt in der Inbox.'
|
||||||
|
: 'Gemerkt. Geht raus, sobald wieder Netz da ist.',
|
||||||
|
)
|
||||||
|
// Der Eintrag selbst interessiert nur, wenn er liegen bleibt.
|
||||||
|
void eintrag
|
||||||
|
} catch {
|
||||||
|
toast.error('Das hat nicht geklappt.')
|
||||||
|
} finally {
|
||||||
|
setLaeuft(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<form onSubmit={(e) => void erfassen(e)} className="flex flex-col gap-3">
|
||||||
|
<label htmlFor="titel" className="sr-only">
|
||||||
|
Was ist zu tun?
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="titel"
|
||||||
|
ref={feld}
|
||||||
|
value={titel}
|
||||||
|
onChange={(e) => setTitel(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// Auf dem Handy ist Enter ein Zeilenumbruch; am Rechner will man
|
||||||
|
// damit abschicken.
|
||||||
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault()
|
||||||
|
void erfassen(e)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
rows={3}
|
||||||
|
maxLength={200}
|
||||||
|
autoFocus
|
||||||
|
placeholder="Was ist zu tun?"
|
||||||
|
className="border-rand bg-karte focus-visible:ring-betont/40 w-full resize-none rounded-lg border p-4 text-base outline-none focus-visible:ring-2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={laeuft || titel.trim().length === 0}
|
||||||
|
className="h-12 text-base"
|
||||||
|
>
|
||||||
|
{laeuft ? (
|
||||||
|
<Loader2 className="animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Plus aria-hidden />
|
||||||
|
)}
|
||||||
|
Erfassen
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{online === false && (
|
||||||
|
<p className="text-gedaempft-vordergrund flex items-center gap-2 text-sm">
|
||||||
|
<CloudOff className="size-4 shrink-0" aria-hidden />
|
||||||
|
Kein Netz. Erfasstes wird gemerkt und später verschickt.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{wartend.length > 0 && (
|
||||||
|
<div className="border-rand flex flex-col gap-2 rounded-md border p-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{wartend.length === 1 ? 'Eine wartet' : `${wartend.length} warten`}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
art="leise"
|
||||||
|
groesse="klein"
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() => void leeren()}
|
||||||
|
>
|
||||||
|
<RefreshCw aria-hidden />
|
||||||
|
Jetzt versuchen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ul className="text-gedaempft-vordergrund flex flex-col gap-1 text-sm">
|
||||||
|
{wartend.map((e) => (
|
||||||
|
<li key={e.ref} className="truncate">
|
||||||
|
{e.titel}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { Inbox } from 'lucide-react'
|
||||||
|
import { Erfassung } from '@/app/(app)/erfassen/erfassung'
|
||||||
|
import { zaehlstaende } from '@/server/abfragen/aufgaben'
|
||||||
|
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: 'Erfassen' }
|
||||||
|
|
||||||
|
export default async function ErfassenSeite() {
|
||||||
|
await verlangeAnmeldung('/erfassen')
|
||||||
|
|
||||||
|
// Dieselbe Zaehlung wie in der Kopfleiste. Eine eigene Abfrage zaehlte
|
||||||
|
// Ideen mit und widersprach dem Zaehler eine Zeile darueber.
|
||||||
|
const { inbox } = await zaehlstaende()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-md flex-col gap-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Erfassen</h1>
|
||||||
|
<p className="text-gedaempft-vordergrund text-sm">
|
||||||
|
Alles landet in der Inbox und wird später einsortiert. Ohne Netz wird es
|
||||||
|
gemerkt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Erfassung />
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/inbox"
|
||||||
|
className="text-gedaempft-vordergrund hover:text-vordergrund flex items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<Inbox className="size-4" aria-hidden />
|
||||||
|
{inbox === 0
|
||||||
|
? 'Inbox ist leer'
|
||||||
|
: inbox === 1
|
||||||
|
? 'Eine Aufgabe in der Inbox'
|
||||||
|
: `${inbox} Aufgaben in der Inbox`}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { KeyRound } from 'lucide-react'
|
||||||
|
import { TokenVerwaltung } from '@/app/(app)/verwaltung/api/token-verwaltung'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardBeschreibung,
|
||||||
|
CardInhalt,
|
||||||
|
CardKopf,
|
||||||
|
CardTitel,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { rechteLesen } from '@/lib/api'
|
||||||
|
import { alsDatumUhrzeit } from '@/lib/zeit'
|
||||||
|
import { verlangeRolle } from '@/server/berechtigung'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
import { umgebung } from '@/server/umgebung'
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: 'API-Tokens' }
|
||||||
|
|
||||||
|
export default async function ApiSeite() {
|
||||||
|
await verlangeRolle('ADMIN', '/verwaltung/api')
|
||||||
|
|
||||||
|
const tokens = await db.apiToken.findMany({
|
||||||
|
// Gueltige zuerst, Zurueckgezogenes ans Ende: Was noch Zugang hat, ist
|
||||||
|
// das, wonach man sucht.
|
||||||
|
orderBy: [{ widerrufenAm: { sort: 'asc', nulls: 'first' } }, { erstelltAm: 'desc' }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
praefix: true,
|
||||||
|
rechte: true,
|
||||||
|
gueltigBis: true,
|
||||||
|
letzteNutzung: true,
|
||||||
|
widerrufenAm: true,
|
||||||
|
erstelltAm: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const adresse = umgebung().APP_URL
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<KeyRound className="text-gedaempft-vordergrund size-6" aria-hidden />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">API-Tokens</h1>
|
||||||
|
<p className="text-gedaempft-vordergrund text-sm">
|
||||||
|
Zugänge für fremde Anwendungen — mit so wenig Rechten wie möglich.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardKopf>
|
||||||
|
<CardTitel className="text-base">Tokens</CardTitel>
|
||||||
|
<CardBeschreibung>
|
||||||
|
Ein Token ist ein Passwort. Er wird beim Anlegen ein einziges Mal angezeigt;
|
||||||
|
danach liegt in der Datenbank nur noch sein Fingerabdruck. Wer ihn verliert,
|
||||||
|
legt einen neuen an — nachschlagen lässt er sich nicht.
|
||||||
|
</CardBeschreibung>
|
||||||
|
</CardKopf>
|
||||||
|
<CardInhalt>
|
||||||
|
<TokenVerwaltung
|
||||||
|
adresse={adresse}
|
||||||
|
tokens={tokens.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
praefix: t.praefix,
|
||||||
|
rechte: rechteLesen(t.rechte),
|
||||||
|
gueltigBis: t.gueltigBis ? alsDatumUhrzeit(t.gueltigBis) : null,
|
||||||
|
letzteNutzung: t.letzteNutzung ? alsDatumUhrzeit(t.letzteNutzung) : null,
|
||||||
|
widerrufen: t.widerrufenAm !== null,
|
||||||
|
erstelltAm: alsDatumUhrzeit(t.erstelltAm),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useTransition } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { Copy, KeyRound, Loader2, Plus, Trash2 } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Kennzeichen } from '@/components/ui/kennzeichen'
|
||||||
|
import { RECHTE, RECHT_BESCHREIBUNG, RECHT_NAME, type Recht } from '@/lib/api'
|
||||||
|
import { tokenErzeugen, tokenZuruecknehmen } from '@/server/aktionen/api'
|
||||||
|
|
||||||
|
export type TokenAnzeige = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
praefix: string
|
||||||
|
rechte: Recht[]
|
||||||
|
gueltigBis: string | null
|
||||||
|
letzteNutzung: string | null
|
||||||
|
widerrufen: boolean
|
||||||
|
erstelltAm: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TokenVerwaltung({
|
||||||
|
tokens,
|
||||||
|
adresse,
|
||||||
|
}: {
|
||||||
|
tokens: TokenAnzeige[]
|
||||||
|
adresse: string
|
||||||
|
}) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [laeuft, starte] = useTransition()
|
||||||
|
const [felder, setFelder] = useState<Record<string, string>>({})
|
||||||
|
const [offen, setOffen] = useState(false)
|
||||||
|
const [frisch, setFrisch] = useState<{ name: string; klartext: string } | null>(null)
|
||||||
|
|
||||||
|
function anlegen(daten: FormData) {
|
||||||
|
starte(async () => {
|
||||||
|
const antwort = await tokenErzeugen(daten)
|
||||||
|
if (antwort.ok) {
|
||||||
|
setFelder({})
|
||||||
|
setOffen(false)
|
||||||
|
setFrisch({ name: antwort.name, klartext: antwort.klartext })
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
setFelder(antwort.felder ?? {})
|
||||||
|
toast.error(antwort.fehler)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function zurueckziehen(id: string) {
|
||||||
|
starte(async () => {
|
||||||
|
const daten = new FormData()
|
||||||
|
daten.set('id', id)
|
||||||
|
const antwort = await tokenZuruecknehmen(daten)
|
||||||
|
if (antwort.ok) {
|
||||||
|
toast.success(antwort.meldung)
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
toast.error(antwort.fehler)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{/* Der frisch erzeugte Token – einmalig */}
|
||||||
|
{frisch && (
|
||||||
|
<div className="border-betont bg-betont/5 rounded-md border p-4">
|
||||||
|
<p className="text-sm font-medium">„{frisch.name}“ ist angelegt.</p>
|
||||||
|
<p className="text-gedaempft-vordergrund mt-1 text-xs">
|
||||||
|
Jetzt kopieren. Sobald du diese Seite verlässt, ist der Wert weg — in der
|
||||||
|
Datenbank liegt nur sein Fingerabdruck.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
<code className="border-rand bg-karte grow overflow-x-auto rounded-md border px-3 py-2 font-mono text-xs">
|
||||||
|
{frisch.klartext}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
art="umriss"
|
||||||
|
onClick={() => {
|
||||||
|
void navigator.clipboard
|
||||||
|
.writeText(frisch.klartext)
|
||||||
|
.then(() => toast.success('Kopiert.'))
|
||||||
|
.catch(() => toast.error('Das Kopieren hat nicht geklappt.'))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy aria-hidden />
|
||||||
|
Kopieren
|
||||||
|
</Button>
|
||||||
|
<Button art="leise" onClick={() => setFrisch(null)}>
|
||||||
|
Fertig
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-gedaempft-vordergrund mt-3 text-xs">
|
||||||
|
Ausprobieren:{' '}
|
||||||
|
<code className="text-xs">
|
||||||
|
curl -H "Authorization: Bearer …" {adresse}/api/v1/selbst
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Liste */}
|
||||||
|
{tokens.length === 0 ? (
|
||||||
|
<p className="text-gedaempft-vordergrund text-sm">
|
||||||
|
Noch kein Token vergeben. Ohne Token kommt von außen nichts herein.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-rand divide-y">
|
||||||
|
{tokens.map((token) => (
|
||||||
|
<li key={token.id} className="flex flex-wrap items-center gap-3 py-3">
|
||||||
|
<KeyRound
|
||||||
|
className={
|
||||||
|
token.widerrufen
|
||||||
|
? 'text-gedaempft-vordergrund size-4 shrink-0'
|
||||||
|
: 'text-erfolg size-4 shrink-0'
|
||||||
|
}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<span className={token.widerrufen ? 'line-through' : ''}>
|
||||||
|
{token.name}
|
||||||
|
</span>
|
||||||
|
<code className="text-gedaempft-vordergrund text-xs">
|
||||||
|
planer_{token.praefix}_…
|
||||||
|
</code>
|
||||||
|
{token.widerrufen && (
|
||||||
|
<Kennzeichen art="neutral">Zurückgezogen</Kennzeichen>
|
||||||
|
)}
|
||||||
|
{token.rechte.map((r) => (
|
||||||
|
<Kennzeichen key={r} art="betont">
|
||||||
|
{RECHT_NAME[r]}
|
||||||
|
</Kennzeichen>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
<p className="text-gedaempft-vordergrund text-xs">
|
||||||
|
Angelegt {token.erstelltAm}
|
||||||
|
{token.gueltigBis ? ` · gilt bis ${token.gueltigBis}` : ''}
|
||||||
|
{token.letzteNutzung
|
||||||
|
? ` · zuletzt benutzt ${token.letzteNutzung}`
|
||||||
|
: ' · noch nie benutzt'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!token.widerrufen && (
|
||||||
|
<Button
|
||||||
|
art="leise"
|
||||||
|
groesse="quadrat"
|
||||||
|
title="Zurückziehen"
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() => zurueckziehen(token.id)}
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden />
|
||||||
|
<span className="sr-only">Zurückziehen</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Neu */}
|
||||||
|
{offen ? (
|
||||||
|
<form
|
||||||
|
className="border-rand flex flex-col gap-4 rounded-md border p-4"
|
||||||
|
action={anlegen}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="name">Wofür ist der Token?</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
placeholder="z. B. Wartungsplaner-Import"
|
||||||
|
aria-invalid={Boolean(felder.name)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{felder.name && <p className="text-gefahr text-xs">{felder.name}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset className="flex flex-col gap-3">
|
||||||
|
<legend className="mb-1 text-sm font-medium">Was darf er?</legend>
|
||||||
|
{RECHTE.map((recht) => (
|
||||||
|
<label key={recht} className="flex items-start gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="rechte"
|
||||||
|
value={recht}
|
||||||
|
className="accent-betont mt-0.5 size-4"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="text-sm">{RECHT_NAME[recht]}</span>
|
||||||
|
<span className="text-gedaempft-vordergrund block text-xs">
|
||||||
|
{RECHT_BESCHREIBUNG[recht]}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{felder.rechte && <p className="text-gefahr text-xs">{felder.rechte}</p>}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="gueltigTage">Gültig für (Tage)</Label>
|
||||||
|
<Input
|
||||||
|
id="gueltigTage"
|
||||||
|
name="gueltigTage"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={3650}
|
||||||
|
placeholder="leer = unbegrenzt"
|
||||||
|
aria-invalid={Boolean(felder.gueltigTage)}
|
||||||
|
/>
|
||||||
|
<p className="text-gedaempft-vordergrund text-xs">
|
||||||
|
Ein Token mit Ablauf ist einer, den man nicht vergisst.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="submit" disabled={laeuft}>
|
||||||
|
{laeuft && <Loader2 className="animate-spin" aria-hidden />}
|
||||||
|
Anlegen
|
||||||
|
</Button>
|
||||||
|
<Button type="button" art="leise" onClick={() => setOffen(false)}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<Button art="umriss" onClick={() => setOffen(true)}>
|
||||||
|
<Plus aria-hidden />
|
||||||
|
Token anlegen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||