From 7aba87368d19025942118308ef2ef844b6c89cfb Mon Sep 17 00:00:00 2001 From: linus Date: Wed, 9 Sep 2026 16:23:45 +0200 Subject: [PATCH 01/37] feat(backend): implement phases 0-6 (auth, kc, wahl, files, chat, sync) Full NestJS backend for the KC-App platform: - auth: Authentik OIDC resource-server strategy + guest invite-code JWT login, plus TokenVerificationService for the WS handshake path - kc: Leitungsteam-only KC (event) creation/listing - wahl: Wahl/Workshop admin, Force-Zuteilung overrides, ZuteilungService (port of the WP plugin's kc_run_zuteilung), CSV export - files: LT-only upload with visibility tiers; list/download filtered by caller tier; StorageProvider abstraction (WebDAV/Nextcloud default, S3) - chat: Gemeinde group / DM / LT-wide / broadcast channels; REST + raw ws gateway sharing ChatService access rules - sync: append-only SyncLogEntry replication log + local<->cloud push/pull scheduler, shared-secret guarded - common: Role enum, @Roles decorator, KC-scoped RolesGuard (LT global) - serves client/web/ interim static web client under / (API under /api) Typecheck, nest build and boot test pass; needs real Postgres/Authentik/ Nextcloud to run end to end. Co-Authored-By: Claude Sonnet 5 --- README.md | 53 +- backend/.env.example | 26 + backend/README.md | 45 +- backend/package-lock.json | 935 +++++++++++++++++- backend/package.json | 18 +- backend/prisma/schema.prisma | 175 ++-- backend/src/app.module.ts | 16 + backend/src/auth/auth.module.ts | 5 +- backend/src/auth/authenticated-request.ts | 6 + backend/src/auth/guest-auth.service.ts | 4 + backend/src/auth/guest-jwt.strategy.ts | 21 + .../src/auth/token-verification.service.ts | 74 ++ backend/src/chat/caller.util.ts | 13 + backend/src/chat/chat.controller.ts | 45 + backend/src/chat/chat.gateway.ts | 100 ++ backend/src/chat/chat.module.ts | 12 + backend/src/chat/chat.service.ts | 165 ++++ backend/src/chat/dto/create-channel.dto.ts | 11 + .../src/chat/dto/create-direct-channel.dto.ts | 11 + backend/src/files/dto/upload-file.dto.ts | 7 + backend/src/files/files.controller.ts | 73 ++ backend/src/files/files.module.ts | 24 + backend/src/files/files.service.ts | 53 + .../src/files/storage/s3-storage.provider.ts | 53 + backend/src/files/storage/storage-provider.ts | 10 + .../files/storage/webdav-storage.provider.ts | 37 + backend/src/files/visibility.util.ts | 21 + backend/src/kc/kc.service.ts | 13 +- backend/src/main.ts | 3 + backend/src/sync/dto/ingest-entries.dto.ts | 7 + backend/src/sync/sync-scheduler.service.ts | 32 + backend/src/sync/sync-secret.guard.ts | 18 + backend/src/sync/sync.controller.ts | 45 + backend/src/sync/sync.module.ts | 16 + backend/src/sync/sync.service.ts | 154 +++ .../wahl/dto/create-force-zuteilung.dto.ts | 11 + backend/src/wahl/dto/create-wahl.dto.ts | 19 + backend/src/wahl/dto/create-workshop.dto.ts | 15 + backend/src/wahl/dto/submit-teilnehmer.dto.ts | 11 + backend/src/wahl/wahl.controller.ts | 107 ++ backend/src/wahl/wahl.module.ts | 10 + backend/src/wahl/wahl.service.ts | 93 ++ backend/src/wahl/zuteilung.service.ts | 212 ++++ client/web/app.js | 92 ++ client/web/index.html | 51 + client/web/style.css | 62 ++ plan-kcAppMultiTenantPlatform.prompt.md | 134 ++- 47 files changed, 3003 insertions(+), 115 deletions(-) create mode 100644 backend/src/auth/guest-jwt.strategy.ts create mode 100644 backend/src/auth/token-verification.service.ts create mode 100644 backend/src/chat/caller.util.ts create mode 100644 backend/src/chat/chat.controller.ts create mode 100644 backend/src/chat/chat.gateway.ts create mode 100644 backend/src/chat/chat.module.ts create mode 100644 backend/src/chat/chat.service.ts create mode 100644 backend/src/chat/dto/create-channel.dto.ts create mode 100644 backend/src/chat/dto/create-direct-channel.dto.ts create mode 100644 backend/src/files/dto/upload-file.dto.ts create mode 100644 backend/src/files/files.controller.ts create mode 100644 backend/src/files/files.module.ts create mode 100644 backend/src/files/files.service.ts create mode 100644 backend/src/files/storage/s3-storage.provider.ts create mode 100644 backend/src/files/storage/storage-provider.ts create mode 100644 backend/src/files/storage/webdav-storage.provider.ts create mode 100644 backend/src/files/visibility.util.ts create mode 100644 backend/src/sync/dto/ingest-entries.dto.ts create mode 100644 backend/src/sync/sync-scheduler.service.ts create mode 100644 backend/src/sync/sync-secret.guard.ts create mode 100644 backend/src/sync/sync.controller.ts create mode 100644 backend/src/sync/sync.module.ts create mode 100644 backend/src/sync/sync.service.ts create mode 100644 backend/src/wahl/dto/create-force-zuteilung.dto.ts create mode 100644 backend/src/wahl/dto/create-wahl.dto.ts create mode 100644 backend/src/wahl/dto/create-workshop.dto.ts create mode 100644 backend/src/wahl/dto/submit-teilnehmer.dto.ts create mode 100644 backend/src/wahl/wahl.controller.ts create mode 100644 backend/src/wahl/wahl.module.ts create mode 100644 backend/src/wahl/wahl.service.ts create mode 100644 backend/src/wahl/zuteilung.service.ts create mode 100644 client/web/app.js create mode 100644 client/web/index.html create mode 100644 client/web/style.css diff --git a/README.md b/README.md index c832172..b58770d 100644 --- a/README.md +++ b/README.md @@ -8,19 +8,54 @@ for the full architecture and phased roadmap. ## Structure - `backend/` — NestJS API (Prisma/PostgreSQL, Authentik OIDC as resource - server, guest/Konfi local accounts, roles/permissions foundation). See - [backend/README.md](backend/README.md) for setup. -- `client/` — planned Flutter app (mobile + web + desktop), not yet - scaffolded (Flutter is not installed in this environment). + 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/web/` — minimal dependency-free HTML/CSS/JS placeholder web + client (guest join, Wahl submission, file list, chat) exercising the real + API, served by the backend at `/`. Will be replaced by the Flutter web + build once Flutter is available. +- `client/` (mobile/desktop) — planned Flutter app, not yet scaffolded + (Flutter is not installed in this environment). ## Status Phase 0/1 foundation implemented: monorepo skeleton, Prisma data model (Kc, Gemeinde, User, Membership, GuestAccount, Wahl/Workshop/Teilnehmer/Zuteilung, File, Chat), Authentik JWT resource-server strategy, guest invite-code login, -Role-based guard scoped per KC. Backend builds and boots cleanly -(`npm run build`, `node dist/main.js`) but requires a real PostgreSQL -database and Authentik instance (see `backend/.env.example`) to run end to -end. Remaining phases (Wahl-Engine, Dateifreigabe, Chat realtime, Lokal/Cloud -Sync, Flutter clients) are not yet implemented. +Role-based guard scoped per KC. + +Phase 2 (Workshop-Wahl engine) implemented: Wahl/Workshop administration, +guest Teilnehmer submission, Force-Zuteilung overrides, and the assignment +algorithm ported from the WP plugin's `kc_run_zuteilung` (force-assignments → +wish rounds 1-3 → random fill → consolidation of underfilled workshops), +plus CSV export. + +Phase 3 (Dateifreigabe) implemented: Leitungsteam-only upload tagged with a +visibility tier (alle / alle außer Konfis / nur LT), list/download for +Authentik or guest callers filtered by their allowed tiers, storage behind a +provider abstraction defaulting to Nextcloud/WebDAV (S3-compatible storage +as an alternative via `STORAGE_PROVIDER=s3`). + +Phase 5 (Kommunikation) implemented: Gemeinde-Gruppenchat, 1:1-DMs, LT- +kanalübergreifende Kanäle, Broadcast (read-only für Konfis); channel/history +via REST, real-time send/receive via a raw WebSocket gateway authenticated +with the same Authentik/guest tokens as the REST API. + +Phase 6 (Hybrid Lokal/Cloud-Server & Sync) implemented: an append-only +replication log (`SyncLogEntry`) captured by every feature service after its +writes; the local (on-site) server periodically pushes/pulls against the +cloud server's `/sync/ingest` + `/sync/export` endpoints (shared-secret +authenticated, not user auth). No conflict resolution needed by design - the +local server is the sole source of truth while an event is live. + +The backend now also serves the web client directly (static files from +`client/web/`, API under `/api`), so the same process is the single entry +point for the web experience. + +Backend builds and boots cleanly (`npm run build`, `node dist/main.js`) but +requires a real PostgreSQL database, Authentik instance, and Nextcloud/S3 +credentials (see `backend/.env.example`) to run end to end. Remaining: the +Flutter clients (mobile/desktop; web has an interim plain-HTML client). diff --git a/backend/.env.example b/backend/.env.example index edea1cb..11e1602 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -8,3 +8,29 @@ AUTHENTIK_ISSUER_URL="https://authentik.example.org/application/o/kc-app" GUEST_JWT_SECRET="change-me" PORT=3000 + +# 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" diff --git a/backend/README.md b/backend/README.md index b8f69ab..93b5912 100644 --- a/backend/README.md +++ b/backend/README.md @@ -13,6 +13,11 @@ 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 - Team members (Leitungsteam, Gemeinde Verantwortliche, Gemeinde Teamer) are @@ -28,10 +33,44 @@ npm run start:dev ## Modules implemented so far - `prisma/` — shared `PrismaClient` provider. -- `auth/` — Authentik resource-server strategy + guest invite-code login. +- `auth/` — Authentik resource-server strategy (`AuthGuard('authentik')`) + + guest invite-code login issuing a locally-signed JWT (`AuthGuard('guest')`). - `kc/` — KC (event) creation/listing, Leitungsteam-only. +- `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). -Not yet implemented: Wahl/Workshop/Zuteilung engine, file sharing, chat -realtime gateway, local/cloud sync engine. +All planned backend phases are implemented; remaining work is the Flutter +clients (see repo root README). diff --git a/backend/package-lock.json b/backend/package-lock.json index c0bfd1d..31b3040 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "license": "UNLICENSED", "dependencies": { + "@aws-sdk/client-s3": "^3.679.0", "@nestjs/common": "^10.4.15", "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.4.15", @@ -16,15 +17,20 @@ "@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", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "jsonwebtoken": "^9.0.2", "jwks-rsa": "^3.1.0", + "multer": "^2.0.1", "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": { @@ -33,6 +39,8 @@ "@nestjs/testing": "^10.4.15", "@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/passport": "^1.0.17", "@types/passport-jwt": "^4.0.1", @@ -215,6 +223,314 @@ "tslib": "^2.1.0" } }, + "node_modules/@aws-sdk/checksums": { + "version": "3.1000.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.29.tgz", + "integrity": "sha512-Dtu0gr4dnATZAPwEYbpCsG+MpLM7OAliy2gTepEFQwl1vZ6DL3QMH2FveMa3HLvPsOdhJsPRB3KtxVhph9T75A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1128.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1128.0.tgz", + "integrity": "sha512-tYEB4058LdhTiSS7sCVVSpqSAdjIc1jTaf1dDPoIRJbR/XI5A2ZOuCiV1Aebo/pria/pUJBOT0WjdZVIwaZtDA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/checksums": "^3.1000.29", + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-node": "^3.972.82", + "@aws-sdk/middleware-sdk-s3": "^3.972.75", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.977.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.9.tgz", + "integrity": "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@aws-sdk/xml-builder": "^3.972.40", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.33.3", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.70.tgz", + "integrity": "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.72.tgz", + "integrity": "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.15.tgz", + "integrity": "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-login": "^3.972.77", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.77", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.77.tgz", + "integrity": "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.82", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.82.tgz", + "integrity": "sha512-znDkEOGXB8W3kG1LJUKP3foBZY/9qLM0eil/DxWXSp37XsdsRLQHE/d/OaCGGVgKpA6znR38h/+INk8do1FjiA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-ini": "^3.973.15", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.70.tgz", + "integrity": "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.14.tgz", + "integrity": "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/token-providers": "3.1116.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.76", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.76.tgz", + "integrity": "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.75", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.75.tgz", + "integrity": "sha512-wMIsNumRVKaNMKhvU/s9VrdEwE8S6gSzXp4RygFG5BEMnGkkXf8cjh8zf7cKJBpUDpqTWqwbz5isEgp9rH6Lng==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.44.tgz", + "integrity": "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz", + "integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1116.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1116.0.tgz", + "integrity": "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.5.tgz", + "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.40", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz", + "integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -741,6 +1057,34 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/@buttercup/fetch": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@buttercup/fetch/-/fetch-0.2.1.tgz", + "integrity": "sha512-sCgECOx8wiqY8NN1xN22BqqKzXYIG2AicNLlakOAI4f0WgyLVUbAigMf8CZhBtJxdudTcB1gD5lciqi44jwJvg==", + "license": "MIT", + "optionalDependencies": { + "node-fetch": "^3.3.0" + } + }, + "node_modules/@buttercup/fetch/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "optional": true, + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -1824,6 +2168,15 @@ "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0" } }, + "node_modules/@nestjs/jwt/node_modules/@types/jsonwebtoken": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz", + "integrity": "sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@nestjs/passport": { "version": "10.0.3", "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-10.0.3.tgz", @@ -1855,6 +2208,24 @@ "@nestjs/core": "^10.0.0" } }, + "node_modules/@nestjs/platform-express/node_modules/multer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, "node_modules/@nestjs/platform-ws": { "version": "10.4.22", "resolved": "https://registry.npmjs.org/@nestjs/platform-ws/-/platform-ws-10.4.22.tgz", @@ -1895,6 +2266,20 @@ } } }, + "node_modules/@nestjs/schedule": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.1.tgz", + "integrity": "sha512-VxAnCiU4HP0wWw8IdWAVfsGC/FGjyToNjjUtXDEQL6oj+w/N5QDd2VT9k6d7Jbr8PlZuBZNdWtDKSkH5bZ+RXQ==", + "license": "MIT", + "dependencies": { + "cron": "3.1.7", + "uuid": "10.0.0" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, "node_modules/@nestjs/schematics": { "version": "10.2.3", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz", @@ -1919,6 +2304,39 @@ "dev": true, "license": "MIT" }, + "node_modules/@nestjs/serve-static": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/serve-static/-/serve-static-4.0.2.tgz", + "integrity": "sha512-cT0vdWN5ar7jDI2NKbhf4LcwJzU4vS5sVpMkVrHuyLcltbrz6JdGi1TfIMMatP2pNiq5Ie/uUdPSFDVaZX/URQ==", + "license": "MIT", + "dependencies": { + "path-to-regexp": "0.2.5" + }, + "peerDependencies": { + "@fastify/static": "^6.5.0 || ^7.0.0", + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", + "express": "^4.18.1", + "fastify": "^4.7.0" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "express": { + "optional": true + }, + "fastify": { + "optional": true + } + } + }, + "node_modules/@nestjs/serve-static/node_modules/path-to-regexp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.2.5.tgz", + "integrity": "sha512-l6qtdDPIkmAmzEO6egquYDfqQGPMRNGjYtrU13HAXb3YSRrt7HSb1sJY0pKp6o2bAa86tSB6iwaW2JbthPKr7Q==", + "license": "MIT" + }, "node_modules/@nestjs/testing": { "version": "10.4.22", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.22.tgz", @@ -1983,6 +2401,18 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@nodable/entities": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2168,6 +2598,87 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@smithy/core": { + "version": "3.33.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.3.tgz", + "integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.3.tgz", + "integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.18.0.tgz", + "integrity": "sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@tokenizer/inflate": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", @@ -2411,14 +2922,21 @@ "license": "MIT" }, "node_modules/@types/jsonwebtoken": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz", - "integrity": "sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==", + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", "license": "MIT", "dependencies": { + "@types/ms": "*", "@types/node": "*" } }, + "node_modules/@types/luxon": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz", + "integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==", + "license": "MIT" + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -2433,6 +2951,22 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/multer": { + "version": "1.4.13", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.13.tgz", + "integrity": "sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "20.19.43", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", @@ -3148,6 +3682,18 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/append-field": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", @@ -3335,7 +3881,12 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, + "license": "MIT" + }, + "node_modules/base-64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", + "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==", "license": "MIT" }, "node_modules/base64-js": { @@ -3436,11 +3987,16 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -3564,6 +4120,12 @@ "node": ">=10.16.0" } }, + "node_modules/byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/byte-length/-/byte-length-1.0.2.tgz", + "integrity": "sha512-ovBpjmsgd/teRmgcPh23d4gJvxDoXtAzEL9xTfMU8Yc2kqCDb7L9jAG0XHl1nzuGl+h3ebCIF1i62UFyA9V/2Q==", + "license": "MIT" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -3695,6 +4257,15 @@ "dev": true, "license": "MIT" }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -4105,6 +4676,16 @@ "dev": true, "license": "MIT" }, + "node_modules/cron": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/cron/-/cron-3.1.7.tgz", + "integrity": "sha512-tlBg7ARsAMQLzgwqVxy8AZl/qlTc5nibqYwtNGoCrd+cV+ugI+tvZC1oT/8dFH8W455YrywGykx/KMmAqOr7Jw==", + "license": "MIT", + "dependencies": { + "@types/luxon": "~3.4.0", + "luxon": "~3.4.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4120,6 +4701,24 @@ "node": ">= 8" } }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4403,6 +5002,18 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -4975,6 +5586,45 @@ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "license": "MIT" }, + "node_modules/fast-xml-builder": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz", + "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.1.tgz", + "integrity": "sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^3.0.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.2", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastq": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", @@ -4995,6 +5645,29 @@ "bser": "2.1.1" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/fflate": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", @@ -5230,6 +5903,18 @@ "node": ">= 6" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/formidable": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.5.tgz", @@ -5606,6 +6291,12 @@ "node": ">= 0.4" } }, + "node_modules/hot-patcher": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/hot-patcher/-/hot-patcher-2.0.1.tgz", + "integrity": "sha512-ECg1JFG0YzehicQaogenlcs2qg6WsXQsxtnbr1i696u5tLUjtJdQAh0u2g0Q5YV45f263Ta1GnUJsc8WIfJf4Q==", + "license": "MIT" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -5806,6 +6497,12 @@ "node": ">=8" } }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, "node_modules/is-core-module": { "version": "2.16.2", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", @@ -5921,6 +6618,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-unsafe": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz", + "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -6935,6 +7644,12 @@ "node": ">=6" } }, + "node_modules/layerr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/layerr/-/layerr-3.0.0.tgz", + "integrity": "sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA==", + "license": "MIT" + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -7130,6 +7845,15 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, + "node_modules/luxon": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", + "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.8", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", @@ -7185,6 +7909,17 @@ "node": ">= 0.4" } }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "license": "BSD-3-Clause", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -7316,7 +8051,6 @@ "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.2" @@ -7494,21 +8228,22 @@ "license": "MIT" }, "node_modules/multer": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", - "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz", + "integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", "busboy": "^1.6.0", "concat-stream": "^2.0.0", - "mkdirp": "^0.5.6", - "object-assign": "^4.1.1", - "type-is": "^1.6.18", - "xtend": "^4.0.2" + "type-is": "^1.6.18" }, "engines": { "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/mute-stream": { @@ -7541,6 +8276,12 @@ "dev": true, "license": "MIT" }, + "node_modules/nested-property": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/nested-property/-/nested-property-4.0.0.tgz", + "integrity": "sha512-yFehXNWRs4cM0+dz7QxCd06hTbWbSkV0ISsqBfkntU6TOY4Qm3Q88fRRLOddkGh2Qq6dZvnKVAahfhjcUvLnyA==", + "license": "MIT" + }, "node_modules/node-abort-controller": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", @@ -7548,6 +8289,26 @@ "dev": true, "license": "MIT" }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-emoji": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", @@ -7874,6 +8635,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -7901,6 +8677,12 @@ "dev": true, "license": "MIT" }, + "node_modules/path-posix": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/path-posix/-/path-posix-1.0.0.tgz", + "integrity": "sha512-1gJ0WpNIiYcQydgg3Ed8KzvIqTsDpNwq+cjBCssvBtuTWjEqY1AW+i+OepiEMqDCzyro9B2sLAe4RBPajMYFiA==", + "license": "ISC" + }, "node_modules/path-scurry": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", @@ -8211,6 +8993,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -8339,6 +9127,12 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -9028,6 +9822,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz", + "integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, "node_modules/strtok3": { "version": "10.3.5", "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", @@ -9817,6 +10626,25 @@ "punycode": "^2.1.0" } }, + "node_modules/url-join": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz", + "integrity": "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -9832,6 +10660,20 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -9905,6 +10747,58 @@ "defaults": "^1.0.3" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/webdav": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/webdav/-/webdav-5.10.0.tgz", + "integrity": "sha512-fVPuRLtcduVGvSO7Tn/6TQCzIvI/g6BO/+xPRctCvi/GytYpjn4czxWbh4HsArsdom9qz9BI63k9/v2HBUui1A==", + "license": "MIT", + "dependencies": { + "@buttercup/fetch": "^0.2.1", + "base-64": "^1.0.0", + "byte-length": "^1.0.2", + "entities": "^6.0.1", + "fast-xml-parser": "^5.7.2", + "hot-patcher": "^2.0.1", + "layerr": "^3.0.0", + "md5": "^2.3.0", + "minimatch": "^9.0.9", + "nested-property": "^4.0.0", + "node-fetch": "^3.3.2", + "path-posix": "^1.0.0", + "url-join": "^5.0.0", + "url-parse": "^1.5.10" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/webdav/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -10133,6 +11027,21 @@ } } }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index b30c032..35e3b9d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,6 +21,7 @@ "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", @@ -28,15 +29,20 @@ "@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", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "jsonwebtoken": "^9.0.2", "jwks-rsa": "^3.1.0", + "multer": "^2.0.1", "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": { @@ -45,6 +51,8 @@ "@nestjs/testing": "^10.4.15", "@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/passport": "^1.0.17", "@types/passport-jwt": "^4.0.1", @@ -67,13 +75,19 @@ "typescript": "^5.6.3" }, "jest": { - "moduleFileExtensions": ["js", "json", "ts"], + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], "rootDir": "src", "testRegex": ".*\\.spec\\.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" }, - "collectCoverageFrom": ["**/*.(t|j)s"], + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], "coverageDirectory": "../coverage", "testEnvironment": "node" } diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 8c3469d..112c711 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -9,19 +9,19 @@ datasource db { /// 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 + id String @id @default(cuid()) + name String + inviteCode String @unique + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - gemeinden Gemeinde[] + gemeinden Gemeinde[] memberships Membership[] - wahlen Wahl[] - files File[] - channels ChatChannel[] - guests GuestAccount[] + wahlen Wahl[] + files File[] + channels ChatChannel[] + guests GuestAccount[] } /// A local congregation/community participating in one Kc. @@ -31,7 +31,7 @@ model Gemeinde { kcId String createdAt DateTime @default(now()) - kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) + kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) memberships Membership[] guests GuestAccount[] @@ -46,15 +46,16 @@ enum Role { /// Authentik-backed user (team member with elevated rights). model User { - id String @id @default(cuid()) - authentikSub String @unique - email String @unique - firstName String - lastName String - createdAt DateTime @default(now()) + id String @id @default(cuid()) + authentikSub String @unique + email String @unique + firstName String + lastName String + createdAt DateTime @default(now()) - memberships Membership[] - messages ChatMessage[] + memberships Membership[] + messages ChatMessage[] + chatParticipations ChatParticipant[] } /// Scopes a User's role to a specific Kc (and Gemeinde, if applicable). @@ -83,62 +84,79 @@ model GuestAccount { 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[] + kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) + gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade) + messages ChatMessage[] teilnehmer Teilnehmer[] } /// A workshop election, scoped to a Kc; name carries a date key + "Teil". model Wahl { - id String @id @default(cuid()) - kcId String - name String + id String @id @default(cuid()) + kcId String + name String datumsSchluessel String - teil String - isOpen Boolean @default(true) - createdAt DateTime @default(now()) + teil String + isOpen Boolean @default(true) + createdAt DateTime @default(now()) - kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) - workshops Workshop[] - teilnehmer Teilnehmer[] + 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 + 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[] + 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 + id String @id @default(cuid()) + wahlId String guestAccountId String - prioritaeten Json - createdAt DateTime @default(now()) + 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? + 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]) } -/// Result of the assignment algorithm (or a manual force-assignment) for one Teilnehmer. +/// Manual override set by LT before running the assignment algorithm; takes precedence. +model ForceZuteilung { + id String @id @default(cuid()) + wahlId String + teilnehmerId String @unique + workshopId String + + wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade) + teilnehmer Teilnehmer @relation(fields: [teilnehmerId], references: [id], onDelete: Cascade) + workshop Workshop @relation(fields: [workshopId], references: [id], onDelete: Cascade) +} + +/// Result of the assignment algorithm for one Teilnehmer; workshopId is null if unassigned (no capacity left). model Zuteilung { id String @id @default(cuid()) teilnehmerId String @unique - workshopId String + 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: Cascade) + workshop Workshop? @relation(fields: [workshopId], references: [id], onDelete: SetNull) } enum FileVisibility { @@ -173,19 +191,64 @@ model ChatChannel { gemeindeId String? createdAt DateTime @default(now()) - kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) - messages ChatMessage[] + 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()) + 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) +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index b2755e6..f916aaa 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,15 +1,31 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { ServeStaticModule } from '@nestjs/serve-static'; +import { join } from 'path'; import { PrismaModule } from './prisma/prisma.module'; import { AuthModule } from './auth/auth.module'; import { KcModule } from './kc/kc.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'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), + // Serves the plain static web client from ../client/web; the REST API + // lives under /api (see main.ts) so it never collides with these routes. + ServeStaticModule.forRoot({ + rootPath: join(__dirname, '..', '..', 'client', 'web'), + exclude: ['/api*'], + }), PrismaModule, + SyncModule, AuthModule, KcModule, + WahlModule, + FilesModule, + ChatModule, ], }) export class AppModule {} diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index db06d1f..706b234 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -5,6 +5,8 @@ import { PassportModule } from '@nestjs/passport'; import { AuthController } from './auth.controller'; import { GuestAuthService } from './guest-auth.service'; import { AuthentikStrategy } from './authentik.strategy'; +import { GuestJwtStrategy } from './guest-jwt.strategy'; +import { TokenVerificationService } from './token-verification.service'; @Module({ imports: [ @@ -18,6 +20,7 @@ import { AuthentikStrategy } from './authentik.strategy'; }), ], controllers: [AuthController], - providers: [GuestAuthService, AuthentikStrategy], + providers: [GuestAuthService, AuthentikStrategy, GuestJwtStrategy, TokenVerificationService], + exports: [TokenVerificationService], }) export class AuthModule {} diff --git a/backend/src/auth/authenticated-request.ts b/backend/src/auth/authenticated-request.ts index daec849..aa4ed20 100644 --- a/backend/src/auth/authenticated-request.ts +++ b/backend/src/auth/authenticated-request.ts @@ -1,5 +1,6 @@ import { Request } from 'express'; import { Role } from '../common/role.enum'; +import { GuestJwtPayload } from './guest-auth.service'; export interface AuthenticatedMembership { kcId: string; @@ -18,3 +19,8 @@ export interface AuthenticatedUser { 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; +} diff --git a/backend/src/auth/guest-auth.service.ts b/backend/src/auth/guest-auth.service.ts index eafc7b8..fc6eed6 100644 --- a/backend/src/auth/guest-auth.service.ts +++ b/backend/src/auth/guest-auth.service.ts @@ -1,6 +1,8 @@ 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; @@ -15,6 +17,7 @@ export class GuestAuthService { constructor( private readonly prisma: PrismaClient, private readonly jwt: JwtService, + private readonly sync: SyncService, ) {} async createGuest( @@ -30,6 +33,7 @@ export class GuestAuthService { 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, diff --git a/backend/src/auth/guest-jwt.strategy.ts b/backend/src/auth/guest-jwt.strategy.ts new file mode 100644 index 0000000..d149605 --- /dev/null +++ b/backend/src/auth/guest-jwt.strategy.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ConfigService } from '@nestjs/config'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { GuestJwtPayload } from './guest-auth.service'; + +/// Verifies the local JWT issued to guests/Konfis by GuestAuthService. +/// Kept separate from AuthentikStrategy since guests are never Authentik-backed. +@Injectable() +export class GuestJwtStrategy extends PassportStrategy(Strategy, 'guest') { + constructor(config: ConfigService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: config.getOrThrow('GUEST_JWT_SECRET'), + }); + } + + validate(payload: GuestJwtPayload): GuestJwtPayload { + return payload; + } +} diff --git a/backend/src/auth/token-verification.service.ts b/backend/src/auth/token-verification.service.ts new file mode 100644 index 0000000..374725d --- /dev/null +++ b/backend/src/auth/token-verification.service.ts @@ -0,0 +1,74 @@ +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 { AuthenticatedUser } from './authenticated-request'; +import { GuestJwtPayload } from './guest-auth.service'; + +/// 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; + + constructor( + private readonly config: ConfigService, + private readonly prisma: PrismaClient, + private readonly guestJwt: JwtService, + ) { + this.issuerUrl = config.getOrThrow('AUTHENTIK_ISSUER_URL'); + this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true }); + } + + async verifyAuthentik(token: string): Promise { + 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, + algorithms: ['RS256'], + }) as jwt.JwtPayload; + if (!payload.sub) { + throw new UnauthorizedException('Authentik token missing subject'); + } + + const user = await this.prisma.user.findUnique({ + where: { authentikSub: payload.sub }, + include: { memberships: true }, + }); + if (!user) { + throw new UnauthorizedException('User not provisioned locally yet'); + } + return { + userId: user.id, + authentikSub: user.authentikSub, + email: user.email, + memberships: user.memberships.map((m) => ({ + kcId: m.kcId, + gemeindeId: m.gemeindeId, + role: m.role, + })), + }; + } + + async verifyGuest(token: string): Promise { + return this.guestJwt.verifyAsync(token); + } + + /// Tries Authentik first (team member), then falls back to 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 { + return { kind: 'guest', guest: await this.verifyGuest(token) }; + } + } +} diff --git a/backend/src/chat/caller.util.ts b/backend/src/chat/caller.util.ts new file mode 100644 index 0000000..e40a2d7 --- /dev/null +++ b/backend/src/chat/caller.util.ts @@ -0,0 +1,13 @@ +import { AuthenticatedUser } from '../auth/authenticated-request'; +import { GuestJwtPayload } from '../auth/guest-auth.service'; +import { ChatCaller } from './chat.service'; + +function isGuestPayload(user: unknown): user is GuestJwtPayload { + return !!user && typeof user === 'object' && 'guestId' in user; +} + +/// req.user is either an AuthenticatedUser (Authentik) or a GuestJwtPayload, +/// depending on which strategy AuthGuard(['authentik','guest']) picked. +export function resolveChatCaller(user: AuthenticatedUser | GuestJwtPayload): ChatCaller { + return isGuestPayload(user) ? { kind: 'guest', guest: user } : { kind: 'user', user }; +} diff --git a/backend/src/chat/chat.controller.ts b/backend/src/chat/chat.controller.ts new file mode 100644 index 0000000..75370b1 --- /dev/null +++ b/backend/src/chat/chat.controller.ts @@ -0,0 +1,45 @@ +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. + @Post('direct') + @UseGuards(AuthGuard('authentik')) + createDirectChannel(@Body() dto: CreateDirectChannelDto, @Req() req: AuthenticatedRequest) { + return this.chat.getOrCreateDirectChannel(dto.kcId, req.user!.userId, dto.otherUserId); + } + + @Get(':kcId/channels') + @UseGuards(AuthGuard(['authentik', 'guest'])) + listChannels(@Param('kcId') kcId: string, @Req() req: ChatRequest) { + return this.chat.listChannelsForCaller(kcId, resolveChatCaller(req.user!)); + } + + @Get('channels/:channelId/messages') + @UseGuards(AuthGuard(['authentik', 'guest'])) + listMessages(@Param('channelId') channelId: string, @Req() req: ChatRequest) { + return this.chat.listMessages(channelId, resolveChatCaller(req.user!)); + } +} diff --git a/backend/src/chat/chat.gateway.ts b/backend/src/chat/chat.gateway.ts new file mode 100644 index 0000000..27978a9 --- /dev/null +++ b/backend/src/chat/chat.gateway.ts @@ -0,0 +1,100 @@ +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. +@WebSocketGateway({ path: '/chat' }) +export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { + private readonly logger = new Logger(ChatGateway.name); + private readonly callers = new WeakMap(); + private readonly rooms = new Map>(); + + constructor( + private readonly tokenVerification: TokenVerificationService, + private readonly chat: ChatService, + ) {} + + async handleConnection(client: WebSocket, request: IncomingMessage) { + const token = new URL(request.url ?? '', 'http://localhost').searchParams.get('token'); + if (!token) { + client.close(4001, 'Missing token'); + return; + } + try { + this.callers.set(client, await this.tokenVerification.verifyEither(token)); + } catch (err) { + this.logger.warn(`WS auth failed: ${(err as Error).message}`); + client.close(4001, 'Unauthorized'); + } + } + + 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 = this.requireCaller(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 = this.requireCaller(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 requireCaller(client: WebSocket): ChatCaller { + const caller = this.callers.get(client); + if (!caller) { + client.close(4001, 'Unauthorized'); + throw new Error('Unauthorized WS client'); + } + return caller; + } + + private roomFor(channelId: string): Set { + 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); + } + } + } +} diff --git a/backend/src/chat/chat.module.ts b/backend/src/chat/chat.module.ts new file mode 100644 index 0000000..745ac81 --- /dev/null +++ b/backend/src/chat/chat.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { ChatService } from './chat.service'; +import { ChatGateway } from './chat.gateway'; +import { ChatController } from './chat.controller'; + +@Module({ + imports: [AuthModule], + controllers: [ChatController], + providers: [ChatService, ChatGateway], +}) +export class ChatModule {} diff --git a/backend/src/chat/chat.service.ts b/backend/src/chat/chat.service.ts new file mode 100644 index 0000000..df9ef4d --- /dev/null +++ b/backend/src/chat/chat.service.ts @@ -0,0 +1,165 @@ +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' }, + }); + } +} diff --git a/backend/src/chat/dto/create-channel.dto.ts b/backend/src/chat/dto/create-channel.dto.ts new file mode 100644 index 0000000..c7d37c5 --- /dev/null +++ b/backend/src/chat/dto/create-channel.dto.ts @@ -0,0 +1,11 @@ +import { IsEnum, IsOptional, IsString } from 'class-validator'; +import { ChatChannelType } from '@prisma/client'; + +export class CreateChannelDto { + @IsEnum(ChatChannelType) + type!: ChatChannelType; + + @IsOptional() + @IsString() + gemeindeId?: string; +} diff --git a/backend/src/chat/dto/create-direct-channel.dto.ts b/backend/src/chat/dto/create-direct-channel.dto.ts new file mode 100644 index 0000000..aede567 --- /dev/null +++ b/backend/src/chat/dto/create-direct-channel.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class CreateDirectChannelDto { + @IsString() + @IsNotEmpty() + kcId!: string; + + @IsString() + @IsNotEmpty() + otherUserId!: string; +} diff --git a/backend/src/files/dto/upload-file.dto.ts b/backend/src/files/dto/upload-file.dto.ts new file mode 100644 index 0000000..6c122e8 --- /dev/null +++ b/backend/src/files/dto/upload-file.dto.ts @@ -0,0 +1,7 @@ +import { IsEnum } from 'class-validator'; +import { FileVisibility } from '@prisma/client'; + +export class UploadFileDto { + @IsEnum(FileVisibility) + visibility!: FileVisibility; +} diff --git a/backend/src/files/files.controller.ts b/backend/src/files/files.controller.ts new file mode 100644 index 0000000..c173666 --- /dev/null +++ b/backend/src/files/files.controller.ts @@ -0,0 +1,73 @@ +import { + Body, + Controller, + Get, + Param, + Post, + Req, + Res, + UploadedFile, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { AuthGuard } from '@nestjs/passport'; +import { Response } from 'express'; +import { FilesService } from './files.service'; +import { UploadFileDto } from './dto/upload-file.dto'; +import { Roles } from '../common/roles.decorator'; +import { RolesGuard } from '../common/roles.guard'; +import { Role } from '../common/role.enum'; +import { AuthenticatedRequest } from '../auth/authenticated-request'; +import { GuestJwtPayload } from '../auth/guest-auth.service'; +import { allowedVisibilitiesForUser, GUEST_ALLOWED_VISIBILITIES } from './visibility.util'; + +type FileCallerRequest = AuthenticatedRequest & { user?: AuthenticatedRequest['user'] | GuestJwtPayload }; + +function isGuest(user: unknown): user is GuestJwtPayload { + return !!user && typeof user === 'object' && 'guestId' in user; +} + +@Controller('files') +export class FilesController { + constructor(private readonly files: FilesService) {} + + /// Only the Leitungsteam uploads files (per KC), tagged with a visibility tier. + @Post(':kcId') + @UseGuards(AuthGuard('authentik'), 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', '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', '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); + } +} diff --git a/backend/src/files/files.module.ts b/backend/src/files/files.module.ts new file mode 100644 index 0000000..8cf4b02 --- /dev/null +++ b/backend/src/files/files.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { FilesService } from './files.service'; +import { FilesController } from './files.controller'; +import { STORAGE_PROVIDER } from './storage/storage-provider'; +import { WebDavStorageProvider } from './storage/webdav-storage.provider'; +import { S3StorageProvider } from './storage/s3-storage.provider'; + +@Module({ + controllers: [FilesController], + providers: [ + FilesService, + { + // Nextcloud (WebDAV) is the default target; STORAGE_PROVIDER=s3 switches to S3-compatible storage. + provide: STORAGE_PROVIDER, + inject: [ConfigService], + useFactory: (config: ConfigService) => + config.get('STORAGE_PROVIDER') === 's3' + ? new S3StorageProvider(config) + : new WebDavStorageProvider(config), + }, + ], +}) +export class FilesModule {} diff --git a/backend/src/files/files.service.ts b/backend/src/files/files.service.ts new file mode 100644 index 0000000..74dd265 --- /dev/null +++ b/backend/src/files/files.service.ts @@ -0,0 +1,53 @@ +import { ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { FileVisibility, SyncOperation } from '@prisma/client'; +import { PrismaClient } from '../prisma/prisma.module'; +import { STORAGE_PROVIDER, StorageProvider } from './storage/storage-provider'; +import { SyncService } from '../sync/sync.service'; + +@Injectable() +export class FilesService { + constructor( + private readonly prisma: PrismaClient, + @Inject(STORAGE_PROVIDER) private readonly storage: StorageProvider, + private readonly sync: SyncService, + ) {} + + async upload( + kcId: string, + visibility: FileVisibility, + filename: string, + data: Buffer, + uploadedById: string, + ) { + const storageKey = await this.storage.upload(kcId, filename, data); + const file = await this.prisma.file.create({ + data: { kcId, storageKey, filename, visibility, uploadedById }, + }); + // Note: only metadata is replicated here; storageKey only resolves if + // local and cloud share the same Nextcloud/S3 backend (see sync docs). + await this.sync.capture('File', SyncOperation.CREATE, file.id, file); + return file; + } + + listForCaller(kcId: string, allowedVisibilities: FileVisibility[]) { + return this.prisma.file.findMany({ + where: { kcId, visibility: { in: allowedVisibilities } }, + orderBy: { createdAt: 'desc' }, + }); + } + + async downloadForCaller(fileId: string, allowedVisibilities: FileVisibility[]) { + const file = await this.getFileOrThrow(fileId); + if (!allowedVisibilities.includes(file.visibility)) { + throw new ForbiddenException('Not permitted to access this file'); + } + const data = await this.storage.download(file.storageKey); + return { file, data }; + } + + getFileOrThrow(fileId: string) { + return this.prisma.file.findUniqueOrThrow({ where: { id: fileId } }).catch(() => { + throw new NotFoundException('File not found'); + }); + } +} diff --git a/backend/src/files/storage/s3-storage.provider.ts b/backend/src/files/storage/s3-storage.provider.ts new file mode 100644 index 0000000..4b40687 --- /dev/null +++ b/backend/src/files/storage/s3-storage.provider.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { randomUUID } from 'crypto'; +import { + DeleteObjectCommand, + GetObjectCommand, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; +import { StorageProvider } from './storage-provider'; + +/// S3-compatible object storage (AWS S3, MinIO, etc.). +@Injectable() +export class S3StorageProvider implements StorageProvider { + private readonly client: S3Client; + private readonly bucket: string; + + constructor(config: ConfigService) { + this.bucket = config.getOrThrow('S3_BUCKET'); + this.client = new S3Client({ + region: config.get('S3_REGION') ?? 'auto', + endpoint: config.get('S3_ENDPOINT'), + forcePathStyle: config.get('S3_FORCE_PATH_STYLE') === 'true', + credentials: { + accessKeyId: config.getOrThrow('S3_ACCESS_KEY_ID'), + secretAccessKey: config.getOrThrow('S3_SECRET_ACCESS_KEY'), + }, + }); + } + + async upload(kcId: string, filename: string, data: Buffer): Promise { + 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 { + 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) { + chunks.push(chunk); + } + return Buffer.concat(chunks); + } + + async delete(storageKey: string): Promise { + await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: storageKey })); + } +} diff --git a/backend/src/files/storage/storage-provider.ts b/backend/src/files/storage/storage-provider.ts new file mode 100644 index 0000000..b05ac5c --- /dev/null +++ b/backend/src/files/storage/storage-provider.ts @@ -0,0 +1,10 @@ +/// Abstraction over the external file storage backend (Nextcloud via WebDAV, +/// or S3-compatible object storage). Implementations only need to move raw +/// bytes; visibility/ownership metadata lives in the `File` Prisma model. +export interface StorageProvider { + upload(kcId: string, filename: string, data: Buffer): Promise; + download(storageKey: string): Promise; + delete(storageKey: string): Promise; +} + +export const STORAGE_PROVIDER = Symbol('STORAGE_PROVIDER'); diff --git a/backend/src/files/storage/webdav-storage.provider.ts b/backend/src/files/storage/webdav-storage.provider.ts new file mode 100644 index 0000000..f1210b3 --- /dev/null +++ b/backend/src/files/storage/webdav-storage.provider.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { randomUUID } from 'crypto'; +import { createClient, WebDAVClient } from 'webdav'; +import { StorageProvider } from './storage-provider'; + +/// Nextcloud (or any WebDAV server) as file storage backend. +@Injectable() +export class WebDavStorageProvider implements StorageProvider { + private readonly client: WebDAVClient; + + constructor(config: ConfigService) { + this.client = createClient(config.getOrThrow('WEBDAV_URL'), { + username: config.getOrThrow('WEBDAV_USERNAME'), + password: config.getOrThrow('WEBDAV_PASSWORD'), + }); + } + + async upload(kcId: string, filename: string, data: Buffer): Promise { + 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 { + const content = await this.client.getFileContents(storageKey); + return Buffer.isBuffer(content) ? content : Buffer.from(content as ArrayBuffer); + } + + async delete(storageKey: string): Promise { + await this.client.deleteFile(storageKey); + } +} diff --git a/backend/src/files/visibility.util.ts b/backend/src/files/visibility.util.ts new file mode 100644 index 0000000..5b099b2 --- /dev/null +++ b/backend/src/files/visibility.util.ts @@ -0,0 +1,21 @@ +import { FileVisibility, Role } from '@prisma/client'; +import { AuthenticatedUser } from '../auth/authenticated-request'; + +/// Maps the caller's role for a given KC to the file visibility tiers they may see. +/// LEITUNGSTEAM is global (per RolesGuard convention) and sees everything. +export function allowedVisibilitiesForUser( + user: AuthenticatedUser, + kcId: string, +): FileVisibility[] { + const isLt = user.memberships.some((m) => m.role === Role.LEITUNGSTEAM); + if (isLt) { + return [FileVisibility.ALLE, FileVisibility.ALLE_AUSSER_KONFIS, FileVisibility.NUR_LT]; + } + const isTeamMemberForKc = user.memberships.some((m) => m.kcId === kcId); + if (isTeamMemberForKc) { + return [FileVisibility.ALLE, FileVisibility.ALLE_AUSSER_KONFIS]; + } + return []; +} + +export const GUEST_ALLOWED_VISIBILITIES: FileVisibility[] = [FileVisibility.ALLE]; diff --git a/backend/src/kc/kc.service.ts b/backend/src/kc/kc.service.ts index 366f3b3..a411c99 100644 --- a/backend/src/kc/kc.service.ts +++ b/backend/src/kc/kc.service.ts @@ -1,15 +1,22 @@ 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) {} + constructor( + private readonly prisma: PrismaClient, + private readonly sync: SyncService, + ) {} - createKc(name: string) { - return this.prisma.kc.create({ + 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() { diff --git a/backend/src/main.ts b/backend/src/main.ts index 1ebda52..15e4af3 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,11 +1,14 @@ 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(); diff --git a/backend/src/sync/dto/ingest-entries.dto.ts b/backend/src/sync/dto/ingest-entries.dto.ts new file mode 100644 index 0000000..54cc55a --- /dev/null +++ b/backend/src/sync/dto/ingest-entries.dto.ts @@ -0,0 +1,7 @@ +import { IsArray, IsNotEmpty } from 'class-validator'; + +export class IngestEntriesDto { + @IsArray() + @IsNotEmpty() + entries!: unknown[]; +} diff --git a/backend/src/sync/sync-scheduler.service.ts b/backend/src/sync/sync-scheduler.service.ts new file mode 100644 index 0000000..ea56c4f --- /dev/null +++ b/backend/src/sync/sync-scheduler.service.ts @@ -0,0 +1,32 @@ +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('SYNC_ENABLED') !== 'true') return; + const peerUrl = this.config.get('SYNC_PEER_URL'); + const peerSecret = this.config.get('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}`); + } + } +} diff --git a/backend/src/sync/sync-secret.guard.ts b/backend/src/sync/sync-secret.guard.ts new file mode 100644 index 0000000..3db94ce --- /dev/null +++ b/backend/src/sync/sync-secret.guard.ts @@ -0,0 +1,18 @@ +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(); + const expected = this.config.getOrThrow('SYNC_SHARED_SECRET'); + if (request.headers['x-sync-secret'] !== expected) { + throw new ForbiddenException('Invalid sync secret'); + } + return true; + } +} diff --git a/backend/src/sync/sync.controller.ts b/backend/src/sync/sync.controller.ts new file mode 100644 index 0000000..4a06dfc --- /dev/null +++ b/backend/src/sync/sync.controller.ts @@ -0,0 +1,45 @@ +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'), RolesGuard) + @Roles(Role.LEITUNGSTEAM) + async trigger() { + const peerUrl = this.config.getOrThrow('SYNC_PEER_URL'); + const peerSecret = this.config.getOrThrow('SYNC_SHARED_SECRET'); + const pushed = await this.sync.pushToPeer(peerUrl, peerSecret); + const pulled = await this.sync.pullFromPeer(peerUrl, peerSecret); + return { ...pushed, ...pulled }; + } +} diff --git a/backend/src/sync/sync.module.ts b/backend/src/sync/sync.module.ts new file mode 100644 index 0000000..5e47e6a --- /dev/null +++ b/backend/src/sync/sync.module.ts @@ -0,0 +1,16 @@ +import { Global, Module } from '@nestjs/common'; +import { ScheduleModule } from '@nestjs/schedule'; +import { SyncService } from './sync.service'; +import { SyncController } from './sync.controller'; +import { SyncSchedulerService } from './sync-scheduler.service'; + +/// Global so every feature module can inject SyncService to capture its +/// mutations without each one importing SyncModule explicitly. +@Global() +@Module({ + imports: [ScheduleModule.forRoot()], + controllers: [SyncController], + providers: [SyncService, SyncSchedulerService], + exports: [SyncService], +}) +export class SyncModule {} diff --git a/backend/src/sync/sync.service.ts b/backend/src/sync/sync.service.ts new file mode 100644 index 0000000..094aab4 --- /dev/null +++ b/backend/src/sync/sync.service.ts @@ -0,0 +1,154 @@ +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', + '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; + 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('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; + delete: (args: { where: { id: string } }) => Promise; + }; + } +} diff --git a/backend/src/wahl/dto/create-force-zuteilung.dto.ts b/backend/src/wahl/dto/create-force-zuteilung.dto.ts new file mode 100644 index 0000000..e8eefde --- /dev/null +++ b/backend/src/wahl/dto/create-force-zuteilung.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class CreateForceZuteilungDto { + @IsString() + @IsNotEmpty() + teilnehmerId!: string; + + @IsString() + @IsNotEmpty() + workshopId!: string; +} diff --git a/backend/src/wahl/dto/create-wahl.dto.ts b/backend/src/wahl/dto/create-wahl.dto.ts new file mode 100644 index 0000000..ec995bf --- /dev/null +++ b/backend/src/wahl/dto/create-wahl.dto.ts @@ -0,0 +1,19 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class CreateWahlDto { + @IsString() + @IsNotEmpty() + kcId!: string; + + @IsString() + @IsNotEmpty() + name!: string; + + @IsString() + @IsNotEmpty() + datumsSchluessel!: string; + + @IsString() + @IsNotEmpty() + teil!: string; +} diff --git a/backend/src/wahl/dto/create-workshop.dto.ts b/backend/src/wahl/dto/create-workshop.dto.ts new file mode 100644 index 0000000..a3a4362 --- /dev/null +++ b/backend/src/wahl/dto/create-workshop.dto.ts @@ -0,0 +1,15 @@ +import { IsInt, IsNotEmpty, IsString, Min } from 'class-validator'; + +export class CreateWorkshopDto { + @IsString() + @IsNotEmpty() + name!: string; + + @IsInt() + @Min(1) + kapazitaet!: number; + + @IsInt() + @Min(0) + minTeilnehmer: number = 0; +} diff --git a/backend/src/wahl/dto/submit-teilnehmer.dto.ts b/backend/src/wahl/dto/submit-teilnehmer.dto.ts new file mode 100644 index 0000000..b825067 --- /dev/null +++ b/backend/src/wahl/dto/submit-teilnehmer.dto.ts @@ -0,0 +1,11 @@ +import { ArrayMaxSize, ArrayNotEmpty, IsArray, IsString } from 'class-validator'; + +/// Ordered workshop-id preferences, most preferred first (up to 3, matching +/// the original plugin's wunsch1..wunsch3). +export class SubmitTeilnehmerDto { + @IsArray() + @ArrayNotEmpty() + @ArrayMaxSize(3) + @IsString({ each: true }) + prioritaeten!: string[]; +} diff --git a/backend/src/wahl/wahl.controller.ts b/backend/src/wahl/wahl.controller.ts new file mode 100644 index 0000000..f222678 --- /dev/null +++ b/backend/src/wahl/wahl.controller.ts @@ -0,0 +1,107 @@ +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'), RolesGuard) + @Roles(Role.LEITUNGSTEAM) + createWahl(@Body() dto: CreateWahlDto) { + return this.wahl.createWahl(dto.kcId, dto.name, dto.datumsSchluessel, dto.teil); + } + + @Get() + @UseGuards(AuthGuard('authentik'), RolesGuard) + @Roles(Role.LEITUNGSTEAM) + listWahlen(@Query('kcId') kcId: string) { + return this.wahl.listWahlen(kcId); + } + + @Post(':wahlId/workshops') + @UseGuards(AuthGuard('authentik'), 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'), RolesGuard) + @Roles(Role.LEITUNGSTEAM) + listWorkshops(@Param('wahlId') wahlId: string) { + return this.wahl.listWorkshops(wahlId); + } + + @Post(':wahlId/force-zuteilung') + @UseGuards(AuthGuard('authentik'), RolesGuard) + @Roles(Role.LEITUNGSTEAM) + createForceZuteilung( + @Param('wahlId') wahlId: string, + @Body() dto: CreateForceZuteilungDto, + ) { + return this.wahl.createForceZuteilung(wahlId, dto.teilnehmerId, dto.workshopId); + } + + /// 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'), RolesGuard) + @Roles(Role.LEITUNGSTEAM) + runZuteilung(@Param('wahlId') wahlId: string) { + return this.zuteilung.run(wahlId); + } + + @Get(':wahlId/zuteilung') + @UseGuards(AuthGuard('authentik'), RolesGuard) + @Roles(Role.LEITUNGSTEAM) + getZuteilung(@Param('wahlId') wahlId: string) { + return this.zuteilung.getResults(wahlId); + } + + @Get(':wahlId/zuteilung/csv') + @UseGuards(AuthGuard('authentik'), 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); + } +} diff --git a/backend/src/wahl/wahl.module.ts b/backend/src/wahl/wahl.module.ts new file mode 100644 index 0000000..8d655ca --- /dev/null +++ b/backend/src/wahl/wahl.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { WahlService } from './wahl.service'; +import { ZuteilungService } from './zuteilung.service'; +import { WahlController } from './wahl.controller'; + +@Module({ + providers: [WahlService, ZuteilungService], + controllers: [WahlController], +}) +export class WahlModule {} diff --git a/backend/src/wahl/wahl.service.ts b/backend/src/wahl/wahl.service.ts new file mode 100644 index 0000000..290c698 --- /dev/null +++ b/backend/src/wahl/wahl.service.ts @@ -0,0 +1,93 @@ +import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { SyncOperation } from '@prisma/client'; +import { PrismaClient } from '../prisma/prisma.module'; +import { SyncService } from '../sync/sync.service'; + +@Injectable() +export class WahlService { + constructor( + private readonly prisma: PrismaClient, + private readonly sync: SyncService, + ) {} + + async createWahl(kcId: string, name: string, datumsSchluessel: string, teil: string) { + const wahl = await this.prisma.wahl.create({ + data: { kcId, name, datumsSchluessel, teil }, + }); + await this.sync.capture('Wahl', SyncOperation.CREATE, wahl.id, wahl); + return wahl; + } + + listWahlen(kcId: string) { + return this.prisma.wahl.findMany({ where: { kcId } }); + } + + async 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; + } +} diff --git a/backend/src/wahl/zuteilung.service.ts b/backend/src/wahl/zuteilung.service.ts new file mode 100644 index 0000000..b1e4baa --- /dev/null +++ b/backend/src/wahl/zuteilung.service.ts @@ -0,0 +1,212 @@ +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(); + + 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 { + 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, + caps: Map, +) { + const countByWorkshop = new Map(); + 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(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, exclude?: Set): 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]; +} diff --git a/client/web/app.js b/client/web/app.js new file mode 100644 index 0000000..8721ce9 --- /dev/null +++ b/client/web/app.js @@ -0,0 +1,92 @@ +const state = { token: null, kcId: null, socket: null }; + +const $ = (id) => document.getElementById(id); + +function decodeJwtPayload(token) { + try { + const [, payload] = token.split('.'); + return JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/'))); + } catch { + return null; + } +} + +$('guest-form').addEventListener('submit', async (event) => { + event.preventDefault(); + const inviteCode = $('invite-code').value; + const firstName = $('first-name').value; + const lastName = $('last-name').value; + + const res = await fetch('/api/auth/guest', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ inviteCode, firstName, lastName }), + }); + + if (!res.ok) { + $('login-status').textContent = `Fehler: ${res.status}`; + return; + } + + const { accessToken } = await res.json(); + state.token = accessToken; + state.kcId = decodeJwtPayload(accessToken)?.kcId ?? null; + $('login-status').textContent = 'Angemeldet.'; + $('login-section').hidden = true; + $('app-section').hidden = false; +}); + +$('submit-wahl').addEventListener('click', async () => { + const wahlId = $('wahl-id').value; + const prioritaeten = $('prioritaeten') + .value.split(',') + .map((s) => s.trim()) + .filter(Boolean); + + const res = await fetch(`/api/wahl/${wahlId}/teilnehmer`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${state.token}`, + }, + body: JSON.stringify({ prioritaeten }), + }); + $('wahl-status').textContent = res.ok ? 'Gespeichert.' : `Fehler: ${res.status}`; +}); + +$('load-files').addEventListener('click', async () => { + if (!state.kcId) return; + const res = await fetch(`/api/files/${state.kcId}`, { + headers: { Authorization: `Bearer ${state.token}` }, + }); + const files = res.ok ? await res.json() : []; + const list = $('file-list'); + list.innerHTML = ''; + for (const file of files) { + const li = document.createElement('li'); + li.textContent = file.filename; + list.appendChild(li); + } +}); + +$('join-channel').addEventListener('click', () => { + const channelId = $('channel-id').value; + if (!channelId || !state.token) return; + + if (state.socket) state.socket.close(); + const protocol = location.protocol === 'https:' ? 'wss' : 'ws'; + const socket = new WebSocket(`${protocol}://${location.host}/chat?token=${state.token}`); + state.socket = socket; + + socket.addEventListener('open', () => { + socket.send(JSON.stringify({ event: 'chat:join', data: { channelId } })); + }); + + socket.addEventListener('message', (event) => { + const { event: name, data } = JSON.parse(event.data); + if (name !== 'chat:message') return; + const li = document.createElement('li'); + li.textContent = data.body; + $('chat-log').appendChild(li); + }); +}); diff --git a/client/web/index.html b/client/web/index.html new file mode 100644 index 0000000..ba98f16 --- /dev/null +++ b/client/web/index.html @@ -0,0 +1,51 @@ + + + + + + KC-App + + + +
+

KC-App

+

Web-Client (Platzhalter, bis der Flutter-Client bereitsteht)

+
+ +
+
+

Guest/Konfi-Zugang

+
+ + + + +
+

+
+ + +
+ + + + diff --git a/client/web/style.css b/client/web/style.css new file mode 100644 index 0000000..d0733d7 --- /dev/null +++ b/client/web/style.css @@ -0,0 +1,62 @@ +body { + font-family: system-ui, sans-serif; + max-width: 640px; + margin: 2rem auto; + padding: 0 1rem; + color: #1a1a1a; +} + +header { + margin-bottom: 2rem; +} + +.subtitle { + color: #666; + font-size: 0.9rem; +} + +section { + margin-bottom: 2rem; +} + +form, +#app-section > div { + display: flex; + flex-direction: column; + gap: 0.5rem; + max-width: 360px; + margin-bottom: 1rem; +} + +label { + display: flex; + flex-direction: column; + font-size: 0.9rem; + gap: 0.25rem; +} + +input { + padding: 0.4rem; + font-size: 1rem; +} + +button { + padding: 0.5rem; + cursor: pointer; +} + +#chat-log, +#file-list { + list-style: none; + padding: 0; + border: 1px solid #ddd; + border-radius: 4px; + max-height: 200px; + overflow-y: auto; +} + +#chat-log li, +#file-list li { + padding: 0.4rem 0.6rem; + border-bottom: 1px solid #eee; +} diff --git a/plan-kcAppMultiTenantPlatform.prompt.md b/plan-kcAppMultiTenantPlatform.prompt.md index aea0df7..397b934 100644 --- a/plan-kcAppMultiTenantPlatform.prompt.md +++ b/plan-kcAppMultiTenantPlatform.prompt.md @@ -1,37 +1,115 @@ # Plan: KC-App – Multi-Tenant Event-, Wahl- und Kommunikationsplattform -Neuentwicklung, die das WordPress-Plugin **Workshop-Wahlen** (Wahlen/Workshops/Teilnehmer/Zuteilungslogik) ablöst und um Rollen-/Rechteverwaltung via Authentik, gestaffelte Dateifreigabe, mehrstufigen Chat und eine Hybrid-Server-Architektur (online + lokal mit Sync) erweitert. Backend: **NestJS + PostgreSQL + Prisma**. Client: **Flutter** (eine Codebase für Mobile, Web, Desktop). +Neuentwicklung, die das WordPress-Plugin **Workshop-Wahlen** (Wahlen/Workshops/Teilnehmer/Zuteilungslogik) ablöst und um Rollen-/Rechteverwaltung via Authentik, gestaffelte Dateifreigabe, mehrstufigen Chat und eine Hybrid-Server-Architektur (online + lokal mit Sync) erweitert. Backend: **NestJS 10 + PostgreSQL + Prisma 5**. Client: **Flutter** (eine Codebase für Mobile, Web, Desktop) — bis Flutter verfügbar ist, liefert das Backend selbst einen minimalen Platzhalter-Web-Client aus. -**Domänenmodell** -- **KC** (Konfi-Castle-Event) = oberster Mandant. Eine App-Instanz verwaltet mehrere KCs parallel. -- Rollen: **Leitungsteam** (global über alle KCs, Authentik-Gruppe) > **Gemeinde Verantwortliche** (pro Gemeinde/KC, Authentik, verwalten nur eigene Teamer) > **Gemeinde Teamer** (von Verantwortlichen angelegt, Authentik) > **Guest/Konfi** (optionaler lokaler Account auf dem Server, Vor-/Nachname Pflicht, temporär pro KC, kein Authentik). -- Einstieg über KC-Code/QR: gewährt Guest-Zugang oder Vorregistrierung als Verantwortlicher/Teamer einer Gemeinde. -- Wahlen werden vom LT pro KC angelegt (Name mit Datumsschlüssel + "Teil"). -- Dateien: Sichtbarkeitsstufen alle / alle außer Konfis / nur LT. -- Chat: Gruppenchat pro Gemeinde, 1:1-DMs, LT-übergreifende Kanäle, Broadcast (read-only für Konfis), Push via FCM/APNs. -- Server grundsätzlich online (Cloud); zusätzlich lokaler On-Site-Server pro Event, wird von Clients automatisch bevorzugt wenn im lokalen Netz erreichbar, ist während des Events alleinige Quelle der Wahrheit, synchronisiert danach mit Cloud (keine echten Schreibkonflikte durch dieses Design). +> Status (Stand dieser Session): **Alle geplanten Backend-Phasen (0–6) sind implementiert und verifiziert** (Typecheck, Build, Boot-Test). Offen ist ausschließlich der Flutter-Client (Mobile/Desktop), da Flutter in dieser Umgebung nicht installiert ist. -**Phasen** (jede unabhängig verifizierbar, Reihenfolge = Abhängigkeit; Phase 6 kann parallel zu 2–5 starten, sobald API-Verträge aus Phase 0/1 stehen) +--- -1. **Fundament** – Monorepo-Skeleton (backend/, client/, shared contracts), Datenmodell (KC, Gemeinde, User, Membership, Wahl, Workshop, Teilnehmer/Zuteilung, ChatChannel/Message, File+Visibility, InviteCode/QR), Authentik-OIDC-Integration + Authentik-Admin-API-Client für Provisionierung. -2. **Multi-Tenancy & Auth** – Invite/QR-Code-Fluss (KC-Key → Guest oder Vorregistrierung), Permission-Guards je Rolle/Scope, Guest-Login (Name-Pflicht, temporär). -3. **Workshop-Wahl-Engine** – Portierung von Wahlen/Workshops/Teilnehmer/Zuteilungslogik (inkl. Force-Zuteilung, Kapazitätsprüfung, CSV-Export) aus dem WP-Plugin; LT-Verwaltung pro KC; Konfi-Formular + Ergebnisanzeige im Client. *depends on 1–2* -4. **Dateifreigabe** – Speicher-Abstraktion über Nextcloud/S3, Sichtbarkeitsstufen, LT-Upload-Verwaltung. *depends on 1–2, parallel mit 3* -5. **Kommunikation** – Gruppenchat/DM/LT-Kanäle/Broadcast, WebSocket-Transport, Push-Integration. *depends on 1–2, parallel mit 3–4* -6. **Hybrid Lokal/Cloud-Server & Sync** – gleiche Backend-Software als Cloud- oder Vor-Ort-Instanz deploybar, Client-seitige Auto-Discovery des lokalen Servers, Append-only-Change-Log-Sync, lokaler Server = alleinige Quelle der Wahrheit während Live-Events. *depends on 1–5 stabil* -7. **Flutter-Clients** – gemeinsame Codebase; Screens: Invite/Login, rollenspezifisches Dashboard, Wahl-Formular/Ergebnis, Dateien, Chat, Admin/Nutzerverwaltung. *iterativ parallel zu 3–6, sobald jeweilige API-Verträge stehen* +## 1. Domänenmodell + +- **KC** (Konfi-Castle-Event) = oberster Mandant. Eine App-Instanz verwaltet mehrere KCs parallel. Felder: `name`, `inviteCode` (eindeutig, Basis für QR/Code-Einstieg), `isActive`. +- **Gemeinde**: lokale Gemeinde/Kirchengemeinde innerhalb eines KC (`kcId` + `name`, eindeutig pro KC). +- **Rollenmodell** (Enum `Role`, Authentik-gestützt): + - **Leitungsteam (LT)** – global über alle KCs hinweg (Authentik-Gruppe); bleibt LT auf jedem KC, bis die Authentik-Gruppenmitgliedschaft entfernt wird. + - **Gemeinde Verantwortliche** – pro Gemeinde/KC; verwalten **nur Nutzer ihrer eigenen Gemeinde** (legen Teamer an); Wahlen/Workshops macht ausschließlich LT. + - **Gemeinde Teamer** – von Verantwortlichen angelegt, ebenfalls Authentik-Account. + - **Guest/Konfi** – optionaler, rein lokaler Account auf dem jeweiligen Server (kein Authentik), Vor-/Nachname Pflicht, **temporär pro KC/Event** (nicht wiederverwendbar über mehrere Events). +- **Membership**: verknüpft `User` (Authentik) ⇄ `Kc` (+ optional `Gemeinde`) ⇄ `Role`. LT-Memberships lassen `gemeindeId` leer und gelten global. +- **Einstieg/Onboarding**: LT vergibt pro KC einen Code/QR (enthält den KC-Key). Damit erhält man sofortigen Guest-Zugang **oder** kann sich vorab als Gemeinde Verantwortlicher/Teamer einer Gemeinde registrieren. +- **Wahl** (Workshop-Wahl): von LT pro KC angelegt; Name trägt `datumsSchluessel` + `teil` (Bewusste Vereinfachung ggü. Original-Plugin: dort gibt es mehrere "Phasen" *innerhalb* einer Wahl via `Teilnehmer.phase`; hier ist stattdessen **eine Wahl = ein Teil/Phase**, gemäß expliziter Nutzer-Klarstellung). + - **Workshop**: `kapazitaet`, `minTeilnehmer` (für Konsolidierung unterbesetzter Workshops). + - **Teilnehmer**: Guest übermittelt `prioritaeten` (geordnete Workshop-ID-Liste, max. 3 – entspricht wunsch1..wunsch3 im Original). + - **ForceZuteilung**: manuelle LT-Override vor Algorithmus-Lauf, hat Vorrang. + - **Zuteilung**: Ergebnis pro Teilnehmer (`workshopId` nullable = unzugeteilt, `wunschRang`, `isForced`). +- **Datei-Sichtbarkeit** (Enum `FileVisibility`): `ALLE` / `ALLE_AUSSER_KONFIS` / `NUR_LT`. Dateien werden vom LT hochgeladen, teilbar je nach KC-übergreifend/eingeschränkt gemäß Sichtbarkeitsstufe. +- **Chat** (Enum `ChatChannelType`): `GEMEINDE_GRUPPE`, `DIREKT` (1:1, explizite `ChatParticipant`-Zuordnung), `LT_UEBERGREIFEND`, `BROADCAST` (Konfis nur lesend). +- **Sync-Infrastruktur**: `SyncLogEntry` (Append-only-Replikationslog: `model`, `recordId`, `operation`, `payload`, `originId`, autoincrement `sequence`) + `SyncCursor` (pro Peer: `lastPushedSequence`/`lastPulledSequence`). + +Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.prisma). + +--- + +## 2. Architekturentscheidungen + +| Bereich | Entscheidung | Begründung | +|---|---|---| +| Backend | NestJS 10 + PostgreSQL + Prisma 5 | bestätigt vom Nutzer; Nest 10 statt CLI-Default (siehe unten) | +| Client | Flutter, eine Codebase Mobile/Web/Desktop | vom Nutzer delegiert; noch nicht scaffoldbar (Flutter fehlt lokal) | +| Web-Interimslösung | Backend liefert `client/web/` (reines HTML/CSS/JS, kein Build-Schritt) über `ServeStaticModule` aus; REST-API liegt unter `/api/*` | Nutzerwunsch: "Server soll auch Web-Client bereitstellen"; vermeidet Kollision zwischen API-Routen und statischen Dateien | +| Auth (Team) | Authentik als OIDC Resource Server (JWKS-Verifikation), kein lokaler Autorisierungscode-Flow im Backend | Clients machen Authorization Code + PKCE direkt gegen Authentik; Backend validiert nur Access Token + löst lokale `Membership` auf | +| Auth (Guest) | Rein lokale JWTs (`GUEST_JWT_SECRET`), kein Authentik | explizite Nutzervorgabe: Konfi-Accounts sind nie in Authentik | +| Datei-Storage | Provider-Abstraktion (`StorageProvider`), Default **Nextcloud/WebDAV**, umschaltbar auf S3 via `STORAGE_PROVIDER=s3` | Nutzer bestätigte: Nextcloud-Zugangsdaten kommen aus `.env` | +| Chat-Transport | Raw `ws`-Gateway (`@nestjs/platform-ws`) statt Socket.IO | Passt zum schlanken REST-Stack; Rollen/Sichtbarkeits-Logik zentral in `ChatService`, geteilt zwischen REST und WS | +| Sync-Richtung | **Lokaler Server initiiert immer** Push *und* Pull gegen die Cloud-URL | Cloud kann i. d. R. nicht in ein lokales Eventnetzwerk zurückwählen (NAT); lokaler Server kann aber ausgehend zur Cloud verbinden, wenn Internet verfügbar ist | +| Sync-Konflikte | Keine Konfliktauflösung nötig | Nutzer bestätigte explizit: lokaler Server ist während eines laufenden Events alleinige Quelle der Wahrheit | +| Rollen-Scope-Guard | `RolesGuard` behandelt `LEITUNGSTEAM`-Memberships als global (kcId-Check wird übersprungen) | Spiegelt die Anforderung "LT bleibt LT auf allen KCs" direkt in der Autorisierungslogik | + +### Bekannte Einschränkungen / offene Punkte +- **Datei-Bytes werden nicht repliziert** – nur Metadaten (inkl. `storageKey`) laufen durchs Sync-Log. Lokaler und Cloud-Server müssen denselben Nextcloud/S3-Backend-Zugriff haben, damit ein `storageKey` auf beiden Seiten auflösbar ist. +- **Push-Benachrichtigungen (FCM/APNs)** sind im Plan vorgesehen, aber noch nicht implementiert (Teil der noch ausstehenden Flutter-Client-Arbeit). +- **Gemeinde-Verwaltung (CRUD)** existiert aktuell nur als Datenmodell; es gibt noch keinen eigenen `GemeindeController` zum Anlegen/Verwalten von Gemeinden durch LT (bisher nur implizit über Membership/GuestAccount referenziert). Sollte vor dem Produktivbetrieb ergänzt werden. +- **Authentik-Provisionierung**: Wenn ein Gemeinde Verantwortlicher einen Teamer anlegt, muss dieser aktuell weiterhin manuell (oder über eine noch zu bauende Authentik-Admin-API-Integration) in Authentik angelegt werden — das Backend erwartet, dass der `User` mit passendem `authentikSub` bereits existiert, bevor er sich einloggen kann. + +--- + +## 3. Umgesetzte Backend-Module (Stand: alle Phasen abgeschlossen) + +| Modul | Kernfunktion | Wichtige Endpunkte | +|---|---|---| +| `prisma/` | Geteilter `PrismaClient`-Provider | – | +| `auth/` | Authentik-Resource-Server-Strategie (`AuthGuard('authentik')`), Guest-Login (`AuthGuard('guest')`), `TokenVerificationService` für manuelle Verifikation außerhalb des HTTP/Passport-Pfads (WS-Handshake) | `POST /api/auth/guest` | +| `kc/` | KC-Verwaltung, Leitungsteam-only | `POST /api/kc`, `GET /api/kc` | +| `wahl/` | Wahl-/Workshop-Verwaltung, Force-Zuteilung, Teilnehmer-Einreichung, `ZuteilungService` (Portierung von `kc_run_zuteilung`: Force-Zuteilungen → 3 Wunschrunden → Zufallsfüllung → Konsolidierung unterbesetzter Workshops), CSV-Export | `POST/GET /api/wahl`, `POST/GET /api/wahl/:id/workshops`, `POST /api/wahl/:id/force-zuteilung`, `POST /api/wahl/:id/teilnehmer` (Guest), `POST /api/wahl/:id/zuteilung/run`, `GET /api/wahl/:id/zuteilung(.csv)` | +| `files/` | Upload (LT-only, multipart) mit Sichtbarkeitsstufe; Liste/Download für Team oder Guest, gefiltert nach erlaubten Sichtbarkeitsstufen; `StorageProvider`-Abstraktion (WebDAV/Nextcloud Default, S3 optional) | `POST /api/files/:kcId`, `GET /api/files/:kcId`, `GET /api/files/download/:fileId` | +| `chat/` | Zentrale Zugriffslogik (`ChatService`) geteilt zwischen REST (`ChatController`) und WS (`ChatGateway`, Pfad `/chat`, eigene Token-Verifikation via `?token=`); Kanaltypen wie oben | `POST /api/chat/:kcId/channels`, `POST /api/chat/direct`, `GET /api/chat/:kcId/channels`, `GET /api/chat/channels/:id/messages`, WS-Events `chat:join`/`chat:send`/`chat:message` | +| `sync/` | Append-only Replikationslog + Peer-Sync (lokal ⇄ Cloud), `SyncSchedulerService` (alle 30s, wenn `SYNC_ENABLED=true`) | `POST /api/sync/ingest`, `GET /api/sync/export`, `POST /api/sync/trigger` (LT-only) | +| `common/` | `Role`-Enum, `@Roles()`-Decorator, `RolesGuard` (KC-scoped, LT global) | – | +| Web-Client-Hosting | `ServeStaticModule` liefert `client/web/` aus; API unter globalem Prefix `/api` | `GET /` (index.html), `/app.js`, `/style.css` | + +Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/README.md). + +--- + +## 4. Tech-Stack-Stolpersteine (dokumentiert für Nachvollziehbarkeit) + +- `npx @nestjs/cli new` mit aktuellen Defaults (Nest v12-Beta, ESM, Vitest, `@nestjs/observe`) löste einen reproduzierbaren npm-Arborist-Bug aus (`Cannot read properties of null (reading 'edgesOut')`). Workaround: `backend/package.json` wurde von Hand mit gepinnten, stabilen Versionen (Nest 10.x, Jest, CommonJS, TypeScript 5.x) erstellt statt über den CLI-Generator. +- Bei zusätzlichen offiziellen `@nestjs/*`-Paketen (`serve-static`, `schedule`) wurden die Peer-Dependencies vor der Installation geprüft (`npm view @ peerDependencies`), da die jeweils neuesten Majors bereits Nest 11/12 voraussetzen und sonst mit `ERESOLVE` fehlschlagen. Gepinnt: `@nestjs/serve-static@4.0.2`, `@nestjs/schedule@4.1.1`. +- `multer` wurde von 1.x (bekannte CVEs) auf 2.x aktualisiert. + +--- + +## 5. Phasenübersicht (Referenz, ursprüngliche Reihenfolge) + +1. **Fundament** – Monorepo-Skeleton, Datenmodell, Authentik-OIDC-Integration. ✅ +2. **Multi-Tenancy & Auth** – Invite/QR-Code-Fluss, Permission-Guards, Guest-Login. ✅ +3. **Workshop-Wahl-Engine** – Wahlen/Workshops/Zuteilungslogik/CSV-Export. ✅ +4. **Dateifreigabe** – Storage-Abstraktion, Sichtbarkeitsstufen. ✅ +5. **Kommunikation** – Chat (Gruppen/DM/LT/Broadcast), WebSocket. ✅ (Push-Integration noch offen) +6. **Hybrid Lokal/Cloud-Server & Sync** – Replikationslog, Scheduler, Shared-Secret-Auth. ✅ +7. **Flutter-Clients** – gemeinsame Codebase; Screens: Invite/Login, rollenspezifisches Dashboard, Wahl-Formular/Ergebnis, Dateien, Chat, Admin/Nutzerverwaltung. ❌ **offen** (Flutter nicht installiert); Web-Interimslösung siehe Abschnitt 3. + +--- + +## 6. Relevante Referenz -**Relevante Referenz** - WP-Plugin als fachliche Vorlage für Zuteilungslogik: `includes/zuteilungslogik.php` (`kc_run_zuteilung`), Admin-Module `admin-wahlen.php`, `admin-workshops.php`, `admin-teilnehmer.php`, `admin-teamer.php`, `admin-zuteilungen.php`, Frontend-Shortcodes in `frontend-form.php`/`frontend-ergebnis.php` (git.konfi-castle.com/linus/Workshop-Wahlen). -**Verifikation** -1. Nach Phase 1: Login-Flow testbar (LT via Authentik, Guest via KC-Code), Rechte-Guards per Integrationstests. -2. Nach Phase 3: Zuteilungslogik mit Testdaten gegen bekannte Ergebnisse aus dem alten Plugin validieren. -3. Nach Phase 6: Sync-Test — Änderungen am lokalen Server während simuliertem Offline-Zustand, danach Cloud-Abgleich prüfen. -4. Ende-zu-Ende: Rollenmatrix (LT/Verantwortlicher/Teamer/Guest) manuell in allen Kernfeatures durchspielen. +--- -**Entscheidungen** -- Backend: NestJS + PostgreSQL + Prisma (bestätigt). -- Client: Flutter, eine Codebase für Mobile/Web/Desktop (auf Wunsch des Nutzers von mir entschieden). -- Zuteilungen-Konflikte: kein echtes Konfliktmodell nötig, da lokaler Server während Events alleinige Quelle der Wahrheit ist. -- WP-Plugin wird vollständig abgelöst, nicht weiterverwendet (nur als fachliche Vorlage). +## 7. Verifikation (durchgeführt je Phase) + +1. Nach jeder Phase: `npx tsc -p tsconfig.build.json --noEmit`, `npx nest build`, sowie ein kurzer Boot-Test (`node dist/main.js`) zur Prüfung, dass der DI-Graph auflöst und alle Routen korrekt gemappt werden (ohne Live-DB/Authentik/Nextcloud). +2. Sync-Modul: manuell verifiziert, dass `SyncSecretGuard` Requests ohne `x-sync-secret` mit 403 ablehnt und mit korrektem Secret durchlässt (DB-Fehler in der Sandbox ist erwartet, da kein Postgres läuft). +3. Web-Client: `GET /` liefert die statische Seite (200), `GET /api/kc` trifft die echte, geschützte API (401 ohne Token). +4. Noch ausstehend (sobald echte Infrastruktur verfügbar ist): Zuteilungslogik gegen bekannte Testdaten aus dem alten Plugin validieren; Ende-zu-Ende-Rollenmatrix (LT/Verantwortlicher/Teamer/Guest) über alle Kernfeatures; echter Sync-Test zwischen zwei laufenden Instanzen (lokal + Cloud). + +--- + +## 8. Nächste Schritte + +1. Sobald Flutter verfügbar ist: Client-Grundgerüst aufsetzen (Mobile + Web + Desktop, eine Codebase), beginnend mit Invite/Login-Flow gegen die bestehende API. +2. `GemeindeController` ergänzen (LT-CRUD für Gemeinden), da bisher nur das Datenmodell existiert. +3. Authentik-Admin-API-Integration für automatische Teamer-Provisionierung durch Gemeinde Verantwortliche. +4. Push-Benachrichtigungen (FCM/APNs) für Chat/Ankündigungen. +5. Echte Infrastruktur (Postgres, Authentik, Nextcloud/S3) aufsetzen und die in Abschnitt 7 offenen Verifikationsschritte durchführen. From 100f5bc2aff03af3ee156ad2f8ef0b4a986a6261 Mon Sep 17 00:00:00 2001 From: linus Date: Wed, 9 Sep 2026 16:25:14 +0200 Subject: [PATCH 02/37] feat(backend): add GemeindeController for LT congregation CRUD Fills the plan's known gap where Gemeinde existed only as a Prisma model. GemeindeModule exposes Leitungsteam-only create/list/get/update/delete under /api/gemeinde, each mutation captured into the sync log like the other feature services. Unique-name-per-KC violations surface as 409. Docs (plan + backend README) updated to drop the gap and next-step item. Co-Authored-By: Claude Sonnet 5 --- backend/README.md | 4 + backend/src/app.module.ts | 2 + .../src/gemeinde/dto/create-gemeinde.dto.ts | 11 +++ .../src/gemeinde/dto/update-gemeinde.dto.ts | 7 ++ backend/src/gemeinde/gemeinde.controller.ts | 53 ++++++++++++ backend/src/gemeinde/gemeinde.module.ts | 9 +++ backend/src/gemeinde/gemeinde.service.ts | 81 +++++++++++++++++++ plan-kcAppMultiTenantPlatform.prompt.md | 10 +-- 8 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 backend/src/gemeinde/dto/create-gemeinde.dto.ts create mode 100644 backend/src/gemeinde/dto/update-gemeinde.dto.ts create mode 100644 backend/src/gemeinde/gemeinde.controller.ts create mode 100644 backend/src/gemeinde/gemeinde.module.ts create mode 100644 backend/src/gemeinde/gemeinde.service.ts diff --git a/backend/README.md b/backend/README.md index 93b5912..6d347c3 100644 --- a/backend/README.md +++ b/backend/README.md @@ -36,6 +36,10 @@ client's host - no separate web server is needed. - `auth/` — Authentik resource-server strategy (`AuthGuard('authentik')`) + guest invite-code login issuing a locally-signed JWT (`AuthGuard('guest')`). - `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. - `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 → diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index f916aaa..3ddb730 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -5,6 +5,7 @@ import { join } from 'path'; import { PrismaModule } from './prisma/prisma.module'; import { AuthModule } from './auth/auth.module'; import { KcModule } from './kc/kc.module'; +import { GemeindeModule } from './gemeinde/gemeinde.module'; import { WahlModule } from './wahl/wahl.module'; import { FilesModule } from './files/files.module'; import { ChatModule } from './chat/chat.module'; @@ -23,6 +24,7 @@ import { SyncModule } from './sync/sync.module'; SyncModule, AuthModule, KcModule, + GemeindeModule, WahlModule, FilesModule, ChatModule, diff --git a/backend/src/gemeinde/dto/create-gemeinde.dto.ts b/backend/src/gemeinde/dto/create-gemeinde.dto.ts new file mode 100644 index 0000000..e6def00 --- /dev/null +++ b/backend/src/gemeinde/dto/create-gemeinde.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class CreateGemeindeDto { + @IsString() + @IsNotEmpty() + kcId!: string; + + @IsString() + @IsNotEmpty() + name!: string; +} diff --git a/backend/src/gemeinde/dto/update-gemeinde.dto.ts b/backend/src/gemeinde/dto/update-gemeinde.dto.ts new file mode 100644 index 0000000..7714ffd --- /dev/null +++ b/backend/src/gemeinde/dto/update-gemeinde.dto.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class UpdateGemeindeDto { + @IsString() + @IsNotEmpty() + name!: string; +} diff --git a/backend/src/gemeinde/gemeinde.controller.ts b/backend/src/gemeinde/gemeinde.controller.ts new file mode 100644 index 0000000..5f404ef --- /dev/null +++ b/backend/src/gemeinde/gemeinde.controller.ts @@ -0,0 +1,53 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { GemeindeService } from './gemeinde.service'; +import { CreateGemeindeDto } from './dto/create-gemeinde.dto'; +import { UpdateGemeindeDto } from './dto/update-gemeinde.dto'; +import { Roles } from '../common/roles.decorator'; +import { RolesGuard } from '../common/roles.guard'; +import { Role } from '../common/role.enum'; + +/// Gemeinde (congregation) management. Reserved for the Leitungsteam, which is +/// global across all KCs (see RolesGuard); Gemeinde Verantwortliche/Teamer +/// learn their own Gemeinde from their Membership, not from this endpoint. +@Controller('gemeinde') +@UseGuards(AuthGuard('authentik'), 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); + } +} diff --git a/backend/src/gemeinde/gemeinde.module.ts b/backend/src/gemeinde/gemeinde.module.ts new file mode 100644 index 0000000..f383556 --- /dev/null +++ b/backend/src/gemeinde/gemeinde.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { GemeindeService } from './gemeinde.service'; +import { GemeindeController } from './gemeinde.controller'; + +@Module({ + providers: [GemeindeService], + controllers: [GemeindeController], +}) +export class GemeindeModule {} diff --git a/backend/src/gemeinde/gemeinde.service.ts b/backend/src/gemeinde/gemeinde.service.ts new file mode 100644 index 0000000..5311516 --- /dev/null +++ b/backend/src/gemeinde/gemeinde.service.ts @@ -0,0 +1,81 @@ +import { + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma, SyncOperation } from '@prisma/client'; +import { PrismaClient } from '../prisma/prisma.module'; +import { SyncService } from '../sync/sync.service'; + +/// CRUD for Gemeinden (congregations) within a KC. Creating/renaming/deleting +/// is Leitungsteam-only (see GemeindeController); other team roles may list +/// and read the Gemeinden of their KC for onboarding/assignment UIs. +@Injectable() +export class GemeindeService { + constructor( + private readonly prisma: PrismaClient, + private readonly sync: SyncService, + ) {} + + async create(kcId: string, name: string) { + const kc = await this.prisma.kc.findUnique({ where: { id: kcId } }); + if (!kc) { + throw new NotFoundException('KC not found'); + } + try { + const gemeinde = await this.prisma.gemeinde.create({ data: { kcId, name } }); + await this.sync.capture('Gemeinde', SyncOperation.CREATE, gemeinde.id, gemeinde); + return gemeinde; + } catch (err) { + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === 'P2002' + ) { + throw new ConflictException('A Gemeinde with this name already exists in this KC'); + } + throw err; + } + } + + list(kcId: string) { + return this.prisma.gemeinde.findMany({ + where: { kcId }, + orderBy: { name: 'asc' }, + }); + } + + async get(id: string) { + const gemeinde = await this.prisma.gemeinde.findUnique({ where: { id } }); + if (!gemeinde) { + throw new NotFoundException('Gemeinde not found'); + } + return gemeinde; + } + + async update(id: string, name: string) { + await this.get(id); + try { + const gemeinde = await this.prisma.gemeinde.update({ + where: { id }, + data: { name }, + }); + await this.sync.capture('Gemeinde', SyncOperation.UPDATE, gemeinde.id, gemeinde); + return gemeinde; + } catch (err) { + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === 'P2002' + ) { + throw new ConflictException('A Gemeinde with this name already exists in this KC'); + } + throw err; + } + } + + async remove(id: string) { + await this.get(id); + const gemeinde = await this.prisma.gemeinde.delete({ where: { id } }); + await this.sync.capture('Gemeinde', SyncOperation.DELETE, gemeinde.id, gemeinde); + return gemeinde; + } +} diff --git a/plan-kcAppMultiTenantPlatform.prompt.md b/plan-kcAppMultiTenantPlatform.prompt.md index 397b934..eb7db70 100644 --- a/plan-kcAppMultiTenantPlatform.prompt.md +++ b/plan-kcAppMultiTenantPlatform.prompt.md @@ -48,7 +48,7 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris ### Bekannte Einschränkungen / offene Punkte - **Datei-Bytes werden nicht repliziert** – nur Metadaten (inkl. `storageKey`) laufen durchs Sync-Log. Lokaler und Cloud-Server müssen denselben Nextcloud/S3-Backend-Zugriff haben, damit ein `storageKey` auf beiden Seiten auflösbar ist. - **Push-Benachrichtigungen (FCM/APNs)** sind im Plan vorgesehen, aber noch nicht implementiert (Teil der noch ausstehenden Flutter-Client-Arbeit). -- **Gemeinde-Verwaltung (CRUD)** existiert aktuell nur als Datenmodell; es gibt noch keinen eigenen `GemeindeController` zum Anlegen/Verwalten von Gemeinden durch LT (bisher nur implizit über Membership/GuestAccount referenziert). Sollte vor dem Produktivbetrieb ergänzt werden. +- **Gemeinde-Verwaltung (CRUD)**: über `GemeindeController` (LT-only) verfügbar — Anlegen/Auflisten/Lesen/Umbenennen/Löschen von Gemeinden pro KC. Gemeinde Verantwortliche/Teamer erfahren ihre eigene Gemeinde weiterhin aus der `Membership`, nicht über diesen Endpunkt. - **Authentik-Provisionierung**: Wenn ein Gemeinde Verantwortlicher einen Teamer anlegt, muss dieser aktuell weiterhin manuell (oder über eine noch zu bauende Authentik-Admin-API-Integration) in Authentik angelegt werden — das Backend erwartet, dass der `User` mit passendem `authentikSub` bereits existiert, bevor er sich einloggen kann. --- @@ -60,6 +60,7 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris | `prisma/` | Geteilter `PrismaClient`-Provider | – | | `auth/` | Authentik-Resource-Server-Strategie (`AuthGuard('authentik')`), Guest-Login (`AuthGuard('guest')`), `TokenVerificationService` für manuelle Verifikation außerhalb des HTTP/Passport-Pfads (WS-Handshake) | `POST /api/auth/guest` | | `kc/` | KC-Verwaltung, Leitungsteam-only | `POST /api/kc`, `GET /api/kc` | +| `gemeinde/` | Gemeinde-CRUD pro KC, Leitungsteam-only | `POST /api/gemeinde`, `GET /api/gemeinde?kcId=`, `GET/PATCH/DELETE /api/gemeinde/:id` | | `wahl/` | Wahl-/Workshop-Verwaltung, Force-Zuteilung, Teilnehmer-Einreichung, `ZuteilungService` (Portierung von `kc_run_zuteilung`: Force-Zuteilungen → 3 Wunschrunden → Zufallsfüllung → Konsolidierung unterbesetzter Workshops), CSV-Export | `POST/GET /api/wahl`, `POST/GET /api/wahl/:id/workshops`, `POST /api/wahl/:id/force-zuteilung`, `POST /api/wahl/:id/teilnehmer` (Guest), `POST /api/wahl/:id/zuteilung/run`, `GET /api/wahl/:id/zuteilung(.csv)` | | `files/` | Upload (LT-only, multipart) mit Sichtbarkeitsstufe; Liste/Download für Team oder Guest, gefiltert nach erlaubten Sichtbarkeitsstufen; `StorageProvider`-Abstraktion (WebDAV/Nextcloud Default, S3 optional) | `POST /api/files/:kcId`, `GET /api/files/:kcId`, `GET /api/files/download/:fileId` | | `chat/` | Zentrale Zugriffslogik (`ChatService`) geteilt zwischen REST (`ChatController`) und WS (`ChatGateway`, Pfad `/chat`, eigene Token-Verifikation via `?token=`); Kanaltypen wie oben | `POST /api/chat/:kcId/channels`, `POST /api/chat/direct`, `GET /api/chat/:kcId/channels`, `GET /api/chat/channels/:id/messages`, WS-Events `chat:join`/`chat:send`/`chat:message` | @@ -109,7 +110,6 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM ## 8. Nächste Schritte 1. Sobald Flutter verfügbar ist: Client-Grundgerüst aufsetzen (Mobile + Web + Desktop, eine Codebase), beginnend mit Invite/Login-Flow gegen die bestehende API. -2. `GemeindeController` ergänzen (LT-CRUD für Gemeinden), da bisher nur das Datenmodell existiert. -3. Authentik-Admin-API-Integration für automatische Teamer-Provisionierung durch Gemeinde Verantwortliche. -4. Push-Benachrichtigungen (FCM/APNs) für Chat/Ankündigungen. -5. Echte Infrastruktur (Postgres, Authentik, Nextcloud/S3) aufsetzen und die in Abschnitt 7 offenen Verifikationsschritte durchführen. +2. Authentik-Admin-API-Integration für automatische Teamer-Provisionierung durch Gemeinde Verantwortliche. +3. Push-Benachrichtigungen (FCM/APNs) für Chat/Ankündigungen. +4. Echte Infrastruktur (Postgres, Authentik, Nextcloud/S3) aufsetzen und die in Abschnitt 7 offenen Verifikationsschritte durchführen. From 39f83252874884586408eb100e869ee1f9c820ac Mon Sep 17 00:00:00 2001 From: linus Date: Wed, 9 Sep 2026 16:29:02 +0200 Subject: [PATCH 03/37] test(backend): unit-test ZuteilungService assignment algorithm First automated tests in the backend. Fakes Prisma + SyncService in memory and asserts on the zuteilung.createMany payload: - Force-Zuteilung wins over participant wishes - wish-round fallback when a workshop hits capacity - participant left unassigned when nothing is free - underfilled-workshop consolidation reassigns via remaining wishes - workshop exactly meeting minTeilnehmer is kept - one CREATE sync entry captured per resulting Zuteilung npm test green (9 tests). Plan verification section updated. Co-Authored-By: Claude Sonnet 5 --- backend/src/wahl/zuteilung.service.spec.ts | 222 +++++++++++++++++++++ plan-kcAppMultiTenantPlatform.prompt.md | 5 +- 2 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 backend/src/wahl/zuteilung.service.spec.ts diff --git a/backend/src/wahl/zuteilung.service.spec.ts b/backend/src/wahl/zuteilung.service.spec.ts new file mode 100644 index 0000000..d160b31 --- /dev/null +++ b/backend/src/wahl/zuteilung.service.spec.ts @@ -0,0 +1,222 @@ +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) }), + ); + }); +}); diff --git a/plan-kcAppMultiTenantPlatform.prompt.md b/plan-kcAppMultiTenantPlatform.prompt.md index eb7db70..be34e92 100644 --- a/plan-kcAppMultiTenantPlatform.prompt.md +++ b/plan-kcAppMultiTenantPlatform.prompt.md @@ -13,7 +13,7 @@ Neuentwicklung, die das WordPress-Plugin **Workshop-Wahlen** (Wahlen/Workshops/T - **Rollenmodell** (Enum `Role`, Authentik-gestützt): - **Leitungsteam (LT)** – global über alle KCs hinweg (Authentik-Gruppe); bleibt LT auf jedem KC, bis die Authentik-Gruppenmitgliedschaft entfernt wird. - **Gemeinde Verantwortliche** – pro Gemeinde/KC; verwalten **nur Nutzer ihrer eigenen Gemeinde** (legen Teamer an); Wahlen/Workshops macht ausschließlich LT. - - **Gemeinde Teamer** – von Verantwortlichen angelegt, ebenfalls Authentik-Account. + - **Gemeinde Teamer** – von Verantwortlichen angelegt, ebenfalls Lokaler Account. - **Guest/Konfi** – optionaler, rein lokaler Account auf dem jeweiligen Server (kein Authentik), Vor-/Nachname Pflicht, **temporär pro KC/Event** (nicht wiederverwendbar über mehrere Events). - **Membership**: verknüpft `User` (Authentik) ⇄ `Kc` (+ optional `Gemeinde`) ⇄ `Role`. LT-Memberships lassen `gemeindeId` leer und gelten global. - **Einstieg/Onboarding**: LT vergibt pro KC einen Code/QR (enthält den KC-Key). Damit erhält man sofortigen Guest-Zugang **oder** kann sich vorab als Gemeinde Verantwortlicher/Teamer einer Gemeinde registrieren. @@ -103,7 +103,8 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM 1. Nach jeder Phase: `npx tsc -p tsconfig.build.json --noEmit`, `npx nest build`, sowie ein kurzer Boot-Test (`node dist/main.js`) zur Prüfung, dass der DI-Graph auflöst und alle Routen korrekt gemappt werden (ohne Live-DB/Authentik/Nextcloud). 2. Sync-Modul: manuell verifiziert, dass `SyncSecretGuard` Requests ohne `x-sync-secret` mit 403 ablehnt und mit korrektem Secret durchlässt (DB-Fehler in der Sandbox ist erwartet, da kein Postgres läuft). 3. Web-Client: `GET /` liefert die statische Seite (200), `GET /api/kc` trifft die echte, geschützte API (401 ohne Token). -4. Noch ausstehend (sobald echte Infrastruktur verfügbar ist): Zuteilungslogik gegen bekannte Testdaten aus dem alten Plugin validieren; Ende-zu-Ende-Rollenmatrix (LT/Verantwortlicher/Teamer/Guest) über alle Kernfeatures; echter Sync-Test zwischen zwei laufenden Instanzen (lokal + Cloud). +4. `ZuteilungService`: Jest-Unit-Tests (`src/wahl/zuteilung.service.spec.ts`, Prisma/Sync gemockt) decken Force-Vorrang, Wunschrunden-Fallback bei voller Kapazität, Unzugeteilt-Fall, Konsolidierung unterbesetzter Workshops und die Sync-Capture-Anzahl ab. `npm test` grün. +5. Noch ausstehend (sobald echte Infrastruktur verfügbar ist): Zuteilungslogik gegen bekannte Testdaten aus dem alten Plugin validieren; Ende-zu-Ende-Rollenmatrix (LT/Verantwortlicher/Teamer/Guest) über alle Kernfeatures; echter Sync-Test zwischen zwei laufenden Instanzen (lokal + Cloud). --- From 891105414a34e58d5dac1ee06647d37b41779b10 Mon Sep 17 00:00:00 2001 From: linus Date: Wed, 9 Sep 2026 16:50:06 +0200 Subject: [PATCH 04/37] feat(backend): local accounts + invites for Gemeinde Teamer Per the updated plan, Gemeinde Teamer are no longer Authentik-backed; they are local accounts a Gemeinde Verantwortliche/r provisions per KC. Schema: - User.authentikSub now nullable; add passwordHash + kcId (cascade from Kc) so one User model covers Authentik members and local Teamer. - new TeamerInvite model: shareable group link (email null, maxUses null) or personal invite (email pinned, single use), with expiry + revoke. - sync log now also replicates User / Membership / TeamerInvite. Auth: - TeamAuthService: bcrypt password login (POST /auth/team-login) and invite redemption (POST /auth/teamer/register) issuing a JWT signed with TEAM_JWT_SECRET, payload typ:"team". - TeamJwtStrategy (AuthGuard('team')) resolves it to the same AuthenticatedUser shape as AuthentikStrategy. - TokenVerificationService.verifyEither() also accepts team tokens (WS). - files + chat read endpoints accept 'team' tokens; Teamer see non-Konfi files and can use chat / start DMs. Teamer admin (teamer/ module, under /gemeinde/:gemeindeId): - POST/GET teamer, DELETE teamer/:userId - POST/GET teamer-invites, DELETE teamer-invites/:inviteId - LT may manage any Gemeinde; a Verantwortliche/r only their own (checked in TeamerService, since RolesGuard only scopes by kcId). Tests: TeamAuthService + TeamerService specs added (Prisma/Sync mocked), npm test green at 32. Docs (plan + backend README) updated. Co-Authored-By: Claude Sonnet 5 --- backend/.env.example | 3 + backend/README.md | 36 ++- backend/package-lock.json | 18 ++ backend/package.json | 2 + backend/prisma/schema.prisma | 52 +++- backend/src/app.module.ts | 2 + backend/src/auth/auth.controller.ts | 20 +- backend/src/auth/auth.module.ts | 13 +- backend/src/auth/authenticated-request.ts | 6 +- backend/src/auth/dto/register-teamer.dto.ts | 30 +++ backend/src/auth/dto/team-login.dto.ts | 10 + backend/src/auth/team-auth.service.spec.ts | 232 ++++++++++++++++++ backend/src/auth/team-auth.service.ts | 160 ++++++++++++ backend/src/auth/team-jwt.strategy.ts | 26 ++ .../src/auth/token-verification.service.ts | 9 +- backend/src/chat/chat.controller.ts | 9 +- backend/src/files/files.controller.ts | 4 +- backend/src/sync/sync.service.ts | 3 + .../teamer/dto/create-teamer-invite.dto.ts | 21 ++ backend/src/teamer/dto/create-teamer.dto.ts | 18 ++ backend/src/teamer/teamer.controller.ts | 74 ++++++ backend/src/teamer/teamer.module.ts | 9 + backend/src/teamer/teamer.service.spec.ts | 167 +++++++++++++ backend/src/teamer/teamer.service.ts | 182 ++++++++++++++ plan-kcAppMultiTenantPlatform.prompt.md | 22 +- 25 files changed, 1091 insertions(+), 37 deletions(-) create mode 100644 backend/src/auth/dto/register-teamer.dto.ts create mode 100644 backend/src/auth/dto/team-login.dto.ts create mode 100644 backend/src/auth/team-auth.service.spec.ts create mode 100644 backend/src/auth/team-auth.service.ts create mode 100644 backend/src/auth/team-jwt.strategy.ts create mode 100644 backend/src/teamer/dto/create-teamer-invite.dto.ts create mode 100644 backend/src/teamer/dto/create-teamer.dto.ts create mode 100644 backend/src/teamer/teamer.controller.ts create mode 100644 backend/src/teamer/teamer.module.ts create mode 100644 backend/src/teamer/teamer.service.spec.ts create mode 100644 backend/src/teamer/teamer.service.ts diff --git a/backend/.env.example b/backend/.env.example index 11e1602..bced9a5 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -7,6 +7,9 @@ AUTHENTIK_ISSUER_URL="https://authentik.example.org/application/o/kc-app" # 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 # File storage: defaults to Nextcloud via WebDAV; set STORAGE_PROVIDER=s3 to diff --git a/backend/README.md b/backend/README.md index 6d347c3..fd8764e 100644 --- a/backend/README.md +++ b/backend/README.md @@ -7,7 +7,7 @@ architecture context). ```bash npm install -cp .env.example .env # then fill in DATABASE_URL / AUTHENTIK_ISSUER_URL / GUEST_JWT_SECRET +cp .env.example .env # then fill in DATABASE_URL / AUTHENTIK_ISSUER_URL / GUEST_JWT_SECRET / TEAM_JWT_SECRET npx prisma generate npx prisma migrate dev --name init # requires a running PostgreSQL instance npm run start:dev @@ -20,12 +20,20 @@ client's host - no separate web server is needed. ## Auth model -- Team members (Leitungsteam, Gemeinde Verantwortliche, Gemeinde Teamer) are - provisioned in Authentik; this API acts as an OIDC **resource server**, +- Leitungsteam and Gemeinde Verantwortliche are provisioned in Authentik + (the "Konfi-Castle-ID"); this API acts as an OIDC **resource server**, verifying access tokens against Authentik's JWKS (`AuthentikStrategy`) and then resolving local `Membership` rows to determine role + KC/Gemeinde scope. Clients perform the actual Authorization Code + PKCE flow against Authentik directly. +- Gemeinde Teamer are **local accounts** (no Authentik): a `User` row with a + `passwordHash` and `kcId` set, `authentikSub` left null. A Gemeinde + Verantwortliche/r creates them directly or via a `TeamerInvite` + (shareable group link or per-email invite). Login is `POST /auth/team-login` + (email + password) or `POST /auth/teamer/register` (redeem an invite + token); both return a JWT signed with `TEAM_JWT_SECRET` and carrying + `typ: "team"`. `TeamJwtStrategy` (`AuthGuard('team')`) resolves it to the + same shape as `AuthentikStrategy`, so guards/controllers treat both alike. - Guests/Konfis get a temporary local account (first/last name required, no Authentik) created via `POST /auth/guest` with a KC invite code, returning a JWT signed with `GUEST_JWT_SECRET`. @@ -33,13 +41,24 @@ client's host - no separate web server is needed. ## Modules implemented so far - `prisma/` — shared `PrismaClient` provider. -- `auth/` — Authentik resource-server strategy (`AuthGuard('authentik')`) + - guest invite-code login issuing a locally-signed JWT (`AuthGuard('guest')`). +- `auth/` — Authentik resource-server strategy (`AuthGuard('authentik')`), + guest invite-code login (`AuthGuard('guest')`), and local Gemeinde Teamer + auth (`AuthGuard('team')`): `POST /auth/team-login` and + `POST /auth/teamer/register` (invite redemption), bcrypt hashes, tokens + signed with `TEAM_JWT_SECRET`. `TokenVerificationService` (WS handshake) + now accepts Authentik, team, or guest tokens. - `kc/` — KC (event) creation/listing, Leitungsteam-only. - `gemeinde/` — Gemeinde (congregation) CRUD per KC (`POST /gemeinde`, `GET /gemeinde?kcId=`, `GET/PATCH/DELETE /gemeinde/:id`), Leitungsteam-only. Gemeinde Verantwortliche/Teamer get their own Gemeinde from their `Membership`, not from this endpoint. +- `teamer/` — local Gemeinde Teamer accounts + invites, under + `/gemeinde/:gemeindeId/...`: `POST/GET teamer`, + `DELETE teamer/:userId`, `POST/GET teamer-invites`, + `DELETE teamer-invites/:inviteId`. Callable by Leitungsteam (any Gemeinde) + or a Verantwortliche/r for their own Gemeinde (enforced in `TeamerService`, + since `RolesGuard` only scopes by `kcId`). Files/chat read endpoints accept + `'team'` tokens too, so Teamer see non-Konfi files and chat. - `wahl/` — Wahl/Workshop administration (Leitungsteam-only), guest Teilnehmer submission, Force-Zuteilung overrides, and `ZuteilungService`: a faithful port of the WP plugin's `kc_run_zuteilung` (force-assignments → @@ -76,5 +95,8 @@ client's host - no separate web server is needed. - `common/` — `Role` enum, `@Roles()` decorator, `RolesGuard` (KC-scoped, Leitungsteam roles are global across all KCs). -All planned backend phases are implemented; remaining work is the Flutter -clients (see repo root README). +All planned backend phases are implemented. `npm test` runs Jest unit tests +(`ZuteilungService`, `TeamAuthService`, `TeamerService`; Prisma mocked). +Remaining work: the Flutter clients (see repo root README), Authentik +provisioning for LT/Verantwortliche, and the first real Prisma migration +(only `schema.prisma` exists so far). diff --git a/backend/package-lock.json b/backend/package-lock.json index 31b3040..5b3c298 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -21,6 +21,7 @@ "@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", @@ -37,6 +38,7 @@ "@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", @@ -2776,6 +2778,13 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -3923,6 +3932,15 @@ "node": ">=6.0.0" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", diff --git a/backend/package.json b/backend/package.json index 35e3b9d..61c95b8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -33,6 +33,7 @@ "@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", @@ -49,6 +50,7 @@ "@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", diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 112c711..5812b27 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -16,12 +16,14 @@ model Kc { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - gemeinden Gemeinde[] - memberships Membership[] - wahlen Wahl[] - files File[] - channels ChatChannel[] - guests GuestAccount[] + gemeinden Gemeinde[] + memberships Membership[] + wahlen Wahl[] + files File[] + channels ChatChannel[] + guests GuestAccount[] + localUsers User[] + teamerInvites TeamerInvite[] } /// A local congregation/community participating in one Kc. @@ -31,9 +33,10 @@ model Gemeinde { kcId String createdAt DateTime @default(now()) - kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) - memberships Membership[] - guests GuestAccount[] + kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) + memberships Membership[] + guests GuestAccount[] + teamerInvites TeamerInvite[] @@unique([kcId, name]) } @@ -44,15 +47,21 @@ enum Role { GEMEINDE_TEAMER } -/// Authentik-backed user (team member with elevated rights). +/// 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 + authentikSub String? @unique email String @unique firstName String lastName String + passwordHash String? + kcId String? createdAt DateTime @default(now()) + kc Kc? @relation(fields: [kcId], references: [id], onDelete: Cascade) memberships Membership[] messages ChatMessage[] chatParticipations ChatParticipant[] @@ -90,6 +99,27 @@ model GuestAccount { 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()) diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 3ddb730..806bcf0 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -6,6 +6,7 @@ import { PrismaModule } from './prisma/prisma.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 { WahlModule } from './wahl/wahl.module'; import { FilesModule } from './files/files.module'; import { ChatModule } from './chat/chat.module'; @@ -25,6 +26,7 @@ import { SyncModule } from './sync/sync.module'; AuthModule, KcModule, GemeindeModule, + TeamerModule, WahlModule, FilesModule, ChatModule, diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index d4a1202..370d0d5 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -1,14 +1,32 @@ import { Body, Controller, Post } from '@nestjs/common'; 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'; @Controller('auth') export class AuthController { - constructor(private readonly guestAuth: GuestAuthService) {} + constructor( + private readonly guestAuth: GuestAuthService, + private readonly teamAuth: TeamAuthService, + ) {} /// 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); + } } diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 706b234..20d2733 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -4,8 +4,10 @@ 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({ @@ -20,7 +22,14 @@ import { TokenVerificationService } from './token-verification.service'; }), ], controllers: [AuthController], - providers: [GuestAuthService, AuthentikStrategy, GuestJwtStrategy, TokenVerificationService], - exports: [TokenVerificationService], + providers: [ + GuestAuthService, + TeamAuthService, + AuthentikStrategy, + GuestJwtStrategy, + TeamJwtStrategy, + TokenVerificationService, + ], + exports: [TokenVerificationService, TeamAuthService], }) export class AuthModule {} diff --git a/backend/src/auth/authenticated-request.ts b/backend/src/auth/authenticated-request.ts index aa4ed20..31e4ea0 100644 --- a/backend/src/auth/authenticated-request.ts +++ b/backend/src/auth/authenticated-request.ts @@ -8,10 +8,12 @@ export interface AuthenticatedMembership { role: Role; } -/// Shape attached to req.user by JwtStrategy after validating an access token. +/// Shape attached to req.user after validating an access token — by +/// AuthentikStrategy for Authentik-backed members, or by TeamJwtStrategy for +/// local Gemeinde Teamer (then `authentikSub` is null). export interface AuthenticatedUser { userId: string; - authentikSub: string; + authentikSub: string | null; email: string; memberships: AuthenticatedMembership[]; } diff --git a/backend/src/auth/dto/register-teamer.dto.ts b/backend/src/auth/dto/register-teamer.dto.ts new file mode 100644 index 0000000..994037d --- /dev/null +++ b/backend/src/auth/dto/register-teamer.dto.ts @@ -0,0 +1,30 @@ +import { + IsEmail, + IsNotEmpty, + IsOptional, + IsString, + MinLength, +} from 'class-validator'; + +export class RegisterTeamerDto { + @IsString() + @IsNotEmpty() + token!: string; + + @IsString() + @IsNotEmpty() + firstName!: string; + + @IsString() + @IsNotEmpty() + lastName!: string; + + @IsString() + @MinLength(8) + password!: string; + + /// Required for group-link invites; ignored/validated against a personal invite. + @IsOptional() + @IsEmail() + email?: string; +} diff --git a/backend/src/auth/dto/team-login.dto.ts b/backend/src/auth/dto/team-login.dto.ts new file mode 100644 index 0000000..e4f31af --- /dev/null +++ b/backend/src/auth/dto/team-login.dto.ts @@ -0,0 +1,10 @@ +import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; + +export class TeamLoginDto { + @IsEmail() + email!: string; + + @IsString() + @IsNotEmpty() + password!: string; +} diff --git a/backend/src/auth/team-auth.service.spec.ts b/backend/src/auth/team-auth.service.spec.ts new file mode 100644 index 0000000..bff85e3 --- /dev/null +++ b/backend/src/auth/team-auth.service.spec.ts @@ -0,0 +1,232 @@ +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 }) => { + 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 }) => + 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 { + 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)); + }); +}); diff --git a/backend/src/auth/team-auth.service.ts b/backend/src/auth/team-auth.service.ts new file mode 100644 index 0000000..5ab4833 --- /dev/null +++ b/backend/src/auth/team-auth.service.ts @@ -0,0 +1,160 @@ +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'; + +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('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 { + 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 { + const user = await this.prisma.user.findFirst({ + where: { id: userId, passwordHash: { not: null } }, + include: { memberships: true }, + }); + if (!user) { + throw new UnauthorizedException('Team account no longer exists'); + } + return { + userId: user.id, + authentikSub: user.authentikSub, + email: user.email, + memberships: user.memberships.map((m) => ({ + kcId: m.kcId, + gemeindeId: m.gemeindeId, + role: m.role, + })), + }; + } +} diff --git a/backend/src/auth/team-jwt.strategy.ts b/backend/src/auth/team-jwt.strategy.ts new file mode 100644 index 0000000..f6896a7 --- /dev/null +++ b/backend/src/auth/team-jwt.strategy.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ConfigService } from '@nestjs/config'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { AuthenticatedUser } from './authenticated-request'; +import { TeamAuthService, TeamJwtPayload } from './team-auth.service'; + +/// Verifies the local JWT issued to Gemeinde Teamer by TeamAuthService and +/// resolves it to the same AuthenticatedUser shape as AuthentikStrategy, so +/// downstream RolesGuard / controllers treat both member kinds identically. +@Injectable() +export class TeamJwtStrategy extends PassportStrategy(Strategy, 'team') { + constructor( + config: ConfigService, + private readonly teamAuth: TeamAuthService, + ) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: config.getOrThrow('TEAM_JWT_SECRET'), + }); + } + + validate(payload: TeamJwtPayload): Promise { + return this.teamAuth.resolve(payload.sub); + } +} diff --git a/backend/src/auth/token-verification.service.ts b/backend/src/auth/token-verification.service.ts index 374725d..6823c52 100644 --- a/backend/src/auth/token-verification.service.ts +++ b/backend/src/auth/token-verification.service.ts @@ -6,6 +6,7 @@ import * as jwksRsa from 'jwks-rsa'; import { PrismaClient } from '../prisma/prisma.module'; import { AuthenticatedUser } from './authenticated-request'; import { GuestJwtPayload } from './guest-auth.service'; +import { TeamAuthService } from './team-auth.service'; /// Verifies raw bearer tokens outside the HTTP/passport pipeline, needed for /// the WebSocket handshake where AuthGuard('authentik'|'guest') don't apply. @@ -18,6 +19,7 @@ export class TokenVerificationService { private readonly config: ConfigService, private readonly prisma: PrismaClient, private readonly guestJwt: JwtService, + private readonly teamAuth: TeamAuthService, ) { this.issuerUrl = config.getOrThrow('AUTHENTIK_ISSUER_URL'); this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true }); @@ -61,12 +63,17 @@ export class TokenVerificationService { return this.guestJwt.verifyAsync(token); } - /// Tries Authentik first (team member), then falls back to a guest 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) }; } diff --git a/backend/src/chat/chat.controller.ts b/backend/src/chat/chat.controller.ts index 75370b1..325068f 100644 --- a/backend/src/chat/chat.controller.ts +++ b/backend/src/chat/chat.controller.ts @@ -24,21 +24,22 @@ export class ChatController { return this.chat.createChannel(kcId, dto.type, dto.gemeindeId); } - /// Any two team members of the same KC can start a direct conversation. + /// 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')) + @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', 'guest'])) + @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', 'guest'])) + @UseGuards(AuthGuard(['authentik', 'team', 'guest'])) listMessages(@Param('channelId') channelId: string, @Req() req: ChatRequest) { return this.chat.listMessages(channelId, resolveChatCaller(req.user!)); } diff --git a/backend/src/files/files.controller.ts b/backend/src/files/files.controller.ts index c173666..b7bb5e3 100644 --- a/backend/src/files/files.controller.ts +++ b/backend/src/files/files.controller.ts @@ -47,7 +47,7 @@ export class FilesController { } @Get(':kcId') - @UseGuards(AuthGuard(['authentik', 'guest'])) + @UseGuards(AuthGuard(['authentik', 'team', 'guest'])) list(@Param('kcId') kcId: string, @Req() req: FileCallerRequest) { const allowed = isGuest(req.user) ? GUEST_ALLOWED_VISIBILITIES @@ -56,7 +56,7 @@ export class FilesController { } @Get('download/:fileId') - @UseGuards(AuthGuard(['authentik', 'guest'])) + @UseGuards(AuthGuard(['authentik', 'team', 'guest'])) async download( @Param('fileId') fileId: string, @Req() req: FileCallerRequest, diff --git a/backend/src/sync/sync.service.ts b/backend/src/sync/sync.service.ts index 094aab4..3d2746e 100644 --- a/backend/src/sync/sync.service.ts +++ b/backend/src/sync/sync.service.ts @@ -6,6 +6,9 @@ import { PrismaClient } from '../prisma/prisma.module'; const SYNCED_MODELS = [ 'Kc', 'Gemeinde', + 'User', + 'Membership', + 'TeamerInvite', 'GuestAccount', 'Wahl', 'Workshop', diff --git a/backend/src/teamer/dto/create-teamer-invite.dto.ts b/backend/src/teamer/dto/create-teamer-invite.dto.ts new file mode 100644 index 0000000..b1a331e --- /dev/null +++ b/backend/src/teamer/dto/create-teamer-invite.dto.ts @@ -0,0 +1,21 @@ +import { IsEmail, IsInt, IsOptional, Min } from 'class-validator'; + +export class CreateTeamerInviteDto { + /// Set for a personal invite pinned to one address; omit for a shareable + /// group link. + @IsOptional() + @IsEmail() + email?: string; + + /// Max redemptions. Defaults to 1 for a personal invite, unlimited for a + /// group link. + @IsOptional() + @IsInt() + @Min(1) + maxUses?: number; + + @IsOptional() + @IsInt() + @Min(1) + expiresInHours?: number; +} diff --git a/backend/src/teamer/dto/create-teamer.dto.ts b/backend/src/teamer/dto/create-teamer.dto.ts new file mode 100644 index 0000000..e5d4002 --- /dev/null +++ b/backend/src/teamer/dto/create-teamer.dto.ts @@ -0,0 +1,18 @@ +import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator'; + +export class CreateTeamerDto { + @IsString() + @IsNotEmpty() + firstName!: string; + + @IsString() + @IsNotEmpty() + lastName!: string; + + @IsEmail() + email!: string; + + @IsString() + @MinLength(8) + password!: string; +} diff --git a/backend/src/teamer/teamer.controller.ts b/backend/src/teamer/teamer.controller.ts new file mode 100644 index 0000000..ba11723 --- /dev/null +++ b/backend/src/teamer/teamer.controller.ts @@ -0,0 +1,74 @@ +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 (both Authentik-backed); TeamerService then +/// checks the caller is actually responsible for `:gemeindeId`. +@Controller('gemeinde/:gemeindeId') +@UseGuards(AuthGuard('authentik'), 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); + } +} diff --git a/backend/src/teamer/teamer.module.ts b/backend/src/teamer/teamer.module.ts new file mode 100644 index 0000000..7706997 --- /dev/null +++ b/backend/src/teamer/teamer.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { TeamerService } from './teamer.service'; +import { TeamerController } from './teamer.controller'; + +@Module({ + providers: [TeamerService], + controllers: [TeamerController], +}) +export class TeamerModule {} diff --git a/backend/src/teamer/teamer.service.spec.ts b/backend/src/teamer/teamer.service.spec.ts new file mode 100644 index 0000000..28a71d9 --- /dev/null +++ b/backend/src/teamer/teamer.service.spec.ts @@ -0,0 +1,167 @@ +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 = {}; + + const prisma = { + gemeinde: { findUnique: jest.fn().mockResolvedValue(gemeinde) }, + 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 }) => { + 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 }) => { + 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 }) => + Promise.resolve({ id: 'inv-1', usedCount: 0, revokedAt: null, ...data }), + ), + }, + }; + const sync = { capture: jest.fn().mockResolvedValue(undefined) }; + const service = new TeamerService(prisma as never, sync as never); + return { service, prisma, sync, 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 and no expiry', async () => { + const { service } = 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)); + }); + + it('defaults a personal invite to a single use and lowercases the email', async () => { + const { service } = 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); + }); + + 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' }); + }); +}); diff --git a/backend/src/teamer/teamer.service.ts b/backend/src/teamer/teamer.service.ts new file mode 100644 index 0000000..e397106 --- /dev/null +++ b/backend/src/teamer/teamer.service.ts @@ -0,0 +1,182 @@ +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 { 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, + ) {} + + async createTeamer( + caller: AuthenticatedUser, + gemeindeId: string, + input: { firstName: string; lastName: string; email: string; password: string }, + ): Promise { + 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 { + 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); + return invite; + } + + 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, + }; +} diff --git a/plan-kcAppMultiTenantPlatform.prompt.md b/plan-kcAppMultiTenantPlatform.prompt.md index be34e92..1dee59d 100644 --- a/plan-kcAppMultiTenantPlatform.prompt.md +++ b/plan-kcAppMultiTenantPlatform.prompt.md @@ -12,7 +12,7 @@ Neuentwicklung, die das WordPress-Plugin **Workshop-Wahlen** (Wahlen/Workshops/T - **Gemeinde**: lokale Gemeinde/Kirchengemeinde innerhalb eines KC (`kcId` + `name`, eindeutig pro KC). - **Rollenmodell** (Enum `Role`, Authentik-gestützt): - **Leitungsteam (LT)** – global über alle KCs hinweg (Authentik-Gruppe); bleibt LT auf jedem KC, bis die Authentik-Gruppenmitgliedschaft entfernt wird. - - **Gemeinde Verantwortliche** – pro Gemeinde/KC; verwalten **nur Nutzer ihrer eigenen Gemeinde** (legen Teamer an); Wahlen/Workshops macht ausschließlich LT. + - **Gemeinde Verantwortliche** – pro Gemeinde/KC; verwalten **nur Nutzer ihrer eigenen Gemeinde** (legen Teamer an); Wahlen/Workshops macht ausschließlich LT; zudem Authentik Gruppe und Benutzer. - **Gemeinde Teamer** – von Verantwortlichen angelegt, ebenfalls Lokaler Account. - **Guest/Konfi** – optionaler, rein lokaler Account auf dem jeweiligen Server (kein Authentik), Vor-/Nachname Pflicht, **temporär pro KC/Event** (nicht wiederverwendbar über mehrere Events). - **Membership**: verknüpft `User` (Authentik) ⇄ `Kc` (+ optional `Gemeinde`) ⇄ `Role`. LT-Memberships lassen `gemeindeId` leer und gelten global. @@ -39,6 +39,7 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris | Web-Interimslösung | Backend liefert `client/web/` (reines HTML/CSS/JS, kein Build-Schritt) über `ServeStaticModule` aus; REST-API liegt unter `/api/*` | Nutzerwunsch: "Server soll auch Web-Client bereitstellen"; vermeidet Kollision zwischen API-Routen und statischen Dateien | | Auth (Team) | Authentik als OIDC Resource Server (JWKS-Verifikation), kein lokaler Autorisierungscode-Flow im Backend | Clients machen Authorization Code + PKCE direkt gegen Authentik; Backend validiert nur Access Token + löst lokale `Membership` auf | | Auth (Guest) | Rein lokale JWTs (`GUEST_JWT_SECRET`), kein Authentik | explizite Nutzervorgabe: Konfi-Accounts sind nie in Authentik | +| Auth (Gemeinde Teamer) | Lokale Accounts: `User` mit `passwordHash`+`kcId`, `authentikSub` bleibt leer; eigenes JWT (`TEAM_JWT_SECRET`, Payload `typ:'team'`), Passwort-Login oder Invite-Redemption. Verantwortliche legen Teamer an (Direkt/Gruppen-Link/E-Mail-Invite) | Nutzervorgabe: Teamer laufen nicht über die Konfi-Castle-ID (Authentik), sondern werden pro KC lokal verwaltet (wie Guests, nur dauerhaft + mit Rolle) | | Datei-Storage | Provider-Abstraktion (`StorageProvider`), Default **Nextcloud/WebDAV**, umschaltbar auf S3 via `STORAGE_PROVIDER=s3` | Nutzer bestätigte: Nextcloud-Zugangsdaten kommen aus `.env` | | Chat-Transport | Raw `ws`-Gateway (`@nestjs/platform-ws`) statt Socket.IO | Passt zum schlanken REST-Stack; Rollen/Sichtbarkeits-Logik zentral in `ChatService`, geteilt zwischen REST und WS | | Sync-Richtung | **Lokaler Server initiiert immer** Push *und* Pull gegen die Cloud-URL | Cloud kann i. d. R. nicht in ein lokales Eventnetzwerk zurückwählen (NAT); lokaler Server kann aber ausgehend zur Cloud verbinden, wenn Internet verfügbar ist | @@ -49,7 +50,9 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris - **Datei-Bytes werden nicht repliziert** – nur Metadaten (inkl. `storageKey`) laufen durchs Sync-Log. Lokaler und Cloud-Server müssen denselben Nextcloud/S3-Backend-Zugriff haben, damit ein `storageKey` auf beiden Seiten auflösbar ist. - **Push-Benachrichtigungen (FCM/APNs)** sind im Plan vorgesehen, aber noch nicht implementiert (Teil der noch ausstehenden Flutter-Client-Arbeit). - **Gemeinde-Verwaltung (CRUD)**: über `GemeindeController` (LT-only) verfügbar — Anlegen/Auflisten/Lesen/Umbenennen/Löschen von Gemeinden pro KC. Gemeinde Verantwortliche/Teamer erfahren ihre eigene Gemeinde weiterhin aus der `Membership`, nicht über diesen Endpunkt. -- **Authentik-Provisionierung**: Wenn ein Gemeinde Verantwortlicher einen Teamer anlegt, muss dieser aktuell weiterhin manuell (oder über eine noch zu bauende Authentik-Admin-API-Integration) in Authentik angelegt werden — das Backend erwartet, dass der `User` mit passendem `authentikSub` bereits existiert, bevor er sich einloggen kann. +- **Authentik-Provisionierung (LT + Gemeinde Verantwortliche)**: Diese beiden Rollen laufen über die Konfi-Castle-ID (Authentik). Das Backend erwartet, dass der `User` mit passendem `authentikSub` bereits lokal existiert, bevor er sich einloggen kann — ein automatischer Provisionierungs-/Sync-Pfad aus Authentik heraus fehlt noch. (Gemeinde Teamer brauchen das nicht mehr: seit dieser Session sind sie lokale Accounts, siehe `teamer/` + `auth/team-login`.) +- **Teamer-Identität ist E-Mail-basiert und global eindeutig**: `User.email` ist instanzweit unique, d. h. dieselbe E-Mail kann nicht gleichzeitig Teamer in zwei KCs sein. Für den „pro KC wie Guests"-Fall in der Praxis unkritisch, aber dokumentiert. +- **Kein E-Mail-Versand**: `teamer-invites` erzeugt Token/Link; das tatsächliche Verschicken der E-Mail-Invites ist noch nicht angebunden. --- @@ -58,9 +61,10 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris | Modul | Kernfunktion | Wichtige Endpunkte | |---|---|---| | `prisma/` | Geteilter `PrismaClient`-Provider | – | -| `auth/` | Authentik-Resource-Server-Strategie (`AuthGuard('authentik')`), Guest-Login (`AuthGuard('guest')`), `TokenVerificationService` für manuelle Verifikation außerhalb des HTTP/Passport-Pfads (WS-Handshake) | `POST /api/auth/guest` | +| `auth/` | Authentik-Resource-Server-Strategie (`AuthGuard('authentik')`), Guest-Login (`AuthGuard('guest')`), **lokaler Teamer-Login** (`AuthGuard('team')`, `TEAM_JWT_SECRET`, bcrypt) inkl. Invite-Redemption, `TokenVerificationService` für manuelle Verifikation außerhalb des HTTP/Passport-Pfads (WS-Handshake, akzeptiert jetzt Authentik/Team/Guest) | `POST /api/auth/guest`, `POST /api/auth/team-login`, `POST /api/auth/teamer/register` | | `kc/` | KC-Verwaltung, Leitungsteam-only | `POST /api/kc`, `GET /api/kc` | | `gemeinde/` | Gemeinde-CRUD pro KC, Leitungsteam-only | `POST /api/gemeinde`, `GET /api/gemeinde?kcId=`, `GET/PATCH/DELETE /api/gemeinde/:id` | +| `teamer/` | Lokale Gemeinde-Teamer-Accounts + Invites; Direkt-Anlage, Gruppen-Link und E-Mail-Invite; nutzbar von LT (jede Gemeinde) oder Verantwortliche/r (nur eigene Gemeinde, in `TeamerService` geprüft) | `POST/GET /api/gemeinde/:gemeindeId/teamer`, `DELETE /api/gemeinde/:gemeindeId/teamer/:userId`, `POST/GET /api/gemeinde/:gemeindeId/teamer-invites`, `DELETE .../teamer-invites/:id` | | `wahl/` | Wahl-/Workshop-Verwaltung, Force-Zuteilung, Teilnehmer-Einreichung, `ZuteilungService` (Portierung von `kc_run_zuteilung`: Force-Zuteilungen → 3 Wunschrunden → Zufallsfüllung → Konsolidierung unterbesetzter Workshops), CSV-Export | `POST/GET /api/wahl`, `POST/GET /api/wahl/:id/workshops`, `POST /api/wahl/:id/force-zuteilung`, `POST /api/wahl/:id/teilnehmer` (Guest), `POST /api/wahl/:id/zuteilung/run`, `GET /api/wahl/:id/zuteilung(.csv)` | | `files/` | Upload (LT-only, multipart) mit Sichtbarkeitsstufe; Liste/Download für Team oder Guest, gefiltert nach erlaubten Sichtbarkeitsstufen; `StorageProvider`-Abstraktion (WebDAV/Nextcloud Default, S3 optional) | `POST /api/files/:kcId`, `GET /api/files/:kcId`, `GET /api/files/download/:fileId` | | `chat/` | Zentrale Zugriffslogik (`ChatService`) geteilt zwischen REST (`ChatController`) und WS (`ChatGateway`, Pfad `/chat`, eigene Token-Verifikation via `?token=`); Kanaltypen wie oben | `POST /api/chat/:kcId/channels`, `POST /api/chat/direct`, `GET /api/chat/:kcId/channels`, `GET /api/chat/channels/:id/messages`, WS-Events `chat:join`/`chat:send`/`chat:message` | @@ -103,7 +107,10 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM 1. Nach jeder Phase: `npx tsc -p tsconfig.build.json --noEmit`, `npx nest build`, sowie ein kurzer Boot-Test (`node dist/main.js`) zur Prüfung, dass der DI-Graph auflöst und alle Routen korrekt gemappt werden (ohne Live-DB/Authentik/Nextcloud). 2. Sync-Modul: manuell verifiziert, dass `SyncSecretGuard` Requests ohne `x-sync-secret` mit 403 ablehnt und mit korrektem Secret durchlässt (DB-Fehler in der Sandbox ist erwartet, da kein Postgres läuft). 3. Web-Client: `GET /` liefert die statische Seite (200), `GET /api/kc` trifft die echte, geschützte API (401 ohne Token). -4. `ZuteilungService`: Jest-Unit-Tests (`src/wahl/zuteilung.service.spec.ts`, Prisma/Sync gemockt) decken Force-Vorrang, Wunschrunden-Fallback bei voller Kapazität, Unzugeteilt-Fall, Konsolidierung unterbesetzter Workshops und die Sync-Capture-Anzahl ab. `npm test` grün. +4. Jest-Unit-Tests (Prisma/Sync gemockt, `npm test` grün, 32 Tests): + - `src/wahl/zuteilung.service.spec.ts`: Force-Vorrang, Wunschrunden-Fallback bei voller Kapazität, Unzugeteilt-Fall, Konsolidierung unterbesetzter Workshops, Sync-Capture-Anzahl. + - `src/auth/team-auth.service.spec.ts`: Invite-Redemption (unbekannt/widerrufen/abgelaufen/aufgebraucht, Gruppen-Link ohne E-Mail, E-Mail-Mismatch, Dublette) + Passwort-Login. + - `src/teamer/teamer.service.spec.ts`: Gemeinde-Scope-Check (LT global, Verantwortliche/r nur eigene Gemeinde), Direkt-Anlage, Invite-Defaults, Löschung. 5. Noch ausstehend (sobald echte Infrastruktur verfügbar ist): Zuteilungslogik gegen bekannte Testdaten aus dem alten Plugin validieren; Ende-zu-Ende-Rollenmatrix (LT/Verantwortlicher/Teamer/Guest) über alle Kernfeatures; echter Sync-Test zwischen zwei laufenden Instanzen (lokal + Cloud). --- @@ -111,6 +118,7 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM ## 8. Nächste Schritte 1. Sobald Flutter verfügbar ist: Client-Grundgerüst aufsetzen (Mobile + Web + Desktop, eine Codebase), beginnend mit Invite/Login-Flow gegen die bestehende API. -2. Authentik-Admin-API-Integration für automatische Teamer-Provisionierung durch Gemeinde Verantwortliche. -3. Push-Benachrichtigungen (FCM/APNs) für Chat/Ankündigungen. -4. Echte Infrastruktur (Postgres, Authentik, Nextcloud/S3) aufsetzen und die in Abschnitt 7 offenen Verifikationsschritte durchführen. +2. Authentik-Provisionierung für LT + Gemeinde Verantwortliche automatisieren (JIT-Anlage des lokalen `User` beim ersten Login aus den Token-Claims, oder Sync aus der Authentik-Admin-API). +3. E-Mail-Versand für `teamer-invites` anbinden (Mailer + Templates); aktuell wird nur Token/Link erzeugt. +4. Push-Benachrichtigungen (FCM/APNs) für Chat/Ankündigungen. +5. Echte Infrastruktur (Postgres, Authentik, Nextcloud/S3) aufsetzen, erste Prisma-Migration erzeugen (`prisma migrate dev`, bisher nur `schema.prisma`) und die in Abschnitt 7 offenen Verifikationsschritte durchführen. From dbaafabcf4c0dd1f11d767c2b21218c244650383 Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 08:01:19 +0200 Subject: [PATCH 05/37] feat(backend): self-registration for Gemeinde Verantwortliche New onboarding/ module. A prospective Verantwortliche/r signs in with their Konfi-Castle-ID (Authentik), looks up a KC by invite code, picks an existing Gemeinde, and registers: - GET /api/onboarding/kc/:inviteCode -> KC name + its Gemeinden (public; the invite code is the shared secret) - POST /api/onboarding/verantwortliche -> verifies the raw Authentik bearer token's claims (no local Membership required yet via new TokenVerificationService.verifyAuthentikClaims), JIT-provisions the local User, and creates a Membership with status PENDING. Idempotent per (user, kc, gemeinde). - GET /api/onboarding/requests?kcId= (LT) list pending - POST /api/onboarding/requests/:id/approve|reject (LT) approve flips to ACTIVE, reject deletes. Schema: Membership gains status (enum MembershipStatus { ACTIVE, PENDING }, default ACTIVE). AuthentikStrategy / TokenVerificationService / TeamAuthService now load only ACTIVE memberships, so a pending request grants nothing until approved. Membership create/update/delete flow through the sync log. Tests: onboarding.service.spec.ts (14 cases); npm test green at 46. Docs (plan + backend README) updated. Co-Authored-By: Claude Sonnet 5 --- backend/README.md | 16 +- backend/prisma/schema.prisma | 13 +- backend/src/app.module.ts | 2 + backend/src/auth/authentik.strategy.ts | 2 +- backend/src/auth/team-auth.service.ts | 2 +- .../src/auth/token-verification.service.ts | 34 ++- .../dto/register-verantwortliche.dto.ts | 11 + .../src/onboarding/onboarding.controller.ts | 67 ++++++ backend/src/onboarding/onboarding.module.ts | 11 + .../src/onboarding/onboarding.service.spec.ts | 224 ++++++++++++++++++ backend/src/onboarding/onboarding.service.ts | 155 ++++++++++++ plan-kcAppMultiTenantPlatform.prompt.md | 14 +- 12 files changed, 533 insertions(+), 18 deletions(-) create mode 100644 backend/src/onboarding/dto/register-verantwortliche.dto.ts create mode 100644 backend/src/onboarding/onboarding.controller.ts create mode 100644 backend/src/onboarding/onboarding.module.ts create mode 100644 backend/src/onboarding/onboarding.service.spec.ts create mode 100644 backend/src/onboarding/onboarding.service.ts diff --git a/backend/README.md b/backend/README.md index fd8764e..d005f65 100644 --- a/backend/README.md +++ b/backend/README.md @@ -59,6 +59,15 @@ client's host - no separate web server is needed. 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. +- `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. - `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 → @@ -96,7 +105,8 @@ client's host - no separate web server is needed. Leitungsteam roles are global across all KCs). All planned backend phases are implemented. `npm test` runs Jest unit tests -(`ZuteilungService`, `TeamAuthService`, `TeamerService`; Prisma mocked). -Remaining work: the Flutter clients (see repo root README), Authentik -provisioning for LT/Verantwortliche, and the first real Prisma migration +(`ZuteilungService`, `TeamAuthService`, `TeamerService`, `OnboardingService`; +Prisma mocked). Remaining work: the Flutter clients (see repo root README), +Authentik JIT provisioning for LT (Verantwortliche already self-provision via +`onboarding/`), invite email delivery, and the first real Prisma migration (only `schema.prisma` exists so far). diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 5812b27..460f05f 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -47,6 +47,14 @@ enum Role { 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` @@ -70,12 +78,13 @@ model User { /// 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()) + id String @id @default(cuid()) userId String kcId String gemeindeId String? role Role - createdAt DateTime @default(now()) + 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) diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 806bcf0..ffffde1 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -7,6 +7,7 @@ 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'; @@ -27,6 +28,7 @@ import { SyncModule } from './sync/sync.module'; KcModule, GemeindeModule, TeamerModule, + OnboardingModule, WahlModule, FilesModule, ChatModule, diff --git a/backend/src/auth/authentik.strategy.ts b/backend/src/auth/authentik.strategy.ts index 3cf9465..f5b88b8 100644 --- a/backend/src/auth/authentik.strategy.ts +++ b/backend/src/auth/authentik.strategy.ts @@ -43,7 +43,7 @@ export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') { async validate(payload: AuthentikJwtPayload): Promise { const user = await this.prisma.user.findUnique({ where: { authentikSub: payload.sub }, - include: { memberships: true }, + include: { memberships: { where: { status: 'ACTIVE' } } }, }); if (!user) { throw new UnauthorizedException('User not provisioned locally yet'); diff --git a/backend/src/auth/team-auth.service.ts b/backend/src/auth/team-auth.service.ts index 5ab4833..048333e 100644 --- a/backend/src/auth/team-auth.service.ts +++ b/backend/src/auth/team-auth.service.ts @@ -141,7 +141,7 @@ export class TeamAuthService { async resolve(userId: string): Promise { const user = await this.prisma.user.findFirst({ where: { id: userId, passwordHash: { not: null } }, - include: { memberships: true }, + include: { memberships: { where: { status: 'ACTIVE' } } }, }); if (!user) { throw new UnauthorizedException('Team account no longer exists'); diff --git a/backend/src/auth/token-verification.service.ts b/backend/src/auth/token-verification.service.ts index 6823c52..73a09b5 100644 --- a/backend/src/auth/token-verification.service.ts +++ b/backend/src/auth/token-verification.service.ts @@ -25,7 +25,15 @@ export class TokenVerificationService { this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true }); } - async verifyAuthentik(token: string): Promise { + /// Verifies an Authentik token's signature and returns its identity claims, + /// without requiring a local User to exist yet (used by the onboarding + /// self-registration path, which provisions that User). + async verifyAuthentikClaims(token: string): Promise<{ + sub: string; + email: string; + firstName: string; + lastName: string; + }> { const decoded = jwt.decode(token, { complete: true }); const kid = decoded?.header.kid; if (!kid) { @@ -35,14 +43,28 @@ export class TokenVerificationService { const payload = jwt.verify(token, key.getPublicKey(), { issuer: this.issuerUrl, algorithms: ['RS256'], - }) as jwt.JwtPayload; - if (!payload.sub) { - throw new UnauthorizedException('Authentik token missing subject'); + }) as jwt.JwtPayload & { + email?: string; + given_name?: string; + family_name?: string; + }; + if (!payload.sub || !payload.email) { + throw new UnauthorizedException('Authentik token missing subject or email'); } + return { + sub: payload.sub, + email: payload.email, + firstName: payload.given_name ?? '', + lastName: payload.family_name ?? '', + }; + } + + async verifyAuthentik(token: string): Promise { + const { sub } = await this.verifyAuthentikClaims(token); const user = await this.prisma.user.findUnique({ - where: { authentikSub: payload.sub }, - include: { memberships: true }, + where: { authentikSub: sub }, + include: { memberships: { where: { status: 'ACTIVE' } } }, }); if (!user) { throw new UnauthorizedException('User not provisioned locally yet'); diff --git a/backend/src/onboarding/dto/register-verantwortliche.dto.ts b/backend/src/onboarding/dto/register-verantwortliche.dto.ts new file mode 100644 index 0000000..13048aa --- /dev/null +++ b/backend/src/onboarding/dto/register-verantwortliche.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class RegisterVerantwortlicheDto { + @IsString() + @IsNotEmpty() + inviteCode!: string; + + @IsString() + @IsNotEmpty() + gemeindeId!: string; +} diff --git a/backend/src/onboarding/onboarding.controller.ts b/backend/src/onboarding/onboarding.controller.ts new file mode 100644 index 0000000..4fb1861 --- /dev/null +++ b/backend/src/onboarding/onboarding.controller.ts @@ -0,0 +1,67 @@ +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'), RolesGuard) + @Roles(Role.LEITUNGSTEAM) + listRequests(@Query('kcId') kcId: string) { + return this.onboarding.listRequests(kcId); + } + + @Post('requests/:membershipId/approve') + @UseGuards(AuthGuard('authentik'), RolesGuard) + @Roles(Role.LEITUNGSTEAM) + approve(@Param('membershipId') membershipId: string) { + return this.onboarding.approve(membershipId); + } + + @Post('requests/:membershipId/reject') + @UseGuards(AuthGuard('authentik'), RolesGuard) + @Roles(Role.LEITUNGSTEAM) + reject(@Param('membershipId') membershipId: string) { + return this.onboarding.reject(membershipId); + } +} diff --git a/backend/src/onboarding/onboarding.module.ts b/backend/src/onboarding/onboarding.module.ts new file mode 100644 index 0000000..307c076 --- /dev/null +++ b/backend/src/onboarding/onboarding.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { OnboardingService } from './onboarding.service'; +import { OnboardingController } from './onboarding.controller'; + +@Module({ + imports: [AuthModule], + providers: [OnboardingService], + controllers: [OnboardingController], +}) +export class OnboardingModule {} diff --git a/backend/src/onboarding/onboarding.service.spec.ts b/backend/src/onboarding/onboarding.service.spec.ts new file mode 100644 index 0000000..7e1ee35 --- /dev/null +++ b/backend/src/onboarding/onboarding.service.spec.ts @@ -0,0 +1,224 @@ +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 }) => + Promise.resolve({ id: 'u-new', ...data }), + ), + }, + membership: { + findUnique: jest.fn(() => Promise.resolve(state.membership)), + findMany: jest.fn().mockResolvedValue([]), + create: jest.fn(({ data }: { data: Record }) => { + 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' }); + }); +}); diff --git a/backend/src/onboarding/onboarding.service.ts b/backend/src/onboarding/onboarding.service.ts new file mode 100644 index 0000000..71b606f --- /dev/null +++ b/backend/src/onboarding/onboarding.service.ts @@ -0,0 +1,155 @@ +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'; + +/// 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 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 this.upsertUser(claims); + + 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 async upsertUser(claims: { + sub: string; + email: string; + firstName: string; + lastName: string; + }) { + const email = claims.email.toLowerCase(); + const existing = await this.prisma.user.findUnique({ + where: { authentikSub: claims.sub }, + }); + if (existing) { + return existing; + } + const user = await this.prisma.user.create({ + data: { + authentikSub: claims.sub, + email, + firstName: claims.firstName, + lastName: claims.lastName, + }, + }); + await this.sync.capture('User', SyncOperation.CREATE, user.id, user); + return user; + } + + private summary( + membershipId: string, + status: MembershipStatus, + kcName: string, + gemeindeName: string, + ) { + return { membershipId, status, kcName, gemeindeName }; + } +} diff --git a/plan-kcAppMultiTenantPlatform.prompt.md b/plan-kcAppMultiTenantPlatform.prompt.md index 1dee59d..6f2bbb2 100644 --- a/plan-kcAppMultiTenantPlatform.prompt.md +++ b/plan-kcAppMultiTenantPlatform.prompt.md @@ -15,8 +15,10 @@ Neuentwicklung, die das WordPress-Plugin **Workshop-Wahlen** (Wahlen/Workshops/T - **Gemeinde Verantwortliche** – pro Gemeinde/KC; verwalten **nur Nutzer ihrer eigenen Gemeinde** (legen Teamer an); Wahlen/Workshops macht ausschließlich LT; zudem Authentik Gruppe und Benutzer. - **Gemeinde Teamer** – von Verantwortlichen angelegt, ebenfalls Lokaler Account. - **Guest/Konfi** – optionaler, rein lokaler Account auf dem jeweiligen Server (kein Authentik), Vor-/Nachname Pflicht, **temporär pro KC/Event** (nicht wiederverwendbar über mehrere Events). -- **Membership**: verknüpft `User` (Authentik) ⇄ `Kc` (+ optional `Gemeinde`) ⇄ `Role`. LT-Memberships lassen `gemeindeId` leer und gelten global. -- **Einstieg/Onboarding**: LT vergibt pro KC einen Code/QR (enthält den KC-Key). Damit erhält man sofortigen Guest-Zugang **oder** kann sich vorab als Gemeinde Verantwortlicher/Teamer einer Gemeinde registrieren. +- **Membership**: verknüpft `User` ⇄ `Kc` (+ optional `Gemeinde`) ⇄ `Role`, mit `status` (`ACTIVE`/`PENDING`). LT-Memberships lassen `gemeindeId` leer und gelten global. `PENDING` (aus der Selbstregistrierung) gewährt keine Rechte, bis ein LT sie genehmigt — die Auth-Strategien laden nur `ACTIVE`-Memberships. +- **Einstieg/Onboarding**: LT vergibt pro KC einen Code/QR (enthält den KC-Key). Damit erhält man sofortigen Guest-Zugang **oder** kann sich vorab registrieren: + - **Gemeinde Verantwortliche/r**: `onboarding/`-Modul — mit Konfi-Castle-ID (Authentik) einloggen, KC-Code + bestehende Gemeinde wählen → `User` wird JIT angelegt, `Membership` als `PENDING`; LT genehmigt. + - **Gemeinde Teamer**: `teamer/`-Modul — von einer Verantwortliche/r direkt angelegt oder per Invite-Link/E-Mail-Invite selbst registriert (lokaler Account, sofort `ACTIVE`). - **Wahl** (Workshop-Wahl): von LT pro KC angelegt; Name trägt `datumsSchluessel` + `teil` (Bewusste Vereinfachung ggü. Original-Plugin: dort gibt es mehrere "Phasen" *innerhalb* einer Wahl via `Teilnehmer.phase`; hier ist stattdessen **eine Wahl = ein Teil/Phase**, gemäß expliziter Nutzer-Klarstellung). - **Workshop**: `kapazitaet`, `minTeilnehmer` (für Konsolidierung unterbesetzter Workshops). - **Teilnehmer**: Guest übermittelt `prioritaeten` (geordnete Workshop-ID-Liste, max. 3 – entspricht wunsch1..wunsch3 im Original). @@ -50,7 +52,7 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris - **Datei-Bytes werden nicht repliziert** – nur Metadaten (inkl. `storageKey`) laufen durchs Sync-Log. Lokaler und Cloud-Server müssen denselben Nextcloud/S3-Backend-Zugriff haben, damit ein `storageKey` auf beiden Seiten auflösbar ist. - **Push-Benachrichtigungen (FCM/APNs)** sind im Plan vorgesehen, aber noch nicht implementiert (Teil der noch ausstehenden Flutter-Client-Arbeit). - **Gemeinde-Verwaltung (CRUD)**: über `GemeindeController` (LT-only) verfügbar — Anlegen/Auflisten/Lesen/Umbenennen/Löschen von Gemeinden pro KC. Gemeinde Verantwortliche/Teamer erfahren ihre eigene Gemeinde weiterhin aus der `Membership`, nicht über diesen Endpunkt. -- **Authentik-Provisionierung (LT + Gemeinde Verantwortliche)**: Diese beiden Rollen laufen über die Konfi-Castle-ID (Authentik). Das Backend erwartet, dass der `User` mit passendem `authentikSub` bereits lokal existiert, bevor er sich einloggen kann — ein automatischer Provisionierungs-/Sync-Pfad aus Authentik heraus fehlt noch. (Gemeinde Teamer brauchen das nicht mehr: seit dieser Session sind sie lokale Accounts, siehe `teamer/` + `auth/team-login`.) +- **Authentik-Provisionierung**: Gemeinde Verantwortliche legen ihren lokalen `User` jetzt selbst über `onboarding/` an (JIT aus den Token-Claims, dann `PENDING` bis LT-Freigabe). **LT** dagegen wird von `AuthentikStrategy` noch nicht JIT angelegt — ein LT-`User` muss vor dem ersten Login manuell existieren. (Gemeinde Teamer sind seit dieser Session lokale Accounts, siehe `teamer/` + `auth/team-login`.) - **Teamer-Identität ist E-Mail-basiert und global eindeutig**: `User.email` ist instanzweit unique, d. h. dieselbe E-Mail kann nicht gleichzeitig Teamer in zwei KCs sein. Für den „pro KC wie Guests"-Fall in der Praxis unkritisch, aber dokumentiert. - **Kein E-Mail-Versand**: `teamer-invites` erzeugt Token/Link; das tatsächliche Verschicken der E-Mail-Invites ist noch nicht angebunden. @@ -64,6 +66,7 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris | `auth/` | Authentik-Resource-Server-Strategie (`AuthGuard('authentik')`), Guest-Login (`AuthGuard('guest')`), **lokaler Teamer-Login** (`AuthGuard('team')`, `TEAM_JWT_SECRET`, bcrypt) inkl. Invite-Redemption, `TokenVerificationService` für manuelle Verifikation außerhalb des HTTP/Passport-Pfads (WS-Handshake, akzeptiert jetzt Authentik/Team/Guest) | `POST /api/auth/guest`, `POST /api/auth/team-login`, `POST /api/auth/teamer/register` | | `kc/` | KC-Verwaltung, Leitungsteam-only | `POST /api/kc`, `GET /api/kc` | | `gemeinde/` | Gemeinde-CRUD pro KC, Leitungsteam-only | `POST /api/gemeinde`, `GET /api/gemeinde?kcId=`, `GET/PATCH/DELETE /api/gemeinde/:id` | +| `onboarding/` | Selbstregistrierung Gemeinde Verantwortliche/r: öffentlicher Invite-Lookup, JIT-`User`-Anlage aus Authentik-Claims, `Membership` im Status `PENDING`; LT sieht/genehmigt/lehnt ab | `GET /api/onboarding/kc/:inviteCode`, `POST /api/onboarding/verantwortliche` (Authentik-Bearer), `GET /api/onboarding/requests?kcId=` (LT), `POST /api/onboarding/requests/:id/approve\|reject` (LT) | | `teamer/` | Lokale Gemeinde-Teamer-Accounts + Invites; Direkt-Anlage, Gruppen-Link und E-Mail-Invite; nutzbar von LT (jede Gemeinde) oder Verantwortliche/r (nur eigene Gemeinde, in `TeamerService` geprüft) | `POST/GET /api/gemeinde/:gemeindeId/teamer`, `DELETE /api/gemeinde/:gemeindeId/teamer/:userId`, `POST/GET /api/gemeinde/:gemeindeId/teamer-invites`, `DELETE .../teamer-invites/:id` | | `wahl/` | Wahl-/Workshop-Verwaltung, Force-Zuteilung, Teilnehmer-Einreichung, `ZuteilungService` (Portierung von `kc_run_zuteilung`: Force-Zuteilungen → 3 Wunschrunden → Zufallsfüllung → Konsolidierung unterbesetzter Workshops), CSV-Export | `POST/GET /api/wahl`, `POST/GET /api/wahl/:id/workshops`, `POST /api/wahl/:id/force-zuteilung`, `POST /api/wahl/:id/teilnehmer` (Guest), `POST /api/wahl/:id/zuteilung/run`, `GET /api/wahl/:id/zuteilung(.csv)` | | `files/` | Upload (LT-only, multipart) mit Sichtbarkeitsstufe; Liste/Download für Team oder Guest, gefiltert nach erlaubten Sichtbarkeitsstufen; `StorageProvider`-Abstraktion (WebDAV/Nextcloud Default, S3 optional) | `POST /api/files/:kcId`, `GET /api/files/:kcId`, `GET /api/files/download/:fileId` | @@ -107,10 +110,11 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM 1. Nach jeder Phase: `npx tsc -p tsconfig.build.json --noEmit`, `npx nest build`, sowie ein kurzer Boot-Test (`node dist/main.js`) zur Prüfung, dass der DI-Graph auflöst und alle Routen korrekt gemappt werden (ohne Live-DB/Authentik/Nextcloud). 2. Sync-Modul: manuell verifiziert, dass `SyncSecretGuard` Requests ohne `x-sync-secret` mit 403 ablehnt und mit korrektem Secret durchlässt (DB-Fehler in der Sandbox ist erwartet, da kein Postgres läuft). 3. Web-Client: `GET /` liefert die statische Seite (200), `GET /api/kc` trifft die echte, geschützte API (401 ohne Token). -4. Jest-Unit-Tests (Prisma/Sync gemockt, `npm test` grün, 32 Tests): +4. Jest-Unit-Tests (Prisma/Sync gemockt, `npm test` grün, 46 Tests): - `src/wahl/zuteilung.service.spec.ts`: Force-Vorrang, Wunschrunden-Fallback bei voller Kapazität, Unzugeteilt-Fall, Konsolidierung unterbesetzter Workshops, Sync-Capture-Anzahl. - `src/auth/team-auth.service.spec.ts`: Invite-Redemption (unbekannt/widerrufen/abgelaufen/aufgebraucht, Gruppen-Link ohne E-Mail, E-Mail-Mismatch, Dublette) + Passwort-Login. - `src/teamer/teamer.service.spec.ts`: Gemeinde-Scope-Check (LT global, Verantwortliche/r nur eigene Gemeinde), Direkt-Anlage, Invite-Defaults, Löschung. + - `src/onboarding/onboarding.service.spec.ts`: Invite-Lookup, Verantwortlichen-Selbstregistrierung (Token fehlt/ungültig, unbekannter Code, Gemeinde nicht im KC, JIT-User + `PENDING`, Idempotenz), Approve/Reject. 5. Noch ausstehend (sobald echte Infrastruktur verfügbar ist): Zuteilungslogik gegen bekannte Testdaten aus dem alten Plugin validieren; Ende-zu-Ende-Rollenmatrix (LT/Verantwortlicher/Teamer/Guest) über alle Kernfeatures; echter Sync-Test zwischen zwei laufenden Instanzen (lokal + Cloud). --- @@ -118,7 +122,7 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM ## 8. Nächste Schritte 1. Sobald Flutter verfügbar ist: Client-Grundgerüst aufsetzen (Mobile + Web + Desktop, eine Codebase), beginnend mit Invite/Login-Flow gegen die bestehende API. -2. Authentik-Provisionierung für LT + Gemeinde Verantwortliche automatisieren (JIT-Anlage des lokalen `User` beim ersten Login aus den Token-Claims, oder Sync aus der Authentik-Admin-API). +2. Authentik-JIT für **LT** vervollständigen: `AuthentikStrategy` legt den lokalen `User` beim ersten Login noch nicht selbst an (nur der `onboarding/`-Pfad für Verantwortliche tut das). LT bleibt bis dahin auf manuelle `User`-Anlage angewiesen. 3. E-Mail-Versand für `teamer-invites` anbinden (Mailer + Templates); aktuell wird nur Token/Link erzeugt. 4. Push-Benachrichtigungen (FCM/APNs) für Chat/Ankündigungen. 5. Echte Infrastruktur (Postgres, Authentik, Nextcloud/S3) aufsetzen, erste Prisma-Migration erzeugen (`prisma migrate dev`, bisher nur `schema.prisma`) und die in Abschnitt 7 offenen Verifikationsschritte durchführen. From 5079d48905d2790b8d70f19283253119dc49ee42 Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 08:05:18 +0200 Subject: [PATCH 06/37] feat(backend): JIT-provision the local User on first Authentik login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AuthentikStrategy no longer rejects a valid token whose user has no local row — it creates the User from the token claims (given_name/family_name/ email) via the new shared resolveOrProvisionAuthentikUser helper, which is race-safe (P2002 -> re-read) and captures the User to the sync log. The WS token path (TokenVerificationService.verifyAuthentik) and OnboardingService now use the same helper, removing three copies of the lookup/create logic. A provisioned user still has no Membership and therefore no rights: LT role assignment from Authentik groups is the remaining gap; Verantwortliche go through the onboarding approval flow. Tests: provision-user.spec.ts (existing/new/race/rethrow); npm test green at 51. Docs updated. Co-Authored-By: Claude Sonnet 5 --- backend/README.md | 27 +++-- backend/src/auth/authentik.strategy.ts | 26 +++-- backend/src/auth/provision-user.spec.ts | 104 ++++++++++++++++++ backend/src/auth/provision-user.ts | 58 ++++++++++ .../src/auth/token-verification.service.ts | 14 +-- backend/src/onboarding/onboarding.service.ts | 28 +---- plan-kcAppMultiTenantPlatform.prompt.md | 9 +- 7 files changed, 206 insertions(+), 60 deletions(-) create mode 100644 backend/src/auth/provision-user.spec.ts create mode 100644 backend/src/auth/provision-user.ts diff --git a/backend/README.md b/backend/README.md index d005f65..6077422 100644 --- a/backend/README.md +++ b/backend/README.md @@ -20,12 +20,16 @@ client's host - no separate web server is needed. ## Auth model -- Leitungsteam and Gemeinde Verantwortliche are provisioned in Authentik - (the "Konfi-Castle-ID"); this API acts as an OIDC **resource server**, - verifying access tokens against Authentik's JWKS (`AuthentikStrategy`) and - then resolving local `Membership` rows to determine role + KC/Gemeinde - scope. Clients perform the actual Authorization Code + PKCE flow against - Authentik directly. +- 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`); role + KC/Gemeinde scope then come + from local `Membership` rows (only `status = ACTIVE` ones count). A freshly + provisioned user has no membership and thus no rights until one is granted + (LT: manually for now; Verantwortliche: the `onboarding/` approval flow). + 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` @@ -105,8 +109,9 @@ client's host - no separate web server is needed. Leitungsteam roles are global across all KCs). All planned backend phases are implemented. `npm test` runs Jest unit tests -(`ZuteilungService`, `TeamAuthService`, `TeamerService`, `OnboardingService`; -Prisma mocked). Remaining work: the Flutter clients (see repo root README), -Authentik JIT provisioning for LT (Verantwortliche already self-provision via -`onboarding/`), invite email delivery, and the first real Prisma migration -(only `schema.prisma` exists so far). +(`ZuteilungService`, `TeamAuthService`, `TeamerService`, `OnboardingService`, +`resolveOrProvisionAuthentikUser`; Prisma mocked). Remaining work: the +Flutter clients (see repo root README), deriving the LT `Membership` from +Authentik group claims (the `User` is provisioned, the role is not), invite +email delivery, and the first real Prisma migration (only `schema.prisma` +exists so far). diff --git a/backend/src/auth/authentik.strategy.ts b/backend/src/auth/authentik.strategy.ts index f5b88b8..ffcd0d3 100644 --- a/backend/src/auth/authentik.strategy.ts +++ b/backend/src/auth/authentik.strategy.ts @@ -5,24 +5,28 @@ 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 { resolveOrProvisionAuthentikUser } from './provision-user'; interface AuthentikJwtPayload { sub: string; - email: string; + email?: string; given_name?: string; family_name?: string; } /// Validates access tokens issued by Authentik (resource-server pattern): -/// signature is checked against Authentik's JWKS, then the local Membership -/// table decides what the user may do. Authentik itself is only the identity -/// source, never asked for authorization here. +/// signature is checked against Authentik's JWKS, the local `User` is +/// provisioned on first login (JIT), 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') { constructor( config: ConfigService, private readonly prisma: PrismaClient, + private readonly sync: SyncService, ) { const issuerUrl = config.getOrThrow('AUTHENTIK_ISSUER_URL'); super({ @@ -41,13 +45,15 @@ export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') { } async validate(payload: AuthentikJwtPayload): Promise { - const user = await this.prisma.user.findUnique({ - where: { authentikSub: payload.sub }, - include: { memberships: { where: { status: 'ACTIVE' } } }, - }); - if (!user) { - throw new UnauthorizedException('User not provisioned locally yet'); + if (!payload.email) { + throw new UnauthorizedException('Authentik token missing email claim'); } + const user = await resolveOrProvisionAuthentikUser(this.prisma, this.sync, { + sub: payload.sub, + email: payload.email, + firstName: payload.given_name ?? '', + lastName: payload.family_name ?? '', + }); return { userId: user.id, authentikSub: user.authentikSub, diff --git a/backend/src/auth/provision-user.spec.ts b/backend/src/auth/provision-user.spec.ts new file mode 100644 index 0000000..8377bec --- /dev/null +++ b/backend/src/auth/provision-user.spec.ts @@ -0,0 +1,104 @@ +import { Prisma } from '@prisma/client'; +import { resolveOrProvisionAuthentikUser } 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', + }); +} + +function makeMocks() { + const sync = { capture: jest.fn().mockResolvedValue(undefined) }; + return { sync }; +} + +describe('resolveOrProvisionAuthentikUser', () => { + it('returns the existing user without creating or capturing', async () => { + const { sync } = makeMocks(); + const existing = { id: 'u-1', authentikSub: 'sub-1', memberships: [] }; + const prisma = { + user: { + findUnique: jest.fn().mockResolvedValue(existing), + create: jest.fn(), + }, + }; + const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS); + expect(res).toBe(existing); + expect(prisma.user.create).not.toHaveBeenCalled(); + expect(sync.capture).not.toHaveBeenCalled(); + }); + + it('provisions a new user from claims (lowercased email) and captures it', async () => { + const { sync } = makeMocks(); + const prisma = { + user: { + findUnique: jest.fn().mockResolvedValue(null), + create: jest.fn(({ data }: { data: Record }) => + Promise.resolve({ id: 'u-2', ...data }), + ), + }, + }; + const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS); + 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('recovers from a concurrent-create race (P2002) by re-reading', async () => { + const { sync } = makeMocks(); + const raced = { id: 'u-3', authentikSub: 'sub-1', memberships: [] }; + const prisma = { + user: { + findUnique: jest + .fn() + .mockResolvedValueOnce(null) // first check: not there yet + .mockResolvedValueOnce(raced), // after the failed insert: it exists + create: jest.fn().mockRejectedValue(p2002()), + }, + }; + const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS); + expect(res).toBe(raced); + expect(sync.capture).not.toHaveBeenCalled(); + }); + + it('rethrows a P2002 when the row still cannot be found', async () => { + const { sync } = makeMocks(); + const prisma = { + user: { + findUnique: jest.fn().mockResolvedValue(null), + create: jest.fn().mockRejectedValue(p2002()), + }, + }; + await expect( + resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS), + ).rejects.toBeInstanceOf(Prisma.PrismaClientKnownRequestError); + }); + + it('rethrows a non-P2002 error', async () => { + const { sync } = makeMocks(); + const prisma = { + user: { + findUnique: jest.fn().mockResolvedValue(null), + create: jest.fn().mockRejectedValue(new Error('db down')), + }, + }; + await expect( + resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS), + ).rejects.toThrow('db down'); + }); +}); diff --git a/backend/src/auth/provision-user.ts b/backend/src/auth/provision-user.ts new file mode 100644 index 0000000..4e15fed --- /dev/null +++ b/backend/src/auth/provision-user.ts @@ -0,0 +1,58 @@ +import { Prisma, SyncOperation } from '@prisma/client'; +import { PrismaClient } from '../prisma/prisma.module'; +import { SyncService } from '../sync/sync.service'; + +export interface AuthentikClaims { + sub: string; + email: string; + firstName: string; + lastName: string; +} + +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). The new user has no +/// memberships and therefore no rights until one is granted (LT via Authentik +/// group sync — still manual — or the onboarding approval flow). Shared by +/// AuthentikStrategy and the WS token path so both provision identically. +export async function resolveOrProvisionAuthentikUser( + prisma: PrismaClient, + sync: SyncService, + claims: AuthentikClaims, +): Promise { + 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; + } +} diff --git a/backend/src/auth/token-verification.service.ts b/backend/src/auth/token-verification.service.ts index 73a09b5..a6fe7f7 100644 --- a/backend/src/auth/token-verification.service.ts +++ b/backend/src/auth/token-verification.service.ts @@ -4,9 +4,11 @@ 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 { resolveOrProvisionAuthentikUser } from './provision-user'; /// Verifies raw bearer tokens outside the HTTP/passport pipeline, needed for /// the WebSocket handshake where AuthGuard('authentik'|'guest') don't apply. @@ -20,6 +22,7 @@ export class TokenVerificationService { private readonly prisma: PrismaClient, private readonly guestJwt: JwtService, private readonly teamAuth: TeamAuthService, + private readonly sync: SyncService, ) { this.issuerUrl = config.getOrThrow('AUTHENTIK_ISSUER_URL'); this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true }); @@ -60,15 +63,8 @@ export class TokenVerificationService { } async verifyAuthentik(token: string): Promise { - const { sub } = await this.verifyAuthentikClaims(token); - - const user = await this.prisma.user.findUnique({ - where: { authentikSub: sub }, - include: { memberships: { where: { status: 'ACTIVE' } } }, - }); - if (!user) { - throw new UnauthorizedException('User not provisioned locally yet'); - } + const claims = await this.verifyAuthentikClaims(token); + const user = await resolveOrProvisionAuthentikUser(this.prisma, this.sync, claims); return { userId: user.id, authentikSub: user.authentikSub, diff --git a/backend/src/onboarding/onboarding.service.ts b/backend/src/onboarding/onboarding.service.ts index 71b606f..081c6e5 100644 --- a/backend/src/onboarding/onboarding.service.ts +++ b/backend/src/onboarding/onboarding.service.ts @@ -8,6 +8,7 @@ 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 @@ -52,7 +53,7 @@ export class OnboardingService { throw new BadRequestException('Gemeinde does not belong to this KC'); } - const user = await this.upsertUser(claims); + const user = await resolveOrProvisionAuthentikUser(this.prisma, this.sync, claims); const existing = await this.prisma.membership.findUnique({ where: { @@ -119,31 +120,6 @@ export class OnboardingService { return membership; } - private async upsertUser(claims: { - sub: string; - email: string; - firstName: string; - lastName: string; - }) { - const email = claims.email.toLowerCase(); - const existing = await this.prisma.user.findUnique({ - where: { authentikSub: claims.sub }, - }); - if (existing) { - return existing; - } - const user = await this.prisma.user.create({ - data: { - authentikSub: claims.sub, - email, - firstName: claims.firstName, - lastName: claims.lastName, - }, - }); - await this.sync.capture('User', SyncOperation.CREATE, user.id, user); - return user; - } - private summary( membershipId: string, status: MembershipStatus, diff --git a/plan-kcAppMultiTenantPlatform.prompt.md b/plan-kcAppMultiTenantPlatform.prompt.md index 6f2bbb2..ff3baf3 100644 --- a/plan-kcAppMultiTenantPlatform.prompt.md +++ b/plan-kcAppMultiTenantPlatform.prompt.md @@ -52,7 +52,7 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris - **Datei-Bytes werden nicht repliziert** – nur Metadaten (inkl. `storageKey`) laufen durchs Sync-Log. Lokaler und Cloud-Server müssen denselben Nextcloud/S3-Backend-Zugriff haben, damit ein `storageKey` auf beiden Seiten auflösbar ist. - **Push-Benachrichtigungen (FCM/APNs)** sind im Plan vorgesehen, aber noch nicht implementiert (Teil der noch ausstehenden Flutter-Client-Arbeit). - **Gemeinde-Verwaltung (CRUD)**: über `GemeindeController` (LT-only) verfügbar — Anlegen/Auflisten/Lesen/Umbenennen/Löschen von Gemeinden pro KC. Gemeinde Verantwortliche/Teamer erfahren ihre eigene Gemeinde weiterhin aus der `Membership`, nicht über diesen Endpunkt. -- **Authentik-Provisionierung**: Gemeinde Verantwortliche legen ihren lokalen `User` jetzt selbst über `onboarding/` an (JIT aus den Token-Claims, dann `PENDING` bis LT-Freigabe). **LT** dagegen wird von `AuthentikStrategy` noch nicht JIT angelegt — ein LT-`User` muss vor dem ersten Login manuell existieren. (Gemeinde Teamer sind seit dieser Session lokale Accounts, siehe `teamer/` + `auth/team-login`.) +- **Authentik-Provisionierung**: Jeder gültige Authentik-Login legt den lokalen `User` jetzt automatisch an (`AuthentikStrategy` → `resolveOrProvisionAuthentikUser`, JIT, race-sicher). Was noch fehlt: die **Rollen-/Gruppen-Zuordnung aus Authentik** — ein frisch angelegter `User` hat keine `Membership`, also keine Rechte. LT-Rechte müssen aktuell per manueller `Membership(LEITUNGSTEAM)` gesetzt werden; Verantwortliche laufen über den `onboarding/`-Freigabepfad. (Gemeinde Teamer sind lokale Accounts, siehe `teamer/` + `auth/team-login`.) - **Teamer-Identität ist E-Mail-basiert und global eindeutig**: `User.email` ist instanzweit unique, d. h. dieselbe E-Mail kann nicht gleichzeitig Teamer in zwei KCs sein. Für den „pro KC wie Guests"-Fall in der Praxis unkritisch, aber dokumentiert. - **Kein E-Mail-Versand**: `teamer-invites` erzeugt Token/Link; das tatsächliche Verschicken der E-Mail-Invites ist noch nicht angebunden. @@ -63,7 +63,7 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris | Modul | Kernfunktion | Wichtige Endpunkte | |---|---|---| | `prisma/` | Geteilter `PrismaClient`-Provider | – | -| `auth/` | Authentik-Resource-Server-Strategie (`AuthGuard('authentik')`), Guest-Login (`AuthGuard('guest')`), **lokaler Teamer-Login** (`AuthGuard('team')`, `TEAM_JWT_SECRET`, bcrypt) inkl. Invite-Redemption, `TokenVerificationService` für manuelle Verifikation außerhalb des HTTP/Passport-Pfads (WS-Handshake, akzeptiert jetzt Authentik/Team/Guest) | `POST /api/auth/guest`, `POST /api/auth/team-login`, `POST /api/auth/teamer/register` | +| `auth/` | Authentik-Resource-Server-Strategie (`AuthGuard('authentik')`) mit **JIT-`User`-Anlage** beim ersten Login (`resolveOrProvisionAuthentikUser`, race-sicher), Guest-Login (`AuthGuard('guest')`), **lokaler Teamer-Login** (`AuthGuard('team')`, `TEAM_JWT_SECRET`, bcrypt) inkl. Invite-Redemption, `TokenVerificationService` für manuelle Verifikation außerhalb des HTTP/Passport-Pfads (WS-Handshake, akzeptiert Authentik/Team/Guest). Alle Strategien laden nur `ACTIVE`-Memberships. | `POST /api/auth/guest`, `POST /api/auth/team-login`, `POST /api/auth/teamer/register` | | `kc/` | KC-Verwaltung, Leitungsteam-only | `POST /api/kc`, `GET /api/kc` | | `gemeinde/` | Gemeinde-CRUD pro KC, Leitungsteam-only | `POST /api/gemeinde`, `GET /api/gemeinde?kcId=`, `GET/PATCH/DELETE /api/gemeinde/:id` | | `onboarding/` | Selbstregistrierung Gemeinde Verantwortliche/r: öffentlicher Invite-Lookup, JIT-`User`-Anlage aus Authentik-Claims, `Membership` im Status `PENDING`; LT sieht/genehmigt/lehnt ab | `GET /api/onboarding/kc/:inviteCode`, `POST /api/onboarding/verantwortliche` (Authentik-Bearer), `GET /api/onboarding/requests?kcId=` (LT), `POST /api/onboarding/requests/:id/approve\|reject` (LT) | @@ -110,11 +110,12 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM 1. Nach jeder Phase: `npx tsc -p tsconfig.build.json --noEmit`, `npx nest build`, sowie ein kurzer Boot-Test (`node dist/main.js`) zur Prüfung, dass der DI-Graph auflöst und alle Routen korrekt gemappt werden (ohne Live-DB/Authentik/Nextcloud). 2. Sync-Modul: manuell verifiziert, dass `SyncSecretGuard` Requests ohne `x-sync-secret` mit 403 ablehnt und mit korrektem Secret durchlässt (DB-Fehler in der Sandbox ist erwartet, da kein Postgres läuft). 3. Web-Client: `GET /` liefert die statische Seite (200), `GET /api/kc` trifft die echte, geschützte API (401 ohne Token). -4. Jest-Unit-Tests (Prisma/Sync gemockt, `npm test` grün, 46 Tests): +4. Jest-Unit-Tests (Prisma/Sync gemockt, `npm test` grün, 51 Tests): - `src/wahl/zuteilung.service.spec.ts`: Force-Vorrang, Wunschrunden-Fallback bei voller Kapazität, Unzugeteilt-Fall, Konsolidierung unterbesetzter Workshops, Sync-Capture-Anzahl. - `src/auth/team-auth.service.spec.ts`: Invite-Redemption (unbekannt/widerrufen/abgelaufen/aufgebraucht, Gruppen-Link ohne E-Mail, E-Mail-Mismatch, Dublette) + Passwort-Login. - `src/teamer/teamer.service.spec.ts`: Gemeinde-Scope-Check (LT global, Verantwortliche/r nur eigene Gemeinde), Direkt-Anlage, Invite-Defaults, Löschung. - `src/onboarding/onboarding.service.spec.ts`: Invite-Lookup, Verantwortlichen-Selbstregistrierung (Token fehlt/ungültig, unbekannter Code, Gemeinde nicht im KC, JIT-User + `PENDING`, Idempotenz), Approve/Reject. + - `src/auth/provision-user.spec.ts`: JIT-`User`-Anlage aus Authentik-Claims inkl. Race-Recovery (P2002 → Re-Read) und Fehler-Weiterreichung. 5. Noch ausstehend (sobald echte Infrastruktur verfügbar ist): Zuteilungslogik gegen bekannte Testdaten aus dem alten Plugin validieren; Ende-zu-Ende-Rollenmatrix (LT/Verantwortlicher/Teamer/Guest) über alle Kernfeatures; echter Sync-Test zwischen zwei laufenden Instanzen (lokal + Cloud). --- @@ -122,7 +123,7 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM ## 8. Nächste Schritte 1. Sobald Flutter verfügbar ist: Client-Grundgerüst aufsetzen (Mobile + Web + Desktop, eine Codebase), beginnend mit Invite/Login-Flow gegen die bestehende API. -2. Authentik-JIT für **LT** vervollständigen: `AuthentikStrategy` legt den lokalen `User` beim ersten Login noch nicht selbst an (nur der `onboarding/`-Pfad für Verantwortliche tut das). LT bleibt bis dahin auf manuelle `User`-Anlage angewiesen. +2. LT-Rolle aus Authentik ableiten: `User` wird beim ersten Login jetzt JIT angelegt, aber die `Membership(LEITUNGSTEAM)` noch nicht — aus den Authentik-Gruppen-Claims des Tokens (oder per Admin-API-Sync) eine globale LT-Membership erzeugen/entfernen. 3. E-Mail-Versand für `teamer-invites` anbinden (Mailer + Templates); aktuell wird nur Token/Link erzeugt. 4. Push-Benachrichtigungen (FCM/APNs) für Chat/Ankündigungen. 5. Echte Infrastruktur (Postgres, Authentik, Nextcloud/S3) aufsetzen, erste Prisma-Migration erzeugen (`prisma migrate dev`, bisher nur `schema.prisma`) und die in Abschnitt 7 offenen Verifikationsschritte durchführen. From 24f8070b8a9788df434c416c583200a2a97555fe Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 08:13:20 +0200 Subject: [PATCH 07/37] feat(backend): derive LEITUNGSTEAM from the Authentik groups claim On every Authentik login the token's `groups` claim is compared against AUTHENTIK_LEITUNGSTEAM_GROUP (default "Leitungsteam") and mirrored to the new User.isLeitungsteam column. LT is global, not KC-scoped, so it lives on the User rather than as a per-KC Membership row: toAuthenticatedUser() synthesises a virtual global LEITUNGSTEAM membership from the flag, so RolesGuard / visibility / TeamerService keep working unchanged. - provision helper gains an isLeitungsteam arg and reconciles the flag both ways (grant on join, drop when the group is gone), capturing a User UPDATE to the sync log. - verifyAuthentikClaims() now also returns isLeitungsteam; strategy, WS path and onboarding all funnel through the shared helper + mapper. - new env var AUTHENTIK_LEITUNGSTEAM_GROUP. Tests: provision-user.spec.ts extended (flag up/down, virtual membership); npm test green at 55. Docs updated; ops note added that the Authentik provider must emit the groups claim. Co-Authored-By: Claude Sonnet 5 --- backend/.env.example | 5 + backend/README.md | 26 ++-- backend/prisma/schema.prisma | 21 +-- backend/src/auth/authentik.strategy.ts | 42 +++--- backend/src/auth/provision-user.spec.ts | 125 ++++++++++++++---- backend/src/auth/provision-user.ts | 63 ++++++++- .../src/auth/token-verification.service.ts | 46 ++++--- backend/src/onboarding/onboarding.service.ts | 9 +- plan-kcAppMultiTenantPlatform.prompt.md | 14 +- 9 files changed, 248 insertions(+), 103 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index bced9a5..668d845 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -4,6 +4,11 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/kcapp?schema=public" # Authentik OIDC issuer, e.g. https://auth.example.org/application/o/kc-app/ AUTHENTIK_ISSUER_URL="https://authentik.example.org/application/o/kc-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="Leitungsteam" + # Secret used to sign guest/Konfi session tokens (local accounts only) GUEST_JWT_SECRET="change-me" diff --git a/backend/README.md b/backend/README.md index 6077422..64a605f 100644 --- a/backend/README.md +++ b/backend/README.md @@ -7,7 +7,7 @@ architecture context). ```bash npm install -cp .env.example .env # then fill in DATABASE_URL / AUTHENTIK_ISSUER_URL / GUEST_JWT_SECRET / TEAM_JWT_SECRET +cp .env.example .env # DATABASE_URL / AUTHENTIK_ISSUER_URL / AUTHENTIK_LEITUNGSTEAM_GROUP / GUEST_JWT_SECRET / TEAM_JWT_SECRET npx prisma generate npx prisma migrate dev --name init # requires a running PostgreSQL instance npm run start:dev @@ -24,12 +24,14 @@ client's host - no separate web server is needed. "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`); role + KC/Gemeinde scope then come - from local `Membership` rows (only `status = ACTIVE` ones count). A freshly - provisioned user has no membership and thus no rights until one is granted - (LT: manually for now; Verantwortliche: the `onboarding/` approval flow). - Clients perform the Authorization Code + PKCE flow against Authentik - directly. + (`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` @@ -110,8 +112,8 @@ client's host - no separate web server is needed. All planned backend phases are implemented. `npm test` runs Jest unit tests (`ZuteilungService`, `TeamAuthService`, `TeamerService`, `OnboardingService`, -`resolveOrProvisionAuthentikUser`; Prisma mocked). Remaining work: the -Flutter clients (see repo root README), deriving the LT `Membership` from -Authentik group claims (the `User` is provisioned, the role is not), invite -email delivery, and the first real Prisma migration (only `schema.prisma` -exists so far). +`resolveOrProvisionAuthentikUser` / `toAuthenticatedUser`; Prisma mocked). +Remaining work: the Flutter clients (see repo root README), invite email +delivery, push notifications, and the first real Prisma migration (only +`schema.prisma` exists so far). Ops note: the Authentik provider must emit a +`groups` claim in the access token for the LT check to work. diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 460f05f..5b89be4 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -60,14 +60,19 @@ enum MembershipStatus { /// 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? - createdAt DateTime @default(now()) + 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[] diff --git a/backend/src/auth/authentik.strategy.ts b/backend/src/auth/authentik.strategy.ts index ffcd0d3..9fca443 100644 --- a/backend/src/auth/authentik.strategy.ts +++ b/backend/src/auth/authentik.strategy.ts @@ -7,22 +7,26 @@ import { Request } from 'express'; import { PrismaClient } from '../prisma/prisma.module'; import { SyncService } from '../sync/sync.service'; import { AuthenticatedUser } from './authenticated-request'; -import { resolveOrProvisionAuthentikUser } from './provision-user'; +import { resolveOrProvisionAuthentikUser, toAuthenticatedUser } from './provision-user'; interface AuthentikJwtPayload { sub: string; email?: string; given_name?: string; family_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), then the local Membership table decides -/// what the user may do. Authentik itself is only the identity source, never -/// asked for authorization here. +/// 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, @@ -42,27 +46,25 @@ export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') { issuer: issuerUrl, algorithms: ['RS256'], }); + this.leitungsteamGroup = config.get('AUTHENTIK_LEITUNGSTEAM_GROUP', 'Leitungsteam'); } async validate(payload: AuthentikJwtPayload): Promise { if (!payload.email) { throw new UnauthorizedException('Authentik token missing email claim'); } - const user = await resolveOrProvisionAuthentikUser(this.prisma, this.sync, { - sub: payload.sub, - email: payload.email, - firstName: payload.given_name ?? '', - lastName: payload.family_name ?? '', - }); - return { - userId: user.id, - authentikSub: user.authentikSub, - email: user.email, - memberships: user.memberships.map((m) => ({ - kcId: m.kcId, - gemeindeId: m.gemeindeId, - role: m.role, - })), - }; + const isLeitungsteam = (payload.groups ?? []).includes(this.leitungsteamGroup); + const user = await resolveOrProvisionAuthentikUser( + this.prisma, + this.sync, + { + sub: payload.sub, + email: payload.email, + firstName: payload.given_name ?? '', + lastName: payload.family_name ?? '', + }, + isLeitungsteam, + ); + return toAuthenticatedUser(user); } } diff --git a/backend/src/auth/provision-user.spec.ts b/backend/src/auth/provision-user.spec.ts index 8377bec..6555462 100644 --- a/backend/src/auth/provision-user.spec.ts +++ b/backend/src/auth/provision-user.spec.ts @@ -1,5 +1,9 @@ -import { Prisma } from '@prisma/client'; -import { resolveOrProvisionAuthentikUser } from './provision-user'; +import { Prisma, Role } from '@prisma/client'; +import { + GLOBAL_LT_KC_ID, + resolveOrProvisionAuthentikUser, + toAuthenticatedUser, +} from './provision-user'; const CLAIMS = { sub: 'sub-1', @@ -15,38 +19,36 @@ function p2002() { }); } -function makeMocks() { - const sync = { capture: jest.fn().mockResolvedValue(undefined) }; - return { sync }; -} - describe('resolveOrProvisionAuthentikUser', () => { - it('returns the existing user without creating or capturing', async () => { - const { sync } = makeMocks(); - const existing = { id: 'u-1', authentikSub: 'sub-1', memberships: [] }; + 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); + 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 } = makeMocks(); + const sync = { capture: jest.fn().mockResolvedValue(undefined) }; const prisma = { user: { findUnique: jest.fn().mockResolvedValue(null), create: jest.fn(({ data }: { data: Record }) => - Promise.resolve({ id: 'u-2', ...data }), + Promise.resolve({ id: 'u-2', isLeitungsteam: false, ...data }), ), + update: jest.fn(), }, }; - const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS); + const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false); expect(prisma.user.create).toHaveBeenCalledWith({ data: { authentikSub: 'sub-1', @@ -59,46 +61,115 @@ describe('resolveOrProvisionAuthentikUser', () => { expect(sync.capture).toHaveBeenCalledWith('User', 'CREATE', 'u-2', expect.anything()); }); - it('recovers from a concurrent-create race (P2002) by re-reading', async () => { - const { sync } = makeMocks(); - const raced = { id: 'u-3', authentikSub: 'sub-1', memberships: [] }; + 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() - .mockResolvedValueOnce(null) // first check: not there yet - .mockResolvedValueOnce(raced), // after the failed insert: it exists - create: jest.fn().mockRejectedValue(p2002()), + findUnique: jest.fn().mockResolvedValue(existing), + create: jest.fn(), + update: jest.fn(({ data }: { data: Record }) => + Promise.resolve({ ...existing, ...data, memberships: [] }), + ), }, }; - const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS); + 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 }) => + 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 } = makeMocks(); + 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), + resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false), ).rejects.toBeInstanceOf(Prisma.PrismaClientKnownRequestError); }); it('rethrows a non-P2002 error', async () => { - const { sync } = makeMocks(); + 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), + 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); + }); +}); diff --git a/backend/src/auth/provision-user.ts b/backend/src/auth/provision-user.ts index 4e15fed..be68c4e 100644 --- a/backend/src/auth/provision-user.ts +++ b/backend/src/auth/provision-user.ts @@ -1,6 +1,7 @@ -import { Prisma, SyncOperation } from '@prisma/client'; +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; @@ -9,19 +10,48 @@ export interface AuthentikClaims { lastName: string; } +/// 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). The new user has no -/// memberships and therefore no rights until one is granted (LT via Authentik -/// group sync — still manual — or the onboarding approval flow). Shared by -/// AuthentikStrategy and the WS token path so both provision identically. +/// 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 { + 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 { const existing = await prisma.user.findUnique({ where: { authentikSub: claims.sub }, @@ -56,3 +86,26 @@ export async function resolveOrProvisionAuthentikUser( 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, + }; +} diff --git a/backend/src/auth/token-verification.service.ts b/backend/src/auth/token-verification.service.ts index a6fe7f7..f3caa60 100644 --- a/backend/src/auth/token-verification.service.ts +++ b/backend/src/auth/token-verification.service.ts @@ -8,7 +8,11 @@ import { SyncService } from '../sync/sync.service'; import { AuthenticatedUser } from './authenticated-request'; import { GuestJwtPayload } from './guest-auth.service'; import { TeamAuthService } from './team-auth.service'; -import { resolveOrProvisionAuthentikUser } from './provision-user'; +import { + AuthentikClaims, + 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. @@ -16,6 +20,7 @@ import { resolveOrProvisionAuthentikUser } from './provision-user'; export class TokenVerificationService { private readonly issuerUrl: string; private readonly jwks: jwksRsa.JwksClient; + private readonly leitungsteamGroup: string; constructor( private readonly config: ConfigService, @@ -26,17 +31,16 @@ export class TokenVerificationService { ) { this.issuerUrl = config.getOrThrow('AUTHENTIK_ISSUER_URL'); this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true }); + this.leitungsteamGroup = config.get('AUTHENTIK_LEITUNGSTEAM_GROUP', 'Leitungsteam'); } - /// Verifies an Authentik token's signature and returns its identity claims, - /// without requiring a local User to exist yet (used by the onboarding - /// self-registration path, which provisions that User). - async verifyAuthentikClaims(token: string): Promise<{ - sub: string; - email: string; - firstName: string; - lastName: string; - }> { + /// 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 { const decoded = jwt.decode(token, { complete: true }); const kid = decoded?.header.kid; if (!kid) { @@ -50,6 +54,7 @@ export class TokenVerificationService { email?: string; given_name?: string; family_name?: string; + groups?: string[]; }; if (!payload.sub || !payload.email) { throw new UnauthorizedException('Authentik token missing subject or email'); @@ -59,22 +64,19 @@ export class TokenVerificationService { email: payload.email, firstName: payload.given_name ?? '', lastName: payload.family_name ?? '', + isLeitungsteam: (payload.groups ?? []).includes(this.leitungsteamGroup), }; } async verifyAuthentik(token: string): Promise { - const claims = await this.verifyAuthentikClaims(token); - const user = await resolveOrProvisionAuthentikUser(this.prisma, this.sync, claims); - return { - userId: user.id, - authentikSub: user.authentikSub, - email: user.email, - memberships: user.memberships.map((m) => ({ - kcId: m.kcId, - gemeindeId: m.gemeindeId, - role: m.role, - })), - }; + 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 { diff --git a/backend/src/onboarding/onboarding.service.ts b/backend/src/onboarding/onboarding.service.ts index 081c6e5..0a0dad1 100644 --- a/backend/src/onboarding/onboarding.service.ts +++ b/backend/src/onboarding/onboarding.service.ts @@ -42,7 +42,7 @@ export class OnboardingService { if (!token) { throw new UnauthorizedException('Missing Authentik bearer token'); } - const claims = await this.tokens.verifyAuthentikClaims(token); + const { isLeitungsteam, ...claims } = await this.tokens.verifyAuthentikClaims(token); const kc = await this.prisma.kc.findUnique({ where: { inviteCode } }); if (!kc || !kc.isActive) { @@ -53,7 +53,12 @@ export class OnboardingService { throw new BadRequestException('Gemeinde does not belong to this KC'); } - const user = await resolveOrProvisionAuthentikUser(this.prisma, this.sync, claims); + const user = await resolveOrProvisionAuthentikUser( + this.prisma, + this.sync, + claims, + isLeitungsteam, + ); const existing = await this.prisma.membership.findUnique({ where: { diff --git a/plan-kcAppMultiTenantPlatform.prompt.md b/plan-kcAppMultiTenantPlatform.prompt.md index ff3baf3..2a4460d 100644 --- a/plan-kcAppMultiTenantPlatform.prompt.md +++ b/plan-kcAppMultiTenantPlatform.prompt.md @@ -11,11 +11,11 @@ Neuentwicklung, die das WordPress-Plugin **Workshop-Wahlen** (Wahlen/Workshops/T - **KC** (Konfi-Castle-Event) = oberster Mandant. Eine App-Instanz verwaltet mehrere KCs parallel. Felder: `name`, `inviteCode` (eindeutig, Basis für QR/Code-Einstieg), `isActive`. - **Gemeinde**: lokale Gemeinde/Kirchengemeinde innerhalb eines KC (`kcId` + `name`, eindeutig pro KC). - **Rollenmodell** (Enum `Role`, Authentik-gestützt): - - **Leitungsteam (LT)** – global über alle KCs hinweg (Authentik-Gruppe); bleibt LT auf jedem KC, bis die Authentik-Gruppenmitgliedschaft entfernt wird. + - **Leitungsteam (LT)** – global über alle KCs hinweg. Wird bei **jedem** Authentik-Login aus dem `groups`-Claim des Tokens abgeglichen (Gruppenname aus `AUTHENTIK_LEITUNGSTEAM_GROUP`) und als `User.isLeitungsteam` gespeichert; die Auth-Schicht synthetisiert daraus eine virtuelle globale LT-`Membership`. Kein `Membership`-Row nötig. Fällt die Gruppenmitgliedschaft weg, ist man beim nächsten Login kein LT mehr. - **Gemeinde Verantwortliche** – pro Gemeinde/KC; verwalten **nur Nutzer ihrer eigenen Gemeinde** (legen Teamer an); Wahlen/Workshops macht ausschließlich LT; zudem Authentik Gruppe und Benutzer. - **Gemeinde Teamer** – von Verantwortlichen angelegt, ebenfalls Lokaler Account. - **Guest/Konfi** – optionaler, rein lokaler Account auf dem jeweiligen Server (kein Authentik), Vor-/Nachname Pflicht, **temporär pro KC/Event** (nicht wiederverwendbar über mehrere Events). -- **Membership**: verknüpft `User` ⇄ `Kc` (+ optional `Gemeinde`) ⇄ `Role`, mit `status` (`ACTIVE`/`PENDING`). LT-Memberships lassen `gemeindeId` leer und gelten global. `PENDING` (aus der Selbstregistrierung) gewährt keine Rechte, bis ein LT sie genehmigt — die Auth-Strategien laden nur `ACTIVE`-Memberships. +- **Membership**: verknüpft `User` ⇄ `Kc` (+ optional `Gemeinde`) ⇄ `Role`, mit `status` (`ACTIVE`/`PENDING`). Nur für `GEMEINDE_VERANTWORTLICHER`/`GEMEINDE_TEAMER` — LT läuft über `User.isLeitungsteam` (s. o.). `PENDING` (aus der Selbstregistrierung) gewährt keine Rechte, bis ein LT sie genehmigt — die Auth-Strategien laden nur `ACTIVE`-Memberships. - **Einstieg/Onboarding**: LT vergibt pro KC einen Code/QR (enthält den KC-Key). Damit erhält man sofortigen Guest-Zugang **oder** kann sich vorab registrieren: - **Gemeinde Verantwortliche/r**: `onboarding/`-Modul — mit Konfi-Castle-ID (Authentik) einloggen, KC-Code + bestehende Gemeinde wählen → `User` wird JIT angelegt, `Membership` als `PENDING`; LT genehmigt. - **Gemeinde Teamer**: `teamer/`-Modul — von einer Verantwortliche/r direkt angelegt oder per Invite-Link/E-Mail-Invite selbst registriert (lokaler Account, sofort `ACTIVE`). @@ -52,7 +52,7 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris - **Datei-Bytes werden nicht repliziert** – nur Metadaten (inkl. `storageKey`) laufen durchs Sync-Log. Lokaler und Cloud-Server müssen denselben Nextcloud/S3-Backend-Zugriff haben, damit ein `storageKey` auf beiden Seiten auflösbar ist. - **Push-Benachrichtigungen (FCM/APNs)** sind im Plan vorgesehen, aber noch nicht implementiert (Teil der noch ausstehenden Flutter-Client-Arbeit). - **Gemeinde-Verwaltung (CRUD)**: über `GemeindeController` (LT-only) verfügbar — Anlegen/Auflisten/Lesen/Umbenennen/Löschen von Gemeinden pro KC. Gemeinde Verantwortliche/Teamer erfahren ihre eigene Gemeinde weiterhin aus der `Membership`, nicht über diesen Endpunkt. -- **Authentik-Provisionierung**: Jeder gültige Authentik-Login legt den lokalen `User` jetzt automatisch an (`AuthentikStrategy` → `resolveOrProvisionAuthentikUser`, JIT, race-sicher). Was noch fehlt: die **Rollen-/Gruppen-Zuordnung aus Authentik** — ein frisch angelegter `User` hat keine `Membership`, also keine Rechte. LT-Rechte müssen aktuell per manueller `Membership(LEITUNGSTEAM)` gesetzt werden; Verantwortliche laufen über den `onboarding/`-Freigabepfad. (Gemeinde Teamer sind lokale Accounts, siehe `teamer/` + `auth/team-login`.) +- **Authentik-Provisionierung**: Jeder gültige Authentik-Login legt den lokalen `User` automatisch an (`AuthentikStrategy` → `resolveOrProvisionAuthentikUser`, JIT, race-sicher) **und** setzt `isLeitungsteam` aus dem `groups`-Claim. Voraussetzung: der Authentik-Provider muss den `groups`-Claim ins Access-Token schreiben (Scope „groups" hinzufügen) und der LT-Gruppenname muss zu `AUTHENTIK_LEITUNGSTEAM_GROUP` passen — sonst wird niemand als LT erkannt. Gemeinde Verantwortliche brauchen weiterhin die `onboarding/`-Freigabe durch LT. (Gemeinde Teamer sind lokale Accounts, siehe `teamer/` + `auth/team-login`.) - **Teamer-Identität ist E-Mail-basiert und global eindeutig**: `User.email` ist instanzweit unique, d. h. dieselbe E-Mail kann nicht gleichzeitig Teamer in zwei KCs sein. Für den „pro KC wie Guests"-Fall in der Praxis unkritisch, aber dokumentiert. - **Kein E-Mail-Versand**: `teamer-invites` erzeugt Token/Link; das tatsächliche Verschicken der E-Mail-Invites ist noch nicht angebunden. @@ -63,7 +63,7 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris | Modul | Kernfunktion | Wichtige Endpunkte | |---|---|---| | `prisma/` | Geteilter `PrismaClient`-Provider | – | -| `auth/` | Authentik-Resource-Server-Strategie (`AuthGuard('authentik')`) mit **JIT-`User`-Anlage** beim ersten Login (`resolveOrProvisionAuthentikUser`, race-sicher), Guest-Login (`AuthGuard('guest')`), **lokaler Teamer-Login** (`AuthGuard('team')`, `TEAM_JWT_SECRET`, bcrypt) inkl. Invite-Redemption, `TokenVerificationService` für manuelle Verifikation außerhalb des HTTP/Passport-Pfads (WS-Handshake, akzeptiert Authentik/Team/Guest). Alle Strategien laden nur `ACTIVE`-Memberships. | `POST /api/auth/guest`, `POST /api/auth/team-login`, `POST /api/auth/teamer/register` | +| `auth/` | Authentik-Resource-Server-Strategie (`AuthGuard('authentik')`) mit **JIT-`User`-Anlage** beim ersten Login + **LT-Abgleich** aus dem `groups`-Claim → `User.isLeitungsteam` → virtuelle globale LT-`Membership` (`resolveOrProvisionAuthentikUser` / `toAuthenticatedUser`, race-sicher), Guest-Login (`AuthGuard('guest')`), **lokaler Teamer-Login** (`AuthGuard('team')`, `TEAM_JWT_SECRET`, bcrypt) inkl. Invite-Redemption, `TokenVerificationService` für manuelle Verifikation außerhalb des HTTP/Passport-Pfads (WS-Handshake, akzeptiert Authentik/Team/Guest). Alle Strategien laden nur `ACTIVE`-Memberships. | `POST /api/auth/guest`, `POST /api/auth/team-login`, `POST /api/auth/teamer/register` | | `kc/` | KC-Verwaltung, Leitungsteam-only | `POST /api/kc`, `GET /api/kc` | | `gemeinde/` | Gemeinde-CRUD pro KC, Leitungsteam-only | `POST /api/gemeinde`, `GET /api/gemeinde?kcId=`, `GET/PATCH/DELETE /api/gemeinde/:id` | | `onboarding/` | Selbstregistrierung Gemeinde Verantwortliche/r: öffentlicher Invite-Lookup, JIT-`User`-Anlage aus Authentik-Claims, `Membership` im Status `PENDING`; LT sieht/genehmigt/lehnt ab | `GET /api/onboarding/kc/:inviteCode`, `POST /api/onboarding/verantwortliche` (Authentik-Bearer), `GET /api/onboarding/requests?kcId=` (LT), `POST /api/onboarding/requests/:id/approve\|reject` (LT) | @@ -110,12 +110,12 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM 1. Nach jeder Phase: `npx tsc -p tsconfig.build.json --noEmit`, `npx nest build`, sowie ein kurzer Boot-Test (`node dist/main.js`) zur Prüfung, dass der DI-Graph auflöst und alle Routen korrekt gemappt werden (ohne Live-DB/Authentik/Nextcloud). 2. Sync-Modul: manuell verifiziert, dass `SyncSecretGuard` Requests ohne `x-sync-secret` mit 403 ablehnt und mit korrektem Secret durchlässt (DB-Fehler in der Sandbox ist erwartet, da kein Postgres läuft). 3. Web-Client: `GET /` liefert die statische Seite (200), `GET /api/kc` trifft die echte, geschützte API (401 ohne Token). -4. Jest-Unit-Tests (Prisma/Sync gemockt, `npm test` grün, 51 Tests): +4. Jest-Unit-Tests (Prisma/Sync gemockt, `npm test` grün, 55 Tests): - `src/wahl/zuteilung.service.spec.ts`: Force-Vorrang, Wunschrunden-Fallback bei voller Kapazität, Unzugeteilt-Fall, Konsolidierung unterbesetzter Workshops, Sync-Capture-Anzahl. - `src/auth/team-auth.service.spec.ts`: Invite-Redemption (unbekannt/widerrufen/abgelaufen/aufgebraucht, Gruppen-Link ohne E-Mail, E-Mail-Mismatch, Dublette) + Passwort-Login. - `src/teamer/teamer.service.spec.ts`: Gemeinde-Scope-Check (LT global, Verantwortliche/r nur eigene Gemeinde), Direkt-Anlage, Invite-Defaults, Löschung. - `src/onboarding/onboarding.service.spec.ts`: Invite-Lookup, Verantwortlichen-Selbstregistrierung (Token fehlt/ungültig, unbekannter Code, Gemeinde nicht im KC, JIT-User + `PENDING`, Idempotenz), Approve/Reject. - - `src/auth/provision-user.spec.ts`: JIT-`User`-Anlage aus Authentik-Claims inkl. Race-Recovery (P2002 → Re-Read) und Fehler-Weiterreichung. + - `src/auth/provision-user.spec.ts`: JIT-`User`-Anlage aus Authentik-Claims, LT-Flag-Abgleich (rauf/runter) aus dem `groups`-Claim, virtuelle LT-`Membership` in `toAuthenticatedUser`, Race-Recovery (P2002 → Re-Read), Fehler-Weiterreichung. 5. Noch ausstehend (sobald echte Infrastruktur verfügbar ist): Zuteilungslogik gegen bekannte Testdaten aus dem alten Plugin validieren; Ende-zu-Ende-Rollenmatrix (LT/Verantwortlicher/Teamer/Guest) über alle Kernfeatures; echter Sync-Test zwischen zwei laufenden Instanzen (lokal + Cloud). --- @@ -123,7 +123,7 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM ## 8. Nächste Schritte 1. Sobald Flutter verfügbar ist: Client-Grundgerüst aufsetzen (Mobile + Web + Desktop, eine Codebase), beginnend mit Invite/Login-Flow gegen die bestehende API. -2. LT-Rolle aus Authentik ableiten: `User` wird beim ersten Login jetzt JIT angelegt, aber die `Membership(LEITUNGSTEAM)` noch nicht — aus den Authentik-Gruppen-Claims des Tokens (oder per Admin-API-Sync) eine globale LT-Membership erzeugen/entfernen. +2. Authentik-Provider so konfigurieren, dass das Access-Token den `groups`-Claim trägt (Scope „groups"), und die LT-Gruppe auf `AUTHENTIK_LEITUNGSTEAM_GROUP` abstimmen — sonst greift der LT-Abgleich nicht. (Reine Ops-/Config-Aufgabe, Code ist fertig.) 3. E-Mail-Versand für `teamer-invites` anbinden (Mailer + Templates); aktuell wird nur Token/Link erzeugt. 4. Push-Benachrichtigungen (FCM/APNs) für Chat/Ankündigungen. 5. Echte Infrastruktur (Postgres, Authentik, Nextcloud/S3) aufsetzen, erste Prisma-Migration erzeugen (`prisma migrate dev`, bisher nur `schema.prisma`) und die in Abschnitt 7 offenen Verifikationsschritte durchführen. From 320a41142bde4218921c6c3eb4fbe3e4c1864dac Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 08:17:53 +0200 Subject: [PATCH 08/37] feat(backend): mail module + send personal Gemeinde-Teamer invites New global mail/ module mirroring the files/storage/ provider pattern: - MailProvider abstraction; default LogMailProvider only logs (no delivery), MAIL_PROVIDER=smtp switches to a nodemailer SMTP transport (SMTP_*, MAIL_FROM). - MailService.sendTeamerInvite() composes the invite email with a link built from APP_BASE_URL. TeamerService.createInvite() now mails personal invites (those with an email) best-effort and returns `emailSent`; group links are unchanged. Delivery failures are logged and swallowed, never blocking invite creation. New env: APP_BASE_URL, MAIL_PROVIDER, MAIL_FROM, SMTP_HOST/PORT/SECURE/ USER/PASS. Tests: teamer spec covers mail-on-personal-invite, no-mail-on-group-link, and transport-drop; npm test green at 56. Docs updated. Co-Authored-By: Claude Sonnet 5 --- backend/.env.example | 14 +++++++ backend/README.md | 22 ++++++++--- backend/package-lock.json | 21 +++++++++++ backend/package.json | 2 + backend/src/app.module.ts | 2 + backend/src/mail/log-mail.provider.ts | 15 ++++++++ backend/src/mail/mail-provider.ts | 18 +++++++++ backend/src/mail/mail.module.ts | 25 +++++++++++++ backend/src/mail/mail.service.ts | 43 ++++++++++++++++++++++ backend/src/mail/smtp-mail.provider.ts | 45 +++++++++++++++++++++++ backend/src/teamer/teamer.service.spec.ts | 28 +++++++++++--- backend/src/teamer/teamer.service.ts | 21 ++++++++++- plan-kcAppMultiTenantPlatform.prompt.md | 12 +++--- 13 files changed, 250 insertions(+), 18 deletions(-) create mode 100644 backend/src/mail/log-mail.provider.ts create mode 100644 backend/src/mail/mail-provider.ts create mode 100644 backend/src/mail/mail.module.ts create mode 100644 backend/src/mail/mail.service.ts create mode 100644 backend/src/mail/smtp-mail.provider.ts diff --git a/backend/.env.example b/backend/.env.example index 668d845..de0a625 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -17,6 +17,20 @@ 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 " +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" diff --git a/backend/README.md b/backend/README.md index 64a605f..a81bbe0 100644 --- a/backend/README.md +++ b/backend/README.md @@ -7,7 +7,8 @@ architecture context). ```bash npm install -cp .env.example .env # DATABASE_URL / AUTHENTIK_ISSUER_URL / AUTHENTIK_LEITUNGSTEAM_GROUP / GUEST_JWT_SECRET / TEAM_JWT_SECRET +cp .env.example .env # DATABASE_URL / AUTHENTIK_ISSUER_URL / AUTHENTIK_LEITUNGSTEAM_GROUP / + # GUEST_JWT_SECRET / TEAM_JWT_SECRET / APP_BASE_URL (+ MAIL_* for real email) npx prisma generate npx prisma migrate dev --name init # requires a running PostgreSQL instance npm run start:dev @@ -64,7 +65,9 @@ client's host - no separate web server is needed. `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. + `'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 @@ -74,6 +77,12 @@ client's host - no separate web server is needed. `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 → @@ -113,7 +122,8 @@ client's host - no separate web server is needed. 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), invite email -delivery, push notifications, and the first real Prisma migration (only -`schema.prisma` exists so far). Ops note: the Authentik provider must emit a -`groups` claim in the access token for the LT check to work. +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. diff --git a/backend/package-lock.json b/backend/package-lock.json index 5b3c298..25d18a0 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -27,6 +27,7 @@ "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", @@ -44,6 +45,7 @@ "@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", @@ -2985,6 +2987,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "6.4.24", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.24.tgz", + "integrity": "sha512-Ww4u0rT9wQNXh4JiQaIwx3QWdcOFXzOjQA2zc+jtFYNmQiT4mIUqcDin51bDFdkzKubFnQCZNK7FIHlPKQ/q9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/passport": { "version": "1.0.17", "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", @@ -8374,6 +8386,15 @@ "node": ">=18" } }, + "node_modules/nodemailer": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", + "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index 61c95b8..0f9caf5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -39,6 +39,7 @@ "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", @@ -56,6 +57,7 @@ "@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", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index ffffde1..2cf76fb 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -3,6 +3,7 @@ import { ConfigModule } from '@nestjs/config'; import { ServeStaticModule } from '@nestjs/serve-static'; 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'; @@ -23,6 +24,7 @@ import { SyncModule } from './sync/sync.module'; exclude: ['/api*'], }), PrismaModule, + MailModule, SyncModule, AuthModule, KcModule, diff --git a/backend/src/mail/log-mail.provider.ts b/backend/src/mail/log-mail.provider.ts new file mode 100644 index 0000000..312100c --- /dev/null +++ b/backend/src/mail/log-mail.provider.ts @@ -0,0 +1,15 @@ +import { Logger } from '@nestjs/common'; +import { MailMessage, MailProvider } from './mail-provider'; + +/// Default provider: doesn't send anything, just logs that it would have. +/// Keeps the invite flow working before SMTP is configured. +export class LogMailProvider implements MailProvider { + private readonly logger = new Logger('MailProvider'); + + async send(message: MailMessage): Promise { + this.logger.log( + `[log-only] would send "${message.subject}" to ${message.to}: ${message.text}`, + ); + return false; + } +} diff --git a/backend/src/mail/mail-provider.ts b/backend/src/mail/mail-provider.ts new file mode 100644 index 0000000..7cdf3b9 --- /dev/null +++ b/backend/src/mail/mail-provider.ts @@ -0,0 +1,18 @@ +/// Abstraction over the outbound email backend. Default is a no-send provider +/// that only logs (fine for dev and for deployments that don't do email yet); +/// MAIL_PROVIDER=smtp switches to a real SMTP transport. +export interface MailMessage { + to: string; + subject: string; + text: string; + html?: string; +} + +export interface MailProvider { + /// Resolves true if the message was handed off to the transport, false if + /// it was dropped (e.g. the log provider). Never throws for delivery + /// problems — callers treat email as best-effort. + send(message: MailMessage): Promise; +} + +export const MAIL_PROVIDER = Symbol('MAIL_PROVIDER'); diff --git a/backend/src/mail/mail.module.ts b/backend/src/mail/mail.module.ts new file mode 100644 index 0000000..7691ccd --- /dev/null +++ b/backend/src/mail/mail.module.ts @@ -0,0 +1,25 @@ +import { Global, Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { MAIL_PROVIDER } from './mail-provider'; +import { LogMailProvider } from './log-mail.provider'; +import { SmtpMailProvider } from './smtp-mail.provider'; +import { MailService } from './mail.service'; + +/// Global so any feature module can inject MailService. Provider defaults to +/// log-only; MAIL_PROVIDER=smtp switches to a real SMTP transport. +@Global() +@Module({ + providers: [ + MailService, + { + provide: MAIL_PROVIDER, + inject: [ConfigService], + useFactory: (config: ConfigService) => + config.get('MAIL_PROVIDER') === 'smtp' + ? new SmtpMailProvider(config) + : new LogMailProvider(), + }, + ], + exports: [MailService], +}) +export class MailModule {} diff --git a/backend/src/mail/mail.service.ts b/backend/src/mail/mail.service.ts new file mode 100644 index 0000000..e587c11 --- /dev/null +++ b/backend/src/mail/mail.service.ts @@ -0,0 +1,43 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { MAIL_PROVIDER, MailProvider } from './mail-provider'; + +@Injectable() +export class MailService { + private readonly appBaseUrl: string; + + constructor( + @Inject(MAIL_PROVIDER) private readonly provider: MailProvider, + config: ConfigService, + ) { + this.appBaseUrl = (config.get('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 { + 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`, + }); + } +} diff --git a/backend/src/mail/smtp-mail.provider.ts b/backend/src/mail/smtp-mail.provider.ts new file mode 100644 index 0000000..b9cc7a5 --- /dev/null +++ b/backend/src/mail/smtp-mail.provider.ts @@ -0,0 +1,45 @@ +import { Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as nodemailer from 'nodemailer'; +import { MailMessage, MailProvider } from './mail-provider'; + +/// SMTP transport (MAIL_PROVIDER=smtp). Delivery failures are logged and +/// swallowed — callers treat email as best-effort. +export class SmtpMailProvider implements MailProvider { + private readonly logger = new Logger('MailProvider'); + private readonly from: string; + private readonly transport: nodemailer.Transporter; + + constructor(config: ConfigService) { + this.from = config.getOrThrow('MAIL_FROM'); + this.transport = nodemailer.createTransport({ + host: config.getOrThrow('SMTP_HOST'), + port: Number(config.get('SMTP_PORT') ?? 587), + secure: config.get('SMTP_SECURE') === 'true', + auth: config.get('SMTP_USER') + ? { + user: config.getOrThrow('SMTP_USER'), + pass: config.getOrThrow('SMTP_PASS'), + } + : undefined, + }); + } + + async send(message: MailMessage): Promise { + 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; + } + } +} diff --git a/backend/src/teamer/teamer.service.spec.ts b/backend/src/teamer/teamer.service.spec.ts index 28a71d9..498d229 100644 --- a/backend/src/teamer/teamer.service.spec.ts +++ b/backend/src/teamer/teamer.service.spec.ts @@ -31,6 +31,7 @@ function makeService(opts: { gemeinde?: typeof GEMEINDE | null; existingEmails?: 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), @@ -56,8 +57,9 @@ function makeService(opts: { gemeinde?: typeof GEMEINDE | null; existingEmails?: }, }; const sync = { capture: jest.fn().mockResolvedValue(undefined) }; - const service = new TeamerService(prisma as never, sync as never); - return { service, prisma, sync, created }; + 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', () => { @@ -120,20 +122,34 @@ describe('TeamerService.createTeamer', () => { }); describe('TeamerService.createInvite', () => { - it('defaults a group link to unlimited uses and no expiry', async () => { - const { service } = makeService(); + 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 and lowercases the email', async () => { - const { service } = makeService(); + 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 () => { diff --git a/backend/src/teamer/teamer.service.ts b/backend/src/teamer/teamer.service.ts index e397106..0eac3d2 100644 --- a/backend/src/teamer/teamer.service.ts +++ b/backend/src/teamer/teamer.service.ts @@ -9,6 +9,7 @@ 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'; @@ -30,6 +31,7 @@ export class TeamerService { constructor( private readonly prisma: PrismaClient, private readonly sync: SyncService, + private readonly mail: MailService, ) {} async createTeamer( @@ -118,7 +120,24 @@ export class TeamerService { }, }); await this.sync.capture('TeamerInvite', SyncOperation.CREATE, invite.id, invite); - return 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) { diff --git a/plan-kcAppMultiTenantPlatform.prompt.md b/plan-kcAppMultiTenantPlatform.prompt.md index 2a4460d..11c1f5e 100644 --- a/plan-kcAppMultiTenantPlatform.prompt.md +++ b/plan-kcAppMultiTenantPlatform.prompt.md @@ -43,6 +43,7 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris | Auth (Guest) | Rein lokale JWTs (`GUEST_JWT_SECRET`), kein Authentik | explizite Nutzervorgabe: Konfi-Accounts sind nie in Authentik | | Auth (Gemeinde Teamer) | Lokale Accounts: `User` mit `passwordHash`+`kcId`, `authentikSub` bleibt leer; eigenes JWT (`TEAM_JWT_SECRET`, Payload `typ:'team'`), Passwort-Login oder Invite-Redemption. Verantwortliche legen Teamer an (Direkt/Gruppen-Link/E-Mail-Invite) | Nutzervorgabe: Teamer laufen nicht über die Konfi-Castle-ID (Authentik), sondern werden pro KC lokal verwaltet (wie Guests, nur dauerhaft + mit Rolle) | | Datei-Storage | Provider-Abstraktion (`StorageProvider`), Default **Nextcloud/WebDAV**, umschaltbar auf S3 via `STORAGE_PROVIDER=s3` | Nutzer bestätigte: Nextcloud-Zugangsdaten kommen aus `.env` | +| E-Mail | Provider-Abstraktion (`MailProvider`), Default **log-only** (kein Versand), umschaltbar auf SMTP via `MAIL_PROVIDER=smtp` (`nodemailer`) | Spiegelt das Storage-Muster; E-Mail ist best-effort und darf den Invite-Flow nie blockieren | | Chat-Transport | Raw `ws`-Gateway (`@nestjs/platform-ws`) statt Socket.IO | Passt zum schlanken REST-Stack; Rollen/Sichtbarkeits-Logik zentral in `ChatService`, geteilt zwischen REST und WS | | Sync-Richtung | **Lokaler Server initiiert immer** Push *und* Pull gegen die Cloud-URL | Cloud kann i. d. R. nicht in ein lokales Eventnetzwerk zurückwählen (NAT); lokaler Server kann aber ausgehend zur Cloud verbinden, wenn Internet verfügbar ist | | Sync-Konflikte | Keine Konfliktauflösung nötig | Nutzer bestätigte explizit: lokaler Server ist während eines laufenden Events alleinige Quelle der Wahrheit | @@ -54,7 +55,7 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris - **Gemeinde-Verwaltung (CRUD)**: über `GemeindeController` (LT-only) verfügbar — Anlegen/Auflisten/Lesen/Umbenennen/Löschen von Gemeinden pro KC. Gemeinde Verantwortliche/Teamer erfahren ihre eigene Gemeinde weiterhin aus der `Membership`, nicht über diesen Endpunkt. - **Authentik-Provisionierung**: Jeder gültige Authentik-Login legt den lokalen `User` automatisch an (`AuthentikStrategy` → `resolveOrProvisionAuthentikUser`, JIT, race-sicher) **und** setzt `isLeitungsteam` aus dem `groups`-Claim. Voraussetzung: der Authentik-Provider muss den `groups`-Claim ins Access-Token schreiben (Scope „groups" hinzufügen) und der LT-Gruppenname muss zu `AUTHENTIK_LEITUNGSTEAM_GROUP` passen — sonst wird niemand als LT erkannt. Gemeinde Verantwortliche brauchen weiterhin die `onboarding/`-Freigabe durch LT. (Gemeinde Teamer sind lokale Accounts, siehe `teamer/` + `auth/team-login`.) - **Teamer-Identität ist E-Mail-basiert und global eindeutig**: `User.email` ist instanzweit unique, d. h. dieselbe E-Mail kann nicht gleichzeitig Teamer in zwei KCs sein. Für den „pro KC wie Guests"-Fall in der Praxis unkritisch, aber dokumentiert. -- **Kein E-Mail-Versand**: `teamer-invites` erzeugt Token/Link; das tatsächliche Verschicken der E-Mail-Invites ist noch nicht angebunden. +- **E-Mail-Versand**: `mail/`-Modul mit `MailProvider`-Abstraktion. Persönliche `teamer-invites` (mit `email`) werden verschickt; Default-Provider ist **log-only** (schreibt nur ins Log), echter Versand erst mit `MAIL_PROVIDER=smtp` + `SMTP_*`/`MAIL_FROM`. Onboarding-Benachrichtigungen an LT gibt es noch nicht. --- @@ -67,7 +68,8 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris | `kc/` | KC-Verwaltung, Leitungsteam-only | `POST /api/kc`, `GET /api/kc` | | `gemeinde/` | Gemeinde-CRUD pro KC, Leitungsteam-only | `POST /api/gemeinde`, `GET /api/gemeinde?kcId=`, `GET/PATCH/DELETE /api/gemeinde/:id` | | `onboarding/` | Selbstregistrierung Gemeinde Verantwortliche/r: öffentlicher Invite-Lookup, JIT-`User`-Anlage aus Authentik-Claims, `Membership` im Status `PENDING`; LT sieht/genehmigt/lehnt ab | `GET /api/onboarding/kc/:inviteCode`, `POST /api/onboarding/verantwortliche` (Authentik-Bearer), `GET /api/onboarding/requests?kcId=` (LT), `POST /api/onboarding/requests/:id/approve\|reject` (LT) | -| `teamer/` | Lokale Gemeinde-Teamer-Accounts + Invites; Direkt-Anlage, Gruppen-Link und E-Mail-Invite; nutzbar von LT (jede Gemeinde) oder Verantwortliche/r (nur eigene Gemeinde, in `TeamerService` geprüft) | `POST/GET /api/gemeinde/:gemeindeId/teamer`, `DELETE /api/gemeinde/:gemeindeId/teamer/:userId`, `POST/GET /api/gemeinde/:gemeindeId/teamer-invites`, `DELETE .../teamer-invites/:id` | +| `teamer/` | Lokale Gemeinde-Teamer-Accounts + Invites; Direkt-Anlage, Gruppen-Link und E-Mail-Invite (persönliche Invites werden per `MailService` best-effort verschickt, `emailSent` im Response); nutzbar von LT (jede Gemeinde) oder Verantwortliche/r (nur eigene Gemeinde, in `TeamerService` geprüft) | `POST/GET /api/gemeinde/:gemeindeId/teamer`, `DELETE /api/gemeinde/:gemeindeId/teamer/:userId`, `POST/GET /api/gemeinde/:gemeindeId/teamer-invites`, `DELETE .../teamer-invites/:id` | +| `mail/` | Globale `MailProvider`-Abstraktion (log-only Default, SMTP via `MAIL_PROVIDER=smtp`); `MailService` baut die Invite-Mail inkl. Link aus `APP_BASE_URL` | – | | `wahl/` | Wahl-/Workshop-Verwaltung, Force-Zuteilung, Teilnehmer-Einreichung, `ZuteilungService` (Portierung von `kc_run_zuteilung`: Force-Zuteilungen → 3 Wunschrunden → Zufallsfüllung → Konsolidierung unterbesetzter Workshops), CSV-Export | `POST/GET /api/wahl`, `POST/GET /api/wahl/:id/workshops`, `POST /api/wahl/:id/force-zuteilung`, `POST /api/wahl/:id/teilnehmer` (Guest), `POST /api/wahl/:id/zuteilung/run`, `GET /api/wahl/:id/zuteilung(.csv)` | | `files/` | Upload (LT-only, multipart) mit Sichtbarkeitsstufe; Liste/Download für Team oder Guest, gefiltert nach erlaubten Sichtbarkeitsstufen; `StorageProvider`-Abstraktion (WebDAV/Nextcloud Default, S3 optional) | `POST /api/files/:kcId`, `GET /api/files/:kcId`, `GET /api/files/download/:fileId` | | `chat/` | Zentrale Zugriffslogik (`ChatService`) geteilt zwischen REST (`ChatController`) und WS (`ChatGateway`, Pfad `/chat`, eigene Token-Verifikation via `?token=`); Kanaltypen wie oben | `POST /api/chat/:kcId/channels`, `POST /api/chat/direct`, `GET /api/chat/:kcId/channels`, `GET /api/chat/channels/:id/messages`, WS-Events `chat:join`/`chat:send`/`chat:message` | @@ -110,10 +112,10 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM 1. Nach jeder Phase: `npx tsc -p tsconfig.build.json --noEmit`, `npx nest build`, sowie ein kurzer Boot-Test (`node dist/main.js`) zur Prüfung, dass der DI-Graph auflöst und alle Routen korrekt gemappt werden (ohne Live-DB/Authentik/Nextcloud). 2. Sync-Modul: manuell verifiziert, dass `SyncSecretGuard` Requests ohne `x-sync-secret` mit 403 ablehnt und mit korrektem Secret durchlässt (DB-Fehler in der Sandbox ist erwartet, da kein Postgres läuft). 3. Web-Client: `GET /` liefert die statische Seite (200), `GET /api/kc` trifft die echte, geschützte API (401 ohne Token). -4. Jest-Unit-Tests (Prisma/Sync gemockt, `npm test` grün, 55 Tests): +4. Jest-Unit-Tests (Prisma/Sync/Mail gemockt, `npm test` grün, 56 Tests): - `src/wahl/zuteilung.service.spec.ts`: Force-Vorrang, Wunschrunden-Fallback bei voller Kapazität, Unzugeteilt-Fall, Konsolidierung unterbesetzter Workshops, Sync-Capture-Anzahl. - `src/auth/team-auth.service.spec.ts`: Invite-Redemption (unbekannt/widerrufen/abgelaufen/aufgebraucht, Gruppen-Link ohne E-Mail, E-Mail-Mismatch, Dublette) + Passwort-Login. - - `src/teamer/teamer.service.spec.ts`: Gemeinde-Scope-Check (LT global, Verantwortliche/r nur eigene Gemeinde), Direkt-Anlage, Invite-Defaults, Löschung. + - `src/teamer/teamer.service.spec.ts`: Gemeinde-Scope-Check (LT global, Verantwortliche/r nur eigene Gemeinde), Direkt-Anlage, Invite-Defaults, E-Mail-Versand nur bei persönlichem Invite + Best-effort bei Transport-Fehler, Löschung. - `src/onboarding/onboarding.service.spec.ts`: Invite-Lookup, Verantwortlichen-Selbstregistrierung (Token fehlt/ungültig, unbekannter Code, Gemeinde nicht im KC, JIT-User + `PENDING`, Idempotenz), Approve/Reject. - `src/auth/provision-user.spec.ts`: JIT-`User`-Anlage aus Authentik-Claims, LT-Flag-Abgleich (rauf/runter) aus dem `groups`-Claim, virtuelle LT-`Membership` in `toAuthenticatedUser`, Race-Recovery (P2002 → Re-Read), Fehler-Weiterreichung. 5. Noch ausstehend (sobald echte Infrastruktur verfügbar ist): Zuteilungslogik gegen bekannte Testdaten aus dem alten Plugin validieren; Ende-zu-Ende-Rollenmatrix (LT/Verantwortlicher/Teamer/Guest) über alle Kernfeatures; echter Sync-Test zwischen zwei laufenden Instanzen (lokal + Cloud). @@ -124,6 +126,6 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM 1. Sobald Flutter verfügbar ist: Client-Grundgerüst aufsetzen (Mobile + Web + Desktop, eine Codebase), beginnend mit Invite/Login-Flow gegen die bestehende API. 2. Authentik-Provider so konfigurieren, dass das Access-Token den `groups`-Claim trägt (Scope „groups"), und die LT-Gruppe auf `AUTHENTIK_LEITUNGSTEAM_GROUP` abstimmen — sonst greift der LT-Abgleich nicht. (Reine Ops-/Config-Aufgabe, Code ist fertig.) -3. E-Mail-Versand für `teamer-invites` anbinden (Mailer + Templates); aktuell wird nur Token/Link erzeugt. +3. SMTP konfigurieren (`MAIL_PROVIDER=smtp` + `SMTP_*`/`MAIL_FROM`/`APP_BASE_URL`) und die Invite-Mail-Templates finalisieren (aktuell nur Plain-Text); optional Onboarding-Benachrichtigungen an LT. 4. Push-Benachrichtigungen (FCM/APNs) für Chat/Ankündigungen. 5. Echte Infrastruktur (Postgres, Authentik, Nextcloud/S3) aufsetzen, erste Prisma-Migration erzeugen (`prisma migrate dev`, bisher nur `schema.prisma`) und die in Abschnitt 7 offenen Verifikationsschritte durchführen. From 4471d3a7163bcea90c20d9cb846d52eee1c84ccd Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 08:38:35 +0200 Subject: [PATCH 09/37] chore(backend): add initial Prisma migration Generated with a real local PostgreSQL 16 and applied cleanly (prisma migrate dev --name init). Covers the full current schema: Kc, Gemeinde, Role/MembershipStatus enums, User (authentikSub nullable, passwordHash, kcId, isLeitungsteam), Membership.status, GuestAccount, TeamerInvite, Wahl/Workshop/Teilnehmer/ForceZuteilung/Zuteilung, File, Chat*, Sync* . Backend boots against the real DB and the smoke-tested routes (/, guest login, onboarding invite lookup, protected /api/kc) behave correctly. Co-Authored-By: Claude Sonnet 5 --- .../20260910063804_init/migration.sql | 328 ++++++++++++++++++ backend/prisma/migrations/migration_lock.toml | 3 + 2 files changed, 331 insertions(+) create mode 100644 backend/prisma/migrations/20260910063804_init/migration.sql create mode 100644 backend/prisma/migrations/migration_lock.toml diff --git a/backend/prisma/migrations/20260910063804_init/migration.sql b/backend/prisma/migrations/20260910063804_init/migration.sql new file mode 100644 index 0000000..1e29009 --- /dev/null +++ b/backend/prisma/migrations/20260910063804_init/migration.sql @@ -0,0 +1,328 @@ +-- CreateEnum +CREATE TYPE "Role" AS ENUM ('LEITUNGSTEAM', 'GEMEINDE_VERANTWORTLICHER', 'GEMEINDE_TEAMER'); + +-- CreateEnum +CREATE TYPE "MembershipStatus" AS ENUM ('ACTIVE', 'PENDING'); + +-- CreateEnum +CREATE TYPE "FileVisibility" AS ENUM ('ALLE', 'ALLE_AUSSER_KONFIS', 'NUR_LT'); + +-- CreateEnum +CREATE TYPE "ChatChannelType" AS ENUM ('GEMEINDE_GRUPPE', 'DIREKT', 'LT_UEBERGREIFEND', 'BROADCAST'); + +-- CreateEnum +CREATE TYPE "SyncOperation" AS ENUM ('CREATE', 'UPDATE', 'DELETE'); + +-- CreateTable +CREATE TABLE "Kc" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "inviteCode" TEXT NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Kc_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Gemeinde" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "kcId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Gemeinde_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "authentikSub" TEXT, + "email" TEXT NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "passwordHash" TEXT, + "kcId" TEXT, + "isLeitungsteam" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Membership" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "kcId" TEXT NOT NULL, + "gemeindeId" TEXT, + "role" "Role" NOT NULL, + "status" "MembershipStatus" NOT NULL DEFAULT 'ACTIVE', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Membership_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "GuestAccount" ( + "id" TEXT NOT NULL, + "kcId" TEXT NOT NULL, + "gemeindeId" TEXT, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "GuestAccount_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamerInvite" ( + "id" TEXT NOT NULL, + "kcId" TEXT NOT NULL, + "gemeindeId" TEXT NOT NULL, + "token" TEXT NOT NULL, + "email" TEXT, + "maxUses" INTEGER, + "usedCount" INTEGER NOT NULL DEFAULT 0, + "expiresAt" TIMESTAMP(3), + "revokedAt" TIMESTAMP(3), + "createdByUserId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TeamerInvite_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Wahl" ( + "id" TEXT NOT NULL, + "kcId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "datumsSchluessel" TEXT NOT NULL, + "teil" TEXT NOT NULL, + "isOpen" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Wahl_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Workshop" ( + "id" TEXT NOT NULL, + "wahlId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "kapazitaet" INTEGER NOT NULL, + "minTeilnehmer" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "Workshop_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Teilnehmer" ( + "id" TEXT NOT NULL, + "wahlId" TEXT NOT NULL, + "guestAccountId" TEXT NOT NULL, + "prioritaeten" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Teilnehmer_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ForceZuteilung" ( + "id" TEXT NOT NULL, + "wahlId" TEXT NOT NULL, + "teilnehmerId" TEXT NOT NULL, + "workshopId" TEXT NOT NULL, + + CONSTRAINT "ForceZuteilung_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Zuteilung" ( + "id" TEXT NOT NULL, + "teilnehmerId" TEXT NOT NULL, + "workshopId" TEXT, + "wunschRang" INTEGER NOT NULL DEFAULT -1, + "isForced" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Zuteilung_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "File" ( + "id" TEXT NOT NULL, + "kcId" TEXT NOT NULL, + "storageKey" TEXT NOT NULL, + "filename" TEXT NOT NULL, + "visibility" "FileVisibility" NOT NULL, + "uploadedById" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "File_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ChatChannel" ( + "id" TEXT NOT NULL, + "kcId" TEXT NOT NULL, + "type" "ChatChannelType" NOT NULL, + "gemeindeId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ChatChannel_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ChatParticipant" ( + "id" TEXT NOT NULL, + "channelId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ChatParticipant_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ChatMessage" ( + "id" TEXT NOT NULL, + "channelId" TEXT NOT NULL, + "senderUserId" TEXT, + "senderGuestId" TEXT, + "body" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ChatMessage_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "SyncLogEntry" ( + "id" TEXT NOT NULL, + "sequence" SERIAL NOT NULL, + "model" TEXT NOT NULL, + "recordId" TEXT NOT NULL, + "operation" "SyncOperation" NOT NULL, + "payload" JSONB NOT NULL, + "originId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SyncLogEntry_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "SyncCursor" ( + "id" TEXT NOT NULL, + "peerId" TEXT NOT NULL, + "lastPushedSequence" INTEGER NOT NULL DEFAULT 0, + "lastPulledSequence" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "SyncCursor_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Kc_inviteCode_key" ON "Kc"("inviteCode"); + +-- CreateIndex +CREATE UNIQUE INDEX "Gemeinde_kcId_name_key" ON "Gemeinde"("kcId", "name"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_authentikSub_key" ON "User"("authentikSub"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Membership_userId_kcId_gemeindeId_key" ON "Membership"("userId", "kcId", "gemeindeId"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamerInvite_token_key" ON "TeamerInvite"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "Teilnehmer_wahlId_guestAccountId_key" ON "Teilnehmer"("wahlId", "guestAccountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ForceZuteilung_teilnehmerId_key" ON "ForceZuteilung"("teilnehmerId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Zuteilung_teilnehmerId_key" ON "Zuteilung"("teilnehmerId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ChatParticipant_channelId_userId_key" ON "ChatParticipant"("channelId", "userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "SyncCursor_peerId_key" ON "SyncCursor"("peerId"); + +-- AddForeignKey +ALTER TABLE "Gemeinde" ADD CONSTRAINT "Gemeinde_kcId_fkey" FOREIGN KEY ("kcId") REFERENCES "Kc"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "User" ADD CONSTRAINT "User_kcId_fkey" FOREIGN KEY ("kcId") REFERENCES "Kc"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Membership" ADD CONSTRAINT "Membership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Membership" ADD CONSTRAINT "Membership_kcId_fkey" FOREIGN KEY ("kcId") REFERENCES "Kc"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Membership" ADD CONSTRAINT "Membership_gemeindeId_fkey" FOREIGN KEY ("gemeindeId") REFERENCES "Gemeinde"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "GuestAccount" ADD CONSTRAINT "GuestAccount_kcId_fkey" FOREIGN KEY ("kcId") REFERENCES "Kc"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "GuestAccount" ADD CONSTRAINT "GuestAccount_gemeindeId_fkey" FOREIGN KEY ("gemeindeId") REFERENCES "Gemeinde"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamerInvite" ADD CONSTRAINT "TeamerInvite_kcId_fkey" FOREIGN KEY ("kcId") REFERENCES "Kc"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamerInvite" ADD CONSTRAINT "TeamerInvite_gemeindeId_fkey" FOREIGN KEY ("gemeindeId") REFERENCES "Gemeinde"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Wahl" ADD CONSTRAINT "Wahl_kcId_fkey" FOREIGN KEY ("kcId") REFERENCES "Kc"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Workshop" ADD CONSTRAINT "Workshop_wahlId_fkey" FOREIGN KEY ("wahlId") REFERENCES "Wahl"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Teilnehmer" ADD CONSTRAINT "Teilnehmer_wahlId_fkey" FOREIGN KEY ("wahlId") REFERENCES "Wahl"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Teilnehmer" ADD CONSTRAINT "Teilnehmer_guestAccountId_fkey" FOREIGN KEY ("guestAccountId") REFERENCES "GuestAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ForceZuteilung" ADD CONSTRAINT "ForceZuteilung_wahlId_fkey" FOREIGN KEY ("wahlId") REFERENCES "Wahl"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ForceZuteilung" ADD CONSTRAINT "ForceZuteilung_teilnehmerId_fkey" FOREIGN KEY ("teilnehmerId") REFERENCES "Teilnehmer"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ForceZuteilung" ADD CONSTRAINT "ForceZuteilung_workshopId_fkey" FOREIGN KEY ("workshopId") REFERENCES "Workshop"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Zuteilung" ADD CONSTRAINT "Zuteilung_teilnehmerId_fkey" FOREIGN KEY ("teilnehmerId") REFERENCES "Teilnehmer"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Zuteilung" ADD CONSTRAINT "Zuteilung_workshopId_fkey" FOREIGN KEY ("workshopId") REFERENCES "Workshop"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "File" ADD CONSTRAINT "File_kcId_fkey" FOREIGN KEY ("kcId") REFERENCES "Kc"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ChatChannel" ADD CONSTRAINT "ChatChannel_kcId_fkey" FOREIGN KEY ("kcId") REFERENCES "Kc"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ChatParticipant" ADD CONSTRAINT "ChatParticipant_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "ChatChannel"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ChatParticipant" ADD CONSTRAINT "ChatParticipant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ChatMessage" ADD CONSTRAINT "ChatMessage_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "ChatChannel"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ChatMessage" ADD CONSTRAINT "ChatMessage_senderUserId_fkey" FOREIGN KEY ("senderUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ChatMessage" ADD CONSTRAINT "ChatMessage_senderGuestId_fkey" FOREIGN KEY ("senderGuestId") REFERENCES "GuestAccount"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/migration_lock.toml b/backend/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/backend/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file From 912461751aa1adff28a42ad044c3726e76804f40 Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 08:45:11 +0200 Subject: [PATCH 10/37] feat(backend): guest-facing Wahl overview endpoint + dev seed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/wahl/guest/overview (guest JWT) returns the open Wahlen for the guest's KC, each with its workshops and the guest's own current priorities (null if not yet submitted) — everything the client needs to render the Wahl form without any LT-only endpoint. Verified end to end against a local Postgres (guest login -> overview -> submit -> re-fetch). prisma/seed-dev.js: minimal dev fixture (one KC "DEV123" + Gemeinde + open Wahl with three workshops). Co-Authored-By: Claude Sonnet 5 --- backend/prisma/seed-dev.js | 35 +++++++++++++++++++++++++++++ backend/src/wahl/wahl.controller.ts | 9 ++++++++ backend/src/wahl/wahl.service.ts | 34 ++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 backend/prisma/seed-dev.js diff --git a/backend/prisma/seed-dev.js b/backend/prisma/seed-dev.js new file mode 100644 index 0000000..22f71c3 --- /dev/null +++ b/backend/prisma/seed-dev.js @@ -0,0 +1,35 @@ +/* Minimal dev seed: one KC + Gemeinde + open Wahl with workshops. + Run: node prisma/seed-dev.js (backend/.env must point at the dev DB) */ +const { PrismaClient } = require('@prisma/client'); +const prisma = new PrismaClient(); + +async function main() { + const kc = await prisma.kc.upsert({ + where: { inviteCode: 'DEV123' }, + update: {}, + create: { name: 'KC Dev 2026', inviteCode: 'DEV123' }, + }); + const gem = await prisma.gemeinde.upsert({ + where: { kcId_name: { kcId: kc.id, name: 'Mustergemeinde' } }, + update: {}, + create: { kcId: kc.id, name: 'Mustergemeinde' }, + }); + let wahl = await prisma.wahl.findFirst({ where: { kcId: kc.id } }); + if (!wahl) { + wahl = await prisma.wahl.create({ + data: { kcId: kc.id, name: 'Samstag Teil 1', datumsSchluessel: '2026-06-13', teil: '1' }, + }); + await prisma.workshop.createMany({ + data: [ + { wahlId: wahl.id, name: 'Töpfern', kapazitaet: 12, minTeilnehmer: 4 }, + { wahlId: wahl.id, name: 'Fußball', kapazitaet: 20, minTeilnehmer: 6 }, + { wahlId: wahl.id, name: 'Bandworkshop', kapazitaet: 8, minTeilnehmer: 3 }, + ], + }); + } + console.log('KC', kc.id, 'invite', kc.inviteCode); + console.log('Gemeinde', gem.id); + console.log('Wahl', wahl.id); +} + +main().finally(() => prisma.$disconnect()); diff --git a/backend/src/wahl/wahl.controller.ts b/backend/src/wahl/wahl.controller.ts index f222678..e60e74b 100644 --- a/backend/src/wahl/wahl.controller.ts +++ b/backend/src/wahl/wahl.controller.ts @@ -69,6 +69,15 @@ export class WahlController { 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); + } + /// Guests submit their own workshop preferences (guest JWT, not Authentik). @Post(':wahlId/teilnehmer') @UseGuards(AuthGuard('guest')) diff --git a/backend/src/wahl/wahl.service.ts b/backend/src/wahl/wahl.service.ts index 290c698..c2e7153 100644 --- a/backend/src/wahl/wahl.service.ts +++ b/backend/src/wahl/wahl.service.ts @@ -22,6 +22,40 @@ export class WahlService { 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, + })), + }; + } + async createWorkshop( wahlId: string, name: string, From 7a95f4098f7348f4ebec7303e506cb045e0f73e7 Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 08:46:18 +0200 Subject: [PATCH 11/37] feat(backend): GET /api/auth/me for role-aware clients Accepts any of the three token kinds and echoes back the identity behind it: {kind:"guest", guestId, kcId, gemeindeId} for a Konfi token, or {kind:"user", userId, email, memberships, isLeitungsteam} for an Authentik or local Teamer token. Lets the client pick the right screens without decoding the JWT itself. Co-Authored-By: Claude Sonnet 5 --- backend/src/auth/auth.controller.ts | 33 ++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 370d0d5..1a2e2dd 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -1,9 +1,12 @@ -import { Body, Controller, Post } from '@nestjs/common'; +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 { @@ -12,6 +15,34 @@ export class AuthController { 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; + 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) { From 0886424526766bc7d497865ece20a9ae0bba4878 Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 08:52:36 +0200 Subject: [PATCH 12/37] =?UTF-8?q?feat(client):=20Flutter=20app=20(web=20ta?= =?UTF-8?q?rget)=20=E2=80=94=20Phase=207=20start?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single Flutter codebase under client/app/ with web enabled (mobile/desktop can be added later; lib/ is platform-agnostic). Talks to the NestJS backend via a thin REST wrapper; API_BASE is a --dart-define (defaults to the local backend). Screens: - Login: Konfi/guest (invite code), local Teamer password login, Teamer invite redemption. Token persisted in shared_preferences, restored on start; GET /auth/me drives a role-aware home. - Workshop-Wahl (guests): loads /wahl/guest/overview, ordered pick of up to 3 workshops, submits to /wahl/:id/teilnehmer. - Dateien: /files/:kcId list. - Chat: channel + message list (read-only; WS send is a follow-up). State: AppState (ChangeNotifier) exposed via an InheritedNotifier (AppScope) — no third-party state package. flutter analyze clean, flutter build web --release passes, one widget smoke test. Also: interim client/web/ HTML placeholder stays as-is (per plan it is superseded by this Flutter web build). Co-Authored-By: Claude Sonnet 5 --- client/app/.gitignore | 48 +++ client/app/.metadata | 30 ++ client/app/README.md | 43 +++ client/app/analysis_options.yaml | 6 + client/app/lib/api.dart | 355 ++++++++++++++++++++ client/app/lib/main.dart | 58 ++++ client/app/lib/screens/chat_screen.dart | 159 +++++++++ client/app/lib/screens/files_screen.dart | 77 +++++ client/app/lib/screens/home_screen.dart | 139 ++++++++ client/app/lib/screens/login_screen.dart | 206 ++++++++++++ client/app/lib/screens/wahl_screen.dart | 223 +++++++++++++ client/app/pubspec.lock | 362 +++++++++++++++++++++ client/app/pubspec.yaml | 21 ++ client/app/test/smoke_test.dart | 22 ++ client/app/web/favicon.png | Bin 0 -> 917 bytes client/app/web/icons/Icon-192.png | Bin 0 -> 5292 bytes client/app/web/icons/Icon-512.png | Bin 0 -> 8252 bytes client/app/web/icons/Icon-maskable-192.png | Bin 0 -> 5594 bytes client/app/web/icons/Icon-maskable-512.png | Bin 0 -> 20998 bytes client/app/web/index.html | 46 +++ client/app/web/manifest.json | 35 ++ 21 files changed, 1830 insertions(+) create mode 100644 client/app/.gitignore create mode 100644 client/app/.metadata create mode 100644 client/app/README.md create mode 100644 client/app/analysis_options.yaml create mode 100644 client/app/lib/api.dart create mode 100644 client/app/lib/main.dart create mode 100644 client/app/lib/screens/chat_screen.dart create mode 100644 client/app/lib/screens/files_screen.dart create mode 100644 client/app/lib/screens/home_screen.dart create mode 100644 client/app/lib/screens/login_screen.dart create mode 100644 client/app/lib/screens/wahl_screen.dart create mode 100644 client/app/pubspec.lock create mode 100644 client/app/pubspec.yaml create mode 100644 client/app/test/smoke_test.dart create mode 100644 client/app/web/favicon.png create mode 100644 client/app/web/icons/Icon-192.png create mode 100644 client/app/web/icons/Icon-512.png create mode 100644 client/app/web/icons/Icon-maskable-192.png create mode 100644 client/app/web/icons/Icon-maskable-512.png create mode 100644 client/app/web/index.html create mode 100644 client/app/web/manifest.json diff --git a/client/app/.gitignore b/client/app/.gitignore new file mode 100644 index 0000000..79f7eca --- /dev/null +++ b/client/app/.gitignore @@ -0,0 +1,48 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Widget Preview related +.widget_preview/ diff --git a/client/app/.metadata b/client/app/.metadata new file mode 100644 index 0000000..e731571 --- /dev/null +++ b/client/app/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "e8113bf45620cbeb8aff64947ee4c93e16adb4cf" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: e8113bf45620cbeb8aff64947ee4c93e16adb4cf + base_revision: e8113bf45620cbeb8aff64947ee4c93e16adb4cf + - platform: web + create_revision: e8113bf45620cbeb8aff64947ee4c93e16adb4cf + base_revision: e8113bf45620cbeb8aff64947ee4c93e16adb4cf + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/client/app/README.md b/client/app/README.md new file mode 100644 index 0000000..a98f1b5 --- /dev/null +++ b/client/app/README.md @@ -0,0 +1,43 @@ +# KC-App client (Flutter) + +Single Flutter codebase for the KC-App platform. **Web** is the only target +enabled so far (`flutter config --enable-web`); Android/iOS/desktop can be +added later with `flutter create --platforms=...` in this directory — the +`lib/` code is platform-agnostic. + +## Run + +```bash +flutter pub get +flutter run -d chrome --dart-define=API_BASE=http://localhost:3000/api +``` + +`API_BASE` defaults to `http://localhost:3000/api` (the local NestJS +backend, which also serves the interim plain-HTML client at `/`). + +## What's implemented + +- **Login** (`lib/screens/login_screen.dart`) — three tabs: + - *Konfi / Gast*: KC invite code + first/last name → `POST /auth/guest`. + - *Team-Login*: email + password for local Gemeinde Teamer → + `POST /auth/team-login`. (Leitungsteam / Verantwortliche use the + Authentik Authorization-Code flow, not yet wired into this client.) + - *Einladung*: redeem a Teamer invite token → `POST /auth/teamer/register`. +- The token is stored via `shared_preferences` (localStorage on web) and + restored on start; `GET /auth/me` resolves the role for a role-aware home. +- **Home** (`lib/screens/home_screen.dart`) — identity card + navigation. +- **Workshop-Wahl** (`lib/screens/wahl_screen.dart`, guests) — loads + `GET /wahl/guest/overview`, tap workshops in order (max 3) to set + priorities, `POST /wahl/:id/teilnehmer`. +- **Dateien** (`lib/screens/files_screen.dart`) — `GET /files/:kcId`, + filtered server-side by the caller's visibility tier. +- **Chat** (`lib/screens/chat_screen.dart`) — channel + message list + (read-only; sending is a WebSocket path, still to do). + +## Architecture + +- `lib/api.dart` — `Api` (thin REST wrapper + models) and `AppState` + (`ChangeNotifier`: session, login/logout, token persistence). +- `lib/main.dart` — `AppScope` (an `InheritedNotifier`) exposes + `AppScope.of(context)`; `_AuthGate` switches Login/Home. No third-party + state-management package. diff --git a/client/app/analysis_options.yaml b/client/app/analysis_options.yaml new file mode 100644 index 0000000..3d3c734 --- /dev/null +++ b/client/app/analysis_options.yaml @@ -0,0 +1,6 @@ +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - build/** + - web/** diff --git a/client/app/lib/api.dart b/client/app/lib/api.dart new file mode 100644 index 0000000..8d70094 --- /dev/null +++ b/client/app/lib/api.dart @@ -0,0 +1,355 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Backend base URL. Override at build/run time with +/// `--dart-define=API_BASE=https://...`. +const String kApiBase = String.fromEnvironment( + 'API_BASE', + defaultValue: 'http://localhost:3000/api', +); + +class ApiException implements Exception { + ApiException(this.statusCode, this.message); + final int statusCode; + final String message; + @override + String toString() => 'ApiException($statusCode): $message'; +} + +enum SessionKind { guest, user } + +/// Whatever `GET /auth/me` told us about the current token. +class Identity { + Identity({ + required this.kind, + this.guestId, + this.userId, + this.email, + this.kcId, + this.gemeindeId, + this.isLeitungsteam = false, + this.memberships = const [], + }); + + final SessionKind kind; + final String? guestId; + final String? userId; + final String? email; + final String? kcId; + final String? gemeindeId; + final bool isLeitungsteam; + final List memberships; + + factory Identity.fromJson(Map j) { + if (j['kind'] == 'guest') { + return Identity( + kind: SessionKind.guest, + guestId: j['guestId'] as String?, + kcId: j['kcId'] as String?, + gemeindeId: j['gemeindeId'] as String?, + ); + } + final ms = (j['memberships'] as List? ?? []) + .map((m) => Membership.fromJson(m as Map)) + .toList(); + return Identity( + kind: SessionKind.user, + userId: j['userId'] as String?, + email: j['email'] as String?, + isLeitungsteam: j['isLeitungsteam'] as bool? ?? false, + memberships: ms, + kcId: ms.isNotEmpty ? ms.first.kcId : null, + gemeindeId: ms.isNotEmpty ? ms.first.gemeindeId : null, + ); + } + + String get roleLabel { + if (kind == SessionKind.guest) return 'Konfi / Gast'; + if (isLeitungsteam) return 'Leitungsteam'; + if (memberships.any((m) => m.role == 'GEMEINDE_VERANTWORTLICHER')) { + return 'Gemeinde Verantwortliche/r'; + } + if (memberships.any((m) => m.role == 'GEMEINDE_TEAMER')) { + return 'Gemeinde Teamer:in'; + } + return 'Angemeldet (ohne Rolle)'; + } +} + +class Membership { + Membership({required this.kcId, this.gemeindeId, required this.role}); + final String kcId; + final String? gemeindeId; + final String role; + factory Membership.fromJson(Map j) => Membership( + kcId: j['kcId'] as String, + gemeindeId: j['gemeindeId'] as String?, + role: j['role'] as String, + ); +} + +class Workshop { + Workshop({required this.id, required this.name, required this.kapazitaet}); + final String id; + final String name; + final int kapazitaet; + factory Workshop.fromJson(Map j) => Workshop( + id: j['id'] as String, + name: j['name'] as String, + kapazitaet: (j['kapazitaet'] as num).toInt(), + ); +} + +class Wahl { + Wahl({ + required this.id, + required this.name, + required this.datumsSchluessel, + required this.teil, + required this.workshops, + required this.meinePrioritaeten, + }); + final String id; + final String name; + final String datumsSchluessel; + final String teil; + final List workshops; + final List? meinePrioritaeten; + + factory Wahl.fromJson(Map j) => Wahl( + id: j['id'] as String, + name: j['name'] as String, + datumsSchluessel: j['datumsSchluessel'] as String, + teil: j['teil'] as String, + workshops: (j['workshops'] as List) + .map((w) => Workshop.fromJson(w as Map)) + .toList(), + meinePrioritaeten: (j['meinePrioritaeten'] as List?) + ?.map((e) => e as String) + .toList(), + ); +} + +class GuestOverview { + GuestOverview({required this.kcName, required this.wahlen}); + final String kcName; + final List wahlen; + factory GuestOverview.fromJson(Map j) => GuestOverview( + kcName: (j['kc'] as Map?)?['name'] as String? ?? '', + wahlen: (j['wahlen'] as List) + .map((w) => Wahl.fromJson(w as Map)) + .toList(), + ); +} + +class FileEntry { + FileEntry({required this.id, required this.filename, required this.visibility}); + final String id; + final String filename; + final String visibility; + factory FileEntry.fromJson(Map j) => FileEntry( + id: j['id'] as String, + filename: j['filename'] as String, + visibility: j['visibility'] as String? ?? '', + ); +} + +class ChatChannel { + ChatChannel({required this.id, required this.type}); + final String id; + final String type; + factory ChatChannel.fromJson(Map j) => ChatChannel( + id: j['id'] as String, + type: j['type'] as String? ?? '', + ); +} + +class ChatMessage { + ChatMessage({required this.body, required this.createdAt}); + final String body; + final String createdAt; + factory ChatMessage.fromJson(Map j) => ChatMessage( + body: j['body'] as String? ?? '', + createdAt: j['createdAt'] as String? ?? '', + ); +} + +/// Thin REST wrapper. Holds the bearer token for the current session. +class Api { + Api(this._client); + final http.Client _client; + String? token; + + Map get _headers => { + 'Content-Type': 'application/json', + if (token != null) 'Authorization': 'Bearer $token', + }; + + Future _get(String path) async { + final res = await _client.get(Uri.parse('$kApiBase$path'), headers: _headers); + return _decode(res); + } + + Future _post(String path, Object? body) async { + final res = await _client.post( + 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; + try { + parsed = jsonDecode(text); + } catch (_) { + parsed = text; + } + if (res.statusCode >= 200 && res.statusCode < 300) return parsed; + final msg = parsed is Map && parsed['message'] != null + ? (parsed['message'] is List + ? (parsed['message'] as List).join(', ') + : parsed['message'].toString()) + : 'HTTP ${res.statusCode}'; + throw ApiException(res.statusCode, msg); + } + + // --- auth --- + Future guestLogin(String inviteCode, String firstName, String lastName) async { + final j = await _post('/auth/guest', { + 'inviteCode': inviteCode, + 'firstName': firstName, + 'lastName': lastName, + }); + return j['accessToken'] as String; + } + + Future teamLogin(String email, String password) async { + final j = await _post('/auth/team-login', {'email': email, 'password': password}); + return j['accessToken'] as String; + } + + Future redeemTeamerInvite({ + required String inviteToken, + required String firstName, + required String lastName, + required String password, + String? email, + }) async { + final j = await _post('/auth/teamer/register', { + 'token': inviteToken, + 'firstName': firstName, + 'lastName': lastName, + 'password': password, + if (email != null && email.isNotEmpty) 'email': email, + }); + return j['accessToken'] as String; + } + + Future me() async => + Identity.fromJson(await _get('/auth/me') as Map); + + // --- guest Wahl --- + Future guestWahlOverview() async => + GuestOverview.fromJson(await _get('/wahl/guest/overview') as Map); + + Future submitPrioritaeten(String wahlId, List workshopIds) async { + await _post('/wahl/$wahlId/teilnehmer', {'prioritaeten': workshopIds}); + } + + // --- files --- + Future> files(String kcId) async { + final list = await _get('/files/$kcId') as List; + return list.map((e) => FileEntry.fromJson(e as Map)).toList(); + } + + String fileDownloadUrl(String fileId) => '$kApiBase/files/download/$fileId'; + + // --- chat (read-only for now; sending is a WebSocket-only path) --- + Future> channels(String kcId) async { + final list = await _get('/chat/$kcId/channels') as List; + return list.map((e) => ChatChannel.fromJson(e as Map)).toList(); + } + + Future> messages(String channelId) async { + final list = await _get('/chat/channels/$channelId/messages') as List; + return list.map((e) => ChatMessage.fromJson(e as Map)).toList(); + } +} + +/// App-wide session + auth actions. Persists the token in shared_preferences +/// (localStorage on web). +class AppState extends ChangeNotifier { + AppState(this._api); + final Api _api; + + static const _tokenKey = 'kc_token'; + + Identity? _identity; + Identity? get identity => _identity; + bool _loading = true; + bool get loading => _loading; + bool get isLoggedIn => _identity != null; + + Api get api => _api; + + Future bootstrap() async { + final prefs = await SharedPreferences.getInstance(); + final saved = prefs.getString(_tokenKey); + if (saved != null) { + _api.token = saved; + try { + _identity = await _api.me(); + } catch (_) { + _api.token = null; + await prefs.remove(_tokenKey); + } + } + _loading = false; + notifyListeners(); + } + + Future _establish(String token) async { + _api.token = token; + _identity = await _api.me(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_tokenKey, token); + notifyListeners(); + } + + Future guestLogin(String code, String first, String last) => + _api.guestLogin(code, first, last).then(_establish); + + Future teamLogin(String email, String password) => + _api.teamLogin(email, password).then(_establish); + + Future redeemInvite({ + required String token, + required String first, + required String last, + required String password, + String? email, + }) => + _api + .redeemTeamerInvite( + inviteToken: token, + firstName: first, + lastName: last, + password: password, + email: email, + ) + .then(_establish); + + Future logout() async { + _api.token = null; + _identity = null; + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_tokenKey); + notifyListeners(); + } +} diff --git a/client/app/lib/main.dart b/client/app/lib/main.dart new file mode 100644 index 0000000..811010f --- /dev/null +++ b/client/app/lib/main.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; + +import 'api.dart'; +import 'screens/home_screen.dart'; +import 'screens/login_screen.dart'; + +void main() { + final state = AppState(Api(http.Client()))..bootstrap(); + runApp(KcApp(state: state)); +} + +/// Minimal InheritedNotifier so screens can read `AppState.of(context)` and +/// rebuild on change — no third-party state management. +class AppScope extends InheritedNotifier { + const AppScope({super.key, required AppState state, required super.child}) + : super(notifier: state); + + static AppState of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType(); + assert(scope != null, 'AppScope missing above this widget'); + return scope!.notifier!; + } +} + +class KcApp extends StatelessWidget { + const KcApp({super.key, required this.state}); + final AppState state; + + @override + Widget build(BuildContext context) { + return AppScope( + state: state, + child: MaterialApp( + title: 'KC-App', + debugShowCheckedModeBanner: false, + theme: ThemeData( + colorSchemeSeed: const Color(0xFF3B5BA5), + useMaterial3: true, + ), + home: const _AuthGate(), + ), + ); + } +} + +class _AuthGate extends StatelessWidget { + const _AuthGate(); + + @override + Widget build(BuildContext context) { + final state = AppScope.of(context); + if (state.loading) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + return state.isLoggedIn ? const HomeScreen() : const LoginScreen(); + } +} diff --git a/client/app/lib/screens/chat_screen.dart b/client/app/lib/screens/chat_screen.dart new file mode 100644 index 0000000..63cc8e9 --- /dev/null +++ b/client/app/lib/screens/chat_screen.dart @@ -0,0 +1,159 @@ +import 'package:flutter/material.dart'; + +import '../api.dart'; +import '../main.dart'; + +/// Read-only chat view. Sending a message is a WebSocket-only path on the +/// backend (`chat:send`); wiring that up is a follow-up. +class ChatScreen extends StatefulWidget { + const ChatScreen({super.key, required this.kcId}); + final String kcId; + + @override + State createState() => _ChatScreenState(); +} + +class _ChatScreenState extends State { + Future>? _future; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _future ??= AppScope.of(context).api.channels(widget.kcId); + } + + static const _typeLabels = { + 'GEMEINDE_GRUPPE': 'Gemeinde-Gruppe', + 'DIREKT': 'Direktnachricht', + 'LT_UEBERGREIFEND': 'Leitungsteam', + 'BROADCAST': 'Ankündigungen', + }; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Chat')), + body: FutureBuilder>( + 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), + ), + ); + } + 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), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => _ChannelMessages( + channelId: c.id, + title: _typeLabels[c.type] ?? c.type, + ), + ), + ), + ), + ], + ); + }, + ), + ); + } +} + +class _ChannelMessages extends StatefulWidget { + const _ChannelMessages({required this.channelId, required this.title}); + final String channelId; + final String title; + + @override + State<_ChannelMessages> createState() => _ChannelMessagesState(); +} + +class _ChannelMessagesState extends State<_ChannelMessages> { + Future>? _future; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _future ??= AppScope.of(context).api.messages(widget.channelId); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(widget.title)), + body: FutureBuilder>( + 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), + ), + ); + } + final messages = snap.data!; + if (messages.isEmpty) { + return const Center(child: Text('Noch keine Nachrichten.')); + } + return ListView.builder( + 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), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(m.body), + const SizedBox(height: 2), + Text( + m.createdAt, + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + ), + ); + }, + ); + }, + ), + bottomNavigationBar: const Padding( + padding: EdgeInsets.all(12), + child: Text( + 'Senden folgt (WebSocket) — aktuell nur Lesen.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12), + ), + ), + ); + } +} diff --git a/client/app/lib/screens/files_screen.dart b/client/app/lib/screens/files_screen.dart new file mode 100644 index 0000000..a37c062 --- /dev/null +++ b/client/app/lib/screens/files_screen.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; + +import '../api.dart'; +import '../main.dart'; + +class FilesScreen extends StatefulWidget { + const FilesScreen({super.key, required this.kcId}); + final String kcId; + + @override + State createState() => _FilesScreenState(); +} + +class _FilesScreenState extends State { + Future>? _future; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _future ??= AppScope.of(context).api.files(widget.kcId); + } + + static const _visibilityLabels = { + 'ALLE': 'Alle', + 'ALLE_AUSSER_KONFIS': 'Team (ohne Konfis)', + 'NUR_LT': 'Nur Leitungsteam', + }; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Dateien')), + body: FutureBuilder>( + 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), + ), + ); + } + final files = snap.data!; + if (files.isEmpty) { + return const Center(child: Text('Keine Dateien freigegeben.')); + } + return ListView.separated( + itemCount: files.length, + separatorBuilder: (context, index) => const Divider(height: 1), + itemBuilder: (context, i) { + final f = files[i]; + return ListTile( + leading: const Icon(Icons.insert_drive_file_outlined), + title: Text(f.filename), + subtitle: Text(_visibilityLabels[f.visibility] ?? f.visibility), + trailing: const Icon(Icons.download), + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Download-URL: ${AppScope.of(context).api.fileDownloadUrl(f.id)}', + ), + ), + ); + }, + ); + }, + ); + }, + ), + ); + } +} diff --git a/client/app/lib/screens/home_screen.dart b/client/app/lib/screens/home_screen.dart new file mode 100644 index 0000000..44d7586 --- /dev/null +++ b/client/app/lib/screens/home_screen.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; + +import '../api.dart'; +import '../main.dart'; +import 'chat_screen.dart'; +import 'files_screen.dart'; +import 'wahl_screen.dart'; + +class HomeScreen extends StatelessWidget { + const HomeScreen({super.key}); + + @override + Widget build(BuildContext context) { + final state = AppScope.of(context); + final id = state.identity!; + final kcId = id.kcId; + + final tiles = [ + if (id.kind == SessionKind.guest) + _NavTile( + icon: Icons.how_to_vote, + title: 'Workshop-Wahl', + subtitle: 'Deine Wünsche abgeben', + onTap: () => _open(context, const WahlScreen()), + ), + if (kcId != null) + _NavTile( + icon: Icons.folder_shared, + title: 'Dateien', + subtitle: 'Freigegebene Dateien ansehen', + onTap: () => _open(context, FilesScreen(kcId: kcId)), + ), + if (kcId != null) + _NavTile( + icon: Icons.forum, + title: 'Chat', + subtitle: 'Kanäle & Nachrichten (lesen)', + onTap: () => _open(context, ChatScreen(kcId: kcId)), + ), + ]; + + return Scaffold( + appBar: AppBar( + title: const Text('KC-App'), + actions: [ + IconButton( + tooltip: 'Abmelden', + onPressed: state.logout, + icon: const Icon(Icons.logout), + ), + ], + ), + body: SafeArea( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: ListView( + padding: const EdgeInsets.all(20), + children: [ + _IdentityCard(id: id), + const SizedBox(height: 16), + ...tiles, + if (tiles.isEmpty) + const Padding( + padding: EdgeInsets.only(top: 24), + child: Text( + 'Für diesen Account gibt es hier noch keine Ansichten. ' + 'Sobald dir eine Gemeinde/ein KC zugeordnet ist, erscheinen ' + 'Dateien und Chat.', + ), + ), + ], + ), + ), + ), + ), + ); + } + + void _open(BuildContext context, Widget screen) { + Navigator.of(context).push(MaterialPageRoute(builder: (_) => screen)); + } +} + +class _IdentityCard extends StatelessWidget { + const _IdentityCard({required this.id}); + final Identity id; + + @override + Widget build(BuildContext context) { + final lines = [ + 'Rolle: ${id.roleLabel}', + if (id.email != null) 'E-Mail: ${id.email}', + if (id.isLeitungsteam) + 'Leitungsteam-Rechte gelten KC-übergreifend.' + else if (id.memberships.length > 1) + '${id.memberships.length} Zuordnungen', + ]; + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Angemeldet', style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + for (final l in lines) Text(l), + ], + ), + ), + ); + } +} + +class _NavTile extends StatelessWidget { + const _NavTile({ + required this.icon, + required this.title, + required this.subtitle, + required this.onTap, + }); + final IconData icon; + final String title; + final String subtitle; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Card( + child: ListTile( + leading: Icon(icon), + title: Text(title), + subtitle: Text(subtitle), + trailing: const Icon(Icons.chevron_right), + onTap: onTap, + ), + ); + } +} diff --git a/client/app/lib/screens/login_screen.dart b/client/app/lib/screens/login_screen.dart new file mode 100644 index 0000000..ab94dd3 --- /dev/null +++ b/client/app/lib/screens/login_screen.dart @@ -0,0 +1,206 @@ +import 'package:flutter/material.dart'; + +import '../api.dart'; +import '../main.dart'; + +class LoginScreen extends StatelessWidget { + const LoginScreen({super.key}); + + @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( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Padding( + padding: const EdgeInsets.all(24), + child: TabBarView( + children: const [ + _GuestForm(), + _TeamForm(), + _InviteForm(), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +/// Shared submit-button + error handling for the three little forms. +class _FormShell extends StatefulWidget { + const _FormShell({required this.title, required this.fields, required this.onSubmit}); + final String title; + final List fields; + final Future Function() onSubmit; + + @override + State<_FormShell> createState() => _FormShellState(); +} + +class _FormShellState extends State<_FormShell> { + bool _busy = false; + String? _error; + + Future _run() async { + setState(() { + _busy = true; + _error = null; + }); + try { + await widget.onSubmit(); + } on ApiException catch (e) { + setState(() => _error = e.message); + } catch (e) { + setState(() => _error = e.toString()); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + return ListView( + shrinkWrap: true, + children: [ + Text(widget.title, style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 16), + ...widget.fields, + const SizedBox(height: 20), + if (_error != null) ...[ + Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + const SizedBox(height: 12), + ], + FilledButton( + onPressed: _busy ? null : _run, + child: _busy + ? const SizedBox( + height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Weiter'), + ), + ], + ); + } +} + +TextField _field(TextEditingController c, String label, {bool obscure = false}) => TextField( + controller: c, + obscureText: obscure, + decoration: InputDecoration(labelText: label, border: const OutlineInputBorder()), + ); + +class _GuestForm extends StatefulWidget { + const _GuestForm(); + @override + State<_GuestForm> createState() => _GuestFormState(); +} + +class _GuestFormState extends State<_GuestForm> { + final _code = TextEditingController(); + final _first = TextEditingController(); + final _last = TextEditingController(); + + @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'), + ], + 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(); + + @override + Widget build(BuildContext context) { + final state = AppScope.of(context); + return _FormShell( + title: 'Teamer:in-Login', + fields: [ + _field(_email, 'E-Mail'), + const SizedBox(height: 12), + _field(_password, 'Passwort', obscure: true), + const SizedBox(height: 8), + const Text( + 'Leitungsteam & Gemeinde-Verantwortliche melden sich über die ' + 'Konfi-Castle-ID (Authentik) an — dieser Client deckt bisher den ' + 'lokalen Teamer-Login ab.', + style: TextStyle(fontSize: 12), + ), + ], + onSubmit: () => state.teamLogin(_email.text.trim(), _password.text), + ); + } +} + +class _InviteForm extends StatefulWidget { + const _InviteForm(); + @override + State<_InviteForm> createState() => _InviteFormState(); +} + +class _InviteFormState extends State<_InviteForm> { + final _token = TextEditingController(); + final _first = TextEditingController(); + final _last = TextEditingController(); + final _email = TextEditingController(); + final _password = TextEditingController(); + + @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), + ], + onSubmit: () => state.redeemInvite( + token: _token.text.trim(), + first: _first.text.trim(), + last: _last.text.trim(), + password: _password.text, + email: _email.text.trim(), + ), + ); + } +} diff --git a/client/app/lib/screens/wahl_screen.dart b/client/app/lib/screens/wahl_screen.dart new file mode 100644 index 0000000..ce6eca9 --- /dev/null +++ b/client/app/lib/screens/wahl_screen.dart @@ -0,0 +1,223 @@ +import 'package:flutter/material.dart'; + +import '../api.dart'; +import '../main.dart'; + +class WahlScreen extends StatefulWidget { + const WahlScreen({super.key}); + + @override + State createState() => _WahlScreenState(); +} + +class _WahlScreenState extends State { + Future? _future; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _future ??= AppScope.of(context).api.guestWahlOverview(); + } + + void _reload() { + setState(() { + _future = AppScope.of(context).api.guestWahlOverview(); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Workshop-Wahl')), + body: FutureBuilder( + future: _future, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + if (snap.hasError) { + return _ErrorView(message: '${snap.error}', onRetry: _reload); + } + final data = snap.data!; + if (data.wahlen.isEmpty) { + return const Center(child: Text('Aktuell ist keine Wahl geöffnet.')); + } + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Text(data.kcName, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + for (final w in data.wahlen) + _WahlCard(wahl: w, onSubmitted: _reload), + ], + ); + }, + ), + ); + } +} + +class _WahlCard extends StatefulWidget { + const _WahlCard({required this.wahl, required this.onSubmitted}); + final Wahl wahl; + final VoidCallback onSubmitted; + + @override + State<_WahlCard> createState() => _WahlCardState(); +} + +class _WahlCardState extends State<_WahlCard> { + late final List _picked = [...?widget.wahl.meinePrioritaeten]; + bool _busy = false; + String? _error; + bool _done = false; + + static const _maxPicks = 3; + + void _toggle(String workshopId) { + setState(() { + if (_picked.contains(workshopId)) { + _picked.remove(workshopId); + } else if (_picked.length < _maxPicks) { + _picked.add(workshopId); + } + _done = false; + }); + } + + Future _submit() async { + setState(() { + _busy = true; + _error = null; + }); + try { + await AppScope.of(context).api.submitPrioritaeten(widget.wahl.id, _picked); + setState(() => _done = true); + widget.onSubmitted(); + } on ApiException catch (e) { + setState(() => _error = e.message); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final w = widget.wahl; + return Card( + margin: const EdgeInsets.only(bottom: 16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(w.name, style: Theme.of(context).textTheme.titleLarge), + Text('${w.datumsSchluessel} · Teil ${w.teil}', + style: Theme.of(context).textTheme.bodySmall), + const SizedBox(height: 4), + Text( + 'Tippe deine Wünsche in Reihenfolge an (max. $_maxPicks). ' + 'Die Zahl zeigt den Rang.', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 8), + for (final ws in w.workshops) + _WorkshopRow( + workshop: ws, + rank: _picked.indexOf(ws.id), + enabled: !_busy, + onTap: () => _toggle(ws.id), + ), + const SizedBox(height: 12), + if (_error != null) ...[ + Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + const SizedBox(height: 8), + ], + Row( + children: [ + FilledButton( + onPressed: (_busy || _picked.isEmpty) ? null : _submit, + child: _busy + ? const SizedBox( + height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Wünsche absenden'), + ), + const SizedBox(width: 12), + if (_done) + Row( + children: const [ + Icon(Icons.check_circle, color: Colors.green, size: 20), + SizedBox(width: 4), + Text('Gespeichert'), + ], + ), + ], + ), + ], + ), + ), + ); + } +} + +class _WorkshopRow extends StatelessWidget { + const _WorkshopRow({ + required this.workshop, + required this.rank, + required this.enabled, + required this.onTap, + }); + final Workshop workshop; + final int rank; + final bool enabled; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final selected = rank >= 0; + return ListTile( + dense: true, + enabled: enabled, + onTap: onTap, + leading: CircleAvatar( + radius: 14, + backgroundColor: selected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.surfaceContainerHighest, + child: Text( + selected ? '${rank + 1}' : '', + style: TextStyle( + fontSize: 13, + color: selected ? Theme.of(context).colorScheme.onPrimary : null, + ), + ), + ), + title: Text(workshop.name), + subtitle: Text('Kapazität ${workshop.kapazitaet}'), + trailing: Icon(selected ? Icons.check_box : Icons.check_box_outline_blank), + ); + } +} + +class _ErrorView extends StatelessWidget { + const _ErrorView({required this.message, required this.onRetry}); + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(message, textAlign: TextAlign.center), + const SizedBox(height: 12), + OutlinedButton(onPressed: onRetry, child: const Text('Erneut versuchen')), + ], + ), + ), + ); + } +} diff --git a/client/app/pubspec.lock b/client/app/pubspec.lock new file mode 100644 index 0000000..0da6913 --- /dev/null +++ b/client/app/pubspec.lock @@ -0,0 +1,362 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e + url: "https://pub.dev" + source: hosted + version: "1.1.3" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d + url: "https://pub.dev" + source: hosted + version: "1.18.3" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec + url: "https://pub.dev" + source: hosted + version: "3.2.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" + url: "https://pub.dev" + source: hosted + version: "2.4.28" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + url: "https://pub.dev" + source: hosted + version: "2.5.7" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + url: "https://pub.dev" + source: hosted + version: "1.12.2" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" +sdks: + dart: ">=3.13.3 <4.0.0" + flutter: ">=3.44.0" diff --git a/client/app/pubspec.yaml b/client/app/pubspec.yaml new file mode 100644 index 0000000..47a4147 --- /dev/null +++ b/client/app/pubspec.yaml @@ -0,0 +1,21 @@ +name: kc_app +description: "KC-App client — multi-tenant event, election and communication platform for Konfi-Castle events." +publish_to: 'none' +version: 0.1.0+1 + +environment: + sdk: ^3.13.3 + +dependencies: + flutter: + sdk: flutter + http: ^1.2.2 + shared_preferences: ^2.3.2 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true diff --git a/client/app/test/smoke_test.dart b/client/app/test/smoke_test.dart new file mode 100644 index 0000000..ae2922a --- /dev/null +++ b/client/app/test/smoke_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:kc_app/api.dart'; +import 'package:kc_app/main.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + testWidgets('shows the login screen when there is no stored token', (tester) async { + SharedPreferences.setMockInitialValues({}); + final state = AppState(Api(http.Client())); + await state.bootstrap(); // no stored token -> resolves immediately, no network + + await tester.pumpWidget(KcApp(state: state)); + await tester.pumpAndSettle(); + + expect(find.text('Konfi / Gast'), findsOneWidget); + expect(find.text('Team-Login'), findsOneWidget); + expect(find.text('Einladung'), findsOneWidget); + expect(find.widgetWithText(FilledButton, 'Weiter'), findsWidgets); + }); +} diff --git a/client/app/web/favicon.png b/client/app/web/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..8aaa46ac1ae21512746f852a42ba87e4165dfdd1 GIT binary patch literal 917 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|I14-?iy0X7 zltGxWVyS%@P(fs7NJL45ua8x7ey(0(N`6wRUPW#JP&EUCO@$SZnVVXYs8ErclUHn2 zVXFjIVFhG^g!Ppaz)DK8ZIvQ?0~DO|i&7O#^-S~(l1AfjnEK zjFOT9D}DX)@^Za$W4-*MbbUihOG|wNBYh(yU7!lx;>x^|#0uTKVr7USFmqf|i<65o z3raHc^AtelCMM;Vme?vOfh>Xph&xL%(-1c06+^uR^q@XSM&D4+Kp$>4P^%3{)XKjo zGZknv$b36P8?Z_gF{nK@`XI}Z90TzwSQO}0J1!f2c(B=V`5aP@1P1a|PZ!4!3&Gl8 zTYqUsf!gYFyJnXpu0!n&N*SYAX-%d(5gVjrHJWqXQshj@!Zm{!01WsQrH~9=kTxW#6SvuapgMqt>$=j#%eyGrQzr zP{L-3gsMA^$I1&gsBAEL+vxi1*Igl=8#8`5?A-T5=z-sk46WA1IUT)AIZHx1rdUrf zVJrJn<74DDw`j)Ki#gt}mIT-Q`XRa2-jQXQoI%w`nb|XblvzK${ZzlV)m-XcwC(od z71_OEC5Bt9GEXosOXaPTYOia#R4ID2TiU~`zVMl08TV_C%DnU4^+HE>9(CE4D6?Fz oujB08i7adh9xk7*FX66dWH6F5TM;?E2b5PlUHx3vIVCg!0Dx9vYXATM literal 0 HcmV?d00001 diff --git a/client/app/web/icons/Icon-192.png b/client/app/web/icons/Icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..b749bfef07473333cf1dd31e9eed89862a5d52aa GIT binary patch literal 5292 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 literal 0 HcmV?d00001 diff --git a/client/app/web/icons/Icon-512.png b/client/app/web/icons/Icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..88cfd48dff1169879ba46840804b412fe02fefd6 GIT binary patch literal 8252 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 literal 0 HcmV?d00001 diff --git a/client/app/web/icons/Icon-maskable-192.png b/client/app/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000000000000000000000000000000000000..eb9b4d76e525556d5d89141648c724331630325d GIT binary patch literal 5594 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 X>c!)3PgKfL&lX^$Z>Cpu&6)6jvi^Z! literal 0 HcmV?d00001 diff --git a/client/app/web/icons/Icon-maskable-512.png b/client/app/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..d69c56691fbdb0b7efa65097c7cc1edac12a6d3e GIT binary patch literal 20998 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 MPgg&ebxsLQ025IeI{*Lx literal 0 HcmV?d00001 diff --git a/client/app/web/index.html b/client/app/web/index.html new file mode 100644 index 0000000..443cec3 --- /dev/null +++ b/client/app/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + kc_app + + + + + + + diff --git a/client/app/web/manifest.json b/client/app/web/manifest.json new file mode 100644 index 0000000..0785abb --- /dev/null +++ b/client/app/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "kc_app", + "short_name": "kc_app", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} From 138d782ca3d927c37b4173907925c61b31b8ccd3 Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 08:54:15 +0200 Subject: [PATCH 13/37] docs: reflect Postgres, migration, and the Flutter client Root README + plan updated: local PostgreSQL 16 + first Prisma migration, end-to-end guest-flow verification, and the started Flutter client (client/app/, web target). Phase 7 marked in progress with the remaining client work spelled out. Co-Authored-By: Claude Sonnet 5 --- README.md | 37 +++++++++++++++++-------- plan-kcAppMultiTenantPlatform.prompt.md | 16 ++++++----- 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index b58770d..53022ea 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,13 @@ for the full architecture and phased roadmap. 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, + read-only chat. See [client/app/README.md](client/app/README.md). - `client/web/` — minimal dependency-free HTML/CSS/JS placeholder web - client (guest join, Wahl submission, file list, chat) exercising the real - API, served by the backend at `/`. Will be replaced by the Flutter web - build once Flutter is available. -- `client/` (mobile/desktop) — planned Flutter app, not yet scaffolded - (Flutter is not installed in this environment). + client, served by the backend at `/`. Superseded by the Flutter web build; + kept for now as a zero-dependency fallback. ## Status @@ -50,12 +51,24 @@ cloud server's `/sync/ingest` + `/sync/export` endpoints (shared-secret authenticated, not user auth). No conflict resolution needed by design - the local server is the sole source of truth while an event is live. -The backend now also serves the web client directly (static files from -`client/web/`, API under `/api`), so the same process is the single entry -point for the web experience. +Since then: local (non-Authentik) Gemeinde Teamer accounts + invites +(`teamer/`, `auth/team-login`), Gemeinde CRUD (`gemeinde/`), Gemeinde +Verantwortliche self-registration with LT approval (`onboarding/`), JIT +`User` provisioning on first Authentik login, LEITUNGSTEAM derived from the +Authentik `groups` claim, and an email module (`mail/`, log-only by default, +SMTP opt-in) that sends personal Teamer invites. First Prisma migration is +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). -Backend builds and boots cleanly (`npm run build`, `node dist/main.js`) but -requires a real PostgreSQL database, Authentik instance, and Nextcloud/S3 -credentials (see `backend/.env.example`) to run end to end. Remaining: the -Flutter clients (mobile/desktop; web has an interim plain-HTML client). +Phase 7 (Flutter client) started: `client/app/` is a single Flutter +codebase with the **web** target enabled — login (guest / local Teamer / +invite redemption), role-aware home, guest Workshop-Wahl, file list, +read-only chat. `flutter build web` and `flutter test` pass. Mobile/desktop +targets, the Authentik Authorization-Code flow, WebSocket chat send, and LT +admin screens are still to come. + +The backend also serves the interim `client/web/` placeholder at `/` (API +under `/api`). Running end to end still needs a real Authentik instance and +Nextcloud/S3 credentials (see `backend/.env.example`). diff --git a/plan-kcAppMultiTenantPlatform.prompt.md b/plan-kcAppMultiTenantPlatform.prompt.md index 11c1f5e..9675fdd 100644 --- a/plan-kcAppMultiTenantPlatform.prompt.md +++ b/plan-kcAppMultiTenantPlatform.prompt.md @@ -2,7 +2,7 @@ Neuentwicklung, die das WordPress-Plugin **Workshop-Wahlen** (Wahlen/Workshops/Teilnehmer/Zuteilungslogik) ablöst und um Rollen-/Rechteverwaltung via Authentik, gestaffelte Dateifreigabe, mehrstufigen Chat und eine Hybrid-Server-Architektur (online + lokal mit Sync) erweitert. Backend: **NestJS 10 + PostgreSQL + Prisma 5**. Client: **Flutter** (eine Codebase für Mobile, Web, Desktop) — bis Flutter verfügbar ist, liefert das Backend selbst einen minimalen Platzhalter-Web-Client aus. -> Status (Stand dieser Session): **Alle geplanten Backend-Phasen (0–6) sind implementiert und verifiziert** (Typecheck, Build, Boot-Test). Offen ist ausschließlich der Flutter-Client (Mobile/Desktop), da Flutter in dieser Umgebung nicht installiert ist. +> Status: **Backend-Phasen 0–6 komplett** plus lokale Teamer-Accounts/Invites, Gemeinde-CRUD, Verantwortlichen-Selbstregistrierung (`onboarding/`), Authentik-JIT + LT-aus-`groups`, `mail/`-Modul. Erste Prisma-Migration vorhanden, Backend lief Ende-zu-Ende gegen lokales PostgreSQL 16 (`npm test`: 56). **Phase 7 (Flutter) begonnen**: `client/app/` — eine Codebase, Web-Target aktiv, Login/Home/Wahl/Dateien/Chat(read-only); `flutter build web` + `flutter test` grün. Offen: Mobile/Desktop-Targets, Authentik-Auth-Code-Flow im Client, WS-Chat-Senden, LT-Admin-Screens, Push, echte Authentik/Nextcloud-Infra. --- @@ -37,7 +37,7 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris | Bereich | Entscheidung | Begründung | |---|---|---| | Backend | NestJS 10 + PostgreSQL + Prisma 5 | bestätigt vom Nutzer; Nest 10 statt CLI-Default (siehe unten) | -| Client | Flutter, eine Codebase Mobile/Web/Desktop | vom Nutzer delegiert; noch nicht scaffoldbar (Flutter fehlt lokal) | +| Client | Flutter, eine Codebase Mobile/Web/Desktop — `client/app/`, aktuell nur Web-Target aktiviert | vom Nutzer delegiert; Flutter-SDK lokal via Homebrew installiert (nur Web-Toolchain, Android/iOS wegen Speicher weggelassen); `lib/` ist plattformneutral, weitere Targets per `flutter create --platforms=…` nachrüstbar | | Web-Interimslösung | Backend liefert `client/web/` (reines HTML/CSS/JS, kein Build-Schritt) über `ServeStaticModule` aus; REST-API liegt unter `/api/*` | Nutzerwunsch: "Server soll auch Web-Client bereitstellen"; vermeidet Kollision zwischen API-Routen und statischen Dateien | | Auth (Team) | Authentik als OIDC Resource Server (JWKS-Verifikation), kein lokaler Autorisierungscode-Flow im Backend | Clients machen Authorization Code + PKCE direkt gegen Authentik; Backend validiert nur Access Token + löst lokale `Membership` auf | | Auth (Guest) | Rein lokale JWTs (`GUEST_JWT_SECRET`), kein Authentik | explizite Nutzervorgabe: Konfi-Accounts sind nie in Authentik | @@ -97,7 +97,7 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM 4. **Dateifreigabe** – Storage-Abstraktion, Sichtbarkeitsstufen. ✅ 5. **Kommunikation** – Chat (Gruppen/DM/LT/Broadcast), WebSocket. ✅ (Push-Integration noch offen) 6. **Hybrid Lokal/Cloud-Server & Sync** – Replikationslog, Scheduler, Shared-Secret-Auth. ✅ -7. **Flutter-Clients** – gemeinsame Codebase; Screens: Invite/Login, rollenspezifisches Dashboard, Wahl-Formular/Ergebnis, Dateien, Chat, Admin/Nutzerverwaltung. ❌ **offen** (Flutter nicht installiert); Web-Interimslösung siehe Abschnitt 3. +7. **Flutter-Clients** – gemeinsame Codebase (`client/app/`, Web-Target). ✅ Grundgerüst: Login (Guest / lokaler Teamer / Invite-Redemption, Token in `shared_preferences`, `GET /auth/me` für rollenabhängige Startseite), Guest-Wahl-Formular (`/wahl/guest/overview` → geordnete Wunsch-Auswahl → Absenden), Datei-Liste, Chat (nur lesen). 🔜 Mobile/Desktop-Targets, Authentik-Auth-Code-Flow, Wahl-**Ergebnis**-Ansicht, WS-Chat-Senden, LT-/Verantwortlichen-Admin-Screens (KC/Gemeinde/Teamer/Onboarding-Freigaben). --- @@ -112,6 +112,8 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM 1. Nach jeder Phase: `npx tsc -p tsconfig.build.json --noEmit`, `npx nest build`, sowie ein kurzer Boot-Test (`node dist/main.js`) zur Prüfung, dass der DI-Graph auflöst und alle Routen korrekt gemappt werden (ohne Live-DB/Authentik/Nextcloud). 2. Sync-Modul: manuell verifiziert, dass `SyncSecretGuard` Requests ohne `x-sync-secret` mit 403 ablehnt und mit korrektem Secret durchlässt (DB-Fehler in der Sandbox ist erwartet, da kein Postgres läuft). 3. Web-Client: `GET /` liefert die statische Seite (200), `GET /api/kc` trifft die echte, geschützte API (401 ohne Token). +3a. Gegen lokales **PostgreSQL 16** (Homebrew): `prisma migrate dev --name init` erzeugt/appliziert die erste Migration; Guest-Flow Ende-zu-Ende geprüft (`/auth/guest` → `/auth/me` → `/wahl/guest/overview` → `POST …/teilnehmer` → Re-Fetch zeigt `meinePrioritaeten`). Seed: `prisma/seed-dev.js`. +3b. Flutter-Client (`client/app/`): `flutter analyze` sauber, `flutter build web --release` erfolgreich, `flutter test` grün (Login-Screen-Smoke-Test); CORS-Preflight vom Browser-Origin ok. 4. Jest-Unit-Tests (Prisma/Sync/Mail gemockt, `npm test` grün, 56 Tests): - `src/wahl/zuteilung.service.spec.ts`: Force-Vorrang, Wunschrunden-Fallback bei voller Kapazität, Unzugeteilt-Fall, Konsolidierung unterbesetzter Workshops, Sync-Capture-Anzahl. - `src/auth/team-auth.service.spec.ts`: Invite-Redemption (unbekannt/widerrufen/abgelaufen/aufgebraucht, Gruppen-Link ohne E-Mail, E-Mail-Mismatch, Dublette) + Passwort-Login. @@ -124,8 +126,8 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM ## 8. Nächste Schritte -1. Sobald Flutter verfügbar ist: Client-Grundgerüst aufsetzen (Mobile + Web + Desktop, eine Codebase), beginnend mit Invite/Login-Flow gegen die bestehende API. -2. Authentik-Provider so konfigurieren, dass das Access-Token den `groups`-Claim trägt (Scope „groups"), und die LT-Gruppe auf `AUTHENTIK_LEITUNGSTEAM_GROUP` abstimmen — sonst greift der LT-Abgleich nicht. (Reine Ops-/Config-Aufgabe, Code ist fertig.) -3. SMTP konfigurieren (`MAIL_PROVIDER=smtp` + `SMTP_*`/`MAIL_FROM`/`APP_BASE_URL`) und die Invite-Mail-Templates finalisieren (aktuell nur Plain-Text); optional Onboarding-Benachrichtigungen an LT. +1. Flutter-Client ausbauen: Authentik-Authorization-Code-Flow (LT/Verantwortliche), Wahl-Ergebnis-Ansicht, LT-/Verantwortlichen-Admin-Screens (KC/Gemeinde/Teamer anlegen, Onboarding-Anfragen freigeben), WS-Chat-Senden, dann Mobile/Desktop-Targets aktivieren. +2. Authentik-Provider so konfigurieren, dass das Access-Token den `groups`-Claim trägt (Scope „groups"), LT-Gruppe = `AUTHENTIK_LEITUNGSTEAM_GROUP`. (Ops/Config, Code ist fertig.) +3. SMTP konfigurieren (`MAIL_PROVIDER=smtp` + `SMTP_*`/`MAIL_FROM`/`APP_BASE_URL`) und Invite-Mail-Templates finalisieren (aktuell Plain-Text); optional Onboarding-Benachrichtigungen an LT. 4. Push-Benachrichtigungen (FCM/APNs) für Chat/Ankündigungen. -5. Echte Infrastruktur (Postgres, Authentik, Nextcloud/S3) aufsetzen, erste Prisma-Migration erzeugen (`prisma migrate dev`, bisher nur `schema.prisma`) und die in Abschnitt 7 offenen Verifikationsschritte durchführen. +5. Echte Authentik- + Nextcloud/S3-Infra anbinden und die in Abschnitt 7 offenen E2E-Verifikationsschritte durchführen (lokales Postgres + Migration sind erledigt). From 0b588fa4b7155fd63f5d3b7bf828e836eb20abec Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 08:57:05 +0200 Subject: [PATCH 14/37] feat(backend): guest-facing Wahl result endpoint GET /api/wahl/guest/results (guest JWT) returns, per Wahl the guest took part in, their assignment: status PENDING (algorithm not run yet) / ASSIGNED (workshopName + wunschRang) / UNASSIGNED (no capacity left). Verified both PENDING and ASSIGNED paths against local Postgres. Co-Authored-By: Claude Sonnet 5 --- backend/src/wahl/wahl.controller.ts | 8 +++++++ backend/src/wahl/wahl.service.ts | 36 +++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/backend/src/wahl/wahl.controller.ts b/backend/src/wahl/wahl.controller.ts index e60e74b..6498857 100644 --- a/backend/src/wahl/wahl.controller.ts +++ b/backend/src/wahl/wahl.controller.ts @@ -78,6 +78,14 @@ export class WahlController { 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')) diff --git a/backend/src/wahl/wahl.service.ts b/backend/src/wahl/wahl.service.ts index c2e7153..9223234 100644 --- a/backend/src/wahl/wahl.service.ts +++ b/backend/src/wahl/wahl.service.ts @@ -56,6 +56,42 @@ export class WahlService { }; } + /// 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, From e55faeaa958621c6e4429c819e20a5811598e943 Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 09:02:40 +0200 Subject: [PATCH 15/37] feat: Wahl result view + live WebSocket chat in the Flutter client Backend: - fix(chat): ChatGateway stored the per-socket caller only after the async token check resolved, so a client that sent chat:join immediately on open raced ahead and got 4001. The caller is now stored as a promise that the message handlers await. Verified with a two-client send/receive E2E test against local Postgres. Client (client/app/): - Wahl screen gains a "Ergebnis" tab backed by GET /wahl/guest/results (PENDING / ASSIGNED with workshop + wish rank / UNASSIGNED). - Chat channel view loads history over REST, then connects the /chat WebSocket (chat_socket.dart): live chat:message stream + a compose bar that sends chat:send. Shows the socket status. - web_socket_channel dependency added. flutter analyze/test/build web all green; backend npm test 56. Co-Authored-By: Claude Sonnet 5 --- backend/src/chat/chat.gateway.ts | 33 ++-- client/app/lib/api.dart | 56 +++++++ client/app/lib/chat_socket.dart | 57 +++++++ client/app/lib/screens/chat_screen.dart | 197 ++++++++++++++++-------- client/app/lib/screens/home_screen.dart | 2 +- client/app/lib/screens/wahl_screen.dart | 143 ++++++++++++++--- client/app/pubspec.lock | 24 +++ client/app/pubspec.yaml | 1 + 8 files changed, 416 insertions(+), 97 deletions(-) create mode 100644 client/app/lib/chat_socket.dart diff --git a/backend/src/chat/chat.gateway.ts b/backend/src/chat/chat.gateway.ts index 27978a9..932d0a1 100644 --- a/backend/src/chat/chat.gateway.ts +++ b/backend/src/chat/chat.gateway.ts @@ -15,10 +15,14 @@ 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 WeakMap(); + private readonly callers = new Map>(); private readonly rooms = new Map>(); constructor( @@ -26,18 +30,18 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { private readonly chat: ChatService, ) {} - async handleConnection(client: WebSocket, request: IncomingMessage) { + handleConnection(client: WebSocket, request: IncomingMessage) { const token = new URL(request.url ?? '', 'http://localhost').searchParams.get('token'); if (!token) { client.close(4001, 'Missing token'); return; } - try { - this.callers.set(client, await this.tokenVerification.verifyEither(token)); - } catch (err) { + 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) { @@ -52,7 +56,7 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { @ConnectedSocket() client: WebSocket, @MessageBody() data: { channelId: string }, ) { - const caller = this.requireCaller(client); + 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 } }; @@ -63,19 +67,24 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { @ConnectedSocket() client: WebSocket, @MessageBody() data: { channelId: string; body: string }, ) { - const caller = this.requireCaller(client); + 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 requireCaller(client: WebSocket): ChatCaller { - const caller = this.callers.get(client); - if (!caller) { + private async resolveCaller(client: WebSocket): Promise { + 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'); } - return caller; } private roomFor(channelId: string): Set { diff --git a/client/app/lib/api.dart b/client/app/lib/api.dart index 8d70094..6db9483 100644 --- a/client/app/lib/api.dart +++ b/client/app/lib/api.dart @@ -145,6 +145,44 @@ class GuestOverview { ); } +enum WahlResultStatus { pending, assigned, unassigned } + +class WahlResult { + WahlResult({ + required this.wahlName, + required this.datumsSchluessel, + required this.teil, + required this.status, + required this.workshopName, + required this.wunschRang, + required this.isForced, + }); + final String wahlName; + final String datumsSchluessel; + final String teil; + final WahlResultStatus status; + final String? workshopName; + final int? wunschRang; + final bool isForced; + + factory WahlResult.fromJson(Map j) { + final wahl = j['wahl'] as Map; + return WahlResult( + wahlName: wahl['name'] as String, + datumsSchluessel: wahl['datumsSchluessel'] as String, + teil: wahl['teil'] as String, + status: switch (j['status'] as String?) { + 'ASSIGNED' => WahlResultStatus.assigned, + 'UNASSIGNED' => WahlResultStatus.unassigned, + _ => WahlResultStatus.pending, + }, + workshopName: j['workshopName'] as String?, + wunschRang: (j['wunschRang'] as num?)?.toInt(), + isForced: j['isForced'] as bool? ?? false, + ); + } +} + class FileEntry { FileEntry({required this.id, required this.filename, required this.visibility}); final String id; @@ -262,6 +300,11 @@ class Api { await _post('/wahl/$wahlId/teilnehmer', {'prioritaeten': workshopIds}); } + Future> guestWahlResults() async { + final list = await _get('/wahl/guest/results') as List; + return list.map((e) => WahlResult.fromJson(e as Map)).toList(); + } + // --- files --- Future> files(String kcId) async { final list = await _get('/files/$kcId') as List; @@ -270,6 +313,19 @@ class Api { String fileDownloadUrl(String fileId) => '$kApiBase/files/download/$fileId'; + /// WebSocket endpoint for the chat gateway. It lives at `/chat` (outside the + /// `/api` prefix) and authenticates via a `?token=` query param. + Uri chatWsUri() { + final base = Uri.parse(kApiBase); + return Uri( + scheme: base.scheme == 'https' ? 'wss' : 'ws', + host: base.host, + port: base.hasPort ? base.port : null, + path: '/chat', + queryParameters: {'token': token ?? ''}, + ); + } + // --- chat (read-only for now; sending is a WebSocket-only path) --- Future> channels(String kcId) async { final list = await _get('/chat/$kcId/channels') as List; diff --git a/client/app/lib/chat_socket.dart b/client/app/lib/chat_socket.dart new file mode 100644 index 0000000..d15e4eb --- /dev/null +++ b/client/app/lib/chat_socket.dart @@ -0,0 +1,57 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:web_socket_channel/web_socket_channel.dart'; + +import 'api.dart'; + +/// Thin wrapper over the raw `ws` chat gateway. The NestJS `WsAdapter` +/// expects `{"event": ..., "data": ...}` frames in both directions. +class ChatSocket { + ChatSocket(this._uri); + final Uri _uri; + + WebSocketChannel? _channel; + final _messages = StreamController.broadcast(); + final _status = StreamController.broadcast(); + + /// Incoming `chat:message` frames. + Stream get messages => _messages.stream; + + /// "connected" / "closed" / "error: ..." for a small status line. + Stream get status => _status.stream; + + void connect(String channelId) { + _channel = WebSocketChannel.connect(_uri); + _channel!.stream.listen( + (raw) { + _status.add('connected'); + try { + final frame = jsonDecode(raw as String) as Map; + if (frame['event'] == 'chat:message') { + _messages.add(ChatMessage.fromJson(frame['data'] as Map)); + } + } catch (_) { + // ignore frames we don't model + } + }, + onError: (Object e) => _status.add('error: $e'), + onDone: () => _status.add('closed'), + ); + _send('chat:join', {'channelId': channelId}); + } + + void sendMessage(String channelId, String body) { + _send('chat:send', {'channelId': channelId, 'body': body}); + } + + void _send(String event, Map data) { + _channel?.sink.add(jsonEncode({'event': event, 'data': data})); + } + + void dispose() { + _channel?.sink.close(); + _messages.close(); + _status.close(); + } +} diff --git a/client/app/lib/screens/chat_screen.dart b/client/app/lib/screens/chat_screen.dart index 63cc8e9..33c795e 100644 --- a/client/app/lib/screens/chat_screen.dart +++ b/client/app/lib/screens/chat_screen.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import '../api.dart'; +import '../chat_socket.dart'; import '../main.dart'; -/// Read-only chat view. Sending a message is a WebSocket-only path on the -/// backend (`chat:send`); wiring that up is a follow-up. +/// 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`). class ChatScreen extends StatefulWidget { const ChatScreen({super.key, required this.kcId}); final String kcId; @@ -85,75 +87,150 @@ class _ChannelMessages extends StatefulWidget { } class _ChannelMessagesState extends State<_ChannelMessages> { - Future>? _future; + final List _messages = []; + final _composer = TextEditingController(); + final _scroll = ScrollController(); + ChatSocket? _socket; + bool _loading = true; + String? _loadError; + String _wsStatus = 'verbinde…'; @override void didChangeDependencies() { super.didChangeDependencies(); - _future ??= AppScope.of(context).api.messages(widget.channelId); + if (_socket != null) return; + final api = AppScope.of(context).api; + _load(api); + _socket = ChatSocket(api.chatWsUri()) + ..connect(widget.channelId) + ..messages.listen(_onIncoming) + ..status.listen((s) => mounted ? setState(() => _wsStatus = s) : null); + } + + Future _load(Api api) async { + try { + final history = await api.messages(widget.channelId); + if (!mounted) return; + setState(() { + _messages + ..clear() + ..addAll(history); + _loading = false; + }); + _jump(); + } catch (e) { + if (mounted) setState(() { _loadError = '$e'; _loading = false; }); + } + } + + void _onIncoming(ChatMessage m) { + if (!mounted) return; + setState(() => _messages.add(m)); + _jump(); + } + + void _jump() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scroll.hasClients) { + _scroll.jumpTo(_scroll.position.maxScrollExtent); + } + }); + } + + void _send() { + final text = _composer.text.trim(); + if (text.isEmpty) return; + _socket?.sendMessage(widget.channelId, text); + _composer.clear(); + } + + @override + void dispose() { + _socket?.dispose(); + _composer.dispose(); + _scroll.dispose(); + super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: Text(widget.title)), - body: FutureBuilder>( - 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), - ), - ); - } - final messages = snap.data!; - if (messages.isEmpty) { - return const Center(child: Text('Noch keine Nachrichten.')); - } - return ListView.builder( - 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), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(m.body), - const SizedBox(height: 2), - Text( - m.createdAt, - style: Theme.of(context).textTheme.labelSmall, - ), - ], - ), - ), - ); - }, - ); - }, - ), - bottomNavigationBar: const Padding( - padding: EdgeInsets.all(12), - child: Text( - 'Senden folgt (WebSocket) — aktuell nur Lesen.', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 12), + appBar: AppBar( + title: Text(widget.title), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(18), + child: Text('WebSocket: $_wsStatus', style: const TextStyle(fontSize: 11)), ), ), + 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, + ), + ), + ), + IconButton(icon: const Icon(Icons.send), onPressed: _send), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _body(BuildContext context) { + if (_loading) return const Center(child: CircularProgressIndicator()); + if (_loadError != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: 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), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(m.body), + const SizedBox(height: 2), + Text(m.createdAt, style: Theme.of(context).textTheme.labelSmall), + ], + ), + ), + ); + }, ); } } diff --git a/client/app/lib/screens/home_screen.dart b/client/app/lib/screens/home_screen.dart index 44d7586..acd9a8c 100644 --- a/client/app/lib/screens/home_screen.dart +++ b/client/app/lib/screens/home_screen.dart @@ -34,7 +34,7 @@ class HomeScreen extends StatelessWidget { _NavTile( icon: Icons.forum, title: 'Chat', - subtitle: 'Kanäle & Nachrichten (lesen)', + subtitle: 'Kanäle, Verlauf & Live-Nachrichten', onTap: () => _open(context, ChatScreen(kcId: kcId)), ), ]; diff --git a/client/app/lib/screens/wahl_screen.dart b/client/app/lib/screens/wahl_screen.dart index ce6eca9..ec4ac52 100644 --- a/client/app/lib/screens/wahl_screen.dart +++ b/client/app/lib/screens/wahl_screen.dart @@ -27,31 +27,126 @@ class _WahlScreenState extends State { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Workshop-Wahl')), - body: FutureBuilder( - future: _future, - builder: (context, snap) { - if (snap.connectionState != ConnectionState.done) { - return const Center(child: CircularProgressIndicator()); - } - if (snap.hasError) { - return _ErrorView(message: '${snap.error}', onRetry: _reload); - } - final data = snap.data!; - if (data.wahlen.isEmpty) { - return const Center(child: Text('Aktuell ist keine Wahl geöffnet.')); - } - return ListView( + return DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + title: const Text('Workshop-Wahl'), + bottom: const TabBar(tabs: [Tab(text: 'Wünsche'), Tab(text: 'Ergebnis')]), + ), + body: TabBarView( + children: [ + FutureBuilder( + future: _future, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + if (snap.hasError) { + return _ErrorView(message: '${snap.error}', onRetry: _reload); + } + final data = snap.data!; + if (data.wahlen.isEmpty) { + return const Center(child: Text('Aktuell ist keine Wahl geöffnet.')); + } + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Text(data.kcName, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + for (final w in data.wahlen) + _WahlCard(wahl: w, onSubmitted: _reload), + ], + ); + }, + ), + const _ErgebnisTab(), + ], + ), + ), + ); + } +} + +class _ErgebnisTab extends StatefulWidget { + const _ErgebnisTab(); + @override + State<_ErgebnisTab> createState() => _ErgebnisTabState(); +} + +class _ErgebnisTabState extends State<_ErgebnisTab> { + Future>? _future; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _future ??= AppScope.of(context).api.guestWahlResults(); + } + + void _reload() { + setState(() => _future = AppScope.of(context).api.guestWahlResults()); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: _future, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + if (snap.hasError) { + return _ErrorView(message: '${snap.error}', onRetry: _reload); + } + final results = snap.data!; + if (results.isEmpty) { + return const Center(child: Text('Noch keine Teilnahme an einer Wahl.')); + } + return RefreshIndicator( + onRefresh: () async => _reload(), + child: ListView( padding: const EdgeInsets.all(16), - children: [ - Text(data.kcName, style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - for (final w in data.wahlen) - _WahlCard(wahl: w, onSubmitted: _reload), - ], - ); - }, + children: [for (final r in results) _ResultCard(result: r)], + ), + ); + }, + ); + } +} + +class _ResultCard extends StatelessWidget { + const _ResultCard({required this.result}); + final WahlResult result; + + @override + Widget build(BuildContext context) { + final (label, color, detail) = switch (result.status) { + WahlResultStatus.assigned => ( + result.workshopName ?? 'Zugeteilt', + Colors.green, + result.isForced + ? 'Fest zugeteilt (Leitungsteam)' + : 'Wunsch ${result.wunschRang ?? '?'}', + ), + WahlResultStatus.unassigned => ( + 'Kein Platz frei', + Theme.of(context).colorScheme.error, + 'Bitte beim Leitungsteam melden.', + ), + WahlResultStatus.pending => ( + 'Noch nicht zugeteilt', + Theme.of(context).colorScheme.outline, + 'Die Zuteilung läuft noch.', + ), + }; + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: ListTile( + leading: Icon(Icons.emoji_events, color: color), + title: Text('${result.wahlName} · ${result.datumsSchluessel} Teil ${result.teil}', + style: Theme.of(context).textTheme.bodySmall), + subtitle: Text(label, style: Theme.of(context).textTheme.titleMedium), + trailing: Text(detail, textAlign: TextAlign.end), ), ); } diff --git a/client/app/pubspec.lock b/client/app/pubspec.lock index 0da6913..3fe5047 100644 --- a/client/app/pubspec.lock +++ b/client/app/pubspec.lock @@ -41,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" fake_async: dependency: transitive description: @@ -349,6 +357,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: "direct main" + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" xdg_directories: dependency: transitive description: diff --git a/client/app/pubspec.yaml b/client/app/pubspec.yaml index 47a4147..01e5ac6 100644 --- a/client/app/pubspec.yaml +++ b/client/app/pubspec.yaml @@ -11,6 +11,7 @@ dependencies: sdk: flutter http: ^1.2.2 shared_preferences: ^2.3.2 + web_socket_channel: ^3.0.1 dev_dependencies: flutter_test: From d5ecdcd3c41c9f3f40d162425c2abcb4fac99fc1 Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 09:03:42 +0200 Subject: [PATCH 16/37] docs: Wahl result view, live chat, and the gateway race fix Plan + client README updated for the guest Wahl result endpoint/tab, the WebSocket chat wiring, and the ChatGateway caller-promise fix. Section 8 now scopes the remaining Flutter work to the Authentik-dependent screens. Co-Authored-By: Claude Sonnet 5 --- client/app/README.md | 12 +++++++----- client/app/lib/api.dart | 2 +- plan-kcAppMultiTenantPlatform.prompt.md | 8 +++++--- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/client/app/README.md b/client/app/README.md index a98f1b5..2f486b9 100644 --- a/client/app/README.md +++ b/client/app/README.md @@ -26,13 +26,15 @@ backend, which also serves the interim plain-HTML client at `/`). - The token is stored via `shared_preferences` (localStorage on web) and restored on start; `GET /auth/me` resolves the role for a role-aware home. - **Home** (`lib/screens/home_screen.dart`) — identity card + navigation. -- **Workshop-Wahl** (`lib/screens/wahl_screen.dart`, guests) — loads - `GET /wahl/guest/overview`, tap workshops in order (max 3) to set - priorities, `POST /wahl/:id/teilnehmer`. +- **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` — + PENDING / ASSIGNED with workshop + wish rank / UNASSIGNED). - **Dateien** (`lib/screens/files_screen.dart`) — `GET /files/:kcId`, filtered server-side by the caller's visibility tier. -- **Chat** (`lib/screens/chat_screen.dart`) — channel + message list - (read-only; sending is a WebSocket path, still to do). +- **Chat** (`lib/screens/chat_screen.dart` + `lib/chat_socket.dart`) — + channel list, REST history, then a live `/chat` WebSocket connection + (`chat:join` / `chat:send` / `chat:message`) with a compose bar. ## Architecture diff --git a/client/app/lib/api.dart b/client/app/lib/api.dart index 6db9483..0d04bcf 100644 --- a/client/app/lib/api.dart +++ b/client/app/lib/api.dart @@ -326,7 +326,7 @@ class Api { ); } - // --- chat (read-only for now; sending is a WebSocket-only path) --- + // --- chat: REST for channels/history; live send/receive is the /chat WS --- Future> channels(String kcId) async { final list = await _get('/chat/$kcId/channels') as List; return list.map((e) => ChatChannel.fromJson(e as Map)).toList(); diff --git a/plan-kcAppMultiTenantPlatform.prompt.md b/plan-kcAppMultiTenantPlatform.prompt.md index 9675fdd..eb0c2b9 100644 --- a/plan-kcAppMultiTenantPlatform.prompt.md +++ b/plan-kcAppMultiTenantPlatform.prompt.md @@ -70,7 +70,7 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris | `onboarding/` | Selbstregistrierung Gemeinde Verantwortliche/r: öffentlicher Invite-Lookup, JIT-`User`-Anlage aus Authentik-Claims, `Membership` im Status `PENDING`; LT sieht/genehmigt/lehnt ab | `GET /api/onboarding/kc/:inviteCode`, `POST /api/onboarding/verantwortliche` (Authentik-Bearer), `GET /api/onboarding/requests?kcId=` (LT), `POST /api/onboarding/requests/:id/approve\|reject` (LT) | | `teamer/` | Lokale Gemeinde-Teamer-Accounts + Invites; Direkt-Anlage, Gruppen-Link und E-Mail-Invite (persönliche Invites werden per `MailService` best-effort verschickt, `emailSent` im Response); nutzbar von LT (jede Gemeinde) oder Verantwortliche/r (nur eigene Gemeinde, in `TeamerService` geprüft) | `POST/GET /api/gemeinde/:gemeindeId/teamer`, `DELETE /api/gemeinde/:gemeindeId/teamer/:userId`, `POST/GET /api/gemeinde/:gemeindeId/teamer-invites`, `DELETE .../teamer-invites/:id` | | `mail/` | Globale `MailProvider`-Abstraktion (log-only Default, SMTP via `MAIL_PROVIDER=smtp`); `MailService` baut die Invite-Mail inkl. Link aus `APP_BASE_URL` | – | -| `wahl/` | Wahl-/Workshop-Verwaltung, Force-Zuteilung, Teilnehmer-Einreichung, `ZuteilungService` (Portierung von `kc_run_zuteilung`: Force-Zuteilungen → 3 Wunschrunden → Zufallsfüllung → Konsolidierung unterbesetzter Workshops), CSV-Export | `POST/GET /api/wahl`, `POST/GET /api/wahl/:id/workshops`, `POST /api/wahl/:id/force-zuteilung`, `POST /api/wahl/:id/teilnehmer` (Guest), `POST /api/wahl/:id/zuteilung/run`, `GET /api/wahl/:id/zuteilung(.csv)` | +| `wahl/` | Wahl-/Workshop-Verwaltung, Force-Zuteilung, Teilnehmer-Einreichung, `ZuteilungService` (Portierung von `kc_run_zuteilung`: Force-Zuteilungen → 3 Wunschrunden → Zufallsfüllung → Konsolidierung unterbesetzter Workshops), CSV-Export | `POST/GET /api/wahl`, `POST/GET /api/wahl/:id/workshops`, `POST /api/wahl/:id/force-zuteilung`, `POST /api/wahl/:id/teilnehmer` (Guest), `GET /api/wahl/guest/overview` + `GET /api/wahl/guest/results` (Guest), `POST /api/wahl/:id/zuteilung/run`, `GET /api/wahl/:id/zuteilung(.csv)` | | `files/` | Upload (LT-only, multipart) mit Sichtbarkeitsstufe; Liste/Download für Team oder Guest, gefiltert nach erlaubten Sichtbarkeitsstufen; `StorageProvider`-Abstraktion (WebDAV/Nextcloud Default, S3 optional) | `POST /api/files/:kcId`, `GET /api/files/:kcId`, `GET /api/files/download/:fileId` | | `chat/` | Zentrale Zugriffslogik (`ChatService`) geteilt zwischen REST (`ChatController`) und WS (`ChatGateway`, Pfad `/chat`, eigene Token-Verifikation via `?token=`); Kanaltypen wie oben | `POST /api/chat/:kcId/channels`, `POST /api/chat/direct`, `GET /api/chat/:kcId/channels`, `GET /api/chat/channels/:id/messages`, WS-Events `chat:join`/`chat:send`/`chat:message` | | `sync/` | Append-only Replikationslog + Peer-Sync (lokal ⇄ Cloud), `SyncSchedulerService` (alle 30s, wenn `SYNC_ENABLED=true`) | `POST /api/sync/ingest`, `GET /api/sync/export`, `POST /api/sync/trigger` (LT-only) | @@ -97,7 +97,7 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM 4. **Dateifreigabe** – Storage-Abstraktion, Sichtbarkeitsstufen. ✅ 5. **Kommunikation** – Chat (Gruppen/DM/LT/Broadcast), WebSocket. ✅ (Push-Integration noch offen) 6. **Hybrid Lokal/Cloud-Server & Sync** – Replikationslog, Scheduler, Shared-Secret-Auth. ✅ -7. **Flutter-Clients** – gemeinsame Codebase (`client/app/`, Web-Target). ✅ Grundgerüst: Login (Guest / lokaler Teamer / Invite-Redemption, Token in `shared_preferences`, `GET /auth/me` für rollenabhängige Startseite), Guest-Wahl-Formular (`/wahl/guest/overview` → geordnete Wunsch-Auswahl → Absenden), Datei-Liste, Chat (nur lesen). 🔜 Mobile/Desktop-Targets, Authentik-Auth-Code-Flow, Wahl-**Ergebnis**-Ansicht, WS-Chat-Senden, LT-/Verantwortlichen-Admin-Screens (KC/Gemeinde/Teamer/Onboarding-Freigaben). +7. **Flutter-Clients** – gemeinsame Codebase (`client/app/`, Web-Target). ✅ Login (Guest / lokaler Teamer / Invite-Redemption, Token in `shared_preferences`, `GET /auth/me` für rollenabhängige Startseite), Guest-Wahl: **Wünsche** (`/wahl/guest/overview` → geordnete Auswahl → Absenden) + **Ergebnis** (`/wahl/guest/results`, PENDING/ASSIGNED/UNASSIGNED), Datei-Liste, **Chat** mit REST-Verlauf + Live-`chat:message` und Senden über das `/chat`-WebSocket (`chat_socket.dart`). 🔜 Mobile/Desktop-Targets, Authentik-Auth-Code-Flow, LT-/Verantwortlichen-Admin-Screens (KC/Gemeinde/Teamer/Onboarding-Freigaben). --- @@ -114,6 +114,8 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM 3. Web-Client: `GET /` liefert die statische Seite (200), `GET /api/kc` trifft die echte, geschützte API (401 ohne Token). 3a. Gegen lokales **PostgreSQL 16** (Homebrew): `prisma migrate dev --name init` erzeugt/appliziert die erste Migration; Guest-Flow Ende-zu-Ende geprüft (`/auth/guest` → `/auth/me` → `/wahl/guest/overview` → `POST …/teilnehmer` → Re-Fetch zeigt `meinePrioritaeten`). Seed: `prisma/seed-dev.js`. 3b. Flutter-Client (`client/app/`): `flutter analyze` sauber, `flutter build web --release` erfolgreich, `flutter test` grün (Login-Screen-Smoke-Test); CORS-Preflight vom Browser-Origin ok. +3c. Guest-Ergebnis: `/wahl/guest/results` gegen echtes Postgres in beiden Zuständen geprüft (PENDING nach Einreichung, ASSIGNED nach manuell gesetzter `Zuteilung` → Workshop-Name + Wunschrang). +3d. WS-Chat: Zwei-Client-E2E gegen echtes Postgres (zwei lokale Teamer im selben `GEMEINDE_GRUPPE`-Kanal, `chat:send` → der andere empfängt `chat:message`). Dabei behoben: `ChatGateway` speicherte den Caller erst nach dem asynchronen Token-Check, wodurch ein sofortiges `chat:join` mit 4001 abgewiesen wurde — jetzt wartet der Handler auf das Caller-Promise. 4. Jest-Unit-Tests (Prisma/Sync/Mail gemockt, `npm test` grün, 56 Tests): - `src/wahl/zuteilung.service.spec.ts`: Force-Vorrang, Wunschrunden-Fallback bei voller Kapazität, Unzugeteilt-Fall, Konsolidierung unterbesetzter Workshops, Sync-Capture-Anzahl. - `src/auth/team-auth.service.spec.ts`: Invite-Redemption (unbekannt/widerrufen/abgelaufen/aufgebraucht, Gruppen-Link ohne E-Mail, E-Mail-Mismatch, Dublette) + Passwort-Login. @@ -126,7 +128,7 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM ## 8. Nächste Schritte -1. Flutter-Client ausbauen: Authentik-Authorization-Code-Flow (LT/Verantwortliche), Wahl-Ergebnis-Ansicht, LT-/Verantwortlichen-Admin-Screens (KC/Gemeinde/Teamer anlegen, Onboarding-Anfragen freigeben), WS-Chat-Senden, dann Mobile/Desktop-Targets aktivieren. +1. Flutter-Client ausbauen: **Authentik-Authorization-Code-Flow** (LT/Verantwortliche — braucht eine erreichbare Authentik-Instanz), LT-/Verantwortlichen-Admin-Screens (KC/Gemeinde/Teamer anlegen, Onboarding-Anfragen freigeben), Verantwortlichen-Selbstregistrierung, dann Mobile/Desktop-Targets aktivieren. 2. Authentik-Provider so konfigurieren, dass das Access-Token den `groups`-Claim trägt (Scope „groups"), LT-Gruppe = `AUTHENTIK_LEITUNGSTEAM_GROUP`. (Ops/Config, Code ist fertig.) 3. SMTP konfigurieren (`MAIL_PROVIDER=smtp` + `SMTP_*`/`MAIL_FROM`/`APP_BASE_URL`) und Invite-Mail-Templates finalisieren (aktuell Plain-Text); optional Onboarding-Benachrichtigungen an LT. 4. Push-Benachrichtigungen (FCM/APNs) für Chat/Ankündigungen. From df11d8492df1b7b5c5edee707830720b24bf2349 Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 09:24:38 +0200 Subject: [PATCH 17/37] feat: Authentik OIDC login (PKCE) + Leitungsteam admin screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - AuthentikStrategy / TokenVerificationService: normalise the issuer's trailing slash and accept both `iss` spellings (Authentik's discovery issuer and token `iss` carry a trailing slash; the JWKS URL must not double it). Wire the real konfi-castle issuer into .env.example. - team token path now goes through toAuthenticatedUser too, so a local account flagged isLeitungsteam gets the synthetic global LT membership regardless of token kind. - LT-admin controllers (kc, gemeinde, onboarding, sync, teamer) accept ['authentik','team'] so such an account can use them. RolesGuard still enforces the actual LT/role check. - app.module serves the Flutter web build from client/app/build/web (SPA fallback covers the OIDC redirect path /v1/auth/callback), falling back to the interim client/web/ if it isn't built. Client (client/app/): - oidc.dart: Authorization-Code + PKCE against Authentik (discovery, S256 challenge, state, token exchange, refresh). Browser bits (sessionStorage, redirect, URL) behind a conditional import so `flutter test` still compiles on the VM. - AppState handles the ?code= callback on bootstrap, stores access + refresh, refreshes an expired token on restart. - Login screen: "Mit Konfi-Castle-ID anmelden" button (Leitungsteam / Verantwortliche) alongside the local Teamer password form. - admin_screen.dart: LT-only "Verwaltung" — list/create KCs, per KC the Gemeinden (list/create) and pending Verantwortlichen requests (approve/reject). Verified end to end against local Postgres with an isLeitungsteam account (create KC/Gemeinde, list + approve a request). flutter analyze/test/build web green; backend npm test 56. Co-Authored-By: Claude Sonnet 5 --- backend/.env.example | 4 +- backend/src/app.module.ts | 16 +- backend/src/auth/authentik.strategy.ts | 8 +- backend/src/auth/team-auth.service.ts | 14 +- .../src/auth/token-verification.service.ts | 5 +- backend/src/gemeinde/gemeinde.controller.ts | 2 +- backend/src/kc/kc.controller.ts | 2 +- .../src/onboarding/onboarding.controller.ts | 6 +- backend/src/sync/sync.controller.ts | 2 +- backend/src/teamer/teamer.controller.ts | 7 +- client/app/lib/api.dart | 129 +++++++- client/app/lib/browser.dart | 3 + client/app/lib/browser_stub.dart | 9 + client/app/lib/browser_web.dart | 22 ++ client/app/lib/oidc.dart | 151 +++++++++ client/app/lib/screens/admin_screen.dart | 291 ++++++++++++++++++ client/app/lib/screens/home_screen.dart | 8 + client/app/lib/screens/login_screen.dart | 57 +++- client/app/pubspec.lock | 4 +- client/app/pubspec.yaml | 2 + 20 files changed, 693 insertions(+), 49 deletions(-) create mode 100644 client/app/lib/browser.dart create mode 100644 client/app/lib/browser_stub.dart create mode 100644 client/app/lib/browser_web.dart create mode 100644 client/app/lib/oidc.dart create mode 100644 client/app/lib/screens/admin_screen.dart diff --git a/backend/.env.example b/backend/.env.example index de0a625..808ad73 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,8 +1,8 @@ # Postgres connection used by Prisma DATABASE_URL="postgresql://postgres:postgres@localhost:5432/kcapp?schema=public" -# Authentik OIDC issuer, e.g. https://auth.example.org/application/o/kc-app/ -AUTHENTIK_ISSUER_URL="https://authentik.example.org/application/o/kc-app" +# 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` diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 2cf76fb..ffaeec6 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,6 +1,7 @@ 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'; @@ -14,13 +15,22 @@ 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 }), - // Serves the plain static web client from ../client/web; the REST API - // lives under /api (see main.ts) so it never collides with these routes. + // 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: join(__dirname, '..', '..', 'client', 'web'), + rootPath: webRoot, exclude: ['/api*'], }), PrismaModule, diff --git a/backend/src/auth/authentik.strategy.ts b/backend/src/auth/authentik.strategy.ts index 9fca443..474e6e4 100644 --- a/backend/src/auth/authentik.strategy.ts +++ b/backend/src/auth/authentik.strategy.ts @@ -32,18 +32,20 @@ export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') { private readonly prisma: PrismaClient, private readonly sync: SyncService, ) { - const issuerUrl = config.getOrThrow('AUTHENTIK_ISSUER_URL'); + // Authentik's discovery `issuer` carries a trailing slash and so does the + // `iss` claim in its tokens; accept both spellings and never emit `//`. + const base = config.getOrThrow('AUTHENTIK_ISSUER_URL').replace(/\/+$/, ''); super({ jwtFromRequest: (req: Request) => req.headers.authorization?.startsWith('Bearer ') ? req.headers.authorization.slice('Bearer '.length) : null, secretOrKeyProvider: jwksRsa.passportJwtSecret({ - jwksUri: `${issuerUrl}/jwks/`, + jwksUri: `${base}/jwks/`, cache: true, rateLimit: true, }), - issuer: issuerUrl, + issuer: [base, `${base}/`], algorithms: ['RS256'], }); this.leitungsteamGroup = config.get('AUTHENTIK_LEITUNGSTEAM_GROUP', 'Leitungsteam'); diff --git a/backend/src/auth/team-auth.service.ts b/backend/src/auth/team-auth.service.ts index 048333e..935f781 100644 --- a/backend/src/auth/team-auth.service.ts +++ b/backend/src/auth/team-auth.service.ts @@ -12,6 +12,7 @@ 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; @@ -146,15 +147,8 @@ export class TeamAuthService { if (!user) { throw new UnauthorizedException('Team account no longer exists'); } - return { - userId: user.id, - authentikSub: user.authentikSub, - email: user.email, - memberships: user.memberships.map((m) => ({ - kcId: m.kcId, - gemeindeId: m.gemeindeId, - role: m.role, - })), - }; + // Same shape as the Authentik path, incl. the synthetic global + // LEITUNGSTEAM membership when `isLeitungsteam` is set on the row. + return toAuthenticatedUser(user); } } diff --git a/backend/src/auth/token-verification.service.ts b/backend/src/auth/token-verification.service.ts index f3caa60..3d48559 100644 --- a/backend/src/auth/token-verification.service.ts +++ b/backend/src/auth/token-verification.service.ts @@ -29,7 +29,8 @@ export class TokenVerificationService { private readonly teamAuth: TeamAuthService, private readonly sync: SyncService, ) { - this.issuerUrl = config.getOrThrow('AUTHENTIK_ISSUER_URL'); + // See AuthentikStrategy: normalise the trailing slash, accept both forms. + this.issuerUrl = config.getOrThrow('AUTHENTIK_ISSUER_URL').replace(/\/+$/, ''); this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true }); this.leitungsteamGroup = config.get('AUTHENTIK_LEITUNGSTEAM_GROUP', 'Leitungsteam'); } @@ -48,7 +49,7 @@ export class TokenVerificationService { } const key = await this.jwks.getSigningKey(kid); const payload = jwt.verify(token, key.getPublicKey(), { - issuer: this.issuerUrl, + issuer: [this.issuerUrl, `${this.issuerUrl}/`], algorithms: ['RS256'], }) as jwt.JwtPayload & { email?: string; diff --git a/backend/src/gemeinde/gemeinde.controller.ts b/backend/src/gemeinde/gemeinde.controller.ts index 5f404ef..9b6c06a 100644 --- a/backend/src/gemeinde/gemeinde.controller.ts +++ b/backend/src/gemeinde/gemeinde.controller.ts @@ -21,7 +21,7 @@ import { Role } from '../common/role.enum'; /// 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'), RolesGuard) +@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) @Roles(Role.LEITUNGSTEAM) export class GemeindeController { constructor(private readonly gemeinde: GemeindeService) {} diff --git a/backend/src/kc/kc.controller.ts b/backend/src/kc/kc.controller.ts index b520137..896cb02 100644 --- a/backend/src/kc/kc.controller.ts +++ b/backend/src/kc/kc.controller.ts @@ -7,7 +7,7 @@ import { RolesGuard } from '../common/roles.guard'; import { Role } from '../common/role.enum'; @Controller('kc') -@UseGuards(AuthGuard('authentik'), RolesGuard) +@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) export class KcController { constructor(private readonly kc: KcService) {} diff --git a/backend/src/onboarding/onboarding.controller.ts b/backend/src/onboarding/onboarding.controller.ts index 4fb1861..dabbffe 100644 --- a/backend/src/onboarding/onboarding.controller.ts +++ b/backend/src/onboarding/onboarding.controller.ts @@ -45,21 +45,21 @@ export class OnboardingController { /// Leitungsteam: review and act on pending self-registrations. @Get('requests') - @UseGuards(AuthGuard('authentik'), RolesGuard) + @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'), RolesGuard) + @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'), RolesGuard) + @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) @Roles(Role.LEITUNGSTEAM) reject(@Param('membershipId') membershipId: string) { return this.onboarding.reject(membershipId); diff --git a/backend/src/sync/sync.controller.ts b/backend/src/sync/sync.controller.ts index 4a06dfc..c84107f 100644 --- a/backend/src/sync/sync.controller.ts +++ b/backend/src/sync/sync.controller.ts @@ -33,7 +33,7 @@ export class SyncController { /// Manual on-demand push+pull against the configured peer (Leitungsteam-only). @Post('trigger') - @UseGuards(AuthGuard('authentik'), RolesGuard) + @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) @Roles(Role.LEITUNGSTEAM) async trigger() { const peerUrl = this.config.getOrThrow('SYNC_PEER_URL'); diff --git a/backend/src/teamer/teamer.controller.ts b/backend/src/teamer/teamer.controller.ts index ba11723..ddc3cde 100644 --- a/backend/src/teamer/teamer.controller.ts +++ b/backend/src/teamer/teamer.controller.ts @@ -18,10 +18,11 @@ import { Role } from '../common/role.enum'; import { AuthenticatedRequest } from '../auth/authenticated-request'; /// Gemeinde Teamer administration. The coarse guard admits Leitungsteam and -/// Gemeinde Verantwortliche (both Authentik-backed); TeamerService then -/// checks the caller is actually responsible for `:gemeindeId`. +/// 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'), RolesGuard) +@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) @Roles(Role.LEITUNGSTEAM, Role.GEMEINDE_VERANTWORTLICHER) export class TeamerController { constructor(private readonly teamer: TeamerService) {} diff --git a/client/app/lib/api.dart b/client/app/lib/api.dart index 0d04bcf..c9902f8 100644 --- a/client/app/lib/api.dart +++ b/client/app/lib/api.dart @@ -4,6 +4,8 @@ import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; +import 'oidc.dart'; + /// Backend base URL. Override at build/run time with /// `--dart-define=API_BASE=https://...`. const String kApiBase = String.fromEnvironment( @@ -91,6 +93,55 @@ class Membership { ); } +class Kc { + Kc({required this.id, required this.name, required this.inviteCode, required this.isActive}); + final String id; + final String name; + final String inviteCode; + final bool isActive; + factory Kc.fromJson(Map j) => Kc( + id: j['id'] as String, + name: j['name'] as String, + inviteCode: j['inviteCode'] as String? ?? '', + isActive: j['isActive'] as bool? ?? true, + ); +} + +class Gemeinde { + Gemeinde({required this.id, required this.name, required this.kcId}); + final String id; + final String name; + final String kcId; + factory Gemeinde.fromJson(Map j) => Gemeinde( + id: j['id'] as String, + name: j['name'] as String, + kcId: j['kcId'] as String? ?? '', + ); +} + +class OnboardingRequest { + OnboardingRequest({ + required this.id, + required this.userName, + required this.userEmail, + required this.gemeindeName, + }); + final String id; + final String userName; + final String userEmail; + final String gemeindeName; + factory OnboardingRequest.fromJson(Map j) { + final u = j['user'] as Map? ?? const {}; + final g = j['gemeinde'] as Map? ?? const {}; + return OnboardingRequest( + id: j['id'] as String, + userName: [u['firstName'], u['lastName']].whereType().join(' ').trim(), + userEmail: u['email'] as String? ?? '', + gemeindeName: g['name'] as String? ?? '', + ); + } +} + class Workshop { Workshop({required this.id, required this.name, required this.kapazitaet}); final String id; @@ -326,6 +377,31 @@ class Api { ); } + // --- LT admin --- + Future> kcs() async { + final list = await _get('/kc') as List; + return list.map((e) => Kc.fromJson(e as Map)).toList(); + } + + Future createKc(String name) async => + Kc.fromJson(await _post('/kc', {'name': name}) as Map); + + Future> gemeinden(String kcId) async { + final list = await _get('/gemeinde?kcId=$kcId') as List; + return list.map((e) => Gemeinde.fromJson(e as Map)).toList(); + } + + Future createGemeinde(String kcId, String name) async => Gemeinde.fromJson( + await _post('/gemeinde', {'kcId': kcId, 'name': name}) as Map); + + Future> onboardingRequests(String kcId) async { + final list = await _get('/onboarding/requests?kcId=$kcId') as List; + return list.map((e) => OnboardingRequest.fromJson(e as Map)).toList(); + } + + Future approveOnboarding(String id) => _post('/onboarding/requests/$id/approve', null); + Future rejectOnboarding(String id) => _post('/onboarding/requests/$id/reject', null); + // --- chat: REST for channels/history; live send/receive is the /chat WS --- Future> channels(String kcId) async { final list = await _get('/chat/$kcId/channels') as List; @@ -341,43 +417,83 @@ class Api { /// App-wide session + auth actions. Persists the token in shared_preferences /// (localStorage on web). class AppState extends ChangeNotifier { - AppState(this._api); + AppState(this._api, {OidcClient? oidc}) : _oidc = oidc ?? OidcClient(http.Client()); final Api _api; + final OidcClient _oidc; static const _tokenKey = 'kc_token'; + static const _refreshKey = 'kc_refresh'; Identity? _identity; Identity? get identity => _identity; bool _loading = true; bool get loading => _loading; bool get isLoggedIn => _identity != null; + String? _authError; + String? get authError => _authError; Api get api => _api; Future bootstrap() async { final prefs = await SharedPreferences.getInstance(); + + // 1. Are we landing on the OIDC redirect (?code=…)? + try { + final tokens = await _oidc.completeIfCallback(); + if (tokens != null) { + await _establish(tokens.accessToken, refreshToken: tokens.refreshToken); + _loading = false; + notifyListeners(); + return; + } + } catch (e) { + _authError = '$e'; + } + + // 2. Restore a stored session, refreshing an expired Authentik token. final saved = prefs.getString(_tokenKey); if (saved != null) { _api.token = saved; try { _identity = await _api.me(); } catch (_) { - _api.token = null; - await prefs.remove(_tokenKey); + final refresh = prefs.getString(_refreshKey); + if (refresh != null) { + try { + final t = await _oidc.refresh(refresh); + await _establish(t.accessToken, refreshToken: t.refreshToken ?? refresh); + } catch (_) { + await _clear(prefs); + } + } else { + await _clear(prefs); + } } } _loading = false; notifyListeners(); } - Future _establish(String token) async { + Future beginOidcLogin() => _oidc.beginLogin(); + + Future _establish(String token, {String? refreshToken}) async { _api.token = token; _identity = await _api.me(); final prefs = await SharedPreferences.getInstance(); await prefs.setString(_tokenKey, token); + if (refreshToken != null) { + await prefs.setString(_refreshKey, refreshToken); + } + _authError = null; notifyListeners(); } + Future _clear(SharedPreferences prefs) async { + _api.token = null; + await prefs.remove(_tokenKey); + await prefs.remove(_refreshKey); + } + Future guestLogin(String code, String first, String last) => _api.guestLogin(code, first, last).then(_establish); @@ -402,10 +518,9 @@ class AppState extends ChangeNotifier { .then(_establish); Future logout() async { - _api.token = null; _identity = null; - final prefs = await SharedPreferences.getInstance(); - await prefs.remove(_tokenKey); + _authError = null; + await _clear(await SharedPreferences.getInstance()); notifyListeners(); } } diff --git a/client/app/lib/browser.dart b/client/app/lib/browser.dart new file mode 100644 index 0000000..d243a95 --- /dev/null +++ b/client/app/lib/browser.dart @@ -0,0 +1,3 @@ +// Picks the real browser implementation on web, a throwing stub elsewhere +// (so `flutter test` on the Dart VM still compiles). +export 'browser_stub.dart' if (dart.library.js_interop) 'browser_web.dart'; diff --git a/client/app/lib/browser_stub.dart b/client/app/lib/browser_stub.dart new file mode 100644 index 0000000..3e02c0f --- /dev/null +++ b/client/app/lib/browser_stub.dart @@ -0,0 +1,9 @@ +// Non-web fallback: the OIDC redirect flow only runs in a browser. +const _msg = 'Browser-only: OIDC login is not available on this platform.'; + +void setSession(String key, String value) => throw UnsupportedError(_msg); +String? getSession(String key) => throw UnsupportedError(_msg); +void removeSession(String key) => throw UnsupportedError(_msg); +Never redirect(String url) => throw UnsupportedError(_msg); +Map currentQueryParameters() => const {}; +void clearQuery() {} diff --git a/client/app/lib/browser_web.dart b/client/app/lib/browser_web.dart new file mode 100644 index 0000000..d68ac12 --- /dev/null +++ b/client/app/lib/browser_web.dart @@ -0,0 +1,22 @@ +import 'package:web/web.dart' as web; + +/// 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. + +void setSession(String key, String value) => + web.window.sessionStorage.setItem(key, value); + +String? getSession(String key) => web.window.sessionStorage.getItem(key); + +void removeSession(String key) => web.window.sessionStorage.removeItem(key); + +void redirect(String url) => web.window.location.assign(url); + +Map currentQueryParameters() => + Uri.parse(web.window.location.href).queryParameters; + +/// Drop the OIDC callback path + `?code=…&state=…` from the address bar +/// without reloading (back to the app root). +void clearQuery() { + web.window.history.replaceState(null, '', '/'); +} diff --git a/client/app/lib/oidc.dart b/client/app/lib/oidc.dart new file mode 100644 index 0000000..d183821 --- /dev/null +++ b/client/app/lib/oidc.dart @@ -0,0 +1,151 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:crypto/crypto.dart'; +import 'package:http/http.dart' as http; + +import 'browser.dart' as browser; + +/// Authentik OIDC config. Overridable with --dart-define; defaults are the +/// konfi-castle production values (a *public* client — PKCE, no secret). +const kOidcIssuer = String.fromEnvironment( + 'OIDC_ISSUER', + defaultValue: 'https://sso.konfi-castle.com/application/o/konfi-castle-app/', +); +const kOidcClientId = String.fromEnvironment( + 'OIDC_CLIENT_ID', + defaultValue: 'K7f9mn6bP6jSjZDMYuiZCXMeVmVcqFcYNj0blJk9', +); +const kOidcRedirectUri = String.fromEnvironment( + 'OIDC_REDIRECT_URI', + defaultValue: 'http://localhost:3000/v1/auth/callback', +); + +const _scope = 'openid profile email groups offline_access'; +const _verifierKey = 'oidc_verifier'; +const _stateKey = 'oidc_state'; + +class OidcTokens { + OidcTokens({required this.accessToken, this.refreshToken, this.expiresIn}); + final String accessToken; + final String? refreshToken; + final int? expiresIn; + + factory OidcTokens.fromJson(Map j) => OidcTokens( + accessToken: j['access_token'] as String, + refreshToken: j['refresh_token'] as String?, + expiresIn: (j['expires_in'] as num?)?.toInt(), + ); +} + +class OidcException implements Exception { + OidcException(this.message); + final String message; + @override + String toString() => 'OidcException: $message'; +} + +/// Authorization-Code + PKCE against Authentik, for the browser. The backend +/// only validates the resulting access token (resource-server pattern). +class OidcClient { + OidcClient(this._http); + final http.Client _http; + Map? _discovery; + + Future> _disc() async { + if (_discovery != null) return _discovery!; + final base = kOidcIssuer.endsWith('/') ? kOidcIssuer : '$kOidcIssuer/'; + final res = await _http.get(Uri.parse('$base.well-known/openid-configuration')); + if (res.statusCode != 200) { + throw OidcException('Discovery failed (${res.statusCode})'); + } + return _discovery = jsonDecode(res.body) as Map; + } + + /// Kicks off the redirect to Authentik. Does not return (page navigates). + Future beginLogin() async { + final d = await _disc(); + final verifier = _randomUrlToken(64); + final state = _randomUrlToken(24); + final challenge = base64UrlEncode(sha256.convert(ascii.encode(verifier)).bytes) + .replaceAll('=', ''); + browser.setSession(_verifierKey, verifier); + browser.setSession(_stateKey, state); + + final authUri = Uri.parse(d['authorization_endpoint'] as String).replace( + queryParameters: { + 'response_type': 'code', + 'client_id': kOidcClientId, + 'redirect_uri': kOidcRedirectUri, + 'scope': _scope, + 'state': state, + 'code_challenge': challenge, + 'code_challenge_method': 'S256', + }, + ); + browser.redirect(authUri.toString()); + } + + /// If the current URL carries `?code=…`, exchanges it for tokens and scrubs + /// the query. Returns null when this isn't a callback load. + Future completeIfCallback() async { + final params = browser.currentQueryParameters(); + final code = params['code']; + if (code == null || code.isEmpty) { + if (params['error'] != null) { + browser.clearQuery(); + throw OidcException('Authentik: ${params['error_description'] ?? params['error']}'); + } + return null; + } + final expectedState = browser.getSession(_stateKey); + final verifier = browser.getSession(_verifierKey); + browser.removeSession(_stateKey); + browser.removeSession(_verifierKey); + browser.clearQuery(); + + if (verifier == null || params['state'] != expectedState) { + throw OidcException('State mismatch — please retry the login.'); + } + final d = await _disc(); + final res = await _http.post( + Uri.parse(d['token_endpoint'] as String), + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: { + 'grant_type': 'authorization_code', + 'code': code, + 'redirect_uri': kOidcRedirectUri, + 'client_id': kOidcClientId, + 'code_verifier': verifier, + }, + ); + if (res.statusCode != 200) { + throw OidcException('Token exchange failed (${res.statusCode}): ${res.body}'); + } + return OidcTokens.fromJson(jsonDecode(res.body) as Map); + } + + Future refresh(String refreshToken) async { + final d = await _disc(); + final res = await _http.post( + Uri.parse(d['token_endpoint'] as String), + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: { + 'grant_type': 'refresh_token', + 'refresh_token': refreshToken, + 'client_id': kOidcClientId, + }, + ); + if (res.statusCode != 200) { + throw OidcException('Refresh failed (${res.statusCode})'); + } + return OidcTokens.fromJson(jsonDecode(res.body) as Map); + } + + static String _randomUrlToken(int length) { + const chars = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; + final rnd = Random.secure(); + return List.generate(length, (_) => chars[rnd.nextInt(chars.length)]).join(); + } +} diff --git a/client/app/lib/screens/admin_screen.dart b/client/app/lib/screens/admin_screen.dart new file mode 100644 index 0000000..82f5e16 --- /dev/null +++ b/client/app/lib/screens/admin_screen.dart @@ -0,0 +1,291 @@ +import 'package:flutter/material.dart'; + +import '../api.dart'; +import '../main.dart'; + +/// Leitungsteam admin: KCs, their Gemeinden, and pending self-registrations. +class AdminScreen extends StatefulWidget { + const AdminScreen({super.key}); + + @override + State createState() => _AdminScreenState(); +} + +class _AdminScreenState extends State { + Future>? _future; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _future ??= AppScope.of(context).api.kcs(); + } + + void _reload() => setState(() => _future = AppScope.of(context).api.kcs()); + + Future _createKc() async { + final api = AppScope.of(context).api; + final name = await _promptText(context, 'Neues KC', 'Name'); + if (name == null || name.isEmpty || !mounted) return; + try { + await api.createKc(name); + if (mounted) _reload(); + } catch (e) { + if (mounted) _toast(context, '$e'); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Verwaltung')), + floatingActionButton: FloatingActionButton.extended( + onPressed: _createKc, + icon: const Icon(Icons.add), + label: const Text('KC'), + ), + body: FutureBuilder>( + 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), + ), + ); + } + final kcs = snap.data!; + if (kcs.isEmpty) { + return const Center(child: Text('Noch keine KCs. Unten anlegen.')); + } + return ListView( + children: [ + for (final kc in kcs) + ListTile( + leading: const Icon(Icons.festival), + title: Text(kc.name), + subtitle: Text('Code ${kc.inviteCode}'), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => KcDetailScreen(kc: kc)), + ), + ), + ], + ); + }, + ), + ); + } +} + +class KcDetailScreen extends StatefulWidget { + const KcDetailScreen({super.key, required this.kc}); + final Kc kc; + + @override + State createState() => _KcDetailScreenState(); +} + +class _KcDetailScreenState extends State { + Future>? _gemeinden; + Future>? _requests; + + Api get _api => AppScope.of(context).api; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _gemeinden ??= _api.gemeinden(widget.kc.id); + _requests ??= _api.onboardingRequests(widget.kc.id); + } + + void _reloadGemeinden() => + setState(() => _gemeinden = _api.gemeinden(widget.kc.id)); + void _reloadRequests() => + setState(() => _requests = _api.onboardingRequests(widget.kc.id)); + + Future _addGemeinde() async { + final api = _api; + final name = await _promptText(context, 'Neue Gemeinde', 'Name'); + if (name == null || name.isEmpty || !mounted) return; + try { + await api.createGemeinde(widget.kc.id, name); + if (mounted) _reloadGemeinden(); + } catch (e) { + if (mounted) _toast(context, '$e'); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(widget.kc.name)), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + child: ListTile( + title: const Text('Einladungscode'), + subtitle: Text(widget.kc.inviteCode), + trailing: const Icon(Icons.qr_code_2), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: Text('Gemeinden', + style: Theme.of(context).textTheme.titleMedium), + ), + TextButton.icon( + onPressed: _addGemeinde, + icon: const Icon(Icons.add), + label: const Text('Hinzufügen'), + ), + ], + ), + _GemeindeList(future: _gemeinden!, onRetry: _reloadGemeinden), + const Divider(height: 40), + Text('Offene Verantwortlichen-Anfragen', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + _RequestList( + future: _requests!, + onAction: (id, approve) async { + try { + approve + ? await _api.approveOnboarding(id) + : await _api.rejectOnboarding(id); + _reloadRequests(); + } catch (e) { + if (context.mounted) _toast(context, '$e'); + } + }, + ), + ], + ), + ); + } +} + +class _GemeindeList extends StatelessWidget { + const _GemeindeList({required this.future, required this.onRetry}); + final Future> future; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: future, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const Padding( + padding: EdgeInsets.all(12), + child: Center(child: CircularProgressIndicator()), + ); + } + if (snap.hasError) { + return TextButton(onPressed: onRetry, child: Text('Fehler: ${snap.error}')); + } + final gemeinden = snap.data!; + if (gemeinden.isEmpty) return const Text('Noch keine Gemeinden.'); + return Column( + children: [ + for (final g in gemeinden) + ListTile( + dense: true, + leading: const Icon(Icons.church), + title: Text(g.name), + ), + ], + ); + }, + ); + } +} + +class _RequestList extends StatelessWidget { + const _RequestList({required this.future, required this.onAction}); + final Future> future; + final Future Function(String id, bool approve) onAction; + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: future, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const Padding( + padding: EdgeInsets.all(12), + child: Center(child: CircularProgressIndicator()), + ); + } + if (snap.hasError) { + return Text('Fehler: ${snap.error}'); + } + final requests = snap.data!; + if (requests.isEmpty) return const Text('Keine offenen Anfragen.'); + return Column( + children: [ + for (final r in requests) + Card( + child: ListTile( + title: Text(r.userName.isEmpty ? r.userEmail : r.userName), + subtitle: Text('${r.userEmail}\nGemeinde: ${r.gemeindeName}'), + isThreeLine: true, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'Genehmigen', + icon: const Icon(Icons.check, color: Colors.green), + onPressed: () => onAction(r.id, true), + ), + IconButton( + tooltip: 'Ablehnen', + icon: const Icon(Icons.close, color: Colors.red), + onPressed: () => onAction(r.id, false), + ), + ], + ), + ), + ), + ], + ); + }, + ); + } +} + +Future _promptText(BuildContext context, String title, String label) { + final controller = TextEditingController(); + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: TextField( + controller: controller, + autofocus: true, + decoration: InputDecoration(labelText: label), + onSubmitted: (v) => Navigator.of(context).pop(v.trim()), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Abbrechen'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(controller.text.trim()), + child: const Text('OK'), + ), + ], + ), + ); +} + +void _toast(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); +} diff --git a/client/app/lib/screens/home_screen.dart b/client/app/lib/screens/home_screen.dart index acd9a8c..5244f72 100644 --- a/client/app/lib/screens/home_screen.dart +++ b/client/app/lib/screens/home_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../api.dart'; import '../main.dart'; +import 'admin_screen.dart'; import 'chat_screen.dart'; import 'files_screen.dart'; import 'wahl_screen.dart'; @@ -16,6 +17,13 @@ class HomeScreen extends StatelessWidget { final kcId = id.kcId; final tiles = [ + if (id.isLeitungsteam) + _NavTile( + icon: Icons.admin_panel_settings, + title: 'Verwaltung', + subtitle: 'KCs, Gemeinden, Onboarding-Freigaben', + onTap: () => _open(context, const AdminScreen()), + ), if (id.kind == SessionKind.guest) _NavTile( icon: Icons.how_to_vote, diff --git a/client/app/lib/screens/login_screen.dart b/client/app/lib/screens/login_screen.dart index ab94dd3..803c652 100644 --- a/client/app/lib/screens/login_screen.dart +++ b/client/app/lib/screens/login_screen.dart @@ -79,8 +79,10 @@ class _FormShellState extends State<_FormShell> { return ListView( shrinkWrap: true, children: [ - Text(widget.title, style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: 16), + 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) ...[ @@ -146,21 +148,54 @@ class _TeamFormState extends State<_TeamForm> { @override Widget build(BuildContext context) { final state = AppScope.of(context); - return _FormShell( - title: 'Teamer:in-Login', - fields: [ - _field(_email, 'E-Mail'), + return ListView( + shrinkWrap: true, + children: [ + Text('Leitungsteam / Verantwortliche', + style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 12), - _field(_password, 'Passwort', obscure: true), + if (state.authError != null) ...[ + 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), + label: const Text('Mit Konfi-Castle-ID anmelden'), + ), const SizedBox(height: 8), const Text( - 'Leitungsteam & Gemeinde-Verantwortliche melden sich über die ' - 'Konfi-Castle-ID (Authentik) an — dieser Client deckt bisher den ' - 'lokalen Teamer-Login ab.', + 'Öffnet die Konfi-Castle-ID (Authentik). Leitungsteam-Rechte kommen ' + 'aus deiner Authentik-Gruppe.', style: TextStyle(fontSize: 12), ), + const Divider(height: 40), + Text('Lokaler Teamer:in-Login', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + _TeamPasswordForm(email: _email, password: _password), ], - onSubmit: () => state.teamLogin(_email.text.trim(), _password.text), + ); + } +} + +class _TeamPasswordForm extends StatelessWidget { + const _TeamPasswordForm({required this.email, required this.password}); + final TextEditingController email; + final TextEditingController password; + + @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), + ], + onSubmit: () => state.teamLogin(email.text.trim(), password.text), ); } } diff --git a/client/app/pubspec.lock b/client/app/pubspec.lock index 3fe5047..bcddee5 100644 --- a/client/app/pubspec.lock +++ b/client/app/pubspec.lock @@ -42,7 +42,7 @@ packages: source: hosted version: "1.19.1" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf @@ -350,7 +350,7 @@ packages: source: hosted version: "15.3.0" web: - dependency: transitive + dependency: "direct main" description: name: web sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" diff --git a/client/app/pubspec.yaml b/client/app/pubspec.yaml index 01e5ac6..fc9abf1 100644 --- a/client/app/pubspec.yaml +++ b/client/app/pubspec.yaml @@ -12,6 +12,8 @@ dependencies: http: ^1.2.2 shared_preferences: ^2.3.2 web_socket_channel: ^3.0.1 + crypto: ^3.0.6 + web: ^1.1.0 dev_dependencies: flutter_test: From 40b623dd79dda14b2a2a00b21bff0f7b17fb6b52 Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 09:37:10 +0200 Subject: [PATCH 18/37] docs: OIDC PKCE login, LT admin screens, backend-served Flutter build Plan + both READMEs updated for the Authentik Authorization-Code + PKCE flow, the Leitungsteam admin screen, the widened LT-admin guards, the issuer trailing-slash normalisation, and the backend now serving the Flutter web build (SPA fallback for /v1/auth/callback). Verification section records the local-Postgres E2E for LT admin + onboarding approval, and notes the OIDC browser round-trip still needs a test account. Co-Authored-By: Claude Sonnet 5 --- README.md | 21 +++++++------ client/app/README.md | 42 ++++++++++++++++++++----- plan-kcAppMultiTenantPlatform.prompt.md | 12 ++++--- 3 files changed, 53 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 53022ea..cdae247 100644 --- a/README.md +++ b/README.md @@ -61,14 +61,17 @@ 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) started: `client/app/` is a single Flutter -codebase with the **web** target enabled — login (guest / local Teamer / -invite redemption), role-aware home, guest Workshop-Wahl, file list, -read-only chat. `flutter build web` and `flutter test` pass. Mobile/desktop -targets, the Authentik Authorization-Code flow, WebSocket chat send, and LT -admin screens are still to come. +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 +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. -The backend also serves the interim `client/web/` placeholder at `/` (API -under `/api`). Running end to end still needs a real Authentik instance and -Nextcloud/S3 credentials (see `backend/.env.example`). +Running end to end needs the Authentik redirect registered + a test account, +plus Nextcloud/S3 credentials (see `backend/.env.example`). diff --git a/client/app/README.md b/client/app/README.md index 2f486b9..137b257 100644 --- a/client/app/README.md +++ b/client/app/README.md @@ -7,25 +7,46 @@ added later with `flutter create --platforms=...` in this directory — the ## Run +The Authentik redirect URI is `http://localhost:3000/v1/auth/callback`, so +the app must be reached on `:3000` — i.e. served by the backend, not `flutter +run`'s own dev server. Build it and let NestJS serve it: + ```bash flutter pub get -flutter run -d chrome --dart-define=API_BASE=http://localhost:3000/api +flutter build web # backend serves client/app/build/web at / +# then run the backend (npm run start:dev in ../../backend) and open :3000 ``` -`API_BASE` defaults to `http://localhost:3000/api` (the local NestJS -backend, which also serves the interim plain-HTML client at `/`). +For pure UI work without the OIDC flow, `flutter run -d chrome +--dart-define=API_BASE=http://localhost:3000/api` still works (guest / local +Teamer login only). + +### Dart-defines + +| define | default | +|---|---| +| `API_BASE` | `http://localhost:3000/api` | +| `OIDC_ISSUER` | `https://sso.konfi-castle.com/application/o/konfi-castle-app/` | +| `OIDC_CLIENT_ID` | the konfi-castle public client id | +| `OIDC_REDIRECT_URI` | `http://localhost:3000/v1/auth/callback` | ## What's implemented - **Login** (`lib/screens/login_screen.dart`) — three tabs: - *Konfi / Gast*: KC invite code + first/last name → `POST /auth/guest`. - - *Team-Login*: email + password for local Gemeinde Teamer → - `POST /auth/team-login`. (Leitungsteam / Verantwortliche use the - Authentik Authorization-Code flow, not yet wired into this client.) + - *Leitungsteam / Verantwortliche*: "Mit Konfi-Castle-ID anmelden" starts + the Authentik **Authorization Code + PKCE** flow (`lib/oidc.dart`); + below it, the local Gemeinde-Teamer password form + (`POST /auth/team-login`). - *Einladung*: redeem a Teamer invite token → `POST /auth/teamer/register`. -- The token is stored via `shared_preferences` (localStorage on web) and - restored on start; `GET /auth/me` resolves the role for a role-aware home. +- OIDC: discovery + S256 challenge, `?code=` handled on bootstrap, access + + refresh token persisted (`shared_preferences` / localStorage), expired + access token refreshed on restart. `GET /auth/me` resolves the role. - **Home** (`lib/screens/home_screen.dart`) — identity card + navigation. +- **Verwaltung** (`lib/screens/admin_screen.dart`, Leitungsteam only) — + list/create KCs; per KC the Gemeinden (list/create) and pending + Verantwortlichen self-registrations (`GET /onboarding/requests`, + approve / reject). - **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` — @@ -40,6 +61,11 @@ backend, which also serves the interim plain-HTML client at `/`). - `lib/api.dart` — `Api` (thin REST wrapper + models) and `AppState` (`ChangeNotifier`: session, login/logout, token persistence). +- `lib/oidc.dart` — Authentik PKCE flow. Browser-only bits (sessionStorage, + redirect, `window.location`) sit behind a conditional import + (`browser.dart` → `browser_web.dart` / `browser_stub.dart`) so + `flutter test` compiles on the Dart VM. +- `lib/chat_socket.dart` — `/chat` WebSocket wrapper. - `lib/main.dart` — `AppScope` (an `InheritedNotifier`) exposes `AppScope.of(context)`; `_AuthGate` switches Login/Home. No third-party state-management package. diff --git a/plan-kcAppMultiTenantPlatform.prompt.md b/plan-kcAppMultiTenantPlatform.prompt.md index eb0c2b9..6421fe1 100644 --- a/plan-kcAppMultiTenantPlatform.prompt.md +++ b/plan-kcAppMultiTenantPlatform.prompt.md @@ -38,8 +38,8 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris |---|---|---| | Backend | NestJS 10 + PostgreSQL + Prisma 5 | bestätigt vom Nutzer; Nest 10 statt CLI-Default (siehe unten) | | Client | Flutter, eine Codebase Mobile/Web/Desktop — `client/app/`, aktuell nur Web-Target aktiviert | vom Nutzer delegiert; Flutter-SDK lokal via Homebrew installiert (nur Web-Toolchain, Android/iOS wegen Speicher weggelassen); `lib/` ist plattformneutral, weitere Targets per `flutter create --platforms=…` nachrüstbar | -| Web-Interimslösung | Backend liefert `client/web/` (reines HTML/CSS/JS, kein Build-Schritt) über `ServeStaticModule` aus; REST-API liegt unter `/api/*` | Nutzerwunsch: "Server soll auch Web-Client bereitstellen"; vermeidet Kollision zwischen API-Routen und statischen Dateien | -| Auth (Team) | Authentik als OIDC Resource Server (JWKS-Verifikation), kein lokaler Autorisierungscode-Flow im Backend | Clients machen Authorization Code + PKCE direkt gegen Authentik; Backend validiert nur Access Token + löst lokale `Membership` auf | +| Web-Auslieferung | `ServeStaticModule` liefert primär den **Flutter-Web-Build** (`client/app/build/web`) auf `:3000` aus, mit SPA-Fallback (u. a. für den OIDC-Redirect `/v1/auth/callback`); fällt auf `client/web/` zurück, falls der Build fehlt. REST-API unter `/api/*`. | Ein Origin für App + API; der registrierte Authentik-Redirect zeigt auf `http://localhost:3000/v1/auth/callback` | +| Auth (Team) | Authentik als OIDC Resource Server (JWKS-Verifikation), kein lokaler Autorisierungscode-Flow im Backend. Client macht **Authorization Code + PKCE (S256)** direkt gegen Authentik (`sso.konfi-castle.com`, Public Client, `oidc.dart`), Backend validiert nur das Access-Token. Issuer-Trailing-Slash wird normalisiert (beide `iss`-Schreibweisen akzeptiert). | Public Client kann kein Secret halten; PKCE genügt | | Auth (Guest) | Rein lokale JWTs (`GUEST_JWT_SECRET`), kein Authentik | explizite Nutzervorgabe: Konfi-Accounts sind nie in Authentik | | Auth (Gemeinde Teamer) | Lokale Accounts: `User` mit `passwordHash`+`kcId`, `authentikSub` bleibt leer; eigenes JWT (`TEAM_JWT_SECRET`, Payload `typ:'team'`), Passwort-Login oder Invite-Redemption. Verantwortliche legen Teamer an (Direkt/Gruppen-Link/E-Mail-Invite) | Nutzervorgabe: Teamer laufen nicht über die Konfi-Castle-ID (Authentik), sondern werden pro KC lokal verwaltet (wie Guests, nur dauerhaft + mit Rolle) | | Datei-Storage | Provider-Abstraktion (`StorageProvider`), Default **Nextcloud/WebDAV**, umschaltbar auf S3 via `STORAGE_PROVIDER=s3` | Nutzer bestätigte: Nextcloud-Zugangsdaten kommen aus `.env` | @@ -53,7 +53,7 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris - **Datei-Bytes werden nicht repliziert** – nur Metadaten (inkl. `storageKey`) laufen durchs Sync-Log. Lokaler und Cloud-Server müssen denselben Nextcloud/S3-Backend-Zugriff haben, damit ein `storageKey` auf beiden Seiten auflösbar ist. - **Push-Benachrichtigungen (FCM/APNs)** sind im Plan vorgesehen, aber noch nicht implementiert (Teil der noch ausstehenden Flutter-Client-Arbeit). - **Gemeinde-Verwaltung (CRUD)**: über `GemeindeController` (LT-only) verfügbar — Anlegen/Auflisten/Lesen/Umbenennen/Löschen von Gemeinden pro KC. Gemeinde Verantwortliche/Teamer erfahren ihre eigene Gemeinde weiterhin aus der `Membership`, nicht über diesen Endpunkt. -- **Authentik-Provisionierung**: Jeder gültige Authentik-Login legt den lokalen `User` automatisch an (`AuthentikStrategy` → `resolveOrProvisionAuthentikUser`, JIT, race-sicher) **und** setzt `isLeitungsteam` aus dem `groups`-Claim. Voraussetzung: der Authentik-Provider muss den `groups`-Claim ins Access-Token schreiben (Scope „groups" hinzufügen) und der LT-Gruppenname muss zu `AUTHENTIK_LEITUNGSTEAM_GROUP` passen — sonst wird niemand als LT erkannt. Gemeinde Verantwortliche brauchen weiterhin die `onboarding/`-Freigabe durch LT. (Gemeinde Teamer sind lokale Accounts, siehe `teamer/` + `auth/team-login`.) +- **Authentik-Provisionierung**: Jeder gültige Authentik-Login legt den lokalen `User` automatisch an (`AuthentikStrategy` → `resolveOrProvisionAuthentikUser`, JIT, race-sicher) **und** setzt `isLeitungsteam` aus dem `groups`-Claim. Voraussetzung: der Authentik-Provider muss den `groups`-Claim ins Access-Token schreiben (Scope „groups" hinzufügen) und der LT-Gruppenname muss zu `AUTHENTIK_LEITUNGSTEAM_GROUP` passen — sonst wird niemand als LT erkannt. Gemeinde Verantwortliche brauchen weiterhin die `onboarding/`-Freigabe durch LT. (Gemeinde Teamer sind lokale Accounts, siehe `teamer/` + `auth/team-login`.) Der Client-seitige PKCE-Flow (`oidc.dart`) ist gebaut, aber der volle Browser-Roundtrip ist noch nicht live getestet — dafür muss der genutzte Redirect (`http://localhost:3000/v1/auth/callback` bzw. die Prod-URL) am Authentik-Provider hinterlegt sein und ein Testaccount existieren. - **Teamer-Identität ist E-Mail-basiert und global eindeutig**: `User.email` ist instanzweit unique, d. h. dieselbe E-Mail kann nicht gleichzeitig Teamer in zwei KCs sein. Für den „pro KC wie Guests"-Fall in der Praxis unkritisch, aber dokumentiert. - **E-Mail-Versand**: `mail/`-Modul mit `MailProvider`-Abstraktion. Persönliche `teamer-invites` (mit `email`) werden verschickt; Default-Provider ist **log-only** (schreibt nur ins Log), echter Versand erst mit `MAIL_PROVIDER=smtp` + `SMTP_*`/`MAIL_FROM`. Onboarding-Benachrichtigungen an LT gibt es noch nicht. @@ -64,7 +64,7 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris | Modul | Kernfunktion | Wichtige Endpunkte | |---|---|---| | `prisma/` | Geteilter `PrismaClient`-Provider | – | -| `auth/` | Authentik-Resource-Server-Strategie (`AuthGuard('authentik')`) mit **JIT-`User`-Anlage** beim ersten Login + **LT-Abgleich** aus dem `groups`-Claim → `User.isLeitungsteam` → virtuelle globale LT-`Membership` (`resolveOrProvisionAuthentikUser` / `toAuthenticatedUser`, race-sicher), Guest-Login (`AuthGuard('guest')`), **lokaler Teamer-Login** (`AuthGuard('team')`, `TEAM_JWT_SECRET`, bcrypt) inkl. Invite-Redemption, `TokenVerificationService` für manuelle Verifikation außerhalb des HTTP/Passport-Pfads (WS-Handshake, akzeptiert Authentik/Team/Guest). Alle Strategien laden nur `ACTIVE`-Memberships. | `POST /api/auth/guest`, `POST /api/auth/team-login`, `POST /api/auth/teamer/register` | +| `auth/` | Authentik-Resource-Server-Strategie (`AuthGuard('authentik')`) mit **JIT-`User`-Anlage** beim ersten Login + **LT-Abgleich** aus dem `groups`-Claim → `User.isLeitungsteam` → virtuelle globale LT-`Membership` (`resolveOrProvisionAuthentikUser` / `toAuthenticatedUser`, race-sicher), Guest-Login (`AuthGuard('guest')`), **lokaler Teamer-Login** (`AuthGuard('team')`, `TEAM_JWT_SECRET`, bcrypt) inkl. Invite-Redemption, `TokenVerificationService` für manuelle Verifikation außerhalb des HTTP/Passport-Pfads (WS-Handshake, akzeptiert Authentik/Team/Guest). Alle Strategien laden nur `ACTIVE`-Memberships; auch der Team-Token-Pfad geht durch `toAuthenticatedUser` (synthetische LT-`Membership` bei `isLeitungsteam`). LT-Admin-Controller (`kc`/`gemeinde`/`onboarding`/`sync`/`teamer`) akzeptieren `['authentik','team']`. | `POST /api/auth/guest`, `POST /api/auth/team-login`, `POST /api/auth/teamer/register`, `GET /api/auth/me` | | `kc/` | KC-Verwaltung, Leitungsteam-only | `POST /api/kc`, `GET /api/kc` | | `gemeinde/` | Gemeinde-CRUD pro KC, Leitungsteam-only | `POST /api/gemeinde`, `GET /api/gemeinde?kcId=`, `GET/PATCH/DELETE /api/gemeinde/:id` | | `onboarding/` | Selbstregistrierung Gemeinde Verantwortliche/r: öffentlicher Invite-Lookup, JIT-`User`-Anlage aus Authentik-Claims, `Membership` im Status `PENDING`; LT sieht/genehmigt/lehnt ab | `GET /api/onboarding/kc/:inviteCode`, `POST /api/onboarding/verantwortliche` (Authentik-Bearer), `GET /api/onboarding/requests?kcId=` (LT), `POST /api/onboarding/requests/:id/approve\|reject` (LT) | @@ -116,6 +116,8 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM 3b. Flutter-Client (`client/app/`): `flutter analyze` sauber, `flutter build web --release` erfolgreich, `flutter test` grün (Login-Screen-Smoke-Test); CORS-Preflight vom Browser-Origin ok. 3c. Guest-Ergebnis: `/wahl/guest/results` gegen echtes Postgres in beiden Zuständen geprüft (PENDING nach Einreichung, ASSIGNED nach manuell gesetzter `Zuteilung` → Workshop-Name + Wunschrang). 3d. WS-Chat: Zwei-Client-E2E gegen echtes Postgres (zwei lokale Teamer im selben `GEMEINDE_GRUPPE`-Kanal, `chat:send` → der andere empfängt `chat:message`). Dabei behoben: `ChatGateway` speicherte den Caller erst nach dem asynchronen Token-Check, wodurch ein sofortiges `chat:join` mit 4001 abgewiesen wurde — jetzt wartet der Handler auf das Caller-Promise. +3e. LT-Admin gegen echtes Postgres mit einem `isLeitungsteam`-Account (Team-Token): `POST/GET /api/kc`, `POST /api/gemeinde`, `GET /api/onboarding/requests`, sowie eine PENDING-Verantwortlichen-Anfrage → `approve` → Status `ACTIVE`, Liste danach leer. +3f. Authentik-OIDC-Discovery von `sso.konfi-castle.com` abgerufen (PKCE `S256`, Scopes inkl. `groups`, `authorization_code`+`refresh_token`). **Noch offen:** vollständiger Browser-Roundtrip (Redirect → Login → Code-Tausch) — braucht den registrierten Redirect + einen LT-/Verantwortlichen-Testaccount. 4. Jest-Unit-Tests (Prisma/Sync/Mail gemockt, `npm test` grün, 56 Tests): - `src/wahl/zuteilung.service.spec.ts`: Force-Vorrang, Wunschrunden-Fallback bei voller Kapazität, Unzugeteilt-Fall, Konsolidierung unterbesetzter Workshops, Sync-Capture-Anzahl. - `src/auth/team-auth.service.spec.ts`: Invite-Redemption (unbekannt/widerrufen/abgelaufen/aufgebraucht, Gruppen-Link ohne E-Mail, E-Mail-Mismatch, Dublette) + Passwort-Login. @@ -128,7 +130,7 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM ## 8. Nächste Schritte -1. Flutter-Client ausbauen: **Authentik-Authorization-Code-Flow** (LT/Verantwortliche — braucht eine erreichbare Authentik-Instanz), LT-/Verantwortlichen-Admin-Screens (KC/Gemeinde/Teamer anlegen, Onboarding-Anfragen freigeben), Verantwortlichen-Selbstregistrierung, dann Mobile/Desktop-Targets aktivieren. +1. Flutter-Client: ✅ Authentik-PKCE-Login-Flow (`oidc.dart`) + LT-Admin-Screens (KC/Gemeinde anlegen, Onboarding-Anfragen freigeben). 🔜 Browser-Roundtrip einmal live testen (Redirect + Testaccount); Teamer-Verwaltungs-Screen (für Verantwortliche/LT: Teamer + Invites), Verantwortlichen-Selbstregistrierungs-Screen, Wahl-Verwaltung für LT, dann Mobile/Desktop-Targets. 2. Authentik-Provider so konfigurieren, dass das Access-Token den `groups`-Claim trägt (Scope „groups"), LT-Gruppe = `AUTHENTIK_LEITUNGSTEAM_GROUP`. (Ops/Config, Code ist fertig.) 3. SMTP konfigurieren (`MAIL_PROVIDER=smtp` + `SMTP_*`/`MAIL_FROM`/`APP_BASE_URL`) und Invite-Mail-Templates finalisieren (aktuell Plain-Text); optional Onboarding-Benachrichtigungen an LT. 4. Push-Benachrichtigungen (FCM/APNs) für Chat/Ankündigungen. From edff87de5f9814429fd02020e4d293ac085200ef Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 10:00:58 +0200 Subject: [PATCH 19/37] feat(backend): tolerate Authentik users without an email + verify against real SSO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authentik accounts don't always have an email set (the test account `hermes` doesn't). AuthentikStrategy / verifyAuthentikClaims no longer reject those — `authentikEmail()` falls back to a stable `@no-email.authentik` handle for the local User row, and first/last name fall back to preferred_username/name. Set AUTHENTIK_LEITUNGSTEAM_GROUP to the real group "KC-APP-LT". Verified end to end against the live https://sso.konfi-castle.com with a password-grant token for a KC-APP-LT member: backend accepts the RS256 token (JWKS + trailing-slash issuer), JIT-provisions the User, maps the `groups` claim to isLeitungsteam=true, and POST /api/kc returns 201. Only the in-browser redirect round-trip remains untested. Co-Authored-By: Claude Sonnet 5 --- backend/.env.example | 2 +- backend/src/auth/authentik.strategy.ts | 16 +++++++++++----- backend/src/auth/provision-user.ts | 12 ++++++++++++ backend/src/auth/token-verification.service.ts | 18 +++++++++++++----- 4 files changed, 37 insertions(+), 11 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index 808ad73..9fc0c44 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -7,7 +7,7 @@ AUTHENTIK_ISSUER_URL="https://sso.konfi-castle.com/application/o/konfi-castle-ap # 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="Leitungsteam" +AUTHENTIK_LEITUNGSTEAM_GROUP="KC-APP-LT" # Secret used to sign guest/Konfi session tokens (local accounts only) GUEST_JWT_SECRET="change-me" diff --git a/backend/src/auth/authentik.strategy.ts b/backend/src/auth/authentik.strategy.ts index 474e6e4..b064f7b 100644 --- a/backend/src/auth/authentik.strategy.ts +++ b/backend/src/auth/authentik.strategy.ts @@ -7,13 +7,19 @@ import { Request } from 'express'; import { PrismaClient } from '../prisma/prisma.module'; import { SyncService } from '../sync/sync.service'; import { AuthenticatedUser } from './authenticated-request'; -import { resolveOrProvisionAuthentikUser, toAuthenticatedUser } from './provision-user'; +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[]; } @@ -52,8 +58,8 @@ export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') { } async validate(payload: AuthentikJwtPayload): Promise { - if (!payload.email) { - throw new UnauthorizedException('Authentik token missing email claim'); + if (!payload.sub) { + throw new UnauthorizedException('Authentik token missing subject'); } const isLeitungsteam = (payload.groups ?? []).includes(this.leitungsteamGroup); const user = await resolveOrProvisionAuthentikUser( @@ -61,8 +67,8 @@ export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') { this.sync, { sub: payload.sub, - email: payload.email, - firstName: payload.given_name ?? '', + email: authentikEmail(payload), + firstName: payload.given_name ?? payload.preferred_username ?? payload.name ?? '', lastName: payload.family_name ?? '', }, isLeitungsteam, diff --git a/backend/src/auth/provision-user.ts b/backend/src/auth/provision-user.ts index be68c4e..e8d9610 100644 --- a/backend/src/auth/provision-user.ts +++ b/backend/src/auth/provision-user.ts @@ -10,6 +10,18 @@ export interface AuthentikClaims { 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 = '*'; diff --git a/backend/src/auth/token-verification.service.ts b/backend/src/auth/token-verification.service.ts index 3d48559..0a973cf 100644 --- a/backend/src/auth/token-verification.service.ts +++ b/backend/src/auth/token-verification.service.ts @@ -10,6 +10,7 @@ import { GuestJwtPayload } from './guest-auth.service'; import { TeamAuthService } from './team-auth.service'; import { AuthentikClaims, + authentikEmail, resolveOrProvisionAuthentikUser, toAuthenticatedUser, } from './provision-user'; @@ -55,15 +56,22 @@ export class TokenVerificationService { email?: string; given_name?: string; family_name?: string; + preferred_username?: string; + name?: string; groups?: string[]; }; - if (!payload.sub || !payload.email) { - throw new UnauthorizedException('Authentik token missing subject or email'); + const sub = payload.sub; + if (!sub) { + throw new UnauthorizedException('Authentik token missing subject'); } return { - sub: payload.sub, - email: payload.email, - firstName: payload.given_name ?? '', + 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), }; From d68ce42635129911bd89d495453e020191c36805 Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 10:01:12 +0200 Subject: [PATCH 20/37] docs: record real-Authentik E2E verification Co-Authored-By: Claude Sonnet 5 --- plan-kcAppMultiTenantPlatform.prompt.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plan-kcAppMultiTenantPlatform.prompt.md b/plan-kcAppMultiTenantPlatform.prompt.md index 6421fe1..b0fcf09 100644 --- a/plan-kcAppMultiTenantPlatform.prompt.md +++ b/plan-kcAppMultiTenantPlatform.prompt.md @@ -117,7 +117,7 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM 3c. Guest-Ergebnis: `/wahl/guest/results` gegen echtes Postgres in beiden Zuständen geprüft (PENDING nach Einreichung, ASSIGNED nach manuell gesetzter `Zuteilung` → Workshop-Name + Wunschrang). 3d. WS-Chat: Zwei-Client-E2E gegen echtes Postgres (zwei lokale Teamer im selben `GEMEINDE_GRUPPE`-Kanal, `chat:send` → der andere empfängt `chat:message`). Dabei behoben: `ChatGateway` speicherte den Caller erst nach dem asynchronen Token-Check, wodurch ein sofortiges `chat:join` mit 4001 abgewiesen wurde — jetzt wartet der Handler auf das Caller-Promise. 3e. LT-Admin gegen echtes Postgres mit einem `isLeitungsteam`-Account (Team-Token): `POST/GET /api/kc`, `POST /api/gemeinde`, `GET /api/onboarding/requests`, sowie eine PENDING-Verantwortlichen-Anfrage → `approve` → Status `ACTIVE`, Liste danach leer. -3f. Authentik-OIDC-Discovery von `sso.konfi-castle.com` abgerufen (PKCE `S256`, Scopes inkl. `groups`, `authorization_code`+`refresh_token`). **Noch offen:** vollständiger Browser-Roundtrip (Redirect → Login → Code-Tausch) — braucht den registrierten Redirect + einen LT-/Verantwortlichen-Testaccount. +3f. **Echte Authentik verifiziert**: mit einem Password-Grant-Token für ein `KC-APP-LT`-Mitglied (`hermes`) gegen `https://sso.konfi-castle.com` → `GET /api/auth/me` liefert `isLeitungsteam: true` (JWKS-Prüfung, Trailing-Slash-Issuer, JIT-`User`, `groups`→LT), `POST /api/kc` → 201. Placeholder-E-Mail-Fallback, da `hermes` keine E-Mail hat. `AUTHENTIK_LEITUNGSTEAM_GROUP="KC-APP-LT"`. **Noch offen:** nur der In-Browser-Redirect-Roundtrip (Authentik-Loginseite → Code-Tausch). 4. Jest-Unit-Tests (Prisma/Sync/Mail gemockt, `npm test` grün, 56 Tests): - `src/wahl/zuteilung.service.spec.ts`: Force-Vorrang, Wunschrunden-Fallback bei voller Kapazität, Unzugeteilt-Fall, Konsolidierung unterbesetzter Workshops, Sync-Capture-Anzahl. - `src/auth/team-auth.service.spec.ts`: Invite-Redemption (unbekannt/widerrufen/abgelaufen/aufgebraucht, Gruppen-Link ohne E-Mail, E-Mail-Mismatch, Dublette) + Passwort-Login. From 2e3e62b89689d7dcbeb667f7d921f124fc3ab12e Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 10:09:35 +0200 Subject: [PATCH 21/37] feat(client): LT Wahl admin, Teamer admin, Verantwortlichen self-registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New screens (client/app/lib/screens/): - wahl_admin_screen.dart — per KC: list/create Wahlen; per Wahl: add workshops, run the assignment (POST /wahl/:id/zuteilung/run), view the result table. - teamer_admin_screen.dart — per Gemeinde: list/create local Teamer accounts, create group-link or per-email invites (shows the token). - verantwortliche_register_screen.dart — enter a KC invite code (GET /onboarding/kc/:code), pick a Gemeinde, submit (POST /onboarding/verantwortliche); shown on the home screen to a logged-in Authentik user who has no membership yet. - ui.dart — shared toast / ErrorText / SectionHeader / promptText. KcDetailScreen now links to Wahl admin and each Gemeinde row opens Teamer admin. Backend: widen the wahl + files LT routes to AuthGuard(['authentik','team']) for consistency with the other LT controllers. Rebrand web/index.html + manifest from "kc_app" to "KC-App". Verified against local Postgres with an isLeitungsteam team token: create KC/Gemeinde/Wahl/Workshop, run Zuteilung, create Teamer + invite, resolve an invite code. flutter analyze/test/build web green; backend npm test 56. Co-Authored-By: Claude Sonnet 5 --- backend/src/files/files.controller.ts | 2 +- backend/src/wahl/wahl.controller.ts | 16 +- client/app/.gitignore | 3 + client/app/lib/api.dart | 195 +++++++++++ client/app/lib/screens/admin_screen.dart | 74 ++--- client/app/lib/screens/home_screen.dart | 12 + .../app/lib/screens/teamer_admin_screen.dart | 224 +++++++++++++ client/app/lib/screens/ui.dart | 77 +++++ .../verantwortliche_register_screen.dart | 137 ++++++++ client/app/lib/screens/wahl_admin_screen.dart | 308 ++++++++++++++++++ client/app/web/index.html | 6 +- client/app/web/manifest.json | 6 +- 12 files changed, 999 insertions(+), 61 deletions(-) create mode 100644 client/app/lib/screens/teamer_admin_screen.dart create mode 100644 client/app/lib/screens/ui.dart create mode 100644 client/app/lib/screens/verantwortliche_register_screen.dart create mode 100644 client/app/lib/screens/wahl_admin_screen.dart diff --git a/backend/src/files/files.controller.ts b/backend/src/files/files.controller.ts index b7bb5e3..4f91664 100644 --- a/backend/src/files/files.controller.ts +++ b/backend/src/files/files.controller.ts @@ -34,7 +34,7 @@ export class FilesController { /// Only the Leitungsteam uploads files (per KC), tagged with a visibility tier. @Post(':kcId') - @UseGuards(AuthGuard('authentik'), RolesGuard) + @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) @Roles(Role.LEITUNGSTEAM) @UseInterceptors(FileInterceptor('file')) upload( diff --git a/backend/src/wahl/wahl.controller.ts b/backend/src/wahl/wahl.controller.ts index 6498857..332b9a2 100644 --- a/backend/src/wahl/wahl.controller.ts +++ b/backend/src/wahl/wahl.controller.ts @@ -32,35 +32,35 @@ export class WahlController { /// Wahl-/Workshop-/Force-Zuteilung-Verwaltung ist ausschließlich Sache des /// Leitungsteams (global über alle KCs, siehe RolesGuard). @Post() - @UseGuards(AuthGuard('authentik'), RolesGuard) + @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'), RolesGuard) + @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) @Roles(Role.LEITUNGSTEAM) listWahlen(@Query('kcId') kcId: string) { return this.wahl.listWahlen(kcId); } @Post(':wahlId/workshops') - @UseGuards(AuthGuard('authentik'), RolesGuard) + @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'), RolesGuard) + @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'), RolesGuard) + @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) @Roles(Role.LEITUNGSTEAM) createForceZuteilung( @Param('wahlId') wahlId: string, @@ -99,21 +99,21 @@ export class WahlController { } @Post(':wahlId/zuteilung/run') - @UseGuards(AuthGuard('authentik'), RolesGuard) + @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) @Roles(Role.LEITUNGSTEAM) runZuteilung(@Param('wahlId') wahlId: string) { return this.zuteilung.run(wahlId); } @Get(':wahlId/zuteilung') - @UseGuards(AuthGuard('authentik'), RolesGuard) + @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'), RolesGuard) + @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) @Roles(Role.LEITUNGSTEAM) async exportCsv(@Param('wahlId') wahlId: string, @Res() res: Response) { const csv = await this.zuteilung.exportCsv(wahlId); diff --git a/client/app/.gitignore b/client/app/.gitignore index 79f7eca..1c09fde 100644 --- a/client/app/.gitignore +++ b/client/app/.gitignore @@ -46,3 +46,6 @@ app.*.map.json # Widget Preview related .widget_preview/ + +# DevTools options +devtools_options.yaml diff --git a/client/app/lib/api.dart b/client/app/lib/api.dart index c9902f8..63add4d 100644 --- a/client/app/lib/api.dart +++ b/client/app/lib/api.dart @@ -142,6 +142,108 @@ class OnboardingRequest { } } +class WahlAdmin { + WahlAdmin({ + required this.id, + required this.name, + required this.datumsSchluessel, + required this.teil, + required this.isOpen, + }); + final String id; + final String name; + final String datumsSchluessel; + final String teil; + final bool isOpen; + factory WahlAdmin.fromJson(Map j) => WahlAdmin( + id: j['id'] as String, + name: j['name'] as String, + datumsSchluessel: j['datumsSchluessel'] as String? ?? '', + teil: j['teil'] as String? ?? '', + isOpen: j['isOpen'] as bool? ?? true, + ); +} + +class WorkshopAdmin { + WorkshopAdmin({ + required this.id, + required this.name, + required this.kapazitaet, + required this.minTeilnehmer, + }); + final String id; + final String name; + final int kapazitaet; + final int minTeilnehmer; + factory WorkshopAdmin.fromJson(Map j) => WorkshopAdmin( + id: j['id'] as String, + name: j['name'] as String, + kapazitaet: (j['kapazitaet'] as num).toInt(), + minTeilnehmer: (j['minTeilnehmer'] as num?)?.toInt() ?? 0, + ); +} + +class ZuteilungRow { + ZuteilungRow({ + required this.name, + required this.workshopName, + required this.wunschRang, + required this.isForced, + }); + final String name; + final String? workshopName; + final int wunschRang; + final bool isForced; + factory ZuteilungRow.fromJson(Map j) { + final ga = (j['teilnehmer'] as Map?)?['guestAccount'] + as Map? ?? + const {}; + return ZuteilungRow( + name: [ga['firstName'], ga['lastName']].whereType().join(' ').trim(), + workshopName: (j['workshop'] as Map?)?['name'] as String?, + wunschRang: (j['wunschRang'] as num?)?.toInt() ?? -1, + isForced: j['isForced'] as bool? ?? false, + ); + } +} + +class TeamerAccount { + TeamerAccount({required this.id, required this.email, required this.name}); + final String id; + final String email; + final String name; + factory TeamerAccount.fromJson(Map j) => TeamerAccount( + id: j['id'] as String, + email: j['email'] as String? ?? '', + name: [j['firstName'], j['lastName']].whereType().join(' ').trim(), + ); +} + +class TeamerInvite { + TeamerInvite({ + required this.id, + required this.token, + required this.email, + required this.usedCount, + required this.maxUses, + required this.revoked, + }); + final String id; + final String token; + final String? email; + final int usedCount; + final int? maxUses; + final bool revoked; + factory TeamerInvite.fromJson(Map j) => TeamerInvite( + id: j['id'] as String, + token: j['token'] as String, + email: j['email'] as String?, + usedCount: (j['usedCount'] as num?)?.toInt() ?? 0, + maxUses: (j['maxUses'] as num?)?.toInt(), + revoked: j['revokedAt'] != null, + ); +} + class Workshop { Workshop({required this.id, required this.name, required this.kapazitaet}); final String id; @@ -402,6 +504,99 @@ class Api { Future approveOnboarding(String id) => _post('/onboarding/requests/$id/approve', null); Future rejectOnboarding(String id) => _post('/onboarding/requests/$id/reject', null); + // --- LT Wahl administration --- + Future> wahlenForKc(String kcId) async { + final list = await _get('/wahl?kcId=$kcId') as List; + return list.map((e) => WahlAdmin.fromJson(e as Map)).toList(); + } + + Future createWahl( + String kcId, + String name, + String datumsSchluessel, + String teil, + ) => + _post('/wahl', { + 'kcId': kcId, + 'name': name, + 'datumsSchluessel': datumsSchluessel, + 'teil': teil, + }); + + Future> workshopsForWahl(String wahlId) async { + final list = await _get('/wahl/$wahlId/workshops') as List; + return list.map((e) => WorkshopAdmin.fromJson(e as Map)).toList(); + } + + Future createWorkshop( + String wahlId, + String name, + int kapazitaet, + int minTeilnehmer, + ) => + _post('/wahl/$wahlId/workshops', { + 'name': name, + 'kapazitaet': kapazitaet, + 'minTeilnehmer': minTeilnehmer, + }); + + Future runZuteilung(String wahlId) => _post('/wahl/$wahlId/zuteilung/run', null); + + Future> zuteilungResults(String wahlId) async { + final list = await _get('/wahl/$wahlId/zuteilung') as List; + return list.map((e) => ZuteilungRow.fromJson(e as Map)).toList(); + } + + // --- Teamer administration (LT or the responsible Verantwortliche/r) --- + Future> teamerFor(String gemeindeId) async { + final list = await _get('/gemeinde/$gemeindeId/teamer') as List; + return list.map((e) => TeamerAccount.fromJson(e as Map)).toList(); + } + + Future createTeamer( + String gemeindeId, { + required String firstName, + required String lastName, + required String email, + required String password, + }) => + _post('/gemeinde/$gemeindeId/teamer', { + 'firstName': firstName, + 'lastName': lastName, + 'email': email, + 'password': password, + }); + + Future> teamerInvitesFor(String gemeindeId) async { + final list = await _get('/gemeinde/$gemeindeId/teamer-invites') as List; + return list.map((e) => TeamerInvite.fromJson(e as Map)).toList(); + } + + Future createTeamerInvite( + String gemeindeId, { + String? email, + int? maxUses, + int? expiresInHours, + }) async => + TeamerInvite.fromJson(await _post('/gemeinde/$gemeindeId/teamer-invites', { + if (email != null && email.isNotEmpty) 'email': email, + 'maxUses': ?maxUses, + 'expiresInHours': ?expiresInHours, + }) as Map); + + // --- Verantwortlichen self-registration --- + Future> resolveInvite(String inviteCode) async => + await _get('/onboarding/kc/$inviteCode') as Map; + + Future> registerVerantwortliche( + String inviteCode, + String gemeindeId, + ) async => + await _post('/onboarding/verantwortliche', { + 'inviteCode': inviteCode, + 'gemeindeId': gemeindeId, + }) as Map; + // --- chat: REST for channels/history; live send/receive is the /chat WS --- Future> channels(String kcId) async { final list = await _get('/chat/$kcId/channels') as List; diff --git a/client/app/lib/screens/admin_screen.dart b/client/app/lib/screens/admin_screen.dart index 82f5e16..290417e 100644 --- a/client/app/lib/screens/admin_screen.dart +++ b/client/app/lib/screens/admin_screen.dart @@ -2,6 +2,9 @@ import 'package:flutter/material.dart'; import '../api.dart'; import '../main.dart'; +import 'teamer_admin_screen.dart'; +import 'ui.dart'; +import 'wahl_admin_screen.dart'; /// Leitungsteam admin: KCs, their Gemeinden, and pending self-registrations. class AdminScreen extends StatefulWidget { @@ -24,13 +27,13 @@ class _AdminScreenState extends State { Future _createKc() async { final api = AppScope.of(context).api; - final name = await _promptText(context, 'Neues KC', 'Name'); + final name = await promptText(context, 'Neues KC', 'Name'); if (name == null || name.isEmpty || !mounted) return; try { await api.createKc(name); if (mounted) _reload(); } catch (e) { - if (mounted) _toast(context, '$e'); + if (mounted) toast(context, '$e'); } } @@ -109,13 +112,13 @@ class _KcDetailScreenState extends State { Future _addGemeinde() async { final api = _api; - final name = await _promptText(context, 'Neue Gemeinde', 'Name'); + final name = await promptText(context, 'Neue Gemeinde', 'Name'); if (name == null || name.isEmpty || !mounted) return; try { await api.createGemeinde(widget.kc.id, name); if (mounted) _reloadGemeinden(); } catch (e) { - if (mounted) _toast(context, '$e'); + if (mounted) toast(context, '$e'); } } @@ -133,20 +136,22 @@ class _KcDetailScreenState extends State { trailing: const Icon(Icons.qr_code_2), ), ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: Text('Gemeinden', - style: Theme.of(context).textTheme.titleMedium), + Card( + child: ListTile( + leading: const Icon(Icons.how_to_vote), + title: const Text('Workshop-Wahlen'), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => WahlAdminScreen(kcId: widget.kc.id)), ), - TextButton.icon( - onPressed: _addGemeinde, - icon: const Icon(Icons.add), - label: const Text('Hinzufügen'), - ), - ], + ), ), + const SizedBox(height: 16), + SectionHeader('Gemeinden', action: TextButton.icon( + onPressed: _addGemeinde, + icon: const Icon(Icons.add), + label: const Text('Hinzufügen'), + )), _GemeindeList(future: _gemeinden!, onRetry: _reloadGemeinden), const Divider(height: 40), Text('Offene Verantwortlichen-Anfragen', @@ -161,7 +166,7 @@ class _KcDetailScreenState extends State { : await _api.rejectOnboarding(id); _reloadRequests(); } catch (e) { - if (context.mounted) _toast(context, '$e'); + if (context.mounted) toast(context, '$e'); } }, ), @@ -199,6 +204,12 @@ class _GemeindeList extends StatelessWidget { dense: true, leading: const Icon(Icons.church), title: Text(g.name), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => TeamerAdminScreen(gemeindeId: g.id, gemeindeName: g.name), + ), + ), ), ], ); @@ -260,32 +271,3 @@ class _RequestList extends StatelessWidget { } } -Future _promptText(BuildContext context, String title, String label) { - final controller = TextEditingController(); - return showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(title), - content: TextField( - controller: controller, - autofocus: true, - decoration: InputDecoration(labelText: label), - onSubmitted: (v) => Navigator.of(context).pop(v.trim()), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Abbrechen'), - ), - FilledButton( - onPressed: () => Navigator.of(context).pop(controller.text.trim()), - child: const Text('OK'), - ), - ], - ), - ); -} - -void _toast(BuildContext context, String message) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); -} diff --git a/client/app/lib/screens/home_screen.dart b/client/app/lib/screens/home_screen.dart index 5244f72..0432025 100644 --- a/client/app/lib/screens/home_screen.dart +++ b/client/app/lib/screens/home_screen.dart @@ -5,6 +5,7 @@ import '../main.dart'; import 'admin_screen.dart'; import 'chat_screen.dart'; import 'files_screen.dart'; +import 'verantwortliche_register_screen.dart'; import 'wahl_screen.dart'; class HomeScreen extends StatelessWidget { @@ -16,6 +17,10 @@ class HomeScreen extends StatelessWidget { final id = state.identity!; final kcId = id.kcId; + final needsVerantwRegistration = id.kind == SessionKind.user && + !id.isLeitungsteam && + id.memberships.isEmpty; + final tiles = [ if (id.isLeitungsteam) _NavTile( @@ -24,6 +29,13 @@ class HomeScreen extends StatelessWidget { subtitle: 'KCs, Gemeinden, Onboarding-Freigaben', onTap: () => _open(context, const AdminScreen()), ), + if (needsVerantwRegistration) + _NavTile( + icon: Icons.how_to_reg, + title: 'Als Verantwortliche/r registrieren', + subtitle: 'KC-Code eingeben, Gemeinde wählen, Freigabe abwarten', + onTap: () => _open(context, const VerantwortlicheRegisterScreen()), + ), if (id.kind == SessionKind.guest) _NavTile( icon: Icons.how_to_vote, diff --git a/client/app/lib/screens/teamer_admin_screen.dart b/client/app/lib/screens/teamer_admin_screen.dart new file mode 100644 index 0000000..fa21d7f --- /dev/null +++ b/client/app/lib/screens/teamer_admin_screen.dart @@ -0,0 +1,224 @@ +import 'package:flutter/material.dart'; + +import '../api.dart'; +import '../main.dart'; +import 'ui.dart'; + +/// Teamer administration for one Gemeinde — usable by the Leitungsteam or the +/// responsible Gemeinde Verantwortliche/r. +class TeamerAdminScreen extends StatefulWidget { + const TeamerAdminScreen({ + super.key, + required this.gemeindeId, + required this.gemeindeName, + }); + final String gemeindeId; + final String gemeindeName; + + @override + State createState() => _TeamerAdminScreenState(); +} + +class _TeamerAdminScreenState extends State { + Future>? _teamer; + Future>? _invites; + Api get _api => AppScope.of(context).api; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _teamer ??= _api.teamerFor(widget.gemeindeId); + _invites ??= _api.teamerInvitesFor(widget.gemeindeId); + } + + void _reloadTeamer() => + setState(() => _teamer = _api.teamerFor(widget.gemeindeId)); + void _reloadInvites() => + setState(() => _invites = _api.teamerInvitesFor(widget.gemeindeId)); + + Future _addTeamer() async { + final api = _api; + final v = await showDialog<(String, String, String, String)>( + context: context, + builder: (_) => const _NewTeamerDialog(), + ); + if (v == null || !mounted) return; + try { + await api.createTeamer( + widget.gemeindeId, + firstName: v.$1, + lastName: v.$2, + email: v.$3, + password: v.$4, + ); + if (mounted) _reloadTeamer(); + } catch (e) { + if (mounted) toast(context, '$e'); + } + } + + Future _addInvite({required bool personal}) async { + final api = _api; + String? email; + if (personal) { + email = await promptText(context, 'E-Mail-Invite', 'E-Mail-Adresse'); + if (email == null || email.isEmpty || !mounted) return; + } + try { + final inv = await api.createTeamerInvite(widget.gemeindeId, email: email); + if (!mounted) return; + _reloadInvites(); + showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Invite erstellt'), + content: SelectableText( + personal + ? 'E-Mail an ${inv.email} ausgelöst.\n\nToken: ${inv.token}' + : 'Gruppen-Link-Token (mehrfach nutzbar):\n\n${inv.token}', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('OK'), + ), + ], + ), + ); + } catch (e) { + if (mounted) toast(context, '$e'); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('Teamer:innen · ${widget.gemeindeName}')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + SectionHeader('Konten', action: TextButton.icon( + onPressed: _addTeamer, + icon: const Icon(Icons.person_add), + label: const Text('Anlegen'), + )), + FutureBuilder>( + future: _teamer, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const LinearProgressIndicator(); + } + if (snap.hasError) return Text('Fehler: ${snap.error}'); + final list = snap.data!; + if (list.isEmpty) return const Text('Noch keine Teamer:innen.'); + return Column( + children: [ + for (final t in list) + ListTile( + dense: true, + leading: const Icon(Icons.person), + title: Text(t.name.isEmpty ? t.email : t.name), + subtitle: Text(t.email), + ), + ], + ); + }, + ), + const Divider(height: 40), + SectionHeader('Einladungen', action: Wrap( + spacing: 4, + children: [ + TextButton( + onPressed: () => _addInvite(personal: false), + child: const Text('Gruppen-Link'), + ), + TextButton( + onPressed: () => _addInvite(personal: true), + child: const Text('per E-Mail'), + ), + ], + )), + FutureBuilder>( + future: _invites, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const LinearProgressIndicator(); + } + if (snap.hasError) return Text('Fehler: ${snap.error}'); + final list = snap.data!; + if (list.isEmpty) return const Text('Keine Einladungen.'); + return Column( + children: [ + for (final i in list) + ListTile( + dense: true, + leading: Icon(i.revoked + ? Icons.block + : i.email != null + ? Icons.mail + : Icons.link), + title: Text(i.email ?? 'Gruppen-Link'), + subtitle: Text( + '${i.usedCount}${i.maxUses != null ? '/${i.maxUses}' : ''} genutzt' + '${i.revoked ? ' · widerrufen' : ''}', + ), + trailing: SelectableText( + i.token.substring(0, 8), + style: Theme.of(context).textTheme.labelSmall, + ), + ), + ], + ); + }, + ), + ], + ), + ); + } +} + +class _NewTeamerDialog extends StatefulWidget { + const _NewTeamerDialog(); + @override + State<_NewTeamerDialog> createState() => _NewTeamerDialogState(); +} + +class _NewTeamerDialogState extends State<_NewTeamerDialog> { + final _first = TextEditingController(); + final _last = TextEditingController(); + final _email = TextEditingController(); + final _password = TextEditingController(); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Teamer:in anlegen'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField(controller: _first, decoration: const InputDecoration(labelText: 'Vorname')), + TextField(controller: _last, decoration: const InputDecoration(labelText: 'Nachname')), + TextField(controller: _email, decoration: const InputDecoration(labelText: 'E-Mail')), + TextField( + controller: _password, + obscureText: true, + decoration: const InputDecoration(labelText: 'Passwort (min. 8)'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), child: const Text('Abbrechen')), + FilledButton( + onPressed: () => Navigator.of(context).pop(( + _first.text.trim(), + _last.text.trim(), + _email.text.trim(), + _password.text, + )), + child: const Text('Anlegen'), + ), + ], + ); + } +} diff --git a/client/app/lib/screens/ui.dart b/client/app/lib/screens/ui.dart new file mode 100644 index 0000000..bf1d05c --- /dev/null +++ b/client/app/lib/screens/ui.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; + +/// Small shared widgets/helpers used across the admin screens. + +void toast(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); +} + +class ErrorText extends StatelessWidget { + const ErrorText(this.message, {super.key, this.onRetry}); + final String message; + final VoidCallback? onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(message, textAlign: TextAlign.center), + if (onRetry != null) ...[ + const SizedBox(height: 12), + OutlinedButton(onPressed: onRetry, child: const Text('Erneut versuchen')), + ], + ], + ), + ), + ); + } +} + +class SectionHeader extends StatelessWidget { + const SectionHeader(this.title, {super.key, this.action}); + final String title; + final Widget? action; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Text(title, style: Theme.of(context).textTheme.titleMedium), + ), + ?action, + ], + ); + } +} + +/// Single-line text prompt dialog. Returns the trimmed value or null. +Future promptText(BuildContext context, String title, String label) { + final controller = TextEditingController(); + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: TextField( + controller: controller, + autofocus: true, + decoration: InputDecoration(labelText: label), + onSubmitted: (v) => Navigator.of(context).pop(v.trim()), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Abbrechen'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(controller.text.trim()), + child: const Text('OK'), + ), + ], + ), + ); +} diff --git a/client/app/lib/screens/verantwortliche_register_screen.dart b/client/app/lib/screens/verantwortliche_register_screen.dart new file mode 100644 index 0000000..431deb6 --- /dev/null +++ b/client/app/lib/screens/verantwortliche_register_screen.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; + +import '../api.dart'; +import '../main.dart'; + +/// Self-registration as a Gemeinde Verantwortliche/r: enter the KC invite +/// code, pick your Gemeinde, send the request. The result is a PENDING +/// membership a Leitungsteam member has to approve. +class VerantwortlicheRegisterScreen extends StatefulWidget { + const VerantwortlicheRegisterScreen({super.key}); + + @override + State createState() => + _VerantwortlicheRegisterScreenState(); +} + +class _VerantwortlicheRegisterScreenState + extends State { + final _code = TextEditingController(); + String? _kcName; + List<(String id, String name)> _gemeinden = []; + String? _selectedGemeinde; + bool _busy = false; + String? _error; + String? _done; + + Api get _api => AppScope.of(context).api; + + Future _resolve() async { + setState(() { + _busy = true; + _error = null; + _kcName = null; + _gemeinden = []; + }); + try { + final res = await _api.resolveInvite(_code.text.trim()); + setState(() { + _kcName = res['kcName'] as String?; + _gemeinden = ((res['gemeinden'] as List?) ?? []) + .map((g) => (g['id'] as String, g['name'] as String)) + .toList(); + }); + } catch (e) { + setState(() => _error = '$e'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _submit() async { + if (_selectedGemeinde == null) return; + setState(() { + _busy = true; + _error = null; + }); + try { + final res = + await _api.registerVerantwortliche(_code.text.trim(), _selectedGemeinde!); + setState(() => _done = + 'Anfrage gesendet (Status: ${res['status']}). Ein Leitungsteam-Mitglied ' + 'muss dich noch freischalten.'); + } catch (e) { + setState(() => _error = '$e'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Als Verantwortliche/r registrieren')), + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: ListView( + padding: const EdgeInsets.all(24), + children: [ + if (_done != null) ...[ + const Icon(Icons.check_circle, color: Colors.green, size: 48), + const SizedBox(height: 12), + Text(_done!, textAlign: TextAlign.center), + const SizedBox(height: 20), + FilledButton( + onPressed: () => AppScope.of(context).logout(), + child: const Text('Abmelden'), + ), + ] else ...[ + TextField( + controller: _code, + decoration: const InputDecoration( + labelText: 'KC-Einladungscode', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: _busy ? null : _resolve, + child: const Text('KC suchen'), + ), + if (_kcName != null) ...[ + const SizedBox(height: 20), + Text('KC: $_kcName', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + DropdownButtonFormField( + initialValue: _selectedGemeinde, + decoration: const InputDecoration( + labelText: 'Deine Gemeinde', + border: OutlineInputBorder(), + ), + items: [ + for (final g in _gemeinden) + DropdownMenuItem(value: g.$1, child: Text(g.$2)), + ], + onChanged: (v) => setState(() => _selectedGemeinde = v), + ), + const SizedBox(height: 16), + FilledButton( + onPressed: (_busy || _selectedGemeinde == null) ? null : _submit, + child: const Text('Anfrage senden'), + ), + ], + if (_error != null) ...[ + const SizedBox(height: 16), + Text(_error!, + style: TextStyle(color: Theme.of(context).colorScheme.error)), + ], + ], + ], + ), + ), + ), + ); + } +} diff --git a/client/app/lib/screens/wahl_admin_screen.dart b/client/app/lib/screens/wahl_admin_screen.dart new file mode 100644 index 0000000..28a09b5 --- /dev/null +++ b/client/app/lib/screens/wahl_admin_screen.dart @@ -0,0 +1,308 @@ +import 'package:flutter/material.dart'; + +import '../api.dart'; +import '../main.dart'; +import 'ui.dart'; + +/// LT: manage the Wahlen of one KC — create, add workshops, run the +/// assignment algorithm, view the result. +class WahlAdminScreen extends StatefulWidget { + const WahlAdminScreen({super.key, required this.kcId}); + final String kcId; + + @override + State createState() => _WahlAdminScreenState(); +} + +class _WahlAdminScreenState extends State { + Future>? _future; + Api get _api => AppScope.of(context).api; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _future ??= _api.wahlenForKc(widget.kcId); + } + + void _reload() => setState(() => _future = _api.wahlenForKc(widget.kcId)); + + Future _create() async { + final api = _api; + final v = await showDialog<(String, String, String)>( + context: context, + builder: (_) => const _NewWahlDialog(), + ); + if (v == null || !mounted) return; + try { + await api.createWahl(widget.kcId, v.$1, v.$2, v.$3); + if (mounted) _reload(); + } catch (e) { + if (mounted) toast(context, '$e'); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Wahlen')), + floatingActionButton: FloatingActionButton.extended( + onPressed: _create, + icon: const Icon(Icons.add), + label: const Text('Wahl'), + ), + body: FutureBuilder>( + future: _future, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + if (snap.hasError) return ErrorText('${snap.error}', onRetry: _reload); + final wahlen = snap.data!; + if (wahlen.isEmpty) { + return const Center(child: Text('Noch keine Wahlen. Unten anlegen.')); + } + return ListView( + children: [ + for (final w in wahlen) + ListTile( + leading: Icon(w.isOpen ? Icons.lock_open : Icons.lock), + title: Text(w.name), + subtitle: Text('${w.datumsSchluessel} · Teil ${w.teil}'), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => WahlDetailScreen(wahl: w)), + ), + ), + ], + ); + }, + ), + ); + } +} + +class WahlDetailScreen extends StatefulWidget { + const WahlDetailScreen({super.key, required this.wahl}); + final WahlAdmin wahl; + + @override + State createState() => _WahlDetailScreenState(); +} + +class _WahlDetailScreenState extends State { + Future>? _workshops; + Future>? _results; + bool _running = false; + Api get _api => AppScope.of(context).api; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _workshops ??= _api.workshopsForWahl(widget.wahl.id); + _results ??= _api.zuteilungResults(widget.wahl.id); + } + + void _reloadWorkshops() => + setState(() => _workshops = _api.workshopsForWahl(widget.wahl.id)); + void _reloadResults() => + setState(() => _results = _api.zuteilungResults(widget.wahl.id)); + + Future _addWorkshop() async { + final api = _api; + final v = await showDialog<(String, int, int)>( + context: context, + builder: (_) => const _NewWorkshopDialog(), + ); + if (v == null || !mounted) return; + try { + await api.createWorkshop(widget.wahl.id, v.$1, v.$2, v.$3); + if (mounted) _reloadWorkshops(); + } catch (e) { + if (mounted) toast(context, '$e'); + } + } + + Future _run() async { + final api = _api; + setState(() => _running = true); + try { + await api.runZuteilung(widget.wahl.id); + if (mounted) _reloadResults(); + } catch (e) { + if (mounted) toast(context, '$e'); + } finally { + if (mounted) setState(() => _running = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(widget.wahl.name)), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + SectionHeader('Workshops', action: TextButton.icon( + onPressed: _addWorkshop, + icon: const Icon(Icons.add), + label: const Text('Hinzufügen'), + )), + FutureBuilder>( + future: _workshops, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const LinearProgressIndicator(); + } + if (snap.hasError) return Text('Fehler: ${snap.error}'); + final ws = snap.data!; + if (ws.isEmpty) return const Text('Noch keine Workshops.'); + return Column( + children: [ + for (final w in ws) + ListTile( + dense: true, + leading: const Icon(Icons.groups), + title: Text(w.name), + subtitle: Text('Kapazität ${w.kapazitaet} · min. ${w.minTeilnehmer}'), + ), + ], + ); + }, + ), + const Divider(height: 40), + Row( + children: [ + Expanded( + child: Text('Zuteilung', + style: Theme.of(context).textTheme.titleMedium), + ), + FilledButton.icon( + onPressed: _running ? null : _run, + icon: _running + ? const SizedBox( + height: 16, width: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.play_arrow), + label: const Text('Ausführen'), + ), + ], + ), + const SizedBox(height: 8), + FutureBuilder>( + future: _results, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const LinearProgressIndicator(); + } + if (snap.hasError) return Text('Fehler: ${snap.error}'); + final rows = snap.data!; + if (rows.isEmpty) { + return const Text('Noch keine Zuteilung berechnet.'); + } + return Column( + children: [ + for (final r in rows) + ListTile( + dense: true, + title: Text(r.name), + subtitle: Text(r.workshopName ?? 'UNZUGETEILT'), + trailing: Text( + r.isForced + ? 'fest' + : r.wunschRang > 0 + ? 'Wunsch ${r.wunschRang}' + : '—', + ), + ), + ], + ); + }, + ), + ], + ), + ); + } +} + +class _NewWahlDialog extends StatefulWidget { + const _NewWahlDialog(); + @override + State<_NewWahlDialog> createState() => _NewWahlDialogState(); +} + +class _NewWahlDialogState extends State<_NewWahlDialog> { + final _name = TextEditingController(); + final _datum = TextEditingController(); + final _teil = TextEditingController(text: '1'); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Neue Wahl'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField(controller: _name, decoration: const InputDecoration(labelText: 'Name')), + TextField( + controller: _datum, + decoration: const InputDecoration(labelText: 'Datumsschlüssel (z. B. 2026-06-13)')), + TextField(controller: _teil, decoration: const InputDecoration(labelText: 'Teil')), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), child: const Text('Abbrechen')), + FilledButton( + onPressed: () => Navigator.of(context).pop( + (_name.text.trim(), _datum.text.trim(), _teil.text.trim()), + ), + child: const Text('Anlegen'), + ), + ], + ); + } +} + +class _NewWorkshopDialog extends StatefulWidget { + const _NewWorkshopDialog(); + @override + State<_NewWorkshopDialog> createState() => _NewWorkshopDialogState(); +} + +class _NewWorkshopDialogState extends State<_NewWorkshopDialog> { + final _name = TextEditingController(); + final _kap = TextEditingController(text: '12'); + final _min = TextEditingController(text: '0'); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Neuer Workshop'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField(controller: _name, decoration: const InputDecoration(labelText: 'Name')), + TextField( + controller: _kap, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: 'Kapazität')), + TextField( + controller: _min, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: 'Mindestteilnehmer')), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), child: const Text('Abbrechen')), + FilledButton( + onPressed: () => Navigator.of(context).pop(( + _name.text.trim(), + int.tryParse(_kap.text) ?? 0, + int.tryParse(_min.text) ?? 0, + )), + child: const Text('Anlegen'), + ), + ], + ); + } +} diff --git a/client/app/web/index.html b/client/app/web/index.html index 443cec3..400faaa 100644 --- a/client/app/web/index.html +++ b/client/app/web/index.html @@ -18,18 +18,18 @@ - + - + - kc_app + KC-App diff --git a/client/app/web/manifest.json b/client/app/web/manifest.json index 0785abb..817619f 100644 --- a/client/app/web/manifest.json +++ b/client/app/web/manifest.json @@ -1,11 +1,11 @@ { - "name": "kc_app", - "short_name": "kc_app", + "name": "KC-App", + "short_name": "KC-App", "start_url": ".", "display": "standalone", "background_color": "#0175C2", "theme_color": "#0175C2", - "description": "A new Flutter project.", + "description": "Konfi-Castle Event-, Wahl- und Kommunikationsplattform", "orientation": "portrait-primary", "prefer_related_applications": false, "icons": [ From 73ff55643f1be9ba6fdba12c15ec66326ce515d5 Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 10:10:07 +0200 Subject: [PATCH 22/37] docs: LT Wahl/Teamer admin + Verantwortlichen self-registration screens Co-Authored-By: Claude Sonnet 5 --- client/app/README.md | 14 +++++++++++--- plan-kcAppMultiTenantPlatform.prompt.md | 4 ++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/client/app/README.md b/client/app/README.md index 137b257..a689196 100644 --- a/client/app/README.md +++ b/client/app/README.md @@ -44,9 +44,17 @@ Teamer login only). access token refreshed on restart. `GET /auth/me` resolves the role. - **Home** (`lib/screens/home_screen.dart`) — identity card + navigation. - **Verwaltung** (`lib/screens/admin_screen.dart`, Leitungsteam only) — - list/create KCs; per KC the Gemeinden (list/create) and pending - Verantwortlichen self-registrations (`GET /onboarding/requests`, - approve / reject). + list/create KCs; per KC: + - 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. + - 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. - **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` — diff --git a/plan-kcAppMultiTenantPlatform.prompt.md b/plan-kcAppMultiTenantPlatform.prompt.md index b0fcf09..dbc272c 100644 --- a/plan-kcAppMultiTenantPlatform.prompt.md +++ b/plan-kcAppMultiTenantPlatform.prompt.md @@ -116,7 +116,7 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM 3b. Flutter-Client (`client/app/`): `flutter analyze` sauber, `flutter build web --release` erfolgreich, `flutter test` grün (Login-Screen-Smoke-Test); CORS-Preflight vom Browser-Origin ok. 3c. Guest-Ergebnis: `/wahl/guest/results` gegen echtes Postgres in beiden Zuständen geprüft (PENDING nach Einreichung, ASSIGNED nach manuell gesetzter `Zuteilung` → Workshop-Name + Wunschrang). 3d. WS-Chat: Zwei-Client-E2E gegen echtes Postgres (zwei lokale Teamer im selben `GEMEINDE_GRUPPE`-Kanal, `chat:send` → der andere empfängt `chat:message`). Dabei behoben: `ChatGateway` speicherte den Caller erst nach dem asynchronen Token-Check, wodurch ein sofortiges `chat:join` mit 4001 abgewiesen wurde — jetzt wartet der Handler auf das Caller-Promise. -3e. LT-Admin gegen echtes Postgres mit einem `isLeitungsteam`-Account (Team-Token): `POST/GET /api/kc`, `POST /api/gemeinde`, `GET /api/onboarding/requests`, sowie eine PENDING-Verantwortlichen-Anfrage → `approve` → Status `ACTIVE`, Liste danach leer. +3e. LT-Admin gegen echtes Postgres mit einem `isLeitungsteam`-Account (Team-Token): `POST/GET /api/kc`, `POST /api/gemeinde`, `GET /api/onboarding/requests` + PENDING-Anfrage → `approve` → `ACTIVE`; Wahl-Admin (`POST /api/wahl`, `POST /api/wahl/:id/workshops`, `POST .../zuteilung/run`, `GET .../zuteilung`); Teamer-Admin (`POST/GET /api/gemeinde/:id/teamer`, `POST /api/gemeinde/:id/teamer-invites`); `GET /api/onboarding/kc/:code`. 3f. **Echte Authentik verifiziert**: mit einem Password-Grant-Token für ein `KC-APP-LT`-Mitglied (`hermes`) gegen `https://sso.konfi-castle.com` → `GET /api/auth/me` liefert `isLeitungsteam: true` (JWKS-Prüfung, Trailing-Slash-Issuer, JIT-`User`, `groups`→LT), `POST /api/kc` → 201. Placeholder-E-Mail-Fallback, da `hermes` keine E-Mail hat. `AUTHENTIK_LEITUNGSTEAM_GROUP="KC-APP-LT"`. **Noch offen:** nur der In-Browser-Redirect-Roundtrip (Authentik-Loginseite → Code-Tausch). 4. Jest-Unit-Tests (Prisma/Sync/Mail gemockt, `npm test` grün, 56 Tests): - `src/wahl/zuteilung.service.spec.ts`: Force-Vorrang, Wunschrunden-Fallback bei voller Kapazität, Unzugeteilt-Fall, Konsolidierung unterbesetzter Workshops, Sync-Capture-Anzahl. @@ -130,7 +130,7 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM ## 8. Nächste Schritte -1. Flutter-Client: ✅ Authentik-PKCE-Login-Flow (`oidc.dart`) + LT-Admin-Screens (KC/Gemeinde anlegen, Onboarding-Anfragen freigeben). 🔜 Browser-Roundtrip einmal live testen (Redirect + Testaccount); Teamer-Verwaltungs-Screen (für Verantwortliche/LT: Teamer + Invites), Verantwortlichen-Selbstregistrierungs-Screen, Wahl-Verwaltung für LT, dann Mobile/Desktop-Targets. +1. Flutter-Client: ✅ Authentik-PKCE-Login (`oidc.dart`), LT-Admin (KC/Gemeinde), **Wahl-Verwaltung** (Wahlen/Workshops/Zuteilung ausführen + Ergebnis), **Teamer-Verwaltung** (Konten + Invites), **Verantwortlichen-Selbstregistrierung**. 🔜 Browser-OIDC-Roundtrip einmal live testen (Redirect + Testaccount durchklicken); Wahl schließen/öffnen + Force-Zuteilung im UI; Datei-Upload für LT; dann Mobile/Desktop-Targets. 2. Authentik-Provider so konfigurieren, dass das Access-Token den `groups`-Claim trägt (Scope „groups"), LT-Gruppe = `AUTHENTIK_LEITUNGSTEAM_GROUP`. (Ops/Config, Code ist fertig.) 3. SMTP konfigurieren (`MAIL_PROVIDER=smtp` + `SMTP_*`/`MAIL_FROM`/`APP_BASE_URL`) und Invite-Mail-Templates finalisieren (aktuell Plain-Text); optional Onboarding-Benachrichtigungen an LT. 4. Push-Benachrichtigungen (FCM/APNs) für Chat/Ankündigungen. From a4ae7549ae4e49d48940d4e0bce30d77f48460f4 Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 10:19:35 +0200 Subject: [PATCH 23/37] feat: LT Wahl controls (open/close, Force-Zuteilung, CSV) + file upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 + 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 --- backend/src/wahl/dto/update-wahl.dto.ts | 7 ++ backend/src/wahl/wahl.controller.ts | 16 +++ backend/src/wahl/wahl.service.ts | 27 +++++ client/app/lib/api.dart | 65 ++++++++++ client/app/lib/browser_stub.dart | 5 + client/app/lib/browser_web.dart | 41 +++++++ client/app/lib/screens/admin_screen.dart | 11 ++ .../app/lib/screens/files_admin_screen.dart | 102 ++++++++++++++++ client/app/lib/screens/wahl_admin_screen.dart | 113 +++++++++++++++++- client/app/pubspec.yaml | 1 - 10 files changed, 384 insertions(+), 4 deletions(-) create mode 100644 backend/src/wahl/dto/update-wahl.dto.ts create mode 100644 client/app/lib/screens/files_admin_screen.dart diff --git a/backend/src/wahl/dto/update-wahl.dto.ts b/backend/src/wahl/dto/update-wahl.dto.ts new file mode 100644 index 0000000..5d3a1bc --- /dev/null +++ b/backend/src/wahl/dto/update-wahl.dto.ts @@ -0,0 +1,7 @@ +import { IsBoolean, IsOptional } from 'class-validator'; + +export class UpdateWahlDto { + @IsOptional() + @IsBoolean() + isOpen?: boolean; +} diff --git a/backend/src/wahl/wahl.controller.ts b/backend/src/wahl/wahl.controller.ts index 332b9a2..8dd49c1 100644 --- a/backend/src/wahl/wahl.controller.ts +++ b/backend/src/wahl/wahl.controller.ts @@ -3,6 +3,7 @@ import { Controller, Get, Param, + Patch, Post, Query, Req, @@ -14,6 +15,7 @@ import { Response } from 'express'; import { WahlService } from './wahl.service'; import { ZuteilungService } from './zuteilung.service'; import { CreateWahlDto } from './dto/create-wahl.dto'; +import { UpdateWahlDto } from './dto/update-wahl.dto'; import { CreateWorkshopDto } from './dto/create-workshop.dto'; import { SubmitTeilnehmerDto } from './dto/submit-teilnehmer.dto'; import { CreateForceZuteilungDto } from './dto/create-force-zuteilung.dto'; @@ -45,6 +47,20 @@ export class WahlController { return this.wahl.listWahlen(kcId); } + @Patch(':wahlId') + @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) + @Roles(Role.LEITUNGSTEAM) + updateWahl(@Param('wahlId') wahlId: string, @Body() dto: UpdateWahlDto) { + return this.wahl.updateWahl(wahlId, { isOpen: dto.isOpen }); + } + + @Get(':wahlId/teilnehmer') + @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) + @Roles(Role.LEITUNGSTEAM) + listTeilnehmer(@Param('wahlId') wahlId: string) { + return this.wahl.listTeilnehmer(wahlId); + } + @Post(':wahlId/workshops') @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) @Roles(Role.LEITUNGSTEAM) diff --git a/backend/src/wahl/wahl.service.ts b/backend/src/wahl/wahl.service.ts index 9223234..f1858ae 100644 --- a/backend/src/wahl/wahl.service.ts +++ b/backend/src/wahl/wahl.service.ts @@ -22,6 +22,33 @@ export class WahlService { return this.prisma.wahl.findMany({ where: { kcId } }); } + async updateWahl(wahlId: string, data: { isOpen?: boolean }) { + await this.getWahlOrThrow(wahlId); + const wahl = await this.prisma.wahl.update({ where: { id: wahlId }, data }); + await this.sync.capture('Wahl', SyncOperation.UPDATE, wahl.id, wahl); + return wahl; + } + + /// LT view of who took part in a Wahl, with their priorities and any + /// existing Force-Zuteilung. + async listTeilnehmer(wahlId: string) { + await this.getWahlOrThrow(wahlId); + const rows = await this.prisma.teilnehmer.findMany({ + where: { wahlId }, + orderBy: { guestAccount: { lastName: 'asc' } }, + include: { + guestAccount: { select: { firstName: true, lastName: true } }, + forceZuteilung: { select: { workshopId: true } }, + }, + }); + return rows.map((t) => ({ + id: t.id, + name: `${t.guestAccount.firstName} ${t.guestAccount.lastName}`.trim(), + prioritaeten: (t.prioritaeten as string[] | null) ?? [], + forcedWorkshopId: t.forceZuteilung?.workshopId ?? null, + })); + } + /// Guest-facing view: open Wahlen for the guest's KC, each with its /// workshops and the guest's own current priorities (null if not submitted). async guestOverview(kcId: string, guestAccountId: string) { diff --git a/client/app/lib/api.dart b/client/app/lib/api.dart index 63add4d..da4acc0 100644 --- a/client/app/lib/api.dart +++ b/client/app/lib/api.dart @@ -183,6 +183,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 prioritaeten; + final String? forcedWorkshopId; + factory TeilnehmerRow.fromJson(Map j) => TeilnehmerRow( + id: j['id'] as String, + name: j['name'] as String? ?? '', + prioritaeten: + (j['prioritaeten'] as List? ?? []).map((e) => e as String).toList(), + forcedWorkshopId: j['forcedWorkshopId'] as String?, + ); +} + class ZuteilungRow { ZuteilungRow({ required this.name, @@ -393,6 +413,15 @@ class Api { return _decode(res); } + Future _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; @@ -547,6 +576,42 @@ class Api { return list.map((e) => ZuteilungRow.fromJson(e as Map)).toList(); } + Future setWahlOpen(String wahlId, bool isOpen) => + _patch('/wahl/$wahlId', {'isOpen': isOpen}); + + Future> wahlTeilnehmer(String wahlId) async { + final list = await _get('/wahl/$wahlId/teilnehmer') as List; + return list.map((e) => TeilnehmerRow.fromJson(e as Map)).toList(); + } + + Future forceZuteilung(String wahlId, String teilnehmerId, String workshopId) => + _post('/wahl/$wahlId/force-zuteilung', { + 'teilnehmerId': teilnehmerId, + 'workshopId': workshopId, + }); + + Future zuteilungCsv(String wahlId) async { + final res = await _get('/wahl/$wahlId/zuteilung/csv'); + return res is String ? res : res.toString(); + } + + Future uploadFile( + String kcId, + String filename, + List 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> teamerFor(String gemeindeId) async { final list = await _get('/gemeinde/$gemeindeId/teamer') as List; diff --git a/client/app/lib/browser_stub.dart b/client/app/lib/browser_stub.dart index 3e02c0f..79f4cfa 100644 --- a/client/app/lib/browser_stub.dart +++ b/client/app/lib/browser_stub.dart @@ -7,3 +7,8 @@ void removeSession(String key) => throw UnsupportedError(_msg); Never redirect(String url) => throw UnsupportedError(_msg); Map currentQueryParameters() => const {}; void clearQuery() {} + +Future<({String name, List bytes})?> pickFile() async => + throw UnsupportedError(_msg); +void downloadText(String filename, String content, {String mime = 'text/plain'}) => + throw UnsupportedError(_msg); diff --git a/client/app/lib/browser_web.dart b/client/app/lib/browser_web.dart index d68ac12..10e507d 100644 --- a/client/app/lib/browser_web.dart +++ b/client/app/lib/browser_web.dart @@ -1,3 +1,6 @@ +import 'dart:async'; +import 'dart:js_interop'; + import 'package:web/web.dart' as web; /// Per-tab storage for the PKCE verifier + CSRF state (cleared when the tab @@ -20,3 +23,41 @@ Map currentQueryParameters() => void clearQuery() { web.window.history.replaceState(null, '', '/'); } + +/// Opens the OS file picker and reads the chosen file's bytes. +Future<({String name, List bytes})?> pickFile() { + final completer = Completer<({String name, List 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); +} diff --git a/client/app/lib/screens/admin_screen.dart b/client/app/lib/screens/admin_screen.dart index 290417e..adc6382 100644 --- a/client/app/lib/screens/admin_screen.dart +++ b/client/app/lib/screens/admin_screen.dart @@ -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 { ), ), ), + 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, diff --git a/client/app/lib/screens/files_admin_screen.dart b/client/app/lib/screens/files_admin_screen.dart new file mode 100644 index 0000000..7648b14 --- /dev/null +++ b/client/app/lib/screens/files_admin_screen.dart @@ -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 createState() => _FilesAdminScreenState(); +} + +class _FilesAdminScreenState extends State { + Future>? _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 _upload() async { + final api = _api; + final picked = await browser.pickFile(); + if (picked == null || !mounted) return; + final visibility = await showDialog( + 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>( + 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), + ), + ], + ); + }, + ), + ); + } +} diff --git a/client/app/lib/screens/wahl_admin_screen.dart b/client/app/lib/screens/wahl_admin_screen.dart index 28a09b5..1e4f0f5 100644 --- a/client/app/lib/screens/wahl_admin_screen.dart +++ b/client/app/lib/screens/wahl_admin_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../api.dart'; +import '../browser.dart' as browser; import '../main.dart'; import 'ui.dart'; @@ -92,20 +93,80 @@ class WahlDetailScreen extends StatefulWidget { class _WahlDetailScreenState extends State { Future>? _workshops; Future>? _results; + Future>? _teilnehmer; + late bool _isOpen = widget.wahl.isOpen; + List _workshopCache = const []; bool _running = false; Api get _api => AppScope.of(context).api; @override void didChangeDependencies() { super.didChangeDependencies(); - _workshops ??= _api.workshopsForWahl(widget.wahl.id); + _workshops ??= _api.workshopsForWahl(widget.wahl.id).then((w) { + _workshopCache = w; + return w; + }); _results ??= _api.zuteilungResults(widget.wahl.id); + _teilnehmer ??= _api.wahlTeilnehmer(widget.wahl.id); } - void _reloadWorkshops() => - setState(() => _workshops = _api.workshopsForWahl(widget.wahl.id)); + void _reloadWorkshops() => setState(() { + _workshops = _api.workshopsForWahl(widget.wahl.id).then((w) { + _workshopCache = w; + return w; + }); + }); void _reloadResults() => setState(() => _results = _api.zuteilungResults(widget.wahl.id)); + void _reloadTeilnehmer() => + setState(() => _teilnehmer = _api.wahlTeilnehmer(widget.wahl.id)); + + Future _toggleOpen(bool value) async { + final api = _api; + setState(() => _isOpen = value); + try { + await api.setWahlOpen(widget.wahl.id, value); + } catch (e) { + if (mounted) { + setState(() => _isOpen = !value); + toast(context, '$e'); + } + } + } + + Future _exportCsv() async { + final api = _api; + try { + final csv = await api.zuteilungCsv(widget.wahl.id); + browser.downloadText('zuteilung-${widget.wahl.name}.csv', csv); + } catch (e) { + if (mounted) toast(context, '$e'); + } + } + + Future _forceFor(TeilnehmerRow t) async { + final api = _api; + final workshopId = await showDialog( + context: context, + builder: (_) => SimpleDialog( + title: Text('Zuteilung für ${t.name}'), + children: [ + for (final w in _workshopCache) + SimpleDialogOption( + onPressed: () => Navigator.of(context).pop(w.id), + child: Text(w.name), + ), + ], + ), + ); + if (workshopId == null || !mounted) return; + try { + await api.forceZuteilung(widget.wahl.id, t.id, workshopId); + if (mounted) _reloadTeilnehmer(); + } catch (e) { + if (mounted) toast(context, '$e'); + } + } Future _addWorkshop() async { final api = _api; @@ -142,6 +203,17 @@ class _WahlDetailScreenState extends State { body: ListView( padding: const EdgeInsets.all(16), children: [ + Card( + child: SwitchListTile( + title: const Text('Wahl geöffnet'), + subtitle: Text(_isOpen + ? 'Konfis können Wünsche abgeben' + : 'Geschlossen — keine neuen Einreichungen'), + value: _isOpen, + onChanged: _toggleOpen, + ), + ), + const SizedBox(height: 8), SectionHeader('Workshops', action: TextButton.icon( onPressed: _addWorkshop, icon: const Icon(Icons.add), @@ -170,12 +242,47 @@ class _WahlDetailScreenState extends State { }, ), const Divider(height: 40), + SectionHeader('Teilnehmer:innen'), + const SizedBox(height: 4), + FutureBuilder>( + future: _teilnehmer, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const LinearProgressIndicator(); + } + if (snap.hasError) return Text('Fehler: ${snap.error}'); + final rows = snap.data!; + if (rows.isEmpty) return const Text('Noch keine Einreichungen.'); + return Column( + children: [ + for (final t in rows) + ListTile( + dense: true, + leading: const Icon(Icons.person), + title: Text(t.name), + subtitle: Text('Wünsche: ${t.prioritaeten.length}' + '${t.forcedWorkshopId != null ? ' · fest zugeteilt' : ''}'), + trailing: TextButton( + onPressed: () => _forceFor(t), + child: const Text('Zuteilen'), + ), + ), + ], + ); + }, + ), + const Divider(height: 40), Row( children: [ Expanded( child: Text('Zuteilung', style: Theme.of(context).textTheme.titleMedium), ), + IconButton( + tooltip: 'CSV exportieren', + onPressed: _exportCsv, + icon: const Icon(Icons.download), + ), FilledButton.icon( onPressed: _running ? null : _run, icon: _running diff --git a/client/app/pubspec.yaml b/client/app/pubspec.yaml index fc9abf1..70d1f7c 100644 --- a/client/app/pubspec.yaml +++ b/client/app/pubspec.yaml @@ -14,7 +14,6 @@ dependencies: web_socket_channel: ^3.0.1 crypto: ^3.0.6 web: ^1.1.0 - dev_dependencies: flutter_test: sdk: flutter From 55509eccb716b17bc73f79034c9c460defc61f12 Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 10:20:07 +0200 Subject: [PATCH 24/37] docs: LT Wahl controls, file upload, updated next steps Co-Authored-By: Claude Sonnet 5 --- client/app/README.md | 8 ++++++-- plan-kcAppMultiTenantPlatform.prompt.md | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/client/app/README.md b/client/app/README.md index a689196..3647af6 100644 --- a/client/app/README.md +++ b/client/app/README.md @@ -48,8 +48,12 @@ 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 ``), 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 diff --git a/plan-kcAppMultiTenantPlatform.prompt.md b/plan-kcAppMultiTenantPlatform.prompt.md index dbc272c..d9f3a13 100644 --- a/plan-kcAppMultiTenantPlatform.prompt.md +++ b/plan-kcAppMultiTenantPlatform.prompt.md @@ -116,7 +116,7 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM 3b. Flutter-Client (`client/app/`): `flutter analyze` sauber, `flutter build web --release` erfolgreich, `flutter test` grün (Login-Screen-Smoke-Test); CORS-Preflight vom Browser-Origin ok. 3c. Guest-Ergebnis: `/wahl/guest/results` gegen echtes Postgres in beiden Zuständen geprüft (PENDING nach Einreichung, ASSIGNED nach manuell gesetzter `Zuteilung` → Workshop-Name + Wunschrang). 3d. WS-Chat: Zwei-Client-E2E gegen echtes Postgres (zwei lokale Teamer im selben `GEMEINDE_GRUPPE`-Kanal, `chat:send` → der andere empfängt `chat:message`). Dabei behoben: `ChatGateway` speicherte den Caller erst nach dem asynchronen Token-Check, wodurch ein sofortiges `chat:join` mit 4001 abgewiesen wurde — jetzt wartet der Handler auf das Caller-Promise. -3e. LT-Admin gegen echtes Postgres mit einem `isLeitungsteam`-Account (Team-Token): `POST/GET /api/kc`, `POST /api/gemeinde`, `GET /api/onboarding/requests` + PENDING-Anfrage → `approve` → `ACTIVE`; Wahl-Admin (`POST /api/wahl`, `POST /api/wahl/:id/workshops`, `POST .../zuteilung/run`, `GET .../zuteilung`); Teamer-Admin (`POST/GET /api/gemeinde/:id/teamer`, `POST /api/gemeinde/:id/teamer-invites`); `GET /api/onboarding/kc/:code`. +3e. LT-Admin gegen echtes Postgres mit einem `isLeitungsteam`-Account (Team-Token): `POST/GET /api/kc`, `POST /api/gemeinde`, `GET /api/onboarding/requests` + PENDING-Anfrage → `approve` → `ACTIVE`; Wahl-Admin (`POST /api/wahl`, `POST /api/wahl/:id/workshops`, `POST .../zuteilung/run`, `GET .../zuteilung`, `PATCH /api/wahl/:id` `isOpen`, `GET /api/wahl/:id/teilnehmer`, CSV-Export); Teamer-Admin (`POST/GET /api/gemeinde/:id/teamer`, `POST /api/gemeinde/:id/teamer-invites`); `GET /api/onboarding/kc/:code`. (Datei-Upload `POST /api/files/:kcId` liefert 500 ohne konfiguriertes Nextcloud/S3 — erwartet.) 3f. **Echte Authentik verifiziert**: mit einem Password-Grant-Token für ein `KC-APP-LT`-Mitglied (`hermes`) gegen `https://sso.konfi-castle.com` → `GET /api/auth/me` liefert `isLeitungsteam: true` (JWKS-Prüfung, Trailing-Slash-Issuer, JIT-`User`, `groups`→LT), `POST /api/kc` → 201. Placeholder-E-Mail-Fallback, da `hermes` keine E-Mail hat. `AUTHENTIK_LEITUNGSTEAM_GROUP="KC-APP-LT"`. **Noch offen:** nur der In-Browser-Redirect-Roundtrip (Authentik-Loginseite → Code-Tausch). 4. Jest-Unit-Tests (Prisma/Sync/Mail gemockt, `npm test` grün, 56 Tests): - `src/wahl/zuteilung.service.spec.ts`: Force-Vorrang, Wunschrunden-Fallback bei voller Kapazität, Unzugeteilt-Fall, Konsolidierung unterbesetzter Workshops, Sync-Capture-Anzahl. @@ -130,7 +130,7 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM ## 8. Nächste Schritte -1. Flutter-Client: ✅ Authentik-PKCE-Login (`oidc.dart`), LT-Admin (KC/Gemeinde), **Wahl-Verwaltung** (Wahlen/Workshops/Zuteilung ausführen + Ergebnis), **Teamer-Verwaltung** (Konten + Invites), **Verantwortlichen-Selbstregistrierung**. 🔜 Browser-OIDC-Roundtrip einmal live testen (Redirect + Testaccount durchklicken); Wahl schließen/öffnen + Force-Zuteilung im UI; Datei-Upload für LT; dann Mobile/Desktop-Targets. +1. Flutter-Client: ✅ Authentik-PKCE-Login (`oidc.dart`), LT-Admin (KC/Gemeinde), **Wahl-Verwaltung** (Wahlen/Workshops/Zuteilung + Ergebnis, öffnen/schließen, **Force-Zuteilung**, **CSV-Export** als Browser-Download), **Teamer-Verwaltung** (Konten + Invites), **Verantwortlichen-Selbstregistrierung**, **LT-Datei-Upload** (nativer `` + Sichtbarkeitsstufe). 🔜 Browser-OIDC-Roundtrip einmal live durchklicken; Mobile/Desktop-Targets (`flutter create --platforms=…`, Toolchains fehlen); Push. 2. Authentik-Provider so konfigurieren, dass das Access-Token den `groups`-Claim trägt (Scope „groups"), LT-Gruppe = `AUTHENTIK_LEITUNGSTEAM_GROUP`. (Ops/Config, Code ist fertig.) 3. SMTP konfigurieren (`MAIL_PROVIDER=smtp` + `SMTP_*`/`MAIL_FROM`/`APP_BASE_URL`) und Invite-Mail-Templates finalisieren (aktuell Plain-Text); optional Onboarding-Benachrichtigungen an LT. 4. Push-Benachrichtigungen (FCM/APNs) für Chat/Ankündigungen. From cc663c7e17f67ed60d49defbd3070b057441c779 Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 11:42:34 +0200 Subject: [PATCH 25/37] feat(backend): push notifications module (FCM HTTP v1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/.env.example | 8 + .../migration.sql | 21 +++ backend/prisma/schema.prisma | 26 ++- backend/src/app.module.ts | 2 + backend/src/chat/chat.service.ts | 25 ++- backend/src/push/dto/register-device.dto.ts | 16 ++ backend/src/push/fcm-push.provider.ts | 110 +++++++++++++ backend/src/push/log-push.provider.ts | 15 ++ backend/src/push/push-provider.ts | 20 +++ backend/src/push/push.controller.ts | 29 ++++ backend/src/push/push.module.ts | 27 ++++ backend/src/push/push.service.ts | 151 ++++++++++++++++++ backend/src/sync/sync.service.ts | 1 + 13 files changed, 446 insertions(+), 5 deletions(-) create mode 100644 backend/prisma/migrations/20260910093835_device_tokens/migration.sql create mode 100644 backend/src/push/dto/register-device.dto.ts create mode 100644 backend/src/push/fcm-push.provider.ts create mode 100644 backend/src/push/log-push.provider.ts create mode 100644 backend/src/push/push-provider.ts create mode 100644 backend/src/push/push.controller.ts create mode 100644 backend/src/push/push.module.ts create mode 100644 backend/src/push/push.service.ts diff --git a/backend/.env.example b/backend/.env.example index 9fc0c44..8050262 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -31,6 +31,14 @@ SMTP_SECURE="false" SMTP_USER="" SMTP_PASS="" +# Push: defaults to "log" (no delivery). Set PUSH_PROVIDER=fcm plus +# FCM_PROJECT_ID and GOOGLE_APPLICATION_CREDENTIALS (path to a Firebase +# service-account JSON with the "Firebase Cloud Messaging API" enabled) to +# send real notifications via FCM HTTP v1. +PUSH_PROVIDER="log" +FCM_PROJECT_ID="konfi-castle-app" +GOOGLE_APPLICATION_CREDENTIALS="/absolute/path/to/serviceAccount.json" + # File storage: defaults to Nextcloud via WebDAV; set STORAGE_PROVIDER=s3 to # use an S3-compatible bucket instead (see S3_* vars below). STORAGE_PROVIDER="webdav" diff --git a/backend/prisma/migrations/20260910093835_device_tokens/migration.sql b/backend/prisma/migrations/20260910093835_device_tokens/migration.sql new file mode 100644 index 0000000..ff7c9c9 --- /dev/null +++ b/backend/prisma/migrations/20260910093835_device_tokens/migration.sql @@ -0,0 +1,21 @@ +-- CreateTable +CREATE TABLE "DeviceToken" ( + "id" TEXT NOT NULL, + "token" TEXT NOT NULL, + "platform" TEXT NOT NULL, + "userId" TEXT, + "guestAccountId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "DeviceToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "DeviceToken_token_key" ON "DeviceToken"("token"); + +-- AddForeignKey +ALTER TABLE "DeviceToken" ADD CONSTRAINT "DeviceToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DeviceToken" ADD CONSTRAINT "DeviceToken_guestAccountId_fkey" FOREIGN KEY ("guestAccountId") REFERENCES "GuestAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 5b89be4..782c69f 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -78,6 +78,7 @@ model User { memberships Membership[] messages ChatMessage[] chatParticipations ChatParticipant[] + deviceTokens DeviceToken[] } /// Scopes a User's role to a specific Kc (and Gemeinde, if applicable). @@ -107,10 +108,27 @@ model GuestAccount { 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[] + kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) + gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade) + messages ChatMessage[] + teilnehmer Teilnehmer[] + deviceTokens DeviceToken[] +} + +/// A push-notification target (FCM registration token) bound to whoever +/// registered it — a team `User` or a `GuestAccount`. Replicated so a +/// notification can be sent from either server. +model DeviceToken { + id String @id @default(cuid()) + token String @unique + platform String + userId String? + guestAccountId String? + createdAt DateTime @default(now()) + lastSeenAt DateTime @default(now()) + + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + guestAccount GuestAccount? @relation(fields: [guestAccountId], references: [id], onDelete: Cascade) } /// Invitation issued by a Gemeinde Verantwortliche/r so new Gemeinde Teamer diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index ffaeec6..bbf2da8 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -5,6 +5,7 @@ import { existsSync } from 'fs'; import { join } from 'path'; import { PrismaModule } from './prisma/prisma.module'; import { MailModule } from './mail/mail.module'; +import { PushModule } from './push/push.module'; import { AuthModule } from './auth/auth.module'; import { KcModule } from './kc/kc.module'; import { GemeindeModule } from './gemeinde/gemeinde.module'; @@ -35,6 +36,7 @@ const webRoot = }), PrismaModule, MailModule, + PushModule, SyncModule, AuthModule, KcModule, diff --git a/backend/src/chat/chat.service.ts b/backend/src/chat/chat.service.ts index df9ef4d..5e89d4e 100644 --- a/backend/src/chat/chat.service.ts +++ b/backend/src/chat/chat.service.ts @@ -4,16 +4,25 @@ import { PrismaClient } from '../prisma/prisma.module'; import { AuthenticatedUser } from '../auth/authenticated-request'; import { GuestJwtPayload } from '../auth/guest-auth.service'; import { SyncService } from '../sync/sync.service'; +import { PushService } from '../push/push.service'; export type ChatCaller = | { kind: 'user'; user: AuthenticatedUser } | { kind: 'guest'; guest: GuestJwtPayload }; +const CHANNEL_TITLES: Record = { + [ChatChannelType.GEMEINDE_GRUPPE]: 'Gemeinde-Gruppe', + [ChatChannelType.DIREKT]: 'Direktnachricht', + [ChatChannelType.LT_UEBERGREIFEND]: 'Leitungsteam', + [ChatChannelType.BROADCAST]: 'Ankündigung', +}; + @Injectable() export class ChatService { constructor( private readonly prisma: PrismaClient, private readonly sync: SyncService, + private readonly push: PushService, ) {} async createChannel(kcId: string, type: ChatChannelType, gemeindeId?: string) { @@ -142,7 +151,7 @@ export class ChatService { } async sendMessage(channelId: string, caller: ChatCaller, body: string) { - await this.assertCanWrite(channelId, caller); + const channel = await this.assertCanWrite(channelId, caller); const message = await this.prisma.chatMessage.create({ data: { channelId, @@ -152,6 +161,20 @@ export class ChatService { }, }); await this.sync.capture('ChatMessage', SyncOperation.CREATE, message.id, message); + + void this.push.notifyChannel( + channelId, + { + title: CHANNEL_TITLES[channel?.type ?? ChatChannelType.GEMEINDE_GRUPPE], + body: body.length > 140 ? `${body.slice(0, 137)}…` : body, + data: { channelId }, + }, + { + userId: caller.kind === 'user' ? caller.user.userId : null, + guestId: caller.kind === 'guest' ? caller.guest.guestId : null, + }, + ); + return message; } diff --git a/backend/src/push/dto/register-device.dto.ts b/backend/src/push/dto/register-device.dto.ts new file mode 100644 index 0000000..d8ea4fe --- /dev/null +++ b/backend/src/push/dto/register-device.dto.ts @@ -0,0 +1,16 @@ +import { IsIn, IsNotEmpty, IsString } from 'class-validator'; + +export class RegisterDeviceDto { + @IsString() + @IsNotEmpty() + token!: string; + + @IsIn(['web', 'android', 'ios']) + platform!: 'web' | 'android' | 'ios'; +} + +export class UnregisterDeviceDto { + @IsString() + @IsNotEmpty() + token!: string; +} diff --git a/backend/src/push/fcm-push.provider.ts b/backend/src/push/fcm-push.provider.ts new file mode 100644 index 0000000..63ff3fc --- /dev/null +++ b/backend/src/push/fcm-push.provider.ts @@ -0,0 +1,110 @@ +import { readFileSync } from 'fs'; +import { Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as jwt from 'jsonwebtoken'; +import { PushNotification, PushProvider, PushResult } from './push-provider'; + +interface ServiceAccount { + client_email: string; + private_key: string; + token_uri: string; +} + +/// Firebase Cloud Messaging HTTP v1. Auth is a service-account JWT exchanged +/// for an OAuth access token (no google-auth-library dependency — jsonwebtoken +/// is already here). Delivery failures are logged and swallowed. +export class FcmPushProvider implements PushProvider { + private readonly logger = new Logger('PushProvider'); + private readonly projectId: string; + private readonly sa: ServiceAccount; + private accessToken: { value: string; expiresAt: number } | null = null; + + constructor(config: ConfigService) { + this.projectId = config.getOrThrow('FCM_PROJECT_ID'); + const path = config.getOrThrow('GOOGLE_APPLICATION_CREDENTIALS'); + this.sa = JSON.parse(readFileSync(path, 'utf8')) as ServiceAccount; + } + + async sendToTokens(tokens: string[], n: PushNotification): Promise { + if (tokens.length === 0) return { sent: 0, invalidTokens: [] }; + let accessToken: string; + try { + accessToken = await this.getAccessToken(); + } catch (err) { + this.logger.error(`FCM auth failed: ${(err as Error).message}`); + return { sent: 0, invalidTokens: [] }; + } + + const url = `https://fcm.googleapis.com/v1/projects/${this.projectId}/messages:send`; + const invalidTokens: string[] = []; + let sent = 0; + + await Promise.all( + tokens.map(async (token) => { + try { + const res = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + message: { + token, + notification: { title: n.title, body: n.body }, + data: n.data, + webpush: { fcmOptions: {} }, + }, + }), + }); + if (res.ok) { + sent += 1; + } else if (res.status === 404 || res.status === 400) { + invalidTokens.push(token); + } else { + this.logger.warn(`FCM send ${res.status}: ${await res.text()}`); + } + } catch (err) { + this.logger.warn(`FCM send error: ${(err as Error).message}`); + } + }), + ); + + return { sent, invalidTokens }; + } + + private async getAccessToken(): Promise { + if (this.accessToken && this.accessToken.expiresAt > Date.now() + 60_000) { + return this.accessToken.value; + } + const now = Math.floor(Date.now() / 1000); + const assertion = jwt.sign( + { + iss: this.sa.client_email, + scope: 'https://www.googleapis.com/auth/firebase.messaging', + aud: this.sa.token_uri, + iat: now, + exp: now + 3600, + }, + this.sa.private_key, + { algorithm: 'RS256' }, + ); + const res = await fetch(this.sa.token_uri, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion, + }), + }); + if (!res.ok) { + throw new Error(`token exchange ${res.status}: ${await res.text()}`); + } + const json = (await res.json()) as { access_token: string; expires_in: number }; + this.accessToken = { + value: json.access_token, + expiresAt: Date.now() + json.expires_in * 1000, + }; + return json.access_token; + } +} diff --git a/backend/src/push/log-push.provider.ts b/backend/src/push/log-push.provider.ts new file mode 100644 index 0000000..b08baee --- /dev/null +++ b/backend/src/push/log-push.provider.ts @@ -0,0 +1,15 @@ +import { Logger } from '@nestjs/common'; +import { PushNotification, PushProvider, PushResult } from './push-provider'; + +/// Default provider: doesn't send, just logs. Keeps the app working before +/// FCM credentials are configured. +export class LogPushProvider implements PushProvider { + private readonly logger = new Logger('PushProvider'); + + async sendToTokens(tokens: string[], n: PushNotification): Promise { + this.logger.log( + `[log-only] would push "${n.title}" to ${tokens.length} device(s): ${n.body}`, + ); + return { sent: 0, invalidTokens: [] }; + } +} diff --git a/backend/src/push/push-provider.ts b/backend/src/push/push-provider.ts new file mode 100644 index 0000000..0d4c41e --- /dev/null +++ b/backend/src/push/push-provider.ts @@ -0,0 +1,20 @@ +/// Abstraction over the push backend. Default is a no-send provider that only +/// logs; PUSH_PROVIDER=fcm switches to Firebase Cloud Messaging (HTTP v1). +export interface PushNotification { + title: string; + body: string; + data?: Record; +} + +export interface PushResult { + sent: number; + /// Tokens FCM reported as permanently invalid — the caller prunes them. + invalidTokens: string[]; +} + +export interface PushProvider { + /// Best-effort: never throws for delivery problems. + sendToTokens(tokens: string[], notification: PushNotification): Promise; +} + +export const PUSH_PROVIDER = Symbol('PUSH_PROVIDER'); diff --git a/backend/src/push/push.controller.ts b/backend/src/push/push.controller.ts new file mode 100644 index 0000000..7b05715 --- /dev/null +++ b/backend/src/push/push.controller.ts @@ -0,0 +1,29 @@ +import { Body, Controller, Post, Req, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { PushService } from './push.service'; +import { RegisterDeviceDto, UnregisterDeviceDto } from './dto/register-device.dto'; +// Import the util directly (not via chat.service) to keep the module graph acyclic. +import { resolveChatCaller } from '../chat/caller.util'; +import { AuthenticatedRequest } from '../auth/authenticated-request'; +import { GuestJwtPayload } from '../auth/guest-auth.service'; + +type PushRequest = AuthenticatedRequest & { + user?: AuthenticatedRequest['user'] | GuestJwtPayload; +}; + +@Controller('push') +export class PushController { + constructor(private readonly push: PushService) {} + + @Post('register') + @UseGuards(AuthGuard(['authentik', 'team', 'guest'])) + register(@Body() dto: RegisterDeviceDto, @Req() req: PushRequest) { + return this.push.register(dto.token, dto.platform, resolveChatCaller(req.user!)); + } + + @Post('unregister') + @UseGuards(AuthGuard(['authentik', 'team', 'guest'])) + unregister(@Body() dto: UnregisterDeviceDto) { + return this.push.unregister(dto.token); + } +} diff --git a/backend/src/push/push.module.ts b/backend/src/push/push.module.ts new file mode 100644 index 0000000..fe3e15c --- /dev/null +++ b/backend/src/push/push.module.ts @@ -0,0 +1,27 @@ +import { Global, Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PUSH_PROVIDER } from './push-provider'; +import { LogPushProvider } from './log-push.provider'; +import { FcmPushProvider } from './fcm-push.provider'; +import { PushService } from './push.service'; +import { PushController } from './push.controller'; + +/// Global so ChatService can inject PushService. Provider defaults to +/// log-only; PUSH_PROVIDER=fcm switches to Firebase Cloud Messaging. +@Global() +@Module({ + controllers: [PushController], + providers: [ + PushService, + { + provide: PUSH_PROVIDER, + inject: [ConfigService], + useFactory: (config: ConfigService) => + config.get('PUSH_PROVIDER') === 'fcm' + ? new FcmPushProvider(config) + : new LogPushProvider(), + }, + ], + exports: [PushService], +}) +export class PushModule {} diff --git a/backend/src/push/push.service.ts b/backend/src/push/push.service.ts new file mode 100644 index 0000000..d68a9ab --- /dev/null +++ b/backend/src/push/push.service.ts @@ -0,0 +1,151 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { ChatChannelType, SyncOperation } from '@prisma/client'; +import { PrismaClient } from '../prisma/prisma.module'; +import { SyncService } from '../sync/sync.service'; +import type { ChatCaller } from '../chat/chat.service'; +import { PUSH_PROVIDER, PushNotification, PushProvider } from './push-provider'; + +@Injectable() +export class PushService { + private readonly logger = new Logger(PushService.name); + + constructor( + private readonly prisma: PrismaClient, + private readonly sync: SyncService, + @Inject(PUSH_PROVIDER) private readonly provider: PushProvider, + ) {} + + /// Upsert a device token for the current caller (team user or guest). + async register(token: string, platform: string, caller: ChatCaller) { + const owner = + caller.kind === 'user' + ? { userId: caller.user.userId, guestAccountId: null } + : { userId: null, guestAccountId: caller.guest.guestId }; + const row = await this.prisma.deviceToken.upsert({ + where: { token }, + create: { token, platform, ...owner }, + update: { platform, lastSeenAt: new Date(), ...owner }, + }); + await this.sync.capture('DeviceToken', SyncOperation.UPDATE, row.id, row); + return { ok: true }; + } + + async unregister(token: string) { + const existing = await this.prisma.deviceToken.findUnique({ where: { token } }); + if (!existing) return { ok: true }; + await this.prisma.deviceToken.delete({ where: { token } }); + await this.sync.capture('DeviceToken', SyncOperation.DELETE, existing.id, { + id: existing.id, + }); + return { ok: true }; + } + + /// Fan a chat message out as a push to everyone who can read the channel, + /// minus the sender. Best-effort — never throws into the caller. + async notifyChannel( + channelId: string, + notification: PushNotification, + exclude: { userId?: string | null; guestId?: string | null } = {}, + ): Promise { + try { + const channel = await this.prisma.chatChannel.findUnique({ + where: { id: channelId }, + include: { participants: { select: { userId: true } } }, + }); + if (!channel) return; + + const { userIds, guestIds } = await this.audience(channel); + const tokens = await this.prisma.deviceToken.findMany({ + where: { + OR: [ + userIds.length ? { userId: { in: userIds } } : undefined, + guestIds.length ? { guestAccountId: { in: guestIds } } : undefined, + ].filter(Boolean) as object[], + NOT: { + OR: [ + exclude.userId ? { userId: exclude.userId } : undefined, + exclude.guestId ? { guestAccountId: exclude.guestId } : undefined, + ].filter(Boolean) as object[], + }, + }, + select: { token: true }, + }); + if (tokens.length === 0) return; + + const { invalidTokens } = await this.provider.sendToTokens( + tokens.map((t) => t.token), + notification, + ); + if (invalidTokens.length) { + await this.prisma.deviceToken.deleteMany({ + where: { token: { in: invalidTokens } }, + }); + } + } catch (err) { + this.logger.warn(`notifyChannel failed: ${(err as Error).message}`); + } + } + + private async audience(channel: { + kcId: string; + type: ChatChannelType; + gemeindeId: string | null; + participants: { userId: string }[]; + }): Promise<{ userIds: string[]; guestIds: string[] }> { + if (channel.type === ChatChannelType.DIREKT) { + return { userIds: channel.participants.map((p) => p.userId), guestIds: [] }; + } + + const ltUsers = await this.prisma.user.findMany({ + where: { + OR: [ + { isLeitungsteam: true }, + { memberships: { some: { kcId: channel.kcId, role: 'LEITUNGSTEAM' } } }, + ], + }, + select: { id: true }, + }); + const ltIds = ltUsers.map((u) => u.id); + + if (channel.type === ChatChannelType.LT_UEBERGREIFEND) { + return { userIds: ltIds, guestIds: [] }; + } + + if (channel.type === ChatChannelType.GEMEINDE_GRUPPE) { + const [members, guests] = await Promise.all([ + this.prisma.membership.findMany({ + where: { + kcId: channel.kcId, + gemeindeId: channel.gemeindeId, + status: 'ACTIVE', + }, + select: { userId: true }, + }), + this.prisma.guestAccount.findMany({ + where: { kcId: channel.kcId, gemeindeId: channel.gemeindeId }, + select: { id: true }, + }), + ]); + return { + userIds: [...new Set([...ltIds, ...members.map((m) => m.userId)])], + guestIds: guests.map((g) => g.id), + }; + } + + // BROADCAST: everyone in the KC. + const [members, guests] = await Promise.all([ + this.prisma.membership.findMany({ + where: { kcId: channel.kcId, status: 'ACTIVE' }, + select: { userId: true }, + }), + this.prisma.guestAccount.findMany({ + where: { kcId: channel.kcId }, + select: { id: true }, + }), + ]); + return { + userIds: [...new Set([...ltIds, ...members.map((m) => m.userId)])], + guestIds: guests.map((g) => g.id), + }; + } +} diff --git a/backend/src/sync/sync.service.ts b/backend/src/sync/sync.service.ts index 3d2746e..5e896a3 100644 --- a/backend/src/sync/sync.service.ts +++ b/backend/src/sync/sync.service.ts @@ -18,6 +18,7 @@ const SYNCED_MODELS = [ 'File', 'ChatChannel', 'ChatMessage', + 'DeviceToken', ] as const; export type SyncedModel = (typeof SYNCED_MODELS)[number]; From 530be36458764283a6bcbcf3f068f17880682236 Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 11:45:01 +0200 Subject: [PATCH 26/37] feat(client): FCM web push registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/app/lib/api.dart | 15 +++++++++++ client/app/lib/browser_stub.dart | 3 +++ client/app/lib/browser_web.dart | 14 +++++++++++ client/app/web/firebase-messaging-sw.js | 21 ++++++++++++++++ client/app/web/index.html | 33 +++++++++++++++++++++++++ 5 files changed, 86 insertions(+) create mode 100644 client/app/web/firebase-messaging-sw.js diff --git a/client/app/lib/api.dart b/client/app/lib/api.dart index da4acc0..0305518 100644 --- a/client/app/lib/api.dart +++ b/client/app/lib/api.dart @@ -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 @@ -662,6 +663,10 @@ class Api { 'gemeindeId': gemeindeId, }) as Map; + // --- push --- + Future 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> channels(String kcId) async { final list = await _get('/chat/$kcId/channels') as List; @@ -746,6 +751,16 @@ class AppState extends ChangeNotifier { } _authError = null; notifyListeners(); + _registerForPush(); // best-effort, fire and forget + } + + Future _registerForPush() async { + try { + final pushToken = await browser.getPushToken(); + if (pushToken != null) await _api.registerDevice(pushToken); + } catch (_) { + // push is optional + } } Future _clear(SharedPreferences prefs) async { diff --git a/client/app/lib/browser_stub.dart b/client/app/lib/browser_stub.dart index 79f4cfa..1cce5fb 100644 --- a/client/app/lib/browser_stub.dart +++ b/client/app/lib/browser_stub.dart @@ -12,3 +12,6 @@ Future<({String name, List 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 getPushToken() async => null; diff --git a/client/app/lib/browser_web.dart b/client/app/lib/browser_web.dart index 10e507d..4d04e13 100644 --- a/client/app/lib/browser_web.dart +++ b/client/app/lib/browser_web.dart @@ -3,6 +3,20 @@ 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 _kcGetPushToken(); + +Future 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. diff --git a/client/app/web/firebase-messaging-sw.js b/client/app/web/firebase-messaging-sw.js new file mode 100644 index 0000000..97a9097 --- /dev/null +++ b/client/app/web/firebase-messaging-sw.js @@ -0,0 +1,21 @@ +// Background handler for FCM web push. Keep the config in sync with +// window.KC_FIREBASE in index.html (a service worker can't read window). +// Fill in apiKey / appId from the Firebase console before enabling push. +importScripts('https://www.gstatic.com/firebasejs/10.13.2/firebase-app-compat.js'); +importScripts('https://www.gstatic.com/firebasejs/10.13.2/firebase-messaging-compat.js'); + +firebase.initializeApp({ + apiKey: 'REPLACE_ME', + projectId: 'konfi-castle-app', + messagingSenderId: '307226979593', + appId: 'REPLACE_ME', +}); + +firebase.messaging().onBackgroundMessage(function (payload) { + const n = payload.notification || {}; + self.registration.showNotification(n.title || 'KC-App', { + body: n.body || '', + icon: '/icons/Icon-192.png', + data: payload.data || {}, + }); +}); diff --git a/client/app/web/index.html b/client/app/web/index.html index 400faaa..320c4af 100644 --- a/client/app/web/index.html +++ b/client/app/web/index.html @@ -31,6 +31,39 @@ KC-App + + + + + + + diff --git a/client/web/style.css b/client/web/style.css index d0733d7..704b9d1 100644 --- a/client/web/style.css +++ b/client/web/style.css @@ -1,62 +1,388 @@ -body { - font-family: system-ui, sans-serif; - max-width: 640px; - margin: 2rem auto; - padding: 0 1rem; - color: #1a1a1a; +:root { + --bg: #0f1220; + --bg-soft: #171b2e; + --card: #1c2138; + --card-border: #2a3050; + --text: #eef0fb; + --text-muted: #9aa1c4; + --accent: #6c8cff; + --accent-strong: #8f6cff; + --accent-text: #ffffff; + --success: #4ade80; + --danger: #f87171; + --radius: 14px; + color-scheme: dark; } -header { - margin-bottom: 2rem; +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; + line-height: 1.5; +} + +.bg-decor { + position: fixed; + inset: 0; + z-index: -1; + background: + radial-gradient(600px circle at 15% -10%, rgba(108, 140, 255, 0.25), transparent 60%), + radial-gradient(500px circle at 100% 10%, rgba(143, 108, 255, 0.18), transparent 55%), + var(--bg); +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + max-width: 640px; + margin: 0 auto; + padding: 2rem 1.25rem 1rem; +} + +.brand { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.brand-mark { + display: grid; + place-items: center; + width: 44px; + height: 44px; + border-radius: 12px; + background: linear-gradient(135deg, var(--accent), var(--accent-strong)); + color: white; + font-weight: 700; + font-size: 0.95rem; + letter-spacing: 0.02em; + box-shadow: 0 8px 20px rgba(108, 140, 255, 0.35); +} + +.brand h1 { + margin: 0; + font-size: 1.25rem; + font-weight: 700; } .subtitle { - color: #666; - font-size: 0.9rem; + margin: 0.1rem 0 0; + color: var(--text-muted); + font-size: 0.85rem; } -section { - margin-bottom: 2rem; +.badge { + font-size: 0.75rem; + padding: 0.35rem 0.7rem; + border-radius: 999px; + border: 1px solid var(--card-border); + white-space: nowrap; } -form, -#app-section > div { +.badge-muted { + color: var(--text-muted); + background: rgba(255, 255, 255, 0.03); +} + +.badge-online { + color: var(--success); + background: rgba(74, 222, 128, 0.12); + border-color: rgba(74, 222, 128, 0.35); +} + +main { + max-width: 640px; + margin: 0 auto; + padding: 0.5rem 1.25rem 3rem; +} + +.stack { display: flex; flex-direction: column; - gap: 0.5rem; - max-width: 360px; - margin-bottom: 1rem; + gap: 1.25rem; +} + +.card { + background: var(--card); + border: 1px solid var(--card-border); + border-radius: var(--radius); + padding: 1.5rem; + margin-bottom: 1.25rem; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25); +} + +.stack .card { + margin-bottom: 0; +} + +.card-header { + margin-bottom: 1.1rem; +} + +.card-header.row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.card-header h2 { + margin: 0 0 0.25rem; + font-size: 1.05rem; + font-weight: 650; +} + +.card-hint { + margin: 0; + color: var(--text-muted); + font-size: 0.85rem; +} + +.form-grid { + display: flex; + flex-direction: column; + gap: 0.9rem; +} + +.form-inline { + flex-direction: row; + align-items: flex-end; + flex-wrap: wrap; +} + +.form-inline label { + flex: 1; + min-width: 160px; } label { display: flex; flex-direction: column; - font-size: 0.9rem; - gap: 0.25rem; + gap: 0.35rem; + font-size: 0.82rem; + color: var(--text-muted); } input { - padding: 0.4rem; - font-size: 1rem; + padding: 0.65rem 0.75rem; + font-size: 0.95rem; + border-radius: 10px; + border: 1px solid var(--card-border); + background: var(--bg-soft); + color: var(--text); + outline: none; + transition: border-color 0.15s ease, box-shadow 0.15s ease; } -button { - padding: 0.5rem; +input::placeholder { + color: #6b7194; +} + +input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(108, 140, 255, 0.2); +} + +.btn { + appearance: none; + border: none; + border-radius: 10px; + padding: 0.7rem 1.1rem; + font-size: 0.92rem; + font-weight: 600; + cursor: pointer; + transition: transform 0.08s ease, opacity 0.15s ease, box-shadow 0.15s ease; +} + +.btn:active { + transform: translateY(1px); +} + +.btn-primary { + background: linear-gradient(135deg, var(--accent), var(--accent-strong)); + color: var(--accent-text); + box-shadow: 0 8px 20px rgba(108, 140, 255, 0.3); +} + +.btn-primary:hover { + opacity: 0.92; +} + +.btn-ghost { + background: rgba(255, 255, 255, 0.04); + color: var(--text); + border: 1px solid var(--card-border); + padding: 0.5rem 0.85rem; + font-size: 0.82rem; +} + +.btn-ghost:hover { + background: rgba(255, 255, 255, 0.08); +} + +.status { + min-height: 1.1rem; + margin: 0.75rem 0 0; + font-size: 0.85rem; + color: var(--text-muted); +} + +.status.status-ok { + color: var(--success); +} + +.status.status-error { + color: var(--danger); +} + +.list { + list-style: none; + margin: 0; + padding: 0; + border: 1px solid var(--card-border); + border-radius: 10px; + max-height: 220px; + overflow-y: auto; + background: var(--bg-soft); +} + +.list li { + padding: 0.6rem 0.85rem; + border-bottom: 1px solid var(--card-border); + font-size: 0.88rem; +} + +.list li:last-child { + border-bottom: none; +} + +.list-empty { + color: var(--text-muted); + font-style: italic; +} + +.list-files li { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.list-files li::before { + content: "📄"; +} + +.list-chat li { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.list-chat li .chat-body { + color: var(--text); +} + +.tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.tab-btn { + appearance: none; + border: 1px solid var(--card-border); + background: rgba(255, 255, 255, 0.03); + color: var(--text-muted); + border-radius: 999px; + padding: 0.4rem 0.9rem; + font-size: 0.82rem; cursor: pointer; } -#chat-log, -#file-list { - list-style: none; - padding: 0; - border: 1px solid #ddd; - border-radius: 4px; - max-height: 200px; - overflow-y: auto; +.tab-btn.active { + color: var(--accent-text); + background: linear-gradient(135deg, var(--accent), var(--accent-strong)); + border-color: transparent; } -#chat-log li, -#file-list li { - padding: 0.4rem 0.6rem; - border-bottom: 1px solid #eee; +.two-col { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin: 0.75rem 0 1rem; +} + +@media (max-width: 480px) { + .two-col { + grid-template-columns: 1fr; + } +} + +.list-picker { + max-height: 180px; +} + +.list-picker li { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; +} + +.list-picker li input { + padding: 0; + width: auto; +} + +.list-picker li.picked { + background: rgba(108, 140, 255, 0.12); +} + +.list-channels li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + cursor: pointer; +} + +.list-channels li:hover { + background: rgba(255, 255, 255, 0.04); +} + +.chip { + font-size: 0.7rem; + padding: 0.15rem 0.5rem; + border-radius: 999px; + border: 1px solid var(--card-border); + color: var(--text-muted); + white-space: nowrap; +} + +footer { + max-width: 640px; + margin: 0 auto; + padding: 1rem 1.25rem 2rem; + text-align: center; + color: var(--text-muted); + font-size: 0.75rem; +} + +@media (max-width: 480px) { + .topbar { + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + } + + .card { + padding: 1.1rem; + } }