Files
KC-APP/plan-kcAppMultiTenantPlatform.prompt.md
T
2026-09-10 11:46:39 +02:00

23 KiB
Raw Blame History

Plan: KC-App Multi-Tenant Event-, Wahl- und Kommunikationsplattform

Neuentwicklung, die das WordPress-Plugin Workshop-Wahlen (Wahlen/Workshops/Teilnehmer/Zuteilungslogik) ablöst und um Rollen-/Rechteverwaltung via Authentik, gestaffelte Dateifreigabe, mehrstufigen Chat und eine Hybrid-Server-Architektur (online + lokal mit Sync) erweitert. Backend: NestJS 10 + PostgreSQL + Prisma 5. Client: Flutter (eine Codebase für Mobile, Web, Desktop) — bis Flutter verfügbar ist, liefert das Backend selbst einen minimalen Platzhalter-Web-Client aus.

Status: Backend-Phasen 06 komplett plus lokale Teamer-Accounts/Invites, Gemeinde-CRUD, Verantwortlichen-Selbstregistrierung (onboarding/), Authentik-JIT + LT-aus-groups, mail/-Modul. Erste Prisma-Migration vorhanden, Backend lief Ende-zu-Ende gegen lokales PostgreSQL 16 (npm test: 56). Phase 7 (Flutter) begonnen: client/app/ — eine Codebase, Web-Target aktiv, Login/Home/Wahl/Dateien/Chat(read-only); flutter build web + flutter test grün. Offen: Mobile/Desktop-Targets, Authentik-Auth-Code-Flow im Client, WS-Chat-Senden, LT-Admin-Screens, Push, echte Authentik/Nextcloud-Infra.


1. Domänenmodell

  • KC (Konfi-Castle-Event) = oberster Mandant. Eine App-Instanz verwaltet mehrere KCs parallel. Felder: name, inviteCode (eindeutig, Basis für QR/Code-Einstieg), isActive.
  • Gemeinde: lokale Gemeinde/Kirchengemeinde innerhalb eines KC (kcId + name, eindeutig pro KC).
  • Rollenmodell (Enum Role, Authentik-gestützt):
    • Leitungsteam (LT) global über alle KCs hinweg. Wird bei jedem Authentik-Login aus dem groups-Claim des Tokens abgeglichen (Gruppenname aus AUTHENTIK_LEITUNGSTEAM_GROUP) und als User.isLeitungsteam gespeichert; die Auth-Schicht synthetisiert daraus eine virtuelle globale LT-Membership. Kein Membership-Row nötig. Fällt die Gruppenmitgliedschaft weg, ist man beim nächsten Login kein LT mehr.
    • Gemeinde Verantwortliche pro Gemeinde/KC; verwalten nur Nutzer ihrer eigenen Gemeinde (legen Teamer an); Wahlen/Workshops macht ausschließlich LT; zudem Authentik Gruppe und Benutzer.
    • Gemeinde Teamer von Verantwortlichen angelegt, ebenfalls Lokaler Account.
    • Guest/Konfi optionaler, rein lokaler Account auf dem jeweiligen Server (kein Authentik), Vor-/Nachname Pflicht, temporär pro KC/Event (nicht wiederverwendbar über mehrere Events).
  • Membership: verknüpft UserKc (+ optional Gemeinde) ⇄ Role, mit status (ACTIVE/PENDING). Nur für GEMEINDE_VERANTWORTLICHER/GEMEINDE_TEAMER — LT läuft über User.isLeitungsteam (s. o.). PENDING (aus der Selbstregistrierung) gewährt keine Rechte, bis ein LT sie genehmigt — die Auth-Strategien laden nur ACTIVE-Memberships.
  • Einstieg/Onboarding: LT vergibt pro KC einen Code/QR (enthält den KC-Key). Damit erhält man sofortigen Guest-Zugang oder kann sich vorab registrieren:
    • Gemeinde Verantwortliche/r: onboarding/-Modul — mit Konfi-Castle-ID (Authentik) einloggen, KC-Code + bestehende Gemeinde wählen → User wird JIT angelegt, Membership als PENDING; LT genehmigt.
    • Gemeinde Teamer: teamer/-Modul — von einer Verantwortliche/r direkt angelegt oder per Invite-Link/E-Mail-Invite selbst registriert (lokaler Account, sofort ACTIVE).
  • Wahl (Workshop-Wahl): von LT pro KC angelegt; Name trägt datumsSchluessel + teil (Bewusste Vereinfachung ggü. Original-Plugin: dort gibt es mehrere "Phasen" innerhalb einer Wahl via Teilnehmer.phase; hier ist stattdessen eine Wahl = ein Teil/Phase, gemäß expliziter Nutzer-Klarstellung).
    • Workshop: kapazitaet, minTeilnehmer (für Konsolidierung unterbesetzter Workshops).
    • Teilnehmer: Guest übermittelt prioritaeten (geordnete Workshop-ID-Liste, max. 3 entspricht wunsch1..wunsch3 im Original).
    • ForceZuteilung: manuelle LT-Override vor Algorithmus-Lauf, hat Vorrang.
    • Zuteilung: Ergebnis pro Teilnehmer (workshopId nullable = unzugeteilt, wunschRang, isForced).
  • Datei-Sichtbarkeit (Enum FileVisibility): ALLE / ALLE_AUSSER_KONFIS / NUR_LT. Dateien werden vom LT hochgeladen, teilbar je nach KC-übergreifend/eingeschränkt gemäß Sichtbarkeitsstufe.
  • Chat (Enum ChatChannelType): GEMEINDE_GRUPPE, DIREKT (1:1, explizite ChatParticipant-Zuordnung), LT_UEBERGREIFEND, BROADCAST (Konfis nur lesend).
  • Sync-Infrastruktur: SyncLogEntry (Append-only-Replikationslog: model, recordId, operation, payload, originId, autoincrement sequence) + SyncCursor (pro Peer: lastPushedSequence/lastPulledSequence).

