Author SHA1 Message Date
linus 5fd2d47a64 Add code resolver, sync conflict handling, and user isolation
Introduce a CodeResolverService to classify user login codes, complete with detailed resolution logic and usability checks. Extend the sync system to handle conflicts via last-write-wins arbitration, with detailed conflict tracking for review. Update file permissions and runtime isolation in Docker to enhance security.
2026-09-12 15:40:42 +02:00
linus 858c43a6aa fix(ci): stop mounting repo over /app in CybeDefend container
CybeDefend Security Scan / cybedefend_scan (pull_request) Successful in 24s
The CybeDefend CLI image runs from /app/cybedefend (its own binary and
sources live there). Mounting the checked-out repo at -v $WORKSPACE:/app
shadowed that binary, so the container failed with:

  exec: "/app/cybedefend": stat /app/cybedefend: no such file or directory

Mount the repo at /src instead so /app (and its entrypoint) stays intact.
Verified locally: docker run --rm -v <dir>:/src -w /src ghcr.io/cybedefend/cybedefend-cli:latest --help now runs correctly.
2026-09-12 13:48:12 +02:00
linus 2fbfcf53be Merge pull request 'feat: Wahl-Phasen, Verantwortliche-Invites, Auth fixes, GRUPPE chat' (#1) from feat/backend-phases-0-6 into main
CybeDefend Security Scan / cybedefend_scan (push) Failing after 57s
2026-09-12 11:26:16 +00:00
linus 288628f20e feat(chat): free-form GRUPPE channels with mutable participants
CybeDefend Security Scan / cybedefend_scan (push) Failing after 19s
CybeDefend Security Scan / cybedefend_scan (pull_request) Failing after 1s
- ChatChannelType.GRUPPE: created by Leitungsteam (any KC) or a Gemeinde
  Verantwortliche/r (own KC), mixing team users and guests/Konfis as
  explicit ChatParticipant rows (unlike GEMEINDE_GRUPPE, membership is
  not derived from Gemeinde)
- POST /chat/:kcId/gruppen to create, GET participant-candidates, and
  POST/DELETE /chat/gruppen/:channelId/participants to manage membership
  (creator, LT, or Verantwortliche/r of that KC)
- ChatGateway broadcasts chat:participants-changed on membership change
- PushService updated for nullable ChatParticipant.userId + new
  guestAccountId column
- SyncService now replicates ChatParticipant
- Prisma migration + 14 new unit tests (75/75 passing), tsc clean
- CI: add .gitea/workflows/cybedefend-scan.yml + .cybedefend project config
2026-09-12 13:25:48 +02:00
linus 1467c8bdf6 test: add intentionally vulnerable file to trigger CybeDefend scan 2026-09-11 20:30:51 +02:00
linus 3bc40e5908 chore: map Postgres to host port 5433 to avoid local conflicts 2026-09-11 19:45:49 +02:00
linus 1614c19102 fix: correct DATABASE_URL password placeholder in docker-compose.yml
The 'postgres' password was literally written as the masked '***'
placeholder (copy-paste artifact), causing Prisma P1000 auth failures
against the db service. Set it to match POSTGRES_PASSWORD.
2026-09-11 18:34:52 +02:00
linusandClaude Sonnet 5 4902cfe85d chore: adapt Dockerfile/compose for standalone server repo
- Dockerfile no longer bakes in a Flutter web build (this repo has no
  client/ dir); the web bundle is mounted at runtime instead.
- docker-compose.yml: web bundle mount path configurable via
  WEB_CLIENT_BUILD_PATH, defaults to a sibling KC-APP checkout.
- README: point to the KC-APP client repo for clients.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 18:01:38 +02:00
linusandClaude Sonnet 5 2f76790135 feat(backend): Wahl-Phasen, Verantwortliche-Invites, Auth fixes
- New VerantwortlicheInvite model: LT-issued invites so a person can
  register as Gemeinde Verantwortliche(r) for a specific Gemeinde,
  skipping the self-registration approval step.
- Wahl/Workshop/Teilnehmer gain phase support (phasenAnzahl,
  beschreibung), mirroring the WP plugin's multi-phase elections.
  Teilnehmer unique constraint now scoped per phase.
- Auth: team login + guest auth adjustments, spec coverage.
- sync.service.ts: register VerantwortlicheInvite as a synced model.
- wahl.service.ts: submitTeilnehmer updated for the new phase-scoped
  unique key.
- client: login/home screen rework, new theme.dart, FCM web tweaks.
- .gitignore: ignore .DS_Store.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 18:00:18 +02:00
linusandClaude Sonnet 5 cfa0070eab build: drop the Flutter builder stage from the Docker image
The Flutter SDK image is ~2.8 GB and filled Docker Desktop's VM disk
("read-only file system" while extracting a layer). Build the web bundle
on the host instead and COPY client/app/build/web into the 2-stage
(NestJS build -> slim runtime) image. .dockerignore keeps the bundle,
drops the platform scaffolding. README documents the host `flutter build
web` prerequisite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 12:23:33 +02:00
linusandClaude Sonnet 5 be13d8350b build: Docker setup (compose: postgres + all-in-one api image)
- 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>
2026-09-10 12:08:43 +02:00
linusandClaude Sonnet 5 7c8f35f0f0 chore(backend): gitignore serviceAccount.json
FCM push is now verified end to end against the real konfi-castle-app
project: service-account JWT -> OAuth token (200), FCM messages:send
reached and processed (a bogus token gets a 400 INVALID_ARGUMENT and is
pruned from device_token). Real config lives only in the gitignored
backend/.env + backend/serviceAccount.json.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 12:02:18 +02:00
linusandClaude Sonnet 5 f12bb51f3e docs: push notifications module + FCM client wiring
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 11:46:39 +02:00
linusandClaude Sonnet 5 92e0029732 feat(backend): push notifications module (FCM HTTP v1)
New global push/ module mirroring mail/ and files/storage/:
- PushProvider abstraction; default LogPushProvider (no delivery, logs),
  PUSH_PROVIDER=fcm switches to FcmPushProvider — Firebase Cloud Messaging
  HTTP v1, authenticated by a service-account JWT exchanged for an OAuth
  token (no extra dependency; jsonwebtoken does the signing). Prunes tokens
  FCM reports as invalid.
- DeviceToken model (token + platform, bound to a User or GuestAccount),
  migration + added to the sync log.
- POST /api/push/register + /unregister (any of the three token kinds).
- PushService.notifyChannel() resolves a channel's readable audience
  (DIREKT participants / LT / Gemeinde members + guests / whole KC for
  broadcast), looks up their device tokens (minus the sender), sends.
- ChatService.sendMessage() fires it best-effort after persisting.

New env: PUSH_PROVIDER, FCM_PROJECT_ID (default konfi-castle-app),
GOOGLE_APPLICATION_CREDENTIALS.

Verified against local Postgres: register a token, send a Gemeinde-group
chat message from another member -> log-push logs "would push ... to 1
device". Real FCM send needs the service-account JSON. npm test 56.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 11:42:34 +02:00
linusandClaude Sonnet 5 d8ff49480d feat: LT Wahl controls (open/close, Force-Zuteilung, CSV) + file upload
Backend:
- PATCH /api/wahl/:wahlId (isOpen) to open/close a Wahl.
- GET /api/wahl/:wahlId/teilnehmer (LT): participants with their priorities
  and any existing Force-Zuteilung.
- Verified against local Postgres: PATCH toggles isOpen, teilnehmer list
  returns, CSV export works. (A stale dev server on :3000 masked this at
  first — real routes are fine.)

Client (client/app/):
- Wahl detail: open/close switch, participant list with a "Zuteilen"
  (Force-Zuteilung) action, CSV export via a browser download
  (browser.downloadText).
- files_admin_screen.dart: LT file upload — browser.pickFile() +
  visibility picker -> multipart POST /api/files/:kcId; list existing
  files. Reachable from KcDetailScreen.
- browser_web.dart gains pickFile()/downloadText() (native <input file> +
  Blob), with throwing stubs for the VM.
- Dropped the file_picker package again (heavy transitive deps, and the
  native web input is enough); disk on this box is nearly full.

flutter analyze/test/build web green; backend npm test 56.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 10:19:35 +02:00
linusandClaude Sonnet 5 6f4a446ae4 feat(client): LT Wahl admin, Teamer admin, Verantwortlichen self-registration
New screens (client/app/lib/screens/):
- wahl_admin_screen.dart — per KC: list/create Wahlen; per Wahl: add
  workshops, run the assignment (POST /wahl/:id/zuteilung/run), view the
  result table.
- teamer_admin_screen.dart — per Gemeinde: list/create local Teamer
  accounts, create group-link or per-email invites (shows the token).
- verantwortliche_register_screen.dart — enter a KC invite code
  (GET /onboarding/kc/:code), pick a Gemeinde, submit
  (POST /onboarding/verantwortliche); shown on the home screen to a
  logged-in Authentik user who has no membership yet.
- ui.dart — shared toast / ErrorText / SectionHeader / promptText.
KcDetailScreen now links to Wahl admin and each Gemeinde row opens Teamer
admin.

Backend: widen the wahl + files LT routes to AuthGuard(['authentik','team'])
for consistency with the other LT controllers. Rebrand web/index.html +
manifest from "kc_app" to "KC-App".

Verified against local Postgres with an isLeitungsteam team token: create
KC/Gemeinde/Wahl/Workshop, run Zuteilung, create Teamer + invite, resolve
an invite code. flutter analyze/test/build web green; backend npm test 56.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 10:09:35 +02:00
linusandClaude Sonnet 5 7da9a362e1 feat(backend): tolerate Authentik users without an email + verify against real SSO
Authentik accounts don't always have an email set (the test account
`hermes` doesn't). AuthentikStrategy / verifyAuthentikClaims no longer
reject those — `authentikEmail()` falls back to a stable
`<preferred_username|sub>@no-email.authentik` handle for the local User row,
and first/last name fall back to preferred_username/name.

Set AUTHENTIK_LEITUNGSTEAM_GROUP to the real group "KC-APP-LT".

Verified end to end against the live https://sso.konfi-castle.com with a
password-grant token for a KC-APP-LT member: backend accepts the RS256
token (JWKS + trailing-slash issuer), JIT-provisions the User, maps the
`groups` claim to isLeitungsteam=true, and POST /api/kc returns 201. Only
the in-browser redirect round-trip remains untested.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 10:00:58 +02:00
linusandClaude Sonnet 5 847fed8dad feat: Authentik OIDC login (PKCE) + Leitungsteam admin screens
Backend:
- AuthentikStrategy / TokenVerificationService: normalise the issuer's
  trailing slash and accept both `iss` spellings (Authentik's discovery
  issuer and token `iss` carry a trailing slash; the JWKS URL must not
  double it). Wire the real konfi-castle issuer into .env.example.
- team token path now goes through toAuthenticatedUser too, so a local
  account flagged isLeitungsteam gets the synthetic global LT membership
  regardless of token kind.
- LT-admin controllers (kc, gemeinde, onboarding, sync, teamer) accept
  ['authentik','team'] so such an account can use them. RolesGuard still
  enforces the actual LT/role check.
- app.module serves the Flutter web build from client/app/build/web (SPA
  fallback covers the OIDC redirect path /v1/auth/callback), falling back
  to the interim client/web/ if it isn't built.

Client (client/app/):
- oidc.dart: Authorization-Code + PKCE against Authentik (discovery, S256
  challenge, state, token exchange, refresh). Browser bits (sessionStorage,
  redirect, URL) behind a conditional import so `flutter test` still
  compiles on the VM.
- AppState handles the ?code= callback on bootstrap, stores access +
  refresh, refreshes an expired token on restart.
- Login screen: "Mit Konfi-Castle-ID anmelden" button (Leitungsteam /
  Verantwortliche) alongside the local Teamer password form.
- admin_screen.dart: LT-only "Verwaltung" — list/create KCs, per KC the
  Gemeinden (list/create) and pending Verantwortlichen requests
  (approve/reject). Verified end to end against local Postgres with an
  isLeitungsteam account (create KC/Gemeinde, list + approve a request).

flutter analyze/test/build web green; backend npm test 56.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 09:24:38 +02:00
linusandClaude Sonnet 5 a21a9c1cc4 feat: Wahl result view + live WebSocket chat in the Flutter client
Backend:
- fix(chat): ChatGateway stored the per-socket caller only after the async
  token check resolved, so a client that sent chat:join immediately on open
  raced ahead and got 4001. The caller is now stored as a promise that the
  message handlers await. Verified with a two-client send/receive E2E test
  against local Postgres.

Client (client/app/):
- Wahl screen gains a "Ergebnis" tab backed by GET /wahl/guest/results
  (PENDING / ASSIGNED with workshop + wish rank / UNASSIGNED).
- Chat channel view loads history over REST, then connects the /chat
  WebSocket (chat_socket.dart): live chat:message stream + a compose bar
  that sends chat:send. Shows the socket status.
- web_socket_channel dependency added.

flutter analyze/test/build web all green; backend npm test 56.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 09:02:40 +02:00
linusandClaude Sonnet 5 7c70073c41 feat(backend): guest-facing Wahl result endpoint
GET /api/wahl/guest/results (guest JWT) returns, per Wahl the guest took
part in, their assignment: status PENDING (algorithm not run yet) /
ASSIGNED (workshopName + wunschRang) / UNASSIGNED (no capacity left).
Verified both PENDING and ASSIGNED paths against local Postgres.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 08:57:05 +02:00
linusandClaude Sonnet 5 25caff2a51 feat(backend): GET /api/auth/me for role-aware clients
Accepts any of the three token kinds and echoes back the identity behind
it: {kind:"guest", guestId, kcId, gemeindeId} for a Konfi token, or
{kind:"user", userId, email, memberships, isLeitungsteam} for an Authentik
or local Teamer token. Lets the client pick the right screens without
decoding the JWT itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 08:46:18 +02:00
linusandClaude Sonnet 5 a29024407f feat(backend): guest-facing Wahl overview endpoint + dev seed
GET /api/wahl/guest/overview (guest JWT) returns the open Wahlen for the
guest's KC, each with its workshops and the guest's own current priorities
(null if not yet submitted) — everything the client needs to render the
Wahl form without any LT-only endpoint. Verified end to end against a local
Postgres (guest login -> overview -> submit -> re-fetch).

prisma/seed-dev.js: minimal dev fixture (one KC "DEV123" + Gemeinde + open
Wahl with three workshops).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 08:45:11 +02:00
linusandClaude Sonnet 5 8224dff26f chore(backend): add initial Prisma migration
Generated with a real local PostgreSQL 16 and applied cleanly
(prisma migrate dev --name init). Covers the full current schema:
Kc, Gemeinde, Role/MembershipStatus enums, User (authentikSub nullable,
passwordHash, kcId, isLeitungsteam), Membership.status, GuestAccount,
TeamerInvite, Wahl/Workshop/Teilnehmer/ForceZuteilung/Zuteilung, File,
Chat*, Sync* .

Backend boots against the real DB and the smoke-tested routes (/, guest
login, onboarding invite lookup, protected /api/kc) behave correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 08:38:35 +02:00
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
linusandClaude Sonnet 5 eb6f64a0c5 feat(backend): derive LEITUNGSTEAM from the Authentik groups claim
On every Authentik login the token's `groups` claim is compared against
AUTHENTIK_LEITUNGSTEAM_GROUP (default "Leitungsteam") and mirrored to the
new User.isLeitungsteam column. LT is global, not KC-scoped, so it lives on
the User rather than as a per-KC Membership row: toAuthenticatedUser()
synthesises a virtual global LEITUNGSTEAM membership from the flag, so
RolesGuard / visibility / TeamerService keep working unchanged.

- provision helper gains an isLeitungsteam arg and reconciles the flag both
  ways (grant on join, drop when the group is gone), capturing a User
  UPDATE to the sync log.
- verifyAuthentikClaims() now also returns isLeitungsteam; strategy, WS
  path and onboarding all funnel through the shared helper + mapper.
- new env var AUTHENTIK_LEITUNGSTEAM_GROUP.

Tests: provision-user.spec.ts extended (flag up/down, virtual membership);
npm test green at 55. Docs updated; ops note added that the Authentik
provider must emit the groups claim.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 08:13:20 +02:00
linusandClaude Sonnet 5 f03b209e84 feat(backend): JIT-provision the local User on first Authentik login
AuthentikStrategy no longer rejects a valid token whose user has no local
row — it creates the User from the token claims (given_name/family_name/
email) via the new shared resolveOrProvisionAuthentikUser helper, which is
race-safe (P2002 -> re-read) and captures the User to the sync log. The WS
token path (TokenVerificationService.verifyAuthentik) and OnboardingService
now use the same helper, removing three copies of the lookup/create logic.

A provisioned user still has no Membership and therefore no rights: LT role
assignment from Authentik groups is the remaining gap; Verantwortliche go
through the onboarding approval flow.

Tests: provision-user.spec.ts (existing/new/race/rethrow); npm test green
at 51. Docs updated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 08:05:18 +02:00
linusandClaude Sonnet 5 6ed5aa2c76 feat(backend): self-registration for Gemeinde Verantwortliche
New onboarding/ module. A prospective Verantwortliche/r signs in with their
Konfi-Castle-ID (Authentik), looks up a KC by invite code, picks an existing
Gemeinde, and registers:

- GET  /api/onboarding/kc/:inviteCode  -> KC name + its Gemeinden (public;
  the invite code is the shared secret)
- POST /api/onboarding/verantwortliche -> verifies the raw Authentik bearer
  token's claims (no local Membership required yet via new
  TokenVerificationService.verifyAuthentikClaims), JIT-provisions the local
  User, and creates a Membership with status PENDING. Idempotent per
  (user, kc, gemeinde).
- GET  /api/onboarding/requests?kcId=            (LT) list pending
- POST /api/onboarding/requests/:id/approve|reject (LT) approve flips to
  ACTIVE, reject deletes.

Schema: Membership gains status (enum MembershipStatus { ACTIVE, PENDING },
default ACTIVE). AuthentikStrategy / TokenVerificationService / TeamAuthService
now load only ACTIVE memberships, so a pending request grants nothing until
approved. Membership create/update/delete flow through the sync log.

Tests: onboarding.service.spec.ts (14 cases); npm test green at 46.
Docs (plan + backend README) updated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 08:01:19 +02:00
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
linusandClaude Sonnet 5 081a9aa241 test(backend): unit-test ZuteilungService assignment algorithm
First automated tests in the backend. Fakes Prisma + SyncService in
memory and asserts on the zuteilung.createMany payload:
- Force-Zuteilung wins over participant wishes
- wish-round fallback when a workshop hits capacity
- participant left unassigned when nothing is free
- underfilled-workshop consolidation reassigns via remaining wishes
- workshop exactly meeting minTeilnehmer is kept
- one CREATE sync entry captured per resulting Zuteilung

npm test green (9 tests). Plan verification section updated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 16:29:02 +02:00
linusandClaude Sonnet 5 648989a51b feat(backend): add GemeindeController for LT congregation CRUD
Fills the plan's known gap where Gemeinde existed only as a Prisma model.
GemeindeModule exposes Leitungsteam-only create/list/get/update/delete
under /api/gemeinde, each mutation captured into the sync log like the
other feature services. Unique-name-per-KC violations surface as 409.
Docs (plan + backend README) updated to drop the gap and next-step item.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 16:25:14 +02:00
linusandClaude Sonnet 5 8ec127c0fb feat(backend): implement phases 0-6 (auth, kc, wahl, files, chat, sync)
Full NestJS backend for the KC-App platform:
- auth: Authentik OIDC resource-server strategy + guest invite-code JWT
  login, plus TokenVerificationService for the WS handshake path
