From 8ec127c0fbd7370119e2f69840995b02b992439a Mon Sep 17 00:00:00 2001 From: linus Date: Wed, 9 Sep 2026 16:23:45 +0200 Subject: [PATCH 01/28] 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 --- .env.example | 26 + README.md | 45 +- package-lock.json | 935 ++++++++++++++++++- package.json | 18 +- prisma/schema.prisma | 175 ++-- src/app.module.ts | 16 + src/auth/auth.module.ts | 5 +- src/auth/authenticated-request.ts | 6 + src/auth/guest-auth.service.ts | 4 + src/auth/guest-jwt.strategy.ts | 21 + src/auth/token-verification.service.ts | 74 ++ src/chat/caller.util.ts | 13 + src/chat/chat.controller.ts | 45 + src/chat/chat.gateway.ts | 100 ++ src/chat/chat.module.ts | 12 + src/chat/chat.service.ts | 165 ++++ src/chat/dto/create-channel.dto.ts | 11 + src/chat/dto/create-direct-channel.dto.ts | 11 + src/files/dto/upload-file.dto.ts | 7 + src/files/files.controller.ts | 73 ++ src/files/files.module.ts | 24 + src/files/files.service.ts | 53 ++ src/files/storage/s3-storage.provider.ts | 53 ++ src/files/storage/storage-provider.ts | 10 + src/files/storage/webdav-storage.provider.ts | 37 + src/files/visibility.util.ts | 21 + src/kc/kc.service.ts | 13 +- src/main.ts | 3 + src/sync/dto/ingest-entries.dto.ts | 7 + src/sync/sync-scheduler.service.ts | 32 + src/sync/sync-secret.guard.ts | 18 + src/sync/sync.controller.ts | 45 + src/sync/sync.module.ts | 16 + src/sync/sync.service.ts | 154 +++ src/wahl/dto/create-force-zuteilung.dto.ts | 11 + src/wahl/dto/create-wahl.dto.ts | 19 + src/wahl/dto/create-workshop.dto.ts | 15 + src/wahl/dto/submit-teilnehmer.dto.ts | 11 + src/wahl/wahl.controller.ts | 107 +++ src/wahl/wahl.module.ts | 10 + src/wahl/wahl.service.ts | 93 ++ src/wahl/zuteilung.service.ts | 212 +++++ 42 files changed, 2648 insertions(+), 78 deletions(-) create mode 100644 src/auth/guest-jwt.strategy.ts create mode 100644 src/auth/token-verification.service.ts create mode 100644 src/chat/caller.util.ts create mode 100644 src/chat/chat.controller.ts create mode 100644 src/chat/chat.gateway.ts create mode 100644 src/chat/chat.module.ts create mode 100644 src/chat/chat.service.ts create mode 100644 src/chat/dto/create-channel.dto.ts create mode 100644 src/chat/dto/create-direct-channel.dto.ts create mode 100644 src/files/dto/upload-file.dto.ts create mode 100644 src/files/files.controller.ts create mode 100644 src/files/files.module.ts create mode 100644 src/files/files.service.ts create mode 100644 src/files/storage/s3-storage.provider.ts create mode 100644 src/files/storage/storage-provider.ts create mode 100644 src/files/storage/webdav-storage.provider.ts create mode 100644 src/files/visibility.util.ts create mode 100644 src/sync/dto/ingest-entries.dto.ts create mode 100644 src/sync/sync-scheduler.service.ts create mode 100644 src/sync/sync-secret.guard.ts create mode 100644 src/sync/sync.controller.ts create mode 100644 src/sync/sync.module.ts create mode 100644 src/sync/sync.service.ts create mode 100644 src/wahl/dto/create-force-zuteilung.dto.ts create mode 100644 src/wahl/dto/create-wahl.dto.ts create mode 100644 src/wahl/dto/create-workshop.dto.ts create mode 100644 src/wahl/dto/submit-teilnehmer.dto.ts create mode 100644 src/wahl/wahl.controller.ts create mode 100644 src/wahl/wahl.module.ts create mode 100644 src/wahl/wahl.service.ts create mode 100644 src/wahl/zuteilung.service.ts diff --git a/.env.example b/.env.example index edea1cb..11e1602 100644 --- a/.env.example +++ b/.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/README.md b/README.md index b8f69ab..93b5912 100644 --- a/README.md +++ b/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/package-lock.json b/package-lock.json index c0bfd1d..31b3040 100644 --- a/package-lock.json +++ b/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/package.json b/package.json index b30c032..35e3b9d 100644 --- a/package.json +++ b/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/prisma/schema.prisma b/prisma/schema.prisma index 8c3469d..112c711 100644 --- a/prisma/schema.prisma +++ b/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/src/app.module.ts b/src/app.module.ts index b2755e6..f916aaa 100644 --- a/src/app.module.ts +++ b/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/src/auth/auth.module.ts b/src/auth/auth.module.ts index db06d1f..706b234 100644 --- a/src/auth/auth.module.ts +++ b/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/src/auth/authenticated-request.ts b/src/auth/authenticated-request.ts index daec849..aa4ed20 100644 --- a/src/auth/authenticated-request.ts +++ b/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/src/auth/guest-auth.service.ts b/src/auth/guest-auth.service.ts index eafc7b8..fc6eed6 100644 --- a/src/auth/guest-auth.service.ts +++ b/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/src/auth/guest-jwt.strategy.ts b/src/auth/guest-jwt.strategy.ts new file mode 100644 index 0000000..d149605 --- /dev/null +++ b/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/src/auth/token-verification.service.ts b/src/auth/token-verification.service.ts new file mode 100644 index 0000000..374725d --- /dev/null +++ b/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/src/chat/caller.util.ts b/src/chat/caller.util.ts new file mode 100644 index 0000000..e40a2d7 --- /dev/null +++ b/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/src/chat/chat.controller.ts b/src/chat/chat.controller.ts new file mode 100644 index 0000000..75370b1 --- /dev/null +++ b/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/src/chat/chat.gateway.ts b/src/chat/chat.gateway.ts new file mode 100644 index 0000000..27978a9 --- /dev/null +++ b/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/src/chat/chat.module.ts b/src/chat/chat.module.ts new file mode 100644 index 0000000..745ac81 --- /dev/null +++ b/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/src/chat/chat.service.ts b/src/chat/chat.service.ts new file mode 100644 index 0000000..df9ef4d --- /dev/null +++ b/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/src/chat/dto/create-channel.dto.ts b/src/chat/dto/create-channel.dto.ts new file mode 100644 index 0000000..c7d37c5 --- /dev/null +++ b/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/src/chat/dto/create-direct-channel.dto.ts b/src/chat/dto/create-direct-channel.dto.ts new file mode 100644 index 0000000..aede567 --- /dev/null +++ b/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/src/files/dto/upload-file.dto.ts b/src/files/dto/upload-file.dto.ts new file mode 100644 index 0000000..6c122e8 --- /dev/null +++ b/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/src/files/files.controller.ts b/src/files/files.controller.ts new file mode 100644 index 0000000..c173666 --- /dev/null +++ b/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/src/files/files.module.ts b/src/files/files.module.ts new file mode 100644 index 0000000..8cf4b02 --- /dev/null +++ b/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/src/files/files.service.ts b/src/files/files.service.ts new file mode 100644 index 0000000..74dd265 --- /dev/null +++ b/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/src/files/storage/s3-storage.provider.ts b/src/files/storage/s3-storage.provider.ts new file mode 100644 index 0000000..4b40687 --- /dev/null +++ b/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/src/files/storage/storage-provider.ts b/src/files/storage/storage-provider.ts new file mode 100644 index 0000000..b05ac5c --- /dev/null +++ b/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/src/files/storage/webdav-storage.provider.ts b/src/files/storage/webdav-storage.provider.ts new file mode 100644 index 0000000..f1210b3 --- /dev/null +++ b/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/src/files/visibility.util.ts b/src/files/visibility.util.ts new file mode 100644 index 0000000..5b099b2 --- /dev/null +++ b/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/src/kc/kc.service.ts b/src/kc/kc.service.ts index 366f3b3..a411c99 100644 --- a/src/kc/kc.service.ts +++ b/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/src/main.ts b/src/main.ts index 1ebda52..15e4af3 100644 --- a/src/main.ts +++ b/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/src/sync/dto/ingest-entries.dto.ts b/src/sync/dto/ingest-entries.dto.ts new file mode 100644 index 0000000..54cc55a --- /dev/null +++ b/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/src/sync/sync-scheduler.service.ts b/src/sync/sync-scheduler.service.ts new file mode 100644 index 0000000..ea56c4f --- /dev/null +++ b/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/src/sync/sync-secret.guard.ts b/src/sync/sync-secret.guard.ts new file mode 100644 index 0000000..3db94ce --- /dev/null +++ b/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/src/sync/sync.controller.ts b/src/sync/sync.controller.ts new file mode 100644 index 0000000..4a06dfc --- /dev/null +++ b/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/src/sync/sync.module.ts b/src/sync/sync.module.ts new file mode 100644 index 0000000..5e47e6a --- /dev/null +++ b/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/src/sync/sync.service.ts b/src/sync/sync.service.ts new file mode 100644 index 0000000..094aab4 --- /dev/null +++ b/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/src/wahl/dto/create-force-zuteilung.dto.ts b/src/wahl/dto/create-force-zuteilung.dto.ts new file mode 100644 index 0000000..e8eefde --- /dev/null +++ b/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/src/wahl/dto/create-wahl.dto.ts b/src/wahl/dto/create-wahl.dto.ts new file mode 100644 index 0000000..ec995bf --- /dev/null +++ b/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/src/wahl/dto/create-workshop.dto.ts b/src/wahl/dto/create-workshop.dto.ts new file mode 100644 index 0000000..a3a4362 --- /dev/null +++ b/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/src/wahl/dto/submit-teilnehmer.dto.ts b/src/wahl/dto/submit-teilnehmer.dto.ts new file mode 100644 index 0000000..b825067 --- /dev/null +++ b/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/src/wahl/wahl.controller.ts b/src/wahl/wahl.controller.ts new file mode 100644 index 0000000..f222678 --- /dev/null +++ b/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/src/wahl/wahl.module.ts b/src/wahl/wahl.module.ts new file mode 100644 index 0000000..8d655ca --- /dev/null +++ b/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/src/wahl/wahl.service.ts b/src/wahl/wahl.service.ts new file mode 100644 index 0000000..290c698 --- /dev/null +++ b/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/src/wahl/zuteilung.service.ts b/src/wahl/zuteilung.service.ts new file mode 100644 index 0000000..b1e4baa --- /dev/null +++ b/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]; +} -- 2.54.0 From 648989a51b9c46ca4624ce03be89e186155b6714 Mon Sep 17 00:00:00 2001 From: linus Date: Wed, 9 Sep 2026 16:25:14 +0200 Subject: [PATCH 02/28] 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 --- README.md | 4 ++ src/app.module.ts | 2 + src/gemeinde/dto/create-gemeinde.dto.ts | 11 ++++ src/gemeinde/dto/update-gemeinde.dto.ts | 7 +++ src/gemeinde/gemeinde.controller.ts | 53 ++++++++++++++++ src/gemeinde/gemeinde.module.ts | 9 +++ src/gemeinde/gemeinde.service.ts | 81 +++++++++++++++++++++++++ 7 files changed, 167 insertions(+) create mode 100644 src/gemeinde/dto/create-gemeinde.dto.ts create mode 100644 src/gemeinde/dto/update-gemeinde.dto.ts create mode 100644 src/gemeinde/gemeinde.controller.ts create mode 100644 src/gemeinde/gemeinde.module.ts create mode 100644 src/gemeinde/gemeinde.service.ts diff --git a/README.md b/README.md index 93b5912..6d347c3 100644 --- a/README.md +++ b/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/src/app.module.ts b/src/app.module.ts index f916aaa..3ddb730 100644 --- a/src/app.module.ts +++ b/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/src/gemeinde/dto/create-gemeinde.dto.ts b/src/gemeinde/dto/create-gemeinde.dto.ts new file mode 100644 index 0000000..e6def00 --- /dev/null +++ b/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/src/gemeinde/dto/update-gemeinde.dto.ts b/src/gemeinde/dto/update-gemeinde.dto.ts new file mode 100644 index 0000000..7714ffd --- /dev/null +++ b/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/src/gemeinde/gemeinde.controller.ts b/src/gemeinde/gemeinde.controller.ts new file mode 100644 index 0000000..5f404ef --- /dev/null +++ b/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/src/gemeinde/gemeinde.module.ts b/src/gemeinde/gemeinde.module.ts new file mode 100644 index 0000000..f383556 --- /dev/null +++ b/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/src/gemeinde/gemeinde.service.ts b/src/gemeinde/gemeinde.service.ts new file mode 100644 index 0000000..5311516 --- /dev/null +++ b/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; + } +} -- 2.54.0 From 081a9aa241e1fe7c1f6d2ddc61a482689caa014d Mon Sep 17 00:00:00 2001 From: linus Date: Wed, 9 Sep 2026 16:29:02 +0200 Subject: [PATCH 03/28] 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 --- src/wahl/zuteilung.service.spec.ts | 222 +++++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 src/wahl/zuteilung.service.spec.ts diff --git a/src/wahl/zuteilung.service.spec.ts b/src/wahl/zuteilung.service.spec.ts new file mode 100644 index 0000000..d160b31 --- /dev/null +++ b/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) }), + ); + }); +}); -- 2.54.0 From d48c07b0e4819762d2b5f07d2967661be037d207 Mon Sep 17 00:00:00 2001 From: linus Date: Wed, 9 Sep 2026 16:50:06 +0200 Subject: [PATCH 04/28] 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 --- .env.example | 3 + README.md | 36 +++- package-lock.json | 18 ++ package.json | 2 + prisma/schema.prisma | 52 ++++- src/app.module.ts | 2 + src/auth/auth.controller.ts | 20 +- src/auth/auth.module.ts | 13 +- src/auth/authenticated-request.ts | 6 +- src/auth/dto/register-teamer.dto.ts | 30 +++ src/auth/dto/team-login.dto.ts | 10 + src/auth/team-auth.service.spec.ts | 232 +++++++++++++++++++++ src/auth/team-auth.service.ts | 160 ++++++++++++++ src/auth/team-jwt.strategy.ts | 26 +++ src/auth/token-verification.service.ts | 9 +- src/chat/chat.controller.ts | 9 +- src/files/files.controller.ts | 4 +- src/sync/sync.service.ts | 3 + src/teamer/dto/create-teamer-invite.dto.ts | 21 ++ src/teamer/dto/create-teamer.dto.ts | 18 ++ src/teamer/teamer.controller.ts | 74 +++++++ src/teamer/teamer.module.ts | 9 + src/teamer/teamer.service.spec.ts | 167 +++++++++++++++ src/teamer/teamer.service.ts | 182 ++++++++++++++++ 24 files changed, 1076 insertions(+), 30 deletions(-) create mode 100644 src/auth/dto/register-teamer.dto.ts create mode 100644 src/auth/dto/team-login.dto.ts create mode 100644 src/auth/team-auth.service.spec.ts create mode 100644 src/auth/team-auth.service.ts create mode 100644 src/auth/team-jwt.strategy.ts create mode 100644 src/teamer/dto/create-teamer-invite.dto.ts create mode 100644 src/teamer/dto/create-teamer.dto.ts create mode 100644 src/teamer/teamer.controller.ts create mode 100644 src/teamer/teamer.module.ts create mode 100644 src/teamer/teamer.service.spec.ts create mode 100644 src/teamer/teamer.service.ts diff --git a/.env.example b/.env.example index 11e1602..bced9a5 100644 --- a/.env.example +++ b/.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/README.md b/README.md index 6d347c3..fd8764e 100644 --- a/README.md +++ b/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/package-lock.json b/package-lock.json index 31b3040..5b3c298 100644 --- a/package-lock.json +++ b/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/package.json b/package.json index 35e3b9d..61c95b8 100644 --- a/package.json +++ b/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/prisma/schema.prisma b/prisma/schema.prisma index 112c711..5812b27 100644 --- a/prisma/schema.prisma +++ b/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/src/app.module.ts b/src/app.module.ts index 3ddb730..806bcf0 100644 --- a/src/app.module.ts +++ b/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/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index d4a1202..370d0d5 100644 --- a/src/auth/auth.controller.ts +++ b/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/src/auth/auth.module.ts b/src/auth/auth.module.ts index 706b234..20d2733 100644 --- a/src/auth/auth.module.ts +++ b/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/src/auth/authenticated-request.ts b/src/auth/authenticated-request.ts index aa4ed20..31e4ea0 100644 --- a/src/auth/authenticated-request.ts +++ b/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/src/auth/dto/register-teamer.dto.ts b/src/auth/dto/register-teamer.dto.ts new file mode 100644 index 0000000..994037d --- /dev/null +++ b/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/src/auth/dto/team-login.dto.ts b/src/auth/dto/team-login.dto.ts new file mode 100644 index 0000000..e4f31af --- /dev/null +++ b/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/src/auth/team-auth.service.spec.ts b/src/auth/team-auth.service.spec.ts new file mode 100644 index 0000000..bff85e3 --- /dev/null +++ b/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/src/auth/team-auth.service.ts b/src/auth/team-auth.service.ts new file mode 100644 index 0000000..5ab4833 --- /dev/null +++ b/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/src/auth/team-jwt.strategy.ts b/src/auth/team-jwt.strategy.ts new file mode 100644 index 0000000..f6896a7 --- /dev/null +++ b/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/src/auth/token-verification.service.ts b/src/auth/token-verification.service.ts index 374725d..6823c52 100644 --- a/src/auth/token-verification.service.ts +++ b/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/src/chat/chat.controller.ts b/src/chat/chat.controller.ts index 75370b1..325068f 100644 --- a/src/chat/chat.controller.ts +++ b/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/src/files/files.controller.ts b/src/files/files.controller.ts index c173666..b7bb5e3 100644 --- a/src/files/files.controller.ts +++ b/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/src/sync/sync.service.ts b/src/sync/sync.service.ts index 094aab4..3d2746e 100644 --- a/src/sync/sync.service.ts +++ b/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/src/teamer/dto/create-teamer-invite.dto.ts b/src/teamer/dto/create-teamer-invite.dto.ts new file mode 100644 index 0000000..b1a331e --- /dev/null +++ b/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/src/teamer/dto/create-teamer.dto.ts b/src/teamer/dto/create-teamer.dto.ts new file mode 100644 index 0000000..e5d4002 --- /dev/null +++ b/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/src/teamer/teamer.controller.ts b/src/teamer/teamer.controller.ts new file mode 100644 index 0000000..ba11723 --- /dev/null +++ b/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/src/teamer/teamer.module.ts b/src/teamer/teamer.module.ts new file mode 100644 index 0000000..7706997 --- /dev/null +++ b/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/src/teamer/teamer.service.spec.ts b/src/teamer/teamer.service.spec.ts new file mode 100644 index 0000000..28a71d9 --- /dev/null +++ b/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/src/teamer/teamer.service.ts b/src/teamer/teamer.service.ts new file mode 100644 index 0000000..e397106 --- /dev/null +++ b/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, + }; +} -- 2.54.0 From 6ed5aa2c7635b15a0744c8649c80addb403b2be3 Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 08:01:19 +0200 Subject: [PATCH 05/28] 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 --- README.md | 16 +- prisma/schema.prisma | 13 +- src/app.module.ts | 2 + src/auth/authentik.strategy.ts | 2 +- 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 ++++++ src/onboarding/onboarding.module.ts | 11 + src/onboarding/onboarding.service.spec.ts | 224 ++++++++++++++++++ src/onboarding/onboarding.service.ts | 155 ++++++++++++ 11 files changed, 524 insertions(+), 13 deletions(-) create mode 100644 src/onboarding/dto/register-verantwortliche.dto.ts create mode 100644 src/onboarding/onboarding.controller.ts create mode 100644 src/onboarding/onboarding.module.ts create mode 100644 src/onboarding/onboarding.service.spec.ts create mode 100644 src/onboarding/onboarding.service.ts diff --git a/README.md b/README.md index fd8764e..d005f65 100644 --- a/README.md +++ b/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/prisma/schema.prisma b/prisma/schema.prisma index 5812b27..460f05f 100644 --- a/prisma/schema.prisma +++ b/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/src/app.module.ts b/src/app.module.ts index 806bcf0..ffffde1 100644 --- a/src/app.module.ts +++ b/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/src/auth/authentik.strategy.ts b/src/auth/authentik.strategy.ts index 3cf9465..f5b88b8 100644 --- a/src/auth/authentik.strategy.ts +++ b/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/src/auth/team-auth.service.ts b/src/auth/team-auth.service.ts index 5ab4833..048333e 100644 --- a/src/auth/team-auth.service.ts +++ b/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/src/auth/token-verification.service.ts b/src/auth/token-verification.service.ts index 6823c52..73a09b5 100644 --- a/src/auth/token-verification.service.ts +++ b/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/src/onboarding/dto/register-verantwortliche.dto.ts b/src/onboarding/dto/register-verantwortliche.dto.ts new file mode 100644 index 0000000..13048aa --- /dev/null +++ b/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/src/onboarding/onboarding.controller.ts b/src/onboarding/onboarding.controller.ts new file mode 100644 index 0000000..4fb1861 --- /dev/null +++ b/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/src/onboarding/onboarding.module.ts b/src/onboarding/onboarding.module.ts new file mode 100644 index 0000000..307c076 --- /dev/null +++ b/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/src/onboarding/onboarding.service.spec.ts b/src/onboarding/onboarding.service.spec.ts new file mode 100644 index 0000000..7e1ee35 --- /dev/null +++ b/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/src/onboarding/onboarding.service.ts b/src/onboarding/onboarding.service.ts new file mode 100644 index 0000000..71b606f --- /dev/null +++ b/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 }; + } +} -- 2.54.0 From f03b209e8422a0e3c02124f230bc417e609be2de Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 08:05:18 +0200 Subject: [PATCH 06/28] 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 --- README.md | 27 ++++--- src/auth/authentik.strategy.ts | 26 ++++--- src/auth/provision-user.spec.ts | 104 +++++++++++++++++++++++++ src/auth/provision-user.ts | 58 ++++++++++++++ src/auth/token-verification.service.ts | 14 ++-- src/onboarding/onboarding.service.ts | 28 +------ 6 files changed, 201 insertions(+), 56 deletions(-) create mode 100644 src/auth/provision-user.spec.ts create mode 100644 src/auth/provision-user.ts diff --git a/README.md b/README.md index d005f65..6077422 100644 --- a/README.md +++ b/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/src/auth/authentik.strategy.ts b/src/auth/authentik.strategy.ts index f5b88b8..ffcd0d3 100644 --- a/src/auth/authentik.strategy.ts +++ b/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/src/auth/provision-user.spec.ts b/src/auth/provision-user.spec.ts new file mode 100644 index 0000000..8377bec --- /dev/null +++ b/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/src/auth/provision-user.ts b/src/auth/provision-user.ts new file mode 100644 index 0000000..4e15fed --- /dev/null +++ b/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/src/auth/token-verification.service.ts b/src/auth/token-verification.service.ts index 73a09b5..a6fe7f7 100644 --- a/src/auth/token-verification.service.ts +++ b/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/src/onboarding/onboarding.service.ts b/src/onboarding/onboarding.service.ts index 71b606f..081c6e5 100644 --- a/src/onboarding/onboarding.service.ts +++ b/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, -- 2.54.0 From eb6f64a0c50616fa31584358b6d03383e7b5aca2 Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 08:13:20 +0200 Subject: [PATCH 07/28] 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 --- .env.example | 5 + README.md | 26 ++--- prisma/schema.prisma | 21 +++-- src/auth/authentik.strategy.ts | 42 +++++---- src/auth/provision-user.spec.ts | 125 +++++++++++++++++++------ src/auth/provision-user.ts | 63 ++++++++++++- src/auth/token-verification.service.ts | 46 ++++----- src/onboarding/onboarding.service.ts | 9 +- 8 files changed, 241 insertions(+), 96 deletions(-) diff --git a/.env.example b/.env.example index bced9a5..668d845 100644 --- a/.env.example +++ b/.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/README.md b/README.md index 6077422..64a605f 100644 --- a/README.md +++ b/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/prisma/schema.prisma b/prisma/schema.prisma index 460f05f..5b89be4 100644 --- a/prisma/schema.prisma +++ b/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/src/auth/authentik.strategy.ts b/src/auth/authentik.strategy.ts index ffcd0d3..9fca443 100644 --- a/src/auth/authentik.strategy.ts +++ b/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/src/auth/provision-user.spec.ts b/src/auth/provision-user.spec.ts index 8377bec..6555462 100644 --- a/src/auth/provision-user.spec.ts +++ b/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/src/auth/provision-user.ts b/src/auth/provision-user.ts index 4e15fed..be68c4e 100644 --- a/src/auth/provision-user.ts +++ b/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/src/auth/token-verification.service.ts b/src/auth/token-verification.service.ts index a6fe7f7..f3caa60 100644 --- a/src/auth/token-verification.service.ts +++ b/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/src/onboarding/onboarding.service.ts b/src/onboarding/onboarding.service.ts index 081c6e5..0a0dad1 100644 --- a/src/onboarding/onboarding.service.ts +++ b/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: { -- 2.54.0 From da76f8dc96090598cef60459d16c7753c6f17f0f Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 08:17:53 +0200 Subject: [PATCH 08/28] 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 --- .env.example | 14 ++++++++++ README.md | 22 ++++++++++----- package-lock.json | 21 +++++++++++++++ package.json | 2 ++ src/app.module.ts | 2 ++ src/mail/log-mail.provider.ts | 15 +++++++++++ src/mail/mail-provider.ts | 18 +++++++++++++ src/mail/mail.module.ts | 25 +++++++++++++++++ src/mail/mail.service.ts | 43 +++++++++++++++++++++++++++++ src/mail/smtp-mail.provider.ts | 45 +++++++++++++++++++++++++++++++ src/teamer/teamer.service.spec.ts | 28 ++++++++++++++----- src/teamer/teamer.service.ts | 21 ++++++++++++++- 12 files changed, 243 insertions(+), 13 deletions(-) create mode 100644 src/mail/log-mail.provider.ts create mode 100644 src/mail/mail-provider.ts create mode 100644 src/mail/mail.module.ts create mode 100644 src/mail/mail.service.ts create mode 100644 src/mail/smtp-mail.provider.ts diff --git a/.env.example b/.env.example index 668d845..de0a625 100644 --- a/.env.example +++ b/.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/README.md b/README.md index 64a605f..a81bbe0 100644 --- a/README.md +++ b/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/package-lock.json b/package-lock.json index 5b3c298..25d18a0 100644 --- a/package-lock.json +++ b/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/package.json b/package.json index 61c95b8..0f9caf5 100644 --- a/package.json +++ b/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/src/app.module.ts b/src/app.module.ts index ffffde1..2cf76fb 100644 --- a/src/app.module.ts +++ b/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/src/mail/log-mail.provider.ts b/src/mail/log-mail.provider.ts new file mode 100644 index 0000000..312100c --- /dev/null +++ b/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/src/mail/mail-provider.ts b/src/mail/mail-provider.ts new file mode 100644 index 0000000..7cdf3b9 --- /dev/null +++ b/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/src/mail/mail.module.ts b/src/mail/mail.module.ts new file mode 100644 index 0000000..7691ccd --- /dev/null +++ b/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/src/mail/mail.service.ts b/src/mail/mail.service.ts new file mode 100644 index 0000000..e587c11 --- /dev/null +++ b/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/src/mail/smtp-mail.provider.ts b/src/mail/smtp-mail.provider.ts new file mode 100644 index 0000000..b9cc7a5 --- /dev/null +++ b/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/src/teamer/teamer.service.spec.ts b/src/teamer/teamer.service.spec.ts index 28a71d9..498d229 100644 --- a/src/teamer/teamer.service.spec.ts +++ b/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/src/teamer/teamer.service.ts b/src/teamer/teamer.service.ts index e397106..0eac3d2 100644 --- a/src/teamer/teamer.service.ts +++ b/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) { -- 2.54.0 From 8224dff26fb9f1d04882a9a6257f7f3e5e320530 Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 08:38:35 +0200 Subject: [PATCH 09/28] 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 ++++++++++++++++++ prisma/migrations/migration_lock.toml | 3 + 2 files changed, 331 insertions(+) create mode 100644 prisma/migrations/20260910063804_init/migration.sql create mode 100644 prisma/migrations/migration_lock.toml diff --git a/prisma/migrations/20260910063804_init/migration.sql b/prisma/migrations/20260910063804_init/migration.sql new file mode 100644 index 0000000..1e29009 --- /dev/null +++ b/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/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/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 -- 2.54.0 From a29024407f231b008ebd28282c9f69178cdfd611 Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 08:45:11 +0200 Subject: [PATCH 10/28] 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 --- prisma/seed-dev.js | 35 +++++++++++++++++++++++++++++++++++ src/wahl/wahl.controller.ts | 9 +++++++++ src/wahl/wahl.service.ts | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 prisma/seed-dev.js diff --git a/prisma/seed-dev.js b/prisma/seed-dev.js new file mode 100644 index 0000000..22f71c3 --- /dev/null +++ b/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/src/wahl/wahl.controller.ts b/src/wahl/wahl.controller.ts index f222678..e60e74b 100644 --- a/src/wahl/wahl.controller.ts +++ b/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/src/wahl/wahl.service.ts b/src/wahl/wahl.service.ts index 290c698..c2e7153 100644 --- a/src/wahl/wahl.service.ts +++ b/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, -- 2.54.0 From 25caff2a51e989d3d28e1142e3c281a2a96a6393 Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 08:46:18 +0200 Subject: [PATCH 11/28] 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 --- src/auth/auth.controller.ts | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 370d0d5..1a2e2dd 100644 --- a/src/auth/auth.controller.ts +++ b/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) { -- 2.54.0 From 7c70073c41a20983e5d5b371ef14133267bf5cba Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 08:57:05 +0200 Subject: [PATCH 12/28] 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 --- src/wahl/wahl.controller.ts | 8 ++++++++ src/wahl/wahl.service.ts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/wahl/wahl.controller.ts b/src/wahl/wahl.controller.ts index e60e74b..6498857 100644 --- a/src/wahl/wahl.controller.ts +++ b/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/src/wahl/wahl.service.ts b/src/wahl/wahl.service.ts index c2e7153..9223234 100644 --- a/src/wahl/wahl.service.ts +++ b/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, -- 2.54.0 From a21a9c1cc4af2d8ea0e128c55b1f644bdeb5fd86 Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 09:02:40 +0200 Subject: [PATCH 13/28] 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 --- src/chat/chat.gateway.ts | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/src/chat/chat.gateway.ts b/src/chat/chat.gateway.ts index 27978a9..932d0a1 100644 --- a/src/chat/chat.gateway.ts +++ b/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 { -- 2.54.0 From 847fed8daddd96879875ee2c2c6758631f38719f Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 09:24:38 +0200 Subject: [PATCH 14/28] 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 --- .env.example | 4 ++-- src/app.module.ts | 16 +++++++++++++--- src/auth/authentik.strategy.ts | 8 +++++--- src/auth/team-auth.service.ts | 14 ++++---------- src/auth/token-verification.service.ts | 5 +++-- src/gemeinde/gemeinde.controller.ts | 2 +- src/kc/kc.controller.ts | 2 +- src/onboarding/onboarding.controller.ts | 6 +++--- src/sync/sync.controller.ts | 2 +- src/teamer/teamer.controller.ts | 7 ++++--- 10 files changed, 37 insertions(+), 29 deletions(-) diff --git a/.env.example b/.env.example index de0a625..808ad73 100644 --- a/.env.example +++ b/.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/src/app.module.ts b/src/app.module.ts index 2cf76fb..ffaeec6 100644 --- a/src/app.module.ts +++ b/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/src/auth/authentik.strategy.ts b/src/auth/authentik.strategy.ts index 9fca443..474e6e4 100644 --- a/src/auth/authentik.strategy.ts +++ b/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/src/auth/team-auth.service.ts b/src/auth/team-auth.service.ts index 048333e..935f781 100644 --- a/src/auth/team-auth.service.ts +++ b/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/src/auth/token-verification.service.ts b/src/auth/token-verification.service.ts index f3caa60..3d48559 100644 --- a/src/auth/token-verification.service.ts +++ b/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/src/gemeinde/gemeinde.controller.ts b/src/gemeinde/gemeinde.controller.ts index 5f404ef..9b6c06a 100644 --- a/src/gemeinde/gemeinde.controller.ts +++ b/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/src/kc/kc.controller.ts b/src/kc/kc.controller.ts index b520137..896cb02 100644 --- a/src/kc/kc.controller.ts +++ b/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/src/onboarding/onboarding.controller.ts b/src/onboarding/onboarding.controller.ts index 4fb1861..dabbffe 100644 --- a/src/onboarding/onboarding.controller.ts +++ b/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/src/sync/sync.controller.ts b/src/sync/sync.controller.ts index 4a06dfc..c84107f 100644 --- a/src/sync/sync.controller.ts +++ b/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/src/teamer/teamer.controller.ts b/src/teamer/teamer.controller.ts index ba11723..ddc3cde 100644 --- a/src/teamer/teamer.controller.ts +++ b/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) {} -- 2.54.0 From 7da9a362e15e26fa47b9930d7e3c497ef5f41a4c Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 10:00:58 +0200 Subject: [PATCH 15/28] 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 --- .env.example | 2 +- src/auth/authentik.strategy.ts | 16 +++++++++++----- src/auth/provision-user.ts | 12 ++++++++++++ src/auth/token-verification.service.ts | 18 +++++++++++++----- 4 files changed, 37 insertions(+), 11 deletions(-) diff --git a/.env.example b/.env.example index 808ad73..9fc0c44 100644 --- a/.env.example +++ b/.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/src/auth/authentik.strategy.ts b/src/auth/authentik.strategy.ts index 474e6e4..b064f7b 100644 --- a/src/auth/authentik.strategy.ts +++ b/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/src/auth/provision-user.ts b/src/auth/provision-user.ts index be68c4e..e8d9610 100644 --- a/src/auth/provision-user.ts +++ b/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/src/auth/token-verification.service.ts b/src/auth/token-verification.service.ts index 3d48559..0a973cf 100644 --- a/src/auth/token-verification.service.ts +++ b/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), }; -- 2.54.0 From 6f4a446ae42894148963a6ef75fd35706ff5eb09 Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 10:09:35 +0200 Subject: [PATCH 16/28] 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 --- src/files/files.controller.ts | 2 +- src/wahl/wahl.controller.ts | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/files/files.controller.ts b/src/files/files.controller.ts index b7bb5e3..4f91664 100644 --- a/src/files/files.controller.ts +++ b/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/src/wahl/wahl.controller.ts b/src/wahl/wahl.controller.ts index 6498857..332b9a2 100644 --- a/src/wahl/wahl.controller.ts +++ b/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); -- 2.54.0 From d8ff49480dbaf45198ec10f2d2150d43717f3726 Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 10:19:35 +0200 Subject: [PATCH 17/28] 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 --- src/wahl/dto/update-wahl.dto.ts | 7 +++++++ src/wahl/wahl.controller.ts | 16 ++++++++++++++++ src/wahl/wahl.service.ts | 27 +++++++++++++++++++++++++++ 3 files changed, 50 insertions(+) create mode 100644 src/wahl/dto/update-wahl.dto.ts diff --git a/src/wahl/dto/update-wahl.dto.ts b/src/wahl/dto/update-wahl.dto.ts new file mode 100644 index 0000000..5d3a1bc --- /dev/null +++ b/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/src/wahl/wahl.controller.ts b/src/wahl/wahl.controller.ts index 332b9a2..8dd49c1 100644 --- a/src/wahl/wahl.controller.ts +++ b/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/src/wahl/wahl.service.ts b/src/wahl/wahl.service.ts index 9223234..f1858ae 100644 --- a/src/wahl/wahl.service.ts +++ b/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) { -- 2.54.0 From 92e0029732e36bcbf7dd4b6dba6ff187fc5868b6 Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 11:42:34 +0200 Subject: [PATCH 18/28] 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 --- .env.example | 8 + .../migration.sql | 21 +++ prisma/schema.prisma | 26 ++- src/app.module.ts | 2 + src/chat/chat.service.ts | 25 ++- src/push/dto/register-device.dto.ts | 16 ++ src/push/fcm-push.provider.ts | 110 +++++++++++++ src/push/log-push.provider.ts | 15 ++ src/push/push-provider.ts | 20 +++ src/push/push.controller.ts | 29 ++++ src/push/push.module.ts | 27 ++++ src/push/push.service.ts | 151 ++++++++++++++++++ src/sync/sync.service.ts | 1 + 13 files changed, 446 insertions(+), 5 deletions(-) create mode 100644 prisma/migrations/20260910093835_device_tokens/migration.sql create mode 100644 src/push/dto/register-device.dto.ts create mode 100644 src/push/fcm-push.provider.ts create mode 100644 src/push/log-push.provider.ts create mode 100644 src/push/push-provider.ts create mode 100644 src/push/push.controller.ts create mode 100644 src/push/push.module.ts create mode 100644 src/push/push.service.ts diff --git a/.env.example b/.env.example index 9fc0c44..8050262 100644 --- a/.env.example +++ b/.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/prisma/migrations/20260910093835_device_tokens/migration.sql b/prisma/migrations/20260910093835_device_tokens/migration.sql new file mode 100644 index 0000000..ff7c9c9 --- /dev/null +++ b/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/prisma/schema.prisma b/prisma/schema.prisma index 5b89be4..782c69f 100644 --- a/prisma/schema.prisma +++ b/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/src/app.module.ts b/src/app.module.ts index ffaeec6..bbf2da8 100644 --- a/src/app.module.ts +++ b/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/src/chat/chat.service.ts b/src/chat/chat.service.ts index df9ef4d..5e89d4e 100644 --- a/src/chat/chat.service.ts +++ b/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/src/push/dto/register-device.dto.ts b/src/push/dto/register-device.dto.ts new file mode 100644 index 0000000..d8ea4fe --- /dev/null +++ b/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/src/push/fcm-push.provider.ts b/src/push/fcm-push.provider.ts new file mode 100644 index 0000000..63ff3fc --- /dev/null +++ b/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/src/push/log-push.provider.ts b/src/push/log-push.provider.ts new file mode 100644 index 0000000..b08baee --- /dev/null +++ b/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/src/push/push-provider.ts b/src/push/push-provider.ts new file mode 100644 index 0000000..0d4c41e --- /dev/null +++ b/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/src/push/push.controller.ts b/src/push/push.controller.ts new file mode 100644 index 0000000..7b05715 --- /dev/null +++ b/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/src/push/push.module.ts b/src/push/push.module.ts new file mode 100644 index 0000000..fe3e15c --- /dev/null +++ b/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/src/push/push.service.ts b/src/push/push.service.ts new file mode 100644 index 0000000..d68a9ab --- /dev/null +++ b/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/src/sync/sync.service.ts b/src/sync/sync.service.ts index 3d2746e..5e896a3 100644 --- a/src/sync/sync.service.ts +++ b/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]; -- 2.54.0 From f12bb51f3eafbeb314c090b0f6f90c6d452e779e Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 11:46:39 +0200 Subject: [PATCH 19/28] docs: push notifications module + FCM client wiring Co-Authored-By: Claude Sonnet 5 --- README.md | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a81bbe0..e196818 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,13 @@ client's host - no separate web server is needed. `MailService.sendTeamerInvite()` composes the personal-invite email with a link built from `APP_BASE_URL`. Delivery is best-effort — failures are logged and swallowed, never blocking the invite. +- `push/` — global `PushProvider` abstraction; default `log`, `PUSH_PROVIDER=fcm` + uses FCM HTTP v1 (service-account JWT → OAuth token, no extra dep; + `FCM_PROJECT_ID`, `GOOGLE_APPLICATION_CREDENTIALS`). `DeviceToken` rows + (bound to a `User` or `GuestAccount`) via `POST /push/register` + + `/unregister`. `PushService.notifyChannel()` resolves a channel's readable + audience → their tokens (minus the sender) → send, pruning invalid ones; + `ChatService.sendMessage()` fires it best-effort. - `wahl/` — Wahl/Workshop administration (Leitungsteam-only), guest Teilnehmer submission, Force-Zuteilung overrides, and `ZuteilungService`: a faithful port of the WP plugin's `kc_run_zuteilung` (force-assignments → @@ -119,11 +126,11 @@ 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. `npm test` runs Jest unit tests -(`ZuteilungService`, `TeamAuthService`, `TeamerService`, `OnboardingService`, +All planned backend features are implemented (`prisma/migrations/` holds the +schema history). `npm test` runs Jest unit tests (`ZuteilungService`, +`TeamAuthService`, `TeamerService`, `OnboardingService`, `resolveOrProvisionAuthentikUser` / `toAuthenticatedUser`; Prisma mocked). -Remaining work: the Flutter clients (see repo root README), push -notifications, and the first real Prisma migration (only `schema.prisma` -exists so far). Ops notes: the Authentik provider must emit a `groups` claim -for the LT check, and `MAIL_PROVIDER=smtp` + `SMTP_*` must be set for invite -emails to actually leave the box. +Ops notes to go live: the Authentik provider must emit a `groups` claim for +the LT check; `MAIL_PROVIDER=smtp` + `SMTP_*` for invite emails; +`PUSH_PROVIDER=fcm` + a Firebase service-account JSON for push; and real +Nextcloud/S3 credentials for file storage. -- 2.54.0 From 7c8f35f0f0cbc41e550d3de35a39ad77fbed623a Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 12:02:18 +0200 Subject: [PATCH 20/28] chore(backend): gitignore serviceAccount.json FCM push is now verified end to end against the real konfi-castle-app project: service-account JWT -> OAuth token (200), FCM messages:send reached and processed (a bogus token gets a 400 INVALID_ARGUMENT and is pruned from device_token). Real config lives only in the gitignored backend/.env + backend/serviceAccount.json. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 76e3322..e3046a5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ dist coverage .env *.log + +# Firebase service account (secret) +serviceAccount.json +*.serviceAccount.json -- 2.54.0 From be13d8350b1a1c73d52164675083bd4d3d6ca54b Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 12:08:43 +0200 Subject: [PATCH 21/28] build: Docker setup (compose: postgres + all-in-one api image) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dockerfile: 3-stage — Flutter web build, NestJS build, slim node runtime. Runtime copies dist + node_modules + prisma + the web bundle (WEB_CLIENT_DIR=/app/web), runs `prisma migrate deploy` then `node dist/main.js`. One container serves client + API on :3000. - docker-compose.yml: postgres:16-alpine with a healthcheck + the api service; config from backend/.env (Compose v2 strips quotes), DATABASE_URL + GOOGLE_APPLICATION_CREDENTIALS overridden for the container, serviceAccount.json bind-mounted read-only. - .dockerignore keeps node_modules/build/secrets out of the context. Not run here (no Docker on this box); the stack also runs natively against the local Postgres. Co-Authored-By: Claude Sonnet 5 --- .dockerignore | 10 ++++++++++ Dockerfile | 34 ++++++++++++++++++++++++++++++++++ docker-compose.yml | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0715d0b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +**/node_modules +**/dist +**/build +**/.dart_tool +**/coverage +.git +**/*.log +# Secrets: passed at runtime via env_file / bind mount, never baked in. +backend/.env +backend/serviceAccount.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a8261e9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +# syntax=docker/dockerfile:1 + +# --- 1. Flutter web build ------------------------------------------------- +FROM ghcr.io/cirruslabs/flutter:stable AS web +WORKDIR /src +COPY client/app/pubspec.yaml client/app/pubspec.lock ./ +RUN flutter pub get +COPY client/app/ ./ +RUN flutter build web --release + +# --- 2. Backend build -------------------------------------------------------- +FROM node:20-bookworm-slim AS api-build +WORKDIR /src +COPY backend/package.json backend/package-lock.json ./ +RUN npm ci +COPY backend/ ./ +RUN npx prisma generate && npm run build + +# --- 3. Runtime ------------------------------------------------------------ +FROM node:20-bookworm-slim AS runtime +ENV NODE_ENV=production +WORKDIR /app +# Prisma needs OpenSSL at runtime. +RUN apt-get update && apt-get install -y --no-install-recommends openssl \ + && rm -rf /var/lib/apt/lists/* +COPY --from=api-build /src/node_modules ./node_modules +COPY --from=api-build /src/dist ./dist +COPY --from=api-build /src/prisma ./prisma +# The Flutter web build; app.module reads WEB_CLIENT_DIR. +COPY --from=web /src/build/web ./web +ENV WEB_CLIENT_DIR=/app/web +EXPOSE 3000 +# Apply pending migrations, then boot. +CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main.js"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..31c6633 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: kcapp + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d kcapp"] + interval: 5s + timeout: 5s + retries: 10 + + api: + build: + context: . + dockerfile: Dockerfile + depends_on: + db: + condition: service_healthy + # All non-DB config comes from backend/.env (needs Docker Compose v2, + # which strips surrounding quotes). DATABASE_URL and the FCM credential + # path are overridden below for the container. + env_file: + - backend/.env + environment: + DATABASE_URL: postgresql://postgres:postgres@db:5432/kcapp?schema=public + PORT: "3000" + GOOGLE_APPLICATION_CREDENTIALS: /app/serviceAccount.json + APP_BASE_URL: http://localhost:3000 + ports: + - "3000:3000" + volumes: + # Firebase service account — kept out of the image, mounted read-only. + - ./backend/serviceAccount.json:/app/serviceAccount.json:ro + +volumes: + pgdata: -- 2.54.0 From cfa0070eab86d0229c7ed5b266f15cc3c6336444 Mon Sep 17 00:00:00 2001 From: linus Date: Thu, 10 Sep 2026 12:23:33 +0200 Subject: [PATCH 22/28] build: drop the Flutter builder stage from the Docker image The Flutter SDK image is ~2.8 GB and filled Docker Desktop's VM disk ("read-only file system" while extracting a layer). Build the web bundle on the host instead and COPY client/app/build/web into the 2-stage (NestJS build -> slim runtime) image. .dockerignore keeps the bundle, drops the platform scaffolding. README documents the host `flutter build web` prerequisite. Co-Authored-By: Claude Sonnet 5 --- .dockerignore | 13 ++++++++++--- Dockerfile | 21 +++++++++------------ 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/.dockerignore b/.dockerignore index 0715d0b..a75967b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,10 +1,17 @@ **/node_modules -**/dist -**/build **/.dart_tool **/coverage -.git **/*.log +.git +.github +backend/dist +# Flutter platform scaffolding / caches — the build/web bundle IS needed. +client/app/android +client/app/ios +client/app/linux +client/app/macos +client/app/windows +client/app/.dart_tool # Secrets: passed at runtime via env_file / bind mount, never baked in. backend/.env backend/serviceAccount.json diff --git a/Dockerfile b/Dockerfile index a8261e9..c90356a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,11 @@ # syntax=docker/dockerfile:1 +# +# The Flutter web bundle is built on the HOST (it needs a ~2.8 GB SDK image +# otherwise). Before `docker compose build`, run: +# (cd client/app && flutter build web --release) +# This Dockerfile just copies client/app/build/web into the runtime image. -# --- 1. Flutter web build ------------------------------------------------- -FROM ghcr.io/cirruslabs/flutter:stable AS web -WORKDIR /src -COPY client/app/pubspec.yaml client/app/pubspec.lock ./ -RUN flutter pub get -COPY client/app/ ./ -RUN flutter build web --release - -# --- 2. Backend build -------------------------------------------------------- +# --- 1. Backend build --------------------------------------------------------- FROM node:20-bookworm-slim AS api-build WORKDIR /src COPY backend/package.json backend/package-lock.json ./ @@ -16,7 +13,7 @@ RUN npm ci COPY backend/ ./ RUN npx prisma generate && npm run build -# --- 3. Runtime ------------------------------------------------------------ +# --- 2. Runtime ------------------------------------------------------------- FROM node:20-bookworm-slim AS runtime ENV NODE_ENV=production WORKDIR /app @@ -26,8 +23,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends openssl \ COPY --from=api-build /src/node_modules ./node_modules COPY --from=api-build /src/dist ./dist COPY --from=api-build /src/prisma ./prisma -# The Flutter web build; app.module reads WEB_CLIENT_DIR. -COPY --from=web /src/build/web ./web +# Pre-built Flutter web bundle from the host; app.module reads WEB_CLIENT_DIR. +COPY client/app/build/web ./web ENV WEB_CLIENT_DIR=/app/web EXPOSE 3000 # Apply pending migrations, then boot. -- 2.54.0 From 2f76790135a3bf312e80e51af98d8aa79b3b59ae Mon Sep 17 00:00:00 2001 From: linus Date: Fri, 11 Sep 2026 18:00:18 +0200 Subject: [PATCH 23/28] feat(backend): Wahl-Phasen, Verantwortliche-Invites, Auth fixes - New VerantwortlicheInvite model: LT-issued invites so a person can register as Gemeinde Verantwortliche(r) for a specific Gemeinde, skipping the self-registration approval step. - Wahl/Workshop/Teilnehmer gain phase support (phasenAnzahl, beschreibung), mirroring the WP plugin's multi-phase elections. Teilnehmer unique constraint now scoped per phase. - Auth: team login + guest auth adjustments, spec coverage. - sync.service.ts: register VerantwortlicheInvite as a synced model. - wahl.service.ts: submitTeilnehmer updated for the new phase-scoped unique key. - client: login/home screen rework, new theme.dart, FCM web tweaks. - .gitignore: ignore .DS_Store. Co-Authored-By: Claude Sonnet 5 --- Dockerfile | 5 + docker-compose.yml | 6 +- .../migration.sql | 43 +++++ prisma/schema.prisma | 42 ++++- src/auth/auth.controller.ts | 10 +- src/auth/dto/team-login.dto.ts | 15 +- src/auth/guest-auth.service.ts | 24 ++- src/auth/team-auth.service.spec.ts | 98 ++++++++++-- src/auth/team-auth.service.ts | 47 ++++-- src/onboarding/onboarding.service.ts | 150 +++++++++++++++++- src/sync/sync.service.ts | 1 + src/wahl/wahl.service.ts | 2 +- 12 files changed, 403 insertions(+), 40 deletions(-) create mode 100644 prisma/migrations/20260911100000_wahl_phasen_and_verantwortliche_invites/migration.sql diff --git a/Dockerfile b/Dockerfile index c90356a..8cd3470 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,11 @@ # --- 1. Backend build --------------------------------------------------------- FROM node:20-bookworm-slim AS api-build WORKDIR /src +# Prisma detects the OpenSSL version at `generate` time to pick the matching +# query engine binary; without OpenSSL present here it silently defaults to +# openssl-1.1.x, which then fails to load in the runtime stage (openssl 3.0.x). +RUN apt-get update && apt-get install -y --no-install-recommends openssl \ + && rm -rf /var/lib/apt/lists/* COPY backend/package.json backend/package-lock.json ./ RUN npm ci COPY backend/ ./ diff --git a/docker-compose.yml b/docker-compose.yml index 31c6633..032ca4f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,8 @@ services: POSTGRES_DB: kcapp volumes: - pgdata:/var/lib/postgresql/data + ports: + - "5432:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres -d kcapp"] interval: 5s @@ -29,9 +31,9 @@ services: DATABASE_URL: postgresql://postgres:postgres@db:5432/kcapp?schema=public PORT: "3000" GOOGLE_APPLICATION_CREDENTIALS: /app/serviceAccount.json - APP_BASE_URL: http://localhost:3000 + APP_BASE_URL: http://localhost:3010 ports: - - "3000:3000" + - "3010:3000" volumes: # Firebase service account — kept out of the image, mounted read-only. - ./backend/serviceAccount.json:/app/serviceAccount.json:ro diff --git a/prisma/migrations/20260911100000_wahl_phasen_and_verantwortliche_invites/migration.sql b/prisma/migrations/20260911100000_wahl_phasen_and_verantwortliche_invites/migration.sql new file mode 100644 index 0000000..09fd11c --- /dev/null +++ b/prisma/migrations/20260911100000_wahl_phasen_and_verantwortliche_invites/migration.sql @@ -0,0 +1,43 @@ +-- DropIndex +DROP INDEX "Teilnehmer_wahlId_guestAccountId_key"; + +-- AlterTable +ALTER TABLE "Wahl" ADD COLUMN "beschreibung" TEXT, +ADD COLUMN "phasenAnzahl" INTEGER NOT NULL DEFAULT 1; + +-- AlterTable +ALTER TABLE "Workshop" ADD COLUMN "beschreibung" TEXT, +ADD COLUMN "phase" INTEGER NOT NULL DEFAULT 1; + +-- AlterTable +ALTER TABLE "Teilnehmer" ADD COLUMN "phase" INTEGER NOT NULL DEFAULT 1; + +-- CreateTable +CREATE TABLE "VerantwortlicheInvite" ( + "id" TEXT NOT NULL, + "kcId" TEXT NOT NULL, + "gemeindeId" TEXT NOT NULL, + "token" TEXT NOT NULL, + "email" TEXT, + "maxUses" INTEGER, + "usedCount" INTEGER NOT NULL DEFAULT 0, + "expiresAt" TIMESTAMP(3), + "revokedAt" TIMESTAMP(3), + "createdByUserId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "VerantwortlicheInvite_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "VerantwortlicheInvite_token_key" ON "VerantwortlicheInvite"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "Teilnehmer_wahlId_guestAccountId_phase_key" ON "Teilnehmer"("wahlId", "guestAccountId", "phase"); + +-- AddForeignKey +ALTER TABLE "VerantwortlicheInvite" ADD CONSTRAINT "VerantwortlicheInvite_kcId_fkey" FOREIGN KEY ("kcId") REFERENCES "Kc"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "VerantwortlicheInvite" ADD CONSTRAINT "VerantwortlicheInvite_gemeindeId_fkey" FOREIGN KEY ("gemeindeId") REFERENCES "Gemeinde"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 782c69f..ee3787d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -24,6 +24,7 @@ model Kc { guests GuestAccount[] localUsers User[] teamerInvites TeamerInvite[] + verantwortlicheInvites VerantwortlicheInvite[] } /// A local congregation/community participating in one Kc. @@ -37,6 +38,7 @@ model Gemeinde { memberships Membership[] guests GuestAccount[] teamerInvites TeamerInvite[] + verantwortlicheInvites VerantwortlicheInvite[] @@unique([kcId, name]) } @@ -152,13 +154,42 @@ model TeamerInvite { gemeinde Gemeinde @relation(fields: [gemeindeId], references: [id], onDelete: Cascade) } +/// Invitation issued by a Leitungsteam member so a person can register as +/// Gemeinde Verantwortliche/r for a specific Gemeinde via their +/// Konfi-Castle-ID (Authentik) — skips the self-registration approval step +/// since a Leitungsteam member is vouching for them directly. A group link +/// leaves `email` null and may be redeemed up to `maxUses` times (null = +/// unlimited); a personal invite pins `email` and defaults to a single use. +model VerantwortlicheInvite { + id String @id @default(cuid()) + kcId String + gemeindeId String + token String @unique + email String? + maxUses Int? + usedCount Int @default(0) + expiresAt DateTime? + revokedAt DateTime? + createdByUserId String + createdAt DateTime @default(now()) + + kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) + gemeinde Gemeinde @relation(fields: [gemeindeId], references: [id], onDelete: Cascade) +} + /// A workshop election, scoped to a Kc; name carries a date key + "Teil". +/// `phasenAnzahl` mirrors the WP plugin's `anzahl_einheiten`: a Wahl can run +/// several independent phases (e.g. morning/afternoon), each with its own +/// workshops, its own guest submission, and its own assignment run — a guest +/// submits once per phase, not once for the whole Wahl. model Wahl { id String @id @default(cuid()) kcId String name String datumsSchluessel String teil String + beschreibung String? + phasenAnzahl Int @default(1) isOpen Boolean @default(true) createdAt DateTime @default(now()) @@ -168,10 +199,14 @@ model Wahl { forceZuteilungen ForceZuteilung[] } +/// A workshop offered in one phase of a Wahl. `phase` is 1-based and must be +/// <= the owning Wahl's `phasenAnzahl`. model Workshop { id String @id @default(cuid()) wahlId String + phase Int @default(1) name String + beschreibung String? kapazitaet Int minTeilnehmer Int @default(0) @@ -180,10 +215,13 @@ model Workshop { forceZuteilungen ForceZuteilung[] } -/// A participant's submitted choices for a Wahl. +/// A participant's submitted choices for one phase of a Wahl. A guest submits +/// separately per phase (matching the WP plugin), so the same guest can have +/// one row per (wahlId, phase). model Teilnehmer { id String @id @default(cuid()) wahlId String + phase Int @default(1) guestAccountId String prioritaeten Json createdAt DateTime @default(now()) @@ -193,7 +231,7 @@ model Teilnehmer { zuteilung Zuteilung? forceZuteilung ForceZuteilung? - @@unique([wahlId, guestAccountId]) + @@unique([wahlId, guestAccountId, phase]) } /// Manual override set by LT before running the assignment algorithm; takes precedence. diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 1a2e2dd..d0dd059 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common'; +import { BadRequestException, Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { GuestAuthService } from './guest-auth.service'; import { TeamAuthService } from './team-auth.service'; @@ -49,10 +49,14 @@ export class AuthController { return this.guestAuth.createGuest(dto.inviteCode, dto.firstName, dto.lastName); } - /// Password login for local Gemeinde Teamer accounts. + /// Password login for local Gemeinde Teamer accounts — by Gemeinde name + /// (the normal path) or by email (legacy/personal accounts). @Post('team-login') teamLogin(@Body() dto: TeamLoginDto) { - return this.teamAuth.login(dto.email, dto.password); + if (!dto.email && !dto.gemeindeName) { + throw new BadRequestException('email or gemeindeName is required'); + } + return this.teamAuth.login({ email: dto.email, gemeindeName: dto.gemeindeName }, dto.password); } /// Self-registration for a Gemeinde Teamer via an invite token/link. diff --git a/src/auth/dto/team-login.dto.ts b/src/auth/dto/team-login.dto.ts index e4f31af..87ef525 100644 --- a/src/auth/dto/team-login.dto.ts +++ b/src/auth/dto/team-login.dto.ts @@ -1,8 +1,19 @@ -import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; +import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +/// Team login accepts EITHER an email (legacy/personal Verantwortliche +/// accounts) OR a Gemeinde name (the normal Teamer login path, since a +/// Teamer thinks of their login as "meine Gemeinde", not their email). +/// At least one of email/gemeindeName is required; enforced in the +/// controller rather than a custom validator to keep this DTO simple. export class TeamLoginDto { + @IsOptional() @IsEmail() - email!: string; + email?: string; + + @IsOptional() + @IsString() + @IsNotEmpty() + gemeindeName?: string; @IsString() @IsNotEmpty() diff --git a/src/auth/guest-auth.service.ts b/src/auth/guest-auth.service.ts index fc6eed6..4131c4b 100644 --- a/src/auth/guest-auth.service.ts +++ b/src/auth/guest-auth.service.ts @@ -20,6 +20,11 @@ export class GuestAuthService { private readonly sync: SyncService, ) {} + /// Redeems a KC invite code for a guest/Konfi session. If a guest account + /// with the same (trimmed, case-insensitive) name already exists for this + /// KC, reuses it instead of creating a new one — this is what lets a Konfi + /// "log back in" with the same code + name and keep their chat history / + /// Workshop-Wahl submission instead of losing it to a fresh blank account. async createGuest( inviteCode: string, firstName: string, @@ -30,10 +35,23 @@ export class GuestAuthService { throw new NotFoundException('Unknown or inactive KC invite code'); } - const guest = await this.prisma.guestAccount.create({ - data: { kcId: kc.id, firstName, lastName }, + const trimmedFirst = firstName.trim(); + const trimmedLast = lastName.trim(); + + let guest = await this.prisma.guestAccount.findFirst({ + where: { + kcId: kc.id, + firstName: { equals: trimmedFirst, mode: 'insensitive' }, + lastName: { equals: trimmedLast, mode: 'insensitive' }, + }, }); - await this.sync.capture('GuestAccount', SyncOperation.CREATE, guest.id, guest); + + if (!guest) { + guest = await this.prisma.guestAccount.create({ + data: { kcId: kc.id, firstName: trimmedFirst, lastName: trimmedLast }, + }); + await this.sync.capture('GuestAccount', SyncOperation.CREATE, guest.id, guest); + } const payload: GuestJwtPayload = { guestId: guest.id, diff --git a/src/auth/team-auth.service.spec.ts b/src/auth/team-auth.service.spec.ts index bff85e3..3b17bb7 100644 --- a/src/auth/team-auth.service.spec.ts +++ b/src/auth/team-auth.service.spec.ts @@ -28,6 +28,10 @@ interface InviteRow { function makeService(seed: { invites?: InviteRow[]; users?: { id: string; email: string; passwordHash: string | null }[]; + memberships?: { + gemeindeName: string; + user: { id: string; passwordHash: string | null }; + }[]; }) { const invites = [...(seed.invites ?? [])]; const users = [...(seed.users ?? [])].map((u) => ({ @@ -39,6 +43,7 @@ function makeService(seed: { memberships: [] as unknown[], ...u, })); + const memberships = seed.memberships ?? []; const prisma = { user: { @@ -64,6 +69,21 @@ function makeService(seed: { create: jest.fn(({ data }: { data: Record }) => Promise.resolve({ id: `m-1`, ...data }), ), + findMany: jest.fn( + ({ + where, + }: { + where: { gemeinde: { name: { equals: string; mode: string } } }; + }) => + Promise.resolve( + memberships + .filter( + (m) => + m.gemeindeName.toLowerCase() === where.gemeinde.name.equals.toLowerCase(), + ) + .map((m) => ({ user: m.user })), + ), + ), }, teamerInvite: { findUnique: jest.fn(({ where }: { where: { token: string } }) => @@ -195,18 +215,18 @@ describe('TeamAuthService.registerFromInvite', () => { describe('TeamAuthService.login', () => { it('rejects an unknown email', async () => { const { service } = makeService({ users: [] }); - await expect(service.login('nobody@example.org', 'x')).rejects.toBeInstanceOf( - UnauthorizedException, - ); + await expect( + service.login({ email: 'nobody@example.org' }, 'x'), + ).rejects.toBeInstanceOf(UnauthorizedException); }); it('rejects a user without a password hash (Authentik-only account)', async () => { const { service } = makeService({ users: [{ id: 'u-1', email: 'lt@example.org', passwordHash: null }], }); - await expect(service.login('lt@example.org', 'x')).rejects.toBeInstanceOf( - UnauthorizedException, - ); + await expect( + service.login({ email: 'lt@example.org' }, 'x'), + ).rejects.toBeInstanceOf(UnauthorizedException); }); it('rejects a wrong password', async () => { @@ -215,18 +235,74 @@ describe('TeamAuthService.login', () => { { id: 'u-1', email: 't@example.org', passwordHash: bcrypt.hashSync('right', 10) }, ], }); - await expect(service.login('t@example.org', 'wrong')).rejects.toBeInstanceOf( - UnauthorizedException, - ); + await expect( + service.login({ email: 't@example.org' }, 'wrong'), + ).rejects.toBeInstanceOf(UnauthorizedException); }); - it('issues a token for correct credentials', async () => { + it('issues a token for correct credentials by email', async () => { const { service } = makeService({ users: [ { id: 'u-1', email: 't@example.org', passwordHash: bcrypt.hashSync('right', 10) }, ], }); - const res = await service.login('T@example.org', 'right'); + const res = await service.login({ email: 'T@example.org' }, 'right'); expect(res.accessToken).toEqual(expect.any(String)); }); + + it('rejects when neither email nor gemeindeName is given', async () => { + const { service } = makeService({}); + await expect(service.login({}, 'x')).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('rejects an unknown Gemeinde name', async () => { + const { service } = makeService({}); + await expect( + service.login({ gemeindeName: 'Nirgendwo' }, 'x'), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('logs in by Gemeinde name, matching case-insensitively and trimmed', async () => { + const { service } = makeService({ + memberships: [ + { + gemeindeName: 'Musterstadt', + user: { id: 'u-1', passwordHash: bcrypt.hashSync('right', 10) }, + }, + ], + }); + const res = await service.login({ gemeindeName: ' musterstadt ' }, 'right'); + expect(res.accessToken).toEqual(expect.any(String)); + }); + + it('tries every Teamer account for a Gemeinde until one password matches', async () => { + const { service } = makeService({ + memberships: [ + { + gemeindeName: 'Musterstadt', + user: { id: 'u-1', passwordHash: bcrypt.hashSync('wrong-one', 10) }, + }, + { + gemeindeName: 'Musterstadt', + user: { id: 'u-2', passwordHash: bcrypt.hashSync('right', 10) }, + }, + ], + }); + const res = await service.login({ gemeindeName: 'Musterstadt' }, 'right'); + expect(res.accessToken).toEqual(expect.any(String)); + }); + + it('rejects a Gemeinde login when no account password matches', async () => { + const { service } = makeService({ + memberships: [ + { + gemeindeName: 'Musterstadt', + user: { id: 'u-1', passwordHash: bcrypt.hashSync('right', 10) }, + }, + ], + }); + await expect( + service.login({ gemeindeName: 'Musterstadt' }, 'wrong'), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); }); diff --git a/src/auth/team-auth.service.ts b/src/auth/team-auth.service.ts index 935f781..e703619 100644 --- a/src/auth/team-auth.service.ts +++ b/src/auth/team-auth.service.ts @@ -38,19 +38,44 @@ export class TeamAuthService { 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 }, + /// Logs a Teamer in by email (legacy) OR by Gemeinde name — the normal + /// path, since a Teamer thinks of their login as "meine Gemeinde" rather + /// than an email address. A Gemeinde can have several Teamer accounts, so + /// a name lookup tries the password against every active GEMEINDE_TEAMER + /// membership for that Gemeinde (case-insensitive, trimmed name) until one + /// matches, rather than assuming a 1:1 Gemeinde-to-account mapping. + async login( + credentials: { email?: string; gemeindeName?: string }, + password: string, + ): Promise<{ accessToken: string }> { + if (credentials.email) { + const user = await this.prisma.user.findUnique({ + where: { email: credentials.email.toLowerCase() }, + }); + if (!user || !user.passwordHash || !(await bcrypt.compare(password, user.passwordHash))) { + throw new UnauthorizedException('Invalid credentials'); + } + return { accessToken: this.sign(user.id) }; + } + + const gemeindeName = credentials.gemeindeName?.trim(); + if (!gemeindeName) { + throw new UnauthorizedException('Invalid credentials'); + } + const memberships = await this.prisma.membership.findMany({ + where: { + role: Role.GEMEINDE_TEAMER, + status: 'ACTIVE', + gemeinde: { name: { equals: gemeindeName, mode: 'insensitive' } }, + }, + include: { user: true }, }); - if (!user || !user.passwordHash) { - throw new UnauthorizedException('Invalid credentials'); + for (const m of memberships) { + if (m.user.passwordHash && (await bcrypt.compare(password, m.user.passwordHash))) { + return { accessToken: this.sign(m.user.id) }; + } } - const ok = await bcrypt.compare(password, user.passwordHash); - if (!ok) { - throw new UnauthorizedException('Invalid credentials'); - } - return { accessToken: this.sign(user.id) }; + throw new UnauthorizedException('Invalid credentials'); } /// Redeems an invite token and creates the local Teamer account + its diff --git a/src/onboarding/onboarding.service.ts b/src/onboarding/onboarding.service.ts index 0a0dad1..87158ff 100644 --- a/src/onboarding/onboarding.service.ts +++ b/src/onboarding/onboarding.service.ts @@ -5,16 +5,15 @@ import { UnauthorizedException, } from '@nestjs/common'; import { MembershipStatus, Role, SyncOperation } from '@prisma/client'; +import { randomBytes } from 'crypto'; import { PrismaClient } from '../prisma/prisma.module'; import { SyncService } from '../sync/sync.service'; import { TokenVerificationService } from '../auth/token-verification.service'; import { resolveOrProvisionAuthentikUser } from '../auth/provision-user'; +import { AuthenticatedUser } from '../auth/authenticated-request'; -/// Self-service onboarding for Gemeinde Verantwortliche. 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. +/// Self-service onboarding for Gemeinde Verantwortliche, plus the +/// Leitungsteam-initiated shortcut that skips the approval step entirely. @Injectable() export class OnboardingService { constructor( @@ -133,4 +132,145 @@ export class OnboardingService { ) { return { membershipId, status, kcName, gemeindeName }; } + + // --- Leitungsteam-issued Verantwortliche invites --- + // Skips the PENDING approval step: an LT member vouching for someone + // directly is enough, unlike self-registration which needs review. + + async createInvite( + caller: AuthenticatedUser, + gemeindeId: string, + dto: { email?: string; maxUses?: number; expiresInHours?: number }, + ) { + if (!caller.memberships.some((m) => m.role === Role.LEITUNGSTEAM)) { + throw new UnauthorizedException('Only Leitungsteam can issue this invite'); + } + const gemeinde = await this.prisma.gemeinde.findUnique({ where: { id: gemeindeId } }); + if (!gemeinde) { + throw new NotFoundException('Gemeinde not found'); + } + const email = dto.email?.toLowerCase() ?? null; + const maxUses = dto.maxUses ?? (email ? 1 : null); + const expiresAt = dto.expiresInHours + ? new Date(Date.now() + dto.expiresInHours * 3600_000) + : null; + + const invite = await this.prisma.verantwortlicheInvite.create({ + data: { + kcId: gemeinde.kcId, + gemeindeId, + token: randomBytes(24).toString('base64url'), + email, + maxUses, + expiresAt, + createdByUserId: caller.userId, + }, + }); + await this.sync.capture('VerantwortlicheInvite', SyncOperation.CREATE, invite.id, invite); + return invite; + } + + async listInvites(caller: AuthenticatedUser, gemeindeId: string) { + if (!caller.memberships.some((m) => m.role === Role.LEITUNGSTEAM)) { + throw new UnauthorizedException('Only Leitungsteam can view this'); + } + return this.prisma.verantwortlicheInvite.findMany({ + where: { gemeindeId }, + orderBy: { createdAt: 'desc' }, + }); + } + + async revokeInvite(caller: AuthenticatedUser, gemeindeId: string, inviteId: string) { + if (!caller.memberships.some((m) => m.role === Role.LEITUNGSTEAM)) { + throw new UnauthorizedException('Only Leitungsteam can revoke this'); + } + const invite = await this.prisma.verantwortlicheInvite.findFirst({ + where: { id: inviteId, gemeindeId }, + }); + if (!invite) { + throw new NotFoundException('Invite not found'); + } + const updated = await this.prisma.verantwortlicheInvite.update({ + where: { id: inviteId }, + data: { revokedAt: new Date() }, + }); + await this.sync.capture('VerantwortlicheInvite', SyncOperation.UPDATE, updated.id, updated); + return updated; + } + + /// Redeems an LT-issued invite: provisions/updates the caller's Authentik + /// User and grants an immediately-ACTIVE GEMEINDE_VERANTWORTLICHER + /// membership (no approval step, unlike self-registration). + async redeemInvite(token: string | undefined, inviteToken: string) { + if (!token) { + throw new UnauthorizedException('Missing Authentik bearer token'); + } + const invite = await this.prisma.verantwortlicheInvite.findUnique({ + where: { token: inviteToken }, + }); + if (!invite || invite.revokedAt) { + throw new NotFoundException('Unknown or revoked invite'); + } + if (invite.expiresAt && invite.expiresAt.getTime() < Date.now()) { + throw new BadRequestException('Invite has expired'); + } + if (invite.maxUses !== null && invite.usedCount >= invite.maxUses) { + throw new BadRequestException('Invite has already been used up'); + } + + const { isLeitungsteam, ...claims } = await this.tokens.verifyAuthentikClaims(token); + if (invite.email && invite.email !== claims.email.toLowerCase()) { + throw new BadRequestException('This invite is pinned to a different account'); + } + + const user = await resolveOrProvisionAuthentikUser( + this.prisma, + this.sync, + claims, + isLeitungsteam, + ); + + const existing = await this.prisma.membership.findUnique({ + where: { + userId_kcId_gemeindeId: { + userId: user.id, + kcId: invite.kcId, + gemeindeId: invite.gemeindeId, + }, + }, + }); + const membership = existing + ? await this.prisma.membership.update({ + where: { id: existing.id }, + data: { status: MembershipStatus.ACTIVE, role: Role.GEMEINDE_VERANTWORTLICHER }, + }) + : await this.prisma.membership.create({ + data: { + userId: user.id, + kcId: invite.kcId, + gemeindeId: invite.gemeindeId, + role: Role.GEMEINDE_VERANTWORTLICHER, + status: MembershipStatus.ACTIVE, + }, + }); + await this.sync.capture( + 'Membership', + existing ? SyncOperation.UPDATE : SyncOperation.CREATE, + membership.id, + membership, + ); + + const updatedInvite = await this.prisma.verantwortlicheInvite.update({ + where: { id: invite.id }, + data: { usedCount: { increment: 1 } }, + }); + await this.sync.capture( + 'VerantwortlicheInvite', + SyncOperation.UPDATE, + updatedInvite.id, + updatedInvite, + ); + + return { membershipId: membership.id, status: membership.status }; + } } diff --git a/src/sync/sync.service.ts b/src/sync/sync.service.ts index 5e896a3..2fc88d0 100644 --- a/src/sync/sync.service.ts +++ b/src/sync/sync.service.ts @@ -9,6 +9,7 @@ const SYNCED_MODELS = [ 'User', 'Membership', 'TeamerInvite', + 'VerantwortlicheInvite', 'GuestAccount', 'Wahl', 'Workshop', diff --git a/src/wahl/wahl.service.ts b/src/wahl/wahl.service.ts index f1858ae..fe1f0d8 100644 --- a/src/wahl/wahl.service.ts +++ b/src/wahl/wahl.service.ts @@ -172,7 +172,7 @@ export class WahlService { throw new ForbiddenException('Wahl is closed'); } const teilnehmer = await this.prisma.teilnehmer.upsert({ - where: { wahlId_guestAccountId: { wahlId, guestAccountId } }, + where: { wahlId_guestAccountId_phase: { wahlId, guestAccountId, phase: 1 } }, create: { wahlId, guestAccountId, prioritaeten }, update: { prioritaeten }, }); -- 2.54.0 From 4902cfe85d61dfec00328471ff94e60e55cc0cea Mon Sep 17 00:00:00 2001 From: linus Date: Fri, 11 Sep 2026 18:01:38 +0200 Subject: [PATCH 24/28] chore: adapt Dockerfile/compose for standalone server repo - Dockerfile no longer bakes in a Flutter web build (this repo has no client/ dir); the web bundle is mounted at runtime instead. - docker-compose.yml: web bundle mount path configurable via WEB_CLIENT_BUILD_PATH, defaults to a sibling KC-APP checkout. - README: point to the KC-APP client repo for clients. Co-Authored-By: Claude Sonnet 5 --- Dockerfile | 16 +++++++--------- README.md | 4 ++-- docker-compose.yml | 18 ++++++++++++------ 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8cd3470..d3b9b56 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,8 @@ # syntax=docker/dockerfile:1 # -# The Flutter web bundle is built on the HOST (it needs a ~2.8 GB SDK image -# otherwise). Before `docker compose build`, run: -# (cd client/app && flutter build web --release) -# This Dockerfile just copies client/app/build/web into the runtime image. +# Server-only image (this repo has no Flutter client). The web client is +# built in the KC-APP client repo and its `build/web` output is mounted +# into the container at runtime via WEB_CLIENT_DIR (see docker-compose.yml). # --- 1. Backend build --------------------------------------------------------- FROM node:20-bookworm-slim AS api-build @@ -13,9 +12,9 @@ WORKDIR /src # openssl-1.1.x, which then fails to load in the runtime stage (openssl 3.0.x). RUN apt-get update && apt-get install -y --no-install-recommends openssl \ && rm -rf /var/lib/apt/lists/* -COPY backend/package.json backend/package-lock.json ./ +COPY package.json package-lock.json ./ RUN npm ci -COPY backend/ ./ +COPY . . RUN npx prisma generate && npm run build # --- 2. Runtime ------------------------------------------------------------- @@ -28,9 +27,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends openssl \ COPY --from=api-build /src/node_modules ./node_modules COPY --from=api-build /src/dist ./dist COPY --from=api-build /src/prisma ./prisma -# Pre-built Flutter web bundle from the host; app.module reads WEB_CLIENT_DIR. -COPY client/app/build/web ./web -ENV WEB_CLIENT_DIR=/app/web +# Web client bundle is bind-mounted at runtime, not baked into the image; +# app.module reads WEB_CLIENT_DIR. See docker-compose.yml. EXPOSE 3000 # Apply pending migrations, then boot. CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main.js"] diff --git a/README.md b/README.md index e196818..15c89ed 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # KC-App Backend -NestJS API for the KC-App platform (see repo root README + plan for -architecture context). +NestJS API for the KC-App platform. Split out of the main KC-APP monorepo +(https://git.konfi-castle.com/linus/KC-APP); the Flutter clients live there. ## Setup diff --git a/docker-compose.yml b/docker-compose.yml index 032ca4f..8ea978f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,21 +22,27 @@ services: depends_on: db: condition: service_healthy - # All non-DB config comes from backend/.env (needs Docker Compose v2, - # which strips surrounding quotes). DATABASE_URL and the FCM credential - # path are overridden below for the container. + # All non-DB config comes from .env (needs Docker Compose v2, which + # strips surrounding quotes). DATABASE_URL and the FCM credential path + # are overridden below for the container. env_file: - - backend/.env + - .env environment: - DATABASE_URL: postgresql://postgres:postgres@db:5432/kcapp?schema=public + DATABASE_URL: postgresql://postgres:***@db:5432/kcapp?schema=public PORT: "3000" GOOGLE_APPLICATION_CREDENTIALS: /app/serviceAccount.json APP_BASE_URL: http://localhost:3010 + WEB_CLIENT_DIR: /app/web ports: - "3010:3000" volumes: # Firebase service account — kept out of the image, mounted read-only. - - ./backend/serviceAccount.json:/app/serviceAccount.json:ro + - ./serviceAccount.json:/app/serviceAccount.json:ro + # Pre-built Flutter web bundle, built separately in the KC-APP client + # repo (flutter build web --release) and mounted read-only here. + # Set WEB_CLIENT_BUILD_PATH (e.g. in .env) to that build/web directory; + # defaults to a sibling ../KC-APP checkout. + - ${WEB_CLIENT_BUILD_PATH:-../KC-APP/client/app/build/web}:/app/web:ro volumes: pgdata: -- 2.54.0 From 1614c191026f3927347eef8f73b377a66b09a317 Mon Sep 17 00:00:00 2001 From: linus Date: Fri, 11 Sep 2026 18:34:52 +0200 Subject: [PATCH 25/28] fix: correct DATABASE_URL password placeholder in docker-compose.yml The 'postgres' password was literally written as the masked '***' placeholder (copy-paste artifact), causing Prisma P1000 auth failures against the db service. Set it to match POSTGRES_PASSWORD. --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 8ea978f..b55756f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,7 +28,7 @@ services: env_file: - .env environment: - DATABASE_URL: postgresql://postgres:***@db:5432/kcapp?schema=public + DATABASE_URL: postgresql://postgres:postgres@db:5432/kcapp?schema=public PORT: "3000" GOOGLE_APPLICATION_CREDENTIALS: /app/serviceAccount.json APP_BASE_URL: http://localhost:3010 -- 2.54.0 From 3bc40e590892c0552ac9d17b25e457c4cfddcf6f Mon Sep 17 00:00:00 2001 From: linus Date: Fri, 11 Sep 2026 19:45:49 +0200 Subject: [PATCH 26/28] chore: map Postgres to host port 5433 to avoid local conflicts --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index b55756f..b0d32ef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: volumes: - pgdata:/var/lib/postgresql/data ports: - - "5432:5432" + - "5433:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres -d kcapp"] interval: 5s -- 2.54.0 From 1467c8bdf671b7c5c7189f8e0243459c878d73db Mon Sep 17 00:00:00 2001 From: linus Date: Fri, 11 Sep 2026 20:30:51 +0200 Subject: [PATCH 27/28] test: add intentionally vulnerable file to trigger CybeDefend scan --- cybedefend-test-vuln.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 cybedefend-test-vuln.js diff --git a/cybedefend-test-vuln.js b/cybedefend-test-vuln.js new file mode 100644 index 0000000..7aaf026 --- /dev/null +++ b/cybedefend-test-vuln.js @@ -0,0 +1,21 @@ +// TEMPORARY TEST FILE — intentionally vulnerable code to trigger CybeDefend scan +// Safe to delete after the scan demo. + +const AWS_ACCESS_KEY = "AKIAABCDEFGHIJKLMNOP"; // hardcoded secret (should trigger secret scanner) +const DB_PASSWORD = "SuperSecret123!"; // hardcoded credential + +const mysql = require('mysql'); + +function getUser(db, userId) { + // SQL injection: string concatenation of user input directly into query + const query = "SELECT * FROM users WHERE id = '" + userId + "'"; + return db.query(query); +} + +function runCommand(userInput) { + const { exec } = require('child_process'); + // command injection: unsanitized user input passed to shell + exec("echo " + userInput); +} + +module.exports = { getUser, runCommand, AWS_ACCESS_KEY, DB_PASSWORD }; -- 2.54.0 From 288628f20e661faaada65f67ebf40e99716f69a6 Mon Sep 17 00:00:00 2001 From: linus Date: Sat, 12 Sep 2026 13:25:48 +0200 Subject: [PATCH 28/28] feat(chat): free-form GRUPPE channels with mutable participants - ChatChannelType.GRUPPE: created by Leitungsteam (any KC) or a Gemeinde Verantwortliche/r (own KC), mixing team users and guests/Konfis as explicit ChatParticipant rows (unlike GEMEINDE_GRUPPE, membership is not derived from Gemeinde) - POST /chat/:kcId/gruppen to create, GET participant-candidates, and POST/DELETE /chat/gruppen/:channelId/participants to manage membership (creator, LT, or Verantwortliche/r of that KC) - ChatGateway broadcasts chat:participants-changed on membership change - PushService updated for nullable ChatParticipant.userId + new guestAccountId column - SyncService now replicates ChatParticipant - Prisma migration + 14 new unit tests (75/75 passing), tsc clean - CI: add .gitea/workflows/cybedefend-scan.yml + .cybedefend project config --- .cybedefend/config.json | 3 + .gitea/workflows/cybedefend-scan.yml | 51 ++++ README.md | 29 +- .../20260912130000_chat_gruppe/migration.sql | 17 ++ prisma/schema.prisma | 82 ++--- src/chat/chat.controller.ts | 76 ++++- src/chat/chat.gateway.ts | 8 + src/chat/chat.service.spec.ts | 280 ++++++++++++++++++ src/chat/chat.service.ts | 244 ++++++++++++++- src/chat/dto/add-participant.dto.ts | 13 + src/chat/dto/create-channel.dto.ts | 22 +- src/push/push.service.ts | 18 +- src/sync/sync.service.ts | 1 + 13 files changed, 790 insertions(+), 54 deletions(-) create mode 100644 .cybedefend/config.json create mode 100644 .gitea/workflows/cybedefend-scan.yml create mode 100644 prisma/migrations/20260912130000_chat_gruppe/migration.sql create mode 100644 src/chat/chat.service.spec.ts create mode 100644 src/chat/dto/add-participant.dto.ts diff --git a/.cybedefend/config.json b/.cybedefend/config.json new file mode 100644 index 0000000..1b53543 --- /dev/null +++ b/.cybedefend/config.json @@ -0,0 +1,3 @@ +{ + "projectId": "5fe999f9-fbff-4a09-a987-48c4e7540b38" +} diff --git a/.gitea/workflows/cybedefend-scan.yml b/.gitea/workflows/cybedefend-scan.yml new file mode 100644 index 0000000..a06ab52 --- /dev/null +++ b/.gitea/workflows/cybedefend-scan.yml @@ -0,0 +1,51 @@ +name: CybeDefend Security Scan + +on: + push: + branches: + - main + - master + - 'feat/**' + pull_request: + branches: + - main + - master + +jobs: + cybedefend_scan: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run CybeDefend Security Scan + env: + CYBEDEFEND_PAT: ${{ secrets.CYBEDEFEND_PAT }} + CYBEDEFEND_PROJECT_ID: ${{ secrets.CYBEDEFEND_PROJECT_ID }} + run: | + docker run --rm \ + -v "${{ gitea.workspace }}":/app -w /app \ + -e CYBEDEFEND_PAT="$CYBEDEFEND_PAT" \ + -e CYBEDEFEND_PROJECT_ID="$CYBEDEFEND_PROJECT_ID" \ + ghcr.io/cybedefend/cybedefend-cli:latest \ + scan --dir . --region eu --ci --break-on-severity critical + + - name: Fetch detailed SARIF results + if: always() + env: + CYBEDEFEND_PAT: ${{ secrets.CYBEDEFEND_PAT }} + CYBEDEFEND_PROJECT_ID: ${{ secrets.CYBEDEFEND_PROJECT_ID }} + run: | + docker run --rm \ + -v "${{ gitea.workspace }}":/app -w /app \ + -e CYBEDEFEND_PAT="$CYBEDEFEND_PAT" \ + -e CYBEDEFEND_PROJECT_ID="$CYBEDEFEND_PROJECT_ID" \ + ghcr.io/cybedefend/cybedefend-cli:latest \ + results --project-id "$CYBEDEFEND_PROJECT_ID" --all --output sarif --filename results.sarif --ci + + - name: Upload scan results as artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: cybedefend-results + path: results.sarif diff --git a/README.md b/README.md index 15c89ed..2534320 100644 --- a/README.md +++ b/README.md @@ -103,13 +103,28 @@ client's host - no separate web server is needed. (`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. + greifende Kanäle, Broadcast (Konfis lesen nur), and free-form `GRUPPE` + chats. Channel administration and message history are plain REST + (`ChatController`); real-time send/receive is a raw `ws` gateway + (`ChatGateway`, path `/chat`) since passport guards don't apply to WS + upgrades — auth happens once via `?token=` at connect time + (`TokenVerificationService` tries Authentik JWKS, then falls back to a + guest token). Access rules live in `ChatService` and are shared between + the REST and WS entry points. + - `POST /chat/:kcId/gruppen` lets a Leitungsteam member (any KC) or a + Gemeinde Verantwortliche/r (their own KC — `RolesGuard`'s kcId scoping) + create a `GRUPPE` channel with any mix of team users and Konfis (guests) + from that KC as initial participants (`participantUserIds`, + `participantGuestIds`); the creator is always included. Unlike + `GEMEINDE_GRUPPE`, membership isn't derived from `Gemeinde` — every + participant is an explicit `ChatParticipant` row, so a Konfi (who always + belongs to exactly one Gemeinde) can be added regardless of which + Gemeinde the chat's creator manages. + - `POST` / `DELETE /chat/gruppen/:channelId/participants` (body + `{ userId }` or `{ guestId }`) add/remove a participant afterwards. + Allowed for the channel's creator, any Leitungsteam member, or a + Verantwortliche/r of that KC — not the participants themselves, and not + guests. - `sync/` — replicates mutations between the local (on-site) and cloud server. `SyncService.capture()` is called by feature services right after a write, appending an entry to the append-only `SyncLogEntry` log tagged diff --git a/prisma/migrations/20260912130000_chat_gruppe/migration.sql b/prisma/migrations/20260912130000_chat_gruppe/migration.sql new file mode 100644 index 0000000..8a85191 --- /dev/null +++ b/prisma/migrations/20260912130000_chat_gruppe/migration.sql @@ -0,0 +1,17 @@ +-- AlterEnum +ALTER TYPE "ChatChannelType" ADD VALUE 'GRUPPE'; + +-- AlterTable +ALTER TABLE "ChatChannel" ADD COLUMN "createdByUserId" TEXT, +ADD COLUMN "name" TEXT; + +-- AlterTable +ALTER TABLE "ChatParticipant" ADD COLUMN "guestAccountId" TEXT, +ALTER COLUMN "userId" DROP NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "ChatParticipant_channelId_guestAccountId_key" ON "ChatParticipant"("channelId", "guestAccountId"); + +-- AddForeignKey +ALTER TABLE "ChatParticipant" ADD CONSTRAINT "ChatParticipant_guestAccountId_fkey" FOREIGN KEY ("guestAccountId") REFERENCES "GuestAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ee3787d..4b05f3d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -16,14 +16,14 @@ model Kc { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - gemeinden Gemeinde[] - memberships Membership[] - wahlen Wahl[] - files File[] - channels ChatChannel[] - guests GuestAccount[] - localUsers User[] - teamerInvites TeamerInvite[] + gemeinden Gemeinde[] + memberships Membership[] + wahlen Wahl[] + files File[] + channels ChatChannel[] + guests GuestAccount[] + localUsers User[] + teamerInvites TeamerInvite[] verantwortlicheInvites VerantwortlicheInvite[] } @@ -34,10 +34,10 @@ model Gemeinde { kcId String createdAt DateTime @default(now()) - kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) - memberships Membership[] - guests GuestAccount[] - teamerInvites TeamerInvite[] + kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) + memberships Membership[] + guests GuestAccount[] + teamerInvites TeamerInvite[] verantwortlicheInvites VerantwortlicheInvite[] @@unique([kcId, name]) @@ -110,11 +110,12 @@ 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[] - deviceTokens DeviceToken[] + kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) + gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade) + messages ChatMessage[] + teilnehmer Teilnehmer[] + deviceTokens DeviceToken[] + chatParticipations ChatParticipant[] } /// A push-notification target (FCM registration token) bound to whoever @@ -202,13 +203,13 @@ model Wahl { /// A workshop offered in one phase of a Wahl. `phase` is 1-based and must be /// <= the owning Wahl's `phasenAnzahl`. model Workshop { - id String @id @default(cuid()) + id String @id @default(cuid()) wahlId String - phase Int @default(1) + phase Int @default(1) name String beschreibung String? kapazitaet Int - minTeilnehmer Int @default(0) + minTeilnehmer Int @default(0) wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade) zuteilungen Zuteilung[] @@ -282,32 +283,47 @@ enum ChatChannelType { DIREKT LT_UEBERGREIFEND BROADCAST + /// Freely composed group chat: created by a Leitungsteam member or a + /// Gemeinde Verantwortliche/r (for their own KC), with an explicit, + /// mutable participant list (team users and/or guests) via ChatParticipant + /// - unlike GEMEINDE_GRUPPE, membership is not derived from Gemeinde. + GRUPPE } model ChatChannel { - id String @id @default(cuid()) - kcId String - type ChatChannelType - gemeindeId String? - createdAt DateTime @default(now()) + id String @id @default(cuid()) + kcId String + type ChatChannelType + gemeindeId String? + /// Display name; used by GRUPPE channels (optional for other types). + name String? + /// Who created the channel; only set for GRUPPE so far. Used to let the + /// creator manage participants alongside Leitungsteam/Verantwortliche. + createdByUserId String? + + createdAt DateTime @default(now()) 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. +/// Explicit membership for DIREKT (1:1) and GRUPPE channels; other channel +/// types derive access from Membership/Gemeinde instead of this table. +/// Exactly one of userId/guestAccountId is set per row. model ChatParticipant { - id String @id @default(cuid()) - channelId String - userId String - createdAt DateTime @default(now()) + id String @id @default(cuid()) + channelId String + userId String? + guestAccountId String? + createdAt DateTime @default(now()) - channel ChatChannel @relation(fields: [channelId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + channel ChatChannel @relation(fields: [channelId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + guestAccount GuestAccount? @relation(fields: [guestAccountId], references: [id], onDelete: Cascade) @@unique([channelId, userId]) + @@unique([channelId, guestAccountId]) } model ChatMessage { diff --git a/src/chat/chat.controller.ts b/src/chat/chat.controller.ts index 325068f..ca513ee 100644 --- a/src/chat/chat.controller.ts +++ b/src/chat/chat.controller.ts @@ -1,8 +1,10 @@ -import { Body, Controller, Get, Param, Post, Req, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Req, UseGuards } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { ChatService } from './chat.service'; +import { ChatGateway } from './chat.gateway'; import { CreateChannelDto } from './dto/create-channel.dto'; import { CreateDirectChannelDto } from './dto/create-direct-channel.dto'; +import { AddParticipantDto } from './dto/add-participant.dto'; import { Roles } from '../common/roles.decorator'; import { RolesGuard } from '../common/roles.guard'; import { Role } from '../common/role.enum'; @@ -14,7 +16,10 @@ type ChatRequest = AuthenticatedRequest & { user?: AuthenticatedRequest['user'] @Controller('chat') export class ChatController { - constructor(private readonly chat: ChatService) {} + constructor( + private readonly chat: ChatService, + private readonly gateway: ChatGateway, + ) {} /// Channel administration (Gemeinde-Gruppen, LT-Kanäle, Broadcasts) is Leitungsteam-only. @Post(':kcId/channels') @@ -24,6 +29,73 @@ export class ChatController { return this.chat.createChannel(kcId, dto.type, dto.gemeindeId); } + /// Free-form group chat ("Gruppenchat"): a Leitungsteam member (any KC) or + /// a Gemeinde Verantwortliche/r (their own KC, enforced by RolesGuard's + /// kcId scoping) can create one and pick any mix of team users and Konfis + /// (guests) from this KC as initial participants. + @Post(':kcId/gruppen') + @UseGuards(AuthGuard(['authentik', 'team']), RolesGuard) + @Roles(Role.LEITUNGSTEAM, Role.GEMEINDE_VERANTWORTLICHER) + createGruppe( + @Param('kcId') kcId: string, + @Body() dto: CreateChannelDto, + @Req() req: AuthenticatedRequest, + ) { + return this.chat.createGruppe(kcId, dto.name, req.user!.userId, { + userIds: dto.participantUserIds, + guestIds: dto.participantGuestIds, + }); + } + + /// Candidates (team users + Konfis) a caller may add to a Gruppenchat in + /// this KC. Allowed for LT or a Verantwortliche/r of this KC. + @Get(':kcId/gruppen/participant-candidates') + @UseGuards(AuthGuard(['authentik', 'team'])) + listPossibleParticipants(@Param('kcId') kcId: string, @Req() req: AuthenticatedRequest) { + return this.chat.listPossibleParticipants(kcId, req.user!); + } + + /// Add a team user or Konfi to a Gruppenchat. Allowed for the channel's + /// creator, any Leitungsteam member, or a Verantwortliche/r of that KC. + @Post('gruppen/:channelId/participants') + @UseGuards(AuthGuard(['authentik', 'team'])) + addParticipant( + @Param('channelId') channelId: string, + @Body() dto: AddParticipantDto, + @Req() req: AuthenticatedRequest, + ) { + return this.chat + .addParticipant( + channelId, + { kind: 'user', user: req.user! }, + { userId: dto.userId, guestId: dto.guestId }, + ) + .then((result) => { + this.gateway.notifyParticipantsChanged(channelId); + return result; + }); + } + + /// Remove a team user or Konfi from a Gruppenchat. Same authorization as add. + @Delete('gruppen/:channelId/participants') + @UseGuards(AuthGuard(['authentik', 'team'])) + removeParticipant( + @Param('channelId') channelId: string, + @Body() dto: AddParticipantDto, + @Req() req: AuthenticatedRequest, + ) { + return this.chat + .removeParticipant( + channelId, + { kind: 'user', user: req.user! }, + { userId: dto.userId, guestId: dto.guestId }, + ) + .then((result) => { + this.gateway.notifyParticipantsChanged(channelId); + return result; + }); + } + /// Any two team members of the same KC can start a direct conversation /// (Authentik-backed members and local Gemeinde Teamer alike). @Post('direct') diff --git a/src/chat/chat.gateway.ts b/src/chat/chat.gateway.ts index 932d0a1..15f4ae7 100644 --- a/src/chat/chat.gateway.ts +++ b/src/chat/chat.gateway.ts @@ -106,4 +106,12 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect { } } } + + /// Called by ChatController after add/removeParticipant so anyone with the + /// channel already open (e.g. the creator's participant-management UI) + /// gets a live update. Newly added participants join the room themselves + /// via `chat:join` once they open the chat. + notifyParticipantsChanged(channelId: string) { + this.broadcast(channelId, { event: 'chat:participants-changed', data: { channelId } }); + } } diff --git a/src/chat/chat.service.spec.ts b/src/chat/chat.service.spec.ts new file mode 100644 index 0000000..82999dd --- /dev/null +++ b/src/chat/chat.service.spec.ts @@ -0,0 +1,280 @@ +import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; +import { ChatChannelType, Role } from '@prisma/client'; +import { ChatService } from './chat.service'; +import { AuthenticatedUser } from '../auth/authenticated-request'; +import { GuestJwtPayload } from '../auth/guest-auth.service'; + +/// Focus: GRUPPE channel creation + participant management authorization +/// (creator / Leitungsteam / Verantwortliche/r of that KC), and read/write +/// access for team users and guests. Prisma + Sync + Push faked in memory. + +function userCaller(userId: string, memberships: AuthenticatedUser['memberships']) { + return { + kind: 'user' as const, + user: { userId, authentikSub: `sub-${userId}`, email: `${userId}@example.org`, memberships }, + }; +} +function guestCaller(guestId: string, kcId: string, gemeindeId: string | null = null) { + const guest: GuestJwtPayload = { guestId, kcId, gemeindeId }; + return { kind: 'guest' as const, guest }; +} + +const LT = userCaller('lt-1', [{ kcId: 'kc-1', gemeindeId: null, role: Role.LEITUNGSTEAM }]); +const VERANTW = userCaller('ver-1', [ + { kcId: 'kc-1', gemeindeId: 'gem-1', role: Role.GEMEINDE_VERANTWORTLICHER }, +]); +const TEAMER = userCaller('teamer-1', [ + { kcId: 'kc-1', gemeindeId: 'gem-1', role: Role.GEMEINDE_TEAMER }, +]); + +function makeService( + opts: { + channels?: Record; + memberships?: { kcId: string; userId: string }[]; + guests?: { id: string; kcId: string }[]; + } = {}, +) { + const channels: Record = opts.channels ?? {}; + const memberships = opts.memberships ?? []; + const guests = opts.guests ?? []; + const participants: any[] = []; + let participantSeq = 0; + + const prisma = { + chatChannel: { + create: jest.fn(({ data, include }: any) => { + const id = `chan-${Object.keys(channels).length + 1}`; + const created = { id, ...data, participants: [] }; + if (data.participants?.create) { + for (const p of data.participants.create) { + const row = { id: `part-${++participantSeq}`, channelId: id, userId: null, guestAccountId: null, ...p }; + participants.push(row); + created.participants.push(row); + } + } + channels[id] = created; + return Promise.resolve(include ? created : { id, ...data }); + }), + findUnique: jest.fn(({ where, include }: any) => { + const channel = channels[where.id]; + if (!channel) return Promise.resolve(null); + if (include?.participants) { + const seeded = participants.filter((p) => p.channelId === channel.id); + const fallback = Array.isArray(channel.participants) ? channel.participants : []; + return Promise.resolve({ + ...channel, + participants: seeded.length ? seeded : fallback, + }); + } + return Promise.resolve(channel); + }), + findMany: jest.fn().mockResolvedValue([]), + }, + membership: { + findMany: jest.fn(({ where }: any) => { + const ids: string[] = where.userId.in; + const rows = memberships.filter((m) => m.kcId === where.kcId && ids.includes(m.userId)); + const seen = new Set(); + const distinct = rows.filter((r) => (seen.has(r.userId) ? false : (seen.add(r.userId), true))); + return Promise.resolve(distinct); + }), + findFirst: jest.fn(({ where }: any) => + Promise.resolve(memberships.find((m) => m.kcId === where.kcId && m.userId === where.userId) ?? null), + ), + count: jest.fn().mockResolvedValue(0), + }, + guestAccount: { + count: jest.fn(({ where }: any) => + Promise.resolve(guests.filter((g) => where.id.in.includes(g.id) && g.kcId === where.kcId).length), + ), + findFirst: jest.fn(({ where }: any) => + Promise.resolve(guests.find((g) => g.id === where.id && g.kcId === where.kcId) ?? null), + ), + }, + chatParticipant: { + upsert: jest.fn(({ create }: any) => { + const existing = participants.find( + (p) => + p.channelId === create.channelId && + p.userId === (create.userId ?? null) && + p.guestAccountId === (create.guestAccountId ?? null), + ); + if (existing) return Promise.resolve(existing); + const row = { id: `part-${++participantSeq}`, userId: null, guestAccountId: null, ...create }; + participants.push(row); + return Promise.resolve(row); + }), + findFirst: jest.fn(({ where }: any) => + Promise.resolve( + participants.find( + (p) => + p.channelId === where.channelId && + (where.userId === undefined || p.userId === where.userId) && + (where.guestAccountId === undefined || p.guestAccountId === where.guestAccountId), + ) ?? null, + ), + ), + delete: jest.fn(({ where }: any) => { + const idx = participants.findIndex((p) => p.id === where.id); + const [removed] = participants.splice(idx, 1); + return Promise.resolve(removed); + }), + }, + chatMessage: { create: jest.fn(), findMany: jest.fn() }, + }; + const sync = { capture: jest.fn().mockResolvedValue(undefined) }; + const push = { notifyChannel: jest.fn().mockResolvedValue(undefined) }; + const service = new ChatService(prisma as never, sync as never, push as never); + return { service, prisma, sync, push, channels, participants }; +} + +describe('ChatService.createGruppe', () => { + it('creates a GRUPPE channel with the creator plus given team/guest participants', async () => { + const { service, sync } = makeService({ + memberships: [{ kcId: 'kc-1', userId: 'ver-1' }, { kcId: 'kc-1', userId: 'teamer-1' }], + guests: [{ id: 'guest-1', kcId: 'kc-1' }], + }); + const channel = await service.createGruppe('kc-1', 'Ausflugsplanung', 'ver-1', { + userIds: ['teamer-1'], + guestIds: ['guest-1'], + }); + expect(channel.type).toBe(ChatChannelType.GRUPPE); + expect(channel.createdByUserId).toBe('ver-1'); + const userIds = channel.participants.map((p: any) => p.userId).filter(Boolean); + const guestIds = channel.participants.map((p: any) => p.guestAccountId).filter(Boolean); + expect(userIds.sort()).toEqual(['teamer-1', 'ver-1']); + expect(guestIds).toEqual(['guest-1']); + expect(sync.capture).toHaveBeenCalledWith('ChatChannel', 'CREATE', channel.id, expect.anything()); + }); + + it('does not duplicate the creator if already listed as a participant', async () => { + const { service } = makeService({ memberships: [{ kcId: 'kc-1', userId: 'ver-1' }] }); + const channel = await service.createGruppe('kc-1', 'X', 'ver-1', { userIds: ['ver-1'] }); + const userIds = channel.participants.map((p: any) => p.userId).filter(Boolean); + expect(userIds).toEqual(['ver-1']); + }); + + it('rejects a participant who is not a member of the KC', async () => { + const { service } = makeService({ memberships: [] }); + await expect( + service.createGruppe('kc-1', 'X', 'ver-1', { userIds: ['ghost'] }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rejects a guest who is not part of the KC', async () => { + const { service } = makeService({ guests: [{ id: 'guest-1', kcId: 'kc-2' }] }); + await expect( + service.createGruppe('kc-1', 'X', 'ver-1', { guestIds: ['guest-1'] }), + ).rejects.toBeInstanceOf(BadRequestException); + }); +}); + +describe('ChatService participant management', () => { + function seedGruppe() { + const channels = { + 'chan-1': { id: 'chan-1', kcId: 'kc-1', type: ChatChannelType.GRUPPE, createdByUserId: 'ver-1', gemeindeId: null }, + }; + return channels; + } + + it('lets the creator add a team user', async () => { + const { service } = makeService({ + channels: seedGruppe(), + memberships: [{ kcId: 'kc-1', userId: 'teamer-1' }], + }); + const p = await service.addParticipant('chan-1', VERANTW, { userId: 'teamer-1' }); + expect(p.userId).toBe('teamer-1'); + }); + + it('lets a Leitungsteam member add a guest even if not the creator', async () => { + const { service } = makeService({ + channels: seedGruppe(), + guests: [{ id: 'guest-1', kcId: 'kc-1' }], + }); + const p = await service.addParticipant('chan-1', LT, { guestId: 'guest-1' }); + expect(p.guestAccountId).toBe('guest-1'); + }); + + it('forbids a plain Teamer (not creator, not LT, not Verantwortliche/r) from managing participants', async () => { + const { service } = makeService({ channels: seedGruppe() }); + await expect( + service.addParticipant('chan-1', TEAMER, { userId: 'teamer-1' }), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('forbids guests from managing participants', async () => { + const { service } = makeService({ channels: seedGruppe() }); + await expect( + service.addParticipant('chan-1', guestCaller('g-1', 'kc-1') as never, { userId: 'x' }), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('404s for a non-GRUPPE channel', async () => { + const channels = { + 'chan-2': { id: 'chan-2', kcId: 'kc-1', type: ChatChannelType.GEMEINDE_GRUPPE, createdByUserId: null }, + }; + const { service } = makeService({ channels }); + await expect( + service.addParticipant('chan-2', LT, { userId: 'teamer-1' }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('rejects adding a user not in the KC', async () => { + const { service } = makeService({ channels: seedGruppe(), memberships: [] }); + await expect( + service.addParticipant('chan-1', LT, { userId: 'ghost' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('removes a participant and is a no-op if already absent', async () => { + const { service } = makeService({ + channels: seedGruppe(), + memberships: [{ kcId: 'kc-1', userId: 'teamer-1' }], + }); + await service.addParticipant('chan-1', VERANTW, { userId: 'teamer-1' }); + const res = await service.removeParticipant('chan-1', VERANTW, { userId: 'teamer-1' }); + expect(res).toEqual({ ok: true }); + const res2 = await service.removeParticipant('chan-1', VERANTW, { userId: 'teamer-1' }); + expect(res2).toEqual({ ok: true }); + }); +}); + +describe('ChatService GRUPPE read/write access', () => { + function seedGruppeWithParticipants(participants: any[]) { + return { + 'chan-1': { + id: 'chan-1', + kcId: 'kc-1', + type: ChatChannelType.GRUPPE, + createdByUserId: 'ver-1', + gemeindeId: null, + participants, + }, + }; + } + + it('lets a listed guest read messages', async () => { + const { service } = makeService({ + channels: seedGruppeWithParticipants([{ userId: null, guestAccountId: 'guest-1' }]), + }); + await expect( + service.assertCanRead('chan-1', guestCaller('guest-1', 'kc-1') as never), + ).resolves.toBeDefined(); + }); + + it('forbids a guest not in the participant list', async () => { + const { service } = makeService({ + channels: seedGruppeWithParticipants([{ userId: null, guestAccountId: 'guest-1' }]), + }); + await expect( + service.assertCanRead('chan-1', guestCaller('guest-2', 'kc-1') as never), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('forbids a team user not in the participant list', async () => { + const { service } = makeService({ + channels: seedGruppeWithParticipants([{ userId: 'someone-else', guestAccountId: null }]), + }); + await expect(service.assertCanRead('chan-1', TEAMER)).rejects.toBeInstanceOf(ForbiddenException); + }); +}); diff --git a/src/chat/chat.service.ts b/src/chat/chat.service.ts index 5e89d4e..30b29d3 100644 --- a/src/chat/chat.service.ts +++ b/src/chat/chat.service.ts @@ -1,4 +1,4 @@ -import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { ChatChannelType, Role, SyncOperation } from '@prisma/client'; import { PrismaClient } from '../prisma/prisma.module'; import { AuthenticatedUser } from '../auth/authenticated-request'; @@ -15,8 +15,14 @@ const CHANNEL_TITLES: Record = { [ChatChannelType.DIREKT]: 'Direktnachricht', [ChatChannelType.LT_UEBERGREIFEND]: 'Leitungsteam', [ChatChannelType.BROADCAST]: 'Ankündigung', + [ChatChannelType.GRUPPE]: 'Gruppenchat', }; +export interface CreateGruppeParticipants { + userIds?: string[]; + guestIds?: string[]; +} + @Injectable() export class ChatService { constructor( @@ -31,6 +37,206 @@ export class ChatService { return channel; } + /// Free-form group chat: created by a Leitungsteam member (any KC) or a + /// Gemeinde Verantwortliche/r (their own KC — enforced by the RolesGuard's + /// kcId scoping at the controller level). Konfis (guests) may be included + /// directly, unlike DIREKT/GEMEINDE_GRUPPE channels which are team-only. + async createGruppe( + kcId: string, + name: string | undefined, + createdByUserId: string, + participants: CreateGruppeParticipants, + ) { + const userIds = [...new Set(participants.userIds ?? [])]; + const guestIds = [...new Set(participants.guestIds ?? [])]; + + if (userIds.length) { + // A user may show up under more than one Gemeinde membership; just + // make sure every requested id resolves to at least one row for this KC. + const distinctUsers = await this.prisma.membership.findMany({ + where: { kcId, userId: { in: userIds } }, + select: { userId: true }, + distinct: ['userId'], + }); + if (distinctUsers.length !== userIds.length) { + throw new BadRequestException('One or more users are not part of this KC'); + } + } + if (guestIds.length) { + const guestCount = await this.prisma.guestAccount.count({ + where: { id: { in: guestIds }, kcId }, + }); + if (guestCount !== guestIds.length) { + throw new BadRequestException('One or more guests are not part of this KC'); + } + } + + const channel = await this.prisma.chatChannel.create({ + data: { + kcId, + type: ChatChannelType.GRUPPE, + name, + createdByUserId, + participants: { + create: [ + ...(userIds.includes(createdByUserId) ? [] : [{ userId: createdByUserId }]), + ...userIds.map((userId) => ({ userId })), + ...guestIds.map((guestAccountId) => ({ guestAccountId })), + ], + }, + }, + include: { participants: true }, + }); + await this.sync.capture('ChatChannel', SyncOperation.CREATE, channel.id, channel); + return channel; + } + + /// Candidates a caller may add to a GRUPPE channel in this KC: every team + /// member (any Gemeinde) plus every Konfi/guest, so a Verantwortliche/r can + /// pick across Gemeinde boundaries as intended. Same authorization as + /// creating a Gruppenchat (LT or Verantwortliche/r of this KC). + async listPossibleParticipants(kcId: string, caller: AuthenticatedUser) { + const isLt = caller.memberships.some((m) => m.role === Role.LEITUNGSTEAM); + const isVerantwortlicherHere = caller.memberships.some( + (m) => m.kcId === kcId && m.role === Role.GEMEINDE_VERANTWORTLICHER, + ); + if (!isLt && !isVerantwortlicherHere) { + throw new ForbiddenException('Not allowed to list participants for this KC'); + } + + const [memberships, guests] = await Promise.all([ + this.prisma.membership.findMany({ + where: { kcId, status: 'ACTIVE' }, + include: { user: { select: { id: true, firstName: true, lastName: true, email: true } } }, + orderBy: { user: { lastName: 'asc' } }, + }), + this.prisma.guestAccount.findMany({ + where: { kcId }, + select: { id: true, firstName: true, lastName: true, gemeindeId: true }, + orderBy: { lastName: 'asc' }, + }), + ]); + + const seenUsers = new Set(); + const users = []; + for (const m of memberships) { + if (seenUsers.has(m.userId)) continue; + seenUsers.add(m.userId); + users.push({ + userId: m.user.id, + firstName: m.user.firstName, + lastName: m.user.lastName, + email: m.user.email, + role: m.role, + gemeindeId: m.gemeindeId, + }); + } + + return { + users, + guests: guests.map((g) => ({ + guestId: g.id, + firstName: g.firstName, + lastName: g.lastName, + gemeindeId: g.gemeindeId, + })), + }; + } + + /// Adds a team user or a guest/Konfi to an existing GRUPPE channel. Only + /// the channel's creator or a Leitungsteam member may manage participants. + async addParticipant( + channelId: string, + caller: ChatCaller, + target: { userId?: string; guestId?: string }, + ) { + const channel = await this.getGruppeForManagementOrThrow(channelId, caller); + + if (!target.userId && !target.guestId) { + throw new BadRequestException('userId or guestId is required'); + } + if (target.userId && target.guestId) { + throw new BadRequestException('Provide either userId or guestId, not both'); + } + + if (target.userId) { + const isMember = await this.prisma.membership.findFirst({ + where: { kcId: channel.kcId, userId: target.userId }, + }); + if (!isMember) { + throw new BadRequestException('User is not part of this KC'); + } + const participant = await this.prisma.chatParticipant.upsert({ + where: { channelId_userId: { channelId, userId: target.userId } }, + create: { channelId, userId: target.userId }, + update: {}, + }); + await this.sync.capture('ChatParticipant', SyncOperation.CREATE, participant.id, participant); + return participant; + } + + const guest = await this.prisma.guestAccount.findFirst({ + where: { id: target.guestId, kcId: channel.kcId }, + }); + if (!guest) { + throw new BadRequestException('Guest is not part of this KC'); + } + const participant = await this.prisma.chatParticipant.upsert({ + where: { channelId_guestAccountId: { channelId, guestAccountId: target.guestId! } }, + create: { channelId, guestAccountId: target.guestId }, + update: {}, + }); + await this.sync.capture('ChatParticipant', SyncOperation.CREATE, participant.id, participant); + return participant; + } + + /// Removes a team user or a guest/Konfi from a GRUPPE channel. Same + /// authorization as addParticipant. + async removeParticipant( + channelId: string, + caller: ChatCaller, + target: { userId?: string; guestId?: string }, + ) { + await this.getGruppeForManagementOrThrow(channelId, caller); + + if (!target.userId && !target.guestId) { + throw new BadRequestException('userId or guestId is required'); + } + + const existing = await this.prisma.chatParticipant.findFirst({ + where: { + channelId, + userId: target.userId ?? undefined, + guestAccountId: target.guestId ?? undefined, + }, + }); + if (!existing) return { ok: true }; + + await this.prisma.chatParticipant.delete({ where: { id: existing.id } }); + await this.sync.capture('ChatParticipant', SyncOperation.DELETE, existing.id, { id: existing.id }); + return { ok: true }; + } + + private async getGruppeForManagementOrThrow(channelId: string, caller: ChatCaller) { + if (caller.kind !== 'user') { + throw new ForbiddenException('Guests may not manage channel participants'); + } + const channel = await this.prisma.chatChannel.findUnique({ where: { id: channelId } }); + if (!channel || channel.type !== ChatChannelType.GRUPPE) { + throw new NotFoundException('Gruppenchat not found'); + } + const { user } = caller; + const isLt = user.memberships.some((m) => m.role === Role.LEITUNGSTEAM); + const isCreator = channel.createdByUserId === user.userId; + const isVerantwortlicherHere = user.memberships.some( + (m) => m.kcId === channel.kcId && m.role === Role.GEMEINDE_VERANTWORTLICHER, + ); + if (!isLt && !isCreator && !isVerantwortlicherHere) { + throw new ForbiddenException('Not allowed to manage this Gruppenchat'); + } + return channel; + } + async getOrCreateDirectChannel(kcId: string, userAId: string, userBId: string) { const existing = await this.prisma.chatChannel.findFirst({ where: { @@ -57,7 +263,13 @@ export class ChatService { async listChannelsForCaller(kcId: string, caller: ChatCaller) { if (caller.kind === 'guest') { return this.prisma.chatChannel.findMany({ - where: { kcId, type: ChatChannelType.BROADCAST }, + where: { + kcId, + OR: [ + { type: ChatChannelType.BROADCAST }, + { type: ChatChannelType.GRUPPE, participants: { some: { guestAccountId: caller.guest.guestId } } }, + ], + }, }); } const { user } = caller; @@ -75,6 +287,7 @@ export class ChatService { { type: ChatChannelType.BROADCAST }, { type: ChatChannelType.GEMEINDE_GRUPPE, gemeindeId: { in: gemeindeIds } }, { type: ChatChannelType.DIREKT, participants: { some: { userId: user.userId } } }, + { type: ChatChannelType.GRUPPE, participants: { some: { userId: user.userId } } }, ], }, }); @@ -102,14 +315,22 @@ export class ChatService { } if (caller.kind === 'guest') { - const allowed = channel.type === ChatChannelType.BROADCAST && mode === 'read'; - if (!allowed) { - throw new ForbiddenException('Guests may only read broadcast channels'); + if (channel.type === ChatChannelType.BROADCAST && mode === 'read') { + if (caller.guest.kcId !== channel.kcId) { + throw new ForbiddenException('Guest does not belong to this KC'); + } + return channel; } - if (caller.guest.kcId !== channel.kcId) { - throw new ForbiddenException('Guest does not belong to this KC'); + if (channel.type === ChatChannelType.GRUPPE) { + const isParticipant = channel.participants.some( + (p) => p.guestAccountId === caller.guest.guestId, + ); + if (!isParticipant) { + throw new ForbiddenException('Not a participant of this Gruppenchat'); + } + return channel; } - return channel; + throw new ForbiddenException('Guests may only read broadcast channels or their Gruppenchats'); } const { user } = caller; @@ -145,6 +366,13 @@ export class ChatService { } return channel; } + case ChatChannelType.GRUPPE: { + const isParticipant = channel.participants.some((p) => p.userId === user.userId); + if (!isParticipant) { + throw new ForbiddenException('Not a participant of this Gruppenchat'); + } + return channel; + } default: throw new ForbiddenException('Unknown channel type'); } diff --git a/src/chat/dto/add-participant.dto.ts b/src/chat/dto/add-participant.dto.ts new file mode 100644 index 0000000..027d5f8 --- /dev/null +++ b/src/chat/dto/add-participant.dto.ts @@ -0,0 +1,13 @@ +import { IsOptional, IsString } from 'class-validator'; + +/// Exactly one of userId/guestId must be set; validated in the service since +/// class-validator doesn't express "exactly one of" declaratively. +export class AddParticipantDto { + @IsOptional() + @IsString() + userId?: string; + + @IsOptional() + @IsString() + guestId?: string; +} diff --git a/src/chat/dto/create-channel.dto.ts b/src/chat/dto/create-channel.dto.ts index c7d37c5..3d336df 100644 --- a/src/chat/dto/create-channel.dto.ts +++ b/src/chat/dto/create-channel.dto.ts @@ -1,4 +1,4 @@ -import { IsEnum, IsOptional, IsString } from 'class-validator'; +import { ArrayUnique, IsArray, IsEnum, IsOptional, IsString } from 'class-validator'; import { ChatChannelType } from '@prisma/client'; export class CreateChannelDto { @@ -8,4 +8,24 @@ export class CreateChannelDto { @IsOptional() @IsString() gemeindeId?: string; + + /// Display name; used for GRUPPE channels. + @IsOptional() + @IsString() + name?: string; + + /// Initial participants for a GRUPPE channel (team users). More can be + /// added/removed later via the participants endpoints. + @IsOptional() + @IsArray() + @ArrayUnique() + @IsString({ each: true }) + participantUserIds?: string[]; + + /// Initial guest/Konfi participants for a GRUPPE channel. + @IsOptional() + @IsArray() + @ArrayUnique() + @IsString({ each: true }) + participantGuestIds?: string[]; } diff --git a/src/push/push.service.ts b/src/push/push.service.ts index d68a9ab..a432ede 100644 --- a/src/push/push.service.ts +++ b/src/push/push.service.ts @@ -50,7 +50,7 @@ export class PushService { try { const channel = await this.prisma.chatChannel.findUnique({ where: { id: channelId }, - include: { participants: { select: { userId: true } } }, + include: { participants: { select: { userId: true, guestAccountId: true } } }, }); if (!channel) return; @@ -90,10 +90,22 @@ export class PushService { kcId: string; type: ChatChannelType; gemeindeId: string | null; - participants: { userId: string }[]; + participants: { userId: string | null; guestAccountId: string | null }[]; }): Promise<{ userIds: string[]; guestIds: string[] }> { if (channel.type === ChatChannelType.DIREKT) { - return { userIds: channel.participants.map((p) => p.userId), guestIds: [] }; + return { + userIds: channel.participants.map((p) => p.userId).filter((id): id is string => !!id), + guestIds: [], + }; + } + + if (channel.type === ChatChannelType.GRUPPE) { + return { + userIds: channel.participants.map((p) => p.userId).filter((id): id is string => !!id), + guestIds: channel.participants + .map((p) => p.guestAccountId) + .filter((id): id is string => !!id), + }; } const ltUsers = await this.prisma.user.findMany({ diff --git a/src/sync/sync.service.ts b/src/sync/sync.service.ts index 2fc88d0..1c0609d 100644 --- a/src/sync/sync.service.ts +++ b/src/sync/sync.service.ts @@ -18,6 +18,7 @@ const SYNCED_MODELS = [ 'Zuteilung', 'File', 'ChatChannel', + 'ChatParticipant', 'ChatMessage', 'DeviceToken', ] as const; -- 2.54.0