Compare commits

...
Author SHA1 Message Date
linus 0e3b5003f6 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 9278c7fc34 style(web): redesign the zero-dependency web fallback client
- Dark theme with gradient accents, card-based layout replacing the
  bare form/section markup
- Header brand mark + live connection status badge
- Nicer inputs/buttons (focus rings, primary/ghost variants), empty
  states for the file and chat lists, auto-scroll on new chat messages
- Fix a broken template literal in app.js: the Authorization header
  was rendered as the literal string "*** ${state.token}" instead of
  "Bearer ${state.token}" -- guest Wahl submission and file loading
  were silently failing auth
- Add basic network-error handling around the three fetch() calls
2026-09-12 13:27:07 +02:00
linus 842b3c3ef4 docs: point README at split backend repo 2026-09-11 18:06:40 +02:00
linusandClaude Sonnet 5 35d8d9a623 chore: split backend out into KC-APP-Server repo
Backend moved (with full history via git filter-repo) to
https://git.konfi-castle.com/linus/KC-APP-Server. This repo is now
clients-only. README updated to point there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 18:06:23 +02:00
linusandClaude Sonnet 5 8af927c0f2 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 72367637aa 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 f42aead5ca 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 6662ca80d4 docs: FCM push verified end to end
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 12:02:39 +02:00
linusandClaude Sonnet 5 97c5807cb2 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 1c9887e0df chore(client): set FCM web-push VAPID key (public)
Client push-token acquisition is now fully configured; a real browser
session (logged-in user granting notification permission) is needed to
mint the first token. Backend delivery still needs the service-account JSON.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 11:57:07 +02:00
linusandClaude Sonnet 5 3f46088e9a chore(client): fill in Firebase web config (apiKey / appId)
Only the VAPID key (Web Push certificate) is still REPLACE_ME; the push
guard now keys off vapidKey so nothing prompts until it's set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 11:54:47 +02:00
linusandClaude Sonnet 5 d8aa30c606 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 530be36458 feat(client): FCM web push registration
web/index.html loads the Firebase compat SDK and defines
window.kcGetPushToken() — inits Firebase from window.KC_FIREBASE, asks for
notification permission, registers the service worker and returns an FCM
token (or null if not configured / denied). web/firebase-messaging-sw.js
shows background notifications.

browser_web.dart exposes getPushToken() over that JS function (stub returns
null off-web). After every successful login AppState fires
_registerForPush() -> POST /api/push/register, best-effort.

