Files
KC-APP-Server/README.md
T
linusandClaude Sonnet 5 d48c07b0e4 feat(backend): local accounts + invites for Gemeinde Teamer
Per the updated plan, Gemeinde Teamer are no longer Authentik-backed; they
are local accounts a Gemeinde Verantwortliche/r provisions per KC.

Schema:
- User.authentikSub now nullable; add passwordHash + kcId (cascade from Kc)
  so one User model covers Authentik members and local Teamer.
- new TeamerInvite model: shareable group link (email null, maxUses null)
  or personal invite (email pinned, single use), with expiry + revoke.
- sync log now also replicates User / Membership / TeamerInvite.

Auth:
- TeamAuthService: bcrypt password login (POST /auth/team-login) and invite
  redemption (POST /auth/teamer/register) issuing a JWT signed with
  TEAM_JWT_SECRET, payload typ:"team".
- TeamJwtStrategy (AuthGuard('team')) resolves it to the same
  AuthenticatedUser shape as AuthentikStrategy.
- TokenVerificationService.verifyEither() also accepts team tokens (WS).
- files + chat read endpoints accept 'team' tokens; Teamer see non-Konfi
  files and can use chat / start DMs.

Teamer admin (teamer/ module, under /gemeinde/:gemeindeId):
- POST/GET teamer, DELETE teamer/:userId
- POST/GET teamer-invites, DELETE teamer-invites/:inviteId
- LT may manage any Gemeinde; a Verantwortliche/r only their own
  (checked in TeamerService, since RolesGuard only scopes by kcId).

Tests: TeamAuthService + TeamerService specs added (Prisma/Sync mocked),
npm test green at 32. Docs (plan + backend README) updated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 16:50:06 +02:00

103 lines
5.8 KiB
Markdown

# KC-App Backend
NestJS API for the KC-App platform (see repo root README + plan for
architecture context).
## Setup
```bash
npm install
cp .env.example .env # then fill in DATABASE_URL / AUTHENTIK_ISSUER_URL / GUEST_JWT_SECRET / TEAM_JWT_SECRET
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 are provisioned in Authentik
(the "Konfi-Castle-ID"); this API acts as an OIDC **resource server**,
verifying access tokens against Authentik's JWKS (`AuthentikStrategy`) and
then resolving local `Membership` rows to determine role + KC/Gemeinde
scope. Clients perform the actual 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.
- `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`; Prisma mocked).
Remaining work: the Flutter clients (see repo root README), Authentik
provisioning for LT/Verantwortliche, and the first real Prisma migration
(only `schema.prisma` exists so far).