New global mail/ module mirroring the files/storage/ provider pattern: - MailProvider abstraction; default LogMailProvider only logs (no delivery), MAIL_PROVIDER=smtp switches to a nodemailer SMTP transport (SMTP_*, MAIL_FROM). - MailService.sendTeamerInvite() composes the invite email with a link built from APP_BASE_URL. TeamerService.createInvite() now mails personal invites (those with an email) best-effort and returns `emailSent`; group links are unchanged. Delivery failures are logged and swallowed, never blocking invite creation. New env: APP_BASE_URL, MAIL_PROVIDER, MAIL_FROM, SMTP_HOST/PORT/SECURE/ USER/PASS. Tests: teamer spec covers mail-on-personal-invite, no-mail-on-group-link, and transport-drop; npm test green at 56. Docs updated. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
18 KiB
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 (Stand dieser Session): Alle geplanten Backend-Phasen (0–6) sind implementiert und verifiziert (Typecheck, Build, Boot-Test). Offen ist ausschließlich der Flutter-Client (Mobile/Desktop), da Flutter in dieser Umgebung nicht installiert ist.
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 ausAUTHENTIK_LEITUNGSTEAM_GROUP) und alsUser.isLeitungsteamgespeichert; die Auth-Schicht synthetisiert daraus eine virtuelle globale LT-Membership. KeinMembership-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).
- Leitungsteam (LT) – global über alle KCs hinweg. Wird bei jedem Authentik-Login aus dem
- Membership: verknüpft
User⇄Kc(+ optionalGemeinde) ⇄Role, mitstatus(ACTIVE/PENDING). Nur fürGEMEINDE_VERANTWORTLICHER/GEMEINDE_TEAMER— LT läuft überUser.isLeitungsteam(s. o.).PENDING(aus der Selbstregistrierung) gewährt keine Rechte, bis ein LT sie genehmigt — die Auth-Strategien laden nurACTIVE-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 →Userwird JIT angelegt,MembershipalsPENDING; LT genehmigt. - Gemeinde Teamer:
teamer/-Modul — von einer Verantwortliche/r direkt angelegt oder per Invite-Link/E-Mail-Invite selbst registriert (lokaler Account, sofortACTIVE).
- Gemeinde Verantwortliche/r:
- 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 viaTeilnehmer.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 (
workshopIdnullable = unzugeteilt,wunschRang,isForced).
- Workshop:
- 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, expliziteChatParticipant-Zuordnung),LT_UEBERGREIFEND,BROADCAST(Konfis nur lesend). - Sync-Infrastruktur:
SyncLogEntry(Append-only-Replikationslog:model,recordId,operation,payload,originId, autoincrementsequence) +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 | vom Nutzer delegiert; noch nicht scaffoldbar (Flutter fehlt lokal) |
| Web-Interimslösung | Backend liefert client/web/ (reines HTML/CSS/JS, kein Build-Schritt) über ServeStaticModule aus; REST-API liegt unter /api/* |
Nutzerwunsch: "Server soll auch Web-Client bereitstellen"; vermeidet Kollision zwischen API-Routen und statischen Dateien |
| Auth (Team) | Authentik als OIDC Resource Server (JWKS-Verifikation), kein lokaler Autorisierungscode-Flow im Backend | Clients machen Authorization Code + PKCE direkt gegen Authentik; Backend validiert nur Access Token + löst lokale Membership auf |
| 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 |
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 | |
| 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 einstorageKeyauf beiden Seiten auflösbar ist. - Push-Benachrichtigungen (FCM/APNs) sind im Plan vorgesehen, aber noch nicht implementiert (Teil der noch ausstehenden Flutter-Client-Arbeit).
- 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 derMembership, nicht über diesen Endpunkt. - Authentik-Provisionierung: Jeder gültige Authentik-Login legt den lokalen
Userautomatisch an (AuthentikStrategy→resolveOrProvisionAuthentikUser, JIT, race-sicher) und setztisLeitungsteamaus demgroups-Claim. Voraussetzung: der Authentik-Provider muss dengroups-Claim ins Access-Token schreiben (Scope „groups" hinzufügen) und der LT-Gruppenname muss zuAUTHENTIK_LEITUNGSTEAM_GROUPpassen — sonst wird niemand als LT erkannt. Gemeinde Verantwortliche brauchen weiterhin dieonboarding/-Freigabe durch LT. (Gemeinde Teamer sind lokale Accounts, sieheteamer/+auth/team-login.) - Teamer-Identität ist E-Mail-basiert und global eindeutig:
User.emailist 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 mitMailProvider-Abstraktion. Persönlicheteamer-invites(mitemail) werden verschickt; Default-Provider ist log-only (schreibt nur ins Log), echter Versand erst mitMAIL_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. |
POST /api/auth/guest, POST /api/auth/team-login, POST /api/auth/teamer/register |
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 |
– |
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), 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 newmit 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.jsonwurde 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 mitERESOLVEfehlschlagen. Gepinnt:@nestjs/serve-static@4.0.2,@nestjs/schedule@4.1.1. multerwurde von 1.x (bekannte CVEs) auf 2.x aktualisiert.
5. Phasenübersicht (Referenz, ursprüngliche Reihenfolge)
- Fundament – Monorepo-Skeleton, Datenmodell, Authentik-OIDC-Integration. ✅
- Multi-Tenancy & Auth – Invite/QR-Code-Fluss, Permission-Guards, Guest-Login. ✅
- Workshop-Wahl-Engine – Wahlen/Workshops/Zuteilungslogik/CSV-Export. ✅
- Dateifreigabe – Storage-Abstraktion, Sichtbarkeitsstufen. ✅
- Kommunikation – Chat (Gruppen/DM/LT/Broadcast), WebSocket. ✅ (Push-Integration noch offen)
- Hybrid Lokal/Cloud-Server & Sync – Replikationslog, Scheduler, Shared-Secret-Auth. ✅
- Flutter-Clients – gemeinsame Codebase; Screens: Invite/Login, rollenspezifisches Dashboard, Wahl-Formular/Ergebnis, Dateien, Chat, Admin/Nutzerverwaltung. ❌ offen (Flutter nicht installiert); Web-Interimslösung siehe Abschnitt 3.
6. Relevante Referenz
- WP-Plugin als fachliche Vorlage für Zuteilungslogik:
includes/zuteilungslogik.php(kc_run_zuteilung), Admin-Moduleadmin-wahlen.php,admin-workshops.php,admin-teilnehmer.php,admin-teamer.php,admin-zuteilungen.php, Frontend-Shortcodes infrontend-form.php/frontend-ergebnis.php(git.konfi-castle.com/linus/Workshop-Wahlen).
7. Verifikation (durchgeführt je Phase)
- 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). - Sync-Modul: manuell verifiziert, dass
SyncSecretGuardRequests ohnex-sync-secretmit 403 ablehnt und mit korrektem Secret durchlässt (DB-Fehler in der Sandbox ist erwartet, da kein Postgres läuft). - Web-Client:
GET /liefert die statische Seite (200),GET /api/kctrifft die echte, geschützte API (401 ohne Token). - Jest-Unit-Tests (Prisma/Sync/Mail gemockt,
npm testgrü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 demgroups-Claim, virtuelle LT-MembershipintoAuthenticatedUser, Race-Recovery (P2002 → Re-Read), Fehler-Weiterreichung.
- 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
- Sobald Flutter verfügbar ist: Client-Grundgerüst aufsetzen (Mobile + Web + Desktop, eine Codebase), beginnend mit Invite/Login-Flow gegen die bestehende API.
- Authentik-Provider so konfigurieren, dass das Access-Token den
groups-Claim trägt (Scope „groups"), und die LT-Gruppe aufAUTHENTIK_LEITUNGSTEAM_GROUPabstimmen — sonst greift der LT-Abgleich nicht. (Reine Ops-/Config-Aufgabe, Code ist fertig.) - SMTP konfigurieren (
MAIL_PROVIDER=smtp+SMTP_*/MAIL_FROM/APP_BASE_URL) und die Invite-Mail-Templates finalisieren (aktuell nur Plain-Text); optional Onboarding-Benachrichtigungen an LT. - Push-Benachrichtigungen (FCM/APNs) für Chat/Ankündigungen.
- Echte Infrastruktur (Postgres, Authentik, Nextcloud/S3) aufsetzen, erste Prisma-Migration erzeugen (
prisma migrate dev, bisher nurschema.prisma) und die in Abschnitt 7 offenen Verifikationsschritte durchführen.