Files
KC-APP-Server/README.md
T
linusandClaude Sonnet 5 da76f8dc96 feat(backend): mail module + send personal Gemeinde-Teamer invites
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>
2026-09-10 08:17:53 +02:00

7.7 KiB

KC-App Backend

NestJS API for the KC-App platform (see repo root README + plan for architecture context).

Setup

npm install
cp .env.example .env   # DATABASE_URL / AUTHENTIK_ISSUER_URL / AUTHENTIK_LEITUNGSTEAM_GROUP /
                       # GUEST_JWT_SECRET / TEAM_JWT_SECRET / APP_BASE_URL (+ MAIL_* for real email)
npx prisma generate
npx prisma migrate dev --name init   # requires a running PostgreSQL instance
npm run start:dev

The API is served under /api (see app.setGlobalPrefix('api') in main.ts); everything else (/, /app.js, ...) is served statically from ../client/web via ServeStaticModule, so the backend doubles as the web client's host - no separate web server is needed.

Auth model

  • Leitungsteam and Gemeinde Verantwortliche sign in with Authentik (the "Konfi-Castle-ID"); this API acts as an OIDC resource server, verifying access tokens against Authentik's JWKS (AuthentikStrategy). The local User is provisioned just-in-time on first login from the token claims (resolveOrProvisionAuthentikUser), and User.isLeitungsteam is reconciled on every login from the token's groups claim vs. AUTHENTIK_LEITUNGSTEAM_GROUPtoAuthenticatedUser then synthesises a virtual global LEITUNGSTEAM membership from that flag. Other roles come from local Membership rows (only status = ACTIVE ones count). Verantwortliche self-provision through the onboarding/ approval flow; a user with neither the LT flag nor a membership has no rights. Clients perform the Authorization Code + PKCE flow against Authentik directly.
  • Gemeinde Teamer are local accounts (no Authentik): a User row with a passwordHash and kcId set, authentikSub left null. A Gemeinde Verantwortliche/r creates them directly or via a TeamerInvite (shareable group link or per-email invite). Login is POST /auth/team-login (email + password) or POST /auth/teamer/register (redeem an invite token); both return a JWT signed with TEAM_JWT_SECRET and carrying typ: "team". TeamJwtStrategy (AuthGuard('team')) resolves it to the same shape as AuthentikStrategy, so guards/controllers treat both alike.
  • Guests/Konfis get a temporary local account (first/last name required, no Authentik) created via POST /auth/guest with a KC invite code, returning a JWT signed with GUEST_JWT_SECRET.