Vollständiges Schema: backend/prisma/schema.prisma.


2. Architekturentscheidungen

Bereich Entscheidung Begründung
Backend NestJS 10 + PostgreSQL + Prisma 5 bestätigt vom Nutzer; Nest 10 statt CLI-Default (siehe unten)
Client Flutter, eine Codebase Mobile/Web/Desktop — client/app/, aktuell nur Web-Target aktiviert vom Nutzer delegiert; Flutter-SDK lokal via Homebrew installiert (nur Web-Toolchain, Android/iOS wegen Speicher weggelassen); lib/ ist plattformneutral, weitere Targets per flutter create --platforms=… nachrüstbar
Web-Auslieferung ServeStaticModule liefert primär den Flutter-Web-Build (client/app/build/web) auf :3000 aus, mit SPA-Fallback (u. a. für den OIDC-Redirect /v1/auth/callback); fällt auf client/web/ zurück, falls der Build fehlt. REST-API unter /api/*. Ein Origin für App + API; der registrierte Authentik-Redirect zeigt auf http://localhost:3000/v1/auth/callback
Auth (Team) Authentik als OIDC Resource Server (JWKS-Verifikation), kein lokaler Autorisierungscode-Flow im Backend. Client macht Authorization Code + PKCE (S256) direkt gegen Authentik (sso.konfi-castle.com, Public Client, oidc.dart), Backend validiert nur das Access-Token. Issuer-Trailing-Slash wird normalisiert (beide iss-Schreibweisen akzeptiert). Public Client kann kein Secret halten; PKCE genügt
Auth (Guest) Rein lokale JWTs (GUEST_JWT_SECRET), kein Authentik explizite Nutzervorgabe: Konfi-Accounts sind nie in Authentik
Auth (Gemeinde Teamer) Lokale Accounts: User mit passwordHash+kcId, authentikSub bleibt leer; eigenes JWT (TEAM_JWT_SECRET, Payload typ:'team'), Passwort-Login oder Invite-Redemption. Verantwortliche legen Teamer an (Direkt/Gruppen-Link/E-Mail-Invite) Nutzervorgabe: Teamer laufen nicht über die Konfi-Castle-ID (Authentik), sondern werden pro KC lokal verwaltet (wie Guests, nur dauerhaft + mit Rolle)
Datei-Storage Provider-Abstraktion (StorageProvider), Default Nextcloud/WebDAV, umschaltbar auf S3 via STORAGE_PROVIDER=s3 Nutzer bestätigte: Nextcloud-Zugangsdaten kommen aus .env
E-Mail Provider-Abstraktion (MailProvider), Default log-only (kein Versand), umschaltbar auf SMTP via MAIL_PROVIDER=smtp (nodemailer) Spiegelt das Storage-Muster; E-Mail ist best-effort und darf den Invite-Flow nie blockieren
Push Provider-Abstraktion (PushProvider), Default log-only, umschaltbar auf FCM HTTP v1 via PUSH_PROVIDER=fcm (Service-Account-JWT → OAuth-Token, ohne extra Dependency). Client: Firebase-Compat-SDK im index.html + firebase-messaging-sw.js, Token via POST /api/push/register Gleiches Muster wie Storage/E-Mail; Push ist best-effort und blockiert das Senden nie
Chat-Transport Raw ws-Gateway (@nestjs/platform-ws) statt Socket.IO Passt zum schlanken REST-Stack; Rollen/Sichtbarkeits-Logik zentral in ChatService, geteilt zwischen REST und WS
Sync-Richtung Lokaler Server initiiert immer Push und Pull gegen die Cloud-URL Cloud kann i. d. R. nicht in ein lokales Eventnetzwerk zurückwählen (NAT); lokaler Server kann aber ausgehend zur Cloud verbinden, wenn Internet verfügbar ist
Sync-Konflikte Keine Konfliktauflösung nötig Nutzer bestätigte explizit: lokaler Server ist während eines laufenden Events alleinige Quelle der Wahrheit
Rollen-Scope-Guard RolesGuard behandelt LEITUNGSTEAM-Memberships als global (kcId-Check wird übersprungen) Spiegelt die Anforderung "LT bleibt LT auf allen KCs" direkt in der Autorisierungslogik

Bekannte Einschränkungen / offene Punkte

  • Datei-Bytes werden nicht repliziert nur Metadaten (inkl. storageKey) laufen durchs Sync-Log. Lokaler und Cloud-Server müssen denselben Nextcloud/S3-Backend-Zugriff haben, damit ein storageKey auf beiden Seiten auflösbar ist.
  • Push-Benachrichtigungen: push/-Modul (FCM HTTP v1) + DeviceToken-Modell + POST /api/push/register/unregister; ChatService.sendMessage fächert die Nachricht best-effort an die Kanal-Zielgruppe. Default-Provider log-only. Für echten Versand fehlen: die Firebase-Web-Secrets (apiKey, appId, VAPID-Key) in client/app/web/index.html + firebase-messaging-sw.js, sowie backend-seitig ein Service-Account-JSON + PUSH_PROVIDER=fcm. APNs (natives iOS) ist nicht separat gebaut — läuft über FCM, sobald ein iOS-Target existiert.
  • Gemeinde-Verwaltung (CRUD): über GemeindeController (LT-only) verfügbar — Anlegen/Auflisten/Lesen/Umbenennen/Löschen von Gemeinden pro KC. Gemeinde Verantwortliche/Teamer erfahren ihre eigene Gemeinde weiterhin aus der Membership, nicht über diesen Endpunkt.
  • Authentik-Provisionierung: Jeder gültige Authentik-Login legt den lokalen User automatisch an (AuthentikStrategyresolveOrProvisionAuthentikUser, JIT, race-sicher) und setzt isLeitungsteam aus dem groups-Claim. Voraussetzung: der Authentik-Provider muss den groups-Claim ins Access-Token schreiben (Scope „groups" hinzufügen) und der LT-Gruppenname muss zu AUTHENTIK_LEITUNGSTEAM_GROUP passen — sonst wird niemand als LT erkannt. Gemeinde Verantwortliche brauchen weiterhin die onboarding/-Freigabe durch LT. (Gemeinde Teamer sind lokale Accounts, siehe teamer/ + auth/team-login.) Der Client-seitige PKCE-Flow (oidc.dart) ist gebaut, aber der volle Browser-Roundtrip ist noch nicht live getestet — dafür muss der genutzte Redirect (http://localhost:3000/v1/auth/callback bzw. die Prod-URL) am Authentik-Provider hinterlegt sein und ein Testaccount existieren.
  • Teamer-Identität ist E-Mail-basiert und global eindeutig: User.email ist instanzweit unique, d. h. dieselbe E-Mail kann nicht gleichzeitig Teamer in zwei KCs sein. Für den „pro KC wie Guests"-Fall in der Praxis unkritisch, aber dokumentiert.
  • E-Mail-Versand: mail/-Modul mit MailProvider-Abstraktion. Persönliche teamer-invites (mit email) werden verschickt; Default-Provider ist log-only (schreibt nur ins Log), echter Versand erst mit MAIL_PROVIDER=smtp + SMTP_*/MAIL_FROM. Onboarding-Benachrichtigungen an LT gibt es noch nicht.