Config placeholders carry the known values (projectId konfi-castle-app,
messagingSenderId 307226979593); apiKey / appId / vapidKey still say
REPLACE_ME, so push stays inert until they're filled in — the app runs
either way. flutter analyze/test/build web green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 11:45:01 +02:00
linusandClaude Sonnet 5 cc663c7e17 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 55509eccb7 docs: LT Wahl controls, file upload, updated next steps
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 10:20:07 +02:00
linusandClaude Sonnet 5 a4ae7549ae 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
109 changed files with 3681 additions and 16835 deletions
+1
View File
@@ -0,0 +1 @@
.DS_Store
+31 -16
View File
@@ -5,13 +5,24 @@ events (KCs), replacing the WordPress plugin "Workshop-Wahlen". See
[plan-kcAppMultiTenantPlatform.prompt.md](plan-kcAppMultiTenantPlatform.prompt.md)
for the full architecture and phased roadmap.
This repo holds the **Flutter clients**. The backend (NestJS API) moved to
its own repo: <https://git.konfi-castle.com/linus/KC-APP-Server>.
## Run with Docker
See the [KC-APP-Server README](https://git.konfi-castle.com/linus/KC-APP-Server)
for the backend/Docker setup. It expects a pre-built web bundle:
```bash
(cd client/app && flutter build web --release)
```
By default the server's `docker-compose.yml` mounts `../KC-APP/client/app/build/web`
(sibling checkout); override with `WEB_CLIENT_BUILD_PATH` if your layout
differs.
## Structure
- `backend/` — NestJS API (Prisma/PostgreSQL, Authentik OIDC as resource
server, guest/Konfi local accounts, roles/permissions foundation, file
sharing, chat, local/cloud sync). See [backend/README.md](backend/README.md)
for setup. Also serves the web client (see below) directly, so it's the
single entry point for the web experience.
- `client/app/` — the Flutter client (single codebase; **web** target
enabled, mobile/desktop can be added later). Login (guest / local Teamer /
invite redemption), role-aware home, guest Workshop-Wahl, file list,
@@ -61,17 +72,21 @@ in (`backend/prisma/migrations/`); the backend has been run end to end
against a local PostgreSQL 16. `npm test` covers the assignment algorithm
and the new auth/onboarding services (56 tests).
Phase 7 (Flutter client) in progress: `client/app/` is a single Flutter
codebase with the **web** target enabled — guest / local-Teamer / invite
login, the Authentik Authorization-Code + PKCE flow (`lib/oidc.dart`) for
Phase 7 (Flutter client): `client/app/` is a single Flutter codebase with
the **web** target enabled — guest / local-Teamer / invite login, the
Authentik Authorization-Code + PKCE flow (`lib/oidc.dart`) for
Leitungsteam/Verantwortliche, role-aware home, guest Workshop-Wahl (wishes +
result), file list, live WebSocket chat, and a Leitungsteam admin screen
(KCs, Gemeinden, onboarding approvals). `flutter build web` / `flutter test`
pass; the backend serves the build at `/` (SPA fallback covers the OIDC
redirect `/v1/auth/callback`). Still to do: a live browser test of the OIDC
round-trip, the Teamer-management and Verantwortlichen-self-registration
screens, LT Wahl administration, mobile/desktop targets, and push.
result), file list, live WebSocket chat, FCM web-push registration, and the
Leitungsteam admin screens: KCs, Gemeinden, onboarding approvals, full
Workshop-Wahl administration (create/open/close, workshops, Force-Zuteilung,
run assignment, CSV export), Teamer accounts + invites, LT file upload, plus
the Verantwortlichen self-registration flow. `flutter build web` /
`flutter test` pass; the backend serves the build at `/` (SPA fallback
covers the OIDC redirect `/v1/auth/callback`).
Running end to end needs the Authentik redirect registered + a test account,
plus Nextcloud/S3 credentials (see `backend/.env.example`).
Still to do: a live browser test of the OIDC round-trip; mobile/desktop
targets. Going live needs external config — the Authentik redirect + a test
account, Nextcloud/S3 credentials, SMTP, and the Firebase push secrets
(`apiKey`/`appId`/VAPID key + a service-account JSON). See
`backend/.env.example` and `client/app/web/index.html`.
-58
View File
@@ -1,58 +0,0 @@
# Postgres connection used by Prisma
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/kcapp?schema=public"
# Authentik OIDC issuer (trailing slash optional — both forms are accepted).
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)
GUEST_JWT_SECRET="change-me"
# Secret used to sign local Gemeinde Teamer session tokens (password login)
TEAM_JWT_SECRET="change-me-too"
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=""
# 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"
-5
View File
@@ -1,5 +0,0 @@
node_modules
dist
coverage
.env
*.log
-129
View File
@@ -1,129 +0,0 @@
# KC-App Backend
NestJS API for the KC-App platform (see repo root README + plan for
architecture context).
## Setup
```bash
npm install
cp .env.example .env # DATABASE_URL / AUTHENTIK_ISSUER_URL / AUTHENTIK_LEITUNGSTEAM_GROUP /
# GUEST_JWT_SECRET / TEAM_JWT_SECRET / APP_BASE_URL (+ MAIL_* for real email)
npx prisma generate
npx prisma migrate dev --name init # requires a running PostgreSQL instance
npm run start:dev
```
The API is served under `/api` (see `app.setGlobalPrefix('api')` in
`main.ts`); everything else (`/`, `/app.js`, ...) is served statically from
`../client/web` via `ServeStaticModule`, so the backend doubles as the web
client's host - no separate web server is needed.
## Auth model
- Leitungsteam and Gemeinde Verantwortliche sign in with Authentik (the
"Konfi-Castle-ID"); this API acts as an OIDC **resource server**, verifying
access tokens against Authentik's JWKS (`AuthentikStrategy`). The local
`User` is provisioned just-in-time on first login from the token claims
(`resolveOrProvisionAuthentikUser`), and `User.isLeitungsteam` is
reconciled on every login from the token's `groups` claim vs.
`AUTHENTIK_LEITUNGSTEAM_GROUP``toAuthenticatedUser` then synthesises a
virtual global `LEITUNGSTEAM` membership from that flag. Other roles come
from local `Membership` rows (only `status = ACTIVE` ones count).
Verantwortliche self-provision through the `onboarding/` approval flow;
a user with neither the LT flag nor a membership has no rights. Clients
perform the Authorization Code + PKCE flow against Authentik directly.
- Gemeinde Teamer are **local accounts** (no Authentik): a `User` row with a
`passwordHash` and `kcId` set, `authentikSub` left null. A Gemeinde
Verantwortliche/r creates them directly or via a `TeamerInvite`
(shareable group link or per-email invite). Login is `POST /auth/team-login`
(email + password) or `POST /auth/teamer/register` (redeem an invite
token); both return a JWT signed with `TEAM_JWT_SECRET` and carrying
`typ: "team"`. `TeamJwtStrategy` (`AuthGuard('team')`) resolves it to the
same shape as `AuthentikStrategy`, so guards/controllers treat both alike.
- Guests/Konfis get a temporary local account (first/last name required, no
Authentik) created via `POST /auth/guest` with a KC invite code, returning
a JWT signed with `GUEST_JWT_SECRET`.
## Modules implemented so far
- `prisma/` — shared `PrismaClient` provider.
- `auth/` — Authentik resource-server strategy (`AuthGuard('authentik')`),
guest invite-code login (`AuthGuard('guest')`), and local Gemeinde Teamer
auth (`AuthGuard('team')`): `POST /auth/team-login` and
`POST /auth/teamer/register` (invite redemption), bcrypt hashes, tokens
signed with `TEAM_JWT_SECRET`. `TokenVerificationService` (WS handshake)
now accepts Authentik, team, or guest tokens.
- `kc/` — KC (event) creation/listing, Leitungsteam-only.
- `gemeinde/` — Gemeinde (congregation) CRUD per KC (`POST /gemeinde`,
`GET /gemeinde?kcId=`, `GET/PATCH/DELETE /gemeinde/:id`), Leitungsteam-only.
Gemeinde Verantwortliche/Teamer get their own Gemeinde from their
`Membership`, not from this endpoint.
- `teamer/` — local Gemeinde Teamer accounts + invites, under
`/gemeinde/:gemeindeId/...`: `POST/GET teamer`,
`DELETE teamer/:userId`, `POST/GET teamer-invites`,
`DELETE teamer-invites/:inviteId`. Callable by Leitungsteam (any Gemeinde)
or a Verantwortliche/r for their own Gemeinde (enforced in `TeamerService`,
since `RolesGuard` only scopes by `kcId`). Files/chat read endpoints accept
`'team'` tokens too, so Teamer see non-Konfi files and chat. A personal
invite (with `email`) is mailed via `MailService`; the response carries
`emailSent`. Group-link invites (no `email`) are shared by hand.
- `onboarding/` — self-registration for Gemeinde Verantwortliche.
`GET /onboarding/kc/:inviteCode` (public) returns the KC name + its
Gemeinden to pick from. `POST /onboarding/verantwortliche` takes the
caller's raw Authentik bearer token (no local `Membership` needed yet),
JIT-provisions the local `User` from the token claims, and creates a
`Membership` with `status = PENDING`. Leitungsteam reviews via
`GET /onboarding/requests?kcId=` and `POST /onboarding/requests/:id/approve`
or `.../reject`. Auth strategies only load `ACTIVE` memberships, so a
pending request grants nothing until approved.
- `mail/` — global `MailProvider` abstraction (mirrors `files/storage/`):
default `log` provider only logs what it would send; `MAIL_PROVIDER=smtp`
uses a real `nodemailer` SMTP transport (`SMTP_*`, `MAIL_FROM`).
`MailService.sendTeamerInvite()` composes the personal-invite email with a
link built from `APP_BASE_URL`. Delivery is best-effort — failures are
logged and swallowed, never blocking the invite.
- `wahl/` — Wahl/Workshop administration (Leitungsteam-only), guest
Teilnehmer submission, Force-Zuteilung overrides, and `ZuteilungService`:
a faithful port of the WP plugin's `kc_run_zuteilung` (force-assignments →
up to 3 wish rounds → random fill → consolidation of workshops that stay
below `minTeilnehmer`), plus CSV export (`GET /wahl/:id/zuteilung/csv`).
- `files/` — Leitungsteam-only upload (`POST /files/:kcId`, multipart) tagged
with a `FileVisibility` tier; list/download (`GET /files/:kcId`,
`GET /files/download/:fileId`) accept either an Authentik or a guest token
and filter by the caller's allowed visibility tiers. Storage is behind a
`StorageProvider` abstraction: defaults to Nextcloud via WebDAV
(`WEBDAV_*` env vars), switchable to S3-compatible storage with
`STORAGE_PROVIDER=s3` (`S3_*` env vars).
- `chat/` — Gemeinde-Gruppenchat, 1:1 Direktnachrichten, Leitungsteam-über-
greifende Kanäle und Broadcast (Konfis lesen nur). Channel administration
and message history are plain REST (`ChatController`); real-time send/
receive is a raw `ws` gateway (`ChatGateway`, path `/chat`) since passport
guards don't apply to WS upgrades — auth happens once via `?token=` at
connect time (`TokenVerificationService` tries Authentik JWKS, then falls
back to a guest token). Access rules live in `ChatService` and are shared
between the REST and WS entry points.
- `sync/` — replicates mutations between the local (on-site) and cloud
server. `SyncService.capture()` is called by feature services right after
a write, appending an entry to the append-only `SyncLogEntry` log tagged
with this server's `SERVER_ID`. The local server (set `SYNC_ENABLED=true`,
`SYNC_PEER_URL`) periodically pushes its new entries to the cloud's
`POST /sync/ingest` and pulls the cloud's via `GET /sync/export`
(`SyncSchedulerService`, every 30s), both guarded by `SYNC_SHARED_SECRET`
(`SyncSecretGuard`) rather than user auth. No conflict resolution is
implemented by design — the local server is the sole source of truth
while an event is live. `POST /sync/trigger` lets a Leitungsteam member
force an immediate push+pull. Known gap: only entity metadata is
replicated; uploaded file bytes only resolve on both sides if local and
cloud share the same Nextcloud/S3 backend.
- `common/``Role` enum, `@Roles()` decorator, `RolesGuard` (KC-scoped,
Leitungsteam roles are global across all KCs).
All planned backend phases are implemented. `npm test` runs Jest unit tests
(`ZuteilungService`, `TeamAuthService`, `TeamerService`, `OnboardingService`,
`resolveOrProvisionAuthentikUser` / `toAuthenticatedUser`; Prisma mocked).
Remaining work: the Flutter clients (see repo root README), push
notifications, and the first real Prisma migration (only `schema.prisma`
exists so far). Ops notes: the Authentik provider must emit a `groups` claim
for the LT check, and `MAIL_PROVIDER=smtp` + `SMTP_*` must be set for invite
emails to actually leave the box.
-8
View File
@@ -1,8 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
-11163
View File
File diff suppressed because it is too large Load Diff
-98
View File
@@ -1,98 +0,0 @@
{
"name": "backend",
"version": "0.0.1",
"description": "KC-App backend (NestJS)",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:e2e": "jest --config ./test/jest-e2e.json",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.679.0",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^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",
"@prisma/client": "^5.22.0",
"bcryptjs": "^3.0.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.1.0",
"multer": "^2.0.1",
"nodemailer": "^7.0.13",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"webdav": "^5.7.1",
"ws": "^8.18.0"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@nestjs/testing": "^10.4.15",
"@types/bcryptjs": "^2.4.6",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.7",
"@types/multer": "^1.4.12",
"@types/node": "^20.17.9",
"@types/nodemailer": "^6.4.24",
"@types/passport": "^1.0.17",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2",
"@types/ws": "^8.5.13",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"jest": "^29.7.0",
"prettier": "^3.4.2",
"prisma": "^5.22.0",
"source-map-support": "^0.5.21",
"supertest": "^6.3.4",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.6.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
@@ -1,328 +0,0 @@
-- 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;
@@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
-298
View File
@@ -1,298 +0,0 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
/// A Konfi-Castle event; the top-level tenant. One instance manages many KCs.
model Kc {
id String @id @default(cuid())
name String
inviteCode String @unique
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
gemeinden Gemeinde[]
memberships Membership[]
wahlen Wahl[]
files File[]
channels ChatChannel[]
guests GuestAccount[]
localUsers User[]
teamerInvites TeamerInvite[]
}
/// A local congregation/community participating in one Kc.
model Gemeinde {
id String @id @default(cuid())
name String
kcId String
createdAt DateTime @default(now())
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
memberships Membership[]
guests GuestAccount[]
teamerInvites TeamerInvite[]
@@unique([kcId, name])
}
enum Role {
LEITUNGSTEAM
GEMEINDE_VERANTWORTLICHER
GEMEINDE_TEAMER
}
/// 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 {
id String @id @default(cuid())
authentikSub String? @unique
email String @unique
firstName 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())
kc Kc? @relation(fields: [kcId], references: [id], onDelete: Cascade)
memberships Membership[]
messages ChatMessage[]
chatParticipations ChatParticipant[]
}
/// Scopes a User's role to a specific Kc (and Gemeinde, if applicable).
/// LEITUNGSTEAM memberships apply to all Kcs implicitly and omit gemeindeId.
model Membership {
id String @id @default(cuid())
userId String
kcId String
gemeindeId String?
role Role
status MembershipStatus @default(ACTIVE)
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade)
@@unique([userId, kcId, gemeindeId])
}
/// Local, non-Authentik account for Konfis/guests, scoped to one Kc/event.
model GuestAccount {
id String @id @default(cuid())
kcId String
gemeindeId String?
firstName String
lastName String
createdAt DateTime @default(now())
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade)
messages ChatMessage[]
teilnehmer Teilnehmer[]
}
/// 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)
}
/// A workshop election, scoped to a Kc; name carries a date key + "Teil".
model Wahl {
id String @id @default(cuid())
kcId String
name String
datumsSchluessel String
teil String
isOpen Boolean @default(true)
createdAt DateTime @default(now())
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
workshops Workshop[]
teilnehmer Teilnehmer[]
forceZuteilungen ForceZuteilung[]
}
model Workshop {
id String @id @default(cuid())
wahlId String
name String
kapazitaet Int
minTeilnehmer Int @default(0)
wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade)
zuteilungen Zuteilung[]
forceZuteilungen ForceZuteilung[]
}
/// A participant's submitted choices for a Wahl.
model Teilnehmer {
id String @id @default(cuid())
wahlId String
guestAccountId String
prioritaeten Json
createdAt DateTime @default(now())
wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade)
guestAccount GuestAccount @relation(fields: [guestAccountId], references: [id], onDelete: Cascade)
zuteilung Zuteilung?
forceZuteilung ForceZuteilung?
@@unique([wahlId, guestAccountId])
}
/// 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 {
id String @id @default(cuid())
teilnehmerId String @unique
workshopId String?
wunschRang Int @default(-1)
isForced Boolean @default(false)
createdAt DateTime @default(now())
teilnehmer Teilnehmer @relation(fields: [teilnehmerId], references: [id], onDelete: Cascade)
workshop Workshop? @relation(fields: [workshopId], references: [id], onDelete: SetNull)
}
enum FileVisibility {
ALLE
ALLE_AUSSER_KONFIS
NUR_LT
}
model File {
id String @id @default(cuid())
kcId String
storageKey String
filename String
visibility FileVisibility
uploadedById String
createdAt DateTime @default(now())
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
}
enum ChatChannelType {
GEMEINDE_GRUPPE
DIREKT
LT_UEBERGREIFEND
BROADCAST
}
model ChatChannel {
id String @id @default(cuid())
kcId String
type ChatChannelType
gemeindeId String?
createdAt DateTime @default(now())
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
messages ChatMessage[]
participants ChatParticipant[]
}
/// Explicit membership for DIREKT (1:1) channels; other channel types derive
/// access from Membership/Gemeinde instead of this table.
model ChatParticipant {
id String @id @default(cuid())
channelId String
userId String
createdAt DateTime @default(now())
channel ChatChannel @relation(fields: [channelId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([channelId, userId])
}
model ChatMessage {
id String @id @default(cuid())
channelId String
senderUserId String?
senderGuestId String?
body String
createdAt DateTime @default(now())
channel ChatChannel @relation(fields: [channelId], references: [id], onDelete: Cascade)
senderUser User? @relation(fields: [senderUserId], 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).
model SyncLogEntry {
id String @id @default(cuid())
sequence Int @default(autoincrement())
model String
recordId String
operation SyncOperation
payload Json
originId String
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)
}
-35
View File
@@ -1,35 +0,0 @@
/* 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());
-49
View File
@@ -1,49 +0,0 @@
import { Module } from '@nestjs/common';
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 { MailModule } from './mail/mail.module';
import { AuthModule } from './auth/auth.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({
imports: [
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,
MailModule,
SyncModule,
AuthModule,
KcModule,
GemeindeModule,
TeamerModule,
OnboardingModule,
WahlModule,
FilesModule,
ChatModule,
],
})
export class AppModule {}
-63
View File
@@ -1,63 +0,0 @@
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { GuestAuthService } from './guest-auth.service';
import { TeamAuthService } from './team-auth.service';
import { CreateGuestDto } from './dto/create-guest.dto';
import { TeamLoginDto } from './dto/team-login.dto';
import { RegisterTeamerDto } from './dto/register-teamer.dto';
import { AuthenticatedRequest } from './authenticated-request';
import { GuestJwtPayload } from './guest-auth.service';
@Controller('auth')
export class AuthController {
constructor(
private readonly guestAuth: GuestAuthService,
private readonly teamAuth: TeamAuthService,
) {}
/// 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.
@Post('guest')
createGuest(@Body() dto: CreateGuestDto) {
return this.guestAuth.createGuest(dto.inviteCode, dto.firstName, dto.lastName);
}
/// Password login for local Gemeinde Teamer accounts.
@Post('team-login')
teamLogin(@Body() dto: TeamLoginDto) {
return this.teamAuth.login(dto.email, 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);
}
}
-35
View File
@@ -1,35 +0,0 @@
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller';
import { GuestAuthService } from './guest-auth.service';
import { TeamAuthService } from './team-auth.service';
import { AuthentikStrategy } from './authentik.strategy';
import { GuestJwtStrategy } from './guest-jwt.strategy';
import { TeamJwtStrategy } from './team-jwt.strategy';
import { TokenVerificationService } from './token-verification.service';
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.getOrThrow<string>('GUEST_JWT_SECRET'),
signOptions: { expiresIn: '12h' },
}),
}),
],
controllers: [AuthController],
providers: [
GuestAuthService,
TeamAuthService,
AuthentikStrategy,
GuestJwtStrategy,
TeamJwtStrategy,
TokenVerificationService,
],
exports: [TokenVerificationService, TeamAuthService],
})
export class AuthModule {}
-28
View File
@@ -1,28 +0,0 @@
import { Request } from 'express';
import { Role } from '../common/role.enum';
import { GuestJwtPayload } from './guest-auth.service';
export interface AuthenticatedMembership {
kcId: string;
gemeindeId: string | null;
role: Role;
}
/// 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 {
userId: string;
authentikSub: string | null;
email: string;
memberships: AuthenticatedMembership[];
}
export interface AuthenticatedRequest extends Request {
user?: AuthenticatedUser;
}
/// Shape attached to req.user by GuestJwtStrategy for guest/Konfi-authenticated routes.
export interface GuestAuthenticatedRequest extends Request {
user?: GuestJwtPayload;
}
-78
View File
@@ -1,78 +0,0 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { Strategy } from 'passport-jwt';
import * as jwksRsa from 'jwks-rsa';
import { Request } from 'express';
import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service';
import { AuthenticatedUser } from './authenticated-request';
import {
authentikEmail,
resolveOrProvisionAuthentikUser,
toAuthenticatedUser,
} from './provision-user';
interface AuthentikJwtPayload {
sub: string;
email?: string;
given_name?: string;
family_name?: string;
preferred_username?: string;
name?: string;
groups?: string[];
}
/// Validates access tokens issued by Authentik (resource-server pattern):
/// signature is checked against Authentik's JWKS, the local `User` is
/// provisioned on first login (JIT) and its LEITUNGSTEAM flag reconciled with
/// 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()
export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') {
private readonly leitungsteamGroup: string;
constructor(
config: ConfigService,
private readonly prisma: PrismaClient,
private readonly sync: SyncService,
) {
// 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({
jwtFromRequest: (req: Request) =>
req.headers.authorization?.startsWith('Bearer ')
? req.headers.authorization.slice('Bearer '.length)
: null,
secretOrKeyProvider: jwksRsa.passportJwtSecret({
jwksUri: `${base}/jwks/`,
cache: true,
rateLimit: true,
}),
issuer: [base, `${base}/`],
algorithms: ['RS256'],
});
this.leitungsteamGroup = config.get<string>('AUTHENTIK_LEITUNGSTEAM_GROUP', 'Leitungsteam');
}
async validate(payload: AuthentikJwtPayload): Promise<AuthenticatedUser> {
if (!payload.sub) {
throw new UnauthorizedException('Authentik token missing subject');
}
const isLeitungsteam = (payload.groups ?? []).includes(this.leitungsteamGroup);
const user = await resolveOrProvisionAuthentikUser(
this.prisma,
this.sync,
{
sub: payload.sub,
email: authentikEmail(payload),
firstName: payload.given_name ?? payload.preferred_username ?? payload.name ?? '',
lastName: payload.family_name ?? '',
},
isLeitungsteam,
);
return toAuthenticatedUser(user);
}
}
-15
View File
@@ -1,15 +0,0 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class CreateGuestDto {
@IsString()
@IsNotEmpty()
inviteCode!: string;
@IsString()
@IsNotEmpty()
firstName!: string;
@IsString()
@IsNotEmpty()
lastName!: string;
}
@@ -1,30 +0,0 @@
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;
}
-10
View File
@@ -1,10 +0,0 @@
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
export class TeamLoginDto {
@IsEmail()
email!: string;
@IsString()
@IsNotEmpty()
password!: string;
}
-45
View File
@@ -1,45 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { SyncOperation } from '@prisma/client';
import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service';
export interface GuestJwtPayload {
guestId: string;
kcId: string;
gemeindeId: string | null;
}
/// Guest/Konfi accounts are local to this server (never Authentik-backed),
/// created via a KC invite code, and scoped to that single KC.
@Injectable()
export class GuestAuthService {
constructor(
private readonly prisma: PrismaClient,
private readonly jwt: JwtService,
private readonly sync: SyncService,
) {}
async createGuest(
inviteCode: string,
firstName: string,
lastName: string,
): Promise<{ accessToken: string }> {
const kc = await this.prisma.kc.findUnique({ where: { inviteCode } });
if (!kc || !kc.isActive) {
throw new NotFoundException('Unknown or inactive KC invite code');
}
const guest = await this.prisma.guestAccount.create({
data: { kcId: kc.id, firstName, lastName },
});
await this.sync.capture('GuestAccount', SyncOperation.CREATE, guest.id, guest);
const payload: GuestJwtPayload = {
guestId: guest.id,
kcId: kc.id,
gemeindeId: guest.gemeindeId,
};
return { accessToken: await this.jwt.signAsync(payload) };
}
}
-21
View File
@@ -1,21 +0,0 @@
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
@@ -1,175 +0,0 @@
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
@@ -1,123 +0,0 @@
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,
};
}
-232
View File
@@ -1,232 +0,0 @@
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 }[];
}) {
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 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 }),
),
},
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('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('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('t@example.org', 'wrong')).rejects.toBeInstanceOf(
UnauthorizedException,
);
});
it('issues a token for correct credentials', async () => {
const { service } = makeService({
users: [
{ id: 'u-1', email: 't@example.org', passwordHash: bcrypt.hashSync('right', 10) },
],
});
const res = await service.login('T@example.org', 'right');
expect(res.accessToken).toEqual(expect.any(String));
});
});
-154
View File
@@ -1,154 +0,0 @@
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');
}
async login(email: string, password: string): Promise<{ accessToken: string }> {
const user = await this.prisma.user.findUnique({
where: { email: email.toLowerCase() },
include: { memberships: true },
});
if (!user || !user.passwordHash) {
throw new UnauthorizedException('Invalid credentials');
}
const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok) {
throw new UnauthorizedException('Invalid credentials');
}
return { accessToken: this.sign(user.id) };
}
/// 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
@@ -1,26 +0,0 @@
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);
}
}
@@ -1,110 +0,0 @@
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
@@ -1,13 +0,0 @@
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 };
}
-46
View File
@@ -1,46 +0,0 @@
import { Body, Controller, Get, Param, Post, Req, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ChatService } from './chat.service';
import { CreateChannelDto } from './dto/create-channel.dto';
import { CreateDirectChannelDto } from './dto/create-direct-channel.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) {}
/// 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);
}
/// 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!));
}
}
-109
View File
@@ -1,109 +0,0 @@
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);
}
}
}
}
-12
View File
@@ -1,12 +0,0 @@
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 {}
-165
View File
@@ -1,165 +0,0 @@
import { 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';
export type ChatCaller =
| { kind: 'user'; user: AuthenticatedUser }
| { kind: 'guest'; guest: GuestJwtPayload };
@Injectable()
export class ChatService {
constructor(
private readonly prisma: PrismaClient,
private readonly sync: SyncService,
) {}
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;
}
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, type: ChatChannelType.BROADCAST },
});
}
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 } } },
],
},
});
}
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') {
const allowed = channel.type === ChatChannelType.BROADCAST && mode === 'read';
if (!allowed) {
throw new ForbiddenException('Guests may only read broadcast channels');
}
if (caller.guest.kcId !== channel.kcId) {
throw new ForbiddenException('Guest does not belong to this KC');
}
return channel;
}
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;
}
default:
throw new ForbiddenException('Unknown channel type');
}
}
async sendMessage(channelId: string, caller: ChatCaller, body: string) {
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);
return message;
}
async listMessages(channelId: string, caller: ChatCaller) {
await this.assertCanRead(channelId, caller);
return this.prisma.chatMessage.findMany({
where: { channelId },
orderBy: { createdAt: 'asc' },
});
}
}
@@ -1,11 +0,0 @@
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { ChatChannelType } from '@prisma/client';
export class CreateChannelDto {
@IsEnum(ChatChannelType)
type!: ChatChannelType;
@IsOptional()
@IsString()
gemeindeId?: string;
}
@@ -1,11 +0,0 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class CreateDirectChannelDto {
@IsString()
@IsNotEmpty()
kcId!: string;
@IsString()
@IsNotEmpty()
otherUserId!: string;
}
-4
View File
@@ -1,4 +0,0 @@
/// Re-exported from the Prisma client so guards and strategies share one
/// enum type with the database schema. Guests are not part of this enum
/// since they authenticate separately and never hold elevated rights.
export { Role } from '@prisma/client';
-7
View File
@@ -1,7 +0,0 @@
import { SetMetadata } from '@nestjs/common';
import { Role } from './role.enum';
export const ROLES_KEY = 'roles';
/// Marks a route as requiring at least one of the given roles (scope-checked by RolesGuard).
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
-50
View File
@@ -1,50 +0,0 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role } from './role.enum';
import { ROLES_KEY } from './roles.decorator';
import { AuthenticatedRequest } from '../auth/authenticated-request';
/// Checks the caller holds one of the required roles, scoped to the KC in the
/// request (route param `kcId`, falling back to body.kcId). LEITUNGSTEAM
/// memberships are global and satisfy any KC scope.
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const user = request.user;
if (!user) {
throw new ForbiddenException('Not authenticated');
}
const kcId = request.params?.kcId ?? request.body?.kcId;
const hasRole = user.memberships.some((membership) => {
if (!requiredRoles.includes(membership.role)) {
return false;
}
if (membership.role === Role.LEITUNGSTEAM) {
return true;
}
return kcId ? membership.kcId === kcId : true;
});
if (!hasRole) {
throw new ForbiddenException('Insufficient role for this KC');
}
return true;
}
}
-7
View File
@@ -1,7 +0,0 @@
import { IsEnum } from 'class-validator';
import { FileVisibility } from '@prisma/client';
export class UploadFileDto {
@IsEnum(FileVisibility)
visibility!: FileVisibility;
}
-73
View File
@@ -1,73 +0,0 @@
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
@@ -1,24 +0,0 @@
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
@@ -1,53 +0,0 @@
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');
});
}
}
@@ -1,53 +0,0 @@
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 }));
}
}
@@ -1,10 +0,0 @@
/// 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');
@@ -1,37 +0,0 @@
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
@@ -1,21 +0,0 @@
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];
@@ -1,11 +0,0 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class CreateGemeindeDto {
@IsString()
@IsNotEmpty()
kcId!: string;
@IsString()
@IsNotEmpty()
name!: string;
}
@@ -1,7 +0,0 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class UpdateGemeindeDto {
@IsString()
@IsNotEmpty()
name!: string;
}
@@ -1,53 +0,0 @@
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
@@ -1,9 +0,0 @@
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
@@ -1,81 +0,0 @@
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;
}
}
-7
View File
@@ -1,7 +0,0 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class CreateKcDto {
@IsString()
@IsNotEmpty()
name!: string;
}
-26
View File
@@ -1,26 +0,0 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { KcService } from './kc.service';
import { CreateKcDto } from './dto/create-kc.dto';
import { Roles } from '../common/roles.decorator';
import { RolesGuard } from '../common/roles.guard';
import { Role } from '../common/role.enum';
@Controller('kc')
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
export class KcController {
constructor(private readonly kc: KcService) {}
/// Only the Leitungsteam may create new KC events.
@Post()
@Roles(Role.LEITUNGSTEAM)
create(@Body() dto: CreateKcDto) {
return this.kc.createKc(dto.name);
}
@Get()
@Roles(Role.LEITUNGSTEAM)
list() {
return this.kc.listKcs();
}
}
-9
View File
@@ -1,9 +0,0 @@
import { Module } from '@nestjs/common';
import { KcService } from './kc.service';
import { KcController } from './kc.controller';
@Module({
providers: [KcService],
controllers: [KcController],
})
export class KcModule {}
-25
View File
@@ -1,25 +0,0 @@
import { Injectable } from '@nestjs/common';
import { randomBytes } from 'crypto';
import { SyncOperation } from '@prisma/client';
import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service';
@Injectable()
export class KcService {
constructor(
private readonly prisma: PrismaClient,
private readonly sync: SyncService,
) {}
async createKc(name: string) {
const kc = await this.prisma.kc.create({
data: { name, inviteCode: randomBytes(6).toString('hex') },
});
await this.sync.capture('Kc', SyncOperation.CREATE, kc.id, kc);
return kc;
}
listKcs() {
return this.prisma.kc.findMany();
}
}
-15
View File
@@ -1,15 +0,0 @@
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
@@ -1,18 +0,0 @@
/// 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
@@ -1,25 +0,0 @@
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
@@ -1,43 +0,0 @@
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
@@ -1,45 +0,0 @@
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;
}
}
}
-14
View File
@@ -1,14 +0,0 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { WsAdapter } from '@nestjs/platform-ws';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api');
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
app.enableCors();
app.useWebSocketAdapter(new WsAdapter(app));
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
@@ -1,11 +0,0 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class RegisterVerantwortlicheDto {
@IsString()
@IsNotEmpty()
inviteCode!: string;
@IsString()
@IsNotEmpty()
gemeindeId!: string;
}
@@ -1,67 +0,0 @@
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,
);
}
/// 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);
}
}
@@ -1,11 +0,0 @@
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 {}
@@ -1,224 +0,0 @@
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,
),
},
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, prisma } = makeService({});
prisma.kc.findUnique = jest.fn().mockResolvedValue({
id: 'kc-1',
name: 'KC 2026',
isActive: true,
gemeinden: [{ id: 'gem-1', name: 'Nord' }],
});
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' });
});
});
@@ -1,136 +0,0 @@
import {
BadRequestException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { MembershipStatus, Role, SyncOperation } from '@prisma/client';
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';
/// Self-service onboarding for Gemeinde Verantwortliche. The person signs in
/// with their Konfi-Castle-ID (Authentik) and submits a KC invite code plus
/// the Gemeinde they belong to; this provisions their local User (JIT) and a
/// PENDING membership that a Leitungsteam member must approve before it grants
/// any rights.
@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 kc = await this.prisma.kc.findUnique({
where: { inviteCode },
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 kc = await this.prisma.kc.findUnique({ where: { inviteCode } });
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 };
}
}
-17
View File
@@ -1,17 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
/// Shared Prisma connection; injected wherever DB access is needed.
@Global()
@Module({
providers: [
{
provide: PrismaClient,
useFactory: () => new PrismaClient(),
},
],
exports: [PrismaClient],
})
export class PrismaModule {}
export { PrismaClient };
@@ -1,7 +0,0 @@
import { IsArray, IsNotEmpty } from 'class-validator';
export class IngestEntriesDto {
@IsArray()
@IsNotEmpty()
entries!: unknown[];
}
@@ -1,32 +0,0 @@
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);
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;
try {
await this.sync.pushToPeer(peerUrl, peerSecret);
await this.sync.pullFromPeer(peerUrl, peerSecret);
} catch (err) {
this.logger.debug(`Sync with peer skipped: ${(err as Error).message}`);
}
}
}
-18
View File
@@ -1,18 +0,0 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
/// 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');
if (request.headers['x-sync-secret'] !== expected) {
throw new ForbiddenException('Invalid sync secret');
}
return true;
}
}
-45
View File
@@ -1,45 +0,0 @@
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';
@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) {
await this.sync.applyIncoming(dto.entries as never);
return { applied: dto.entries.length };
}
/// Peer pulls our new entries since their last known sequence.
@Get('export')
@UseGuards(SyncSecretGuard)
async export(@Query('since') since: string) {
const entries = await this.sync.getEntriesSince(Number(since) || 0);
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 };
}
}
-16
View File
@@ -1,16 +0,0 @@
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 {}
-157
View File
@@ -1,157 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SyncOperation } from '@prisma/client';
import { PrismaClient } from '../prisma/prisma.module';
const SYNCED_MODELS = [
'Kc',
'Gemeinde',
'User',
'Membership',
'TeamerInvite',
'GuestAccount',
'Wahl',
'Workshop',
'Teilnehmer',
'ForceZuteilung',
'Zuteilung',
'File',
'ChatChannel',
'ChatMessage',
] as const;
export type SyncedModel = (typeof SYNCED_MODELS)[number];
interface IncomingEntry {
sequence: number;
model: string;
recordId: string;
operation: SyncOperation;
payload: Record<string, unknown>;
originId: string;
}
/// Replicates mutations between the local (on-site) and cloud server. The
/// local server is the sole source of truth while an event is live, so
/// incoming entries are applied with simple upserts - no conflict resolution
/// is needed by design (see plan doc).
@Injectable()
export class SyncService {
private readonly logger = new Logger(SyncService.name);
readonly serverId: 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.
async capture(model: SyncedModel, operation: SyncOperation, recordId: string, payload: object) {
await this.prisma.syncLogEntry.create({
data: {
model,
recordId,
operation,
payload: payload as never,
originId: this.serverId,
},
});
}
async getEntriesSince(sequence: number, limit = 500) {
return this.prisma.syncLogEntry.findMany({
where: { sequence: { gt: sequence } },
orderBy: { sequence: 'asc' },
take: limit,
});
}
/// Applies entries received from a peer; never re-captures them, which is
/// what prevents echo loops between the two servers.
async applyIncoming(entries: IncomingEntry[]) {
for (const entry of entries) {
if (entry.originId === this.serverId) continue;
const delegate = this.delegateFor(entry.model);
if (!delegate) {
this.logger.warn(`Skipping sync entry for unknown model "${entry.model}"`);
continue;
}
try {
if (entry.operation === SyncOperation.DELETE) {
await delegate.delete({ where: { id: entry.recordId } });
} else {
await delegate.upsert({
where: { id: entry.recordId },
create: entry.payload,
update: entry.payload,
});
}
} catch (err) {
this.logger.warn(
`Failed to apply sync entry ${entry.model}/${entry.recordId}: ${(err as Error).message}`,
);
}
}
}
async pushToPeer(peerUrl: string, peerSecret: string) {
const peerId = new URL(peerUrl).host;
const cursor = await this.getOrCreateCursor(peerId);
const entries = await this.getEntriesSince(cursor.lastPushedSequence);
if (entries.length === 0) return { pushed: 0 };
const res = await fetch(`${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}`);
}
await this.prisma.syncCursor.update({
where: { peerId },
data: { lastPushedSequence: entries[entries.length - 1].sequence },
});
return { pushed: entries.length };
}
async pullFromPeer(peerUrl: string, peerSecret: string) {
const peerId = new URL(peerUrl).host;
const cursor = await this.getOrCreateCursor(peerId);
const res = await fetch(`${peerUrl}/sync/export?since=${cursor.lastPulledSequence}`, {
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) return { pulled: 0 };
await this.applyIncoming(entries);
await this.prisma.syncCursor.update({
where: { peerId },
data: { lastPulledSequence: entries[entries.length - 1].sequence },
});
return { pulled: entries.length };
}
private async getOrCreateCursor(peerId: string) {
return this.prisma.syncCursor.upsert({
where: { peerId },
create: { peerId },
update: {},
});
}
private delegateFor(model: string) {
if (!SYNCED_MODELS.includes(model as SyncedModel)) return null;
const key = (model.charAt(0).toLowerCase() + model.slice(1)) as keyof PrismaClient;
// Generic dispatch across models is inherent to a replication log; each
// delegate exposes the same upsert/delete shape we need here.
return this.prisma[key] as unknown as {
upsert: (args: { where: { id: string }; create: object; update: object }) => Promise<unknown>;
delete: (args: { where: { id: string } }) => Promise<unknown>;
};
}
}
@@ -1,21 +0,0 @@
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;
}
@@ -1,18 +0,0 @@
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
@@ -1,75 +0,0 @@
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
@@ -1,9 +0,0 @@
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
@@ -1,183 +0,0 @@
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
@@ -1,201 +0,0 @@
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,
};
}
@@ -1,11 +0,0 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class CreateForceZuteilungDto {
@IsString()
@IsNotEmpty()
teilnehmerId!: string;
@IsString()
@IsNotEmpty()
workshopId!: string;
}
-19
View File
@@ -1,19 +0,0 @@
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;
}
@@ -1,15 +0,0 @@
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;
}
@@ -1,11 +0,0 @@
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[];
}
-124
View File
@@ -1,124 +0,0 @@
import {
Body,
Controller,
Get,
Param,
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 { 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);
}
@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
@@ -1,10 +0,0 @@
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 {}
-163
View File
@@ -1,163 +0,0 @@
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 } });
}
/// 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: { wahlId, guestAccountId } },
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;
}
}
-222
View File
@@ -1,222 +0,0 @@
import { NotFoundException } from '@nestjs/common';
import { SyncOperation } from '@prisma/client';
import { ZuteilungService } from './zuteilung.service';
/// Unit tests for the assignment algorithm ported from the WP plugin's
/// kc_run_zuteilung. Prisma and SyncService are faked in-memory; assertions
/// run against the `zuteilung.createMany` payload the service builds.
interface WorkshopFixture {
id: string;
name: string;
kapazitaet: number;
minTeilnehmer: number;
}
interface TeilnehmerFixture {
id: string;
prioritaeten: string[];
}
interface ForceFixture {
id: string;
teilnehmerId: string;
workshopId: string;
}
interface CreatedRow {
teilnehmerId: string;
workshopId: string | null;
wunschRang: number;
isForced: boolean;
}
const WAHL_ID = 'wahl-1';
function makeService(fixture: {
workshops: WorkshopFixture[];
teilnehmer: TeilnehmerFixture[];
forces?: ForceFixture[];
wahlExists?: boolean;
}) {
let lastCreateMany: CreatedRow[] = [];
const workshops = fixture.workshops.map((w) => ({ ...w, wahlId: WAHL_ID }));
const teilnehmer = fixture.teilnehmer.map((t) => ({
id: t.id,
wahlId: WAHL_ID,
guestAccountId: `guest-${t.id}`,
prioritaeten: t.prioritaeten,
createdAt: new Date(),
}));
const forces = (fixture.forces ?? []).map((f) => ({ ...f, wahlId: WAHL_ID }));
const prisma = {
wahl: {
findUnique: jest
.fn()
.mockResolvedValue(
fixture.wahlExists === false ? null : { id: WAHL_ID, kcId: 'kc-1' },
),
},
workshop: { findMany: jest.fn().mockResolvedValue(workshops) },
teilnehmer: { findMany: jest.fn().mockResolvedValue(teilnehmer) },
forceZuteilung: { findMany: jest.fn().mockResolvedValue(forces) },
zuteilung: {
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
createMany: jest.fn().mockImplementation(({ data }: { data: CreatedRow[] }) => {
lastCreateMany = data;
return Promise.resolve({ count: data.length });
}),
findMany: jest
.fn()
.mockImplementation(() =>
Promise.resolve(lastCreateMany.map((row, i) => ({ id: `zut-${i}`, ...row }))),
),
},
};
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
const service = new ZuteilungService(prisma as never, sync as never);
return { service, prisma, sync, rows: () => lastCreateMany };
}
function rowFor(rows: CreatedRow[], teilnehmerId: string): CreatedRow {
const row = rows.find((r) => r.teilnehmerId === teilnehmerId);
if (!row) throw new Error(`no zuteilung row for ${teilnehmerId}`);
return row;
}
describe('ZuteilungService', () => {
it('throws NotFound when the Wahl does not exist', async () => {
const { service } = makeService({
workshops: [],
teilnehmer: [],
wahlExists: false,
});
await expect(service.run(WAHL_ID)).rejects.toBeInstanceOf(NotFoundException);
});
it('clears previous Zuteilungen before recomputing', async () => {
const { service, prisma } = makeService({
workshops: [{ id: 'ws-a', name: 'A', kapazitaet: 5, minTeilnehmer: 0 }],
teilnehmer: [{ id: 't1', prioritaeten: ['ws-a'] }],
});
await service.run(WAHL_ID);
expect(prisma.zuteilung.deleteMany).toHaveBeenCalledWith({
where: { teilnehmer: { wahlId: WAHL_ID } },
});
});
it('honours Force-Zuteilungen over the participant wishes', async () => {
const { service, rows } = makeService({
workshops: [
{ id: 'ws-a', name: 'A', kapazitaet: 5, minTeilnehmer: 0 },
{ id: 'ws-b', name: 'B', kapazitaet: 5, minTeilnehmer: 0 },
],
teilnehmer: [{ id: 't1', prioritaeten: ['ws-b'] }],
forces: [{ id: 'f1', teilnehmerId: 't1', workshopId: 'ws-a' }],
});
await service.run(WAHL_ID);
expect(rowFor(rows(), 't1')).toEqual({
teilnehmerId: 't1',
workshopId: 'ws-a',
wunschRang: 0,
isForced: true,
});
});
it('assigns a first-wish workshop when capacity allows', async () => {
const { service, rows } = makeService({
workshops: [{ id: 'ws-a', name: 'A', kapazitaet: 3, minTeilnehmer: 0 }],
teilnehmer: [{ id: 't1', prioritaeten: ['ws-a'] }],
});
await service.run(WAHL_ID);
expect(rowFor(rows(), 't1')).toMatchObject({
workshopId: 'ws-a',
wunschRang: 1,
isForced: false,
});
});
it('falls back to the next wish once a workshop is full', async () => {
const { service, rows } = makeService({
workshops: [
{ id: 'ws-a', name: 'A', kapazitaet: 1, minTeilnehmer: 0 },
{ id: 'ws-b', name: 'B', kapazitaet: 5, minTeilnehmer: 0 },
],
teilnehmer: [
{ id: 't1', prioritaeten: ['ws-a', 'ws-b'] },
{ id: 't2', prioritaeten: ['ws-a', 'ws-b'] },
],
});
await service.run(WAHL_ID);
const placed = [rowFor(rows(), 't1'), rowFor(rows(), 't2')]
.map((r) => `${r.workshopId}:${r.wunschRang}`)
.sort();
// One keeps the 1st wish (ws-a), the other slides to the 2nd wish (ws-b).
expect(placed).toEqual(['ws-a:1', 'ws-b:2']);
});
it('leaves a participant unassigned when no capacity is left anywhere', async () => {
const { service, rows } = makeService({
workshops: [{ id: 'ws-a', name: 'A', kapazitaet: 0, minTeilnehmer: 0 }],
teilnehmer: [{ id: 't1', prioritaeten: [] }],
});
await service.run(WAHL_ID);
expect(rowFor(rows(), 't1')).toEqual({
teilnehmerId: 't1',
workshopId: null,
wunschRang: -1,
isForced: false,
});
});
it('dissolves a workshop that stays below minTeilnehmer and reassigns via remaining wishes', async () => {
const { service, rows } = makeService({
workshops: [
{ id: 'ws-a', name: 'A', kapazitaet: 10, minTeilnehmer: 3 },
{ id: 'ws-b', name: 'B', kapazitaet: 10, minTeilnehmer: 0 },
],
teilnehmer: [
{ id: 't1', prioritaeten: ['ws-a', 'ws-b'] },
{ id: 't2', prioritaeten: ['ws-a', 'ws-b'] },
],
});
await service.run(WAHL_ID);
// ws-a got 2 (< min 3) -> dissolved; both fall through to their 2nd wish.
for (const id of ['t1', 't2']) {
expect(rowFor(rows(), id)).toMatchObject({ workshopId: 'ws-b', wunschRang: 2 });
}
});
it('keeps a workshop that exactly meets minTeilnehmer', async () => {
const { service, rows } = makeService({
workshops: [{ id: 'ws-a', name: 'A', kapazitaet: 10, minTeilnehmer: 2 }],
teilnehmer: [
{ id: 't1', prioritaeten: ['ws-a'] },
{ id: 't2', prioritaeten: ['ws-a'] },
],
});
await service.run(WAHL_ID);
for (const id of ['t1', 't2']) {
expect(rowFor(rows(), id)).toMatchObject({ workshopId: 'ws-a', wunschRang: 1 });
}
});
it('captures one CREATE sync entry per resulting Zuteilung', async () => {
const { service, sync } = makeService({
workshops: [{ id: 'ws-a', name: 'A', kapazitaet: 5, minTeilnehmer: 0 }],
teilnehmer: [
{ id: 't1', prioritaeten: ['ws-a'] },
{ id: 't2', prioritaeten: ['ws-a'] },
],
});
await service.run(WAHL_ID);
expect(sync.capture).toHaveBeenCalledTimes(2);
expect(sync.capture).toHaveBeenCalledWith(
'Zuteilung',
SyncOperation.CREATE,
expect.any(String),
expect.objectContaining({ teilnehmerId: expect.any(String) }),
);
});
});
-212
View File
@@ -1,212 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { SyncOperation, Teilnehmer } from '@prisma/client';
import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service';
type TeilnehmerRow = Teilnehmer;
interface ZuteilungResult {
workshopId: string | null;
wunschRang: number;
isForced: boolean;
}
/// Port of the WP plugin's kc_run_zuteilung: force-assignments first, then up
/// to 3 wish rounds, then random fill of the rest, then a consolidation pass
/// that dissolves workshops which stayed below their minTeilnehmer.
@Injectable()
export class ZuteilungService {
constructor(
private readonly prisma: PrismaClient,
private readonly sync: SyncService,
) {}
async run(wahlId: string) {
const wahl = await this.prisma.wahl.findUnique({ where: { id: wahlId } });
if (!wahl) {
throw new NotFoundException('Wahl not found');
}
const [workshops, teilnehmerList, forces] = await Promise.all([
this.prisma.workshop.findMany({ where: { wahlId } }),
this.prisma.teilnehmer.findMany({ where: { wahlId } }),
this.prisma.forceZuteilung.findMany({ where: { wahlId } }),
]);
await this.prisma.zuteilung.deleteMany({
where: { teilnehmer: { wahlId } },
});
const caps = new Map(workshops.map((w) => [w.id, w.kapazitaet]));
const results = new Map<string, ZuteilungResult>();
const tryAssign = (
teilnehmerId: string,
workshopId: string,
wunschRang: number,
isForced: boolean,
): boolean => {
const cap = caps.get(workshopId) ?? 0;
if (cap <= 0) return false;
caps.set(workshopId, cap - 1);
results.set(teilnehmerId, { workshopId, wunschRang, isForced });
return true;
};
// 1) Force-Zuteilungen haben Vorrang
for (const force of forces) {
const teilnehmer = teilnehmerList.find((t) => t.id === force.teilnehmerId);
if (!teilnehmer || results.has(teilnehmer.id)) continue;
tryAssign(teilnehmer.id, force.workshopId, 0, true);
}
// 2) Verbleibende Teilnehmer mischen
let remaining = shuffle(teilnehmerList.filter((t) => !results.has(t.id)));
// 3) Wunschrunden 1..3
for (let wunschRang = 1; wunschRang <= 3; wunschRang++) {
const notAssigned: TeilnehmerRow[] = [];
for (const teilnehmer of remaining) {
const wunsch = readPrioritaeten(teilnehmer.prioritaeten)[wunschRang - 1];
if (!wunsch || !tryAssign(teilnehmer.id, wunsch, wunschRang, false)) {
notAssigned.push(teilnehmer);
}
}
remaining = shuffle(notAssigned);
}
// 4) Rest zufällig auf freie Workshops verteilen, sonst unzugeteilt
for (const teilnehmer of remaining) {
const freeWorkshopId = pickRandomFreeWorkshop(caps);
if (freeWorkshopId) {
tryAssign(teilnehmer.id, freeWorkshopId, 99, false);
} else {
results.set(teilnehmer.id, { workshopId: null, wunschRang: -1, isForced: false });
}
}
// 5) Konsolidierung: Workshops unter minTeilnehmer auflösen und neu verteilen
consolidateUnderfilledWorkshops(workshops, teilnehmerList, results, caps);
await this.prisma.zuteilung.createMany({
data: Array.from(results.entries()).map(([teilnehmerId, r]) => ({
teilnehmerId,
workshopId: r.workshopId,
wunschRang: r.wunschRang,
isForced: r.isForced,
})),
});
const created = await this.prisma.zuteilung.findMany({ where: { teilnehmer: { wahlId } } });
for (const row of created) {
await this.sync.capture('Zuteilung', SyncOperation.CREATE, row.id, row);
}
return this.getResults(wahlId);
}
async getResults(wahlId: string) {
return this.prisma.zuteilung.findMany({
where: { teilnehmer: { wahlId } },
include: {
teilnehmer: { include: { guestAccount: true } },
workshop: true,
},
});
}
async exportCsv(wahlId: string): Promise<string> {
const rows = await this.getResults(wahlId);
const header = 'Vorname;Nachname;Workshop;WunschRang;Erzwungen';
const lines = rows.map((r) => {
const vorname = r.teilnehmer.guestAccount.firstName;
const nachname = r.teilnehmer.guestAccount.lastName;
const workshop = r.workshop?.name ?? 'UNZUGETEILT';
return `${vorname};${nachname};${workshop};${r.wunschRang};${r.isForced ? 'ja' : 'nein'}`;
});
return [header, ...lines].join('\n');
}
}
/// Dissolves workshops that got some participants but stayed below their
/// minTeilnehmer, freeing their capacity and reassigning displaced
/// participants (preferring their remaining wishes, then any free workshop).
function consolidateUnderfilledWorkshops(
workshops: { id: string; minTeilnehmer: number }[],
teilnehmerList: TeilnehmerRow[],
results: Map<string, ZuteilungResult>,
caps: Map<string, number>,
) {
const countByWorkshop = new Map<string, number>();
for (const r of results.values()) {
if (r.workshopId) {
countByWorkshop.set(r.workshopId, (countByWorkshop.get(r.workshopId) ?? 0) + 1);
}
}
const failing = workshops.filter((w) => {
const count = countByWorkshop.get(w.id) ?? 0;
return count > 0 && w.minTeilnehmer > 0 && count < w.minTeilnehmer;
});
if (failing.length === 0) return;
const failingIds = new Set(failing.map((w) => w.id));
const toReassign: string[] = [];
for (const [teilnehmerId, r] of results.entries()) {
if (r.workshopId && failingIds.has(r.workshopId)) {
caps.set(r.workshopId, (caps.get(r.workshopId) ?? 0) + 1);
toReassign.push(teilnehmerId);
results.delete(teilnehmerId);
}
}
const assign = (teilnehmerId: string, workshopId: string, wunschRang: number): boolean => {
const cap = caps.get(workshopId) ?? 0;
if (cap <= 0) return false;
caps.set(workshopId, cap - 1);
results.set(teilnehmerId, { workshopId, wunschRang, isForced: false });
return true;
};
for (const teilnehmerId of toReassign) {
const teilnehmer = teilnehmerList.find((t) => t.id === teilnehmerId);
const wuensche = teilnehmer ? readPrioritaeten(teilnehmer.prioritaeten) : [];
let reassigned = false;
for (let wunschRang = 1; wunschRang <= wuensche.length; wunschRang++) {
const choice = wuensche[wunschRang - 1];
if (choice && !failingIds.has(choice) && assign(teilnehmerId, choice, wunschRang)) {
reassigned = true;
break;
}
}
if (!reassigned) {
const freeWorkshopId = pickRandomFreeWorkshop(caps, failingIds);
if (freeWorkshopId) {
assign(teilnehmerId, freeWorkshopId, 99);
} else {
results.set(teilnehmerId, { workshopId: null, wunschRang: -1, isForced: false });
}
}
}
}
function readPrioritaeten(value: unknown): string[] {
return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : [];
}
function shuffle<T>(items: T[]): T[] {
const copy = [...items];
for (let i = copy.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
return copy;
}
function pickRandomFreeWorkshop(caps: Map<string, number>, exclude?: Set<string>): string | null {
const free = [...caps.entries()].filter(
([id, cap]) => cap > 0 && !(exclude && exclude.has(id)),
);
if (free.length === 0) return null;
return free[Math.floor(Math.random() * free.length)][0];
}
-4
View File
@@ -1,4 +0,0 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
-22
View File
@@ -1,22 +0,0 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strict": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": false
}
}
+19 -2
View File
@@ -48,13 +48,30 @@ Teamer login only).
- Gemeinden (list/create); each opens **Teamer-Verwaltung**
(`teamer_admin_screen.dart`): local Teamer accounts + group-link / email
invites.
- **Workshop-Wahlen** (`wahl_admin_screen.dart`): create Wahlen, add
workshops, run the assignment, view the result table.
- **Workshop-Wahlen** (`wahl_admin_screen.dart`): create Wahlen, open/close
them, add workshops, list participants + Force-Zuteilung, run the
assignment, view the result table, export the CSV (browser download).
- **Dateien** (`files_admin_screen.dart`): upload with a visibility tier
(native `<input type=file>`), list. Needs a configured Nextcloud/S3 on
the backend or the upload returns 500.
- pending Verantwortlichen self-registrations (approve / reject).
- **Als Verantwortliche/r registrieren**
(`verantwortliche_register_screen.dart`) — shown on the home screen to a
logged-in Authentik user without a membership: enter a KC invite code,
pick a Gemeinde, submit; a Leitungsteam member then approves.
- **Push (web)** — `web/index.html` loads the Firebase compat SDK and
`web/firebase-messaging-sw.js` handles background messages. After login
`AppState` calls `window.kcGetPushToken()` and registers the token
(`POST /push/register`). Inert until `apiKey` / `appId` / `vapidKey` are
filled into both files (see the `REPLACE_ME` placeholders).
- **Nutzungsanalysen (web)** — `web/index.html` also initialises Google
Analytics for Firebase (`firebase.analytics()`) on every page load,
independent of login/push. Automatically logs `page_view` /
`session_start` / `first_visit`; visible in the Firebase Console under
**Analytics** (data can take a few hours to first appear, and won't show
on `localhost` — Analytics filters out non-public hostnames by default).
Screen-level events inside the Flutter SPA aren't tracked without further
instrumentation, but overall reach/users/sessions are.
- **Workshop-Wahl** (`lib/screens/wahl_screen.dart`, guests) — two tabs:
*Wünsche* (`GET /wahl/guest/overview`, tap workshops in order, max 3,
`POST /wahl/:id/teilnehmer`) and *Ergebnis* (`GET /wahl/guest/results`
+121 -6
View File
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'browser.dart' as browser;
import 'oidc.dart';
/// Backend base URL. Override at build/run time with
@@ -183,6 +184,26 @@ class WorkshopAdmin {
);
}
class TeilnehmerRow {
TeilnehmerRow({
required this.id,
required this.name,
required this.prioritaeten,
required this.forcedWorkshopId,
});
final String id;
final String name;
final List<String> prioritaeten;
final String? forcedWorkshopId;
factory TeilnehmerRow.fromJson(Map<String, dynamic> j) => TeilnehmerRow(
id: j['id'] as String,
name: j['name'] as String? ?? '',
prioritaeten:
(j['prioritaeten'] as List<dynamic>? ?? []).map((e) => e as String).toList(),
forcedWorkshopId: j['forcedWorkshopId'] as String?,
);
}
class ZuteilungRow {
ZuteilungRow({
required this.name,
@@ -349,20 +370,49 @@ class FileEntry {
}
class ChatChannel {
ChatChannel({required this.id, required this.type});
ChatChannel({
required this.id,
required this.type,
this.name,
this.gemeindeId,
this.createdByUserId,
});
final String id;
final String type;
final String? name;
final String? gemeindeId;
final String? createdByUserId;
factory ChatChannel.fromJson(Map<String, dynamic> j) => ChatChannel(
id: j['id'] as String,
type: j['type'] as String? ?? '',
name: j['name'] as String?,
gemeindeId: j['gemeindeId'] as String?,
createdByUserId: j['createdByUserId'] as String?,
);
}
class ChatMessage {
ChatMessage({required this.body, required this.createdAt});
ChatMessage({
this.id,
this.channelId,
this.senderUserId,
this.senderGuestId,
required this.body,
required this.createdAt,
});
final String? id;
final String? channelId;
final String? senderUserId;
final String? senderGuestId;
final String body;
final String createdAt;
factory ChatMessage.fromJson(Map<String, dynamic> j) => ChatMessage(
id: j['id'] as String?,
channelId: j['channelId'] as String?,
senderUserId: j['senderUserId'] as String?,
senderGuestId: j['senderGuestId'] as String?,
body: j['body'] as String? ?? '',
createdAt: j['createdAt'] as String? ?? '',
);
@@ -393,6 +443,15 @@ class Api {
return _decode(res);
}
Future<dynamic> _patch(String path, Object? body) async {
final res = await _client.patch(
Uri.parse('$kApiBase$path'),
headers: _headers,
body: body == null ? null : jsonEncode(body),
);
return _decode(res);
}
dynamic _decode(http.Response res) {
final text = res.body.isEmpty ? '{}' : res.body;
dynamic parsed;
@@ -420,8 +479,14 @@ class Api {
return j['accessToken'] as String;
}
Future<String> teamLogin(String email, String password) async {
final j = await _post('/auth/team-login', {'email': email, 'password': password});
/// Logs a Gemeinde Teamer in by Gemeinde name (the normal path) or by
/// email (legacy/personal accounts) — pass exactly one of the two.
Future<String> teamLogin({String? gemeindeName, String? email, required String password}) async {
final j = await _post('/auth/team-login', {
if (gemeindeName != null && gemeindeName.isNotEmpty) 'gemeindeName': gemeindeName,
if (email != null && email.isNotEmpty) 'email': email,
'password': password,
});
return j['accessToken'] as String;
}
@@ -547,6 +612,42 @@ class Api {
return list.map((e) => ZuteilungRow.fromJson(e as Map<String, dynamic>)).toList();
}
Future<void> setWahlOpen(String wahlId, bool isOpen) =>
_patch('/wahl/$wahlId', {'isOpen': isOpen});
Future<List<TeilnehmerRow>> wahlTeilnehmer(String wahlId) async {
final list = await _get('/wahl/$wahlId/teilnehmer') as List<dynamic>;
return list.map((e) => TeilnehmerRow.fromJson(e as Map<String, dynamic>)).toList();
}
Future<void> forceZuteilung(String wahlId, String teilnehmerId, String workshopId) =>
_post('/wahl/$wahlId/force-zuteilung', {
'teilnehmerId': teilnehmerId,
'workshopId': workshopId,
});
Future<String> zuteilungCsv(String wahlId) async {
final res = await _get('/wahl/$wahlId/zuteilung/csv');
return res is String ? res : res.toString();
}
Future<void> uploadFile(
String kcId,
String filename,
List<int> bytes,
String visibility,
) async {
final req = http.MultipartRequest('POST', Uri.parse('$kApiBase/files/$kcId'))
..fields['visibility'] = visibility
..files.add(http.MultipartFile.fromBytes('file', bytes, filename: filename));
if (token != null) req.headers['Authorization'] = 'Bearer $token';
final streamed = await req.send();
final res = await http.Response.fromStream(streamed);
if (res.statusCode < 200 || res.statusCode >= 300) {
_decode(res); // throws ApiException with the server message
}
}
// --- Teamer administration (LT or the responsible Verantwortliche/r) ---
Future<List<TeamerAccount>> teamerFor(String gemeindeId) async {
final list = await _get('/gemeinde/$gemeindeId/teamer') as List<dynamic>;
@@ -597,6 +698,10 @@ class Api {
'gemeindeId': gemeindeId,
}) as Map<String, dynamic>;
// --- push ---
Future<void> registerDevice(String token, {String platform = 'web'}) =>
_post('/push/register', {'token': token, 'platform': platform});
// --- chat: REST for channels/history; live send/receive is the /chat WS ---
Future<List<ChatChannel>> channels(String kcId) async {
final list = await _get('/chat/$kcId/channels') as List<dynamic>;
@@ -681,6 +786,16 @@ class AppState extends ChangeNotifier {
}
_authError = null;
notifyListeners();
_registerForPush(); // best-effort, fire and forget
}
Future<void> _registerForPush() async {
try {
final pushToken = await browser.getPushToken();
if (pushToken != null) await _api.registerDevice(pushToken);
} catch (_) {
// push is optional
}
}
Future<void> _clear(SharedPreferences prefs) async {
@@ -692,8 +807,8 @@ class AppState extends ChangeNotifier {
Future<void> guestLogin(String code, String first, String last) =>
_api.guestLogin(code, first, last).then(_establish);
Future<void> teamLogin(String email, String password) =>
_api.teamLogin(email, password).then(_establish);
Future<void> teamLogin({String? gemeindeName, String? email, required String password}) =>
_api.teamLogin(gemeindeName: gemeindeName, email: email, password: password).then(_establish);
Future<void> redeemInvite({
required String token,
+8
View File
@@ -7,3 +7,11 @@ void removeSession(String key) => throw UnsupportedError(_msg);
Never redirect(String url) => throw UnsupportedError(_msg);
Map<String, String> currentQueryParameters() => const {};
void clearQuery() {}
Future<({String name, List<int> bytes})?> pickFile() async =>
throw UnsupportedError(_msg);
void downloadText(String filename, String content, {String mime = 'text/plain'}) =>
throw UnsupportedError(_msg);
/// No push on non-web platforms in this build.
Future<String?> getPushToken() async => null;
+55
View File
@@ -1,5 +1,22 @@
import 'dart:async';
import 'dart:js_interop';
import 'package:web/web.dart' as web;
/// Provided by the inline Firebase bootstrap in web/index.html. Returns an FCM
/// registration token, or null if push isn't configured / permission denied.
@JS('kcGetPushToken')
external JSPromise<JSString?> _kcGetPushToken();
Future<String?> getPushToken() async {
try {
final result = await _kcGetPushToken().toDart;
return result?.toDart;
} catch (_) {
return null;
}
}
/// Per-tab storage for the PKCE verifier + CSRF state (cleared when the tab
/// closes), and the little bit of `window` access the OIDC redirect needs.
@@ -20,3 +37,41 @@ Map<String, String> currentQueryParameters() =>
void clearQuery() {
web.window.history.replaceState(null, '', '/');
}
/// Opens the OS file picker and reads the chosen file's bytes.
Future<({String name, List<int> bytes})?> pickFile() {
final completer = Completer<({String name, List<int> bytes})?>();
final input = web.HTMLInputElement()..type = 'file';
input.onchange = ((web.Event _) {
final files = input.files;
if (files == null || files.length == 0) {
completer.complete(null);
return;
}
final file = files.item(0)!;
final reader = web.FileReader();
reader.onload = ((web.Event _) {
final buffer = (reader.result as JSArrayBuffer).toDart;
completer.complete((name: file.name, bytes: buffer.asUint8List()));
}).toJS;
reader.onerror = ((web.Event _) => completer.complete(null)).toJS;
reader.readAsArrayBuffer(file);
}).toJS;
input.click();
return completer.future;
}
/// Triggers a browser download of an in-memory string (e.g. the CSV export).
void downloadText(
String filename,
String content, {
String mime = 'text/csv;charset=utf-8',
}) {
final blob = web.Blob([content.toJS].toJS, web.BlobPropertyBag(type: mime));
final url = web.URL.createObjectURL(blob);
web.HTMLAnchorElement()
..href = url
..download = filename
..click();
web.URL.revokeObjectURL(url);
}
+2 -4
View File
@@ -4,6 +4,7 @@ import 'package:http/http.dart' as http;
import 'api.dart';
import 'screens/home_screen.dart';
import 'screens/login_screen.dart';
import 'theme.dart';
void main() {
final state = AppState(Api(http.Client()))..bootstrap();
@@ -34,10 +35,7 @@ class KcApp extends StatelessWidget {
child: MaterialApp(
title: 'KC-App',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: const Color(0xFF3B5BA5),
useMaterial3: true,
),
theme: buildKcTheme(),
home: const _AuthGate(),
),
);
+11
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../api.dart';
import '../main.dart';
import 'files_admin_screen.dart';
import 'teamer_admin_screen.dart';
import 'ui.dart';
import 'wahl_admin_screen.dart';
@@ -146,6 +147,16 @@ class _KcDetailScreenState extends State<KcDetailScreen> {
),
),
),
Card(
child: ListTile(
leading: const Icon(Icons.folder_shared),
title: const Text('Dateien'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => FilesAdminScreen(kcId: widget.kc.id)),
),
),
),
const SizedBox(height: 16),
SectionHeader('Gemeinden', action: TextButton.icon(
onPressed: _addGemeinde,
+701 -88
View File
@@ -1,12 +1,28 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../api.dart';
import '../chat_socket.dart';
import '../main.dart';
/// Chat: channel list (REST) + a per-channel view that loads history over
/// REST and then streams live messages over the `/chat` WebSocket gateway
/// (`chat:join` / `chat:send` / `chat:message`).
/// WhatsApp brand colors
abstract final class WhatsAppColors {
static const primary = Color(0xFF008069); // WhatsApp Header Green
static const primaryDark = Color(0xFF075E54); // Classic WhatsApp Dark Green
static const accent = Color(0xFF00A884); // WhatsApp Bright Accent Green
static const chatBackground = Color(0xFFEFEAE2); // WhatsApp Chat Wallpaper BG
static const outgoingBubble = Color(0xFFE7FFDB); // WhatsApp Light Green Bubble
static const incomingBubble = Color(0xFFFFFFFF); // WhatsApp White Bubble
static const textPrimary = Color(0xFF111B21); // WhatsApp Main Text Color
static const textSecondary = Color(0xFF667781); // WhatsApp Muted / Timestamp Color
static const checkmarkBlue = Color(0xFF53BDEB); // WhatsApp Blue Double Check
static const dateBadgeBg = Color(0xEEFFFFFF); // WhatsApp Date Header BG
static const dateBadgeText = Color(0xFF54656F); // WhatsApp Date Header Text
static const composerBg = Color(0xFFF0F2F5); // WhatsApp Composer Bar BG
static const iconMuted = Color(0xFF54656F);
}
/// Chat channel overview screen with WhatsApp look and feel.
class ChatScreen extends StatefulWidget {
const ChatScreen({super.key, required this.kcId});
final String kcId;
@@ -29,58 +45,181 @@ class _ChatScreenState extends State<ChatScreen> {
'DIREKT': 'Direktnachricht',
'LT_UEBERGREIFEND': 'Leitungsteam',
'BROADCAST': 'Ankündigungen',
'GRUPPE': 'Gruppenchat',
};
static const _typeIcons = {
'GEMEINDE_GRUPPE': Icons.people_alt_rounded,
'DIREKT': Icons.person_rounded,
'LT_UEBERGREIFEND': Icons.shield_rounded,
'BROADCAST': Icons.campaign_rounded,
'GRUPPE': Icons.groups_rounded,
};
static const _typeColors = {
'GEMEINDE_GRUPPE': Color(0xFF008069),
'DIREKT': Color(0xFF2F7CFF),
'LT_UEBERGREIFEND': Color(0xFF6C5CE7),
'BROADCAST': Color(0xFFF17C20),
'GRUPPE': Color(0xFF0984E3),
};
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Chat')),
body: FutureBuilder<List<ChatChannel>>(
future: _future,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text('${snap.error}', textAlign: TextAlign.center),
return Theme(
data: Theme.of(context).copyWith(
appBarTheme: const AppBarTheme(
backgroundColor: WhatsAppColors.primary,
foregroundColor: Colors.white,
elevation: 1,
iconTheme: IconThemeData(color: Colors.white),
titleTextStyle: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w700,
),
),
),
child: Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: const Text('Chats'),
actions: [
IconButton(
icon: const Icon(Icons.search),
tooltip: 'Suchen',
onPressed: () {},
),
IconButton(
icon: const Icon(Icons.more_vert),
tooltip: 'Optionen',
onPressed: () {},
),
],
),
body: FutureBuilder<List<ChatChannel>>(
future: _future,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(
child: CircularProgressIndicator(color: WhatsAppColors.primary),
);
}
if (snap.hasError) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.redAccent),
const SizedBox(height: 12),
Text('${snap.error}', textAlign: TextAlign.center),
],
),
),
);
}
final channels = snap.data!;
if (channels.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.chat_bubble_outline, size: 56, color: Colors.grey.shade400),
const SizedBox(height: 12),
Text(
'Keine Kanäle sichtbar',
style: TextStyle(fontSize: 16, color: Colors.grey.shade600),
),
],
),
);
}
return ListView.separated(
itemCount: channels.length,
separatorBuilder: (_, __) => const Divider(
indent: 80,
endIndent: 16,
height: 1,
thickness: 0.8,
color: Color(0xFFF0F2F5),
),
);
}
final channels = snap.data!;
if (channels.isEmpty) {
return const Center(child: Text('Keine Kanäle sichtbar.'));
}
return ListView(
children: [
for (final c in channels)
ListTile(
leading: const Icon(Icons.tag),
title: Text(_typeLabels[c.type] ?? c.type),
subtitle: Text(c.id),
itemBuilder: (context, i) {
final c = channels[i];
final label = c.name?.isNotEmpty == true ? c.name! : (_typeLabels[c.type] ?? c.type);
final icon = _typeIcons[c.type] ?? Icons.chat_rounded;
final color = _typeColors[c.type] ?? WhatsAppColors.primary;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
leading: CircleAvatar(
radius: 25,
backgroundColor: color.withValues(alpha: 0.15),
child: Icon(icon, color: color, size: 28),
),
title: Row(
children: [
Expanded(
child: Text(
label,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: WhatsAppColors.textPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
subtitle: Text(
_typeLabels[c.type] ?? c.type,
style: const TextStyle(
fontSize: 13.5,
color: WhatsAppColors.textSecondary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: const Icon(Icons.chevron_right, color: Color(0xFFC0C0C0), size: 20),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => _ChannelMessages(
channelId: c.id,
title: _typeLabels[c.type] ?? c.type,
title: label,
subtitle: _typeLabels[c.type] ?? c.type,
icon: icon,
iconColor: color,
),
),
),
),
],
);
},
);
},
);
},
),
),
);
}
}
/// WhatsApp-style chat message screen.
class _ChannelMessages extends StatefulWidget {
const _ChannelMessages({required this.channelId, required this.title});
const _ChannelMessages({
required this.channelId,
required this.title,
required this.subtitle,
required this.icon,
required this.iconColor,
});
final String channelId;
final String title;
final String subtitle;
final IconData icon;
final Color iconColor;
@override
State<_ChannelMessages> createState() => _ChannelMessagesState();
@@ -94,6 +233,18 @@ class _ChannelMessagesState extends State<_ChannelMessages> {
bool _loading = true;
String? _loadError;
String _wsStatus = 'verbinde…';
bool _hasText = false;
@override
void initState() {
super.initState();
_composer.addListener(() {
final hasText = _composer.text.trim().isNotEmpty;
if (hasText != _hasText) {
setState(() => _hasText = hasText);
}
});
}
@override
void didChangeDependencies() {
@@ -132,7 +283,11 @@ class _ChannelMessagesState extends State<_ChannelMessages> {
void _jump() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scroll.hasClients) {
_scroll.jumpTo(_scroll.position.maxScrollExtent);
_scroll.animateTo(
_scroll.position.maxScrollExtent,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
);
}
});
}
@@ -152,85 +307,543 @@ class _ChannelMessagesState extends State<_ChannelMessages> {
super.dispose();
}
String _formatTime(String rawDate) {
final dt = DateTime.tryParse(rawDate)?.toLocal();
if (dt == null) return rawDate;
final hour = dt.hour.toString().padLeft(2, '0');
final minute = dt.minute.toString().padLeft(2, '0');
return '$hour:$minute';
}
String _formatDateHeader(DateTime date) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final messageDate = DateTime(date.year, date.month, date.day);
if (messageDate == today) {
return 'HEUTE';
} else if (messageDate == today.subtract(const Duration(days: 1))) {
return 'GESTERN';
} else {
final d = date.day.toString().padLeft(2, '0');
final m = date.month.toString().padLeft(2, '0');
return '$d.$m.${date.year}';
}
}
bool _isSameDay(DateTime a, DateTime b) {
return a.year == b.year && a.month == b.month && a.day == b.day;
}
Color _getSenderColor(String id) {
final colors = [
const Color(0xFF1E88E5),
const Color(0xFFE53935),
const Color(0xFF8E24AA),
const Color(0xFF3949AB),
const Color(0xFF00897B),
const Color(0xFFD81B60),
const Color(0xFFFB8C00),
const Color(0xFF43A047),
];
var hash = 0;
for (var i = 0; i < id.length; i++) {
hash = (hash * 31 + id.codeUnitAt(i)) & 0x7FFFFFFF;
}
return colors[hash % colors.length];
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(18),
child: Text('WebSocket: $_wsStatus', style: const TextStyle(fontSize: 11)),
final identity = AppScope.of(context).identity;
return Theme(
data: Theme.of(context).copyWith(
appBarTheme: const AppBarTheme(
backgroundColor: WhatsAppColors.primary,
foregroundColor: Colors.white,
elevation: 1,
iconTheme: IconThemeData(color: Colors.white),
titleTextStyle: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
body: Column(
children: [
Expanded(child: _body(context)),
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
Expanded(
child: TextField(
controller: _composer,
onSubmitted: (_) => _send(),
decoration: const InputDecoration(
hintText: 'Nachricht…',
border: OutlineInputBorder(),
isDense: true,
child: Scaffold(
backgroundColor: WhatsAppColors.chatBackground,
appBar: AppBar(
titleSpacing: 0,
title: Row(
children: [
CircleAvatar(
radius: 19,
backgroundColor: Colors.white.withValues(alpha: 0.2),
child: Icon(widget.icon, color: Colors.white, size: 22),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.title,
style: const TextStyle(
fontSize: 16.5,
fontWeight: FontWeight.w600,
color: Colors.white,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(icon: const Icon(Icons.send), onPressed: _send),
],
const SizedBox(height: 1),
Text(
_wsStatus == 'verbunden' || _wsStatus == 'connected'
? 'online'
: _wsStatus,
style: TextStyle(
fontSize: 11.5,
color: Colors.white.withValues(alpha: 0.85),
fontWeight: FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
actions: [
IconButton(
icon: const Icon(Icons.videocam_rounded),
tooltip: 'Videoanruf',
onPressed: () {},
),
IconButton(
icon: const Icon(Icons.call_rounded),
tooltip: 'Anruf',
onPressed: () {},
),
IconButton(
icon: const Icon(Icons.more_vert),
tooltip: 'Optionen',
onPressed: () {},
),
],
),
body: Stack(
children: [
// WhatsApp Doodle Pattern Background
Positioned.fill(
child: CustomPaint(
painter: _WhatsAppDoodlePainter(),
),
),
),
],
// Chat Content
Column(
children: [
Expanded(child: _buildMessagesList(context, identity)),
_buildComposer(context),
],
),
],
),
),
);
}
Widget _body(BuildContext context) {
if (_loading) return const Center(child: CircularProgressIndicator());
Widget _buildMessagesList(BuildContext context, Identity? identity) {
if (_loading) {
return const Center(
child: CircularProgressIndicator(color: WhatsAppColors.primary),
);
}
if (_loadError != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(_loadError!, textAlign: TextAlign.center),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.redAccent),
const SizedBox(height: 12),
Text(_loadError!, textAlign: TextAlign.center),
],
),
),
);
}
if (_messages.isEmpty) {
return const Center(child: Text('Noch keine Nachrichten.'));
}
return ListView.builder(
controller: _scroll,
padding: const EdgeInsets.all(12),
itemCount: _messages.length,
itemBuilder: (context, i) {
final m = _messages[i];
return Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
return Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: WhatsAppColors.dateBadgeBg,
borderRadius: BorderRadius.circular(8),
boxShadow: const [
BoxShadow(
color: Color(0x14000000),
blurRadius: 3,
offset: Offset(0, 1),
),
],
),
child: const Text(
'Nachrichten sind durch End-to-End-Verschlüsselung geschützt.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
color: WhatsAppColors.dateBadgeText,
fontWeight: FontWeight.w500,
),
),
),
);
}
final items = <Widget>[];
DateTime? lastDate;
for (var i = 0; i < _messages.length; i++) {
final m = _messages[i];
final msgDate = DateTime.tryParse(m.createdAt)?.toLocal();
// Insert date divider if day changed
if (msgDate != null && (lastDate == null || !_isSameDay(lastDate, msgDate))) {
items.add(_buildDateHeader(_formatDateHeader(msgDate)));
lastDate = msgDate;
}
final isMe = (identity?.kind == SessionKind.user &&
m.senderUserId != null &&
m.senderUserId == identity?.userId) ||
(identity?.kind == SessionKind.guest &&
m.senderGuestId != null &&
m.senderGuestId == identity?.guestId);
items.add(_buildMessageBubble(m, isMe));
}
return ListView(
controller: _scroll,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
children: items,
);
}
Widget _buildDateHeader(String text) {
return Center(
child: Container(
margin: const EdgeInsets.symmetric(vertical: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
decoration: BoxDecoration(
color: WhatsAppColors.dateBadgeBg,
borderRadius: BorderRadius.circular(7.5),
boxShadow: const [
BoxShadow(
color: Color(0x10000000),
blurRadius: 2,
offset: Offset(0, 1),
),
],
),
child: Text(
text,
style: const TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w600,
letterSpacing: 0.3,
color: WhatsAppColors.dateBadgeText,
),
),
),
);
}
Widget _buildMessageBubble(ChatMessage m, bool isMe) {
final timeStr = _formatTime(m.createdAt);
final senderId = m.senderUserId ?? m.senderGuestId;
final showSender = !isMe && senderId != null;
final senderColor = showSender ? _getSenderColor(senderId) : Colors.black;
return Align(
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.78,
minWidth: 80,
),
child: Container(
margin: const EdgeInsets.only(bottom: 4, top: 2),
decoration: BoxDecoration(
color: isMe ? WhatsAppColors.outgoingBubble : WhatsAppColors.incomingBubble,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(12),
topRight: const Radius.circular(12),
bottomLeft: isMe ? const Radius.circular(12) : const Radius.circular(2),
bottomRight: isMe ? const Radius.circular(2) : const Radius.circular(12),
),
boxShadow: const [
BoxShadow(
color: Color(0x18000000),
blurRadius: 2,
offset: Offset(0, 1),
),
],
),
child: Padding(
padding: const EdgeInsets.only(left: 10, right: 10, top: 6, bottom: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(m.body),
const SizedBox(height: 2),
Text(m.createdAt, style: Theme.of(context).textTheme.labelSmall),
if (showSender) ...[
Text(
m.senderGuestId != null ? 'Konfi / Gast' : 'Teamer / Leitung',
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w700,
color: senderColor,
),
),
const SizedBox(height: 2),
],
Wrap(
alignment: WrapAlignment.end,
crossAxisAlignment: WrapCrossAlignment.bottom,
spacing: 8,
runSpacing: 2,
children: [
Text(
m.body,
style: const TextStyle(
fontSize: 15,
color: WhatsAppColors.textPrimary,
height: 1.25,
),
),
Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
timeStr,
style: const TextStyle(
fontSize: 10.5,
color: WhatsAppColors.textSecondary,
),
),
if (isMe) ...[
const SizedBox(width: 3),
const Icon(
Icons.done_all_rounded,
size: 15,
color: WhatsAppColors.checkmarkBlue,
),
],
],
),
),
],
),
],
),
),
);
},
),
),
);
}
Widget _buildComposer(BuildContext context) {
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
boxShadow: const [
BoxShadow(
color: Color(0x14000000),
blurRadius: 3,
offset: Offset(0, 1),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
IconButton(
icon: const Icon(Icons.emoji_emotions_outlined),
color: WhatsAppColors.iconMuted,
splashRadius: 20,
onPressed: () {},
),
Expanded(
child: TextField(
controller: _composer,
minLines: 1,
maxLines: 5,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
hintText: 'Nachricht',
hintStyle: TextStyle(
color: WhatsAppColors.textSecondary,
fontSize: 15.5,
),
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.symmetric(vertical: 11, horizontal: 4),
),
onSubmitted: (_) => _send(),
),
),
IconButton(
icon: const Icon(Icons.attach_file_rounded),
color: WhatsAppColors.iconMuted,
splashRadius: 20,
onPressed: () {},
),
if (!_hasText)
IconButton(
icon: const Icon(Icons.camera_alt_rounded),
color: WhatsAppColors.iconMuted,
splashRadius: 20,
onPressed: () {},
),
],
),
),
),
const SizedBox(width: 6),
Container(
decoration: const BoxDecoration(
color: WhatsAppColors.accent,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Color(0x28000000),
blurRadius: 4,
offset: Offset(0, 2),
),
],
),
child: IconButton(
icon: Icon(_hasText ? Icons.send_rounded : Icons.mic_rounded),
color: Colors.white,
splashRadius: 24,
onPressed: _send,
),
),
],
),
),
);
}
}
/// Custom painter for the iconic subtle WhatsApp background doodle canvas.
class _WhatsAppDoodlePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = const Color(0xFF4A6B82).withValues(alpha: 0.05)
..style = PaintingStyle.stroke
..strokeWidth = 1.3;
final fillPaint = Paint()
..color = const Color(0xFF4A6B82).withValues(alpha: 0.035)
..style = PaintingStyle.fill;
const spacing = 75.0;
final rows = (size.height / spacing).ceil() + 1;
final cols = (size.width / spacing).ceil() + 1;
for (var r = 0; r < rows; r++) {
for (var c = 0; c < cols; c++) {
final x = c * spacing + ((r % 2 == 1) ? spacing / 2 : 0);
final y = r * spacing;
final type = (r * 7 + c * 11) % 6;
canvas.save();
canvas.translate(x, y);
switch (type) {
case 0: // Chat bubble doodle
final rrect = RRect.fromRectAndRadius(
const Rect.fromLTWH(-10, -8, 20, 16),
const Radius.circular(5),
);
canvas.drawRRect(rrect, fillPaint);
canvas.drawRRect(rrect, paint);
break;
case 1: // Small Star
final path = Path();
for (var i = 0; i < 5; i++) {
final angle = i * 4 * math.pi / 5 - math.pi / 2;
final px = 8 * math.cos(angle);
final py = 8 * math.sin(angle);
if (i == 0) {
path.moveTo(px, py);
} else {
path.lineTo(px, py);
}
}
path.close();
canvas.drawPath(path, fillPaint);
canvas.drawPath(path, paint);
break;
case 2: // Heart doodle
final path = Path();
path.moveTo(0, 4);
path.cubicTo(-6, -2, -10, -8, 0, -10);
path.cubicTo(10, -8, 6, -2, 0, 4);
canvas.drawPath(path, fillPaint);
canvas.drawPath(path, paint);
break;
case 3: // Musical note
canvas.drawCircle(const Offset(-4, 4), 3, fillPaint);
canvas.drawCircle(const Offset(-4, 4), 3, paint);
canvas.drawLine(const Offset(-1, 4), const Offset(-1, -6), paint);
canvas.drawLine(const Offset(-1, -6), const Offset(5, -4), paint);
break;
case 4: // Coffee / cup
final rrect = RRect.fromRectAndRadius(
const Rect.fromLTWH(-7, -5, 14, 12),
const Radius.circular(3),
);
canvas.drawRRect(rrect, fillPaint);
canvas.drawRRect(rrect, paint);
canvas.drawArc(
const Rect.fromLTWH(4, -3, 6, 6),
-math.pi / 2,
math.pi,
false,
paint,
);
break;
case 5: // Clock / circle
canvas.drawCircle(Offset.zero, 7, fillPaint);
canvas.drawCircle(Offset.zero, 7, paint);
canvas.drawLine(Offset.zero, const Offset(0, -4), paint);
canvas.drawLine(Offset.zero, const Offset(3, 0), paint);
break;
}
canvas.restore();
}
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import '../api.dart';
import '../browser.dart' as browser;
import '../main.dart';
import 'ui.dart';
/// LT file management for one KC: upload with a visibility tier + list.
class FilesAdminScreen extends StatefulWidget {
const FilesAdminScreen({super.key, required this.kcId});
final String kcId;
@override
State<FilesAdminScreen> createState() => _FilesAdminScreenState();
}
class _FilesAdminScreenState extends State<FilesAdminScreen> {
Future<List<FileEntry>>? _files;
bool _uploading = false;
Api get _api => AppScope.of(context).api;
static const _visibilities = {
'ALLE': 'Alle (inkl. Konfis)',
'ALLE_AUSSER_KONFIS': 'Team (ohne Konfis)',
'NUR_LT': 'Nur Leitungsteam',
};
@override
void didChangeDependencies() {
super.didChangeDependencies();
_files ??= _api.files(widget.kcId);
}
void _reload() => setState(() => _files = _api.files(widget.kcId));
Future<void> _upload() async {
final api = _api;
final picked = await browser.pickFile();
if (picked == null || !mounted) return;
final visibility = await showDialog<String>(
context: context,
builder: (_) => SimpleDialog(
title: Text('Sichtbarkeit für „${picked.name}'),
children: [
for (final e in _visibilities.entries)
SimpleDialogOption(
onPressed: () => Navigator.of(context).pop(e.key),
child: Text(e.value),
),
],
),
);
if (visibility == null || !mounted) return;
setState(() => _uploading = true);
try {
await api.uploadFile(widget.kcId, picked.name, picked.bytes, visibility);
if (mounted) _reload();
} catch (e) {
if (mounted) toast(context, '$e');
} finally {
if (mounted) setState(() => _uploading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Dateien (LT)')),
floatingActionButton: FloatingActionButton.extended(
onPressed: _uploading ? null : _upload,
icon: _uploading
? const SizedBox(
height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.upload_file),
label: const Text('Hochladen'),
),
body: FutureBuilder<List<FileEntry>>(
future: _files,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) return ErrorText('${snap.error}', onRetry: _reload);
final files = snap.data!;
if (files.isEmpty) {
return const Center(child: Text('Noch keine Dateien.'));
}
return ListView(
children: [
for (final f in files)
ListTile(
leading: const Icon(Icons.insert_drive_file_outlined),
title: Text(f.filename),
subtitle: Text(_visibilities[f.visibility] ?? f.visibility),
),
],
);
},
),
);
}
}
+61 -13
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../api.dart';
import '../main.dart';
import '../theme.dart';
import 'admin_screen.dart';
import 'chat_screen.dart';
import 'files_screen.dart';
@@ -109,8 +110,7 @@ class _IdentityCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final lines = <String>[
'Rolle: ${id.roleLabel}',
if (id.email != null) 'E-Mail: ${id.email}',
if (id.email != null) id.email!,
if (id.isLeitungsteam)
'Leitungsteam-Rechte gelten KC-übergreifend.'
else if (id.memberships.length > 1)
@@ -118,13 +118,35 @@ class _IdentityCard extends StatelessWidget {
];
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
padding: const EdgeInsets.all(20),
child: Row(
children: [
Text('Angemeldet', style: Theme.of(context).textTheme.labelMedium),
const SizedBox(height: 4),
for (final l in lines) Text(l),
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [KcColors.blue, KcColors.teal],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
shape: BoxShape.circle,
),
child: const Icon(Icons.person, color: Colors.white),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(id.roleLabel, style: Theme.of(context).textTheme.titleMedium),
for (final l in lines) ...[
const SizedBox(height: 2),
Text(l, style: Theme.of(context).textTheme.bodySmall),
],
],
),
),
],
),
),
@@ -147,12 +169,38 @@ class _NavTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
leading: Icon(icon),
title: Text(title),
subtitle: Text(subtitle),
trailing: const Icon(Icons.chevron_right),
margin: const EdgeInsets.only(bottom: 12),
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: KcColors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: KcColors.blue),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 2),
Text(subtitle, style: Theme.of(context).textTheme.bodySmall),
],
),
),
Icon(Icons.chevron_right, color: KcColors.slate.withValues(alpha: 0.6)),
],
),
),
),
);
}
+243 -110
View File
@@ -2,38 +2,49 @@ import 'package:flutter/material.dart';
import '../api.dart';
import '../main.dart';
import '../theme.dart';
class LoginScreen extends StatelessWidget {
/// A single login screen — one card, no tabs, no role switcher. The KC-Code
/// field drives Konfi vs. Leitungsteam: a plain code reveals the Konfi name
/// fields; appending "LT" to the code (e.g. "ABC123LT") reveals the
/// Leitungsteam Authentik button instead. Gemeinde Teamer:in has its own
/// section below, logging in with the Gemeinde name instead of an email.
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 3,
child: Scaffold(
appBar: AppBar(
title: const Text('KC-App'),
bottom: const TabBar(
tabs: [
Tab(text: 'Konfi / Gast'),
Tab(text: 'Team-Login'),
Tab(text: 'Einladung'),
],
),
),
body: SafeArea(
child: Center(
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Padding(
padding: const EdgeInsets.all(24),
child: TabBarView(
children: const [
_GuestForm(),
_TeamForm(),
_InviteForm(),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const _Brand(),
const SizedBox(height: 28),
Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 28, 24, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: const [
_KonfiOrLeitungsteamSection(),
Divider(height: 40),
_TeamerSection(),
],
),
),
),
],
),
),
),
@@ -43,12 +54,44 @@ class LoginScreen extends StatelessWidget {
}
}
/// Shared submit-button + error handling for the three little forms.
class _Brand extends StatelessWidget {
const _Brand();
@override
Widget build(BuildContext context) {
return Column(
children: [
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [KcColors.blue, KcColors.teal],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(18),
),
child: const Icon(Icons.castle_outlined, color: Colors.white, size: 32),
),
const SizedBox(height: 16),
const Text(
'KC-App',
style: TextStyle(fontSize: 26, fontWeight: FontWeight.w800, color: KcColors.navy),
),
const SizedBox(height: 4),
Text('Konfi-Castle Events', style: TextStyle(fontSize: 14, color: KcColors.slate)),
],
);
}
}
/// Shared submit-button + error handling.
class _FormShell extends StatefulWidget {
const _FormShell({required this.title, required this.fields, required this.onSubmit});
final String title;
const _FormShell({required this.fields, required this.onSubmit, this.submitLabel = 'Anmelden'});
final List<Widget> fields;
final Future<void> Function() onSubmit;
final String submitLabel;
@override
State<_FormShell> createState() => _FormShellState();
@@ -76,126 +119,208 @@ class _FormShellState extends State<_FormShell> {
@override
Widget build(BuildContext context) {
return ListView(
shrinkWrap: true,
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (widget.title.isNotEmpty) ...[
Text(widget.title, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
],
...widget.fields,
const SizedBox(height: 20),
if (_error != null) ...[
const SizedBox(height: 8),
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
const SizedBox(height: 12),
],
const SizedBox(height: 16),
FilledButton(
onPressed: _busy ? null : _run,
child: _busy
? const SizedBox(
height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Weiter'),
height: 18, width: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: Text(widget.submitLabel),
),
],
);
}
}
TextField _field(TextEditingController c, String label, {bool obscure = false}) => TextField(
TextField _field(TextEditingController c, String label,
{bool obscure = false, IconData? icon, ValueChanged<String>? onChanged}) =>
TextField(
controller: c,
obscureText: obscure,
decoration: InputDecoration(labelText: label, border: const OutlineInputBorder()),
onChanged: onChanged,
decoration: InputDecoration(
labelText: label,
prefixIcon: icon != null ? Icon(icon, size: 20) : null,
),
);
class _GuestForm extends StatefulWidget {
const _GuestForm();
const _fieldGap = SizedBox(height: 12);
/// One code field drives two different logins: a plain KC-Code reveals the
/// Konfi name fields; a code ending in "LT" (e.g. "ABC123LT") reveals the
/// Leitungsteam Authentik button instead — no separate role picker needed.
class _KonfiOrLeitungsteamSection extends StatefulWidget {
const _KonfiOrLeitungsteamSection();
@override
State<_GuestForm> createState() => _GuestFormState();
State<_KonfiOrLeitungsteamSection> createState() => _KonfiOrLeitungsteamSectionState();
}
class _GuestFormState extends State<_GuestForm> {
class _KonfiOrLeitungsteamSectionState extends State<_KonfiOrLeitungsteamSection> {
final _code = TextEditingController();
final _first = TextEditingController();
final _last = TextEditingController();
bool get _isLeitungsteamCode {
final c = _code.text.trim();
final upper = c.toUpperCase();
final lower = c.toLowerCase();
return upper == 'LT' || (c.length > 2 && upper.endsWith('LT')) || lower == 'login' || lower == 'sso';
}
/// The KC-Code with a trailing "LT" trigger stripped back off, so
/// "ABC123LT" still resolves to the real invite code "ABC123".
String get _plainCode {
final c = _code.text.trim();
if (c.toUpperCase() == 'LT' || c.toLowerCase() == 'login' || c.toLowerCase() == 'sso') return '';
return (c.length > 2 && c.toUpperCase().endsWith('LT'))
? c.substring(0, c.length - 2)
: c;
}
@override
Widget build(BuildContext context) {
final state = AppScope.of(context);
return _FormShell(
title: 'Mit Einladungscode beitreten',
fields: [
_field(_code, 'Einladungscode'),
const SizedBox(height: 12),
_field(_first, 'Vorname'),
const SizedBox(height: 12),
_field(_last, 'Nachname'),
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_field(
_code,
'KC-Code',
icon: Icons.confirmation_number_outlined,
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 4),
Text(
'Konfi: gib deinen KC-Code ein. Leitungsteam: gib "LT" ein oder hänge "LT" an den '
'Code an (z. B. "LT" oder "ABC123LT").',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 16),
AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
child: _code.text.trim().isEmpty
? const SizedBox.shrink(key: ValueKey('empty'))
: _isLeitungsteamCode
? _LeitungsteamLogin(key: const ValueKey('lt'), state: state)
: Column(
key: const ValueKey('konfi'),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_field(_first, 'Vorname', icon: Icons.badge_outlined),
_fieldGap,
_field(_last, 'Nachname'),
const SizedBox(height: 4),
_FormShell(
submitLabel: 'Los geht\'s',
fields: const [],
onSubmit: () => state.guestLogin(
_plainCode,
_first.text.trim(),
_last.text.trim(),
),
),
const SizedBox(height: 4),
Text(
'Meldest du dich erneut mit demselben Code und Namen '
'an, kommst du in deinen bestehenden Account zurück.',
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
],
),
),
],
onSubmit: () => state.guestLogin(_code.text.trim(), _first.text.trim(), _last.text.trim()),
);
}
}
class _TeamForm extends StatefulWidget {
const _TeamForm();
@override
State<_TeamForm> createState() => _TeamFormState();
}
class _TeamFormState extends State<_TeamForm> {
final _email = TextEditingController();
final _password = TextEditingController();
class _LeitungsteamLogin extends StatelessWidget {
const _LeitungsteamLogin({super.key, required this.state});
final AppState state;
@override
Widget build(BuildContext context) {
final state = AppScope.of(context);
return ListView(
shrinkWrap: true,
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Leitungsteam / Verantwortliche',
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
if (state.authError != null) ...[
Text(state.authError!,
style: TextStyle(color: Theme.of(context).colorScheme.error)),
Text(state.authError!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
const SizedBox(height: 12),
],
FilledButton.icon(
onPressed: () => state.beginOidcLogin(),
icon: const Icon(Icons.login),
icon: const Icon(Icons.login, size: 20),
label: const Text('Mit Konfi-Castle-ID anmelden'),
),
const SizedBox(height: 8),
const Text(
'Öffnet die Konfi-Castle-ID (Authentik). Leitungsteam-Rechte kommen '
'aus deiner Authentik-Gruppe.',
style: TextStyle(fontSize: 12),
Text(
'Öffnet die Konfi-Castle-ID (Authentik). Leitungsteam-Rechte und '
'Gemeinde-Zuordnungen kommen automatisch aus deinem Account.',
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
const Divider(height: 40),
Text('Lokaler Teamer:in-Login',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
_TeamPasswordForm(email: _email, password: _password),
],
);
}
}
class _TeamPasswordForm extends StatelessWidget {
const _TeamPasswordForm({required this.email, required this.password});
final TextEditingController email;
final TextEditingController password;
/// Gemeinde Teamer:in login — Gemeinde name instead of email, since that's
/// what a Teamer actually thinks of as "their" login. Invite redemption for
/// a first-time account is folded in underneath.
class _TeamerSection extends StatefulWidget {
const _TeamerSection();
@override
State<_TeamerSection> createState() => _TeamerSectionState();
}
class _TeamerSectionState extends State<_TeamerSection> {
final _gemeinde = TextEditingController();
final _password = TextEditingController();
bool _showInvite = false;
@override
Widget build(BuildContext context) {
final state = AppScope.of(context);
return _FormShell(
title: '',
fields: [
_field(email, 'E-Mail'),
const SizedBox(height: 12),
_field(password, 'Passwort', obscure: true),
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Gemeinde Teamer:in', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 16),
_FormShell(
fields: [
_field(_gemeinde, 'Gemeinde', icon: Icons.groups_outlined),
_fieldGap,
_field(_password, 'Passwort', obscure: true, icon: Icons.lock_outline),
],
onSubmit: () => state.teamLogin(
gemeindeName: _gemeinde.text.trim(),
password: _password.text,
),
),
const SizedBox(height: 4),
Center(
child: TextButton(
onPressed: () => setState(() => _showInvite = !_showInvite),
child: Text(_showInvite
? 'Einladung ausblenden'
: 'Noch kein Konto? Einladung einlösen'),
),
),
if (_showInvite) ...[
const Divider(height: 28),
const _InviteForm(),
],
],
onSubmit: () => state.teamLogin(email.text.trim(), password.text),
);
}
}
@@ -216,26 +341,34 @@ class _InviteFormState extends State<_InviteForm> {
@override
Widget build(BuildContext context) {
final state = AppScope.of(context);
return _FormShell(
title: 'Teamer:in-Einladung einlösen',
fields: [
_field(_token, 'Einladungscode / Token'),
const SizedBox(height: 12),
_field(_first, 'Vorname'),
const SizedBox(height: 12),
_field(_last, 'Nachname'),
const SizedBox(height: 12),
_field(_email, 'E-Mail (bei Gruppen-Link nötig)'),
const SizedBox(height: 12),
_field(_password, 'Passwort wählen (min. 8 Zeichen)', obscure: true),
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Teamer:in-Einladung einlösen',
style: Theme.of(context).textTheme.titleMedium, textAlign: TextAlign.center),
const SizedBox(height: 16),
_FormShell(
submitLabel: 'Konto anlegen',
fields: [
_field(_token, 'Einladungscode / Token'),
_fieldGap,
_field(_first, 'Vorname'),
_fieldGap,
_field(_last, 'Nachname'),
_fieldGap,
_field(_email, 'E-Mail (bei Gruppen-Link nötig)'),
_fieldGap,
_field(_password, 'Passwort wählen (min. 8 Zeichen)', obscure: true),
],
onSubmit: () => state.redeemInvite(
token: _token.text.trim(),
first: _first.text.trim(),
last: _last.text.trim(),
password: _password.text,
email: _email.text.trim(),
),
),
],
onSubmit: () => state.redeemInvite(
token: _token.text.trim(),
first: _first.text.trim(),
last: _last.text.trim(),
password: _password.text,
email: _email.text.trim(),
),
);
}
}

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