Modules implemented so far

  • prisma/ — shared PrismaClient provider.
  • auth/ — Authentik resource-server strategy (AuthGuard('authentik')), guest invite-code login (AuthGuard('guest')), and local Gemeinde Teamer auth (AuthGuard('team')): POST /auth/team-login and POST /auth/teamer/register (invite redemption), bcrypt hashes, tokens signed with TEAM_JWT_SECRET. TokenVerificationService (WS handshake) now accepts Authentik, team, or guest tokens.
  • kc/ — KC (event) creation/listing, Leitungsteam-only.
  • gemeinde/ — Gemeinde (congregation) CRUD per KC (POST /gemeinde, GET /gemeinde?kcId=, GET/PATCH/DELETE /gemeinde/:id), Leitungsteam-only. Gemeinde Verantwortliche/Teamer get their own Gemeinde from their Membership, not from this endpoint.
  • teamer/ — local Gemeinde Teamer accounts + invites, under /gemeinde/:gemeindeId/...: POST/GET teamer, DELETE teamer/:userId, POST/GET teamer-invites, DELETE teamer-invites/:inviteId. Callable by Leitungsteam (any Gemeinde) or a Verantwortliche/r for their own Gemeinde (enforced in TeamerService, since RolesGuard only scopes by kcId). Files/chat read endpoints accept 'team' tokens too, so Teamer see non-Konfi files and chat. A personal invite (with email) is mailed via MailService; the response carries emailSent. Group-link invites (no email) are shared by hand.
  • onboarding/ — self-registration for Gemeinde Verantwortliche. GET /onboarding/kc/:inviteCode (public) returns the KC name + its Gemeinden to pick from. POST /onboarding/verantwortliche takes the caller's raw Authentik bearer token (no local Membership needed yet), JIT-provisions the local User from the token claims, and creates a Membership with status = PENDING. Leitungsteam reviews via GET /onboarding/requests?kcId= and POST /onboarding/requests/:id/approve or .../reject. Auth strategies only load ACTIVE memberships, so a pending request grants nothing until approved.
  • mail/ — global MailProvider abstraction (mirrors files/storage/): default log provider only logs what it would send; MAIL_PROVIDER=smtp uses a real nodemailer SMTP transport (SMTP_*, MAIL_FROM). MailService.sendTeamerInvite() composes the personal-invite email with a link built from APP_BASE_URL. Delivery is best-effort — failures are logged and swallowed, never blocking the invite.
  • wahl/ — Wahl/Workshop administration (Leitungsteam-only), guest Teilnehmer submission, Force-Zuteilung overrides, and ZuteilungService: a faithful port of the WP plugin's kc_run_zuteilung (force-assignments → up to 3 wish rounds → random fill → consolidation of workshops that stay below minTeilnehmer), plus CSV export (GET /wahl/:id/zuteilung/csv).
  • files/ — Leitungsteam-only upload (POST /files/:kcId, multipart) tagged with a FileVisibility tier; list/download (GET /files/:kcId, GET /files/download/:fileId) accept either an Authentik or a guest token and filter by the caller's allowed visibility tiers. Storage is behind a StorageProvider abstraction: defaults to Nextcloud via WebDAV (WEBDAV_* env vars), switchable to S3-compatible storage with STORAGE_PROVIDER=s3 (S3_* env vars).
  • chat/ — Gemeinde-Gruppenchat, 1:1 Direktnachrichten, Leitungsteam-über- greifende Kanäle und Broadcast (Konfis lesen nur). Channel administration and message history are plain REST (ChatController); real-time send/ receive is a raw ws gateway (ChatGateway, path /chat) since passport guards don't apply to WS upgrades — auth happens once via ?token= at connect time (TokenVerificationService tries Authentik JWKS, then falls back to a guest token). Access rules live in ChatService and are shared between the REST and WS entry points.
  • sync/ — replicates mutations between the local (on-site) and cloud server. SyncService.capture() is called by feature services right after a write, appending an entry to the append-only SyncLogEntry log tagged with this server's SERVER_ID. The local server (set SYNC_ENABLED=true, SYNC_PEER_URL) periodically pushes its new entries to the cloud's POST /sync/ingest and pulls the cloud's via GET /sync/export (SyncSchedulerService, every 30s), both guarded by SYNC_SHARED_SECRET (SyncSecretGuard) rather than user auth. No conflict resolution is implemented by design — the local server is the sole source of truth while an event is live. POST /sync/trigger lets a Leitungsteam member force an immediate push+pull. Known gap: only entity metadata is replicated; uploaded file bytes only resolve on both sides if local and cloud share the same Nextcloud/S3 backend.
  • common/Role enum, @Roles() decorator, RolesGuard (KC-scoped, Leitungsteam roles are global across all KCs).

All planned backend phases are implemented. npm test runs Jest unit tests (ZuteilungService, TeamAuthService, TeamerService, OnboardingService, resolveOrProvisionAuthentikUser / toAuthenticatedUser; Prisma mocked). Remaining work: the Flutter clients (see repo root README), push notifications, and the first real Prisma migration (only schema.prisma exists so far). Ops notes: the Authentik provider must emit a groups claim for the LT check, and MAIL_PROVIDER=smtp + SMTP_* must be set for invite emails to actually leave the box.