diff --git a/CHANGELOG.md b/CHANGELOG.md index 61b5963..cff08f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,35 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/). ## [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 diff --git a/README.md b/README.md index 8056d0e..d9d5393 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,10 @@ wird. - **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 Passwort — je Instanz einzeln zuschaltbar - Rollen `Benutzer` und `Verwaltung`; jede schreibende Aktion wird @@ -49,8 +53,7 @@ wird. ### Geplant -Schnellerfassung vom Handy, eine öffentliche API und ein MCP-Server. -Siehe [ROADMAP.md](ROADMAP.md). +Ein MCP-Server. Siehe [ROADMAP.md](ROADMAP.md). ## Bildergalerie diff --git a/ROADMAP.md b/ROADMAP.md index 4b4f711..026814a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,7 +11,7 @@ grünen Prüfungen, aktueller Dokumentation und einem Eintrag im DEVLOG. | [M4](#m4--wochenplanung-und-zeitblöcke) | Wochenplanung und Zeitblöcke | erledigt | | [M5](#m5--benachrichtigungen) | Benachrichtigungen | erledigt | | [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 | --- @@ -134,9 +134,25 @@ im Ticketsystem — sonst weiß die Kollegin nicht, warum es plötzlich zu ist. ## M7 – Schnellerfassung und API -- [ ] Erfassung vom Handy, auch offline -- [ ] Öffentliche API mit Token je Anwendung -- [ ] Rechte je Token, jede Nutzung im Protokoll +**Erledigt am 12.09.2026 mit Version 0.7.0.** + +**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 diff --git a/devlog/DEVLOG.md b/devlog/DEVLOG.md index 70a36a8..eab765b 100644 --- a/devlog/DEVLOG.md +++ b/devlog/DEVLOG.md @@ -13,7 +13,108 @@ die Übersicht aus `scripts/zeit.sh summe`. | M4 | claude | 167 | 2,78 | | M5 | claude | 420 | 7,00 | | M6 | claude | 733 | 12,22 | -| **Gesamt** | | **1566** | **26,10** | +| 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 --- diff --git a/devlog/zeiten.csv b/devlog/zeiten.csv index cf5cdc3..5db23a6 100644 --- a/devlog/zeiten.csv +++ b/devlog/zeiten.csv @@ -5,3 +5,4 @@ datum;start;ende;minuten;meilenstein;art;notiz 2026-09-10;20:42;23:29;167;M4;claude;M4 Wochenplanung fertig 2026-09-11;06:35;13:35;420;M5;claude;M5 Benachrichtigungen fertig 2026-09-11;20:47;09:00;733;M6;claude;M6 Integrationen fertig +2026-09-12;10:15;01:01;886;M7;claude;M7 Schnellerfassung und API fertig diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..c273567 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,158 @@ +# Öffentliche Schnittstelle + +Für fremde Anwendungen, Skripte und Automaten. Wer den Planer selbst bedient, +braucht sie nicht. + +Basisadresse: `/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/` · Recht `lesen` + +### `PATCH /aufgaben/` · 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. diff --git a/docs/bilder/anmelden-dunkel-handy.png b/docs/bilder/anmelden-dunkel-handy.png index 4b0ee00..d070f6a 100644 Binary files a/docs/bilder/anmelden-dunkel-handy.png and b/docs/bilder/anmelden-dunkel-handy.png differ diff --git a/docs/bilder/anmelden-dunkel-schreibtisch.png b/docs/bilder/anmelden-dunkel-schreibtisch.png index 5615e64..c28f172 100644 Binary files a/docs/bilder/anmelden-dunkel-schreibtisch.png and b/docs/bilder/anmelden-dunkel-schreibtisch.png differ diff --git a/docs/bilder/anmelden-hell-handy.png b/docs/bilder/anmelden-hell-handy.png index 0edadad..4e8bf0f 100644 Binary files a/docs/bilder/anmelden-hell-handy.png and b/docs/bilder/anmelden-hell-handy.png differ diff --git a/docs/bilder/anmelden-hell-schreibtisch.png b/docs/bilder/anmelden-hell-schreibtisch.png index 31ccffb..8235e24 100644 Binary files a/docs/bilder/anmelden-hell-schreibtisch.png and b/docs/bilder/anmelden-hell-schreibtisch.png differ diff --git a/docs/bilder/api-dunkel-handy.png b/docs/bilder/api-dunkel-handy.png new file mode 100644 index 0000000..62b8b4d Binary files /dev/null and b/docs/bilder/api-dunkel-handy.png differ diff --git a/docs/bilder/api-dunkel-schreibtisch.png b/docs/bilder/api-dunkel-schreibtisch.png new file mode 100644 index 0000000..8c1ba3a Binary files /dev/null and b/docs/bilder/api-dunkel-schreibtisch.png differ diff --git a/docs/bilder/api-hell-handy.png b/docs/bilder/api-hell-handy.png new file mode 100644 index 0000000..065adc4 Binary files /dev/null and b/docs/bilder/api-hell-handy.png differ diff --git a/docs/bilder/api-hell-schreibtisch.png b/docs/bilder/api-hell-schreibtisch.png new file mode 100644 index 0000000..9c9dc23 Binary files /dev/null and b/docs/bilder/api-hell-schreibtisch.png differ diff --git a/docs/bilder/aufgaben-dunkel-handy.png b/docs/bilder/aufgaben-dunkel-handy.png index bc87b7c..ba67d8b 100644 Binary files a/docs/bilder/aufgaben-dunkel-handy.png and b/docs/bilder/aufgaben-dunkel-handy.png differ diff --git a/docs/bilder/aufgaben-dunkel-schreibtisch.png b/docs/bilder/aufgaben-dunkel-schreibtisch.png index 7021df2..20a236d 100644 Binary files a/docs/bilder/aufgaben-dunkel-schreibtisch.png and b/docs/bilder/aufgaben-dunkel-schreibtisch.png differ diff --git a/docs/bilder/aufgaben-hell-handy.png b/docs/bilder/aufgaben-hell-handy.png index a12ba06..ce3c3a3 100644 Binary files a/docs/bilder/aufgaben-hell-handy.png and b/docs/bilder/aufgaben-hell-handy.png differ diff --git a/docs/bilder/aufgaben-hell-schreibtisch.png b/docs/bilder/aufgaben-hell-schreibtisch.png index 085e16b..c757eae 100644 Binary files a/docs/bilder/aufgaben-hell-schreibtisch.png and b/docs/bilder/aufgaben-hell-schreibtisch.png differ diff --git a/docs/bilder/bereiche-dunkel-handy.png b/docs/bilder/bereiche-dunkel-handy.png index f2a031a..3d7f01e 100644 Binary files a/docs/bilder/bereiche-dunkel-handy.png and b/docs/bilder/bereiche-dunkel-handy.png differ diff --git a/docs/bilder/bereiche-dunkel-schreibtisch.png b/docs/bilder/bereiche-dunkel-schreibtisch.png index 9782056..57edbcc 100644 Binary files a/docs/bilder/bereiche-dunkel-schreibtisch.png and b/docs/bilder/bereiche-dunkel-schreibtisch.png differ diff --git a/docs/bilder/bereiche-hell-handy.png b/docs/bilder/bereiche-hell-handy.png index ce2e811..f9f65a0 100644 Binary files a/docs/bilder/bereiche-hell-handy.png and b/docs/bilder/bereiche-hell-handy.png differ diff --git a/docs/bilder/bereiche-hell-schreibtisch.png b/docs/bilder/bereiche-hell-schreibtisch.png index 8646123..9c5e269 100644 Binary files a/docs/bilder/bereiche-hell-schreibtisch.png and b/docs/bilder/bereiche-hell-schreibtisch.png differ diff --git a/docs/bilder/einstellungen-dunkel-handy.png b/docs/bilder/einstellungen-dunkel-handy.png index 05484d9..f20cbbd 100644 Binary files a/docs/bilder/einstellungen-dunkel-handy.png and b/docs/bilder/einstellungen-dunkel-handy.png differ diff --git a/docs/bilder/einstellungen-dunkel-schreibtisch.png b/docs/bilder/einstellungen-dunkel-schreibtisch.png index 15a3d13..14a63da 100644 Binary files a/docs/bilder/einstellungen-dunkel-schreibtisch.png and b/docs/bilder/einstellungen-dunkel-schreibtisch.png differ diff --git a/docs/bilder/einstellungen-hell-handy.png b/docs/bilder/einstellungen-hell-handy.png index 216fd0c..fcc4b08 100644 Binary files a/docs/bilder/einstellungen-hell-handy.png and b/docs/bilder/einstellungen-hell-handy.png differ diff --git a/docs/bilder/einstellungen-hell-schreibtisch.png b/docs/bilder/einstellungen-hell-schreibtisch.png index 051ad1e..2cd0142 100644 Binary files a/docs/bilder/einstellungen-hell-schreibtisch.png and b/docs/bilder/einstellungen-hell-schreibtisch.png differ diff --git a/docs/bilder/erfassen-dunkel-handy.png b/docs/bilder/erfassen-dunkel-handy.png new file mode 100644 index 0000000..865c04a Binary files /dev/null and b/docs/bilder/erfassen-dunkel-handy.png differ diff --git a/docs/bilder/erfassen-dunkel-schreibtisch.png b/docs/bilder/erfassen-dunkel-schreibtisch.png new file mode 100644 index 0000000..c9bf7db Binary files /dev/null and b/docs/bilder/erfassen-dunkel-schreibtisch.png differ diff --git a/docs/bilder/erfassen-hell-handy.png b/docs/bilder/erfassen-hell-handy.png new file mode 100644 index 0000000..e0a8f3e Binary files /dev/null and b/docs/bilder/erfassen-hell-handy.png differ diff --git a/docs/bilder/erfassen-hell-schreibtisch.png b/docs/bilder/erfassen-hell-schreibtisch.png new file mode 100644 index 0000000..5c6f2df Binary files /dev/null and b/docs/bilder/erfassen-hell-schreibtisch.png differ diff --git a/docs/bilder/ideen-dunkel-handy.png b/docs/bilder/ideen-dunkel-handy.png index c2a3d8e..567896e 100644 Binary files a/docs/bilder/ideen-dunkel-handy.png and b/docs/bilder/ideen-dunkel-handy.png differ diff --git a/docs/bilder/ideen-dunkel-schreibtisch.png b/docs/bilder/ideen-dunkel-schreibtisch.png index a632d23..6d4751f 100644 Binary files a/docs/bilder/ideen-dunkel-schreibtisch.png and b/docs/bilder/ideen-dunkel-schreibtisch.png differ diff --git a/docs/bilder/ideen-hell-handy.png b/docs/bilder/ideen-hell-handy.png index 2e03b90..72e8abe 100644 Binary files a/docs/bilder/ideen-hell-handy.png and b/docs/bilder/ideen-hell-handy.png differ diff --git a/docs/bilder/ideen-hell-schreibtisch.png b/docs/bilder/ideen-hell-schreibtisch.png index 4e6705f..3954440 100644 Binary files a/docs/bilder/ideen-hell-schreibtisch.png and b/docs/bilder/ideen-hell-schreibtisch.png differ diff --git a/docs/bilder/inbox-dunkel-handy.png b/docs/bilder/inbox-dunkel-handy.png index 17484c0..d063d99 100644 Binary files a/docs/bilder/inbox-dunkel-handy.png and b/docs/bilder/inbox-dunkel-handy.png differ diff --git a/docs/bilder/inbox-dunkel-schreibtisch.png b/docs/bilder/inbox-dunkel-schreibtisch.png index aed3eed..a04ee0c 100644 Binary files a/docs/bilder/inbox-dunkel-schreibtisch.png and b/docs/bilder/inbox-dunkel-schreibtisch.png differ diff --git a/docs/bilder/inbox-hell-handy.png b/docs/bilder/inbox-hell-handy.png index c9abefd..2e3f64c 100644 Binary files a/docs/bilder/inbox-hell-handy.png and b/docs/bilder/inbox-hell-handy.png differ diff --git a/docs/bilder/inbox-hell-schreibtisch.png b/docs/bilder/inbox-hell-schreibtisch.png index 0d7fc3c..f32cd6f 100644 Binary files a/docs/bilder/inbox-hell-schreibtisch.png and b/docs/bilder/inbox-hell-schreibtisch.png differ diff --git a/docs/bilder/integrationen-dunkel-handy.png b/docs/bilder/integrationen-dunkel-handy.png index 879fe51..71d5c53 100644 Binary files a/docs/bilder/integrationen-dunkel-handy.png and b/docs/bilder/integrationen-dunkel-handy.png differ diff --git a/docs/bilder/integrationen-dunkel-schreibtisch.png b/docs/bilder/integrationen-dunkel-schreibtisch.png index 06d6d0f..5ab211f 100644 Binary files a/docs/bilder/integrationen-dunkel-schreibtisch.png and b/docs/bilder/integrationen-dunkel-schreibtisch.png differ diff --git a/docs/bilder/integrationen-hell-handy.png b/docs/bilder/integrationen-hell-handy.png index 997dee7..a77e7aa 100644 Binary files a/docs/bilder/integrationen-hell-handy.png and b/docs/bilder/integrationen-hell-handy.png differ diff --git a/docs/bilder/integrationen-hell-schreibtisch.png b/docs/bilder/integrationen-hell-schreibtisch.png index 1f37fc2..f0e440d 100644 Binary files a/docs/bilder/integrationen-hell-schreibtisch.png and b/docs/bilder/integrationen-hell-schreibtisch.png differ diff --git a/docs/bilder/kalenderkonten-dunkel-handy.png b/docs/bilder/kalenderkonten-dunkel-handy.png index 4c16933..10d644b 100644 Binary files a/docs/bilder/kalenderkonten-dunkel-handy.png and b/docs/bilder/kalenderkonten-dunkel-handy.png differ diff --git a/docs/bilder/kalenderkonten-dunkel-schreibtisch.png b/docs/bilder/kalenderkonten-dunkel-schreibtisch.png index ffd14b7..0c1c2e8 100644 Binary files a/docs/bilder/kalenderkonten-dunkel-schreibtisch.png and b/docs/bilder/kalenderkonten-dunkel-schreibtisch.png differ diff --git a/docs/bilder/kalenderkonten-hell-handy.png b/docs/bilder/kalenderkonten-hell-handy.png index 7582588..bbb532e 100644 Binary files a/docs/bilder/kalenderkonten-hell-handy.png and b/docs/bilder/kalenderkonten-hell-handy.png differ diff --git a/docs/bilder/kalenderkonten-hell-schreibtisch.png b/docs/bilder/kalenderkonten-hell-schreibtisch.png index f23fa91..48ebc69 100644 Binary files a/docs/bilder/kalenderkonten-hell-schreibtisch.png and b/docs/bilder/kalenderkonten-hell-schreibtisch.png differ diff --git a/docs/bilder/projekte-dunkel-handy.png b/docs/bilder/projekte-dunkel-handy.png index b5bc992..81867d5 100644 Binary files a/docs/bilder/projekte-dunkel-handy.png and b/docs/bilder/projekte-dunkel-handy.png differ diff --git a/docs/bilder/projekte-dunkel-schreibtisch.png b/docs/bilder/projekte-dunkel-schreibtisch.png index 454524b..7446025 100644 Binary files a/docs/bilder/projekte-dunkel-schreibtisch.png and b/docs/bilder/projekte-dunkel-schreibtisch.png differ diff --git a/docs/bilder/projekte-hell-handy.png b/docs/bilder/projekte-hell-handy.png index 29c0bec..a69a8be 100644 Binary files a/docs/bilder/projekte-hell-handy.png and b/docs/bilder/projekte-hell-handy.png differ diff --git a/docs/bilder/projekte-hell-schreibtisch.png b/docs/bilder/projekte-hell-schreibtisch.png index 49f49bc..d3bccda 100644 Binary files a/docs/bilder/projekte-hell-schreibtisch.png and b/docs/bilder/projekte-hell-schreibtisch.png differ diff --git a/docs/bilder/protokoll-dunkel-handy.png b/docs/bilder/protokoll-dunkel-handy.png index 50de351..3b402c9 100644 Binary files a/docs/bilder/protokoll-dunkel-handy.png and b/docs/bilder/protokoll-dunkel-handy.png differ diff --git a/docs/bilder/protokoll-dunkel-schreibtisch.png b/docs/bilder/protokoll-dunkel-schreibtisch.png index c24ac35..cb6f31f 100644 Binary files a/docs/bilder/protokoll-dunkel-schreibtisch.png and b/docs/bilder/protokoll-dunkel-schreibtisch.png differ diff --git a/docs/bilder/protokoll-hell-handy.png b/docs/bilder/protokoll-hell-handy.png index 6e1eabd..9fed19d 100644 Binary files a/docs/bilder/protokoll-hell-handy.png and b/docs/bilder/protokoll-hell-handy.png differ diff --git a/docs/bilder/protokoll-hell-schreibtisch.png b/docs/bilder/protokoll-hell-schreibtisch.png index 0faab38..ffc92b7 100644 Binary files a/docs/bilder/protokoll-hell-schreibtisch.png and b/docs/bilder/protokoll-hell-schreibtisch.png differ diff --git a/docs/bilder/start-dunkel-handy.png b/docs/bilder/start-dunkel-handy.png index 2646f48..53fca10 100644 Binary files a/docs/bilder/start-dunkel-handy.png and b/docs/bilder/start-dunkel-handy.png differ diff --git a/docs/bilder/start-dunkel-schreibtisch.png b/docs/bilder/start-dunkel-schreibtisch.png index 7ce8ae1..4f19f75 100644 Binary files a/docs/bilder/start-dunkel-schreibtisch.png and b/docs/bilder/start-dunkel-schreibtisch.png differ diff --git a/docs/bilder/start-hell-handy.png b/docs/bilder/start-hell-handy.png index 346ee60..ef60e5a 100644 Binary files a/docs/bilder/start-hell-handy.png and b/docs/bilder/start-hell-handy.png differ diff --git a/docs/bilder/start-hell-schreibtisch.png b/docs/bilder/start-hell-schreibtisch.png index a6448dd..c81d33a 100644 Binary files a/docs/bilder/start-hell-schreibtisch.png and b/docs/bilder/start-hell-schreibtisch.png differ diff --git a/docs/bilder/woche-dunkel-handy.png b/docs/bilder/woche-dunkel-handy.png index 84653b8..ac53daa 100644 Binary files a/docs/bilder/woche-dunkel-handy.png and b/docs/bilder/woche-dunkel-handy.png differ diff --git a/docs/bilder/woche-dunkel-schreibtisch.png b/docs/bilder/woche-dunkel-schreibtisch.png index cfb976e..623dbe6 100644 Binary files a/docs/bilder/woche-dunkel-schreibtisch.png and b/docs/bilder/woche-dunkel-schreibtisch.png differ diff --git a/docs/bilder/woche-hell-handy.png b/docs/bilder/woche-hell-handy.png index ec62a6b..9f6dbd0 100644 Binary files a/docs/bilder/woche-hell-handy.png and b/docs/bilder/woche-hell-handy.png differ diff --git a/docs/bilder/woche-hell-schreibtisch.png b/docs/bilder/woche-hell-schreibtisch.png index 0147196..4c5a479 100644 Binary files a/docs/bilder/woche-hell-schreibtisch.png and b/docs/bilder/woche-hell-schreibtisch.png differ diff --git a/docs/handbuch/README.md b/docs/handbuch/README.md index ba80942..efd439f 100644 --- a/docs/handbuch/README.md +++ b/docs/handbuch/README.md @@ -13,34 +13,36 @@ Anleitung für den Alltag mit dem Planer. Eine Seite je Thema. 3. **[Aufgaben erfassen und wiederfinden](aufgaben.md)** – Schnellerfassung, Inbox leeren, Filter, Status und Fälligkeit -4. **[Ideen](ideen.md)** – festhalten, was noch keine Aufgabe ist -5. **[Projekte](projekte.md)** – mehrere Aufgaben unter einem Ziel, mit +4. **[Schnellerfassung vom Handy](erfassen.md)** – als App ablegen, auch + ohne Netz erfassen +5. **[Ideen](ideen.md)** – festhalten, was noch keine Aufgabe ist +6. **[Projekte](projekte.md)** – mehrere Aufgaben unter einem Ziel, mit Fortschritt -6. **[Kalenderkonten](kalenderkonten.md)** – Microsoft 365 und Google +7. **[Kalenderkonten](kalenderkonten.md)** – Microsoft 365 und Google 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 -8. **[Benachrichtigungen](benachrichtigungen.md)** – Push, E-Mail und +9. **[Benachrichtigungen](benachrichtigungen.md)** – Push, E-Mail und Telegram einrichten, Ruhezeiten setzen -9. **[Integrationen](integrationen.md)** – Zammad, Telegram und der Spiegel - zwischen Firma und Daheim +10. **[Integrationen](integrationen.md)** – Zammad, Telegram und der Spiegel + zwischen Firma und Daheim ## Verwaltung -10. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat +11. **[Protokoll](protokoll.md)** – nachsehen, wer wann was geändert hat ## Was noch kommt Diese Seiten entstehen mit den nächsten Meilensteinen: -| Thema | Meilenstein | -| ------------------------------- | ----------- | -| Schnellerfassung vom Handy, API | M7 | -| MCP | M8 | +| Thema | Meilenstein | +| ----- | ----------- | +| MCP | M8 | 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). Für Betrieb, Sicherung und alle Umgebungsvariablen: [README](../../README.md). diff --git a/docs/handbuch/erfassen.md b/docs/handbuch/erfassen.md new file mode 100644 index 0000000..0478259 --- /dev/null +++ b/docs/handbuch/erfassen.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. + +![Die Seite Erfassen](../bilder/erfassen-hell-handy.png) + +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). diff --git a/e2e/anmeldung.spec.ts b/e2e/anmeldung.spec.ts index ed2620f..d3231ef 100644 --- a/e2e/anmeldung.spec.ts +++ b/e2e/anmeldung.spec.ts @@ -50,7 +50,11 @@ test.describe('Anmeldung', () => { await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click() 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 page.getByTitle('Abmelden').click() @@ -64,6 +68,8 @@ test.describe('Anmeldung', () => { await page.getByRole('button', { name: 'Mit Passwort anmelden' }).click() 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 expect(page.getByRole('heading', { name: 'Protokoll' })).toBeVisible() }) diff --git a/e2e/api.spec.ts b/e2e/api.spec.ts new file mode 100644 index 0000000..aece00f --- /dev/null +++ b/e2e/api.spec.ts @@ -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 { + 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() + }) +}) diff --git a/e2e/aufgaben.spec.ts b/e2e/aufgaben.spec.ts index e8c5175..d23a35c 100644 --- a/e2e/aufgaben.spec.ts +++ b/e2e/aufgaben.spec.ts @@ -52,6 +52,10 @@ test.describe('Aufgaben', () => { await zeile.getByLabel('Einsortieren nach').selectOption(bereich!.id) 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. await page.goto('/inbox') await expect(page.locator('li').filter({ hasText: titel })).toHaveCount(0) diff --git a/package-lock.json b/package-lock.json index 85dd85a..ca23b0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "planer", - "version": "0.6.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "planer", - "version": "0.6.0", + "version": "0.7.0", "dependencies": { "@auth/prisma-adapter": "2.11.3", "@date-fns/tz": "1.5.0", diff --git a/package.json b/package.json index 79dac33..605e9cb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "planer", - "version": "0.6.0", + "version": "0.7.0", "private": true, "description": "Aufgaben, Ideen, Projekte und Zeitblöcke mit Anbindung an Microsoft 365 und Google Kalender", "type": "module", diff --git a/prisma/demo-seed.ts b/prisma/demo-seed.ts index 4830f74..3a27903 100644 --- a/prisma/demo-seed.ts +++ b/prisma/demo-seed.ts @@ -489,6 +489,44 @@ async function main(): Promise { }) } + // --- 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}`) } diff --git a/prisma/migrations/20260912082447_api_token/migration.sql b/prisma/migrations/20260912082447_api_token/migration.sql new file mode 100644 index 0000000..e41c224 --- /dev/null +++ b/prisma/migrations/20260912082447_api_token/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 772bf88..3c64c3d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -39,6 +39,7 @@ model Benutzer { pushGeraete PushGeraet[] meldungen Meldungsnachweis[] telegramChats TelegramChat[] + apiTokens ApiToken[] @@index([aktiv]) @@map("benutzer") @@ -538,3 +539,34 @@ model TelegramKopplung { @@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") +} diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..53b356e --- /dev/null +++ b/public/manifest.webmanifest @@ -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": "/" + } + ] +} diff --git a/public/sw.js b/public/sw.js index bbe231e..18c2d14 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,18 +1,72 @@ /* * Service Worker des Planers. * - * Er tut genau eine Sache: Push-Nachrichten anzeigen und beim Klick die - * richtige Seite öffnen. Kein Zwischenspeichern von Seiten — ein halb - * veralteter Planer wäre schlimmer als gar keiner. + * Zwei Aufgaben: Push-Nachrichten anzeigen, und die Seite "Erfassen" auch + * ohne Netz bereithalten. + * + * 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. self.skipWaiting() }) 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) => { diff --git a/public/symbol-flaeche.svg b/public/symbol-flaeche.svg new file mode 100644 index 0000000..a492338 --- /dev/null +++ b/public/symbol-flaeche.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/public/symbol.svg b/public/symbol.svg new file mode 100644 index 0000000..6e03d02 --- /dev/null +++ b/public/symbol.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/screenshots/bilder.spec.ts b/screenshots/bilder.spec.ts index da824ef..9f412cc 100644 --- a/screenshots/bilder.spec.ts +++ b/screenshots/bilder.spec.ts @@ -37,6 +37,7 @@ const AUFNAHMEN: Aufnahme[] = [ { name: 'anmelden', pfad: '/anmelden' }, { name: 'start', pfad: '/' }, { name: 'inbox', pfad: '/inbox' }, + { name: 'erfassen', pfad: '/erfassen' }, { name: 'aufgaben', pfad: '/aufgaben' }, { name: 'woche', pfad: '/woche' }, { name: 'projekte', pfad: '/projekte' }, @@ -50,6 +51,7 @@ const AUFNAHMEN: Aufnahme[] = [ pfad: '/einstellungen', ausschnitt: '[data-bild="benachrichtigungen"]', }, + { name: 'api', pfad: '/verwaltung/api' }, { name: 'protokoll', pfad: '/verwaltung/protokoll' }, ] diff --git a/scripts/integrationstest.ts b/scripts/integrationstest.ts index 889f400..b02d55a 100644 --- a/scripts/integrationstest.ts +++ b/scripts/integrationstest.ts @@ -67,6 +67,7 @@ async function aufraeumen(): Promise { }) 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 { @@ -395,6 +396,43 @@ async function main(): Promise { }), ) + // --- 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 } }) const nachher = await db.auditEintrag.findUnique({ where: { id: audit.id } }) @@ -420,6 +458,10 @@ async function main(): Promise { 'Push-Geräte verschwinden mit dem Benutzer', (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, diff --git a/src/app/(app)/erfassen/erfassung.tsx b/src/app/(app)/erfassen/erfassung.tsx new file mode 100644 index 0000000..634bf71 --- /dev/null +++ b/src/app/(app)/erfassen/erfassung.tsx @@ -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([]) + // Vor der Hydratation weiss der Server nichts vom Netz des Geraets; + // "null" heisst schlicht: noch nicht nachgesehen. + const [online, setOnline] = useState(null) + const feld = useRef(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 ( +
+
void erfassen(e)} className="flex flex-col gap-3"> + +