3. Umgesetzte Backend-Module (Stand: alle Phasen abgeschlossen)

Modul Kernfunktion Wichtige Endpunkte
prisma/ Geteilter PrismaClient-Provider
auth/ Authentik-Resource-Server-Strategie (AuthGuard('authentik')) mit JIT-User-Anlage beim ersten Login + LT-Abgleich aus dem groups-Claim → User.isLeitungsteam → virtuelle globale LT-Membership (resolveOrProvisionAuthentikUser / toAuthenticatedUser, race-sicher), Guest-Login (AuthGuard('guest')), lokaler Teamer-Login (AuthGuard('team'), TEAM_JWT_SECRET, bcrypt) inkl. Invite-Redemption, TokenVerificationService für manuelle Verifikation außerhalb des HTTP/Passport-Pfads (WS-Handshake, akzeptiert Authentik/Team/Guest). Alle Strategien laden nur ACTIVE-Memberships; auch der Team-Token-Pfad geht durch toAuthenticatedUser (synthetische LT-Membership bei isLeitungsteam). LT-Admin-Controller (kc/gemeinde/onboarding/sync/teamer) akzeptieren ['authentik','team']. POST /api/auth/guest, POST /api/auth/team-login, POST /api/auth/teamer/register, GET /api/auth/me
kc/ KC-Verwaltung, Leitungsteam-only POST /api/kc, GET /api/kc
gemeinde/ Gemeinde-CRUD pro KC, Leitungsteam-only POST /api/gemeinde, GET /api/gemeinde?kcId=, GET/PATCH/DELETE /api/gemeinde/:id
onboarding/ Selbstregistrierung Gemeinde Verantwortliche/r: öffentlicher Invite-Lookup, JIT-User-Anlage aus Authentik-Claims, Membership im Status PENDING; LT sieht/genehmigt/lehnt ab GET /api/onboarding/kc/:inviteCode, POST /api/onboarding/verantwortliche (Authentik-Bearer), GET /api/onboarding/requests?kcId= (LT), POST /api/onboarding/requests/:id/approve|reject (LT)
teamer/ Lokale Gemeinde-Teamer-Accounts + Invites; Direkt-Anlage, Gruppen-Link und E-Mail-Invite (persönliche Invites werden per MailService best-effort verschickt, emailSent im Response); nutzbar von LT (jede Gemeinde) oder Verantwortliche/r (nur eigene Gemeinde, in TeamerService geprüft) POST/GET /api/gemeinde/:gemeindeId/teamer, DELETE /api/gemeinde/:gemeindeId/teamer/:userId, POST/GET /api/gemeinde/:gemeindeId/teamer-invites, DELETE .../teamer-invites/:id
mail/ Globale MailProvider-Abstraktion (log-only Default, SMTP via MAIL_PROVIDER=smtp); MailService baut die Invite-Mail inkl. Link aus APP_BASE_URL
push/ Globale PushProvider-Abstraktion (log-only Default, FCM HTTP v1 via PUSH_PROVIDER=fcm); PushService.notifyChannel löst die Kanal-Zielgruppe auf → DeviceTokens → Versand, prunt ungültige Tokens; von ChatService.sendMessage best-effort ausgelöst POST /api/push/register, POST /api/push/unregister
wahl/ Wahl-/Workshop-Verwaltung, Force-Zuteilung, Teilnehmer-Einreichung, ZuteilungService (Portierung von kc_run_zuteilung: Force-Zuteilungen → 3 Wunschrunden → Zufallsfüllung → Konsolidierung unterbesetzter Workshops), CSV-Export POST/GET /api/wahl, POST/GET /api/wahl/:id/workshops, POST /api/wahl/:id/force-zuteilung, POST /api/wahl/:id/teilnehmer (Guest), GET /api/wahl/guest/overview + GET /api/wahl/guest/results (Guest), POST /api/wahl/:id/zuteilung/run, GET /api/wahl/:id/zuteilung(.csv)
files/ Upload (LT-only, multipart) mit Sichtbarkeitsstufe; Liste/Download für Team oder Guest, gefiltert nach erlaubten Sichtbarkeitsstufen; StorageProvider-Abstraktion (WebDAV/Nextcloud Default, S3 optional) POST /api/files/:kcId, GET /api/files/:kcId, GET /api/files/download/:fileId
chat/ Zentrale Zugriffslogik (ChatService) geteilt zwischen REST (ChatController) und WS (ChatGateway, Pfad /chat, eigene Token-Verifikation via ?token=); Kanaltypen wie oben POST /api/chat/:kcId/channels, POST /api/chat/direct, GET /api/chat/:kcId/channels, GET /api/chat/channels/:id/messages, WS-Events chat:join/chat:send/chat:message
sync/ Append-only Replikationslog + Peer-Sync (lokal ⇄ Cloud), SyncSchedulerService (alle 30s, wenn SYNC_ENABLED=true) POST /api/sync/ingest, GET /api/sync/export, POST /api/sync/trigger (LT-only)
common/ Role-Enum, @Roles()-Decorator, RolesGuard (KC-scoped, LT global)
Web-Client-Hosting ServeStaticModule liefert client/web/ aus; API unter globalem Prefix /api GET / (index.html), /app.js, /style.css

