- Dockerfile: 3-stage — Flutter web build, NestJS build, slim node runtime. Runtime copies dist + node_modules + prisma + the web bundle (WEB_CLIENT_DIR=/app/web), runs `prisma migrate deploy` then `node dist/main.js`. One container serves client + API on :3000. - docker-compose.yml: postgres:16-alpine with a healthcheck + the api service; config from backend/.env (Compose v2 strips quotes), DATABASE_URL + GOOGLE_APPLICATION_CREDENTIALS overridden for the container, serviceAccount.json bind-mounted read-only. - .dockerignore keeps node_modules/build/secrets out of the context. Not run here (no Docker on this box); the stack also runs natively against the local Postgres. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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 localUseris provisioned just-in-time on first login from the token claims (resolveOrProvisionAuthentikUser), andUser.isLeitungsteamis reconciled on every login from the token'sgroupsclaim vs.AUTHENTIK_LEITUNGSTEAM_GROUP—toAuthenticatedUserthen synthesises a virtual globalLEITUNGSTEAMmembership from that flag. Other roles come from localMembershiprows (onlystatus = ACTIVEones count). Verantwortliche self-provision through theonboarding/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
Userrow with apasswordHashandkcIdset,authentikSubleft null. A Gemeinde Verantwortliche/r creates them directly or via aTeamerInvite(shareable group link or per-email invite). Login isPOST /auth/team-login(email + password) orPOST /auth/teamer/register(redeem an invite token); both return a JWT signed withTEAM_JWT_SECRETand carryingtyp: "team".TeamJwtStrategy(AuthGuard('team')) resolves it to the same shape asAuthentikStrategy, so guards/controllers treat both alike. - Guests/Konfis get a temporary local account (first/last name required, no
Authentik) created via
POST /auth/guestwith a KC invite code, returning a JWT signed withGUEST_JWT_SECRET.
Modules implemented so far
prisma/— sharedPrismaClientprovider.auth/— Authentik resource-server strategy (AuthGuard('authentik')), guest invite-code login (AuthGuard('guest')), and local Gemeinde Teamer auth (AuthGuard('team')):POST /auth/team-loginandPOST /auth/teamer/register(invite redemption), bcrypt hashes, tokens signed withTEAM_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 theirMembership, 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 inTeamerService, sinceRolesGuardonly scopes bykcId). Files/chat read endpoints accept'team'tokens too, so Teamer see non-Konfi files and chat. A personal invite (withemail) is mailed viaMailService; the response carriesemailSent. Group-link invites (noemail) 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/verantwortlichetakes the caller's raw Authentik bearer token (no localMembershipneeded yet), JIT-provisions the localUserfrom the token claims, and creates aMembershipwithstatus = PENDING. Leitungsteam reviews viaGET /onboarding/requests?kcId=andPOST /onboarding/requests/:id/approveor.../reject. Auth strategies only loadACTIVEmemberships, so a pending request grants nothing until approved.mail/— globalMailProviderabstraction (mirrorsfiles/storage/): defaultlogprovider only logs what it would send;MAIL_PROVIDER=smtpuses a realnodemailerSMTP transport (SMTP_*,MAIL_FROM).MailService.sendTeamerInvite()composes the personal-invite email with a link built fromAPP_BASE_URL. Delivery is best-effort — failures are logged and swallowed, never blocking the invite.push/— globalPushProviderabstraction; defaultlog,PUSH_PROVIDER=fcmuses FCM HTTP v1 (service-account JWT → OAuth token, no extra dep;FCM_PROJECT_ID,GOOGLE_APPLICATION_CREDENTIALS).DeviceTokenrows (bound to aUserorGuestAccount) viaPOST /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, andZuteilungService: a faithful port of the WP plugin'skc_run_zuteilung(force-assignments → up to 3 wish rounds → random fill → consolidation of workshops that stay belowminTeilnehmer), plus CSV export (GET /wahl/:id/zuteilung/csv).files/— Leitungsteam-only upload (POST /files/:kcId, multipart) tagged with aFileVisibilitytier; 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 aStorageProviderabstraction: defaults to Nextcloud via WebDAV (WEBDAV_*env vars), switchable to S3-compatible storage withSTORAGE_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 rawwsgateway (ChatGateway, path/chat) since passport guards don't apply to WS upgrades — auth happens once via?token=at connect time (TokenVerificationServicetries Authentik JWKS, then falls back to a guest token). Access rules live inChatServiceand 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-onlySyncLogEntrylog tagged with this server'sSERVER_ID. The local server (setSYNC_ENABLED=true,SYNC_PEER_URL) periodically pushes its new entries to the cloud'sPOST /sync/ingestand pulls the cloud's viaGET /sync/export(SyncSchedulerService, every 30s), both guarded bySYNC_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/triggerlets 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/—Roleenum,@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.