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>
130 lines
7.7 KiB
Markdown
130 lines
7.7 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 # 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.
|
|
- `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.
|