Details, Setup-Anleitung und .env-Variablen: backend/README.md.


4. Tech-Stack-Stolpersteine (dokumentiert für Nachvollziehbarkeit)

  • npx @nestjs/cli new mit aktuellen Defaults (Nest v12-Beta, ESM, Vitest, @nestjs/observe) löste einen reproduzierbaren npm-Arborist-Bug aus (Cannot read properties of null (reading 'edgesOut')). Workaround: backend/package.json wurde von Hand mit gepinnten, stabilen Versionen (Nest 10.x, Jest, CommonJS, TypeScript 5.x) erstellt statt über den CLI-Generator.
  • Bei zusätzlichen offiziellen @nestjs/*-Paketen (serve-static, schedule) wurden die Peer-Dependencies vor der Installation geprüft (npm view <pkg>@<version> peerDependencies), da die jeweils neuesten Majors bereits Nest 11/12 voraussetzen und sonst mit ERESOLVE fehlschlagen. Gepinnt: @nestjs/serve-static@4.0.2, @nestjs/schedule@4.1.1.
  • multer wurde von 1.x (bekannte CVEs) auf 2.x aktualisiert.

5. Phasenübersicht (Referenz, ursprüngliche Reihenfolge)

  1. Fundament Monorepo-Skeleton, Datenmodell, Authentik-OIDC-Integration.
  2. Multi-Tenancy & Auth Invite/QR-Code-Fluss, Permission-Guards, Guest-Login.
  3. Workshop-Wahl-Engine Wahlen/Workshops/Zuteilungslogik/CSV-Export.
  4. Dateifreigabe Storage-Abstraktion, Sichtbarkeitsstufen.
  5. Kommunikation Chat (Gruppen/DM/LT/Broadcast), WebSocket. (Push-Integration noch offen)
  6. Hybrid Lokal/Cloud-Server & Sync Replikationslog, Scheduler, Shared-Secret-Auth.
  7. Flutter-Clients gemeinsame Codebase (client/app/, Web-Target). Login (Guest / lokaler Teamer / Invite-Redemption, Token in shared_preferences, GET /auth/me für rollenabhängige Startseite), Guest-Wahl: Wünsche (/wahl/guest/overview → geordnete Auswahl → Absenden) + Ergebnis (/wahl/guest/results, PENDING/ASSIGNED/UNASSIGNED), Datei-Liste, Chat mit REST-Verlauf + Live-chat:message und Senden über das /chat-WebSocket (chat_socket.dart). 🔜 Mobile/Desktop-Targets, Authentik-Auth-Code-Flow, LT-/Verantwortlichen-Admin-Screens (KC/Gemeinde/Teamer/Onboarding-Freigaben).

6. Relevante Referenz

  • WP-Plugin als fachliche Vorlage für Zuteilungslogik: includes/zuteilungslogik.php (kc_run_zuteilung), Admin-Module admin-wahlen.php, admin-workshops.php, admin-teilnehmer.php, admin-teamer.php, admin-zuteilungen.php, Frontend-Shortcodes in frontend-form.php/frontend-ergebnis.php (git.konfi-castle.com/linus/Workshop-Wahlen).

7. Verifikation (durchgeführt je Phase)

  1. Nach jeder Phase: npx tsc -p tsconfig.build.json --noEmit, npx nest build, sowie ein kurzer Boot-Test (node dist/main.js) zur Prüfung, dass der DI-Graph auflöst und alle Routen korrekt gemappt werden (ohne Live-DB/Authentik/Nextcloud).
  2. Sync-Modul: manuell verifiziert, dass SyncSecretGuard Requests ohne x-sync-secret mit 403 ablehnt und mit korrektem Secret durchlässt (DB-Fehler in der Sandbox ist erwartet, da kein Postgres läuft).
  3. Web-Client: GET / liefert die statische Seite (200), GET /api/kc trifft die echte, geschützte API (401 ohne Token). 3a. Gegen lokales PostgreSQL 16 (Homebrew): prisma migrate dev --name init erzeugt/appliziert die erste Migration; Guest-Flow Ende-zu-Ende geprüft (/auth/guest/auth/me/wahl/guest/overviewPOST …/teilnehmer → Re-Fetch zeigt meinePrioritaeten). Seed: prisma/seed-dev.js. 3b. Flutter-Client (client/app/): flutter analyze sauber, flutter build web --release erfolgreich, flutter test grün (Login-Screen-Smoke-Test); CORS-Preflight vom Browser-Origin ok. 3c. Guest-Ergebnis: /wahl/guest/results gegen echtes Postgres in beiden Zuständen geprüft (PENDING nach Einreichung, ASSIGNED nach manuell gesetzter Zuteilung → Workshop-Name + Wunschrang). 3d. WS-Chat: Zwei-Client-E2E gegen echtes Postgres (zwei lokale Teamer im selben GEMEINDE_GRUPPE-Kanal, chat:send → der andere empfängt chat:message). Dabei behoben: ChatGateway speicherte den Caller erst nach dem asynchronen Token-Check, wodurch ein sofortiges chat:join mit 4001 abgewiesen wurde — jetzt wartet der Handler auf das Caller-Promise. 3e. LT-Admin gegen echtes Postgres mit einem isLeitungsteam-Account (Team-Token): POST/GET /api/kc, POST /api/gemeinde, GET /api/onboarding/requests + PENDING-Anfrage → approveACTIVE; Wahl-Admin (POST /api/wahl, POST /api/wahl/:id/workshops, POST .../zuteilung/run, GET .../zuteilung, PATCH /api/wahl/:id isOpen, GET /api/wahl/:id/teilnehmer, CSV-Export); Teamer-Admin (POST/GET /api/gemeinde/:id/teamer, POST /api/gemeinde/:id/teamer-invites); GET /api/onboarding/kc/:code. (Datei-Upload POST /api/files/:kcId liefert 500 ohne konfiguriertes Nextcloud/S3 — erwartet.) 3f. Echte Authentik verifiziert: mit einem Password-Grant-Token für ein KC-APP-LT-Mitglied (hermes) gegen https://sso.konfi-castle.comGET /api/auth/me liefert isLeitungsteam: true (JWKS-Prüfung, Trailing-Slash-Issuer, JIT-User, groups→LT), POST /api/kc → 201. Placeholder-E-Mail-Fallback, da hermes keine E-Mail hat. AUTHENTIK_LEITUNGSTEAM_GROUP="KC-APP-LT". Noch offen: nur der In-Browser-Redirect-Roundtrip (Authentik-Loginseite → Code-Tausch). 3g. Push: gegen echtes Postgres Gerätetoken registriert (POST /api/push/register, Guest + Teamer), dann Gemeinde-Gruppen-Chat von einem anderen Mitglied → PushProvider-Log: „would push „Gemeinde-Gruppe" to 1 device". Backend serviert index.html mit Firebase-Bootstrap + firebase-messaging-sw.js (200). Echter FCM-Versand ungetestet (Secrets fehlen).
  4. Jest-Unit-Tests (Prisma/Sync/Mail gemockt, npm test grün, 56 Tests):
    • src/wahl/zuteilung.service.spec.ts: Force-Vorrang, Wunschrunden-Fallback bei voller Kapazität, Unzugeteilt-Fall, Konsolidierung unterbesetzter Workshops, Sync-Capture-Anzahl.
    • src/auth/team-auth.service.spec.ts: Invite-Redemption (unbekannt/widerrufen/abgelaufen/aufgebraucht, Gruppen-Link ohne E-Mail, E-Mail-Mismatch, Dublette) + Passwort-Login.
    • src/teamer/teamer.service.spec.ts: Gemeinde-Scope-Check (LT global, Verantwortliche/r nur eigene Gemeinde), Direkt-Anlage, Invite-Defaults, E-Mail-Versand nur bei persönlichem Invite + Best-effort bei Transport-Fehler, Löschung.
    • src/onboarding/onboarding.service.spec.ts: Invite-Lookup, Verantwortlichen-Selbstregistrierung (Token fehlt/ungültig, unbekannter Code, Gemeinde nicht im KC, JIT-User + PENDING, Idempotenz), Approve/Reject.
    • src/auth/provision-user.spec.ts: JIT-User-Anlage aus Authentik-Claims, LT-Flag-Abgleich (rauf/runter) aus dem groups-Claim, virtuelle LT-Membership in toAuthenticatedUser, Race-Recovery (P2002 → Re-Read), Fehler-Weiterreichung.
  5. Noch ausstehend (sobald echte Infrastruktur verfügbar ist): Zuteilungslogik gegen bekannte Testdaten aus dem alten Plugin validieren; Ende-zu-Ende-Rollenmatrix (LT/Verantwortlicher/Teamer/Guest) über alle Kernfeatures; echter Sync-Test zwischen zwei laufenden Instanzen (lokal + Cloud).

8. Nächste Schritte

  1. Flutter-Client: Authentik-PKCE-Login (oidc.dart), LT-Admin (KC/Gemeinde), Wahl-Verwaltung (Wahlen/Workshops/Zuteilung + Ergebnis, öffnen/schließen, Force-Zuteilung, CSV-Export als Browser-Download), Teamer-Verwaltung (Konten + Invites), Verantwortlichen-Selbstregistrierung, LT-Datei-Upload (nativer <input file> + Sichtbarkeitsstufe). 🔜 Browser-OIDC-Roundtrip einmal live durchklicken; Mobile/Desktop-Targets (flutter create --platforms=…, Toolchains fehlen); Push.
  2. Authentik-Provider so konfigurieren, dass das Access-Token den groups-Claim trägt (Scope „groups"), LT-Gruppe = AUTHENTIK_LEITUNGSTEAM_GROUP. (Ops/Config, Code ist fertig.)
  3. SMTP konfigurieren (MAIL_PROVIDER=smtp + SMTP_*/MAIL_FROM/APP_BASE_URL) und Invite-Mail-Templates finalisieren (aktuell Plain-Text); optional Onboarding-Benachrichtigungen an LT.
  4. Push scharfschalten: Firebase-Web-App-Secrets (apiKey/appId/VAPID-Key) in client/app/web/index.html + firebase-messaging-sw.js eintragen, Service-Account-JSON hinterlegen und PUSH_PROVIDER=fcm setzen (FCM_PROJECT_ID=konfi-castle-app). Danach echten Zustellungstest.
  5. Echte Authentik- + Nextcloud/S3-Infra anbinden und die in Abschnitt 7 offenen E2E-Verifikationsschritte durchführen (lokales Postgres + Migration sind erledigt).