- kc: Leitungsteam-only KC (event) creation/listing
- wahl: Wahl/Workshop admin, Force-Zuteilung overrides, ZuteilungService
  (port of the WP plugin's kc_run_zuteilung), CSV export
- files: LT-only upload with visibility tiers; list/download filtered by
  caller tier; StorageProvider abstraction (WebDAV/Nextcloud default, S3)
- chat: Gemeinde group / DM / LT-wide / broadcast channels; REST + raw ws
  gateway sharing ChatService access rules
- sync: append-only SyncLogEntry replication log + local<->cloud
  push/pull scheduler, shared-secret guarded
- common: Role enum, @Roles decorator, KC-scoped RolesGuard (LT global)
- serves client/web/ interim static web client under / (API under /api)

Typecheck, nest build and boot test pass; needs real Postgres/Authentik/
Nextcloud to run end to end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 16:23:45 +02:00
102 changed files with 8237 additions and 141 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"projectId": "5fe999f9-fbff-4a09-a987-48c4e7540b38"
}
+17
View File
@@ -0,0 +1,17 @@
**/node_modules
**/.dart_tool
**/coverage
**/*.log
.git
.github
backend/dist
# Flutter platform scaffolding / caches — the build/web bundle IS needed.
client/app/android
client/app/ios
client/app/linux
client/app/macos
client/app/windows
client/app/.dart_tool
# Secrets: passed at runtime via env_file / bind mount, never baked in.
backend/.env
backend/serviceAccount.json
+58 -2
View File
@@ -1,10 +1,66 @@
# Postgres connection used by Prisma # Postgres connection used by Prisma
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/kcapp?schema=public" DATABASE_URL="postgresql://postgres:postgres@localhost:5432/kcapp?schema=public"
# Authentik OIDC issuer, e.g. https://auth.example.org/application/o/kc-app/ # Authentik OIDC issuer (trailing slash optional — both forms are accepted).
AUTHENTIK_ISSUER_URL="https://authentik.example.org/application/o/kc-app" AUTHENTIK_ISSUER_URL="https://sso.konfi-castle.com/application/o/konfi-castle-app"
# Name of the Authentik group whose members are Leitungsteam. Mirrored to
# User.isLeitungsteam on every login (the access token must carry a `groups`
# claim; add the "groups" scope to the Authentik provider).
AUTHENTIK_LEITUNGSTEAM_GROUP="KC-APP-LT"
# Secret used to sign guest/Konfi session tokens (local accounts only) # Secret used to sign guest/Konfi session tokens (local accounts only)
GUEST_JWT_SECRET="change-me" GUEST_JWT_SECRET="change-me"
# Secret used to sign local Gemeinde Teamer session tokens (password login)
TEAM_JWT_SECRET="change-me-too"
PORT=3000 PORT=3000
# Public base URL of the app, used to build links in outgoing emails.
APP_BASE_URL="http://localhost:3000"
# Email: defaults to "log" (writes what it would send to the log, no
# delivery). Set MAIL_PROVIDER=smtp plus the SMTP_* vars + MAIL_FROM to
# actually send Gemeinde-Teamer invite emails.
MAIL_PROVIDER="log"
MAIL_FROM="KC-App <no-reply@example.org>"
SMTP_HOST="smtp.example.org"
SMTP_PORT=587
SMTP_SECURE="false"
SMTP_USER=""
SMTP_PASS=""
# Push: defaults to "log" (no delivery). Set PUSH_PROVIDER=fcm plus
# FCM_PROJECT_ID and GOOGLE_APPLICATION_CREDENTIALS (path to a Firebase
# service-account JSON with the "Firebase Cloud Messaging API" enabled) to
# send real notifications via FCM HTTP v1.
PUSH_PROVIDER="log"
FCM_PROJECT_ID="konfi-castle-app"
GOOGLE_APPLICATION_CREDENTIALS="/absolute/path/to/serviceAccount.json"
# File storage: defaults to Nextcloud via WebDAV; set STORAGE_PROVIDER=s3 to
# use an S3-compatible bucket instead (see S3_* vars below).
STORAGE_PROVIDER="webdav"
WEBDAV_URL="https://nextcloud.example.org/remote.php/dav/files/kc-app"
WEBDAV_USERNAME="kc-app"
WEBDAV_PASSWORD="change-me"
# Only used when STORAGE_PROVIDER=s3
S3_BUCKET="kc-app"
S3_REGION="auto"
S3_ENDPOINT=""
S3_FORCE_PATH_STYLE="false"
S3_ACCESS_KEY_ID=""
S3_SECRET_ACCESS_KEY=""
# Unique id for THIS server instance (local on-site vs. cloud); used to tag
# replication log entries and avoid echoing changes back to their origin.
SERVER_ID="change-me-uuid"
# Local/cloud sync: set on the LOCAL (on-site) server to periodically push/
# pull against the cloud instance's API base URL. Leave SYNC_ENABLED=false
# on the cloud server (it only needs to expose /sync/ingest + /sync/export).
SYNC_ENABLED="false"
SYNC_PEER_URL="https://kc-app-cloud.example.org/api"
SYNC_SHARED_SECRET="change-me"
+51
View File
@@ -0,0 +1,51 @@
name: CybeDefend Security Scan
on:
push:
branches:
- main
- master
- 'feat/**'
pull_request:
branches:
- main
- master
jobs:
cybedefend_scan:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Run CybeDefend Security Scan
env:
CYBEDEFEND_PAT: ${{ secrets.CYBEDEFEND_PAT }}
CYBEDEFEND_PROJECT_ID: ${{ secrets.CYBEDEFEND_PROJECT_ID }}
run: |
docker run --rm \
-v "${{ gitea.workspace }}":/src -w /src \
-e CYBEDEFEND_PAT="$CYBEDEFEND_PAT" \
-e CYBEDEFEND_PROJECT_ID="$CYBEDEFEND_PROJECT_ID" \
ghcr.io/cybedefend/cybedefend-cli:latest \
scan --dir . --region eu --ci --break-on-severity critical
- name: Fetch detailed SARIF results
if: always()
env:
CYBEDEFEND_PAT: ${{ secrets.CYBEDEFEND_PAT }}
CYBEDEFEND_PROJECT_ID: ${{ secrets.CYBEDEFEND_PROJECT_ID }}
run: |
docker run --rm \
-v "${{ gitea.workspace }}":/src -w /src \
-e CYBEDEFEND_PAT="$CYBEDEFEND_PAT" \
-e CYBEDEFEND_PROJECT_ID="$CYBEDEFEND_PROJECT_ID" \
ghcr.io/cybedefend/cybedefend-cli:latest \
results --project-id "$CYBEDEFEND_PROJECT_ID" --all --output sarif --filename results.sarif --ci
- name: Upload scan results as artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: cybedefend-results
path: results.sarif
+4
View File
@@ -3,3 +3,7 @@ dist
coverage coverage
.env .env
*.log *.log
# Firebase service account (secret)
serviceAccount.json
*.serviceAccount.json
+37
View File
@@ -0,0 +1,37 @@
# syntax=docker/dockerfile:1
#
# Server-only image (this repo has no Flutter client). The web client is
# built in the KC-APP client repo and its `build/web` output is mounted
# into the container at runtime via WEB_CLIENT_DIR (see docker-compose.yml).
# --- 1. Backend build ---------------------------------------------------------
FROM node:20-bookworm-slim AS api-build
WORKDIR /src
# Prisma detects the OpenSSL version at `generate` time to pick the matching
# query engine binary; without OpenSSL present here it silently defaults to
# openssl-1.1.x, which then fails to load in the runtime stage (openssl 3.0.x).
RUN apt-get update && apt-get install -y --no-install-recommends openssl \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npx prisma generate && npm run build
# --- 2. Runtime -------------------------------------------------------------
FROM node:20-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
# Prisma needs OpenSSL at runtime.
RUN apt-get update && apt-get install -y --no-install-recommends openssl \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd -g 1001 kc-user && useradd -u 1001 -g kc-user -m -d /home/kc-user kc-user
COPY --from=api-build --chown=kc-user:kc-user /src/node_modules ./node_modules
COPY --from=api-build --chown=kc-user:kc-user /src/dist ./dist
COPY --from=api-build --chown=kc-user:kc-user /src/prisma ./prisma
RUN chown -R kc-user:kc-user /app
USER kc-user
# Web client bundle is bind-mounted at runtime, not baked into the image;
# app.module reads WEB_CLIENT_DIR. See docker-compose.yml.
EXPOSE 3000
# Apply pending migrations, then boot.
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main.js"]
+126 -12
View File
@@ -1,26 +1,46 @@
# KC-App Backend # KC-App Backend
NestJS API for the KC-App platform (see repo root README + plan for NestJS API for the KC-App platform. Split out of the main KC-APP monorepo
architecture context). (https://git.konfi-castle.com/linus/KC-APP); the Flutter clients live there.
## Setup ## Setup
```bash ```bash
npm install npm install
cp .env.example .env # then fill in DATABASE_URL / AUTHENTIK_ISSUER_URL / GUEST_JWT_SECRET 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 generate
npx prisma migrate dev --name init # requires a running PostgreSQL instance npx prisma migrate dev --name init # requires a running PostgreSQL instance
npm run start:dev 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 ## Auth model
- Team members (Leitungsteam, Gemeinde Verantwortliche, Gemeinde Teamer) are - Leitungsteam and Gemeinde Verantwortliche sign in with Authentik (the
provisioned in Authentik; this API acts as an OIDC **resource server**, "Konfi-Castle-ID"); this API acts as an OIDC **resource server**, verifying
verifying access tokens against Authentik's JWKS (`AuthentikStrategy`) and access tokens against Authentik's JWKS (`AuthentikStrategy`). The local
then resolving local `Membership` rows to determine role + KC/Gemeinde `User` is provisioned just-in-time on first login from the token claims
scope. Clients perform the actual Authorization Code + PKCE flow against (`resolveOrProvisionAuthentikUser`), and `User.isLeitungsteam` is
Authentik directly. 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 - Guests/Konfis get a temporary local account (first/last name required, no
Authentik) created via `POST /auth/guest` with a KC invite code, returning Authentik) created via `POST /auth/guest` with a KC invite code, returning
a JWT signed with `GUEST_JWT_SECRET`. a JWT signed with `GUEST_JWT_SECRET`.
@@ -28,10 +48,104 @@ npm run start:dev
## Modules implemented so far ## Modules implemented so far
- `prisma/` — shared `PrismaClient` provider. - `prisma/` — shared `PrismaClient` provider.
- `auth/` — Authentik resource-server strategy + guest invite-code login. - `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. - `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, Broadcast (Konfis lesen nur), and free-form `GRUPPE`
chats. 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.
- `POST /chat/:kcId/gruppen` lets a Leitungsteam member (any KC) or a
Gemeinde Verantwortliche/r (their own KC — `RolesGuard`'s kcId scoping)
create a `GRUPPE` channel with any mix of team users and Konfis (guests)
from that KC as initial participants (`participantUserIds`,
`participantGuestIds`); the creator is always included. Unlike
`GEMEINDE_GRUPPE`, membership isn't derived from `Gemeinde` — every
participant is an explicit `ChatParticipant` row, so a Konfi (who always
belongs to exactly one Gemeinde) can be added regardless of which
Gemeinde the chat's creator manages.
- `POST` / `DELETE /chat/gruppen/:channelId/participants` (body
`{ userId }` or `{ guestId }`) add/remove a participant afterwards.
Allowed for the channel's creator, any Leitungsteam member, or a
Verantwortliche/r of that KC — not the participants themselves, and not
guests.
- `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, - `common/``Role` enum, `@Roles()` decorator, `RolesGuard` (KC-scoped,
Leitungsteam roles are global across all KCs). Leitungsteam roles are global across all KCs).
Not yet implemented: Wahl/Workshop/Zuteilung engine, file sharing, chat All planned backend features are implemented (`prisma/migrations/` holds the
realtime gateway, local/cloud sync engine. 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.
+21
View File
@@ -0,0 +1,21 @@
// TEMPORARY TEST FILE — intentionally vulnerable code to trigger CybeDefend scan
// Safe to delete after the scan demo.
const AWS_ACCESS_KEY = "AKIAABCDEFGHIJKLMNOP"; // hardcoded secret (should trigger secret scanner)
const DB_PASSWORD = "SuperSecret123!"; // hardcoded credential
const mysql = require('mysql');
function getUser(db, userId) {
// SQL injection: string concatenation of user input directly into query
const query = "SELECT * FROM users WHERE id = '" + userId + "'";
return db.query(query);
}
function runCommand(userInput) {
const { exec } = require('child_process');
// command injection: unsanitized user input passed to shell
exec("echo " + userInput);
}
module.exports = { getUser, runCommand, AWS_ACCESS_KEY, DB_PASSWORD };
+48
View File
@@ -0,0 +1,48 @@
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: kcapp
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d kcapp"]
interval: 5s
timeout: 5s
retries: 10
api:
build:
context: .
dockerfile: Dockerfile
depends_on:
db:
condition: service_healthy
# All non-DB config comes from .env (needs Docker Compose v2, which
# strips surrounding quotes). DATABASE_URL and the FCM credential path
# are overridden below for the container.
env_file:
- .env
environment:
DATABASE_URL: postgresql://postgres:postgres@db:5432/kcapp?schema=public
PORT: "3000"
GOOGLE_APPLICATION_CREDENTIALS: /app/serviceAccount.json
APP_BASE_URL: http://localhost:3010
WEB_CLIENT_DIR: /app/web
ports:
- "3010:3000"
volumes:
# Firebase service account — kept out of the image, mounted read-only.
- ./serviceAccount.json:/app/serviceAccount.json:ro
# Pre-built Flutter web bundle, built separately in the KC-APP client
# repo (flutter build web --release) and mounted read-only here.
# Set WEB_CLIENT_BUILD_PATH (e.g. in .env) to that build/web directory;
# defaults to a sibling ../KC-APP checkout.
- ${WEB_CLIENT_BUILD_PATH:-../KC-APP/client/app/build/web}:/app/web:ro
volumes:
pgdata:
+961 -13
View File
File diff suppressed because it is too large Load Diff
+20 -2
View File
@@ -21,6 +21,7 @@
"prisma:migrate": "prisma migrate dev" "prisma:migrate": "prisma migrate dev"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.679.0",
"@nestjs/common": "^10.4.15", "@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0", "@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15", "@nestjs/core": "^10.4.15",
@@ -28,24 +29,35 @@
"@nestjs/passport": "^10.0.3", "@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.15", "@nestjs/platform-express": "^10.4.15",
"@nestjs/platform-ws": "^10.4.15", "@nestjs/platform-ws": "^10.4.15",
"@nestjs/schedule": "^4.1.1",
"@nestjs/serve-static": "^4.0.2",
"@nestjs/websockets": "^10.4.15", "@nestjs/websockets": "^10.4.15",
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"bcryptjs": "^3.0.3",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.1.0", "jwks-rsa": "^3.1.0",
"multer": "^2.0.1",
"nodemailer": "^7.0.13",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"webdav": "^5.7.1",
"ws": "^8.18.0" "ws": "^8.18.0"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^10.4.9", "@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3", "@nestjs/schematics": "^10.2.3",
"@nestjs/testing": "^10.4.15", "@nestjs/testing": "^10.4.15",
"@types/bcryptjs": "^2.4.6",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.7",
"@types/multer": "^1.4.12",
"@types/node": "^20.17.9", "@types/node": "^20.17.9",
"@types/nodemailer": "^6.4.24",
"@types/passport": "^1.0.17", "@types/passport": "^1.0.17",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
@@ -67,13 +79,19 @@
"typescript": "^5.6.3" "typescript": "^5.6.3"
}, },
"jest": { "jest": {
"moduleFileExtensions": ["js", "json", "ts"], "moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src", "rootDir": "src",
"testRegex": ".*\\.spec\\.ts$", "testRegex": ".*\\.spec\\.ts$",
"transform": { "transform": {
"^.+\\.(t|j)s$": "ts-jest" "^.+\\.(t|j)s$": "ts-jest"
}, },
"collectCoverageFrom": ["**/*.(t|j)s"], "collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage", "coverageDirectory": "../coverage",
"testEnvironment": "node" "testEnvironment": "node"
} }
@@ -0,0 +1,328 @@
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('LEITUNGSTEAM', 'GEMEINDE_VERANTWORTLICHER', 'GEMEINDE_TEAMER');
-- CreateEnum
CREATE TYPE "MembershipStatus" AS ENUM ('ACTIVE', 'PENDING');
-- CreateEnum
CREATE TYPE "FileVisibility" AS ENUM ('ALLE', 'ALLE_AUSSER_KONFIS', 'NUR_LT');
-- CreateEnum
CREATE TYPE "ChatChannelType" AS ENUM ('GEMEINDE_GRUPPE', 'DIREKT', 'LT_UEBERGREIFEND', 'BROADCAST');
-- CreateEnum
CREATE TYPE "SyncOperation" AS ENUM ('CREATE', 'UPDATE', 'DELETE');
-- CreateTable
CREATE TABLE "Kc" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"inviteCode" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Kc_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Gemeinde" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"kcId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Gemeinde_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"authentikSub" TEXT,
"email" TEXT NOT NULL,
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"passwordHash" TEXT,
"kcId" TEXT,
"isLeitungsteam" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Membership" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"kcId" TEXT NOT NULL,
"gemeindeId" TEXT,
"role" "Role" NOT NULL,
"status" "MembershipStatus" NOT NULL DEFAULT 'ACTIVE',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Membership_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GuestAccount" (
"id" TEXT NOT NULL,
"kcId" TEXT NOT NULL,
"gemeindeId" TEXT,
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GuestAccount_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TeamerInvite" (
"id" TEXT NOT NULL,
"kcId" TEXT NOT NULL,
"gemeindeId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"email" TEXT,
"maxUses" INTEGER,
"usedCount" INTEGER NOT NULL DEFAULT 0,
"expiresAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
"createdByUserId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TeamerInvite_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Wahl" (
"id" TEXT NOT NULL,
"kcId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"datumsSchluessel" TEXT NOT NULL,
"teil" TEXT NOT NULL,
"isOpen" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Wahl_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Workshop" (
"id" TEXT NOT NULL,
"wahlId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"kapazitaet" INTEGER NOT NULL,
"minTeilnehmer" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "Workshop_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Teilnehmer" (
"id" TEXT NOT NULL,
"wahlId" TEXT NOT NULL,
"guestAccountId" TEXT NOT NULL,
"prioritaeten" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Teilnehmer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ForceZuteilung" (
"id" TEXT NOT NULL,
"wahlId" TEXT NOT NULL,
"teilnehmerId" TEXT NOT NULL,
"workshopId" TEXT NOT NULL,
CONSTRAINT "ForceZuteilung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Zuteilung" (
"id" TEXT NOT NULL,
"teilnehmerId" TEXT NOT NULL,
"workshopId" TEXT,
"wunschRang" INTEGER NOT NULL DEFAULT -1,
"isForced" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Zuteilung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "File" (
"id" TEXT NOT NULL,
"kcId" TEXT NOT NULL,
"storageKey" TEXT NOT NULL,
"filename" TEXT NOT NULL,
"visibility" "FileVisibility" NOT NULL,
"uploadedById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "File_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ChatChannel" (
"id" TEXT NOT NULL,
"kcId" TEXT NOT NULL,
"type" "ChatChannelType" NOT NULL,
"gemeindeId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ChatChannel_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ChatParticipant" (
"id" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ChatParticipant_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ChatMessage" (
"id" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"senderUserId" TEXT,
"senderGuestId" TEXT,
"body" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ChatMessage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SyncLogEntry" (
"id" TEXT NOT NULL,
"sequence" SERIAL NOT NULL,
"model" TEXT NOT NULL,
"recordId" TEXT NOT NULL,
"operation" "SyncOperation" NOT NULL,
"payload" JSONB NOT NULL,
"originId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SyncLogEntry_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SyncCursor" (
"id" TEXT NOT NULL,
"peerId" TEXT NOT NULL,
"lastPushedSequence" INTEGER NOT NULL DEFAULT 0,
"lastPulledSequence" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "SyncCursor_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Kc_inviteCode_key" ON "Kc"("inviteCode");
-- CreateIndex
CREATE UNIQUE INDEX "Gemeinde_kcId_name_key" ON "Gemeinde"("kcId", "name");
-- CreateIndex
CREATE UNIQUE INDEX "User_authentikSub_key" ON "User"("authentikSub");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Membership_userId_kcId_gemeindeId_key" ON "Membership"("userId", "kcId", "gemeindeId");
-- CreateIndex
CREATE UNIQUE INDEX "TeamerInvite_token_key" ON "TeamerInvite"("token");
-- CreateIndex
CREATE UNIQUE INDEX "Teilnehmer_wahlId_guestAccountId_key" ON "Teilnehmer"("wahlId", "guestAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "ForceZuteilung_teilnehmerId_key" ON "ForceZuteilung"("teilnehmerId");
-- CreateIndex
CREATE UNIQUE INDEX "Zuteilung_teilnehmerId_key" ON "Zuteilung"("teilnehmerId");
-- CreateIndex
CREATE UNIQUE INDEX "ChatParticipant_channelId_userId_key" ON "ChatParticipant"("channelId", "userId");
-- CreateIndex
CREATE UNIQUE INDEX "SyncCursor_peerId_key" ON "SyncCursor"("peerId");
-- AddForeignKey
ALTER TABLE "Gemeinde" ADD CONSTRAINT "Gemeinde_kcId_fkey" FOREIGN KEY ("kcId") REFERENCES "Kc"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_kcId_fkey" FOREIGN KEY ("kcId") REFERENCES "Kc"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Membership" ADD CONSTRAINT "Membership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Membership" ADD CONSTRAINT "Membership_kcId_fkey" FOREIGN KEY ("kcId") REFERENCES "Kc"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Membership" ADD CONSTRAINT "Membership_gemeindeId_fkey" FOREIGN KEY ("gemeindeId") REFERENCES "Gemeinde"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GuestAccount" ADD CONSTRAINT "GuestAccount_kcId_fkey" FOREIGN KEY ("kcId") REFERENCES "Kc"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GuestAccount" ADD CONSTRAINT "GuestAccount_gemeindeId_fkey" FOREIGN KEY ("gemeindeId") REFERENCES "Gemeinde"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TeamerInvite" ADD CONSTRAINT "TeamerInvite_kcId_fkey" FOREIGN KEY ("kcId") REFERENCES "Kc"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TeamerInvite" ADD CONSTRAINT "TeamerInvite_gemeindeId_fkey" FOREIGN KEY ("gemeindeId") REFERENCES "Gemeinde"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Wahl" ADD CONSTRAINT "Wahl_kcId_fkey" FOREIGN KEY ("kcId") REFERENCES "Kc"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Workshop" ADD CONSTRAINT "Workshop_wahlId_fkey" FOREIGN KEY ("wahlId") REFERENCES "Wahl"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Teilnehmer" ADD CONSTRAINT "Teilnehmer_wahlId_fkey" FOREIGN KEY ("wahlId") REFERENCES "Wahl"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Teilnehmer" ADD CONSTRAINT "Teilnehmer_guestAccountId_fkey" FOREIGN KEY ("guestAccountId") REFERENCES "GuestAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ForceZuteilung" ADD CONSTRAINT "ForceZuteilung_wahlId_fkey" FOREIGN KEY ("wahlId") REFERENCES "Wahl"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ForceZuteilung" ADD CONSTRAINT "ForceZuteilung_teilnehmerId_fkey" FOREIGN KEY ("teilnehmerId") REFERENCES "Teilnehmer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ForceZuteilung" ADD CONSTRAINT "ForceZuteilung_workshopId_fkey" FOREIGN KEY ("workshopId") REFERENCES "Workshop"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Zuteilung" ADD CONSTRAINT "Zuteilung_teilnehmerId_fkey" FOREIGN KEY ("teilnehmerId") REFERENCES "Teilnehmer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Zuteilung" ADD CONSTRAINT "Zuteilung_workshopId_fkey" FOREIGN KEY ("workshopId") REFERENCES "Workshop"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "File" ADD CONSTRAINT "File_kcId_fkey" FOREIGN KEY ("kcId") REFERENCES "Kc"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatChannel" ADD CONSTRAINT "ChatChannel_kcId_fkey" FOREIGN KEY ("kcId") REFERENCES "Kc"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatParticipant" ADD CONSTRAINT "ChatParticipant_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "ChatChannel"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatParticipant" ADD CONSTRAINT "ChatParticipant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatMessage" ADD CONSTRAINT "ChatMessage_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "ChatChannel"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatMessage" ADD CONSTRAINT "ChatMessage_senderUserId_fkey" FOREIGN KEY ("senderUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatMessage" ADD CONSTRAINT "ChatMessage_senderGuestId_fkey" FOREIGN KEY ("senderGuestId") REFERENCES "GuestAccount"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,21 @@
-- CreateTable
CREATE TABLE "DeviceToken" (
"id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"platform" TEXT NOT NULL,
"userId" TEXT,
"guestAccountId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "DeviceToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "DeviceToken_token_key" ON "DeviceToken"("token");
-- AddForeignKey
ALTER TABLE "DeviceToken" ADD CONSTRAINT "DeviceToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DeviceToken" ADD CONSTRAINT "DeviceToken_guestAccountId_fkey" FOREIGN KEY ("guestAccountId") REFERENCES "GuestAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,43 @@
-- DropIndex
DROP INDEX "Teilnehmer_wahlId_guestAccountId_key";
-- AlterTable
ALTER TABLE "Wahl" ADD COLUMN "beschreibung" TEXT,
ADD COLUMN "phasenAnzahl" INTEGER NOT NULL DEFAULT 1;
-- AlterTable
ALTER TABLE "Workshop" ADD COLUMN "beschreibung" TEXT,
ADD COLUMN "phase" INTEGER NOT NULL DEFAULT 1;
-- AlterTable
ALTER TABLE "Teilnehmer" ADD COLUMN "phase" INTEGER NOT NULL DEFAULT 1;
-- CreateTable
CREATE TABLE "VerantwortlicheInvite" (
"id" TEXT NOT NULL,
"kcId" TEXT NOT NULL,
"gemeindeId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"email" TEXT,
"maxUses" INTEGER,
"usedCount" INTEGER NOT NULL DEFAULT 0,
"expiresAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
"createdByUserId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "VerantwortlicheInvite_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "VerantwortlicheInvite_token_key" ON "VerantwortlicheInvite"("token");
-- CreateIndex
CREATE UNIQUE INDEX "Teilnehmer_wahlId_guestAccountId_phase_key" ON "Teilnehmer"("wahlId", "guestAccountId", "phase");
-- AddForeignKey
ALTER TABLE "VerantwortlicheInvite" ADD CONSTRAINT "VerantwortlicheInvite_kcId_fkey" FOREIGN KEY ("kcId") REFERENCES "Kc"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VerantwortlicheInvite" ADD CONSTRAINT "VerantwortlicheInvite_gemeindeId_fkey" FOREIGN KEY ("gemeindeId") REFERENCES "Gemeinde"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,31 @@
-- AlterTable
ALTER TABLE "SyncLogEntry" ADD COLUMN "occurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- CreateTable
CREATE TABLE "SyncRecordVersion" (
"id" TEXT NOT NULL,
"model" TEXT NOT NULL,
"recordId" TEXT NOT NULL,
"lastWriteAt" TIMESTAMP(3) NOT NULL,
"lastWriteOrigin" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SyncRecordVersion_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SyncConflict" (
"id" TEXT NOT NULL,
"model" TEXT NOT NULL,
"recordId" TEXT NOT NULL,
"winningOrigin" TEXT NOT NULL,
"losingOrigin" TEXT NOT NULL,
"winningPayload" JSONB NOT NULL,
"losingPayload" JSONB NOT NULL,
"detectedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SyncConflict_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "SyncRecordVersion_model_recordId_key" ON "SyncRecordVersion"("model", "recordId");
@@ -0,0 +1,17 @@
-- AlterEnum
ALTER TYPE "ChatChannelType" ADD VALUE 'GRUPPE';
-- AlterTable
ALTER TABLE "ChatChannel" ADD COLUMN "createdByUserId" TEXT,
ADD COLUMN "name" TEXT;
-- AlterTable
ALTER TABLE "ChatParticipant" ADD COLUMN "guestAccountId" TEXT,
ALTER COLUMN "userId" DROP NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "ChatParticipant_channelId_guestAccountId_key" ON "ChatParticipant"("channelId", "guestAccountId");
-- AddForeignKey
ALTER TABLE "ChatParticipant" ADD CONSTRAINT "ChatParticipant_guestAccountId_fkey" FOREIGN KEY ("guestAccountId") REFERENCES "GuestAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
+222 -7
View File
@@ -22,6 +22,9 @@ model Kc {
files File[] files File[]
channels ChatChannel[] channels ChatChannel[]
guests GuestAccount[] guests GuestAccount[]
localUsers User[]
teamerInvites TeamerInvite[]
verantwortlicheInvites VerantwortlicheInvite[]
} }
/// A local congregation/community participating in one Kc. /// A local congregation/community participating in one Kc.
@@ -34,6 +37,8 @@ model Gemeinde {
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
memberships Membership[] memberships Membership[]
guests GuestAccount[] guests GuestAccount[]
teamerInvites TeamerInvite[]
verantwortlicheInvites VerantwortlicheInvite[]
@@unique([kcId, name]) @@unique([kcId, name])
} }
@@ -44,17 +49,38 @@ enum Role {
GEMEINDE_TEAMER GEMEINDE_TEAMER
} }
/// Authentik-backed user (team member with elevated rights). /// PENDING memberships come from self-registration and grant no rights until
/// a Leitungsteam member approves them. Everything created by LT/Verantwortliche
/// directly is ACTIVE from the start.
enum MembershipStatus {
ACTIVE
PENDING
}
/// A team member account. Leitungsteam and Gemeinde Verantwortliche are
/// Authentik-backed (`authentikSub` set, `passwordHash` null). Gemeinde
/// Teamer are local accounts created by a Verantwortliche/r (`passwordHash`
/// set, `authentikSub` null, `kcId` set) and, like guests, scoped to one KC.
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
authentikSub String @unique authentikSub String? @unique
email String @unique email String @unique
firstName String firstName String
lastName String lastName String
passwordHash String?
kcId String?
/// Mirrored from the caller's Authentik group membership on every login.
/// LEITUNGSTEAM is global (not KC-scoped), so it lives here rather than as
/// a per-KC Membership row; the auth layer synthesises a virtual global
/// LEITUNGSTEAM membership from this flag.
isLeitungsteam Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
kc Kc? @relation(fields: [kcId], references: [id], onDelete: Cascade)
memberships Membership[] memberships Membership[]
messages ChatMessage[] messages ChatMessage[]
chatParticipations ChatParticipant[]
deviceTokens DeviceToken[]
} }
/// Scopes a User's role to a specific Kc (and Gemeinde, if applicable). /// Scopes a User's role to a specific Kc (and Gemeinde, if applicable).
@@ -65,6 +91,7 @@ model Membership {
kcId String kcId String
gemeindeId String? gemeindeId String?
role Role role Role
status MembershipStatus @default(ACTIVE)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -87,37 +114,115 @@ model GuestAccount {
gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade) gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade)
messages ChatMessage[] messages ChatMessage[]
teilnehmer Teilnehmer[] teilnehmer Teilnehmer[]
deviceTokens DeviceToken[]
chatParticipations ChatParticipant[]
}
/// A push-notification target (FCM registration token) bound to whoever
/// registered it — a team `User` or a `GuestAccount`. Replicated so a
/// notification can be sent from either server.
model DeviceToken {
id String @id @default(cuid())
token String @unique
platform String
userId String?
guestAccountId String?
createdAt DateTime @default(now())
lastSeenAt DateTime @default(now())
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
guestAccount GuestAccount? @relation(fields: [guestAccountId], references: [id], onDelete: Cascade)
}
/// Invitation issued by a Gemeinde Verantwortliche/r so new Gemeinde Teamer
/// can self-register a local account for one Gemeinde. A group link leaves
/// `email` null and may be redeemed up to `maxUses` times (null = unlimited);
/// a personal invite pins `email` and defaults to a single use.
model TeamerInvite {
id String @id @default(cuid())
kcId String
gemeindeId String
token String @unique
email String?
maxUses Int?
usedCount Int @default(0)
expiresAt DateTime?
revokedAt DateTime?
createdByUserId String
createdAt DateTime @default(now())
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
gemeinde Gemeinde @relation(fields: [gemeindeId], references: [id], onDelete: Cascade)
}
/// Invitation issued by a Leitungsteam member so a person can register as
/// Gemeinde Verantwortliche/r for a specific Gemeinde via their
/// Konfi-Castle-ID (Authentik) — skips the self-registration approval step
/// since a Leitungsteam member is vouching for them directly. A group link
/// leaves `email` null and may be redeemed up to `maxUses` times (null =
/// unlimited); a personal invite pins `email` and defaults to a single use.
model VerantwortlicheInvite {
id String @id @default(cuid())
kcId String
gemeindeId String
token String @unique
email String?
maxUses Int?
usedCount Int @default(0)
expiresAt DateTime?
revokedAt DateTime?
createdByUserId String
createdAt DateTime @default(now())
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
gemeinde Gemeinde @relation(fields: [gemeindeId], references: [id], onDelete: Cascade)
} }
/// A workshop election, scoped to a Kc; name carries a date key + "Teil". /// A workshop election, scoped to a Kc; name carries a date key + "Teil".
/// `phasenAnzahl` mirrors the WP plugin's `anzahl_einheiten`: a Wahl can run
/// several independent phases (e.g. morning/afternoon), each with its own
/// workshops, its own guest submission, and its own assignment run — a guest
/// submits once per phase, not once for the whole Wahl.
model Wahl { model Wahl {
id String @id @default(cuid()) id String @id @default(cuid())
kcId String kcId String
name String name String
datumsSchluessel String datumsSchluessel String
teil String teil String
beschreibung String?
phasenAnzahl Int @default(1)
isOpen Boolean @default(true) isOpen Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
workshops Workshop[] workshops Workshop[]
teilnehmer Teilnehmer[] teilnehmer Teilnehmer[]
forceZuteilungen ForceZuteilung[]
} }
/// A workshop offered in one phase of a Wahl. `phase` is 1-based and must be
/// <= the owning Wahl's `phasenAnzahl`.
model Workshop { model Workshop {
id String @id @default(cuid()) id String @id @default(cuid())
wahlId String wahlId String
phase Int @default(1)
name String name String
beschreibung String?
kapazitaet Int kapazitaet Int
minTeilnehmer Int @default(0)
wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade) wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade)
zuteilungen Zuteilung[] zuteilungen Zuteilung[]
forceZuteilungen ForceZuteilung[]
} }
/// A participant's submitted choices for a Wahl. /// A participant's submitted choices for one phase of a Wahl. A guest submits
/// separately per phase (matching the WP plugin), so the same guest can have
/// one row per (wahlId, phase).
model Teilnehmer { model Teilnehmer {
id String @id @default(cuid()) id String @id @default(cuid())
wahlId String wahlId String
phase Int @default(1)
guestAccountId String guestAccountId String
prioritaeten Json prioritaeten Json
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -125,20 +230,34 @@ model Teilnehmer {
wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade) wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade)
guestAccount GuestAccount @relation(fields: [guestAccountId], references: [id], onDelete: Cascade) guestAccount GuestAccount @relation(fields: [guestAccountId], references: [id], onDelete: Cascade)
zuteilung Zuteilung? zuteilung Zuteilung?
forceZuteilung ForceZuteilung?
@@unique([wahlId, guestAccountId]) @@unique([wahlId, guestAccountId, phase])
} }
/// Result of the assignment algorithm (or a manual force-assignment) for one Teilnehmer. /// Manual override set by LT before running the assignment algorithm; takes precedence.
model ForceZuteilung {
id String @id @default(cuid())
wahlId String
teilnehmerId String @unique
workshopId String
wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade)
teilnehmer Teilnehmer @relation(fields: [teilnehmerId], references: [id], onDelete: Cascade)
workshop Workshop @relation(fields: [workshopId], references: [id], onDelete: Cascade)
}
/// Result of the assignment algorithm for one Teilnehmer; workshopId is null if unassigned (no capacity left).
model Zuteilung { model Zuteilung {
id String @id @default(cuid()) id String @id @default(cuid())
teilnehmerId String @unique teilnehmerId String @unique
workshopId String workshopId String?
wunschRang Int @default(-1)
isForced Boolean @default(false) isForced Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
teilnehmer Teilnehmer @relation(fields: [teilnehmerId], references: [id], onDelete: Cascade) teilnehmer Teilnehmer @relation(fields: [teilnehmerId], references: [id], onDelete: Cascade)
workshop Workshop @relation(fields: [workshopId], references: [id], onDelete: Cascade) workshop Workshop? @relation(fields: [workshopId], references: [id], onDelete: SetNull)
} }
enum FileVisibility { enum FileVisibility {
@@ -164,6 +283,11 @@ enum ChatChannelType {
DIREKT DIREKT
LT_UEBERGREIFEND LT_UEBERGREIFEND
BROADCAST BROADCAST
/// Freely composed group chat: created by a Leitungsteam member or a
/// Gemeinde Verantwortliche/r (for their own KC), with an explicit,
/// mutable participant list (team users and/or guests) via ChatParticipant
/// - unlike GEMEINDE_GRUPPE, membership is not derived from Gemeinde.
GRUPPE
} }
model ChatChannel { model ChatChannel {
@@ -171,10 +295,35 @@ model ChatChannel {
kcId String kcId String
type ChatChannelType type ChatChannelType
gemeindeId String? gemeindeId String?
/// Display name; used by GRUPPE channels (optional for other types).
name String?
/// Who created the channel; only set for GRUPPE so far. Used to let the
/// creator manage participants alongside Leitungsteam/Verantwortliche.
createdByUserId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
messages ChatMessage[] messages ChatMessage[]
participants ChatParticipant[]
}
/// Explicit membership for DIREKT (1:1) and GRUPPE channels; other channel
/// types derive access from Membership/Gemeinde instead of this table.
/// Exactly one of userId/guestAccountId is set per row.
model ChatParticipant {
id String @id @default(cuid())
channelId String
userId String?
guestAccountId String?
createdAt DateTime @default(now())
channel ChatChannel @relation(fields: [channelId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
guestAccount GuestAccount? @relation(fields: [guestAccountId], references: [id], onDelete: Cascade)
@@unique([channelId, userId])
@@unique([channelId, guestAccountId])
} }
model ChatMessage { model ChatMessage {
@@ -189,3 +338,69 @@ model ChatMessage {
senderUser User? @relation(fields: [senderUserId], references: [id]) senderUser User? @relation(fields: [senderUserId], references: [id])
senderGuest GuestAccount? @relation(fields: [senderGuestId], references: [id]) senderGuest GuestAccount? @relation(fields: [senderGuestId], references: [id])
} }
enum SyncOperation {
CREATE
UPDATE
DELETE
}
/// Append-only log of local mutations, replicated to the peer server (local
/// <-> cloud). `originId` is the SERVER_ID that made the change, so applying
/// an incoming entry never gets re-captured/re-pushed back (no echo loops).
/// `occurredAt` is the wall-clock moment of the mutation itself (set at
/// capture time), distinct from `createdAt` which is just row-insert time -
/// conflict resolution compares `occurredAt`, never sync/network timing.
model SyncLogEntry {
id String @id @default(cuid())
sequence Int @default(autoincrement())
model String
recordId String
operation SyncOperation
payload Json
originId String
occurredAt DateTime @default(now())
createdAt DateTime @default(now())
}
/// Per-peer replication progress, kept on the side that initiates sync
/// (normally the local, on-site server, since it can always dial out to the
/// cloud even when the cloud can't reach into the event's local network).
model SyncCursor {
id String @id @default(cuid())
peerId String @unique
lastPushedSequence Int @default(0)
lastPulledSequence Int @default(0)
}
/// Last-write-wins register, one row per replicated record. Tracks the
/// wall-clock time and origin of whichever mutation - local or remote - is
/// currently considered authoritative for that record, so a concurrent edit
/// on both servers resolves deterministically by actual edit time instead
/// of by sync/network arrival order.
model SyncRecordVersion {
id String @id @default(cuid())
model String
recordId String
lastWriteAt DateTime
lastWriteOrigin String
updatedAt DateTime @updatedAt
@@unique([model, recordId])
}
/// Audit trail of detected conflicts: two servers wrote the same record
/// within the replication window. Resolution (last-write-wins by
/// occurredAt) still happens automatically and immediately - nothing here
/// blocks live sync - but Leitungsteam can review afterwards whether a
/// discarded edit needs to be manually reapplied.
model SyncConflict {
id String @id @default(cuid())
model String
recordId String
winningOrigin String
losingOrigin String
winningPayload Json
losingPayload Json
detectedAt DateTime @default(now())
}
+35
View File
@@ -0,0 +1,35 @@
/* Minimal dev seed: one KC + Gemeinde + open Wahl with workshops.
Run: node prisma/seed-dev.js (backend/.env must point at the dev DB) */
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const kc = await prisma.kc.upsert({
where: { inviteCode: 'DEV123' },
update: {},
create: { name: 'KC Dev 2026', inviteCode: 'DEV123' },
});
const gem = await prisma.gemeinde.upsert({
where: { kcId_name: { kcId: kc.id, name: 'Mustergemeinde' } },
update: {},
create: { kcId: kc.id, name: 'Mustergemeinde' },
});
let wahl = await prisma.wahl.findFirst({ where: { kcId: kc.id } });
if (!wahl) {
wahl = await prisma.wahl.create({
data: { kcId: kc.id, name: 'Samstag Teil 1', datumsSchluessel: '2026-06-13', teil: '1' },
});
await prisma.workshop.createMany({
data: [
{ wahlId: wahl.id, name: 'Töpfern', kapazitaet: 12, minTeilnehmer: 4 },
{ wahlId: wahl.id, name: 'Fußball', kapazitaet: 20, minTeilnehmer: 6 },
{ wahlId: wahl.id, name: 'Bandworkshop', kapazitaet: 8, minTeilnehmer: 3 },
],
});
}
console.log('KC', kc.id, 'invite', kc.inviteCode);
console.log('Gemeinde', gem.id);
console.log('Wahl', wahl.id);
}
main().finally(() => prisma.$disconnect());
+36
View File
@@ -1,15 +1,51 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { ServeStaticModule } from '@nestjs/serve-static';
import { existsSync } from 'fs';
import { join } from 'path';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { MailModule } from './mail/mail.module';
import { PushModule } from './push/push.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { KcModule } from './kc/kc.module'; import { KcModule } from './kc/kc.module';
import { GemeindeModule } from './gemeinde/gemeinde.module';
import { TeamerModule } from './teamer/teamer.module';
import { OnboardingModule } from './onboarding/onboarding.module';
import { WahlModule } from './wahl/wahl.module';
import { FilesModule } from './files/files.module';
import { ChatModule } from './chat/chat.module';
import { SyncModule } from './sync/sync.module';
// Prefer the Flutter web build (single entry point at :3000, incl. the OIDC
// redirect path /v1/auth/callback via SPA fallback). Falls back to the plain
// interim client if the Flutter build hasn't been produced yet.
const flutterWeb = join(__dirname, '..', '..', 'client', 'app', 'build', 'web');
const interimWeb = join(__dirname, '..', '..', 'client', 'web');
const webRoot =
process.env.WEB_CLIENT_DIR ?? (existsSync(flutterWeb) ? flutterWeb : interimWeb);
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ isGlobal: true }), ConfigModule.forRoot({ isGlobal: true }),
// Static web client; the REST API lives under /api (see main.ts) so it
// never collides. Unmatched non-file paths fall back to index.html so the
// client-side router owns routes like /v1/auth/callback.
ServeStaticModule.forRoot({
rootPath: webRoot,
exclude: ['/api*'],
}),
PrismaModule, PrismaModule,
MailModule,
PushModule,
SyncModule,
AuthModule, AuthModule,
KcModule, KcModule,
GemeindeModule,
TeamerModule,
OnboardingModule,
WahlModule,
FilesModule,
ChatModule,
], ],
}) })
export class AppModule {} export class AppModule {}
+68 -2
View File
@@ -1,14 +1,80 @@
import { Body, Controller, Post } from '@nestjs/common'; import { BadRequestException, Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { GuestAuthService } from './guest-auth.service'; import { GuestAuthService } from './guest-auth.service';
import { TeamAuthService } from './team-auth.service';
import { CreateGuestDto } from './dto/create-guest.dto'; import { CreateGuestDto } from './dto/create-guest.dto';
import { TeamLoginDto } from './dto/team-login.dto';
import { RegisterTeamerDto } from './dto/register-teamer.dto';
import { ResolveCodeDto } from './dto/resolve-code.dto';
import { CodeResolverService } from './code-resolver.service';
import { AuthenticatedRequest } from './authenticated-request';
import { GuestJwtPayload } from './guest-auth.service';
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
constructor(private readonly guestAuth: GuestAuthService) {} constructor(
private readonly guestAuth: GuestAuthService,
private readonly teamAuth: TeamAuthService,
private readonly codeResolver: CodeResolverService,
) {}
/// Single entry point for the unified login screen: the caller types one
/// "Code" and this classifies it (guest invite code, Teamer/Verantwortliche
/// invite token, Gemeinde name, email, or the "login" SSO keyword) so the
/// client can render the matching follow-up form. Never reveals *why* a
/// code didn't match — always the same 404 "Code ungültig".
@Post('resolve-code')
resolveCode(@Body() dto: ResolveCodeDto) {
return this.codeResolver.resolve(dto.code);
}
/// Returns the identity + scope behind whichever token was presented, so a
/// client can render a role-aware UI. `kind` is "guest" for a Konfi token,
/// "user" for an Authentik or local Teamer token.
@Get('me')
@UseGuards(AuthGuard(['authentik', 'team', 'guest']))
me(@Req() req: AuthenticatedRequest & { user: unknown }) {
const user = req.user as
| AuthenticatedRequest['user']
| GuestJwtPayload;
if (user && 'guestId' in user) {
return {
kind: 'guest',
guestId: user.guestId,
kcId: user.kcId,
gemeindeId: user.gemeindeId,
};
}
const u = user as NonNullable<AuthenticatedRequest['user']>;
return {
kind: 'user',
userId: u.userId,
email: u.email,
authentikSub: u.authentikSub,
memberships: u.memberships,
isLeitungsteam: u.memberships.some((m) => m.role === 'LEITUNGSTEAM'),
};
}
/// Redeems a KC invite code and registers a new temporary guest/Konfi account. /// Redeems a KC invite code and registers a new temporary guest/Konfi account.
@Post('guest') @Post('guest')
createGuest(@Body() dto: CreateGuestDto) { createGuest(@Body() dto: CreateGuestDto) {
return this.guestAuth.createGuest(dto.inviteCode, dto.firstName, dto.lastName); return this.guestAuth.createGuest(dto.inviteCode, dto.firstName, dto.lastName);
} }
/// Password login for local Gemeinde Teamer accounts — by Gemeinde name
/// (the normal path) or by email (legacy/personal accounts).
@Post('team-login')
teamLogin(@Body() dto: TeamLoginDto) {
if (!dto.email && !dto.gemeindeName) {
throw new BadRequestException('email or gemeindeName is required');
}
return this.teamAuth.login({ email: dto.email, gemeindeName: dto.gemeindeName }, dto.password);
}
/// Self-registration for a Gemeinde Teamer via an invite token/link.
@Post('teamer/register')
registerTeamer(@Body() dto: RegisterTeamerDto) {
return this.teamAuth.registerFromInvite(dto);
}
} }
+15 -1
View File
@@ -4,7 +4,12 @@ import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport'; import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { GuestAuthService } from './guest-auth.service'; import { GuestAuthService } from './guest-auth.service';
import { TeamAuthService } from './team-auth.service';
import { AuthentikStrategy } from './authentik.strategy'; import { AuthentikStrategy } from './authentik.strategy';
import { GuestJwtStrategy } from './guest-jwt.strategy';
import { TeamJwtStrategy } from './team-jwt.strategy';
import { TokenVerificationService } from './token-verification.service';
import { CodeResolverService } from './code-resolver.service';
@Module({ @Module({
imports: [ imports: [
@@ -18,6 +23,15 @@ import { AuthentikStrategy } from './authentik.strategy';
}), }),
], ],
controllers: [AuthController], controllers: [AuthController],
providers: [GuestAuthService, AuthentikStrategy], providers: [
GuestAuthService,
TeamAuthService,
AuthentikStrategy,
GuestJwtStrategy,
TeamJwtStrategy,
TokenVerificationService,
CodeResolverService,
],
exports: [TokenVerificationService, TeamAuthService, CodeResolverService],
}) })
export class AuthModule {} export class AuthModule {}
+10 -2
View File
@@ -1,5 +1,6 @@
import { Request } from 'express'; import { Request } from 'express';
import { Role } from '../common/role.enum'; import { Role } from '../common/role.enum';
import { GuestJwtPayload } from './guest-auth.service';
export interface AuthenticatedMembership { export interface AuthenticatedMembership {
kcId: string; kcId: string;
@@ -7,10 +8,12 @@ export interface AuthenticatedMembership {
role: Role; role: Role;
} }
/// Shape attached to req.user by JwtStrategy after validating an access token. /// Shape attached to req.user after validating an access token — by
/// AuthentikStrategy for Authentik-backed members, or by TeamJwtStrategy for
/// local Gemeinde Teamer (then `authentikSub` is null).
export interface AuthenticatedUser { export interface AuthenticatedUser {
userId: string; userId: string;
authentikSub: string; authentikSub: string | null;
email: string; email: string;
memberships: AuthenticatedMembership[]; memberships: AuthenticatedMembership[];
} }
@@ -18,3 +21,8 @@ export interface AuthenticatedUser {
export interface AuthenticatedRequest extends Request { export interface AuthenticatedRequest extends Request {
user?: AuthenticatedUser; user?: AuthenticatedUser;
} }
/// Shape attached to req.user by GuestJwtStrategy for guest/Konfi-authenticated routes.
export interface GuestAuthenticatedRequest extends Request {
user?: GuestJwtPayload;
}
+39 -23
View File
@@ -5,58 +5,74 @@ import { Strategy } from 'passport-jwt';
import * as jwksRsa from 'jwks-rsa'; import * as jwksRsa from 'jwks-rsa';
import { Request } from 'express'; import { Request } from 'express';
import { PrismaClient } from '../prisma/prisma.module'; import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service';
import { AuthenticatedUser } from './authenticated-request'; import { AuthenticatedUser } from './authenticated-request';
import {
authentikEmail,
resolveOrProvisionAuthentikUser,
toAuthenticatedUser,
} from './provision-user';
interface AuthentikJwtPayload { interface AuthentikJwtPayload {
sub: string; sub: string;
email: string; email?: string;
given_name?: string; given_name?: string;
family_name?: string; family_name?: string;
preferred_username?: string;
name?: string;
groups?: string[];
} }
/// Validates access tokens issued by Authentik (resource-server pattern): /// Validates access tokens issued by Authentik (resource-server pattern):
/// signature is checked against Authentik's JWKS, then the local Membership /// signature is checked against Authentik's JWKS, the local `User` is
/// table decides what the user may do. Authentik itself is only the identity /// provisioned on first login (JIT) and its LEITUNGSTEAM flag reconciled with
/// source, never asked for authorization here. /// the token's `groups` claim, then the local Membership table decides what
/// the user may do. Authentik itself is only the identity source, never asked
/// for authorization here.
@Injectable() @Injectable()
export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') { export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') {
private readonly leitungsteamGroup: string;
constructor( constructor(
config: ConfigService, config: ConfigService,
private readonly prisma: PrismaClient, private readonly prisma: PrismaClient,
private readonly sync: SyncService,
) { ) {
const issuerUrl = config.getOrThrow<string>('AUTHENTIK_ISSUER_URL'); // Authentik's discovery `issuer` carries a trailing slash and so does the
// `iss` claim in its tokens; accept both spellings and never emit `//`.
const base = config.getOrThrow<string>('AUTHENTIK_ISSUER_URL').replace(/\/+$/, '');
super({ super({
jwtFromRequest: (req: Request) => jwtFromRequest: (req: Request) =>
req.headers.authorization?.startsWith('Bearer ') req.headers.authorization?.startsWith('Bearer ')
? req.headers.authorization.slice('Bearer '.length) ? req.headers.authorization.slice('Bearer '.length)
: null, : null,
secretOrKeyProvider: jwksRsa.passportJwtSecret({ secretOrKeyProvider: jwksRsa.passportJwtSecret({
jwksUri: `${issuerUrl}/jwks/`, jwksUri: `${base}/jwks/`,
cache: true, cache: true,
rateLimit: true, rateLimit: true,
}), }),
issuer: issuerUrl, issuer: [base, `${base}/`],
algorithms: ['RS256'], algorithms: ['RS256'],
}); });
this.leitungsteamGroup = config.get<string>('AUTHENTIK_LEITUNGSTEAM_GROUP', 'Leitungsteam');
} }
async validate(payload: AuthentikJwtPayload): Promise<AuthenticatedUser> { async validate(payload: AuthentikJwtPayload): Promise<AuthenticatedUser> {
const user = await this.prisma.user.findUnique({ if (!payload.sub) {
where: { authentikSub: payload.sub }, throw new UnauthorizedException('Authentik token missing subject');
include: { memberships: true },
});
if (!user) {
throw new UnauthorizedException('User not provisioned locally yet');
} }
return { const isLeitungsteam = (payload.groups ?? []).includes(this.leitungsteamGroup);
userId: user.id, const user = await resolveOrProvisionAuthentikUser(
authentikSub: user.authentikSub, this.prisma,
email: user.email, this.sync,
memberships: user.memberships.map((m) => ({ {
kcId: m.kcId, sub: payload.sub,
gemeindeId: m.gemeindeId, email: authentikEmail(payload),
role: m.role, firstName: payload.given_name ?? payload.preferred_username ?? payload.name ?? '',
})), lastName: payload.family_name ?? '',
}; },
isLeitungsteam,
);
return toAuthenticatedUser(user);
} }
} }
+186
View File
@@ -0,0 +1,186 @@
import { NotFoundException } from '@nestjs/common';
import { CodeResolverService } from './code-resolver.service';
/// Focus: the classification order (SSO keyword > email > guest code >
/// Teamer invite > Verantwortliche invite > Gemeinde name), invite
/// usability checks (revoked/expired/exhausted), and the generic 404
/// for anything that matches nothing.
function makeService(opts: {
kc?: any;
teamerInvite?: any;
verantwortlicheInvite?: any;
gemeinde?: any;
} = {}) {
const prisma = {
kc: {
findUnique: jest.fn().mockResolvedValue(opts.kc ?? null),
findFirst: jest.fn().mockResolvedValue(opts.kc ?? null),
},
teamerInvite: {
findUnique: jest.fn().mockResolvedValue(opts.teamerInvite ?? null),
findFirst: jest.fn().mockResolvedValue(opts.teamerInvite ?? null),
},
verantwortlicheInvite: {
findUnique: jest.fn().mockResolvedValue(opts.verantwortlicheInvite ?? null),
findFirst: jest.fn().mockResolvedValue(opts.verantwortlicheInvite ?? null),
},
gemeinde: { findFirst: jest.fn().mockResolvedValue(opts.gemeinde ?? null) },
};
return { service: new CodeResolverService(prisma as never), prisma };
}
describe('CodeResolverService.resolve', () => {
it('resolves the "lt", "login", and "sso" keywords to SSO regardless of case', async () => {
const { service } = makeService();
await expect(service.resolve('lt')).resolves.toEqual({ kind: 'sso' });
await expect(service.resolve('LT')).resolves.toEqual({ kind: 'sso' });
await expect(service.resolve(' Lt ')).resolves.toEqual({ kind: 'sso' });
await expect(service.resolve('login')).resolves.toEqual({ kind: 'sso' });
await expect(service.resolve('LOGIN')).resolves.toEqual({ kind: 'sso' });
await expect(service.resolve(' Login ')).resolves.toEqual({ kind: 'sso' });
await expect(service.resolve('sso')).resolves.toEqual({ kind: 'sso' });
await expect(service.resolve('SSO')).resolves.toEqual({ kind: 'sso' });
});
it('resolves codes with LT suffix to SSO', async () => {
const { service } = makeService();
await expect(service.resolve('ABC123LT')).resolves.toEqual({ kind: 'sso' });
await expect(service.resolve('dev123lt')).resolves.toEqual({ kind: 'sso' });
await expect(service.resolve('32d814ed9011LT')).resolves.toEqual({ kind: 'sso' });
});
it('treats an @-containing value as an email team-login, without hitting the DB', async () => {
const { service, prisma } = makeService();
const result = await service.resolve('someone@example.org');
expect(result).toEqual({ kind: 'team_login', identifierType: 'email' });
expect(prisma.kc.findUnique).not.toHaveBeenCalled();
});
it('resolves an active KC invite code to a guest login', async () => {
const { service } = makeService({ kc: { id: 'kc-1', name: 'KC Dev', isActive: true } });
await expect(service.resolve('DEV123')).resolves.toEqual({ kind: 'guest', kcName: 'KC Dev' });
});
it('does not treat an inactive KC as a valid guest code', async () => {
const { service } = makeService({ kc: { id: 'kc-1', name: 'KC Dev', isActive: false } });
await expect(service.resolve('DEV123')).rejects.toBeInstanceOf(NotFoundException);
});
it('resolves a usable Teamer invite token', async () => {
const { service } = makeService({
teamerInvite: {
email: null,
revokedAt: null,
expiresAt: null,
maxUses: null,
usedCount: 0,
gemeinde: { name: 'Nord' },
kc: { name: 'KC Dev' },
},
});
await expect(service.resolve('sometoken')).resolves.toEqual({
kind: 'teamer_invite',
kcName: 'KC Dev',
gemeindeName: 'Nord',
pinnedEmail: null,
});
});
it('rejects a revoked Teamer invite token', async () => {
const { service } = makeService({
teamerInvite: {
email: null,
revokedAt: new Date(),
expiresAt: null,
maxUses: null,
usedCount: 0,
gemeinde: { name: 'Nord' },
kc: { name: 'KC Dev' },
},
});
await expect(service.resolve('sometoken')).rejects.toBeInstanceOf(NotFoundException);
});
it('rejects an expired Teamer invite token', async () => {
const { service } = makeService({
teamerInvite: {
email: null,
revokedAt: null,
expiresAt: new Date(Date.now() - 1000),
maxUses: null,
usedCount: 0,
gemeinde: { name: 'Nord' },
kc: { name: 'KC Dev' },
},
});
await expect(service.resolve('sometoken')).rejects.toBeInstanceOf(NotFoundException);
});
it('rejects a used-up Teamer invite token', async () => {
const { service } = makeService({
teamerInvite: {
email: null,
revokedAt: null,
expiresAt: null,
maxUses: 1,
usedCount: 1,
gemeinde: { name: 'Nord' },
kc: { name: 'KC Dev' },
},
});
await expect(service.resolve('sometoken')).rejects.toBeInstanceOf(NotFoundException);
});
it('resolves a usable Verantwortliche invite token', async () => {
const { service } = makeService({
verantwortlicheInvite: {
email: 'pinned@example.org',
revokedAt: null,
expiresAt: null,
maxUses: 1,
usedCount: 0,
gemeinde: { name: 'Süd' },
kc: { name: 'KC Dev' },
},
});
await expect(service.resolve('vertoken')).resolves.toEqual({
kind: 'verantwortliche_invite',
kcName: 'KC Dev',
gemeindeName: 'Süd',
pinnedEmail: 'pinned@example.org',
});
});
it('resolves a Gemeinde name to a Gemeinde-name team-login', async () => {
const { service } = makeService({ gemeinde: { id: 'gem-1', name: 'Mustergemeinde' } });
await expect(service.resolve('Mustergemeinde')).resolves.toEqual({
kind: 'team_login',
identifierType: 'gemeindeName',
gemeindeName: 'Mustergemeinde',
});
});
it('resolves codes from invite/token URLs', async () => {
const { service } = makeService({ kc: { id: 'kc-1', name: 'KC Dev', isActive: true } });
await expect(service.resolve('https://example.org/join?code=DEV123')).resolves.toEqual({
kind: 'guest',
kcName: 'KC Dev',
});
// noinspection HttpUrlsUsage
await expect(service.resolve('http://example.org/join?token=DEV123')).resolves.toEqual({
kind: 'guest',
kcName: 'KC Dev',
});
});
it('rejects an empty code', async () => {
const { service } = makeService();
await expect(service.resolve(' ')).rejects.toBeInstanceOf(NotFoundException);
});
it('rejects a code that matches nothing', async () => {
const { service } = makeService();
await expect(service.resolve('totally-unknown-code')).rejects.toBeInstanceOf(NotFoundException);
});
});
+135
View File
@@ -0,0 +1,135 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaClient } from '../prisma/prisma.module';
export type ResolvedCodeKind = 'guest' | 'teamer_invite' | 'verantwortliche_invite' | 'team_login' | 'sso';
export interface ResolvedCode {
kind: ResolvedCodeKind;
kcName?: string;
gemeindeName?: string;
/// *_invite only: whether the invite is pinned to a specific email
/// (personal invite) — if so the email field should be locked to it.
pinnedEmail?: string | null;
/// team_login only: which field the entered value should be sent back as.
identifierType?: 'email' | 'gemeindeName';
}
const SSO_KEYWORDS = new Set(['lt', 'login', 'sso']);
/// Classifies whatever the user typed into the single "Code" field on the
/// unified login screen, so the frontend can show the right follow-up form
/// without the user picking a login type up front.
///
/// Resolution order:
/// 1. the fixed keywords "lt", "login", "sso" or codes ending with "LT" (e.g. "ABC123LT")
/// -> Konfi-Castle-ID (Authentik SSO), for Leitungsteam/Verantwortliche
/// 2. an '@'-shaped value -> email + password (Teamer legacy login)
/// 3. a KC guest invite code (case-insensitive)
/// 4. a Teamer-invite token (self-registration link)
/// 5. a Verantwortliche-invite token (LT-issued SSO shortcut link)
/// 6. a Gemeinde name -> Gemeinde-name + password (normal Teamer login)
/// Anything else: "Code ungültig" (never leaks *why* it didn't match).
@Injectable()
export class CodeResolverService {
constructor(private readonly prisma: PrismaClient) {}
async resolve(rawCode: string): Promise<ResolvedCode> {
const code = rawCode.trim();
if (!code) {
throw new NotFoundException('Code ungültig');
}
const lower = code.toLowerCase();
const upper = code.toUpperCase();
if (SSO_KEYWORDS.has(lower) || (code.length > 2 && upper.endsWith('LT'))) {
return { kind: 'sso' };
}
if (code.includes('@')) {
return { kind: 'team_login', identifierType: 'email' };
}
let lookupCode = code;
try {
if (/^https?:\/\//i.test(code)) {
const url = new URL(code);
lookupCode =
url.searchParams.get('token') ||
url.searchParams.get('code') ||
url.searchParams.get('invite') ||
url.pathname.split('/').filter(Boolean).pop() ||
code;
}
} catch {
// Ignore URL parsing errors and keep code as is
}
const kc =
(await this.prisma.kc.findFirst({
where: { inviteCode: { equals: lookupCode, mode: 'insensitive' } },
})) ||
(await this.prisma.kc.findUnique({
where: { inviteCode: lookupCode },
}));
if (kc && kc.isActive) {
return { kind: 'guest', kcName: kc.name };
}
const teamerInvite =
(await this.prisma.teamerInvite.findFirst({
where: { token: { equals: lookupCode, mode: 'insensitive' } },
include: { gemeinde: true, kc: true },
})) ||
(await this.prisma.teamerInvite.findUnique({
where: { token: lookupCode },
include: { gemeinde: true, kc: true },
}));
if (teamerInvite && this.isInviteUsable(teamerInvite)) {
return {
kind: 'teamer_invite',
kcName: teamerInvite.kc.name,
gemeindeName: teamerInvite.gemeinde.name,
pinnedEmail: teamerInvite.email,
};
}
const verantwortlicheInvite =
(await this.prisma.verantwortlicheInvite.findFirst({
where: { token: { equals: lookupCode, mode: 'insensitive' } },
include: { gemeinde: true, kc: true },
})) ||
(await this.prisma.verantwortlicheInvite.findUnique({
where: { token: lookupCode },
include: { gemeinde: true, kc: true },
}));
if (verantwortlicheInvite && this.isInviteUsable(verantwortlicheInvite)) {
return {
kind: 'verantwortliche_invite',
kcName: verantwortlicheInvite.kc.name,
gemeindeName: verantwortlicheInvite.gemeinde.name,
pinnedEmail: verantwortlicheInvite.email,
};
}
const gemeinde = await this.prisma.gemeinde.findFirst({
where: { name: { equals: lookupCode, mode: 'insensitive' } },
});
if (gemeinde) {
return { kind: 'team_login', identifierType: 'gemeindeName', gemeindeName: gemeinde.name };
}
throw new NotFoundException('Code ungültig');
}
private isInviteUsable(invite: {
revokedAt: Date | null;
expiresAt: Date | null;
maxUses: number | null;
usedCount: number;
}): boolean {
if (invite.revokedAt) return false;
if (invite.expiresAt && invite.expiresAt.getTime() < Date.now()) return false;
return invite.maxUses === null || invite.usedCount < invite.maxUses;
}
}
+30
View File
@@ -0,0 +1,30 @@
import {
IsEmail,
IsNotEmpty,
IsOptional,
IsString,
MinLength,
} from 'class-validator';
export class RegisterTeamerDto {
@IsString()
@IsNotEmpty()
token!: string;
@IsString()
@IsNotEmpty()
firstName!: string;
@IsString()
@IsNotEmpty()
lastName!: string;
@IsString()
@MinLength(8)
password!: string;
/// Required for group-link invites; ignored/validated against a personal invite.
@IsOptional()
@IsEmail()
email?: string;
}
+7
View File
@@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class ResolveCodeDto {
@IsString()
@IsNotEmpty()
code!: string;
}
+21
View File
@@ -0,0 +1,21 @@
import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
/// Team login accepts EITHER an email (legacy/personal Verantwortliche
/// accounts) OR a Gemeinde name (the normal Teamer login path, since a
/// Teamer thinks of their login as "meine Gemeinde", not their email).
/// At least one of email/gemeindeName is required; enforced in the
/// controller rather than a custom validator to keep this DTO simple.
export class TeamLoginDto {
@IsOptional()
@IsEmail()
email?: string;
@IsOptional()
@IsString()
@IsNotEmpty()
gemeindeName?: string;
@IsString()
@IsNotEmpty()
password!: string;
}
+32 -3
View File
@@ -1,6 +1,8 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { SyncOperation } from '@prisma/client';
import { PrismaClient } from '../prisma/prisma.module'; import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service';
export interface GuestJwtPayload { export interface GuestJwtPayload {
guestId: string; guestId: string;
@@ -15,22 +17,49 @@ export class GuestAuthService {
constructor( constructor(
private readonly prisma: PrismaClient, private readonly prisma: PrismaClient,
private readonly jwt: JwtService, private readonly jwt: JwtService,
private readonly sync: SyncService,
) {} ) {}
/// Redeems a KC invite code for a guest/Konfi session. If a guest account
/// with the same (trimmed, case-insensitive) name already exists for this
/// KC, reuses it instead of creating a new one — this is what lets a Konfi
/// "log back in" with the same code + name and keep their chat history /
/// Workshop-Wahl submission instead of losing it to a fresh blank account.
async createGuest( async createGuest(
inviteCode: string, inviteCode: string,
firstName: string, firstName: string,
lastName: string, lastName: string,
): Promise<{ accessToken: string }> { ): Promise<{ accessToken: string }> {
const kc = await this.prisma.kc.findUnique({ where: { inviteCode } }); const trimmedCode = inviteCode.trim();
const kc =
(await this.prisma.kc.findFirst({
where: { inviteCode: { equals: trimmedCode, mode: 'insensitive' } },
})) ||
(await this.prisma.kc.findUnique({
where: { inviteCode: trimmedCode },
}));
if (!kc || !kc.isActive) { if (!kc || !kc.isActive) {
throw new NotFoundException('Unknown or inactive KC invite code'); throw new NotFoundException('Unknown or inactive KC invite code');
} }
const guest = await this.prisma.guestAccount.create({ const trimmedFirst = firstName.trim();
data: { kcId: kc.id, firstName, lastName }, const trimmedLast = lastName.trim();
let guest = await this.prisma.guestAccount.findFirst({
where: {
kcId: kc.id,
firstName: { equals: trimmedFirst, mode: 'insensitive' },
lastName: { equals: trimmedLast, mode: 'insensitive' },
},
}); });
if (!guest) {
guest = await this.prisma.guestAccount.create({
data: { kcId: kc.id, firstName: trimmedFirst, lastName: trimmedLast },
});
await this.sync.capture('GuestAccount', SyncOperation.CREATE, guest.id, guest);
}
const payload: GuestJwtPayload = { const payload: GuestJwtPayload = {
guestId: guest.id, guestId: guest.id,
kcId: kc.id, kcId: kc.id,
+21
View File
@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { GuestJwtPayload } from './guest-auth.service';
/// Verifies the local JWT issued to guests/Konfis by GuestAuthService.
/// Kept separate from AuthentikStrategy since guests are never Authentik-backed.
@Injectable()
export class GuestJwtStrategy extends PassportStrategy(Strategy, 'guest') {
constructor(config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: config.getOrThrow<string>('GUEST_JWT_SECRET'),
});
}
validate(payload: GuestJwtPayload): GuestJwtPayload {
return payload;
}
}
+175
View File
@@ -0,0 +1,175 @@
import { Prisma, Role } from '@prisma/client';
import {
GLOBAL_LT_KC_ID,
resolveOrProvisionAuthentikUser,
toAuthenticatedUser,
} from './provision-user';
const CLAIMS = {
sub: 'sub-1',
email: 'New.Person@Example.org',
firstName: 'New',
lastName: 'Person',
};
function p2002() {
return new Prisma.PrismaClientKnownRequestError('unique', {
code: 'P2002',
clientVersion: 'test',
});
}
describe('resolveOrProvisionAuthentikUser', () => {
it('returns the existing user without creating or capturing when nothing changed', async () => {
const sync = { capture: jest.fn() };
const existing = { id: 'u-1', authentikSub: 'sub-1', isLeitungsteam: false, memberships: [] };
const prisma = {
user: {
findUnique: jest.fn().mockResolvedValue(existing),
create: jest.fn(),
update: jest.fn(),
},
};
const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false);
expect(res).toBe(existing);
expect(prisma.user.create).not.toHaveBeenCalled();
expect(prisma.user.update).not.toHaveBeenCalled();
expect(sync.capture).not.toHaveBeenCalled();
});
it('provisions a new user from claims (lowercased email) and captures it', async () => {
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
const prisma = {
user: {
findUnique: jest.fn().mockResolvedValue(null),
create: jest.fn(({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({ id: 'u-2', isLeitungsteam: false, ...data }),
),
update: jest.fn(),
},
};
const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false);
expect(prisma.user.create).toHaveBeenCalledWith({
data: {
authentikSub: 'sub-1',
email: 'new.person@example.org',
firstName: 'New',
lastName: 'Person',
},
});
expect(res.memberships).toEqual([]);
expect(sync.capture).toHaveBeenCalledWith('User', 'CREATE', 'u-2', expect.anything());
});
it('reconciles the LEITUNGSTEAM flag up when the token now has the group', async () => {
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
const existing = { id: 'u-1', authentikSub: 'sub-1', isLeitungsteam: false, memberships: [] };
const prisma = {
user: {
findUnique: jest.fn().mockResolvedValue(existing),
create: jest.fn(),
update: jest.fn(({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({ ...existing, ...data, memberships: [] }),
),
},
};
const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, true);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: 'u-1' },
data: { isLeitungsteam: true },
include: { memberships: { where: { status: 'ACTIVE' } } },
});
expect(res.isLeitungsteam).toBe(true);
expect(sync.capture).toHaveBeenCalledWith('User', 'UPDATE', 'u-1', expect.anything());
});
it('reconciles the LEITUNGSTEAM flag down when the group is gone', async () => {
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
const existing = { id: 'u-1', authentikSub: 'sub-1', isLeitungsteam: true, memberships: [] };
const prisma = {
user: {
findUnique: jest.fn().mockResolvedValue(existing),
create: jest.fn(),
update: jest.fn(({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({ ...existing, ...data, memberships: [] }),
),
},
};
await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false);
expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({ data: { isLeitungsteam: false } }),
);
});
it('recovers from a concurrent-create race (P2002) by re-reading', async () => {
const sync = { capture: jest.fn() };
const raced = { id: 'u-3', authentikSub: 'sub-1', isLeitungsteam: false, memberships: [] };
const prisma = {
user: {
findUnique: jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(raced),
create: jest.fn().mockRejectedValue(p2002()),
update: jest.fn(),
},
};
const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false);
expect(res).toBe(raced);
expect(sync.capture).not.toHaveBeenCalled();
});
it('rethrows a P2002 when the row still cannot be found', async () => {
const sync = { capture: jest.fn() };
const prisma = {
user: {
findUnique: jest.fn().mockResolvedValue(null),
create: jest.fn().mockRejectedValue(p2002()),
update: jest.fn(),
},
};
await expect(
resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false),
).rejects.toBeInstanceOf(Prisma.PrismaClientKnownRequestError);
});
it('rethrows a non-P2002 error', async () => {
const sync = { capture: jest.fn() };
const prisma = {
user: {
findUnique: jest.fn().mockResolvedValue(null),
create: jest.fn().mockRejectedValue(new Error('db down')),
update: jest.fn(),
},
};
await expect(
resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false),
).rejects.toThrow('db down');
});
});
describe('toAuthenticatedUser', () => {
const row = {
id: 'u-1',
authentikSub: 'sub-1',
email: 'a@b.org',
isLeitungsteam: false,
memberships: [
{ kcId: 'kc-1', gemeindeId: 'gem-1', role: Role.GEMEINDE_VERANTWORTLICHER },
],
};
it('maps membership rows straight through when not Leitungsteam', () => {
const res = toAuthenticatedUser(row as never);
expect(res.memberships).toEqual([
{ kcId: 'kc-1', gemeindeId: 'gem-1', role: Role.GEMEINDE_VERANTWORTLICHER },
]);
});
it('prepends a synthetic global LEITUNGSTEAM membership when the flag is set', () => {
const res = toAuthenticatedUser({ ...row, isLeitungsteam: true } as never);
expect(res.memberships[0]).toEqual({
kcId: GLOBAL_LT_KC_ID,
gemeindeId: null,
role: Role.LEITUNGSTEAM,
});
expect(res.memberships).toHaveLength(2);
});
});
+123
View File
@@ -0,0 +1,123 @@
import { Prisma, Role, SyncOperation } from '@prisma/client';
import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service';
import { AuthenticatedUser } from './authenticated-request';
export interface AuthentikClaims {
sub: string;
email: string;
firstName: string;
lastName: string;
}
/// Authentik users don't necessarily have an email set. Fall back to a stable,
/// per-user placeholder so provisioning still has a unique handle for the row.
export function authentikEmail(p: {
email?: string;
preferred_username?: string;
sub: string;
}): string {
const e = p.email?.trim();
if (e) return e.toLowerCase();
return `${p.preferred_username?.trim() || p.sub}@no-email.authentik`.toLowerCase();
}
/// Placeholder kcId for the synthetic, global LEITUNGSTEAM membership. RolesGuard
/// never compares it (LT short-circuits the KC check), it only needs to exist.
export const GLOBAL_LT_KC_ID = '*';
type UserWithActiveMemberships = Prisma.UserGetPayload<{
include: { memberships: true };
}>;
/// Resolves an Authentik identity to its local `User`, creating one from the
/// token claims on first login (JIT provisioning), and reconciling the
/// `isLeitungsteam` flag with the caller's current Authentik group membership
/// on every login. A brand-new user has no `Membership` and therefore no
/// rights until one is granted (the onboarding approval flow) or the LT flag
/// is set. Shared by AuthentikStrategy and the WS token path so both behave
/// identically.
export async function resolveOrProvisionAuthentikUser(
prisma: PrismaClient,
sync: SyncService,
claims: AuthentikClaims,
isLeitungsteam: boolean,
): Promise<UserWithActiveMemberships> {
const user = await loadOrCreate(prisma, sync, claims);
if (user.isLeitungsteam !== isLeitungsteam) {
const updated = await prisma.user.update({
where: { id: user.id },
data: { isLeitungsteam },
include: { memberships: { where: { status: 'ACTIVE' } } },
});
await sync.capture('User', SyncOperation.UPDATE, updated.id, {
...updated,
memberships: undefined,
});
return updated;
}
return user;
}
async function loadOrCreate(
prisma: PrismaClient,
sync: SyncService,
claims: AuthentikClaims,
): Promise<UserWithActiveMemberships> {
const existing = await prisma.user.findUnique({
where: { authentikSub: claims.sub },
include: { memberships: { where: { status: 'ACTIVE' } } },
});
if (existing) {
return existing;
}
try {
const user = await prisma.user.create({
data: {
authentikSub: claims.sub,
email: claims.email.toLowerCase(),
firstName: claims.firstName,
lastName: claims.lastName,
},
});
await sync.capture('User', SyncOperation.CREATE, user.id, user);
return { ...user, memberships: [] };
} catch (err) {
// Lost a race with a concurrent first login — the row exists now.
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
const user = await prisma.user.findUnique({
where: { authentikSub: claims.sub },
include: { memberships: { where: { status: 'ACTIVE' } } },
});
if (user) {
return user;
}
}
throw err;
}
}
/// Maps a provisioned user row to the request-scoped shape, prepending a
/// synthetic global LEITUNGSTEAM membership when the flag is set.
export function toAuthenticatedUser(user: UserWithActiveMemberships): AuthenticatedUser {
const memberships = user.memberships.map((m) => ({
kcId: m.kcId,
gemeindeId: m.gemeindeId,
role: m.role,
}));
if (user.isLeitungsteam) {
memberships.unshift({
kcId: GLOBAL_LT_KC_ID,
gemeindeId: null,
role: Role.LEITUNGSTEAM,
});
}
return {
userId: user.id,
authentikSub: user.authentikSub,
email: user.email,
memberships,
};
}
+308
View File
@@ -0,0 +1,308 @@
import {
ConflictException,
ForbiddenException,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { Role } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { TeamAuthService } from './team-auth.service';
/// Covers the branching in invite redemption and password login. Prisma and
/// SyncService are faked in memory; bcrypt/jsonwebtoken run for real.
const SECRET = 'test-team-secret';
interface InviteRow {
id: string;
kcId: string;
gemeindeId: string;
token: string;
email: string | null;
maxUses: number | null;
usedCount: number;
expiresAt: Date | null;
revokedAt: Date | null;
}
function makeService(seed: {
invites?: InviteRow[];
users?: { id: string; email: string; passwordHash: string | null }[];
memberships?: {
gemeindeName: string;
user: { id: string; passwordHash: string | null };
}[];
}) {
const invites = [...(seed.invites ?? [])];
const users = [...(seed.users ?? [])].map((u) => ({
firstName: 'X',
lastName: 'Y',
authentikSub: null,
kcId: null,
createdAt: new Date(),
memberships: [] as unknown[],
...u,
}));
const memberships = seed.memberships ?? [];
const prisma = {
user: {
findUnique: jest.fn(({ where }: { where: { email?: string; id?: string } }) =>
Promise.resolve(
users.find(
(u) =>
(where.email !== undefined && u.email === where.email) ||
(where.id !== undefined && u.id === where.id),
) ?? null,
),
),
findFirst: jest.fn(({ where }: { where: { id: string } }) =>
Promise.resolve(users.find((u) => u.id === where.id && u.passwordHash) ?? null),
),
create: jest.fn(({ data }: { data: Record<string, unknown> }) => {
const row = { id: `u-${users.length + 1}`, memberships: [], ...data } as never;
users.push(row);
return Promise.resolve(row);
}),
},
membership: {
create: jest.fn(({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({ id: `m-1`, ...data }),
),
findMany: jest.fn(
({
where,
}: {
where: { gemeinde: { name: { equals: string; mode: string } } };
}) =>
Promise.resolve(
memberships
.filter(
(m) =>
m.gemeindeName.toLowerCase() === where.gemeinde.name.equals.toLowerCase(),
)
.map((m) => ({ user: m.user })),
),
),
},
teamerInvite: {
findUnique: jest.fn(({ where }: { where: { token: string } }) =>
Promise.resolve(invites.find((i) => i.token === where.token) ?? null),
),
update: jest.fn(({ where, data }: { where: { id: string }; data: { usedCount: { increment: number } } }) => {
const inv = invites.find((i) => i.id === where.id)!;
inv.usedCount += data.usedCount.increment;
return Promise.resolve(inv);
}),
},
};
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
const config = { getOrThrow: jest.fn().mockReturnValue(SECRET) };
const service = new TeamAuthService(prisma as never, config as never, sync as never);
return { service, prisma, sync, users, invites };
}
function invite(overrides: Partial<InviteRow> = {}): InviteRow {
return {
id: 'inv-1',
kcId: 'kc-1',
gemeindeId: 'gem-1',
token: 'tok-1',
email: null,
maxUses: null,
usedCount: 0,
expiresAt: null,
revokedAt: null,
...overrides,
};
}
const base = {
token: 'tok-1',
firstName: 'Mara',
lastName: 'Klein',
password: 'supersecret',
};
describe('TeamAuthService.registerFromInvite', () => {
it('rejects an unknown token', async () => {
const { service } = makeService({ invites: [] });
await expect(
service.registerFromInvite({ ...base, email: 'm@example.org' }),
).rejects.toBeInstanceOf(NotFoundException);
});
it('rejects a revoked invite', async () => {
const { service } = makeService({ invites: [invite({ revokedAt: new Date() })] });
await expect(
service.registerFromInvite({ ...base, email: 'm@example.org' }),
).rejects.toBeInstanceOf(NotFoundException);
});
it('rejects an expired invite', async () => {
const { service } = makeService({
invites: [invite({ expiresAt: new Date(Date.now() - 1000) })],
});
await expect(
service.registerFromInvite({ ...base, email: 'm@example.org' }),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('rejects an invite that is used up', async () => {
const { service } = makeService({
invites: [invite({ maxUses: 2, usedCount: 2 })],
});
await expect(
service.registerFromInvite({ ...base, email: 'm@example.org' }),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('requires an email for a group-link invite', async () => {
const { service } = makeService({ invites: [invite({ email: null })] });
await expect(service.registerFromInvite({ ...base })).rejects.toBeInstanceOf(
ConflictException,
);
});
it('rejects an email that does not match a personal invite', async () => {
const { service } = makeService({
invites: [invite({ email: 'pinned@example.org' })],
});
await expect(
service.registerFromInvite({ ...base, email: 'other@example.org' }),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('rejects when an account with that email already exists', async () => {
const { service } = makeService({
invites: [invite()],
users: [{ id: 'u-x', email: 'm@example.org', passwordHash: 'h' }],
});
await expect(
service.registerFromInvite({ ...base, email: 'm@example.org' }),
).rejects.toBeInstanceOf(ConflictException);
});
it('creates a local Teamer + GEMEINDE_TEAMER membership and burns one use', async () => {
const { service, prisma, sync, invites } = makeService({ invites: [invite()] });
const res = await service.registerFromInvite({ ...base, email: 'M@Example.org' });
expect(res.accessToken).toEqual(expect.any(String));
expect(prisma.user.create).toHaveBeenCalledWith({
data: expect.objectContaining({
email: 'm@example.org',
kcId: 'kc-1',
passwordHash: expect.any(String),
}),
});
const createdHash = prisma.user.create.mock.calls[0][0].data.passwordHash as string;
expect(await bcrypt.compare('supersecret', createdHash)).toBe(true);
expect(prisma.membership.create).toHaveBeenCalledWith({
data: expect.objectContaining({
kcId: 'kc-1',
gemeindeId: 'gem-1',
role: Role.GEMEINDE_TEAMER,
}),
});
expect(invites[0].usedCount).toBe(1);
expect(sync.capture).toHaveBeenCalledWith('User', 'CREATE', expect.any(String), expect.anything());
expect(sync.capture).toHaveBeenCalledWith('Membership', 'CREATE', expect.any(String), expect.anything());
expect(sync.capture).toHaveBeenCalledWith('TeamerInvite', 'UPDATE', expect.any(String), expect.anything());
});
});
describe('TeamAuthService.login', () => {
it('rejects an unknown email', async () => {
const { service } = makeService({ users: [] });
await expect(
service.login({ email: 'nobody@example.org' }, 'x'),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('rejects a user without a password hash (Authentik-only account)', async () => {
const { service } = makeService({
users: [{ id: 'u-1', email: 'lt@example.org', passwordHash: null }],
});
await expect(
service.login({ email: 'lt@example.org' }, 'x'),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('rejects a wrong password', async () => {
const { service } = makeService({
users: [
{ id: 'u-1', email: 't@example.org', passwordHash: bcrypt.hashSync('right', 10) },
],
});
await expect(
service.login({ email: 't@example.org' }, 'wrong'),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('issues a token for correct credentials by email', async () => {
const { service } = makeService({
users: [
{ id: 'u-1', email: 't@example.org', passwordHash: bcrypt.hashSync('right', 10) },
],
});
const res = await service.login({ email: 'T@example.org' }, 'right');
expect(res.accessToken).toEqual(expect.any(String));
});
it('rejects when neither email nor gemeindeName is given', async () => {
const { service } = makeService({});
await expect(service.login({}, 'x')).rejects.toBeInstanceOf(UnauthorizedException);
});
it('rejects an unknown Gemeinde name', async () => {
const { service } = makeService({});
await expect(
service.login({ gemeindeName: 'Nirgendwo' }, 'x'),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('logs in by Gemeinde name, matching case-insensitively and trimmed', async () => {
const { service } = makeService({
memberships: [
{
gemeindeName: 'Musterstadt',
user: { id: 'u-1', passwordHash: bcrypt.hashSync('right', 10) },
},
],
});
const res = await service.login({ gemeindeName: ' musterstadt ' }, 'right');
expect(res.accessToken).toEqual(expect.any(String));
});
it('tries every Teamer account for a Gemeinde until one password matches', async () => {
const { service } = makeService({
memberships: [
{
gemeindeName: 'Musterstadt',
user: { id: 'u-1', passwordHash: bcrypt.hashSync('wrong-one', 10) },
},
{
gemeindeName: 'Musterstadt',
user: { id: 'u-2', passwordHash: bcrypt.hashSync('right', 10) },
},
],
});
const res = await service.login({ gemeindeName: 'Musterstadt' }, 'right');
expect(res.accessToken).toEqual(expect.any(String));
});
it('rejects a Gemeinde login when no account password matches', async () => {
const { service } = makeService({
memberships: [
{
gemeindeName: 'Musterstadt',
user: { id: 'u-1', passwordHash: bcrypt.hashSync('right', 10) },
},
],
});
await expect(
service.login({ gemeindeName: 'Musterstadt' }, 'wrong'),
).rejects.toBeInstanceOf(UnauthorizedException);
});
});
+179
View File
@@ -0,0 +1,179 @@
import {
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Role, SyncOperation } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import * as jwt from 'jsonwebtoken';
import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service';
import { AuthenticatedUser } from './authenticated-request';
import { toAuthenticatedUser } from './provision-user';
export interface TeamJwtPayload {
sub: string;
typ: 'team';
}
const TOKEN_TTL = '12h';
const BCRYPT_ROUNDS = 10;
/// Local (non-Authentik) auth for Gemeinde Teamer: password login plus
/// redemption of a TeamerInvite issued by a Gemeinde Verantwortliche/r. Team
/// tokens are signed with TEAM_JWT_SECRET and carry `typ: 'team'` so they are
/// never mistaken for a guest token.
@Injectable()
export class TeamAuthService {
private readonly secret: string;
constructor(
private readonly prisma: PrismaClient,
private readonly config: ConfigService,
private readonly sync: SyncService,
) {
this.secret = config.getOrThrow<string>('TEAM_JWT_SECRET');
}
/// Logs a Teamer in by email (legacy) OR by Gemeinde name — the normal
/// path, since a Teamer thinks of their login as "meine Gemeinde" rather
/// than an email address. A Gemeinde can have several Teamer accounts, so
/// a name lookup tries the password against every active GEMEINDE_TEAMER
/// membership for that Gemeinde (case-insensitive, trimmed name) until one
/// matches, rather than assuming a 1:1 Gemeinde-to-account mapping.
async login(
credentials: { email?: string; gemeindeName?: string },
password: string,
): Promise<{ accessToken: string }> {
if (credentials.email) {
const user = await this.prisma.user.findUnique({
where: { email: credentials.email.toLowerCase() },
});
if (!user || !user.passwordHash || !(await bcrypt.compare(password, user.passwordHash))) {
throw new UnauthorizedException('Invalid credentials');
}
return { accessToken: this.sign(user.id) };
}
const gemeindeName = credentials.gemeindeName?.trim();
if (!gemeindeName) {
throw new UnauthorizedException('Invalid credentials');
}
const memberships = await this.prisma.membership.findMany({
where: {
role: Role.GEMEINDE_TEAMER,
status: 'ACTIVE',
gemeinde: { name: { equals: gemeindeName, mode: 'insensitive' } },
},
include: { user: true },
});
for (const m of memberships) {
if (m.user.passwordHash && (await bcrypt.compare(password, m.user.passwordHash))) {
return { accessToken: this.sign(m.user.id) };
}
}
throw new UnauthorizedException('Invalid credentials');
}
/// Redeems an invite token and creates the local Teamer account + its
/// GEMEINDE_TEAMER membership for the invite's Gemeinde.
async registerFromInvite(input: {
token: string;
firstName: string;
lastName: string;
password: string;
email?: string;
}): Promise<{ accessToken: string }> {
const invite = await this.prisma.teamerInvite.findUnique({
where: { token: input.token },
});
if (!invite || invite.revokedAt) {
throw new NotFoundException('Unknown or revoked invite');
}
if (invite.expiresAt && invite.expiresAt.getTime() < Date.now()) {
throw new ForbiddenException('Invite has expired');
}
if (invite.maxUses !== null && invite.usedCount >= invite.maxUses) {
throw new ForbiddenException('Invite has already been used up');
}
if (
invite.email &&
input.email &&
input.email.toLowerCase() !== invite.email.toLowerCase()
) {
throw new ForbiddenException('Email does not match this invite');
}
const email = (invite.email ?? input.email ?? '').toLowerCase();
if (!email) {
throw new ConflictException('This invite requires an email address');
}
if (await this.prisma.user.findUnique({ where: { email } })) {
throw new ConflictException('An account with this email already exists');
}
const passwordHash = await bcrypt.hash(input.password, BCRYPT_ROUNDS);
const user = await this.prisma.user.create({
data: {
email,
firstName: input.firstName,
lastName: input.lastName,
passwordHash,
kcId: invite.kcId,
},
});
const membership = await this.prisma.membership.create({
data: {
userId: user.id,
kcId: invite.kcId,
gemeindeId: invite.gemeindeId,
role: Role.GEMEINDE_TEAMER,
},
});
const updatedInvite = await this.prisma.teamerInvite.update({
where: { id: invite.id },
data: { usedCount: { increment: 1 } },
});
await this.sync.capture('User', SyncOperation.CREATE, user.id, user);
await this.sync.capture('Membership', SyncOperation.CREATE, membership.id, membership);
await this.sync.capture('TeamerInvite', SyncOperation.UPDATE, updatedInvite.id, updatedInvite);
return { accessToken: this.sign(user.id) };
}
private sign(userId: string): string {
const payload: TeamJwtPayload = { sub: userId, typ: 'team' };
return jwt.sign(payload, this.secret, { expiresIn: TOKEN_TTL });
}
/// Verifies a raw team token (used by the WS handshake path, outside passport).
async verify(token: string): Promise<AuthenticatedUser> {
let payload: TeamJwtPayload;
try {
payload = jwt.verify(token, this.secret) as TeamJwtPayload;
} catch {
throw new UnauthorizedException('Invalid team token');
}
if (payload.typ !== 'team' || !payload.sub) {
throw new UnauthorizedException('Not a team token');
}
return this.resolve(payload.sub);
}
async resolve(userId: string): Promise<AuthenticatedUser> {
const user = await this.prisma.user.findFirst({
where: { id: userId, passwordHash: { not: null } },
include: { memberships: { where: { status: 'ACTIVE' } } },
});
if (!user) {
throw new UnauthorizedException('Team account no longer exists');
}
// Same shape as the Authentik path, incl. the synthetic global
// LEITUNGSTEAM membership when `isLeitungsteam` is set on the row.
return toAuthenticatedUser(user);
}
}
+26
View File
@@ -0,0 +1,26 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { AuthenticatedUser } from './authenticated-request';
import { TeamAuthService, TeamJwtPayload } from './team-auth.service';
/// Verifies the local JWT issued to Gemeinde Teamer by TeamAuthService and
/// resolves it to the same AuthenticatedUser shape as AuthentikStrategy, so
/// downstream RolesGuard / controllers treat both member kinds identically.
@Injectable()
export class TeamJwtStrategy extends PassportStrategy(Strategy, 'team') {
constructor(
config: ConfigService,
private readonly teamAuth: TeamAuthService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: config.getOrThrow<string>('TEAM_JWT_SECRET'),
});
}
validate(payload: TeamJwtPayload): Promise<AuthenticatedUser> {
return this.teamAuth.resolve(payload.sub);
}
}
+110
View File
@@ -0,0 +1,110 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import * as jwt from 'jsonwebtoken';
import * as jwksRsa from 'jwks-rsa';
import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service';
import { AuthenticatedUser } from './authenticated-request';
import { GuestJwtPayload } from './guest-auth.service';
import { TeamAuthService } from './team-auth.service';
import {
AuthentikClaims,
authentikEmail,
resolveOrProvisionAuthentikUser,
toAuthenticatedUser,
} from './provision-user';
/// Verifies raw bearer tokens outside the HTTP/passport pipeline, needed for
/// the WebSocket handshake where AuthGuard('authentik'|'guest') don't apply.
@Injectable()
export class TokenVerificationService {
private readonly issuerUrl: string;
private readonly jwks: jwksRsa.JwksClient;
private readonly leitungsteamGroup: string;
constructor(
private readonly config: ConfigService,
private readonly prisma: PrismaClient,
private readonly guestJwt: JwtService,
private readonly teamAuth: TeamAuthService,
private readonly sync: SyncService,
) {
// See AuthentikStrategy: normalise the trailing slash, accept both forms.
this.issuerUrl = config.getOrThrow<string>('AUTHENTIK_ISSUER_URL').replace(/\/+$/, '');
this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true });
this.leitungsteamGroup = config.get<string>('AUTHENTIK_LEITUNGSTEAM_GROUP', 'Leitungsteam');
}
/// Verifies an Authentik token's signature and returns its identity claims
/// plus whether the caller is in the Leitungsteam group, without requiring
/// a local User to exist yet (used by the onboarding self-registration
/// path, which provisions that User).
async verifyAuthentikClaims(
token: string,
): Promise<AuthentikClaims & { isLeitungsteam: boolean }> {
const decoded = jwt.decode(token, { complete: true });
const kid = decoded?.header.kid;
if (!kid) {
throw new UnauthorizedException('Malformed Authentik token');
}
const key = await this.jwks.getSigningKey(kid);
const payload = jwt.verify(token, key.getPublicKey(), {
issuer: [this.issuerUrl, `${this.issuerUrl}/`],
algorithms: ['RS256'],
}) as jwt.JwtPayload & {
email?: string;
given_name?: string;
family_name?: string;
preferred_username?: string;
name?: string;
groups?: string[];
};
const sub = payload.sub;
if (!sub) {
throw new UnauthorizedException('Authentik token missing subject');
}
return {
sub,
email: authentikEmail({
email: payload.email,
preferred_username: payload.preferred_username,
sub,
}),
firstName: payload.given_name ?? payload.preferred_username ?? payload.name ?? '',
lastName: payload.family_name ?? '',
isLeitungsteam: (payload.groups ?? []).includes(this.leitungsteamGroup),
};
}
async verifyAuthentik(token: string): Promise<AuthenticatedUser> {
const { isLeitungsteam, ...claims } = await this.verifyAuthentikClaims(token);
const user = await resolveOrProvisionAuthentikUser(
this.prisma,
this.sync,
claims,
isLeitungsteam,
);
return toAuthenticatedUser(user);
}
async verifyGuest(token: string): Promise<GuestJwtPayload> {
return this.guestJwt.verifyAsync<GuestJwtPayload>(token);
}
/// Tries Authentik, then a local team (Teamer) token, then a guest token.
async verifyEither(token: string): Promise<
{ kind: 'user'; user: AuthenticatedUser } | { kind: 'guest'; guest: GuestJwtPayload }
> {
try {
return { kind: 'user', user: await this.verifyAuthentik(token) };
} catch {
// not an Authentik token
}
try {
return { kind: 'user', user: await this.teamAuth.verify(token) };
} catch {
return { kind: 'guest', guest: await this.verifyGuest(token) };
}
}
}
+13
View File
@@ -0,0 +1,13 @@
import { AuthenticatedUser } from '../auth/authenticated-request';
import { GuestJwtPayload } from '../auth/guest-auth.service';
import { ChatCaller } from './chat.service';
function isGuestPayload(user: unknown): user is GuestJwtPayload {
return !!user && typeof user === 'object' && 'guestId' in user;
}
/// req.user is either an AuthenticatedUser (Authentik) or a GuestJwtPayload,
/// depending on which strategy AuthGuard(['authentik','guest']) picked.
export function resolveChatCaller(user: AuthenticatedUser | GuestJwtPayload): ChatCaller {
return isGuestPayload(user) ? { kind: 'guest', guest: user } : { kind: 'user', user };
}
+118
View File
@@ -0,0 +1,118 @@
import { Body, Controller, Delete, Get, Param, Post, Req, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ChatService } from './chat.service';
import { ChatGateway } from './chat.gateway';
import { CreateChannelDto } from './dto/create-channel.dto';
import { CreateDirectChannelDto } from './dto/create-direct-channel.dto';
import { AddParticipantDto } from './dto/add-participant.dto';
import { Roles } from '../common/roles.decorator';
import { RolesGuard } from '../common/roles.guard';
import { Role } from '../common/role.enum';
import { AuthenticatedRequest } from '../auth/authenticated-request';
import { GuestJwtPayload } from '../auth/guest-auth.service';
import { resolveChatCaller } from './caller.util';
type ChatRequest = AuthenticatedRequest & { user?: AuthenticatedRequest['user'] | GuestJwtPayload };
@Controller('chat')
export class ChatController {
constructor(
private readonly chat: ChatService,
private readonly gateway: ChatGateway,
) {}
/// Channel administration (Gemeinde-Gruppen, LT-Kanäle, Broadcasts) is Leitungsteam-only.
@Post(':kcId/channels')
@UseGuards(AuthGuard('authentik'), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
createChannel(@Param('kcId') kcId: string, @Body() dto: CreateChannelDto) {
return this.chat.createChannel(kcId, dto.type, dto.gemeindeId);
}
/// Free-form group chat ("Gruppenchat"): a Leitungsteam member (any KC) or
/// a Gemeinde Verantwortliche/r (their own KC, enforced by RolesGuard's
/// kcId scoping) can create one and pick any mix of team users and Konfis
/// (guests) from this KC as initial participants.
@Post(':kcId/gruppen')
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM, Role.GEMEINDE_VERANTWORTLICHER)
createGruppe(
@Param('kcId') kcId: string,
@Body() dto: CreateChannelDto,
@Req() req: AuthenticatedRequest,
) {
return this.chat.createGruppe(kcId, dto.name, req.user!.userId, {
userIds: dto.participantUserIds,
guestIds: dto.participantGuestIds,
});
}
/// Candidates (team users + Konfis) a caller may add to a Gruppenchat in
/// this KC. Allowed for LT or a Verantwortliche/r of this KC.
@Get(':kcId/gruppen/participant-candidates')
@UseGuards(AuthGuard(['authentik', 'team']))
listPossibleParticipants(@Param('kcId') kcId: string, @Req() req: AuthenticatedRequest) {
return this.chat.listPossibleParticipants(kcId, req.user!);
}
/// Add a team user or Konfi to a Gruppenchat. Allowed for the channel's
/// creator, any Leitungsteam member, or a Verantwortliche/r of that KC.
@Post('gruppen/:channelId/participants')
@UseGuards(AuthGuard(['authentik', 'team']))
addParticipant(
@Param('channelId') channelId: string,
@Body() dto: AddParticipantDto,
@Req() req: AuthenticatedRequest,
) {
return this.chat
.addParticipant(
channelId,
{ kind: 'user', user: req.user! },
{ userId: dto.userId, guestId: dto.guestId },
)
.then((result) => {
this.gateway.notifyParticipantsChanged(channelId);
return result;
});
}
/// Remove a team user or Konfi from a Gruppenchat. Same authorization as add.
@Delete('gruppen/:channelId/participants')
@UseGuards(AuthGuard(['authentik', 'team']))
removeParticipant(
@Param('channelId') channelId: string,
@Body() dto: AddParticipantDto,
@Req() req: AuthenticatedRequest,
) {
return this.chat
.removeParticipant(
channelId,
{ kind: 'user', user: req.user! },
{ userId: dto.userId, guestId: dto.guestId },
)
.then((result) => {
this.gateway.notifyParticipantsChanged(channelId);
return result;
});
}
/// Any two team members of the same KC can start a direct conversation
/// (Authentik-backed members and local Gemeinde Teamer alike).
@Post('direct')
@UseGuards(AuthGuard(['authentik', 'team']))
createDirectChannel(@Body() dto: CreateDirectChannelDto, @Req() req: AuthenticatedRequest) {
return this.chat.getOrCreateDirectChannel(dto.kcId, req.user!.userId, dto.otherUserId);
}
@Get(':kcId/channels')
@UseGuards(AuthGuard(['authentik', 'team', 'guest']))
listChannels(@Param('kcId') kcId: string, @Req() req: ChatRequest) {
return this.chat.listChannelsForCaller(kcId, resolveChatCaller(req.user!));
}
@Get('channels/:channelId/messages')
@UseGuards(AuthGuard(['authentik', 'team', 'guest']))
listMessages(@Param('channelId') channelId: string, @Req() req: ChatRequest) {
return this.chat.listMessages(channelId, resolveChatCaller(req.user!));
}
}
+117
View File
@@ -0,0 +1,117 @@
import {
ConnectedSocket,
MessageBody,
OnGatewayConnection,
OnGatewayDisconnect,
SubscribeMessage,
WebSocketGateway,
} from '@nestjs/websockets';
import { Logger } from '@nestjs/common';
import { IncomingMessage } from 'http';
import { WebSocket } from 'ws';
import { TokenVerificationService } from '../auth/token-verification.service';
import { ChatCaller, ChatService } from './chat.service';
/// Raw `ws` gateway (no socket.io rooms available), so channel membership is
/// tracked manually per connected socket. Auth happens once at handshake via
/// a `?token=` query param since passport guards don't run for WS upgrades.
///
/// The per-socket caller is stored as a *promise*: the token check is async
/// and a client can send `chat:join` before it resolves, so handlers await
/// the stored promise instead of assuming it's already populated.
@WebSocketGateway({ path: '/chat' })
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
private readonly logger = new Logger(ChatGateway.name);
private readonly callers = new Map<WebSocket, Promise<ChatCaller>>();
private readonly rooms = new Map<string, Set<WebSocket>>();
constructor(
private readonly tokenVerification: TokenVerificationService,
private readonly chat: ChatService,
) {}
handleConnection(client: WebSocket, request: IncomingMessage) {
const token = new URL(request.url ?? '', 'http://localhost').searchParams.get('token');
if (!token) {
client.close(4001, 'Missing token');
return;
}
const pending = this.tokenVerification.verifyEither(token).catch((err) => {
this.logger.warn(`WS auth failed: ${(err as Error).message}`);
client.close(4001, 'Unauthorized');
throw err;
});
this.callers.set(client, pending);
}
handleDisconnect(client: WebSocket) {
this.callers.delete(client);
for (const members of this.rooms.values()) {
members.delete(client);
}
}
@SubscribeMessage('chat:join')
async onJoin(
@ConnectedSocket() client: WebSocket,
@MessageBody() data: { channelId: string },
) {
const caller = await this.resolveCaller(client);
await this.chat.assertCanRead(data.channelId, caller);
this.roomFor(data.channelId).add(client);
return { event: 'chat:joined', data: { channelId: data.channelId } };
}
@SubscribeMessage('chat:send')
async onSend(
@ConnectedSocket() client: WebSocket,
@MessageBody() data: { channelId: string; body: string },
) {
const caller = await this.resolveCaller(client);
const message = await this.chat.sendMessage(data.channelId, caller, data.body);
this.broadcast(data.channelId, { event: 'chat:message', data: message });
return { event: 'chat:sent', data: { id: message.id } };
}
private async resolveCaller(client: WebSocket): Promise<ChatCaller> {
const pending = this.callers.get(client);
if (!pending) {
client.close(4001, 'Unauthorized');
throw new Error('Unauthorized WS client');
}
try {
return await pending;
} catch {
client.close(4001, 'Unauthorized');
throw new Error('Unauthorized WS client');
}
}
private roomFor(channelId: string): Set<WebSocket> {
let room = this.rooms.get(channelId);
if (!room) {
room = new Set();
this.rooms.set(channelId, room);
}
return room;
}
private broadcast(channelId: string, payload: unknown) {
const room = this.rooms.get(channelId);
if (!room) return;
const json = JSON.stringify(payload);
for (const socket of room) {
if (socket.readyState === socket.OPEN) {
socket.send(json);
}
}
}
/// Called by ChatController after add/removeParticipant so anyone with the
/// channel already open (e.g. the creator's participant-management UI)
/// gets a live update. Newly added participants join the room themselves
/// via `chat:join` once they open the chat.
notifyParticipantsChanged(channelId: string) {
this.broadcast(channelId, { event: 'chat:participants-changed', data: { channelId } });
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { ChatService } from './chat.service';
import { ChatGateway } from './chat.gateway';
import { ChatController } from './chat.controller';
@Module({
imports: [AuthModule],
controllers: [ChatController],
providers: [ChatService, ChatGateway],
})
export class ChatModule {}
+280
View File
@@ -0,0 +1,280 @@
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { ChatChannelType, Role } from '@prisma/client';
import { ChatService } from './chat.service';
import { AuthenticatedUser } from '../auth/authenticated-request';
import { GuestJwtPayload } from '../auth/guest-auth.service';
/// Focus: GRUPPE channel creation + participant management authorization
/// (creator / Leitungsteam / Verantwortliche/r of that KC), and read/write
/// access for team users and guests. Prisma + Sync + Push faked in memory.
function userCaller(userId: string, memberships: AuthenticatedUser['memberships']) {
return {
kind: 'user' as const,
user: { userId, authentikSub: `sub-${userId}`, email: `${userId}@example.org`, memberships },
};
}
function guestCaller(guestId: string, kcId: string, gemeindeId: string | null = null) {
const guest: GuestJwtPayload = { guestId, kcId, gemeindeId };
return { kind: 'guest' as const, guest };
}
const LT = userCaller('lt-1', [{ kcId: 'kc-1', gemeindeId: null, role: Role.LEITUNGSTEAM }]);
const VERANTW = userCaller('ver-1', [
{ kcId: 'kc-1', gemeindeId: 'gem-1', role: Role.GEMEINDE_VERANTWORTLICHER },
]);
const TEAMER = userCaller('teamer-1', [
{ kcId: 'kc-1', gemeindeId: 'gem-1', role: Role.GEMEINDE_TEAMER },
]);
function makeService(
opts: {
channels?: Record<string, any>;
memberships?: { kcId: string; userId: string }[];
guests?: { id: string; kcId: string }[];
} = {},
) {
const channels: Record<string, any> = opts.channels ?? {};
const memberships = opts.memberships ?? [];
const guests = opts.guests ?? [];
const participants: any[] = [];
let participantSeq = 0;
const prisma = {
chatChannel: {
create: jest.fn(({ data, include }: any) => {
const id = `chan-${Object.keys(channels).length + 1}`;
const created = { id, ...data, participants: [] };
if (data.participants?.create) {
for (const p of data.participants.create) {
const row = { id: `part-${++participantSeq}`, channelId: id, userId: null, guestAccountId: null, ...p };
participants.push(row);
created.participants.push(row);
}
}
channels[id] = created;
return Promise.resolve(include ? created : { id, ...data });
}),
findUnique: jest.fn(({ where, include }: any) => {
const channel = channels[where.id];
if (!channel) return Promise.resolve(null);
if (include?.participants) {
const seeded = participants.filter((p) => p.channelId === channel.id);
const fallback = Array.isArray(channel.participants) ? channel.participants : [];
return Promise.resolve({
...channel,
participants: seeded.length ? seeded : fallback,
});
}
return Promise.resolve(channel);
}),
findMany: jest.fn().mockResolvedValue([]),
},
membership: {
findMany: jest.fn(({ where }: any) => {
const ids: string[] = where.userId.in;
const rows = memberships.filter((m) => m.kcId === where.kcId && ids.includes(m.userId));
const seen = new Set<string>();
const distinct = rows.filter((r) => (seen.has(r.userId) ? false : (seen.add(r.userId), true)));
return Promise.resolve(distinct);
}),
findFirst: jest.fn(({ where }: any) =>
Promise.resolve(memberships.find((m) => m.kcId === where.kcId && m.userId === where.userId) ?? null),
),
count: jest.fn().mockResolvedValue(0),
},
guestAccount: {
count: jest.fn(({ where }: any) =>
Promise.resolve(guests.filter((g) => where.id.in.includes(g.id) && g.kcId === where.kcId).length),
),
findFirst: jest.fn(({ where }: any) =>
Promise.resolve(guests.find((g) => g.id === where.id && g.kcId === where.kcId) ?? null),
),
},
chatParticipant: {
upsert: jest.fn(({ create }: any) => {
const existing = participants.find(
(p) =>
p.channelId === create.channelId &&
p.userId === (create.userId ?? null) &&
p.guestAccountId === (create.guestAccountId ?? null),
);
if (existing) return Promise.resolve(existing);
const row = { id: `part-${++participantSeq}`, userId: null, guestAccountId: null, ...create };
participants.push(row);
return Promise.resolve(row);
}),
findFirst: jest.fn(({ where }: any) =>
Promise.resolve(
participants.find(
(p) =>
p.channelId === where.channelId &&
(where.userId === undefined || p.userId === where.userId) &&
(where.guestAccountId === undefined || p.guestAccountId === where.guestAccountId),
) ?? null,
),
),
delete: jest.fn(({ where }: any) => {
const idx = participants.findIndex((p) => p.id === where.id);
const [removed] = participants.splice(idx, 1);
return Promise.resolve(removed);
}),
},
chatMessage: { create: jest.fn(), findMany: jest.fn() },
};
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
const push = { notifyChannel: jest.fn().mockResolvedValue(undefined) };
const service = new ChatService(prisma as never, sync as never, push as never);
return { service, prisma, sync, push, channels, participants };
}
describe('ChatService.createGruppe', () => {
it('creates a GRUPPE channel with the creator plus given team/guest participants', async () => {
const { service, sync } = makeService({
memberships: [{ kcId: 'kc-1', userId: 'ver-1' }, { kcId: 'kc-1', userId: 'teamer-1' }],
guests: [{ id: 'guest-1', kcId: 'kc-1' }],
});
const channel = await service.createGruppe('kc-1', 'Ausflugsplanung', 'ver-1', {
userIds: ['teamer-1'],
guestIds: ['guest-1'],
});
expect(channel.type).toBe(ChatChannelType.GRUPPE);
expect(channel.createdByUserId).toBe('ver-1');
const userIds = channel.participants.map((p: any) => p.userId).filter(Boolean);
const guestIds = channel.participants.map((p: any) => p.guestAccountId).filter(Boolean);
expect(userIds.sort()).toEqual(['teamer-1', 'ver-1']);
expect(guestIds).toEqual(['guest-1']);
expect(sync.capture).toHaveBeenCalledWith('ChatChannel', 'CREATE', channel.id, expect.anything());
});
it('does not duplicate the creator if already listed as a participant', async () => {
const { service } = makeService({ memberships: [{ kcId: 'kc-1', userId: 'ver-1' }] });
const channel = await service.createGruppe('kc-1', 'X', 'ver-1', { userIds: ['ver-1'] });
const userIds = channel.participants.map((p: any) => p.userId).filter(Boolean);
expect(userIds).toEqual(['ver-1']);
});
it('rejects a participant who is not a member of the KC', async () => {
const { service } = makeService({ memberships: [] });
await expect(
service.createGruppe('kc-1', 'X', 'ver-1', { userIds: ['ghost'] }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects a guest who is not part of the KC', async () => {
const { service } = makeService({ guests: [{ id: 'guest-1', kcId: 'kc-2' }] });
await expect(
service.createGruppe('kc-1', 'X', 'ver-1', { guestIds: ['guest-1'] }),
).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('ChatService participant management', () => {
function seedGruppe() {
const channels = {
'chan-1': { id: 'chan-1', kcId: 'kc-1', type: ChatChannelType.GRUPPE, createdByUserId: 'ver-1', gemeindeId: null },
};
return channels;
}
it('lets the creator add a team user', async () => {
const { service } = makeService({
channels: seedGruppe(),
memberships: [{ kcId: 'kc-1', userId: 'teamer-1' }],
});
const p = await service.addParticipant('chan-1', VERANTW, { userId: 'teamer-1' });
expect(p.userId).toBe('teamer-1');
});
it('lets a Leitungsteam member add a guest even if not the creator', async () => {
const { service } = makeService({
channels: seedGruppe(),
guests: [{ id: 'guest-1', kcId: 'kc-1' }],
});
const p = await service.addParticipant('chan-1', LT, { guestId: 'guest-1' });
expect(p.guestAccountId).toBe('guest-1');
});
it('forbids a plain Teamer (not creator, not LT, not Verantwortliche/r) from managing participants', async () => {
const { service } = makeService({ channels: seedGruppe() });
await expect(
service.addParticipant('chan-1', TEAMER, { userId: 'teamer-1' }),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('forbids guests from managing participants', async () => {
const { service } = makeService({ channels: seedGruppe() });
await expect(
service.addParticipant('chan-1', guestCaller('g-1', 'kc-1') as never, { userId: 'x' }),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('404s for a non-GRUPPE channel', async () => {
const channels = {
'chan-2': { id: 'chan-2', kcId: 'kc-1', type: ChatChannelType.GEMEINDE_GRUPPE, createdByUserId: null },
};
const { service } = makeService({ channels });
await expect(
service.addParticipant('chan-2', LT, { userId: 'teamer-1' }),
).rejects.toBeInstanceOf(NotFoundException);
});
it('rejects adding a user not in the KC', async () => {
const { service } = makeService({ channels: seedGruppe(), memberships: [] });
await expect(
service.addParticipant('chan-1', LT, { userId: 'ghost' }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('removes a participant and is a no-op if already absent', async () => {
const { service } = makeService({
channels: seedGruppe(),
memberships: [{ kcId: 'kc-1', userId: 'teamer-1' }],
});
await service.addParticipant('chan-1', VERANTW, { userId: 'teamer-1' });
const res = await service.removeParticipant('chan-1', VERANTW, { userId: 'teamer-1' });
expect(res).toEqual({ ok: true });
const res2 = await service.removeParticipant('chan-1', VERANTW, { userId: 'teamer-1' });
expect(res2).toEqual({ ok: true });
});
});
describe('ChatService GRUPPE read/write access', () => {
function seedGruppeWithParticipants(participants: any[]) {
return {
'chan-1': {
id: 'chan-1',
kcId: 'kc-1',
type: ChatChannelType.GRUPPE,
createdByUserId: 'ver-1',
gemeindeId: null,
participants,
},
};
}
it('lets a listed guest read messages', async () => {
const { service } = makeService({
channels: seedGruppeWithParticipants([{ userId: null, guestAccountId: 'guest-1' }]),
});
await expect(
service.assertCanRead('chan-1', guestCaller('guest-1', 'kc-1') as never),
).resolves.toBeDefined();
});
it('forbids a guest not in the participant list', async () => {
const { service } = makeService({
channels: seedGruppeWithParticipants([{ userId: null, guestAccountId: 'guest-1' }]),
});
await expect(
service.assertCanRead('chan-1', guestCaller('guest-2', 'kc-1') as never),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('forbids a team user not in the participant list', async () => {
const { service } = makeService({
channels: seedGruppeWithParticipants([{ userId: 'someone-else', guestAccountId: null }]),
});
await expect(service.assertCanRead('chan-1', TEAMER)).rejects.toBeInstanceOf(ForbiddenException);
});
});
+416
View File
@@ -0,0 +1,416 @@
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { ChatChannelType, Role, SyncOperation } from '@prisma/client';
import { PrismaClient } from '../prisma/prisma.module';
import { AuthenticatedUser } from '../auth/authenticated-request';
import { GuestJwtPayload } from '../auth/guest-auth.service';
import { SyncService } from '../sync/sync.service';
import { PushService } from '../push/push.service';
export type ChatCaller =
| { kind: 'user'; user: AuthenticatedUser }
| { kind: 'guest'; guest: GuestJwtPayload };
const CHANNEL_TITLES: Record<ChatChannelType, string> = {
[ChatChannelType.GEMEINDE_GRUPPE]: 'Gemeinde-Gruppe',
[ChatChannelType.DIREKT]: 'Direktnachricht',
[ChatChannelType.LT_UEBERGREIFEND]: 'Leitungsteam',
[ChatChannelType.BROADCAST]: 'Ankündigung',
[ChatChannelType.GRUPPE]: 'Gruppenchat',
};
export interface CreateGruppeParticipants {
userIds?: string[];
guestIds?: string[];
}
@Injectable()
export class ChatService {
constructor(
private readonly prisma: PrismaClient,
private readonly sync: SyncService,
private readonly push: PushService,
) {}
async createChannel(kcId: string, type: ChatChannelType, gemeindeId?: string) {
const channel = await this.prisma.chatChannel.create({ data: { kcId, type, gemeindeId } });
await this.sync.capture('ChatChannel', SyncOperation.CREATE, channel.id, channel);
return channel;
}
/// Free-form group chat: created by a Leitungsteam member (any KC) or a
/// Gemeinde Verantwortliche/r (their own KC — enforced by the RolesGuard's
/// kcId scoping at the controller level). Konfis (guests) may be included
/// directly, unlike DIREKT/GEMEINDE_GRUPPE channels which are team-only.
async createGruppe(
kcId: string,
name: string | undefined,
createdByUserId: string,
participants: CreateGruppeParticipants,
) {
const userIds = [...new Set(participants.userIds ?? [])];
const guestIds = [...new Set(participants.guestIds ?? [])];
if (userIds.length) {
// A user may show up under more than one Gemeinde membership; just
// make sure every requested id resolves to at least one row for this KC.
const distinctUsers = await this.prisma.membership.findMany({
where: { kcId, userId: { in: userIds } },
select: { userId: true },
distinct: ['userId'],
});
if (distinctUsers.length !== userIds.length) {
throw new BadRequestException('One or more users are not part of this KC');
}
}
if (guestIds.length) {
const guestCount = await this.prisma.guestAccount.count({
where: { id: { in: guestIds }, kcId },
});
if (guestCount !== guestIds.length) {
throw new BadRequestException('One or more guests are not part of this KC');
}
}
const channel = await this.prisma.chatChannel.create({
data: {
kcId,
type: ChatChannelType.GRUPPE,
name,
createdByUserId,
participants: {
create: [
...(userIds.includes(createdByUserId) ? [] : [{ userId: createdByUserId }]),
...userIds.map((userId) => ({ userId })),
...guestIds.map((guestAccountId) => ({ guestAccountId })),
],
},
},
include: { participants: true },
});
await this.sync.capture('ChatChannel', SyncOperation.CREATE, channel.id, channel);
return channel;
}
/// Candidates a caller may add to a GRUPPE channel in this KC: every team
/// member (any Gemeinde) plus every Konfi/guest, so a Verantwortliche/r can
/// pick across Gemeinde boundaries as intended. Same authorization as
/// creating a Gruppenchat (LT or Verantwortliche/r of this KC).
async listPossibleParticipants(kcId: string, caller: AuthenticatedUser) {
const isLt = caller.memberships.some((m) => m.role === Role.LEITUNGSTEAM);
const isVerantwortlicherHere = caller.memberships.some(
(m) => m.kcId === kcId && m.role === Role.GEMEINDE_VERANTWORTLICHER,
);
if (!isLt && !isVerantwortlicherHere) {
throw new ForbiddenException('Not allowed to list participants for this KC');
}
const [memberships, guests] = await Promise.all([
this.prisma.membership.findMany({
where: { kcId, status: 'ACTIVE' },
include: { user: { select: { id: true, firstName: true, lastName: true, email: true } } },
orderBy: { user: { lastName: 'asc' } },
}),
this.prisma.guestAccount.findMany({
where: { kcId },
select: { id: true, firstName: true, lastName: true, gemeindeId: true },
orderBy: { lastName: 'asc' },
}),
]);
const seenUsers = new Set<string>();
const users = [];
for (const m of memberships) {
if (seenUsers.has(m.userId)) continue;
seenUsers.add(m.userId);
users.push({
userId: m.user.id,
firstName: m.user.firstName,
lastName: m.user.lastName,
email: m.user.email,
role: m.role,
gemeindeId: m.gemeindeId,
});
}
return {
users,
guests: guests.map((g) => ({
guestId: g.id,
firstName: g.firstName,
lastName: g.lastName,
gemeindeId: g.gemeindeId,
})),
};
}
/// Adds a team user or a guest/Konfi to an existing GRUPPE channel. Only
/// the channel's creator or a Leitungsteam member may manage participants.
async addParticipant(
channelId: string,
caller: ChatCaller,
target: { userId?: string; guestId?: string },
) {
const channel = await this.getGruppeForManagementOrThrow(channelId, caller);
if (!target.userId && !target.guestId) {
throw new BadRequestException('userId or guestId is required');
}
if (target.userId && target.guestId) {
throw new BadRequestException('Provide either userId or guestId, not both');
}
if (target.userId) {
const isMember = await this.prisma.membership.findFirst({
where: { kcId: channel.kcId, userId: target.userId },
});
if (!isMember) {
throw new BadRequestException('User is not part of this KC');
}
const participant = await this.prisma.chatParticipant.upsert({
where: { channelId_userId: { channelId, userId: target.userId } },
create: { channelId, userId: target.userId },
update: {},
});
await this.sync.capture('ChatParticipant', SyncOperation.CREATE, participant.id, participant);
return participant;
}
const guest = await this.prisma.guestAccount.findFirst({
where: { id: target.guestId, kcId: channel.kcId },
});
if (!guest) {
throw new BadRequestException('Guest is not part of this KC');
}
const participant = await this.prisma.chatParticipant.upsert({
where: { channelId_guestAccountId: { channelId, guestAccountId: target.guestId! } },
create: { channelId, guestAccountId: target.guestId },
update: {},
});
await this.sync.capture('ChatParticipant', SyncOperation.CREATE, participant.id, participant);
return participant;
}
/// Removes a team user or a guest/Konfi from a GRUPPE channel. Same
/// authorization as addParticipant.
async removeParticipant(
channelId: string,
caller: ChatCaller,
target: { userId?: string; guestId?: string },
) {
await this.getGruppeForManagementOrThrow(channelId, caller);
if (!target.userId && !target.guestId) {
throw new BadRequestException('userId or guestId is required');
}
const existing = await this.prisma.chatParticipant.findFirst({
where: {
channelId,
userId: target.userId ?? undefined,
guestAccountId: target.guestId ?? undefined,
},
});
if (!existing) return { ok: true };
await this.prisma.chatParticipant.delete({ where: { id: existing.id } });
await this.sync.capture('ChatParticipant', SyncOperation.DELETE, existing.id, { id: existing.id });
return { ok: true };
}
private async getGruppeForManagementOrThrow(channelId: string, caller: ChatCaller) {
if (caller.kind !== 'user') {
throw new ForbiddenException('Guests may not manage channel participants');
}
const channel = await this.prisma.chatChannel.findUnique({ where: { id: channelId } });
if (!channel || channel.type !== ChatChannelType.GRUPPE) {
throw new NotFoundException('Gruppenchat not found');
}
const { user } = caller;
const isLt = user.memberships.some((m) => m.role === Role.LEITUNGSTEAM);
const isCreator = channel.createdByUserId === user.userId;
const isVerantwortlicherHere = user.memberships.some(
(m) => m.kcId === channel.kcId && m.role === Role.GEMEINDE_VERANTWORTLICHER,
);
if (!isLt && !isCreator && !isVerantwortlicherHere) {
throw new ForbiddenException('Not allowed to manage this Gruppenchat');
}
return channel;
}
async getOrCreateDirectChannel(kcId: string, userAId: string, userBId: string) {
const existing = await this.prisma.chatChannel.findFirst({
where: {
kcId,
type: ChatChannelType.DIREKT,
AND: [
{ participants: { some: { userId: userAId } } },
{ participants: { some: { userId: userBId } } },
],
},
});
if (existing) return existing;
const channel = await this.prisma.chatChannel.create({
data: {
kcId,
type: ChatChannelType.DIREKT,
participants: { create: [{ userId: userAId }, { userId: userBId }] },
},
});
await this.sync.capture('ChatChannel', SyncOperation.CREATE, channel.id, channel);
return channel;
}
async listChannelsForCaller(kcId: string, caller: ChatCaller) {
if (caller.kind === 'guest') {
return this.prisma.chatChannel.findMany({
where: {
kcId,
OR: [
{ type: ChatChannelType.BROADCAST },
{ type: ChatChannelType.GRUPPE, participants: { some: { guestAccountId: caller.guest.guestId } } },
],
},
});
}
const { user } = caller;
const isLt = user.memberships.some((m) => m.role === Role.LEITUNGSTEAM);
if (isLt) {
return this.prisma.chatChannel.findMany({ where: { kcId } });
}
const gemeindeIds = user.memberships
.filter((m) => m.kcId === kcId && m.gemeindeId)
.map((m) => m.gemeindeId as string);
return this.prisma.chatChannel.findMany({
where: {
kcId,
OR: [
{ type: ChatChannelType.BROADCAST },
{ type: ChatChannelType.GEMEINDE_GRUPPE, gemeindeId: { in: gemeindeIds } },
{ type: ChatChannelType.DIREKT, participants: { some: { userId: user.userId } } },
{ type: ChatChannelType.GRUPPE, participants: { some: { userId: user.userId } } },
],
},
});
}
async assertCanRead(channelId: string, caller: ChatCaller) {
return this.getChannelForCallerOrThrow(channelId, caller, 'read');
}
async assertCanWrite(channelId: string, caller: ChatCaller) {
return this.getChannelForCallerOrThrow(channelId, caller, 'write');
}
private async getChannelForCallerOrThrow(
channelId: string,
caller: ChatCaller,
mode: 'read' | 'write',
) {
const channel = await this.prisma.chatChannel.findUnique({
where: { id: channelId },
include: { participants: true },
});
if (!channel) {
throw new NotFoundException('Channel not found');
}
if (caller.kind === 'guest') {
if (channel.type === ChatChannelType.BROADCAST && mode === 'read') {
if (caller.guest.kcId !== channel.kcId) {
throw new ForbiddenException('Guest does not belong to this KC');
}
return channel;
}
if (channel.type === ChatChannelType.GRUPPE) {
const isParticipant = channel.participants.some(
(p) => p.guestAccountId === caller.guest.guestId,
);
if (!isParticipant) {
throw new ForbiddenException('Not a participant of this Gruppenchat');
}
return channel;
}
throw new ForbiddenException('Guests may only read broadcast channels or their Gruppenchats');
}
const { user } = caller;
const isLt = user.memberships.some((m) => m.role === Role.LEITUNGSTEAM);
if (isLt) {
return channel;
}
if (channel.kcId && !user.memberships.some((m) => m.kcId === channel.kcId)) {
throw new ForbiddenException('Not a member of this KC');
}
switch (channel.type) {
case ChatChannelType.BROADCAST:
if (mode === 'write') {
throw new ForbiddenException('Only Leitungsteam may post broadcasts');
}
return channel;
case ChatChannelType.LT_UEBERGREIFEND:
throw new ForbiddenException('Leitungsteam-only channel');
case ChatChannelType.GEMEINDE_GRUPPE: {
const inGemeinde = user.memberships.some(
(m) => m.kcId === channel.kcId && m.gemeindeId === channel.gemeindeId,
);
if (!inGemeinde) {
throw new ForbiddenException('Not a member of this Gemeinde');
}
return channel;
}
case ChatChannelType.DIREKT: {
const isParticipant = channel.participants.some((p) => p.userId === user.userId);
if (!isParticipant) {
throw new ForbiddenException('Not a participant of this conversation');
}
return channel;
}
case ChatChannelType.GRUPPE: {
const isParticipant = channel.participants.some((p) => p.userId === user.userId);
if (!isParticipant) {
throw new ForbiddenException('Not a participant of this Gruppenchat');
}
return channel;
}
default:
throw new ForbiddenException('Unknown channel type');
}
}
async sendMessage(channelId: string, caller: ChatCaller, body: string) {
const channel = await this.assertCanWrite(channelId, caller);
const message = await this.prisma.chatMessage.create({
data: {
channelId,
body,
senderUserId: caller.kind === 'user' ? caller.user.userId : null,
senderGuestId: caller.kind === 'guest' ? caller.guest.guestId : null,
},
});
await this.sync.capture('ChatMessage', SyncOperation.CREATE, message.id, message);
void this.push.notifyChannel(
channelId,
{
title: CHANNEL_TITLES[channel?.type ?? ChatChannelType.GEMEINDE_GRUPPE],
body: body.length > 140 ? `${body.slice(0, 137)}` : body,
data: { channelId },
},
{
userId: caller.kind === 'user' ? caller.user.userId : null,
guestId: caller.kind === 'guest' ? caller.guest.guestId : null,
},
);
return message;
}
async listMessages(channelId: string, caller: ChatCaller) {
await this.assertCanRead(channelId, caller);
return this.prisma.chatMessage.findMany({
where: { channelId },
orderBy: { createdAt: 'asc' },
});
}
}
+13
View File
@@ -0,0 +1,13 @@
import { IsOptional, IsString } from 'class-validator';
/// Exactly one of userId/guestId must be set; validated in the service since
/// class-validator doesn't express "exactly one of" declaratively.
export class AddParticipantDto {
@IsOptional()
@IsString()
userId?: string;
@IsOptional()
@IsString()
guestId?: string;
}
+31
View File
@@ -0,0 +1,31 @@
import { ArrayUnique, IsArray, IsEnum, IsOptional, IsString } from 'class-validator';
import { ChatChannelType } from '@prisma/client';
export class CreateChannelDto {
@IsEnum(ChatChannelType)
type!: ChatChannelType;
@IsOptional()
@IsString()
gemeindeId?: string;
/// Display name; used for GRUPPE channels.
@IsOptional()
@IsString()
name?: string;
/// Initial participants for a GRUPPE channel (team users). More can be
/// added/removed later via the participants endpoints.
@IsOptional()
@IsArray()
@ArrayUnique()
@IsString({ each: true })
participantUserIds?: string[];
/// Initial guest/Konfi participants for a GRUPPE channel.
@IsOptional()
@IsArray()
@ArrayUnique()
@IsString({ each: true })
participantGuestIds?: string[];
}
+11
View File
@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class CreateDirectChannelDto {
@IsString()
@IsNotEmpty()
kcId!: string;
@IsString()
@IsNotEmpty()
otherUserId!: string;
}
+7
View File
@@ -0,0 +1,7 @@
import { IsEnum } from 'class-validator';
import { FileVisibility } from '@prisma/client';
export class UploadFileDto {
@IsEnum(FileVisibility)
visibility!: FileVisibility;
}
+73
View File
@@ -0,0 +1,73 @@
import {
Body,
Controller,
Get,
Param,
Post,
Req,
Res,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { AuthGuard } from '@nestjs/passport';
import { Response } from 'express';
import { FilesService } from './files.service';
import { UploadFileDto } from './dto/upload-file.dto';
import { Roles } from '../common/roles.decorator';
import { RolesGuard } from '../common/roles.guard';
import { Role } from '../common/role.enum';
import { AuthenticatedRequest } from '../auth/authenticated-request';
import { GuestJwtPayload } from '../auth/guest-auth.service';
import { allowedVisibilitiesForUser, GUEST_ALLOWED_VISIBILITIES } from './visibility.util';
type FileCallerRequest = AuthenticatedRequest & { user?: AuthenticatedRequest['user'] | GuestJwtPayload };
function isGuest(user: unknown): user is GuestJwtPayload {
return !!user && typeof user === 'object' && 'guestId' in user;
}
@Controller('files')
export class FilesController {
constructor(private readonly files: FilesService) {}
/// Only the Leitungsteam uploads files (per KC), tagged with a visibility tier.
@Post(':kcId')
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
@UseInterceptors(FileInterceptor('file'))
upload(
@Param('kcId') kcId: string,
@Body() dto: UploadFileDto,
@UploadedFile() file: Express.Multer.File,
@Req() req: AuthenticatedRequest,
) {
return this.files.upload(kcId, dto.visibility, file.originalname, file.buffer, req.user!.userId);
}
@Get(':kcId')
@UseGuards(AuthGuard(['authentik', 'team', 'guest']))
list(@Param('kcId') kcId: string, @Req() req: FileCallerRequest) {
const allowed = isGuest(req.user)
? GUEST_ALLOWED_VISIBILITIES
: allowedVisibilitiesForUser(req.user!, kcId);
return this.files.listForCaller(kcId, allowed);
}
@Get('download/:fileId')
@UseGuards(AuthGuard(['authentik', 'team', 'guest']))
async download(
@Param('fileId') fileId: string,
@Req() req: FileCallerRequest,
@Res() res: Response,
) {
const meta = await this.files.getFileOrThrow(fileId);
const allowed = isGuest(req.user)
? GUEST_ALLOWED_VISIBILITIES
: allowedVisibilitiesForUser(req.user!, meta.kcId);
const { file, data } = await this.files.downloadForCaller(fileId, allowed);
res.setHeader('Content-Disposition', `attachment; filename="${file.filename}"`);
res.send(data);
}
}
+24
View File
@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { FilesService } from './files.service';
import { FilesController } from './files.controller';
import { STORAGE_PROVIDER } from './storage/storage-provider';
import { WebDavStorageProvider } from './storage/webdav-storage.provider';
import { S3StorageProvider } from './storage/s3-storage.provider';
@Module({
controllers: [FilesController],
providers: [
FilesService,
{
// Nextcloud (WebDAV) is the default target; STORAGE_PROVIDER=s3 switches to S3-compatible storage.
provide: STORAGE_PROVIDER,
inject: [ConfigService],
useFactory: (config: ConfigService) =>
config.get<string>('STORAGE_PROVIDER') === 's3'
? new S3StorageProvider(config)
: new WebDavStorageProvider(config),
},
],
})
export class FilesModule {}
+53
View File
@@ -0,0 +1,53 @@
import { ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { FileVisibility, SyncOperation } from '@prisma/client';
import { PrismaClient } from '../prisma/prisma.module';
import { STORAGE_PROVIDER, StorageProvider } from './storage/storage-provider';
import { SyncService } from '../sync/sync.service';
@Injectable()
export class FilesService {
constructor(
private readonly prisma: PrismaClient,
@Inject(STORAGE_PROVIDER) private readonly storage: StorageProvider,
private readonly sync: SyncService,
) {}
async upload(
kcId: string,
visibility: FileVisibility,
filename: string,
data: Buffer,
uploadedById: string,
) {
const storageKey = await this.storage.upload(kcId, filename, data);
const file = await this.prisma.file.create({
data: { kcId, storageKey, filename, visibility, uploadedById },
});
// Note: only metadata is replicated here; storageKey only resolves if
// local and cloud share the same Nextcloud/S3 backend (see sync docs).
await this.sync.capture('File', SyncOperation.CREATE, file.id, file);
return file;
}
listForCaller(kcId: string, allowedVisibilities: FileVisibility[]) {
return this.prisma.file.findMany({
where: { kcId, visibility: { in: allowedVisibilities } },
orderBy: { createdAt: 'desc' },
});
}
async downloadForCaller(fileId: string, allowedVisibilities: FileVisibility[]) {
const file = await this.getFileOrThrow(fileId);
if (!allowedVisibilities.includes(file.visibility)) {
throw new ForbiddenException('Not permitted to access this file');
}
const data = await this.storage.download(file.storageKey);
return { file, data };
}
getFileOrThrow(fileId: string) {
return this.prisma.file.findUniqueOrThrow({ where: { id: fileId } }).catch(() => {
throw new NotFoundException('File not found');
});
}
}
+53
View File
@@ -0,0 +1,53 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { randomUUID } from 'crypto';
import {
DeleteObjectCommand,
GetObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import { StorageProvider } from './storage-provider';
/// S3-compatible object storage (AWS S3, MinIO, etc.).
@Injectable()
export class S3StorageProvider implements StorageProvider {
private readonly client: S3Client;
private readonly bucket: string;
constructor(config: ConfigService) {
this.bucket = config.getOrThrow<string>('S3_BUCKET');
this.client = new S3Client({
region: config.get<string>('S3_REGION') ?? 'auto',
endpoint: config.get<string>('S3_ENDPOINT'),
forcePathStyle: config.get<string>('S3_FORCE_PATH_STYLE') === 'true',
credentials: {
accessKeyId: config.getOrThrow<string>('S3_ACCESS_KEY_ID'),
secretAccessKey: config.getOrThrow<string>('S3_SECRET_ACCESS_KEY'),
},
});
}
async upload(kcId: string, filename: string, data: Buffer): Promise<string> {
const storageKey = `${kcId}/${randomUUID()}-${filename}`;
await this.client.send(
new PutObjectCommand({ Bucket: this.bucket, Key: storageKey, Body: data }),
);
return storageKey;
}
async download(storageKey: string): Promise<Buffer> {
const result = await this.client.send(
new GetObjectCommand({ Bucket: this.bucket, Key: storageKey }),
);
const chunks: Uint8Array[] = [];
for await (const chunk of result.Body as AsyncIterable<Uint8Array>) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
async delete(storageKey: string): Promise<void> {
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: storageKey }));
}
}
+10
View File
@@ -0,0 +1,10 @@
/// Abstraction over the external file storage backend (Nextcloud via WebDAV,
/// or S3-compatible object storage). Implementations only need to move raw
/// bytes; visibility/ownership metadata lives in the `File` Prisma model.
export interface StorageProvider {
upload(kcId: string, filename: string, data: Buffer): Promise<string>;
download(storageKey: string): Promise<Buffer>;
delete(storageKey: string): Promise<void>;
}
export const STORAGE_PROVIDER = Symbol('STORAGE_PROVIDER');
@@ -0,0 +1,37 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { randomUUID } from 'crypto';
import { createClient, WebDAVClient } from 'webdav';
import { StorageProvider } from './storage-provider';
/// Nextcloud (or any WebDAV server) as file storage backend.
@Injectable()
export class WebDavStorageProvider implements StorageProvider {
private readonly client: WebDAVClient;
constructor(config: ConfigService) {
this.client = createClient(config.getOrThrow<string>('WEBDAV_URL'), {
username: config.getOrThrow<string>('WEBDAV_USERNAME'),
password: config.getOrThrow<string>('WEBDAV_PASSWORD'),
});
}
async upload(kcId: string, filename: string, data: Buffer): Promise<string> {
const dir = `/${kcId}`;
if (!(await this.client.exists(dir))) {
await this.client.createDirectory(dir, { recursive: true });
}
const storageKey = `${dir}/${randomUUID()}-${filename}`;
await this.client.putFileContents(storageKey, data, { overwrite: false });
return storageKey;
}
async download(storageKey: string): Promise<Buffer> {
const content = await this.client.getFileContents(storageKey);
return Buffer.isBuffer(content) ? content : Buffer.from(content as ArrayBuffer);
}
async delete(storageKey: string): Promise<void> {
await this.client.deleteFile(storageKey);
}
}
+21
View File
@@ -0,0 +1,21 @@
import { FileVisibility, Role } from '@prisma/client';
import { AuthenticatedUser } from '../auth/authenticated-request';
/// Maps the caller's role for a given KC to the file visibility tiers they may see.
/// LEITUNGSTEAM is global (per RolesGuard convention) and sees everything.
export function allowedVisibilitiesForUser(
user: AuthenticatedUser,
kcId: string,
): FileVisibility[] {
const isLt = user.memberships.some((m) => m.role === Role.LEITUNGSTEAM);
if (isLt) {
return [FileVisibility.ALLE, FileVisibility.ALLE_AUSSER_KONFIS, FileVisibility.NUR_LT];
}
const isTeamMemberForKc = user.memberships.some((m) => m.kcId === kcId);
if (isTeamMemberForKc) {
return [FileVisibility.ALLE, FileVisibility.ALLE_AUSSER_KONFIS];
}
return [];
}
export const GUEST_ALLOWED_VISIBILITIES: FileVisibility[] = [FileVisibility.ALLE];
+11
View File
@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class CreateGemeindeDto {
@IsString()
@IsNotEmpty()
kcId!: string;
@IsString()
@IsNotEmpty()
name!: string;
}
+7
View File
@@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class UpdateGemeindeDto {
@IsString()
@IsNotEmpty()
name!: string;
}
+53
View File
@@ -0,0 +1,53 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { GemeindeService } from './gemeinde.service';
import { CreateGemeindeDto } from './dto/create-gemeinde.dto';
import { UpdateGemeindeDto } from './dto/update-gemeinde.dto';
import { Roles } from '../common/roles.decorator';
import { RolesGuard } from '../common/roles.guard';
import { Role } from '../common/role.enum';
/// Gemeinde (congregation) management. Reserved for the Leitungsteam, which is
/// global across all KCs (see RolesGuard); Gemeinde Verantwortliche/Teamer
/// learn their own Gemeinde from their Membership, not from this endpoint.
@Controller('gemeinde')
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
export class GemeindeController {
constructor(private readonly gemeinde: GemeindeService) {}
@Post()
create(@Body() dto: CreateGemeindeDto) {
return this.gemeinde.create(dto.kcId, dto.name);
}
@Get()
list(@Query('kcId') kcId: string) {
return this.gemeinde.list(kcId);
}
@Get(':id')
get(@Param('id') id: string) {
return this.gemeinde.get(id);
}
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateGemeindeDto) {
return this.gemeinde.update(id, dto.name);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.gemeinde.remove(id);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { GemeindeService } from './gemeinde.service';
import { GemeindeController } from './gemeinde.controller';
@Module({
providers: [GemeindeService],
controllers: [GemeindeController],
})
export class GemeindeModule {}
+81
View File
@@ -0,0 +1,81 @@
import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma, SyncOperation } from '@prisma/client';
import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service';
/// CRUD for Gemeinden (congregations) within a KC. Creating/renaming/deleting
/// is Leitungsteam-only (see GemeindeController); other team roles may list
/// and read the Gemeinden of their KC for onboarding/assignment UIs.
@Injectable()
export class GemeindeService {
constructor(
private readonly prisma: PrismaClient,
private readonly sync: SyncService,
) {}
async create(kcId: string, name: string) {
const kc = await this.prisma.kc.findUnique({ where: { id: kcId } });
if (!kc) {
throw new NotFoundException('KC not found');
}
try {
const gemeinde = await this.prisma.gemeinde.create({ data: { kcId, name } });
await this.sync.capture('Gemeinde', SyncOperation.CREATE, gemeinde.id, gemeinde);
return gemeinde;
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === 'P2002'
) {
throw new ConflictException('A Gemeinde with this name already exists in this KC');
}
throw err;
}
}
list(kcId: string) {
return this.prisma.gemeinde.findMany({
where: { kcId },
orderBy: { name: 'asc' },
});
}
async get(id: string) {
const gemeinde = await this.prisma.gemeinde.findUnique({ where: { id } });
if (!gemeinde) {
throw new NotFoundException('Gemeinde not found');
}
return gemeinde;
}
async update(id: string, name: string) {
await this.get(id);
try {
const gemeinde = await this.prisma.gemeinde.update({
where: { id },
data: { name },
});
await this.sync.capture('Gemeinde', SyncOperation.UPDATE, gemeinde.id, gemeinde);
return gemeinde;
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === 'P2002'
) {
throw new ConflictException('A Gemeinde with this name already exists in this KC');
}
throw err;
}
}
async remove(id: string) {
await this.get(id);
const gemeinde = await this.prisma.gemeinde.delete({ where: { id } });
await this.sync.capture('Gemeinde', SyncOperation.DELETE, gemeinde.id, gemeinde);
return gemeinde;
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { RolesGuard } from '../common/roles.guard';
import { Role } from '../common/role.enum'; import { Role } from '../common/role.enum';
@Controller('kc') @Controller('kc')
@UseGuards(AuthGuard('authentik'), RolesGuard) @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
export class KcController { export class KcController {
constructor(private readonly kc: KcService) {} constructor(private readonly kc: KcService) {}
+10 -3
View File
@@ -1,15 +1,22 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { SyncOperation } from '@prisma/client';
import { PrismaClient } from '../prisma/prisma.module'; import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service';
@Injectable() @Injectable()
export class KcService { export class KcService {
constructor(private readonly prisma: PrismaClient) {} constructor(
private readonly prisma: PrismaClient,
private readonly sync: SyncService,
) {}
createKc(name: string) { async createKc(name: string) {
return this.prisma.kc.create({ const kc = await this.prisma.kc.create({
data: { name, inviteCode: randomBytes(6).toString('hex') }, data: { name, inviteCode: randomBytes(6).toString('hex') },
}); });
await this.sync.capture('Kc', SyncOperation.CREATE, kc.id, kc);
return kc;
} }
listKcs() { listKcs() {
+15
View File
@@ -0,0 +1,15 @@
import { Logger } from '@nestjs/common';
import { MailMessage, MailProvider } from './mail-provider';
/// Default provider: doesn't send anything, just logs that it would have.
/// Keeps the invite flow working before SMTP is configured.
export class LogMailProvider implements MailProvider {
private readonly logger = new Logger('MailProvider');
async send(message: MailMessage): Promise<boolean> {
this.logger.log(
`[log-only] would send "${message.subject}" to ${message.to}: ${message.text}`,
);
return false;
}
}
+18
View File
@@ -0,0 +1,18 @@
/// Abstraction over the outbound email backend. Default is a no-send provider
/// that only logs (fine for dev and for deployments that don't do email yet);
/// MAIL_PROVIDER=smtp switches to a real SMTP transport.
export interface MailMessage {
to: string;
subject: string;
text: string;
html?: string;
}
export interface MailProvider {
/// Resolves true if the message was handed off to the transport, false if
/// it was dropped (e.g. the log provider). Never throws for delivery
/// problems — callers treat email as best-effort.
send(message: MailMessage): Promise<boolean>;
}
export const MAIL_PROVIDER = Symbol('MAIL_PROVIDER');
+25
View File
@@ -0,0 +1,25 @@
import { Global, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MAIL_PROVIDER } from './mail-provider';
import { LogMailProvider } from './log-mail.provider';
import { SmtpMailProvider } from './smtp-mail.provider';
import { MailService } from './mail.service';
/// Global so any feature module can inject MailService. Provider defaults to
/// log-only; MAIL_PROVIDER=smtp switches to a real SMTP transport.
@Global()
@Module({
providers: [
MailService,
{
provide: MAIL_PROVIDER,
inject: [ConfigService],
useFactory: (config: ConfigService) =>
config.get<string>('MAIL_PROVIDER') === 'smtp'
? new SmtpMailProvider(config)
: new LogMailProvider(),
},
],
exports: [MailService],
})
export class MailModule {}
+43
View File
@@ -0,0 +1,43 @@
import { Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MAIL_PROVIDER, MailProvider } from './mail-provider';
@Injectable()
export class MailService {
private readonly appBaseUrl: string;
constructor(
@Inject(MAIL_PROVIDER) private readonly provider: MailProvider,
config: ConfigService,
) {
this.appBaseUrl = (config.get<string>('APP_BASE_URL') ?? 'http://localhost:3000').replace(
/\/$/,
'',
);
}
/// Sends a personal Gemeinde-Teamer invite. Returns whether it was handed
/// to the transport (false for the log-only provider or on failure).
sendTeamerInvite(opts: {
to: string;
kcName: string;
gemeindeName: string;
token: string;
expiresAt: Date | null;
}): Promise<boolean> {
const link = `${this.appBaseUrl}/?teamerInviteToken=${encodeURIComponent(opts.token)}`;
const expiry = opts.expiresAt
? `\n\nDer Link gilt bis ${opts.expiresAt.toISOString()}.`
: '';
return this.provider.send({
to: opts.to,
subject: `Einladung als Teamer:in ${opts.gemeindeName} (${opts.kcName})`,
text:
`Hallo,\n\ndu wurdest als Teamer:in für die Gemeinde "${opts.gemeindeName}" ` +
`beim ${opts.kcName} eingeladen.\n\n` +
`Konto anlegen: ${link}\n\n` +
`Falls der Link nicht funktioniert, nutze diesen Einladungscode: ${opts.token}` +
`${expiry}\n`,
});
}
}
+45
View File
@@ -0,0 +1,45 @@
import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as nodemailer from 'nodemailer';
import { MailMessage, MailProvider } from './mail-provider';
/// SMTP transport (MAIL_PROVIDER=smtp). Delivery failures are logged and
/// swallowed — callers treat email as best-effort.
export class SmtpMailProvider implements MailProvider {
private readonly logger = new Logger('MailProvider');
private readonly from: string;
private readonly transport: nodemailer.Transporter;
constructor(config: ConfigService) {
this.from = config.getOrThrow<string>('MAIL_FROM');
this.transport = nodemailer.createTransport({
host: config.getOrThrow<string>('SMTP_HOST'),
port: Number(config.get<string>('SMTP_PORT') ?? 587),
secure: config.get<string>('SMTP_SECURE') === 'true',
auth: config.get<string>('SMTP_USER')
? {
user: config.getOrThrow<string>('SMTP_USER'),
pass: config.getOrThrow<string>('SMTP_PASS'),
}
: undefined,
});
}
async send(message: MailMessage): Promise<boolean> {
try {
await this.transport.sendMail({
from: this.from,
to: message.to,
subject: message.subject,
text: message.text,
html: message.html,
});
return true;
} catch (err) {
this.logger.error(
`Failed to send "${message.subject}" to ${message.to}: ${(err as Error).message}`,
);
return false;
}
}
}
+52 -1
View File
@@ -1,11 +1,62 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import { WsAdapter } from '@nestjs/platform-ws';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
function parseAllowedOrigins(): string[] {
const envOrigins = process.env.ALLOWED_ORIGINS || process.env.CORS_ORIGIN;
if (envOrigins) {
return envOrigins
.split(',')
.map((o) => o.trim())
.filter(Boolean);
}
const defaultOrigins: string[] = [
'http://localhost:3000',
'http://localhost:3010',
'http://localhost:8080',
'http://127.0.0.1:3000',
'http://127.0.0.1:3010',
'http://127.0.0.1:8080',
];
if (process.env.APP_BASE_URL) {
try {
const parsed = new URL(process.env.APP_BASE_URL);
if (!defaultOrigins.includes(parsed.origin)) {
defaultOrigins.push(parsed.origin);
}
} catch {
const trimmed = process.env.APP_BASE_URL.trim();
if (!defaultOrigins.includes(trimmed)) {
defaultOrigins.push(trimmed);
}
}
}
return defaultOrigins;
}
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api');
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
app.enableCors();
const allowedOrigins = parseAllowedOrigins();
app.enableCors({
origin: (origin, callback) => {
// Allow requests with no origin (e.g. mobile apps, curl, same-origin)
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true,
});
app.useWebSocketAdapter(new WsAdapter(app));
await app.listen(process.env.PORT ?? 3000); await app.listen(process.env.PORT ?? 3000);
} }
bootstrap(); bootstrap();
@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class RegisterVerantwortlicheDto {
@IsString()
@IsNotEmpty()
inviteCode!: string;
@IsString()
@IsNotEmpty()
gemeindeId!: string;
}
+78
View File
@@ -0,0 +1,78 @@
import {
Body,
Controller,
Get,
Headers,
Param,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { OnboardingService } from './onboarding.service';
import { RegisterVerantwortlicheDto } from './dto/register-verantwortliche.dto';
import { Roles } from '../common/roles.decorator';
import { RolesGuard } from '../common/roles.guard';
import { Role } from '../common/role.enum';
function bearer(header?: string): string | undefined {
return header?.startsWith('Bearer ') ? header.slice('Bearer '.length) : undefined;
}
@Controller('onboarding')
export class OnboardingController {
constructor(private readonly onboarding: OnboardingService) {}
/// Public lookup: invite code -> KC name + selectable Gemeinden.
@Get('kc/:inviteCode')
resolveInvite(@Param('inviteCode') inviteCode: string) {
return this.onboarding.resolveInvite(inviteCode);
}
/// Self-registration as Gemeinde Verantwortliche/r. Authenticated by the
/// caller's raw Authentik bearer token (no local Membership required yet).
@Post('verantwortliche')
registerVerantwortliche(
@Body() dto: RegisterVerantwortlicheDto,
@Headers('authorization') authorization?: string,
) {
return this.onboarding.registerVerantwortliche(
bearer(authorization),
dto.inviteCode,
dto.gemeindeId,
);
}
/// Redeems a Leitungsteam-issued Verantwortliche invite: immediately
/// ACTIVE membership, no approval step (unlike self-registration above).
/// Authenticated by the caller's raw Authentik bearer token.
@Post('verantwortliche-invites/:token/redeem')
redeemVerantwortlicheInvite(
@Param('token') token: string,
@Headers('authorization') authorization?: string,
) {
return this.onboarding.redeemInvite(bearer(authorization), token);
}
/// Leitungsteam: review and act on pending self-registrations.
@Get('requests')
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
listRequests(@Query('kcId') kcId: string) {
return this.onboarding.listRequests(kcId);
}
@Post('requests/:membershipId/approve')
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
approve(@Param('membershipId') membershipId: string) {
return this.onboarding.approve(membershipId);
}
@Post('requests/:membershipId/reject')
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
reject(@Param('membershipId') membershipId: string) {
return this.onboarding.reject(membershipId);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { OnboardingService } from './onboarding.service';
import { OnboardingController } from './onboarding.controller';
@Module({
imports: [AuthModule],
providers: [OnboardingService],
controllers: [OnboardingController],
})
export class OnboardingModule {}
+231
View File
@@ -0,0 +1,231 @@
import {
BadRequestException,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { MembershipStatus, Role } from '@prisma/client';
import { OnboardingService } from './onboarding.service';
/// Prisma / Sync / TokenVerification faked in memory.
const CLAIMS = {
sub: 'authentik-sub-1',
email: 'Vera@example.org',
firstName: 'Vera',
lastName: 'Wong',
};
function makeService(seed: {
kc?: { id: string; name: string; inviteCode: string; isActive: boolean } | null;
gemeinde?: { id: string; name: string; kcId: string } | null;
user?: { id: string; authentikSub: string } | null;
membership?: {
id: string;
status: MembershipStatus;
userId: string;
kcId: string;
gemeindeId: string;
} | null;
tokenThrows?: boolean;
}) {
const state = {
membership: seed.membership ?? null,
};
const prisma = {
kc: {
findUnique: jest.fn().mockResolvedValue(
seed.kc === undefined
? { id: 'kc-1', name: 'KC 2026', inviteCode: 'code-1', isActive: true, gemeinden: [] }
: seed.kc,
),
findFirst: jest.fn().mockResolvedValue(
seed.kc === undefined
? { id: 'kc-1', name: 'KC 2026', inviteCode: 'code-1', isActive: true, gemeinden: [] }
: seed.kc,
),
},
gemeinde: {
findUnique: jest.fn().mockResolvedValue(
seed.gemeinde === undefined ? { id: 'gem-1', name: 'Nord', kcId: 'kc-1' } : seed.gemeinde,
),
},
user: {
findUnique: jest.fn().mockResolvedValue(seed.user ?? null),
create: jest.fn(({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({ id: 'u-new', ...data }),
),
},
membership: {
findUnique: jest.fn(() => Promise.resolve(state.membership)),
findMany: jest.fn().mockResolvedValue([]),
create: jest.fn(({ data }: { data: Record<string, unknown> }) => {
state.membership = { id: 'mem-new', ...data } as never;
return Promise.resolve(state.membership);
}),
update: jest.fn(({ data }: { data: { status: MembershipStatus } }) => {
state.membership = { ...state.membership!, ...data };
return Promise.resolve(state.membership);
}),
delete: jest.fn(() => Promise.resolve(state.membership!)),
},
};
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
const tokens = {
verifyAuthentikClaims: seed.tokenThrows
? jest.fn().mockRejectedValue(new UnauthorizedException('bad token'))
: jest.fn().mockResolvedValue(CLAIMS),
};
const service = new OnboardingService(prisma as never, sync as never, tokens as never);
return { service, prisma, sync, tokens };
}
describe('OnboardingService.resolveInvite', () => {
it('404s an unknown code', async () => {
const { service } = makeService({ kc: null });
await expect(service.resolveInvite('nope')).rejects.toBeInstanceOf(NotFoundException);
});
it('404s an inactive KC', async () => {
const { service } = makeService({
kc: { id: 'kc-1', name: 'KC', inviteCode: 'c', isActive: false },
});
await expect(service.resolveInvite('c')).rejects.toBeInstanceOf(NotFoundException);
});
it('returns the KC name and its Gemeinden', async () => {
const { service } = makeService({
kc: {
id: 'kc-1',
name: 'KC 2026',
inviteCode: 'code-1',
isActive: true,
gemeinden: [{ id: 'gem-1', name: 'Nord' }],
} as never,
});
await expect(service.resolveInvite('code-1')).resolves.toEqual({
kcId: 'kc-1',
kcName: 'KC 2026',
gemeinden: [{ id: 'gem-1', name: 'Nord' }],
});
});
});
describe('OnboardingService.registerVerantwortliche', () => {
it('rejects a missing token', async () => {
const { service } = makeService({});
await expect(
service.registerVerantwortliche(undefined, 'code-1', 'gem-1'),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('propagates an invalid token', async () => {
const { service } = makeService({ tokenThrows: true });
await expect(
service.registerVerantwortliche('t', 'code-1', 'gem-1'),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('404s an unknown invite code', async () => {
const { service } = makeService({ kc: null });
await expect(
service.registerVerantwortliche('t', 'bad', 'gem-1'),
).rejects.toBeInstanceOf(NotFoundException);
});
it('400s when the Gemeinde is not part of the KC', async () => {
const { service } = makeService({ gemeinde: { id: 'gem-9', name: 'X', kcId: 'other-kc' } });
await expect(
service.registerVerantwortliche('t', 'code-1', 'gem-9'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('provisions the user and creates a PENDING membership', async () => {
const { service, prisma, sync } = makeService({ user: null, membership: null });
const res = await service.registerVerantwortliche('t', 'code-1', 'gem-1');
expect(prisma.user.create).toHaveBeenCalledWith({
data: expect.objectContaining({
authentikSub: 'authentik-sub-1',
email: 'vera@example.org',
}),
});
expect(prisma.membership.create).toHaveBeenCalledWith({
data: expect.objectContaining({
role: Role.GEMEINDE_VERANTWORTLICHER,
status: MembershipStatus.PENDING,
gemeindeId: 'gem-1',
}),
});
expect(res).toMatchObject({ status: MembershipStatus.PENDING, kcName: 'KC 2026' });
expect(sync.capture).toHaveBeenCalledWith('User', 'CREATE', expect.any(String), expect.anything());
expect(sync.capture).toHaveBeenCalledWith('Membership', 'CREATE', expect.any(String), expect.anything());
});
it('does not re-create the user when one already exists', async () => {
const { service, prisma } = makeService({
user: { id: 'u-1', authentikSub: 'authentik-sub-1' },
membership: null,
});
await service.registerVerantwortliche('t', 'code-1', 'gem-1');
expect(prisma.user.create).not.toHaveBeenCalled();
expect(prisma.membership.create).toHaveBeenCalled();
});
it('returns the existing membership status without creating a second one', async () => {
const { service, prisma } = makeService({
user: { id: 'u-1', authentikSub: 'authentik-sub-1' },
membership: {
id: 'mem-1',
status: MembershipStatus.ACTIVE,
userId: 'u-1',
kcId: 'kc-1',
gemeindeId: 'gem-1',
},
});
const res = await service.registerVerantwortliche('t', 'code-1', 'gem-1');
expect(res).toMatchObject({ membershipId: 'mem-1', status: MembershipStatus.ACTIVE });
expect(prisma.membership.create).not.toHaveBeenCalled();
});
});
describe('OnboardingService.approve / reject', () => {
const pending = {
id: 'mem-1',
status: MembershipStatus.PENDING,
userId: 'u-1',
kcId: 'kc-1',
gemeindeId: 'gem-1',
};
it('404s approving an unknown request', async () => {
const { service } = makeService({ membership: null });
await expect(service.approve('mem-x')).rejects.toBeInstanceOf(NotFoundException);
});
it('400s approving a non-pending request', async () => {
const { service } = makeService({
membership: { ...pending, status: MembershipStatus.ACTIVE },
});
await expect(service.approve('mem-1')).rejects.toBeInstanceOf(BadRequestException);
});
it('flips the status to ACTIVE and captures the update', async () => {
const { service, prisma, sync } = makeService({ membership: { ...pending } });
await service.approve('mem-1');
expect(prisma.membership.update).toHaveBeenCalledWith({
where: { id: 'mem-1' },
data: { status: MembershipStatus.ACTIVE },
});
expect(sync.capture).toHaveBeenCalledWith('Membership', 'UPDATE', 'mem-1', expect.anything());
});
it('deletes on reject and captures the delete', async () => {
const { service, prisma, sync } = makeService({ membership: { ...pending } });
const res = await service.reject('mem-1');
expect(res).toEqual({ id: 'mem-1' });
expect(prisma.membership.delete).toHaveBeenCalledWith({ where: { id: 'mem-1' } });
expect(sync.capture).toHaveBeenCalledWith('Membership', 'DELETE', 'mem-1', { id: 'mem-1' });
});
});
+289
View File
@@ -0,0 +1,289 @@
import {
BadRequestException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { MembershipStatus, Role, SyncOperation } from '@prisma/client';
import { randomBytes } from 'crypto';
import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service';
import { TokenVerificationService } from '../auth/token-verification.service';
import { resolveOrProvisionAuthentikUser } from '../auth/provision-user';
import { AuthenticatedUser } from '../auth/authenticated-request';
/// Self-service onboarding for Gemeinde Verantwortliche, plus the
/// Leitungsteam-initiated shortcut that skips the approval step entirely.
@Injectable()
export class OnboardingService {
constructor(
private readonly prisma: PrismaClient,
private readonly sync: SyncService,
private readonly tokens: TokenVerificationService,
) {}
/// Public: resolves an invite code to the KC name and its Gemeinden so the
/// registrant can pick theirs. The code itself is the shared secret.
async resolveInvite(inviteCode: string) {
const trimmed = inviteCode.trim();
const kc =
(await this.prisma.kc.findFirst({
where: { inviteCode: { equals: trimmed, mode: 'insensitive' } },
include: {
gemeinden: { select: { id: true, name: true }, orderBy: { name: 'asc' } },
},
})) ||
(await this.prisma.kc.findUnique({
where: { inviteCode: trimmed },
include: {
gemeinden: { select: { id: true, name: true }, orderBy: { name: 'asc' } },
},
}));
if (!kc || !kc.isActive) {
throw new NotFoundException('Unknown or inactive KC invite code');
}
return { kcId: kc.id, kcName: kc.name, gemeinden: kc.gemeinden };
}
async registerVerantwortliche(token: string | undefined, inviteCode: string, gemeindeId: string) {
if (!token) {
throw new UnauthorizedException('Missing Authentik bearer token');
}
const { isLeitungsteam, ...claims } = await this.tokens.verifyAuthentikClaims(token);
const trimmed = inviteCode.trim();
const kc =
(await this.prisma.kc.findFirst({
where: { inviteCode: { equals: trimmed, mode: 'insensitive' } },
})) ||
(await this.prisma.kc.findUnique({ where: { inviteCode: trimmed } }));
if (!kc || !kc.isActive) {
throw new NotFoundException('Unknown or inactive KC invite code');
}
const gemeinde = await this.prisma.gemeinde.findUnique({ where: { id: gemeindeId } });
if (!gemeinde || gemeinde.kcId !== kc.id) {
throw new BadRequestException('Gemeinde does not belong to this KC');
}
const user = await resolveOrProvisionAuthentikUser(
this.prisma,
this.sync,
claims,
isLeitungsteam,
);
const existing = await this.prisma.membership.findUnique({
where: {
userId_kcId_gemeindeId: { userId: user.id, kcId: kc.id, gemeindeId },
},
});
if (existing) {
return this.summary(existing.id, existing.status, kc.name, gemeinde.name);
}
const membership = await this.prisma.membership.create({
data: {
userId: user.id,
kcId: kc.id,
gemeindeId,
role: Role.GEMEINDE_VERANTWORTLICHER,
status: MembershipStatus.PENDING,
},
});
await this.sync.capture('Membership', SyncOperation.CREATE, membership.id, membership);
return this.summary(membership.id, membership.status, kc.name, gemeinde.name);
}
async listRequests(kcId: string) {
return this.prisma.membership.findMany({
where: {
kcId,
status: MembershipStatus.PENDING,
role: Role.GEMEINDE_VERANTWORTLICHER,
},
include: {
user: { select: { id: true, email: true, firstName: true, lastName: true } },
gemeinde: { select: { id: true, name: true } },
},
orderBy: { createdAt: 'asc' },
});
}
async approve(membershipId: string) {
await this.getPendingOrThrow(membershipId);
const membership = await this.prisma.membership.update({
where: { id: membershipId },
data: { status: MembershipStatus.ACTIVE },
});
await this.sync.capture('Membership', SyncOperation.UPDATE, membership.id, membership);
return membership;
}
async reject(membershipId: string) {
await this.getPendingOrThrow(membershipId);
const membership = await this.prisma.membership.delete({ where: { id: membershipId } });
await this.sync.capture('Membership', SyncOperation.DELETE, membership.id, { id: membership.id });
return { id: membership.id };
}
private async getPendingOrThrow(membershipId: string) {
const membership = await this.prisma.membership.findUnique({ where: { id: membershipId } });
if (!membership) {
throw new NotFoundException('Request not found');
}
if (membership.status !== MembershipStatus.PENDING) {
throw new BadRequestException('Request is not pending');
}
return membership;
}
private summary(
membershipId: string,
status: MembershipStatus,
kcName: string,
gemeindeName: string,
) {
return { membershipId, status, kcName, gemeindeName };
}
// --- Leitungsteam-issued Verantwortliche invites ---
// Skips the PENDING approval step: an LT member vouching for someone
// directly is enough, unlike self-registration which needs review.
async createInvite(
caller: AuthenticatedUser,
gemeindeId: string,
dto: { email?: string; maxUses?: number; expiresInHours?: number },
) {
if (!caller.memberships.some((m) => m.role === Role.LEITUNGSTEAM)) {
throw new UnauthorizedException('Only Leitungsteam can issue this invite');
}
const gemeinde = await this.prisma.gemeinde.findUnique({ where: { id: gemeindeId } });
if (!gemeinde) {
throw new NotFoundException('Gemeinde not found');
}
const email = dto.email?.toLowerCase() ?? null;
const maxUses = dto.maxUses ?? (email ? 1 : null);
const expiresAt = dto.expiresInHours
? new Date(Date.now() + dto.expiresInHours * 3600_000)
: null;
const invite = await this.prisma.verantwortlicheInvite.create({
data: {
kcId: gemeinde.kcId,
gemeindeId,
token: randomBytes(24).toString('base64url'),
email,
maxUses,
expiresAt,
createdByUserId: caller.userId,
},
});
await this.sync.capture('VerantwortlicheInvite', SyncOperation.CREATE, invite.id, invite);
return invite;
}
async listInvites(caller: AuthenticatedUser, gemeindeId: string) {
if (!caller.memberships.some((m) => m.role === Role.LEITUNGSTEAM)) {
throw new UnauthorizedException('Only Leitungsteam can view this');
}
return this.prisma.verantwortlicheInvite.findMany({
where: { gemeindeId },
orderBy: { createdAt: 'desc' },
});
}
async revokeInvite(caller: AuthenticatedUser, gemeindeId: string, inviteId: string) {
if (!caller.memberships.some((m) => m.role === Role.LEITUNGSTEAM)) {
throw new UnauthorizedException('Only Leitungsteam can revoke this');
}
const invite = await this.prisma.verantwortlicheInvite.findFirst({
where: { id: inviteId, gemeindeId },
});
if (!invite) {
throw new NotFoundException('Invite not found');
}
const updated = await this.prisma.verantwortlicheInvite.update({
where: { id: inviteId },
data: { revokedAt: new Date() },
});
await this.sync.capture('VerantwortlicheInvite', SyncOperation.UPDATE, updated.id, updated);
return updated;
}
/// Redeems an LT-issued invite: provisions/updates the caller's Authentik
/// User and grants an immediately-ACTIVE GEMEINDE_VERANTWORTLICHER
/// membership (no approval step, unlike self-registration).
async redeemInvite(token: string | undefined, inviteToken: string) {
if (!token) {
throw new UnauthorizedException('Missing Authentik bearer token');
}
const invite = await this.prisma.verantwortlicheInvite.findUnique({
where: { token: inviteToken },
});
if (!invite || invite.revokedAt) {
throw new NotFoundException('Unknown or revoked invite');
}
if (invite.expiresAt && invite.expiresAt.getTime() < Date.now()) {
throw new BadRequestException('Invite has expired');
}
if (invite.maxUses !== null && invite.usedCount >= invite.maxUses) {
throw new BadRequestException('Invite has already been used up');
}
const { isLeitungsteam, ...claims } = await this.tokens.verifyAuthentikClaims(token);
if (invite.email && invite.email !== claims.email.toLowerCase()) {
throw new BadRequestException('This invite is pinned to a different account');
}
const user = await resolveOrProvisionAuthentikUser(
this.prisma,
this.sync,
claims,
isLeitungsteam,
);
const existing = await this.prisma.membership.findUnique({
where: {
userId_kcId_gemeindeId: {
userId: user.id,
kcId: invite.kcId,
gemeindeId: invite.gemeindeId,
},
},
});
const membership = existing
? await this.prisma.membership.update({
where: { id: existing.id },
data: { status: MembershipStatus.ACTIVE, role: Role.GEMEINDE_VERANTWORTLICHER },
})
: await this.prisma.membership.create({
data: {
userId: user.id,
kcId: invite.kcId,
gemeindeId: invite.gemeindeId,
role: Role.GEMEINDE_VERANTWORTLICHER,
status: MembershipStatus.ACTIVE,
},
});
await this.sync.capture(
'Membership',
existing ? SyncOperation.UPDATE : SyncOperation.CREATE,
membership.id,
membership,
);
const updatedInvite = await this.prisma.verantwortlicheInvite.update({
where: { id: invite.id },
data: { usedCount: { increment: 1 } },
});
await this.sync.capture(
'VerantwortlicheInvite',
SyncOperation.UPDATE,
updatedInvite.id,
updatedInvite,
);
return { membershipId: membership.id, status: membership.status };
}
}
+16
View File
@@ -0,0 +1,16 @@
import { IsIn, IsNotEmpty, IsString } from 'class-validator';
export class RegisterDeviceDto {
@IsString()
@IsNotEmpty()
token!: string;
@IsIn(['web', 'android', 'ios'])
platform!: 'web' | 'android' | 'ios';
}
export class UnregisterDeviceDto {
@IsString()
@IsNotEmpty()
token!: string;
}
+110
View File
@@ -0,0 +1,110 @@
import { readFileSync } from 'fs';
import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as jwt from 'jsonwebtoken';
import { PushNotification, PushProvider, PushResult } from './push-provider';
interface ServiceAccount {
client_email: string;
private_key: string;
token_uri: string;
}
/// Firebase Cloud Messaging HTTP v1. Auth is a service-account JWT exchanged
/// for an OAuth access token (no google-auth-library dependency — jsonwebtoken
/// is already here). Delivery failures are logged and swallowed.
export class FcmPushProvider implements PushProvider {
private readonly logger = new Logger('PushProvider');
private readonly projectId: string;
private readonly sa: ServiceAccount;
private accessToken: { value: string; expiresAt: number } | null = null;
constructor(config: ConfigService) {
this.projectId = config.getOrThrow<string>('FCM_PROJECT_ID');
const path = config.getOrThrow<string>('GOOGLE_APPLICATION_CREDENTIALS');
this.sa = JSON.parse(readFileSync(path, 'utf8')) as ServiceAccount;
}
async sendToTokens(tokens: string[], n: PushNotification): Promise<PushResult> {
if (tokens.length === 0) return { sent: 0, invalidTokens: [] };
let accessToken: string;
try {
accessToken = await this.getAccessToken();
} catch (err) {
this.logger.error(`FCM auth failed: ${(err as Error).message}`);
return { sent: 0, invalidTokens: [] };
}
const url = `https://fcm.googleapis.com/v1/projects/${this.projectId}/messages:send`;
const invalidTokens: string[] = [];
let sent = 0;
await Promise.all(
tokens.map(async (token) => {
try {
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: {
token,
notification: { title: n.title, body: n.body },
data: n.data,
webpush: { fcmOptions: {} },
},
}),
});
if (res.ok) {
sent += 1;
} else if (res.status === 404 || res.status === 400) {
invalidTokens.push(token);
} else {
this.logger.warn(`FCM send ${res.status}: ${await res.text()}`);
}
} catch (err) {
this.logger.warn(`FCM send error: ${(err as Error).message}`);
}
}),
);
return { sent, invalidTokens };
}
private async getAccessToken(): Promise<string> {
if (this.accessToken && this.accessToken.expiresAt > Date.now() + 60_000) {
return this.accessToken.value;
}
const now = Math.floor(Date.now() / 1000);
const assertion = jwt.sign(
{
iss: this.sa.client_email,
scope: 'https://www.googleapis.com/auth/firebase.messaging',
aud: this.sa.token_uri,
iat: now,
exp: now + 3600,
},
this.sa.private_key,
{ algorithm: 'RS256' },
);
const res = await fetch(this.sa.token_uri, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion,
}),
});
if (!res.ok) {
throw new Error(`token exchange ${res.status}: ${await res.text()}`);
}
const json = (await res.json()) as { access_token: string; expires_in: number };
this.accessToken = {
value: json.access_token,
expiresAt: Date.now() + json.expires_in * 1000,
};
return json.access_token;
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Logger } from '@nestjs/common';
import { PushNotification, PushProvider, PushResult } from './push-provider';
/// Default provider: doesn't send, just logs. Keeps the app working before
/// FCM credentials are configured.
export class LogPushProvider implements PushProvider {
private readonly logger = new Logger('PushProvider');
async sendToTokens(tokens: string[], n: PushNotification): Promise<PushResult> {
this.logger.log(
`[log-only] would push "${n.title}" to ${tokens.length} device(s): ${n.body}`,
);
return { sent: 0, invalidTokens: [] };
}
}
+20
View File
@@ -0,0 +1,20 @@
/// Abstraction over the push backend. Default is a no-send provider that only
/// logs; PUSH_PROVIDER=fcm switches to Firebase Cloud Messaging (HTTP v1).
export interface PushNotification {
title: string;
body: string;
data?: Record<string, string>;
}
export interface PushResult {
sent: number;
/// Tokens FCM reported as permanently invalid — the caller prunes them.
invalidTokens: string[];
}
export interface PushProvider {
/// Best-effort: never throws for delivery problems.
sendToTokens(tokens: string[], notification: PushNotification): Promise<PushResult>;
}
export const PUSH_PROVIDER = Symbol('PUSH_PROVIDER');
+29
View File
@@ -0,0 +1,29 @@
import { Body, Controller, Post, Req, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { PushService } from './push.service';
import { RegisterDeviceDto, UnregisterDeviceDto } from './dto/register-device.dto';
// Import the util directly (not via chat.service) to keep the module graph acyclic.
import { resolveChatCaller } from '../chat/caller.util';
import { AuthenticatedRequest } from '../auth/authenticated-request';
import { GuestJwtPayload } from '../auth/guest-auth.service';
type PushRequest = AuthenticatedRequest & {
user?: AuthenticatedRequest['user'] | GuestJwtPayload;
};
@Controller('push')
export class PushController {
constructor(private readonly push: PushService) {}
@Post('register')
@UseGuards(AuthGuard(['authentik', 'team', 'guest']))
register(@Body() dto: RegisterDeviceDto, @Req() req: PushRequest) {
return this.push.register(dto.token, dto.platform, resolveChatCaller(req.user!));
}
@Post('unregister')
@UseGuards(AuthGuard(['authentik', 'team', 'guest']))
unregister(@Body() dto: UnregisterDeviceDto) {
return this.push.unregister(dto.token);
}
}
+27
View File
@@ -0,0 +1,27 @@
import { Global, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PUSH_PROVIDER } from './push-provider';
import { LogPushProvider } from './log-push.provider';
import { FcmPushProvider } from './fcm-push.provider';
import { PushService } from './push.service';
import { PushController } from './push.controller';
/// Global so ChatService can inject PushService. Provider defaults to
/// log-only; PUSH_PROVIDER=fcm switches to Firebase Cloud Messaging.
@Global()
@Module({
controllers: [PushController],
providers: [
PushService,
{
provide: PUSH_PROVIDER,
inject: [ConfigService],
useFactory: (config: ConfigService) =>
config.get<string>('PUSH_PROVIDER') === 'fcm'
? new FcmPushProvider(config)
: new LogPushProvider(),
},
],
exports: [PushService],
})
export class PushModule {}
+163
View File
@@ -0,0 +1,163 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { ChatChannelType, SyncOperation } from '@prisma/client';
import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service';
import type { ChatCaller } from '../chat/chat.service';
import { PUSH_PROVIDER, PushNotification, PushProvider } from './push-provider';
@Injectable()
export class PushService {
private readonly logger = new Logger(PushService.name);
constructor(
private readonly prisma: PrismaClient,
private readonly sync: SyncService,
@Inject(PUSH_PROVIDER) private readonly provider: PushProvider,
) {}
/// Upsert a device token for the current caller (team user or guest).
async register(token: string, platform: string, caller: ChatCaller) {
const owner =
caller.kind === 'user'
? { userId: caller.user.userId, guestAccountId: null }
: { userId: null, guestAccountId: caller.guest.guestId };
const row = await this.prisma.deviceToken.upsert({
where: { token },
create: { token, platform, ...owner },
update: { platform, lastSeenAt: new Date(), ...owner },
});
await this.sync.capture('DeviceToken', SyncOperation.UPDATE, row.id, row);
return { ok: true };
}
async unregister(token: string) {
const existing = await this.prisma.deviceToken.findUnique({ where: { token } });
if (!existing) return { ok: true };
await this.prisma.deviceToken.delete({ where: { token } });
await this.sync.capture('DeviceToken', SyncOperation.DELETE, existing.id, {
id: existing.id,
});
return { ok: true };
}
/// Fan a chat message out as a push to everyone who can read the channel,
/// minus the sender. Best-effort — never throws into the caller.
async notifyChannel(
channelId: string,
notification: PushNotification,
exclude: { userId?: string | null; guestId?: string | null } = {},
): Promise<void> {
try {
const channel = await this.prisma.chatChannel.findUnique({
where: { id: channelId },
include: { participants: { select: { userId: true, guestAccountId: true } } },
});
if (!channel) return;
const { userIds, guestIds } = await this.audience(channel);
const tokens = await this.prisma.deviceToken.findMany({
where: {
OR: [
userIds.length ? { userId: { in: userIds } } : undefined,
guestIds.length ? { guestAccountId: { in: guestIds } } : undefined,
].filter(Boolean) as object[],
NOT: {
OR: [
exclude.userId ? { userId: exclude.userId } : undefined,
exclude.guestId ? { guestAccountId: exclude.guestId } : undefined,
].filter(Boolean) as object[],
},
},
select: { token: true },
});
if (tokens.length === 0) return;
const { invalidTokens } = await this.provider.sendToTokens(
tokens.map((t) => t.token),
notification,
);
if (invalidTokens.length) {
await this.prisma.deviceToken.deleteMany({
where: { token: { in: invalidTokens } },
});
}
} catch (err) {
this.logger.warn(`notifyChannel failed: ${(err as Error).message}`);
}
}
private async audience(channel: {
kcId: string;
type: ChatChannelType;
gemeindeId: string | null;
participants: { userId: string | null; guestAccountId: string | null }[];
}): Promise<{ userIds: string[]; guestIds: string[] }> {
if (channel.type === ChatChannelType.DIREKT) {
return {
userIds: channel.participants.map((p) => p.userId).filter((id): id is string => !!id),
guestIds: [],
};
}
if (channel.type === ChatChannelType.GRUPPE) {
return {
userIds: channel.participants.map((p) => p.userId).filter((id): id is string => !!id),
guestIds: channel.participants
.map((p) => p.guestAccountId)
.filter((id): id is string => !!id),
};
}
const ltUsers = await this.prisma.user.findMany({
where: {
OR: [
{ isLeitungsteam: true },
{ memberships: { some: { kcId: channel.kcId, role: 'LEITUNGSTEAM' } } },
],
},
select: { id: true },
});
const ltIds = ltUsers.map((u) => u.id);
if (channel.type === ChatChannelType.LT_UEBERGREIFEND) {
return { userIds: ltIds, guestIds: [] };
}
if (channel.type === ChatChannelType.GEMEINDE_GRUPPE) {
const [members, guests] = await Promise.all([
this.prisma.membership.findMany({
where: {
kcId: channel.kcId,
gemeindeId: channel.gemeindeId,
status: 'ACTIVE',
},
select: { userId: true },
}),
this.prisma.guestAccount.findMany({
where: { kcId: channel.kcId, gemeindeId: channel.gemeindeId },
select: { id: true },
}),
]);
return {
userIds: [...new Set([...ltIds, ...members.map((m) => m.userId)])],
guestIds: guests.map((g) => g.id),
};
}
// BROADCAST: everyone in the KC.
const [members, guests] = await Promise.all([
this.prisma.membership.findMany({
where: { kcId: channel.kcId, status: 'ACTIVE' },
select: { userId: true },
}),
this.prisma.guestAccount.findMany({
where: { kcId: channel.kcId },
select: { id: true },
}),
]);
return {
userIds: [...new Set([...ltIds, ...members.map((m) => m.userId)])],
guestIds: guests.map((g) => g.id),
};
}
}
+55
View File
@@ -0,0 +1,55 @@
import { Type } from 'class-transformer';
import {
IsArray,
IsDateString,
IsIn,
IsInt,
IsNotEmpty,
IsObject,
IsString,
Min,
ValidateNested,
} from 'class-validator';
import { SyncOperation } from '@prisma/client';
import { SYNCED_MODELS, SyncedModel } from '../synced-models';
/// Strict per-entry validation: only whitelisted models/operations are
/// accepted, and the payload must be a plain object. This is the boundary
/// where an untrusted peer's JSON becomes typed data - reject anything that
/// doesn't match rather than letting it reach Prisma's generic delegate.
export class SyncEntryDto {
@IsInt()
@Min(1)
sequence!: number;
@IsIn(SYNCED_MODELS)
model!: SyncedModel;
@IsString()
@IsNotEmpty()
recordId!: string;
@IsIn(Object.values(SyncOperation))
operation!: SyncOperation;
@IsObject()
payload!: Record<string, unknown>;
@IsString()
@IsNotEmpty()
originId!: string;
/// Wall-clock time the mutation actually happened, used for last-write-wins
/// conflict resolution - required so a peer can't omit it and silently
/// win every conflict via a default "now".
@IsDateString()
occurredAt!: string;
}
export class IngestEntriesDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => SyncEntryDto)
entries!: SyncEntryDto[];
}
+47
View File
@@ -0,0 +1,47 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Interval } from '@nestjs/schedule';
import { SyncService } from './sync.service';
/// Periodically pushes/pulls against the configured peer when enabled. Safe
/// to fail silently (e.g. no internet at an on-site event) - just retries
/// on the next tick.
@Injectable()
export class SyncSchedulerService {
private readonly logger = new Logger(SyncSchedulerService.name);
/// Guards against a tick starting while the previous one is still running
/// (e.g. a large backlog push/pull that exceeds the 30s interval).
/// SyncService.withPeerLock is the authoritative per-peer guard; this flag
/// just avoids logging noisy "already in progress" warnings every tick.
private running = false;
constructor(
private readonly sync: SyncService,
private readonly config: ConfigService,
) {}
@Interval(30_000)
async tick() {
if (this.config.get<string>('SYNC_ENABLED') !== 'true') return;
const peerUrl = this.config.get<string>('SYNC_PEER_URL');
const peerSecret = this.config.get<string>('SYNC_SHARED_SECRET');
if (!peerUrl || !peerSecret) return;
if (this.running) {
this.logger.debug('Previous sync tick still running, skipping this tick');
return;
}
this.running = true;
try {
const pushResult = await this.sync.pushToPeer(peerUrl, peerSecret);
const pullResult = await this.sync.pullFromPeer(peerUrl, peerSecret);
this.logger.debug(
`Sync tick ok: pushed=${pushResult.pushed} pulled=${pullResult.pulled}`,
);
} catch (err) {
this.logger.debug(`Sync with peer skipped: ${(err as Error).message}`);
} finally {
this.running = false;
}
}
}
+36
View File
@@ -0,0 +1,36 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
import { timingSafeEqual } from 'crypto';
/// Server-to-server auth for /sync/*: a shared secret header, not a user token.
@Injectable()
export class SyncSecretGuard implements CanActivate {
constructor(private readonly config: ConfigService) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<Request>();
const expected = this.config.getOrThrow<string>('SYNC_SHARED_SECRET');
const provided = request.headers['x-sync-secret'];
if (typeof provided !== 'string' || !this.secretsMatch(provided, expected)) {
throw new ForbiddenException('Invalid sync secret');
}
return true;
}
/// Plain `!==` leaks timing info proportional to the matching prefix
/// length, letting an attacker brute-force the secret byte by byte over
/// enough requests. Compare as fixed-length buffers instead.
private secretsMatch(provided: string, expected: string): boolean {
const providedBuf = Buffer.from(provided);
const expectedBuf = Buffer.from(expected);
if (providedBuf.length !== expectedBuf.length) {
// Still run a constant-time compare against a same-length dummy so
// the length check itself doesn't introduce a distinct fast path.
timingSafeEqual(expectedBuf, expectedBuf);
return false;
}
return timingSafeEqual(providedBuf, expectedBuf);
}
}
+64
View File
@@ -0,0 +1,64 @@
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { SyncService } from './sync.service';
import { SyncSecretGuard } from './sync-secret.guard';
import { IngestEntriesDto } from './dto/ingest-entries.dto';
import { Roles } from '../common/roles.decorator';
import { RolesGuard } from '../common/roles.guard';
import { Role } from '../common/role.enum';
const EXPORT_QUERY_MAX = 1_000_000_000;
@Controller('sync')
export class SyncController {
constructor(
private readonly sync: SyncService,
private readonly config: ConfigService,
) {}
/// Peer pushes its new entries to us.
@Post('ingest')
@UseGuards(SyncSecretGuard)
async ingest(@Body() dto: IngestEntriesDto) {
return this.sync.applyIncoming(dto.entries);
}
/// Peer pulls our new entries since their last known sequence.
@Get('export')
@UseGuards(SyncSecretGuard)
async export(@Query('since') since: string) {
// Reject garbage/negative/absurd cursors outright rather than silently
// coercing them to 0 (which would re-export the whole log to a peer
// that sent a malformed value).
const parsed = Number(since);
const sinceSequence =
Number.isInteger(parsed) && parsed >= 0 && parsed <= EXPORT_QUERY_MAX ? parsed : 0;
const entries = await this.sync.getEntriesSince(sinceSequence);
return { entries };
}
/// Manual on-demand push+pull against the configured peer (Leitungsteam-only).
@Post('trigger')
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
async trigger() {
const peerUrl = this.config.getOrThrow<string>('SYNC_PEER_URL');
const peerSecret = this.config.getOrThrow<string>('SYNC_SHARED_SECRET');
const pushed = await this.sync.pushToPeer(peerUrl, peerSecret);
const pulled = await this.sync.pullFromPeer(peerUrl, peerSecret);
return { ...pushed, ...pulled };
}
/// Leitungsteam-only visibility into replication health: cursors, pending
/// backlog size, and the last push/pull timestamps or error, so a stalled
/// sync (e.g. bad secret, network down) shows up before anyone notices
/// stale data.
@Get('status')
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
async status() {
const peerUrl = this.config.getOrThrow<string>('SYNC_PEER_URL');
return this.sync.getStatus(peerUrl);
}
}
+16
View File
@@ -0,0 +1,16 @@
import { Global, Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { SyncService } from './sync.service';
import { SyncController } from './sync.controller';
import { SyncSchedulerService } from './sync-scheduler.service';
/// Global so every feature module can inject SyncService to capture its
/// mutations without each one importing SyncModule explicitly.
@Global()
@Module({
imports: [ScheduleModule.forRoot()],
controllers: [SyncController],
providers: [SyncService, SyncSchedulerService],
exports: [SyncService],
})
export class SyncModule {}
+359
View File
@@ -0,0 +1,359 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SyncOperation } from '@prisma/client';
import { PrismaClient } from '../prisma/prisma.module';
import { SYNCED_MODELS, SyncedModel } from './synced-models';
const PAGE_SIZE = 500;
/// Hard ceiling per push/pull call so a huge backlog (e.g. days offline)
/// can't turn one tick into an unbounded, memory-hungry transfer. The
/// scheduler just picks it back up on the next tick.
const MAX_ENTRIES_PER_CALL = 10 * PAGE_SIZE;
export interface IncomingEntry {
sequence: number;
model: SyncedModel;
recordId: string;
operation: SyncOperation;
payload: Record<string, unknown>;
originId: string;
occurredAt: string | Date;
}
export interface SyncPeerStatus {
peerId: string;
lastPushedSequence: number;
lastPulledSequence: number;
localMaxSequence: number;
pendingPush: number;
lastPushAt: Date | null;
lastPullAt: Date | null;
lastError: string | null;
recentConflicts: number;
}
/// Replicates mutations between the local (on-site) and cloud server.
///
/// Concurrent-edit handling: both servers can legitimately write while an
/// event is live (e.g. Leitungsteam edits in the cloud admin UI while the
/// on-site server is also active), so we resolve conflicts automatically by
/// last-write-wins on the mutation's real wall-clock time (`occurredAt`),
/// never by sync/network arrival order. `SyncRecordVersion` tracks, per
/// record, the most recent writer and timestamp seen by *this* server
/// (whether written locally or applied from a peer). A losing write is
/// still recorded to `SyncConflict` for after-the-fact review - resolution
/// itself never blocks or pauses live sync.
@Injectable()
export class SyncService {
private readonly logger = new Logger(SyncService.name);
readonly serverId: string;
/// In-memory, per-peer status for observability (health endpoint) plus a
/// crude mutex so overlapping ticks (a push+pull pair that takes longer
/// than 30s) can't race each other's cursor updates.
private readonly peerStatus = new Map<string, SyncPeerStatus>();
private readonly peerLocks = new Set<string>();
constructor(
private readonly prisma: PrismaClient,
private readonly config: ConfigService,
) {
this.serverId = config.getOrThrow<string>('SERVER_ID');
}
/// Called by feature services right after a mutation to append it to the
/// replication log and mark this server as the latest writer of record.
async capture(
model: SyncedModel,
operation: SyncOperation,
recordId: string,
payload: object,
occurredAt: Date = new Date(),
) {
await this.prisma.$transaction([
this.prisma.syncLogEntry.create({
data: {
model,
recordId,
operation,
payload: payload as never,
originId: this.serverId,
occurredAt,
},
}),
this.prisma.syncRecordVersion.upsert({
where: { model_recordId: { model, recordId } },
create: { model, recordId, lastWriteAt: occurredAt, lastWriteOrigin: this.serverId },
update: { lastWriteAt: occurredAt, lastWriteOrigin: this.serverId },
}),
]);
}
async getEntriesSince(sequence: number, limit = PAGE_SIZE) {
return this.prisma.syncLogEntry.findMany({
where: { sequence: { gt: sequence } },
orderBy: { sequence: 'asc' },
take: Math.min(limit, MAX_ENTRIES_PER_CALL),
});
}
/// Applies entries received from a peer inside one transaction, so a
/// mid-batch failure can't leave the local DB half-updated relative to the
/// cursor we're about to advance. Never re-captures them, which is what
/// prevents echo loops between the two servers.
///
/// Per entry: if this server has a newer local write for the same record
/// (by occurredAt) from a *different* origin, the incoming entry loses -
/// it's recorded as a SyncConflict and skipped, keeping the newer local
/// data intact. Otherwise the incoming entry wins and is applied.
async applyIncoming(entries: IncomingEntry[]) {
let appliedCount = 0;
let conflictCount = 0;
await this.prisma.$transaction(async (tx) => {
for (const entry of entries) {
if (entry.originId === this.serverId) continue;
if (!SYNCED_MODELS.includes(entry.model)) {
this.logger.warn(`Rejecting sync entry for unknown model "${entry.model}"`);
continue;
}
const occurredAt = new Date(entry.occurredAt);
const existing = await tx.syncRecordVersion.findUnique({
where: { model_recordId: { model: entry.model, recordId: entry.recordId } },
});
if (existing && existing.lastWriteOrigin !== entry.originId && existing.lastWriteAt > occurredAt) {
// A newer write (by a different origin) already won for this
// record - keep it, log the loser for manual review.
conflictCount += 1;
this.logger.warn(
`Sync conflict on ${entry.model}/${entry.recordId}: keeping ${existing.lastWriteOrigin}'s newer write over ${entry.originId}'s`,
);
const delegate = this.delegateFor(tx, entry.model);
const currentRecord = await delegate
.findUnique({ where: { id: entry.recordId } })
.catch(() => null);
await tx.syncConflict.create({
data: {
model: entry.model,
recordId: entry.recordId,
winningOrigin: existing.lastWriteOrigin,
losingOrigin: entry.originId,
winningPayload: (currentRecord ?? {}) as never,
losingPayload: entry.payload as never,
},
});
continue;
}
const delegate = this.delegateFor(tx, entry.model);
try {
if (entry.operation === SyncOperation.DELETE) {
await delegate.delete({ where: { id: entry.recordId } });
} else {
// Force the record's id from recordId, not from payload, so a
// mismatched/forged id in the payload can never redirect the
// write onto a different row.
const { id: _ignoredId, ...rest } = entry.payload;
await delegate.upsert({
where: { id: entry.recordId },
create: { id: entry.recordId, ...rest },
update: rest,
});
}
await tx.syncRecordVersion.upsert({
where: { model_recordId: { model: entry.model, recordId: entry.recordId } },
create: {
model: entry.model,
recordId: entry.recordId,
lastWriteAt: occurredAt,
lastWriteOrigin: entry.originId,
},
update: { lastWriteAt: occurredAt, lastWriteOrigin: entry.originId },
});
appliedCount += 1;
} catch (err) {
this.logger.warn(
`Failed to apply sync entry ${entry.model}/${entry.recordId}: ${(err as Error).message}`,
);
}
}
});
return { applied: appliedCount, conflicts: conflictCount };
}
async pushToPeer(peerUrl: string, peerSecret: string) {
const peerId = new URL(peerUrl).host;
return this.withPeerLock(peerId, async () => {
const cursor = await this.getOrCreateCursor(peerId);
let pushed = 0;
let lastSequence = cursor.lastPushedSequence;
// Loop pages so a large backlog (offline event site catching back up)
// is fully drained in one tick instead of trickling 500 at a time
// across many 30s intervals.
for (;;) {
const entries = await this.getEntriesSince(lastSequence);
if (entries.length === 0) break;
const res = await this.fetchWithTimeout(`${peerUrl}/sync/ingest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-sync-secret': peerSecret },
body: JSON.stringify({ entries }),
});
if (!res.ok) {
throw new Error(`Peer rejected sync push: ${res.status}`);
}
lastSequence = entries[entries.length - 1].sequence;
pushed += entries.length;
await this.prisma.syncCursor.update({
where: { peerId },
data: { lastPushedSequence: lastSequence },
});
if (entries.length < PAGE_SIZE || pushed >= MAX_ENTRIES_PER_CALL) break;
}
this.recordSuccess(peerId, { lastPushAt: new Date() });
return { pushed };
});
}
async pullFromPeer(peerUrl: string, peerSecret: string) {
const peerId = new URL(peerUrl).host;
return this.withPeerLock(peerId, async () => {
const cursor = await this.getOrCreateCursor(peerId);
let pulled = 0;
let conflicts = 0;
let lastSequence = cursor.lastPulledSequence;
for (;;) {
const res = await this.fetchWithTimeout(
`${peerUrl}/sync/export?since=${lastSequence}`,
{ headers: { 'x-sync-secret': peerSecret } },
);
if (!res.ok) {
throw new Error(`Peer rejected sync pull: ${res.status}`);
}
const { entries } = (await res.json()) as { entries: IncomingEntry[] };
if (entries.length === 0) break;
const result = await this.applyIncoming(entries);
conflicts += result.conflicts;
lastSequence = entries[entries.length - 1].sequence;
pulled += entries.length;
await this.prisma.syncCursor.update({
where: { peerId },
data: { lastPulledSequence: lastSequence },
});
if (entries.length < PAGE_SIZE || pulled >= MAX_ENTRIES_PER_CALL) break;
}
this.recordSuccess(peerId, { lastPullAt: new Date(), recentConflicts: conflicts });
return { pulled, conflicts };
});
}
/// Snapshot of replication health for a peer, for the /sync/status endpoint.
async getStatus(peerUrl: string): Promise<SyncPeerStatus> {
const peerId = new URL(peerUrl).host;
const cursor = await this.getOrCreateCursor(peerId);
const latest = await this.prisma.syncLogEntry.findFirst({ orderBy: { sequence: 'desc' } });
const localMaxSequence = latest?.sequence ?? 0;
const recentConflicts = await this.prisma.syncConflict.count({
where: { detectedAt: { gt: new Date(Date.now() - 24 * 60 * 60 * 1000) } },
});
const cached = this.peerStatus.get(peerId);
return {
peerId,
lastPushedSequence: cursor.lastPushedSequence,
lastPulledSequence: cursor.lastPulledSequence,
localMaxSequence,
pendingPush: Math.max(0, localMaxSequence - cursor.lastPushedSequence),
lastPushAt: cached?.lastPushAt ?? null,
lastPullAt: cached?.lastPullAt ?? null,
lastError: cached?.lastError ?? null,
recentConflicts,
};
}
/// Serializes push/pull per peer so an overrunning tick (slow network,
/// big backlog) can never overlap with the next scheduled tick and race
/// the same cursor row.
private async withPeerLock<T>(peerId: string, fn: () => Promise<T>): Promise<T> {
if (this.peerLocks.has(peerId)) {
throw new Error(`Sync with ${peerId} already in progress, skipping`);
}
this.peerLocks.add(peerId);
try {
return await fn();
} catch (err) {
this.recordFailure(peerId, err as Error);
throw err;
} finally {
this.peerLocks.delete(peerId);
}
}
private blankStatus(peerId: string): SyncPeerStatus {
return {
peerId,
lastPushedSequence: 0,
lastPulledSequence: 0,
localMaxSequence: 0,
pendingPush: 0,
lastPushAt: null,
lastPullAt: null,
lastError: null,
recentConflicts: 0,
};
}
private recordSuccess(peerId: string, patch: Partial<SyncPeerStatus>) {
const current = this.peerStatus.get(peerId) ?? this.blankStatus(peerId);
this.peerStatus.set(peerId, { ...current, ...patch, lastError: null });
}
private recordFailure(peerId: string, err: Error) {
const current = this.peerStatus.get(peerId) ?? this.blankStatus(peerId);
this.peerStatus.set(peerId, { ...current, lastError: err.message });
}
private async fetchWithTimeout(url: string, init: RequestInit, timeoutMs = 15_000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
private async getOrCreateCursor(peerId: string) {
return this.prisma.syncCursor.upsert({
where: { peerId },
create: { peerId },
update: {},
});
}
private delegateFor(
tx: Parameters<Parameters<PrismaClient['$transaction']>[0]>[0],
model: SyncedModel,
) {
const key = (model.charAt(0).toLowerCase() + model.slice(1)) as keyof typeof tx;
// Generic dispatch across models is inherent to a replication log; each
// delegate exposes the same upsert/delete shape we need here.
return tx[key] as unknown as {
upsert: (args: {
where: { id: string };
create: object;
update: object;
}) => Promise<unknown>;
delete: (args: { where: { id: string } }) => Promise<unknown>;
findUnique: (args: { where: { id: string } }) => Promise<unknown>;
};
}
}
+21
View File
@@ -0,0 +1,21 @@
export const SYNCED_MODELS = [
'Kc',
'Gemeinde',
'User',
'Membership',
'TeamerInvite',
'VerantwortlicheInvite',
'GuestAccount',
'Wahl',
'Workshop',
'Teilnehmer',
'ForceZuteilung',
'Zuteilung',
'File',
'ChatChannel',
'ChatParticipant',
'ChatMessage',
'DeviceToken',
] as const;
export type SyncedModel = (typeof SYNCED_MODELS)[number];
@@ -0,0 +1,21 @@
import { IsEmail, IsInt, IsOptional, Min } from 'class-validator';
export class CreateTeamerInviteDto {
/// Set for a personal invite pinned to one address; omit for a shareable
/// group link.
@IsOptional()
@IsEmail()
email?: string;
/// Max redemptions. Defaults to 1 for a personal invite, unlimited for a
/// group link.
@IsOptional()
@IsInt()
@Min(1)
maxUses?: number;
@IsOptional()
@IsInt()
@Min(1)
expiresInHours?: number;
}
+18
View File
@@ -0,0 +1,18 @@
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
export class CreateTeamerDto {
@IsString()
@IsNotEmpty()
firstName!: string;
@IsString()
@IsNotEmpty()
lastName!: string;
@IsEmail()
email!: string;
@IsString()
@MinLength(8)
password!: string;
}
+75
View File
@@ -0,0 +1,75 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { TeamerService } from './teamer.service';
import { CreateTeamerDto } from './dto/create-teamer.dto';
import { CreateTeamerInviteDto } from './dto/create-teamer-invite.dto';
import { Roles } from '../common/roles.decorator';
import { RolesGuard } from '../common/roles.guard';
import { Role } from '../common/role.enum';
import { AuthenticatedRequest } from '../auth/authenticated-request';
/// Gemeinde Teamer administration. The coarse guard admits Leitungsteam and
/// Gemeinde Verantwortliche (Authentik tokens, or a local `isLeitungsteam`
/// account via a team token); TeamerService then checks the caller is
/// actually responsible for `:gemeindeId`.
@Controller('gemeinde/:gemeindeId')
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM, Role.GEMEINDE_VERANTWORTLICHER)
export class TeamerController {
constructor(private readonly teamer: TeamerService) {}
@Post('teamer')
create(
@Param('gemeindeId') gemeindeId: string,
@Body() dto: CreateTeamerDto,
@Req() req: AuthenticatedRequest,
) {
return this.teamer.createTeamer(req.user!, gemeindeId, dto);
}
@Get('teamer')
list(@Param('gemeindeId') gemeindeId: string, @Req() req: AuthenticatedRequest) {
return this.teamer.listTeamer(req.user!, gemeindeId);
}
@Delete('teamer/:userId')
remove(
@Param('gemeindeId') gemeindeId: string,
@Param('userId') userId: string,
@Req() req: AuthenticatedRequest,
) {
return this.teamer.removeTeamer(req.user!, gemeindeId, userId);
}
@Post('teamer-invites')
createInvite(
@Param('gemeindeId') gemeindeId: string,
@Body() dto: CreateTeamerInviteDto,
@Req() req: AuthenticatedRequest,
) {
return this.teamer.createInvite(req.user!, gemeindeId, dto);
}
@Get('teamer-invites')
listInvites(@Param('gemeindeId') gemeindeId: string, @Req() req: AuthenticatedRequest) {
return this.teamer.listInvites(req.user!, gemeindeId);
}
@Delete('teamer-invites/:inviteId')
revokeInvite(
@Param('gemeindeId') gemeindeId: string,
@Param('inviteId') inviteId: string,
@Req() req: AuthenticatedRequest,
) {
return this.teamer.revokeInvite(req.user!, gemeindeId, inviteId);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { TeamerService } from './teamer.service';
import { TeamerController } from './teamer.controller';
@Module({
providers: [TeamerService],
controllers: [TeamerController],
})
export class TeamerModule {}
+183
View File
@@ -0,0 +1,183 @@
import {
ConflictException,
ForbiddenException,
NotFoundException,
} from '@nestjs/common';
import { Role } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { TeamerService } from './teamer.service';
import { AuthenticatedUser } from '../auth/authenticated-request';
/// Focus: the Gemeinde-scope check (assertCanManage) and the create/invite
/// branching. Prisma + SyncService faked in memory.
const GEMEINDE = { id: 'gem-1', name: 'Nord', kcId: 'kc-1', createdAt: new Date() };
function caller(memberships: AuthenticatedUser['memberships']): AuthenticatedUser {
return { userId: 'caller-1', authentikSub: 'sub-1', email: 'c@example.org', memberships };
}
const LT = caller([{ kcId: 'kc-1', gemeindeId: null, role: Role.LEITUNGSTEAM }]);
const VERANTW_GEM1 = caller([
{ kcId: 'kc-1', gemeindeId: 'gem-1', role: Role.GEMEINDE_VERANTWORTLICHER },
]);
const VERANTW_GEM2 = caller([
{ kcId: 'kc-1', gemeindeId: 'gem-2', role: Role.GEMEINDE_VERANTWORTLICHER },
]);
function makeService(opts: { gemeinde?: typeof GEMEINDE | null; existingEmails?: string[] } = {}) {
const gemeinde = opts.gemeinde === undefined ? GEMEINDE : opts.gemeinde;
const emails = new Set(opts.existingEmails ?? []);
const created: Record<string, unknown> = {};
const prisma = {
gemeinde: { findUnique: jest.fn().mockResolvedValue(gemeinde) },
kc: { findUnique: jest.fn().mockResolvedValue({ name: 'KC 2026' }) },
user: {
findUnique: jest.fn(({ where }: { where: { email: string } }) =>
Promise.resolve(emails.has(where.email) ? { id: 'dup', email: where.email } : null),
),
create: jest.fn(({ data }: { data: Record<string, unknown> }) => {
created.user = { id: 'u-1', createdAt: new Date(), ...data };
return Promise.resolve(created.user);
}),
delete: jest.fn().mockResolvedValue({ id: 'u-1' }),
},
membership: {
create: jest.fn(({ data }: { data: Record<string, unknown> }) => {
created.membership = { id: 'm-1', ...data };
return Promise.resolve(created.membership);
}),
findFirst: jest.fn().mockResolvedValue(null),
findMany: jest.fn().mockResolvedValue([]),
},
teamerInvite: {
create: jest.fn(({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({ id: 'inv-1', usedCount: 0, revokedAt: null, ...data }),
),
},
};
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
const mail = { sendTeamerInvite: jest.fn().mockResolvedValue(true) };
const service = new TeamerService(prisma as never, sync as never, mail as never);
return { service, prisma, sync, mail, created };
}
describe('TeamerService scope check', () => {
it('404s when the Gemeinde does not exist', async () => {
const { service } = makeService({ gemeinde: null });
await expect(service.listTeamer(LT, 'gem-x')).rejects.toBeInstanceOf(NotFoundException);
});
it('lets the Leitungsteam manage any Gemeinde', async () => {
const { service, prisma } = makeService();
await expect(service.listTeamer(LT, 'gem-1')).resolves.toEqual([]);
});
it('lets a Verantwortliche/r manage their own Gemeinde', async () => {
const { service, prisma } = makeService();
await expect(service.listTeamer(VERANTW_GEM1, 'gem-1')).resolves.toEqual([]);
});
it('forbids a Verantwortliche/r from managing a different Gemeinde', async () => {
const { service } = makeService();
await expect(service.listTeamer(VERANTW_GEM2, 'gem-1')).rejects.toBeInstanceOf(
ForbiddenException,
);
});
});
describe('TeamerService.createTeamer', () => {
it('rejects a duplicate email', async () => {
const { service } = makeService({ existingEmails: ['dup@example.org'] });
await expect(
service.createTeamer(VERANTW_GEM1, 'gem-1', {
firstName: 'A',
lastName: 'B',
email: 'dup@example.org',
password: 'password1',
}),
).rejects.toBeInstanceOf(ConflictException);
});
it('creates a hashed local account + GEMEINDE_TEAMER membership and hides the hash', async () => {
const { service, created, sync } = makeService();
const res = await service.createTeamer(VERANTW_GEM1, 'gem-1', {
firstName: 'Ada',
lastName: 'Lo',
email: 'Ada@Example.org',
password: 'password1',
});
expect(res).not.toHaveProperty('passwordHash');
expect(res.email).toBe('ada@example.org');
expect((created.user as { kcId: string }).kcId).toBe('kc-1');
expect(
await bcrypt.compare('password1', (created.user as { passwordHash: string }).passwordHash),
).toBe(true);
expect((created.membership as { role: Role }).role).toBe(Role.GEMEINDE_TEAMER);
expect((created.membership as { gemeindeId: string }).gemeindeId).toBe('gem-1');
expect(sync.capture).toHaveBeenCalledWith('User', 'CREATE', 'u-1', expect.anything());
expect(sync.capture).toHaveBeenCalledWith('Membership', 'CREATE', 'm-1', expect.anything());
});
});
describe('TeamerService.createInvite', () => {
it('defaults a group link to unlimited uses, no expiry, and sends no email', async () => {
const { service, mail } = makeService();
const inv = await service.createInvite(LT, 'gem-1', {});
expect(inv.email).toBeNull();
expect(inv.maxUses).toBeNull();
expect(inv.expiresAt).toBeNull();
expect(inv.token).toEqual(expect.any(String));
expect(inv.emailSent).toBe(false);
expect(mail.sendTeamerInvite).not.toHaveBeenCalled();
});
it('defaults a personal invite to a single use, lowercases the email, and mails it', async () => {
const { service, mail } = makeService();
const inv = await service.createInvite(LT, 'gem-1', { email: 'New@Example.org' });
expect(inv.email).toBe('new@example.org');
expect(inv.maxUses).toBe(1);
expect(inv.emailSent).toBe(true);
expect(mail.sendTeamerInvite).toHaveBeenCalledWith(
expect.objectContaining({ to: 'new@example.org', gemeindeName: 'Nord', kcName: 'KC 2026' }),
);
});
it('still returns the invite when the mail transport drops it', async () => {
const { service, mail } = makeService();
mail.sendTeamerInvite.mockResolvedValueOnce(false);
const inv = await service.createInvite(LT, 'gem-1', { email: 'x@example.org' });
expect(inv.emailSent).toBe(false);
expect(inv.token).toEqual(expect.any(String));
});
it('turns expiresInHours into a concrete expiry', async () => {
const { service } = makeService();
const before = Date.now();
const inv = await service.createInvite(LT, 'gem-1', { expiresInHours: 48 });
const ms = (inv.expiresAt as Date).getTime() - before;
expect(ms).toBeGreaterThan(47 * 3600_000);
expect(ms).toBeLessThan(49 * 3600_000);
});
});
describe('TeamerService.removeTeamer', () => {
it('404s when the user is not a local Teamer of that Gemeinde', async () => {
const { service } = makeService();
await expect(service.removeTeamer(LT, 'gem-1', 'u-9')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('deletes the account and captures a User DELETE', async () => {
const { service, prisma, sync } = makeService();
prisma.membership.findFirst = jest
.fn()
.mockResolvedValue({ userId: 'u-1', gemeindeId: 'gem-1', user: { passwordHash: 'h' } });
const res = await service.removeTeamer(LT, 'gem-1', 'u-1');
expect(res).toEqual({ id: 'u-1' });
expect(prisma.user.delete).toHaveBeenCalledWith({ where: { id: 'u-1' } });
expect(sync.capture).toHaveBeenCalledWith('User', 'DELETE', 'u-1', { id: 'u-1' });
});
});
+201
View File
@@ -0,0 +1,201 @@
import {
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { randomBytes } from 'crypto';
import { Role, SyncOperation } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service';
import { MailService } from '../mail/mail.service';
import { AuthenticatedUser } from '../auth/authenticated-request';
import { CreateTeamerInviteDto } from './dto/create-teamer-invite.dto';
const BCRYPT_ROUNDS = 10;
type PublicUser = {
id: string;
email: string;
firstName: string;
lastName: string;
createdAt: Date;
};
/// Management of local Gemeinde Teamer accounts and their invites. Callable by
/// the Leitungsteam (any Gemeinde) or by a Gemeinde Verantwortliche/r for
/// their own Gemeinde only.
@Injectable()
export class TeamerService {
constructor(
private readonly prisma: PrismaClient,
private readonly sync: SyncService,
private readonly mail: MailService,
) {}
async createTeamer(
caller: AuthenticatedUser,
gemeindeId: string,
input: { firstName: string; lastName: string; email: string; password: string },
): Promise<PublicUser> {
const gemeinde = await this.assertCanManage(caller, gemeindeId);
const email = input.email.toLowerCase();
if (await this.prisma.user.findUnique({ where: { email } })) {
throw new ConflictException('An account with this email already exists');
}
const passwordHash = await bcrypt.hash(input.password, BCRYPT_ROUNDS);
const user = await this.prisma.user.create({
data: {
email,
firstName: input.firstName,
lastName: input.lastName,
passwordHash,
kcId: gemeinde.kcId,
},
});
const membership = await this.prisma.membership.create({
data: {
userId: user.id,
kcId: gemeinde.kcId,
gemeindeId,
role: Role.GEMEINDE_TEAMER,
},
});
await this.sync.capture('User', SyncOperation.CREATE, user.id, user);
await this.sync.capture('Membership', SyncOperation.CREATE, membership.id, membership);
return toPublicUser(user);
}
async listTeamer(caller: AuthenticatedUser, gemeindeId: string): Promise<PublicUser[]> {
await this.assertCanManage(caller, gemeindeId);
const memberships = await this.prisma.membership.findMany({
where: { gemeindeId, role: Role.GEMEINDE_TEAMER },
include: { user: true },
orderBy: { user: { lastName: 'asc' } },
});
return memberships.map((m) => toPublicUser(m.user));
}
async removeTeamer(
caller: AuthenticatedUser,
gemeindeId: string,
userId: string,
): Promise<{ id: string }> {
await this.assertCanManage(caller, gemeindeId);
const membership = await this.prisma.membership.findFirst({
where: { userId, gemeindeId, role: Role.GEMEINDE_TEAMER },
include: { user: true },
});
if (!membership || !membership.user.passwordHash) {
throw new NotFoundException('No local Teamer account for this Gemeinde');
}
await this.prisma.user.delete({ where: { id: userId } });
await this.sync.capture('User', SyncOperation.DELETE, userId, { id: userId });
return { id: userId };
}
async createInvite(
caller: AuthenticatedUser,
gemeindeId: string,
dto: CreateTeamerInviteDto,
) {
const gemeinde = await this.assertCanManage(caller, gemeindeId);
const email = dto.email?.toLowerCase() ?? null;
const maxUses = dto.maxUses ?? (email ? 1 : null);
const expiresAt = dto.expiresInHours
? new Date(Date.now() + dto.expiresInHours * 3600_000)
: null;
const invite = await this.prisma.teamerInvite.create({
data: {
kcId: gemeinde.kcId,
gemeindeId,
token: randomBytes(24).toString('base64url'),
email,
maxUses,
expiresAt,
createdByUserId: caller.userId,
},
});
await this.sync.capture('TeamerInvite', SyncOperation.CREATE, invite.id, invite);
// Personal invites go out by email (best-effort); group links are shared
// by the Verantwortliche/r directly.
let emailSent = false;
if (email) {
const kc = await this.prisma.kc.findUnique({
where: { id: gemeinde.kcId },
select: { name: true },
});
emailSent = await this.mail.sendTeamerInvite({
to: email,
kcName: kc?.name ?? '',
gemeindeName: gemeinde.name,
token: invite.token,
expiresAt: invite.expiresAt,
});
}
return { ...invite, emailSent };
}
async listInvites(caller: AuthenticatedUser, gemeindeId: string) {
await this.assertCanManage(caller, gemeindeId);
return this.prisma.teamerInvite.findMany({
where: { gemeindeId },
orderBy: { createdAt: 'desc' },
});
}
async revokeInvite(caller: AuthenticatedUser, gemeindeId: string, inviteId: string) {
await this.assertCanManage(caller, gemeindeId);
const invite = await this.prisma.teamerInvite.findFirst({
where: { id: inviteId, gemeindeId },
});
if (!invite) {
throw new NotFoundException('Invite not found');
}
const updated = await this.prisma.teamerInvite.update({
where: { id: inviteId },
data: { revokedAt: new Date() },
});
await this.sync.capture('TeamerInvite', SyncOperation.UPDATE, updated.id, updated);
return updated;
}
/// LT may manage every Gemeinde; a Verantwortliche/r only the one they hold
/// that role for. Returns the Gemeinde (for its kcId) on success.
private async assertCanManage(caller: AuthenticatedUser, gemeindeId: string) {
const gemeinde = await this.prisma.gemeinde.findUnique({ where: { id: gemeindeId } });
if (!gemeinde) {
throw new NotFoundException('Gemeinde not found');
}
const isLeitungsteam = caller.memberships.some(
(m) => m.role === Role.LEITUNGSTEAM,
);
const isVerantwortlich = caller.memberships.some(
(m) => m.role === Role.GEMEINDE_VERANTWORTLICHER && m.gemeindeId === gemeindeId,
);
if (!isLeitungsteam && !isVerantwortlich) {
throw new ForbiddenException('Not responsible for this Gemeinde');
}
return gemeinde;
}
}
function toPublicUser(user: {
id: string;
email: string;
firstName: string;
lastName: string;
createdAt: Date;
}): PublicUser {
return {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
createdAt: user.createdAt,
};
}
@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class CreateForceZuteilungDto {
@IsString()
@IsNotEmpty()
teilnehmerId!: string;
@IsString()
@IsNotEmpty()
workshopId!: string;
}
+19
View File
@@ -0,0 +1,19 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class CreateWahlDto {
@IsString()
@IsNotEmpty()
kcId!: string;
@IsString()
@IsNotEmpty()
name!: string;
@IsString()
@IsNotEmpty()
datumsSchluessel!: string;
@IsString()
@IsNotEmpty()
teil!: string;
}
+15
View File
@@ -0,0 +1,15 @@
import { IsInt, IsNotEmpty, IsString, Min } from 'class-validator';
export class CreateWorkshopDto {
@IsString()
@IsNotEmpty()
name!: string;
@IsInt()
@Min(1)
kapazitaet!: number;
@IsInt()
@Min(0)
minTeilnehmer: number = 0;
}
+11
View File
@@ -0,0 +1,11 @@
import { ArrayMaxSize, ArrayNotEmpty, IsArray, IsString } from 'class-validator';
/// Ordered workshop-id preferences, most preferred first (up to 3, matching
/// the original plugin's wunsch1..wunsch3).
export class SubmitTeilnehmerDto {
@IsArray()
@ArrayNotEmpty()
@ArrayMaxSize(3)
@IsString({ each: true })
prioritaeten!: string[];
}
+7
View File
@@ -0,0 +1,7 @@
import { IsBoolean, IsOptional } from 'class-validator';
export class UpdateWahlDto {
@IsOptional()
@IsBoolean()
isOpen?: boolean;
}
+140
View File
@@ -0,0 +1,140 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Response } from 'express';
import { WahlService } from './wahl.service';
import { ZuteilungService } from './zuteilung.service';
import { CreateWahlDto } from './dto/create-wahl.dto';
import { UpdateWahlDto } from './dto/update-wahl.dto';
import { CreateWorkshopDto } from './dto/create-workshop.dto';
import { SubmitTeilnehmerDto } from './dto/submit-teilnehmer.dto';
import { CreateForceZuteilungDto } from './dto/create-force-zuteilung.dto';
import { Roles } from '../common/roles.decorator';
import { RolesGuard } from '../common/roles.guard';
import { Role } from '../common/role.enum';
import { GuestAuthenticatedRequest } from '../auth/authenticated-request';
@Controller('wahl')
export class WahlController {
constructor(
private readonly wahl: WahlService,
private readonly zuteilung: ZuteilungService,
) {}
/// Wahl-/Workshop-/Force-Zuteilung-Verwaltung ist ausschließlich Sache des
/// Leitungsteams (global über alle KCs, siehe RolesGuard).
@Post()
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
createWahl(@Body() dto: CreateWahlDto) {
return this.wahl.createWahl(dto.kcId, dto.name, dto.datumsSchluessel, dto.teil);
}
@Get()
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
listWahlen(@Query('kcId') kcId: string) {
return this.wahl.listWahlen(kcId);
}
@Patch(':wahlId')
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
updateWahl(@Param('wahlId') wahlId: string, @Body() dto: UpdateWahlDto) {
return this.wahl.updateWahl(wahlId, { isOpen: dto.isOpen });
}
@Get(':wahlId/teilnehmer')
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
listTeilnehmer(@Param('wahlId') wahlId: string) {
return this.wahl.listTeilnehmer(wahlId);
}
@Post(':wahlId/workshops')
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
createWorkshop(@Param('wahlId') wahlId: string, @Body() dto: CreateWorkshopDto) {
return this.wahl.createWorkshop(wahlId, dto.name, dto.kapazitaet, dto.minTeilnehmer);
}
@Get(':wahlId/workshops')
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
listWorkshops(@Param('wahlId') wahlId: string) {
return this.wahl.listWorkshops(wahlId);
}
@Post(':wahlId/force-zuteilung')
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
createForceZuteilung(
@Param('wahlId') wahlId: string,
@Body() dto: CreateForceZuteilungDto,
) {
return this.wahl.createForceZuteilung(wahlId, dto.teilnehmerId, dto.workshopId);
}
/// Everything a guest needs to fill in the Wahl: open Wahlen for their KC,
/// each with its workshops and the guest's own current priorities (if any).
@Get('guest/overview')
@UseGuards(AuthGuard('guest'))
guestOverview(@Req() req: GuestAuthenticatedRequest) {
const guest = req.user!;
return this.wahl.guestOverview(guest.kcId, guest.guestId);
}
/// The guest's own assignment result per Wahl they took part in.
@Get('guest/results')
@UseGuards(AuthGuard('guest'))
guestResults(@Req() req: GuestAuthenticatedRequest) {
const guest = req.user!;
return this.wahl.guestResults(guest.kcId, guest.guestId);
}
/// Guests submit their own workshop preferences (guest JWT, not Authentik).
@Post(':wahlId/teilnehmer')
@UseGuards(AuthGuard('guest'))
submitTeilnehmer(
@Param('wahlId') wahlId: string,
@Body() dto: SubmitTeilnehmerDto,
@Req() req: GuestAuthenticatedRequest,
) {
const guest = req.user!;
return this.wahl.submitTeilnehmer(wahlId, guest.guestId, guest.kcId, dto.prioritaeten);
}
@Post(':wahlId/zuteilung/run')
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
runZuteilung(@Param('wahlId') wahlId: string) {
return this.zuteilung.run(wahlId);
}
@Get(':wahlId/zuteilung')
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
getZuteilung(@Param('wahlId') wahlId: string) {
return this.zuteilung.getResults(wahlId);
}
@Get(':wahlId/zuteilung/csv')
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
async exportCsv(@Param('wahlId') wahlId: string, @Res() res: Response) {
const csv = await this.zuteilung.exportCsv(wahlId);
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="zuteilung-${wahlId}.csv"`);
res.send(csv);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { WahlService } from './wahl.service';
import { ZuteilungService } from './zuteilung.service';
import { WahlController } from './wahl.controller';
@Module({
providers: [WahlService, ZuteilungService],
controllers: [WahlController],
})
export class WahlModule {}
+190
View File
@@ -0,0 +1,190 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { SyncOperation } from '@prisma/client';
import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service';
@Injectable()
export class WahlService {
constructor(
private readonly prisma: PrismaClient,
private readonly sync: SyncService,
) {}
async createWahl(kcId: string, name: string, datumsSchluessel: string, teil: string) {
const wahl = await this.prisma.wahl.create({
data: { kcId, name, datumsSchluessel, teil },
});
await this.sync.capture('Wahl', SyncOperation.CREATE, wahl.id, wahl);
return wahl;
}
listWahlen(kcId: string) {
return this.prisma.wahl.findMany({ where: { kcId } });
}
async updateWahl(wahlId: string, data: { isOpen?: boolean }) {
await this.getWahlOrThrow(wahlId);
const wahl = await this.prisma.wahl.update({ where: { id: wahlId }, data });
await this.sync.capture('Wahl', SyncOperation.UPDATE, wahl.id, wahl);
return wahl;
}
/// LT view of who took part in a Wahl, with their priorities and any
/// existing Force-Zuteilung.
async listTeilnehmer(wahlId: string) {
await this.getWahlOrThrow(wahlId);
const rows = await this.prisma.teilnehmer.findMany({
where: { wahlId },
orderBy: { guestAccount: { lastName: 'asc' } },
include: {
guestAccount: { select: { firstName: true, lastName: true } },
forceZuteilung: { select: { workshopId: true } },
},
});
return rows.map((t) => ({
id: t.id,
name: `${t.guestAccount.firstName} ${t.guestAccount.lastName}`.trim(),
prioritaeten: (t.prioritaeten as string[] | null) ?? [],
forcedWorkshopId: t.forceZuteilung?.workshopId ?? null,
}));
}
/// Guest-facing view: open Wahlen for the guest's KC, each with its
/// workshops and the guest's own current priorities (null if not submitted).
async guestOverview(kcId: string, guestAccountId: string) {
const [kc, wahlen] = await Promise.all([
this.prisma.kc.findUnique({ where: { id: kcId }, select: { id: true, name: true } }),
this.prisma.wahl.findMany({
where: { kcId, isOpen: true },
orderBy: { createdAt: 'asc' },
include: {
workshops: {
select: { id: true, name: true, kapazitaet: true },
orderBy: { name: 'asc' },
},
teilnehmer: {
where: { guestAccountId },
select: { prioritaeten: true },
},
},
}),
]);
return {
kc,
wahlen: wahlen.map((w) => ({
id: w.id,
name: w.name,
datumsSchluessel: w.datumsSchluessel,
teil: w.teil,
workshops: w.workshops,
meinePrioritaeten: (w.teilnehmer[0]?.prioritaeten as string[] | undefined) ?? null,
})),
};
}
/// Guest-facing result view: for every Wahl in the guest's KC where they
/// took part, their assignment (workshop name + wish rank), or a pending
/// marker if the algorithm has not run for them yet.
async guestResults(kcId: string, guestAccountId: string) {
const teilnahmen = await this.prisma.teilnehmer.findMany({
where: { guestAccountId, wahl: { kcId } },
orderBy: { wahl: { createdAt: 'asc' } },
select: {
wahl: { select: { id: true, name: true, datumsSchluessel: true, teil: true } },
zuteilung: { select: { workshopId: true, wunschRang: true, isForced: true } },
},
});
const workshopIds = teilnahmen
.map((t) => t.zuteilung?.workshopId)
.filter((id): id is string => !!id);
const workshops = workshopIds.length
? await this.prisma.workshop.findMany({
where: { id: { in: workshopIds } },
select: { id: true, name: true },
})
: [];
const nameById = new Map(workshops.map((w) => [w.id, w.name]));
return teilnahmen.map((t) => {
const z = t.zuteilung;
return {
wahl: t.wahl,
status: !z ? 'PENDING' : z.workshopId ? 'ASSIGNED' : 'UNASSIGNED',
workshopName: z?.workshopId ? (nameById.get(z.workshopId) ?? null) : null,
wunschRang: z?.wunschRang ?? null,
isForced: z?.isForced ?? false,
};
});
}
async createWorkshop(
wahlId: string,
name: string,
kapazitaet: number,
minTeilnehmer: number,
) {
await this.getWahlOrThrow(wahlId);
const workshop = await this.prisma.workshop.create({
data: { wahlId, name, kapazitaet, minTeilnehmer },
});
await this.sync.capture('Workshop', SyncOperation.CREATE, workshop.id, workshop);
return workshop;
}
listWorkshops(wahlId: string) {
return this.prisma.workshop.findMany({ where: { wahlId } });
}
async createForceZuteilung(wahlId: string, teilnehmerId: string, workshopId: string) {
const [teilnehmer, workshop] = await Promise.all([
this.prisma.teilnehmer.findUnique({ where: { id: teilnehmerId } }),
this.prisma.workshop.findUnique({ where: { id: workshopId } }),
]);
if (!teilnehmer || teilnehmer.wahlId !== wahlId) {
throw new NotFoundException('Teilnehmer not found in this Wahl');
}
if (!workshop || workshop.wahlId !== wahlId) {
throw new NotFoundException('Workshop not found in this Wahl');
}
const force = await this.prisma.forceZuteilung.upsert({
where: { teilnehmerId },
create: { wahlId, teilnehmerId, workshopId },
update: { workshopId },
});
await this.sync.capture('ForceZuteilung', SyncOperation.UPDATE, force.id, force);
return force;
}
/// Guests submit their own choices; only allowed for their own KC and while the Wahl is open.
async submitTeilnehmer(
wahlId: string,
guestAccountId: string,
guestKcId: string,
prioritaeten: string[],
) {
const wahl = await this.getWahlOrThrow(wahlId);
if (wahl.kcId !== guestKcId) {
throw new ForbiddenException('Guest does not belong to this KC');
}
if (!wahl.isOpen) {
throw new ForbiddenException('Wahl is closed');
}
const teilnehmer = await this.prisma.teilnehmer.upsert({
where: { wahlId_guestAccountId_phase: { wahlId, guestAccountId, phase: 1 } },
create: { wahlId, guestAccountId, prioritaeten },
update: { prioritaeten },
});
await this.sync.capture('Teilnehmer', SyncOperation.UPDATE, teilnehmer.id, teilnehmer);
return teilnehmer;
}
async getWahlOrThrow(wahlId: string) {
const wahl = await this.prisma.wahl.findUnique({ where: { id: wahlId } });
if (!wahl) {
throw new NotFoundException('Wahl not found');
}
return wahl;
}
}

Some files were not shown because too many files have changed in this diff Show More