# KC-App Backend NestJS API for the KC-App platform. Split out of the main KC-APP monorepo (https://git.konfi-castle.com/linus/KC-APP); the Flutter clients live there. ## Setup ```bash 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_GROUP` — `toAuthenticatedUser` 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. - `push/` — global `PushProvider` abstraction; default `log`, `PUSH_PROVIDER=fcm` uses FCM HTTP v1 (service-account JWT → OAuth token, no extra dep; `FCM_PROJECT_ID`, `GOOGLE_APPLICATION_CREDENTIALS`). `DeviceToken` rows (bound to a `User` or `GuestAccount`) via `POST /push/register` + `/unregister`. `PushService.notifyChannel()` resolves a channel's readable audience → their tokens (minus the sender) → send, pruning invalid ones; `ChatService.sendMessage()` fires it best-effort. - `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 features are implemented (`prisma/migrations/` holds the schema history). `npm test` runs Jest unit tests (`ZuteilungService`, `TeamAuthService`, `TeamerService`, `OnboardingService`, `resolveOrProvisionAuthentikUser` / `toAuthenticatedUser`; Prisma mocked). Ops notes to go live: the Authentik provider must emit a `groups` claim for the LT check; `MAIL_PROVIDER=smtp` + `SMTP_*` for invite emails; `PUSH_PROVIDER=fcm` + a Firebase service-account JSON for push; and real Nextcloud/S3 credentials for file storage.