Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e3b5003f6 | ||
|
|
9278c7fc34 | ||
|
|
842b3c3ef4 | ||
|
|
35d8d9a623 | ||
|
|
8af927c0f2 | ||
|
|
72367637aa |
@@ -1,10 +0,0 @@
|
||||
**/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
|
||||
@@ -0,0 +1 @@
|
||||
.DS_Store
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
# 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"]
|
||||
@@ -5,28 +5,24 @@ events (KCs), replacing the WordPress plugin "Workshop-Wahlen". See
|
||||
[plan-kcAppMultiTenantPlatform.prompt.md](plan-kcAppMultiTenantPlatform.prompt.md)
|
||||
for the full architecture and phased roadmap.
|
||||
|
||||
This repo holds the **Flutter clients**. The backend (NestJS API) moved to
|
||||
its own repo: <https://git.konfi-castle.com/linus/KC-APP-Server>.
|
||||
|
||||
## Run with Docker
|
||||
|
||||
See the [KC-APP-Server README](https://git.konfi-castle.com/linus/KC-APP-Server)
|
||||
for the backend/Docker setup. It expects a pre-built web bundle:
|
||||
|
||||
```bash
|
||||
cp backend/.env.example backend/.env # fill in the secrets
|
||||
# put the Firebase service account at backend/serviceAccount.json (optional; push)
|
||||
docker compose up --build
|
||||
(cd client/app && flutter build web --release)
|
||||
```
|
||||
|
||||
`docker-compose.yml` starts PostgreSQL 16 and one `api` container (multi-stage
|
||||
`Dockerfile`: Flutter web build → NestJS build → slim runtime). The container
|
||||
runs `prisma migrate deploy` on start and serves the whole app — Flutter web
|
||||
client + REST API — on <http://localhost:3000>. Requires Docker Compose v2.
|
||||
Secrets are read from `backend/.env` and the service-account JSON is bind-
|
||||
mounted read-only; neither is baked into the image.
|
||||
By default the server's `docker-compose.yml` mounts `../KC-APP/client/app/build/web`
|
||||
(sibling checkout); override with `WEB_CLIENT_BUILD_PATH` if your layout
|
||||
differs.
|
||||
|
||||
## Structure
|
||||
|
||||
- `backend/` — NestJS API (Prisma/PostgreSQL, Authentik OIDC as resource
|
||||
server, guest/Konfi local accounts, roles/permissions foundation, file
|
||||
sharing, chat, local/cloud sync). See [backend/README.md](backend/README.md)
|
||||
for setup. Also serves the web client (see below) directly, so it's the
|
||||
single entry point for the web experience.
|
||||
- `client/app/` — the Flutter client (single codebase; **web** target
|
||||
enabled, mobile/desktop can be added later). Login (guest / local Teamer /
|
||||
invite redemption), role-aware home, guest Workshop-Wahl, file list,
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
# Postgres connection used by Prisma
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/kcapp?schema=public"
|
||||
|
||||
# Authentik OIDC issuer (trailing slash optional — both forms are accepted).
|
||||
AUTHENTIK_ISSUER_URL="https://sso.konfi-castle.com/application/o/konfi-castle-app"
|
||||
|
||||
# Name of the Authentik group whose members are Leitungsteam. Mirrored to
|
||||
# User.isLeitungsteam on every login (the access token must carry a `groups`
|
||||
# claim; add the "groups" scope to the Authentik provider).
|
||||
AUTHENTIK_LEITUNGSTEAM_GROUP="KC-APP-LT"
|
||||
|
||||
# Secret used to sign guest/Konfi session tokens (local accounts only)
|
||||
GUEST_JWT_SECRET="change-me"
|
||||
|
||||
# Secret used to sign local Gemeinde Teamer session tokens (password login)
|
||||
TEAM_JWT_SECRET="change-me-too"
|
||||
|
||||
PORT=3000
|
||||
|
||||
# Public base URL of the app, used to build links in outgoing emails.
|
||||
APP_BASE_URL="http://localhost:3000"
|
||||
|
||||
# Email: defaults to "log" (writes what it would send to the log, no
|
||||
# delivery). Set MAIL_PROVIDER=smtp plus the SMTP_* vars + MAIL_FROM to
|
||||
# actually send Gemeinde-Teamer invite emails.
|
||||
MAIL_PROVIDER="log"
|
||||
MAIL_FROM="KC-App <no-reply@example.org>"
|
||||
SMTP_HOST="smtp.example.org"
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE="false"
|
||||
SMTP_USER=""
|
||||
SMTP_PASS=""
|
||||
|
||||
# Push: defaults to "log" (no delivery). Set PUSH_PROVIDER=fcm plus
|
||||
# FCM_PROJECT_ID and GOOGLE_APPLICATION_CREDENTIALS (path to a Firebase
|
||||
# service-account JSON with the "Firebase Cloud Messaging API" enabled) to
|
||||
# send real notifications via FCM HTTP v1.
|
||||
PUSH_PROVIDER="log"
|
||||
FCM_PROJECT_ID="konfi-castle-app"
|
||||
GOOGLE_APPLICATION_CREDENTIALS="/absolute/path/to/serviceAccount.json"
|
||||
|
||||
# File storage: defaults to Nextcloud via WebDAV; set STORAGE_PROVIDER=s3 to
|
||||
# use an S3-compatible bucket instead (see S3_* vars below).
|
||||
STORAGE_PROVIDER="webdav"
|
||||
WEBDAV_URL="https://nextcloud.example.org/remote.php/dav/files/kc-app"
|
||||
WEBDAV_USERNAME="kc-app"
|
||||
WEBDAV_PASSWORD="change-me"
|
||||
|
||||
# Only used when STORAGE_PROVIDER=s3
|
||||
S3_BUCKET="kc-app"
|
||||
S3_REGION="auto"
|
||||
S3_ENDPOINT=""
|
||||
S3_FORCE_PATH_STYLE="false"
|
||||
S3_ACCESS_KEY_ID=""
|
||||
S3_SECRET_ACCESS_KEY=""
|
||||
|
||||
# Unique id for THIS server instance (local on-site vs. cloud); used to tag
|
||||
# replication log entries and avoid echoing changes back to their origin.
|
||||
SERVER_ID="change-me-uuid"
|
||||
|
||||
# Local/cloud sync: set on the LOCAL (on-site) server to periodically push/
|
||||
# pull against the cloud instance's API base URL. Leave SYNC_ENABLED=false
|
||||
# on the cloud server (it only needs to expose /sync/ingest + /sync/export).
|
||||
SYNC_ENABLED="false"
|
||||
SYNC_PEER_URL="https://kc-app-cloud.example.org/api"
|
||||
SYNC_SHARED_SECRET="change-me"
|
||||
@@ -1,9 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
.env
|
||||
*.log
|
||||
|
||||
# Firebase service account (secret)
|
||||
serviceAccount.json
|
||||
*.serviceAccount.json
|
||||
@@ -1,136 +0,0 @@
|
||||
# KC-App Backend
|
||||
|
||||
NestJS API for the KC-App platform (see repo root README + plan for
|
||||
architecture context).
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env # DATABASE_URL / AUTHENTIK_ISSUER_URL / AUTHENTIK_LEITUNGSTEAM_GROUP /
|
||||
# GUEST_JWT_SECRET / TEAM_JWT_SECRET / APP_BASE_URL (+ MAIL_* for real email)
|
||||
npx prisma generate
|
||||
npx prisma migrate dev --name init # requires a running PostgreSQL instance
|
||||
npm run start:dev
|
||||
```
|
||||
|
||||
The API is served under `/api` (see `app.setGlobalPrefix('api')` in
|
||||
`main.ts`); everything else (`/`, `/app.js`, ...) is served statically from
|
||||
`../client/web` via `ServeStaticModule`, so the backend doubles as the web
|
||||
client's host - no separate web server is needed.
|
||||
|
||||
## Auth model
|
||||
|
||||
- Leitungsteam and Gemeinde Verantwortliche sign in with Authentik (the
|
||||
"Konfi-Castle-ID"); this API acts as an OIDC **resource server**, verifying
|
||||
access tokens against Authentik's JWKS (`AuthentikStrategy`). The local
|
||||
`User` is provisioned just-in-time on first login from the token claims
|
||||
(`resolveOrProvisionAuthentikUser`), and `User.isLeitungsteam` is
|
||||
reconciled on every login from the token's `groups` claim vs.
|
||||
`AUTHENTIK_LEITUNGSTEAM_GROUP` — `toAuthenticatedUser` then synthesises a
|
||||
virtual global `LEITUNGSTEAM` membership from that flag. Other roles come
|
||||
from local `Membership` rows (only `status = ACTIVE` ones count).
|
||||
Verantwortliche self-provision through the `onboarding/` approval flow;
|
||||
a user with neither the LT flag nor a membership has no rights. Clients
|
||||
perform the Authorization Code + PKCE flow against Authentik directly.
|
||||
- Gemeinde Teamer are **local accounts** (no Authentik): a `User` row with a
|
||||
`passwordHash` and `kcId` set, `authentikSub` left null. A Gemeinde
|
||||
Verantwortliche/r creates them directly or via a `TeamerInvite`
|
||||
(shareable group link or per-email invite). Login is `POST /auth/team-login`
|
||||
(email + password) or `POST /auth/teamer/register` (redeem an invite
|
||||
token); both return a JWT signed with `TEAM_JWT_SECRET` and carrying
|
||||
`typ: "team"`. `TeamJwtStrategy` (`AuthGuard('team')`) resolves it to the
|
||||
same shape as `AuthentikStrategy`, so guards/controllers treat both alike.
|
||||
- Guests/Konfis get a temporary local account (first/last name required, no
|
||||
Authentik) created via `POST /auth/guest` with a KC invite code, returning
|
||||
a JWT signed with `GUEST_JWT_SECRET`.
|
||||
|
||||
## Modules implemented so far
|
||||
|
||||
- `prisma/` — shared `PrismaClient` provider.
|
||||
- `auth/` — Authentik resource-server strategy (`AuthGuard('authentik')`),
|
||||
guest invite-code login (`AuthGuard('guest')`), and local Gemeinde Teamer
|
||||
auth (`AuthGuard('team')`): `POST /auth/team-login` and
|
||||
`POST /auth/teamer/register` (invite redemption), bcrypt hashes, tokens
|
||||
signed with `TEAM_JWT_SECRET`. `TokenVerificationService` (WS handshake)
|
||||
now accepts Authentik, team, or guest tokens.
|
||||
- `kc/` — KC (event) creation/listing, Leitungsteam-only.
|
||||
- `gemeinde/` — Gemeinde (congregation) CRUD per KC (`POST /gemeinde`,
|
||||
`GET /gemeinde?kcId=`, `GET/PATCH/DELETE /gemeinde/:id`), Leitungsteam-only.
|
||||
Gemeinde Verantwortliche/Teamer get their own Gemeinde from their
|
||||
`Membership`, not from this endpoint.
|
||||
- `teamer/` — local Gemeinde Teamer accounts + invites, under
|
||||
`/gemeinde/:gemeindeId/...`: `POST/GET teamer`,
|
||||
`DELETE teamer/:userId`, `POST/GET teamer-invites`,
|
||||
`DELETE teamer-invites/:inviteId`. Callable by Leitungsteam (any Gemeinde)
|
||||
or a Verantwortliche/r for their own Gemeinde (enforced in `TeamerService`,
|
||||
since `RolesGuard` only scopes by `kcId`). Files/chat read endpoints accept
|
||||
`'team'` tokens too, so Teamer see non-Konfi files and chat. A personal
|
||||
invite (with `email`) is mailed via `MailService`; the response carries
|
||||
`emailSent`. Group-link invites (no `email`) are shared by hand.
|
||||
- `onboarding/` — self-registration for Gemeinde Verantwortliche.
|
||||
`GET /onboarding/kc/:inviteCode` (public) returns the KC name + its
|
||||
Gemeinden to pick from. `POST /onboarding/verantwortliche` takes the
|
||||
caller's raw Authentik bearer token (no local `Membership` needed yet),
|
||||
JIT-provisions the local `User` from the token claims, and creates a
|
||||
`Membership` with `status = PENDING`. Leitungsteam reviews via
|
||||
`GET /onboarding/requests?kcId=` and `POST /onboarding/requests/:id/approve`
|
||||
or `.../reject`. Auth strategies only load `ACTIVE` memberships, so a
|
||||
pending request grants nothing until approved.
|
||||
- `mail/` — global `MailProvider` abstraction (mirrors `files/storage/`):
|
||||
default `log` provider only logs what it would send; `MAIL_PROVIDER=smtp`
|
||||
uses a real `nodemailer` SMTP transport (`SMTP_*`, `MAIL_FROM`).
|
||||
`MailService.sendTeamerInvite()` composes the personal-invite email with a
|
||||
link built from `APP_BASE_URL`. Delivery is best-effort — failures are
|
||||
logged and swallowed, never blocking the invite.
|
||||
- `push/` — global `PushProvider` abstraction; default `log`, `PUSH_PROVIDER=fcm`
|
||||
uses FCM HTTP v1 (service-account JWT → OAuth token, no extra dep;
|
||||
`FCM_PROJECT_ID`, `GOOGLE_APPLICATION_CREDENTIALS`). `DeviceToken` rows
|
||||
(bound to a `User` or `GuestAccount`) via `POST /push/register` +
|
||||
`/unregister`. `PushService.notifyChannel()` resolves a channel's readable
|
||||
audience → their tokens (minus the sender) → send, pruning invalid ones;
|
||||
`ChatService.sendMessage()` fires it best-effort.
|
||||
- `wahl/` — Wahl/Workshop administration (Leitungsteam-only), guest
|
||||
Teilnehmer submission, Force-Zuteilung overrides, and `ZuteilungService`:
|
||||
a faithful port of the WP plugin's `kc_run_zuteilung` (force-assignments →
|
||||
up to 3 wish rounds → random fill → consolidation of workshops that stay
|
||||
below `minTeilnehmer`), plus CSV export (`GET /wahl/:id/zuteilung/csv`).
|
||||
- `files/` — Leitungsteam-only upload (`POST /files/:kcId`, multipart) tagged
|
||||
with a `FileVisibility` tier; list/download (`GET /files/:kcId`,
|
||||
`GET /files/download/:fileId`) accept either an Authentik or a guest token
|
||||
and filter by the caller's allowed visibility tiers. Storage is behind a
|
||||
`StorageProvider` abstraction: defaults to Nextcloud via WebDAV
|
||||
(`WEBDAV_*` env vars), switchable to S3-compatible storage with
|
||||
`STORAGE_PROVIDER=s3` (`S3_*` env vars).
|
||||
- `chat/` — Gemeinde-Gruppenchat, 1:1 Direktnachrichten, Leitungsteam-über-
|
||||
greifende Kanäle und Broadcast (Konfis lesen nur). Channel administration
|
||||
and message history are plain REST (`ChatController`); real-time send/
|
||||
receive is a raw `ws` gateway (`ChatGateway`, path `/chat`) since passport
|
||||
guards don't apply to WS upgrades — auth happens once via `?token=` at
|
||||
connect time (`TokenVerificationService` tries Authentik JWKS, then falls
|
||||
back to a guest token). Access rules live in `ChatService` and are shared
|
||||
between the REST and WS entry points.
|
||||
- `sync/` — replicates mutations between the local (on-site) and cloud
|
||||
server. `SyncService.capture()` is called by feature services right after
|
||||
a write, appending an entry to the append-only `SyncLogEntry` log tagged
|
||||
with this server's `SERVER_ID`. The local server (set `SYNC_ENABLED=true`,
|
||||
`SYNC_PEER_URL`) periodically pushes its new entries to the cloud's
|
||||
`POST /sync/ingest` and pulls the cloud's via `GET /sync/export`
|
||||
(`SyncSchedulerService`, every 30s), both guarded by `SYNC_SHARED_SECRET`
|
||||
(`SyncSecretGuard`) rather than user auth. No conflict resolution is
|
||||
implemented by design — the local server is the sole source of truth
|
||||
while an event is live. `POST /sync/trigger` lets a Leitungsteam member
|
||||
force an immediate push+pull. Known gap: only entity metadata is
|
||||
replicated; uploaded file bytes only resolve on both sides if local and
|
||||
cloud share the same Nextcloud/S3 backend.
|
||||
- `common/` — `Role` enum, `@Roles()` decorator, `RolesGuard` (KC-scoped,
|
||||
Leitungsteam roles are global across all KCs).
|
||||
|
||||
All planned backend features are implemented (`prisma/migrations/` holds the
|
||||
schema history). `npm test` runs Jest unit tests (`ZuteilungService`,
|
||||
`TeamAuthService`, `TeamerService`, `OnboardingService`,
|
||||
`resolveOrProvisionAuthentikUser` / `toAuthenticatedUser`; Prisma mocked).
|
||||
Ops notes to go live: the Authentik provider must emit a `groups` claim for
|
||||
the LT check; `MAIL_PROVIDER=smtp` + `SMTP_*` for invite emails;
|
||||
`PUSH_PROVIDER=fcm` + a Firebase service-account JSON for push; and real
|
||||
Nextcloud/S3 credentials for file storage.
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
Generated
-11163
File diff suppressed because it is too large
Load Diff
@@ -1,98 +0,0 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "0.0.1",
|
||||
"description": "KC-App backend (NestJS)",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.679.0",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"@nestjs/platform-ws": "^10.4.15",
|
||||
"@nestjs/schedule": "^4.1.1",
|
||||
"@nestjs/serve-static": "^4.0.2",
|
||||
"@nestjs/websockets": "^10.4.15",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jwks-rsa": "^3.1.0",
|
||||
"multer": "^2.0.1",
|
||||
"nodemailer": "^7.0.13",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"webdav": "^5.7.1",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@nestjs/testing": "^10.4.15",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^20.17.9",
|
||||
"@types/nodemailer": "^6.4.24",
|
||||
"@types/passport": "^1.0.17",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/ws": "^8.5.13",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prisma": "^5.22.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^6.3.4",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.6.3"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
@@ -1,328 +0,0 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Role" AS ENUM ('LEITUNGSTEAM', 'GEMEINDE_VERANTWORTLICHER', 'GEMEINDE_TEAMER');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "MembershipStatus" AS ENUM ('ACTIVE', 'PENDING');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "FileVisibility" AS ENUM ('ALLE', 'ALLE_AUSSER_KONFIS', 'NUR_LT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ChatChannelType" AS ENUM ('GEMEINDE_GRUPPE', 'DIREKT', 'LT_UEBERGREIFEND', 'BROADCAST');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SyncOperation" AS ENUM ('CREATE', 'UPDATE', 'DELETE');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Kc" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"inviteCode" TEXT NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Kc_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Gemeinde" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"kcId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Gemeinde_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"authentikSub" TEXT,
|
||||
"email" TEXT NOT NULL,
|
||||
"firstName" TEXT NOT NULL,
|
||||
"lastName" TEXT NOT NULL,
|
||||
"passwordHash" TEXT,
|
||||
"kcId" TEXT,
|
||||
"isLeitungsteam" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Membership" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"kcId" TEXT NOT NULL,
|
||||
"gemeindeId" TEXT,
|
||||
"role" "Role" NOT NULL,
|
||||
"status" "MembershipStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Membership_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "GuestAccount" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kcId" TEXT NOT NULL,
|
||||
"gemeindeId" TEXT,
|
||||
"firstName" TEXT NOT NULL,
|
||||
"lastName" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "GuestAccount_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TeamerInvite" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kcId" TEXT NOT NULL,
|
||||
"gemeindeId" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"email" TEXT,
|
||||
"maxUses" INTEGER,
|
||||
"usedCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"expiresAt" TIMESTAMP(3),
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
"createdByUserId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "TeamerInvite_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Wahl" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kcId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"datumsSchluessel" TEXT NOT NULL,
|
||||
"teil" TEXT NOT NULL,
|
||||
"isOpen" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Wahl_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Workshop" (
|
||||
"id" TEXT NOT NULL,
|
||||
"wahlId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"kapazitaet" INTEGER NOT NULL,
|
||||
"minTeilnehmer" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "Workshop_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Teilnehmer" (
|
||||
"id" TEXT NOT NULL,
|
||||
"wahlId" TEXT NOT NULL,
|
||||
"guestAccountId" TEXT NOT NULL,
|
||||
"prioritaeten" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Teilnehmer_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ForceZuteilung" (
|
||||
"id" TEXT NOT NULL,
|
||||
"wahlId" TEXT NOT NULL,
|
||||
"teilnehmerId" TEXT NOT NULL,
|
||||
"workshopId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "ForceZuteilung_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Zuteilung" (
|
||||
"id" TEXT NOT NULL,
|
||||
"teilnehmerId" TEXT NOT NULL,
|
||||
"workshopId" TEXT,
|
||||
"wunschRang" INTEGER NOT NULL DEFAULT -1,
|
||||
"isForced" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Zuteilung_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "File" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kcId" TEXT NOT NULL,
|
||||
"storageKey" TEXT NOT NULL,
|
||||
"filename" TEXT NOT NULL,
|
||||
"visibility" "FileVisibility" NOT NULL,
|
||||
"uploadedById" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "File_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ChatChannel" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kcId" TEXT NOT NULL,
|
||||
"type" "ChatChannelType" NOT NULL,
|
||||
"gemeindeId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ChatChannel_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ChatParticipant" (
|
||||
"id" TEXT NOT NULL,
|
||||
"channelId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ChatParticipant_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ChatMessage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"channelId" TEXT NOT NULL,
|
||||
"senderUserId" TEXT,
|
||||
"senderGuestId" TEXT,
|
||||
"body" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ChatMessage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SyncLogEntry" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sequence" SERIAL NOT NULL,
|
||||
"model" TEXT NOT NULL,
|
||||
"recordId" TEXT NOT NULL,
|
||||
"operation" "SyncOperation" NOT NULL,
|
||||
"payload" JSONB NOT NULL,
|
||||
"originId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "SyncLogEntry_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SyncCursor" (
|
||||
"id" TEXT NOT NULL,
|
||||
"peerId" TEXT NOT NULL,
|
||||
"lastPushedSequence" INTEGER NOT NULL DEFAULT 0,
|
||||
"lastPulledSequence" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "SyncCursor_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Kc_inviteCode_key" ON "Kc"("inviteCode");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Gemeinde_kcId_name_key" ON "Gemeinde"("kcId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_authentikSub_key" ON "User"("authentikSub");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Membership_userId_kcId_gemeindeId_key" ON "Membership"("userId", "kcId", "gemeindeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TeamerInvite_token_key" ON "TeamerInvite"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Teilnehmer_wahlId_guestAccountId_key" ON "Teilnehmer"("wahlId", "guestAccountId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ForceZuteilung_teilnehmerId_key" ON "ForceZuteilung"("teilnehmerId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Zuteilung_teilnehmerId_key" ON "Zuteilung"("teilnehmerId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ChatParticipant_channelId_userId_key" ON "ChatParticipant"("channelId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SyncCursor_peerId_key" ON "SyncCursor"("peerId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Gemeinde" ADD CONSTRAINT "Gemeinde_kcId_fkey" FOREIGN KEY ("kcId") REFERENCES "Kc"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "User" ADD CONSTRAINT "User_kcId_fkey" FOREIGN KEY ("kcId") REFERENCES "Kc"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Membership" ADD CONSTRAINT "Membership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Membership" ADD CONSTRAINT "Membership_kcId_fkey" FOREIGN KEY ("kcId") REFERENCES "Kc"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Membership" ADD CONSTRAINT "Membership_gemeindeId_fkey" FOREIGN KEY ("gemeindeId") REFERENCES "Gemeinde"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GuestAccount" ADD CONSTRAINT "GuestAccount_kcId_fkey" FOREIGN KEY ("kcId") REFERENCES "Kc"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GuestAccount" ADD CONSTRAINT "GuestAccount_gemeindeId_fkey" FOREIGN KEY ("gemeindeId") REFERENCES "Gemeinde"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TeamerInvite" ADD CONSTRAINT "TeamerInvite_kcId_fkey" FOREIGN KEY ("kcId") REFERENCES "Kc"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TeamerInvite" ADD CONSTRAINT "TeamerInvite_gemeindeId_fkey" FOREIGN KEY ("gemeindeId") REFERENCES "Gemeinde"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Wahl" ADD CONSTRAINT "Wahl_kcId_fkey" FOREIGN KEY ("kcId") REFERENCES "Kc"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Workshop" ADD CONSTRAINT "Workshop_wahlId_fkey" FOREIGN KEY ("wahlId") REFERENCES "Wahl"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Teilnehmer" ADD CONSTRAINT "Teilnehmer_wahlId_fkey" FOREIGN KEY ("wahlId") REFERENCES "Wahl"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Teilnehmer" ADD CONSTRAINT "Teilnehmer_guestAccountId_fkey" FOREIGN KEY ("guestAccountId") REFERENCES "GuestAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ForceZuteilung" ADD CONSTRAINT "ForceZuteilung_wahlId_fkey" FOREIGN KEY ("wahlId") REFERENCES "Wahl"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ForceZuteilung" ADD CONSTRAINT "ForceZuteilung_teilnehmerId_fkey" FOREIGN KEY ("teilnehmerId") REFERENCES "Teilnehmer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ForceZuteilung" ADD CONSTRAINT "ForceZuteilung_workshopId_fkey" FOREIGN KEY ("workshopId") REFERENCES "Workshop"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Zuteilung" ADD CONSTRAINT "Zuteilung_teilnehmerId_fkey" FOREIGN KEY ("teilnehmerId") REFERENCES "Teilnehmer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Zuteilung" ADD CONSTRAINT "Zuteilung_workshopId_fkey" FOREIGN KEY ("workshopId") REFERENCES "Workshop"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "File" ADD CONSTRAINT "File_kcId_fkey" FOREIGN KEY ("kcId") REFERENCES "Kc"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ChatChannel" ADD CONSTRAINT "ChatChannel_kcId_fkey" FOREIGN KEY ("kcId") REFERENCES "Kc"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ChatParticipant" ADD CONSTRAINT "ChatParticipant_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "ChatChannel"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ChatParticipant" ADD CONSTRAINT "ChatParticipant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ChatMessage" ADD CONSTRAINT "ChatMessage_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "ChatChannel"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ChatMessage" ADD CONSTRAINT "ChatMessage_senderUserId_fkey" FOREIGN KEY ("senderUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ChatMessage" ADD CONSTRAINT "ChatMessage_senderGuestId_fkey" FOREIGN KEY ("senderGuestId") REFERENCES "GuestAccount"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -1,21 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,3 +0,0 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
@@ -1,316 +0,0 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
/// A Konfi-Castle event; the top-level tenant. One instance manages many KCs.
|
||||
model Kc {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
inviteCode String @unique
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
gemeinden Gemeinde[]
|
||||
memberships Membership[]
|
||||
wahlen Wahl[]
|
||||
files File[]
|
||||
channels ChatChannel[]
|
||||
guests GuestAccount[]
|
||||
localUsers User[]
|
||||
teamerInvites TeamerInvite[]
|
||||
}
|
||||
|
||||
/// A local congregation/community participating in one Kc.
|
||||
model Gemeinde {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
kcId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||
memberships Membership[]
|
||||
guests GuestAccount[]
|
||||
teamerInvites TeamerInvite[]
|
||||
|
||||
@@unique([kcId, name])
|
||||
}
|
||||
|
||||
enum Role {
|
||||
LEITUNGSTEAM
|
||||
GEMEINDE_VERANTWORTLICHER
|
||||
GEMEINDE_TEAMER
|
||||
}
|
||||
|
||||
/// PENDING memberships come from self-registration and grant no rights until
|
||||
/// a Leitungsteam member approves them. Everything created by LT/Verantwortliche
|
||||
/// directly is ACTIVE from the start.
|
||||
enum MembershipStatus {
|
||||
ACTIVE
|
||||
PENDING
|
||||
}
|
||||
|
||||
/// A team member account. Leitungsteam and Gemeinde Verantwortliche are
|
||||
/// Authentik-backed (`authentikSub` set, `passwordHash` null). Gemeinde
|
||||
/// Teamer are local accounts created by a Verantwortliche/r (`passwordHash`
|
||||
/// set, `authentikSub` null, `kcId` set) and, like guests, scoped to one KC.
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
authentikSub String? @unique
|
||||
email String @unique
|
||||
firstName String
|
||||
lastName String
|
||||
passwordHash String?
|
||||
kcId String?
|
||||
/// Mirrored from the caller's Authentik group membership on every login.
|
||||
/// LEITUNGSTEAM is global (not KC-scoped), so it lives here rather than as
|
||||
/// a per-KC Membership row; the auth layer synthesises a virtual global
|
||||
/// LEITUNGSTEAM membership from this flag.
|
||||
isLeitungsteam Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
kc Kc? @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||
memberships Membership[]
|
||||
messages ChatMessage[]
|
||||
chatParticipations ChatParticipant[]
|
||||
deviceTokens DeviceToken[]
|
||||
}
|
||||
|
||||
/// Scopes a User's role to a specific Kc (and Gemeinde, if applicable).
|
||||
/// LEITUNGSTEAM memberships apply to all Kcs implicitly and omit gemeindeId.
|
||||
model Membership {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
kcId String
|
||||
gemeindeId String?
|
||||
role Role
|
||||
status MembershipStatus @default(ACTIVE)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||
gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, kcId, gemeindeId])
|
||||
}
|
||||
|
||||
/// Local, non-Authentik account for Konfis/guests, scoped to one Kc/event.
|
||||
model GuestAccount {
|
||||
id String @id @default(cuid())
|
||||
kcId String
|
||||
gemeindeId String?
|
||||
firstName String
|
||||
lastName String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||
gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade)
|
||||
messages ChatMessage[]
|
||||
teilnehmer Teilnehmer[]
|
||||
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
|
||||
/// can self-register a local account for one Gemeinde. A group link leaves
|
||||
/// `email` null and may be redeemed up to `maxUses` times (null = unlimited);
|
||||
/// a personal invite pins `email` and defaults to a single use.
|
||||
model TeamerInvite {
|
||||
id String @id @default(cuid())
|
||||
kcId String
|
||||
gemeindeId String
|
||||
token String @unique
|
||||
email String?
|
||||
maxUses Int?
|
||||
usedCount Int @default(0)
|
||||
expiresAt DateTime?
|
||||
revokedAt DateTime?
|
||||
createdByUserId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||
gemeinde Gemeinde @relation(fields: [gemeindeId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
/// A workshop election, scoped to a Kc; name carries a date key + "Teil".
|
||||
model Wahl {
|
||||
id String @id @default(cuid())
|
||||
kcId String
|
||||
name String
|
||||
datumsSchluessel String
|
||||
teil String
|
||||
isOpen Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||
workshops Workshop[]
|
||||
teilnehmer Teilnehmer[]
|
||||
forceZuteilungen ForceZuteilung[]
|
||||
}
|
||||
|
||||
model Workshop {
|
||||
id String @id @default(cuid())
|
||||
wahlId String
|
||||
name String
|
||||
kapazitaet Int
|
||||
minTeilnehmer Int @default(0)
|
||||
|
||||
wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade)
|
||||
zuteilungen Zuteilung[]
|
||||
forceZuteilungen ForceZuteilung[]
|
||||
}
|
||||
|
||||
/// A participant's submitted choices for a Wahl.
|
||||
model Teilnehmer {
|
||||
id String @id @default(cuid())
|
||||
wahlId String
|
||||
guestAccountId String
|
||||
prioritaeten Json
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade)
|
||||
guestAccount GuestAccount @relation(fields: [guestAccountId], references: [id], onDelete: Cascade)
|
||||
zuteilung Zuteilung?
|
||||
forceZuteilung ForceZuteilung?
|
||||
|
||||
@@unique([wahlId, guestAccountId])
|
||||
}
|
||||
|
||||
/// Manual override set by LT before running the assignment algorithm; takes precedence.
|
||||
model ForceZuteilung {
|
||||
id String @id @default(cuid())
|
||||
wahlId String
|
||||
teilnehmerId String @unique
|
||||
workshopId String
|
||||
|
||||
wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade)
|
||||
teilnehmer Teilnehmer @relation(fields: [teilnehmerId], references: [id], onDelete: Cascade)
|
||||
workshop Workshop @relation(fields: [workshopId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
/// Result of the assignment algorithm for one Teilnehmer; workshopId is null if unassigned (no capacity left).
|
||||
model Zuteilung {
|
||||
id String @id @default(cuid())
|
||||
teilnehmerId String @unique
|
||||
workshopId String?
|
||||
wunschRang Int @default(-1)
|
||||
isForced Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
teilnehmer Teilnehmer @relation(fields: [teilnehmerId], references: [id], onDelete: Cascade)
|
||||
workshop Workshop? @relation(fields: [workshopId], references: [id], onDelete: SetNull)
|
||||
}
|
||||
|
||||
enum FileVisibility {
|
||||
ALLE
|
||||
ALLE_AUSSER_KONFIS
|
||||
NUR_LT
|
||||
}
|
||||
|
||||
model File {
|
||||
id String @id @default(cuid())
|
||||
kcId String
|
||||
storageKey String
|
||||
filename String
|
||||
visibility FileVisibility
|
||||
uploadedById String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
enum ChatChannelType {
|
||||
GEMEINDE_GRUPPE
|
||||
DIREKT
|
||||
LT_UEBERGREIFEND
|
||||
BROADCAST
|
||||
}
|
||||
|
||||
model ChatChannel {
|
||||
id String @id @default(cuid())
|
||||
kcId String
|
||||
type ChatChannelType
|
||||
gemeindeId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||
messages ChatMessage[]
|
||||
participants ChatParticipant[]
|
||||
}
|
||||
|
||||
/// Explicit membership for DIREKT (1:1) channels; other channel types derive
|
||||
/// access from Membership/Gemeinde instead of this table.
|
||||
model ChatParticipant {
|
||||
id String @id @default(cuid())
|
||||
channelId String
|
||||
userId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
channel ChatChannel @relation(fields: [channelId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([channelId, userId])
|
||||
}
|
||||
|
||||
model ChatMessage {
|
||||
id String @id @default(cuid())
|
||||
channelId String
|
||||
senderUserId String?
|
||||
senderGuestId String?
|
||||
body String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
channel ChatChannel @relation(fields: [channelId], references: [id], onDelete: Cascade)
|
||||
senderUser User? @relation(fields: [senderUserId], references: [id])
|
||||
senderGuest GuestAccount? @relation(fields: [senderGuestId], references: [id])
|
||||
}
|
||||
|
||||
enum SyncOperation {
|
||||
CREATE
|
||||
UPDATE
|
||||
DELETE
|
||||
}
|
||||
|
||||
/// Append-only log of local mutations, replicated to the peer server (local
|
||||
/// <-> cloud). `originId` is the SERVER_ID that made the change, so applying
|
||||
/// an incoming entry never gets re-captured/re-pushed back (no echo loops).
|
||||
model SyncLogEntry {
|
||||
id String @id @default(cuid())
|
||||
sequence Int @default(autoincrement())
|
||||
model String
|
||||
recordId String
|
||||
operation SyncOperation
|
||||
payload Json
|
||||
originId String
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
/// Per-peer replication progress, kept on the side that initiates sync
|
||||
/// (normally the local, on-site server, since it can always dial out to the
|
||||
/// cloud even when the cloud can't reach into the event's local network).
|
||||
model SyncCursor {
|
||||
id String @id @default(cuid())
|
||||
peerId String @unique
|
||||
lastPushedSequence Int @default(0)
|
||||
lastPulledSequence Int @default(0)
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
/* Minimal dev seed: one KC + Gemeinde + open Wahl with workshops.
|
||||
Run: node prisma/seed-dev.js (backend/.env must point at the dev DB) */
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const kc = await prisma.kc.upsert({
|
||||
where: { inviteCode: 'DEV123' },
|
||||
update: {},
|
||||
create: { name: 'KC Dev 2026', inviteCode: 'DEV123' },
|
||||
});
|
||||
const gem = await prisma.gemeinde.upsert({
|
||||
where: { kcId_name: { kcId: kc.id, name: 'Mustergemeinde' } },
|
||||
update: {},
|
||||
create: { kcId: kc.id, name: 'Mustergemeinde' },
|
||||
});
|
||||
let wahl = await prisma.wahl.findFirst({ where: { kcId: kc.id } });
|
||||
if (!wahl) {
|
||||
wahl = await prisma.wahl.create({
|
||||
data: { kcId: kc.id, name: 'Samstag Teil 1', datumsSchluessel: '2026-06-13', teil: '1' },
|
||||
});
|
||||
await prisma.workshop.createMany({
|
||||
data: [
|
||||
{ wahlId: wahl.id, name: 'Töpfern', kapazitaet: 12, minTeilnehmer: 4 },
|
||||
{ wahlId: wahl.id, name: 'Fußball', kapazitaet: 20, minTeilnehmer: 6 },
|
||||
{ wahlId: wahl.id, name: 'Bandworkshop', kapazitaet: 8, minTeilnehmer: 3 },
|
||||
],
|
||||
});
|
||||
}
|
||||
console.log('KC', kc.id, 'invite', kc.inviteCode);
|
||||
console.log('Gemeinde', gem.id);
|
||||
console.log('Wahl', wahl.id);
|
||||
}
|
||||
|
||||
main().finally(() => prisma.$disconnect());
|
||||
@@ -1,51 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { MailModule } from './mail/mail.module';
|
||||
import { PushModule } from './push/push.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { KcModule } from './kc/kc.module';
|
||||
import { GemeindeModule } from './gemeinde/gemeinde.module';
|
||||
import { TeamerModule } from './teamer/teamer.module';
|
||||
import { OnboardingModule } from './onboarding/onboarding.module';
|
||||
import { WahlModule } from './wahl/wahl.module';
|
||||
import { FilesModule } from './files/files.module';
|
||||
import { ChatModule } from './chat/chat.module';
|
||||
import { SyncModule } from './sync/sync.module';
|
||||
|
||||
// Prefer the Flutter web build (single entry point at :3000, incl. the OIDC
|
||||
// redirect path /v1/auth/callback via SPA fallback). Falls back to the plain
|
||||
// interim client if the Flutter build hasn't been produced yet.
|
||||
const flutterWeb = join(__dirname, '..', '..', 'client', 'app', 'build', 'web');
|
||||
const interimWeb = join(__dirname, '..', '..', 'client', 'web');
|
||||
const webRoot =
|
||||
process.env.WEB_CLIENT_DIR ?? (existsSync(flutterWeb) ? flutterWeb : interimWeb);
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
// Static web client; the REST API lives under /api (see main.ts) so it
|
||||
// never collides. Unmatched non-file paths fall back to index.html so the
|
||||
// client-side router owns routes like /v1/auth/callback.
|
||||
ServeStaticModule.forRoot({
|
||||
rootPath: webRoot,
|
||||
exclude: ['/api*'],
|
||||
}),
|
||||
PrismaModule,
|
||||
MailModule,
|
||||
PushModule,
|
||||
SyncModule,
|
||||
AuthModule,
|
||||
KcModule,
|
||||
GemeindeModule,
|
||||
TeamerModule,
|
||||
OnboardingModule,
|
||||
WahlModule,
|
||||
FilesModule,
|
||||
ChatModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { GuestAuthService } from './guest-auth.service';
|
||||
import { TeamAuthService } from './team-auth.service';
|
||||
import { CreateGuestDto } from './dto/create-guest.dto';
|
||||
import { TeamLoginDto } from './dto/team-login.dto';
|
||||
import { RegisterTeamerDto } from './dto/register-teamer.dto';
|
||||
import { AuthenticatedRequest } from './authenticated-request';
|
||||
import { GuestJwtPayload } from './guest-auth.service';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly guestAuth: GuestAuthService,
|
||||
private readonly teamAuth: TeamAuthService,
|
||||
) {}
|
||||
|
||||
/// Returns the identity + scope behind whichever token was presented, so a
|
||||
/// client can render a role-aware UI. `kind` is "guest" for a Konfi token,
|
||||
/// "user" for an Authentik or local Teamer token.
|
||||
@Get('me')
|
||||
@UseGuards(AuthGuard(['authentik', 'team', 'guest']))
|
||||
me(@Req() req: AuthenticatedRequest & { user: unknown }) {
|
||||
const user = req.user as
|
||||
| AuthenticatedRequest['user']
|
||||
| GuestJwtPayload;
|
||||
if (user && 'guestId' in user) {
|
||||
return {
|
||||
kind: 'guest',
|
||||
guestId: user.guestId,
|
||||
kcId: user.kcId,
|
||||
gemeindeId: user.gemeindeId,
|
||||
};
|
||||
}
|
||||
const u = user as NonNullable<AuthenticatedRequest['user']>;
|
||||
return {
|
||||
kind: 'user',
|
||||
userId: u.userId,
|
||||
email: u.email,
|
||||
authentikSub: u.authentikSub,
|
||||
memberships: u.memberships,
|
||||
isLeitungsteam: u.memberships.some((m) => m.role === 'LEITUNGSTEAM'),
|
||||
};
|
||||
}
|
||||
|
||||
/// Redeems a KC invite code and registers a new temporary guest/Konfi account.
|
||||
@Post('guest')
|
||||
createGuest(@Body() dto: CreateGuestDto) {
|
||||
return this.guestAuth.createGuest(dto.inviteCode, dto.firstName, dto.lastName);
|
||||
}
|
||||
|
||||
/// Password login for local Gemeinde Teamer accounts.
|
||||
@Post('team-login')
|
||||
teamLogin(@Body() dto: TeamLoginDto) {
|
||||
return this.teamAuth.login(dto.email, dto.password);
|
||||
}
|
||||
|
||||
/// Self-registration for a Gemeinde Teamer via an invite token/link.
|
||||
@Post('teamer/register')
|
||||
registerTeamer(@Body() dto: RegisterTeamerDto) {
|
||||
return this.teamAuth.registerFromInvite(dto);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { GuestAuthService } from './guest-auth.service';
|
||||
import { TeamAuthService } from './team-auth.service';
|
||||
import { AuthentikStrategy } from './authentik.strategy';
|
||||
import { GuestJwtStrategy } from './guest-jwt.strategy';
|
||||
import { TeamJwtStrategy } from './team-jwt.strategy';
|
||||
import { TokenVerificationService } from './token-verification.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.getOrThrow<string>('GUEST_JWT_SECRET'),
|
||||
signOptions: { expiresIn: '12h' },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
GuestAuthService,
|
||||
TeamAuthService,
|
||||
AuthentikStrategy,
|
||||
GuestJwtStrategy,
|
||||
TeamJwtStrategy,
|
||||
TokenVerificationService,
|
||||
],
|
||||
exports: [TokenVerificationService, TeamAuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Request } from 'express';
|
||||
import { Role } from '../common/role.enum';
|
||||
import { GuestJwtPayload } from './guest-auth.service';
|
||||
|
||||
export interface AuthenticatedMembership {
|
||||
kcId: string;
|
||||
gemeindeId: string | null;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
/// Shape attached to req.user after validating an access token — by
|
||||
/// AuthentikStrategy for Authentik-backed members, or by TeamJwtStrategy for
|
||||
/// local Gemeinde Teamer (then `authentikSub` is null).
|
||||
export interface AuthenticatedUser {
|
||||
userId: string;
|
||||
authentikSub: string | null;
|
||||
email: string;
|
||||
memberships: AuthenticatedMembership[];
|
||||
}
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user?: AuthenticatedUser;
|
||||
}
|
||||
|
||||
/// Shape attached to req.user by GuestJwtStrategy for guest/Konfi-authenticated routes.
|
||||
export interface GuestAuthenticatedRequest extends Request {
|
||||
user?: GuestJwtPayload;
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Strategy } from 'passport-jwt';
|
||||
import * as jwksRsa from 'jwks-rsa';
|
||||
import { Request } from 'express';
|
||||
import { PrismaClient } from '../prisma/prisma.module';
|
||||
import { SyncService } from '../sync/sync.service';
|
||||
import { AuthenticatedUser } from './authenticated-request';
|
||||
import {
|
||||
authentikEmail,
|
||||
resolveOrProvisionAuthentikUser,
|
||||
toAuthenticatedUser,
|
||||
} from './provision-user';
|
||||
|
||||
interface AuthentikJwtPayload {
|
||||
sub: string;
|
||||
email?: string;
|
||||
given_name?: string;
|
||||
family_name?: string;
|
||||
preferred_username?: string;
|
||||
name?: string;
|
||||
groups?: string[];
|
||||
}
|
||||
|
||||
/// Validates access tokens issued by Authentik (resource-server pattern):
|
||||
/// signature is checked against Authentik's JWKS, the local `User` is
|
||||
/// provisioned on first login (JIT) and its LEITUNGSTEAM flag reconciled with
|
||||
/// the token's `groups` claim, then the local Membership table decides what
|
||||
/// the user may do. Authentik itself is only the identity source, never asked
|
||||
/// for authorization here.
|
||||
@Injectable()
|
||||
export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') {
|
||||
private readonly leitungsteamGroup: string;
|
||||
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly sync: SyncService,
|
||||
) {
|
||||
// Authentik's discovery `issuer` carries a trailing slash and so does the
|
||||
// `iss` claim in its tokens; accept both spellings and never emit `//`.
|
||||
const base = config.getOrThrow<string>('AUTHENTIK_ISSUER_URL').replace(/\/+$/, '');
|
||||
super({
|
||||
jwtFromRequest: (req: Request) =>
|
||||
req.headers.authorization?.startsWith('Bearer ')
|
||||
? req.headers.authorization.slice('Bearer '.length)
|
||||
: null,
|
||||
secretOrKeyProvider: jwksRsa.passportJwtSecret({
|
||||
jwksUri: `${base}/jwks/`,
|
||||
cache: true,
|
||||
rateLimit: true,
|
||||
}),
|
||||
issuer: [base, `${base}/`],
|
||||
algorithms: ['RS256'],
|
||||
});
|
||||
this.leitungsteamGroup = config.get<string>('AUTHENTIK_LEITUNGSTEAM_GROUP', 'Leitungsteam');
|
||||
}
|
||||
|
||||
async validate(payload: AuthentikJwtPayload): Promise<AuthenticatedUser> {
|
||||
if (!payload.sub) {
|
||||
throw new UnauthorizedException('Authentik token missing subject');
|
||||
}
|
||||
const isLeitungsteam = (payload.groups ?? []).includes(this.leitungsteamGroup);
|
||||
const user = await resolveOrProvisionAuthentikUser(
|
||||
this.prisma,
|
||||
this.sync,
|
||||
{
|
||||
sub: payload.sub,
|
||||
email: authentikEmail(payload),
|
||||
firstName: payload.given_name ?? payload.preferred_username ?? payload.name ?? '',
|
||||
lastName: payload.family_name ?? '',
|
||||
},
|
||||
isLeitungsteam,
|
||||
);
|
||||
return toAuthenticatedUser(user);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class CreateGuestDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
inviteCode!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
firstName!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
lastName!: string;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import {
|
||||
IsEmail,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class RegisterTeamerDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
token!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
firstName!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
lastName!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
|
||||
/// Required for group-link invites; ignored/validated against a personal invite.
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class TeamLoginDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password!: string;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { SyncOperation } from '@prisma/client';
|
||||
import { PrismaClient } from '../prisma/prisma.module';
|
||||
import { SyncService } from '../sync/sync.service';
|
||||
|
||||
export interface GuestJwtPayload {
|
||||
guestId: string;
|
||||
kcId: string;
|
||||
gemeindeId: string | null;
|
||||
}
|
||||
|
||||
/// Guest/Konfi accounts are local to this server (never Authentik-backed),
|
||||
/// created via a KC invite code, and scoped to that single KC.
|
||||
@Injectable()
|
||||
export class GuestAuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly sync: SyncService,
|
||||
) {}
|
||||
|
||||
async createGuest(
|
||||
inviteCode: string,
|
||||
firstName: string,
|
||||
lastName: string,
|
||||
): Promise<{ accessToken: string }> {
|
||||
const kc = await this.prisma.kc.findUnique({ where: { inviteCode } });
|
||||
if (!kc || !kc.isActive) {
|
||||
throw new NotFoundException('Unknown or inactive KC invite code');
|
||||
}
|
||||
|
||||
const guest = await this.prisma.guestAccount.create({
|
||||
data: { kcId: kc.id, firstName, lastName },
|
||||
});
|
||||
await this.sync.capture('GuestAccount', SyncOperation.CREATE, guest.id, guest);
|
||||
|
||||
const payload: GuestJwtPayload = {
|
||||
guestId: guest.id,
|
||||
kcId: kc.id,
|
||||
gemeindeId: guest.gemeindeId,
|
||||
};
|
||||
return { accessToken: await this.jwt.signAsync(payload) };
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { GuestJwtPayload } from './guest-auth.service';
|
||||
|
||||
/// Verifies the local JWT issued to guests/Konfis by GuestAuthService.
|
||||
/// Kept separate from AuthentikStrategy since guests are never Authentik-backed.
|
||||
@Injectable()
|
||||
export class GuestJwtStrategy extends PassportStrategy(Strategy, 'guest') {
|
||||
constructor(config: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
secretOrKey: config.getOrThrow<string>('GUEST_JWT_SECRET'),
|
||||
});
|
||||
}
|
||||
|
||||
validate(payload: GuestJwtPayload): GuestJwtPayload {
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
import { Prisma, Role } from '@prisma/client';
|
||||
import {
|
||||
GLOBAL_LT_KC_ID,
|
||||
resolveOrProvisionAuthentikUser,
|
||||
toAuthenticatedUser,
|
||||
} from './provision-user';
|
||||
|
||||
const CLAIMS = {
|
||||
sub: 'sub-1',
|
||||
email: 'New.Person@Example.org',
|
||||
firstName: 'New',
|
||||
lastName: 'Person',
|
||||
};
|
||||
|
||||
function p2002() {
|
||||
return new Prisma.PrismaClientKnownRequestError('unique', {
|
||||
code: 'P2002',
|
||||
clientVersion: 'test',
|
||||
});
|
||||
}
|
||||
|
||||
describe('resolveOrProvisionAuthentikUser', () => {
|
||||
it('returns the existing user without creating or capturing when nothing changed', async () => {
|
||||
const sync = { capture: jest.fn() };
|
||||
const existing = { id: 'u-1', authentikSub: 'sub-1', isLeitungsteam: false, memberships: [] };
|
||||
const prisma = {
|
||||
user: {
|
||||
findUnique: jest.fn().mockResolvedValue(existing),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
};
|
||||
const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false);
|
||||
expect(res).toBe(existing);
|
||||
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
expect(sync.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('provisions a new user from claims (lowercased email) and captures it', async () => {
|
||||
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
|
||||
const prisma = {
|
||||
user: {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn(({ data }: { data: Record<string, unknown> }) =>
|
||||
Promise.resolve({ id: 'u-2', isLeitungsteam: false, ...data }),
|
||||
),
|
||||
update: jest.fn(),
|
||||
},
|
||||
};
|
||||
const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false);
|
||||
expect(prisma.user.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
authentikSub: 'sub-1',
|
||||
email: 'new.person@example.org',
|
||||
firstName: 'New',
|
||||
lastName: 'Person',
|
||||
},
|
||||
});
|
||||
expect(res.memberships).toEqual([]);
|
||||
expect(sync.capture).toHaveBeenCalledWith('User', 'CREATE', 'u-2', expect.anything());
|
||||
});
|
||||
|
||||
it('reconciles the LEITUNGSTEAM flag up when the token now has the group', async () => {
|
||||
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
|
||||
const existing = { id: 'u-1', authentikSub: 'sub-1', isLeitungsteam: false, memberships: [] };
|
||||
const prisma = {
|
||||
user: {
|
||||
findUnique: jest.fn().mockResolvedValue(existing),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(({ data }: { data: Record<string, unknown> }) =>
|
||||
Promise.resolve({ ...existing, ...data, memberships: [] }),
|
||||
),
|
||||
},
|
||||
};
|
||||
const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, true);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: 'u-1' },
|
||||
data: { isLeitungsteam: true },
|
||||
include: { memberships: { where: { status: 'ACTIVE' } } },
|
||||
});
|
||||
expect(res.isLeitungsteam).toBe(true);
|
||||
expect(sync.capture).toHaveBeenCalledWith('User', 'UPDATE', 'u-1', expect.anything());
|
||||
});
|
||||
|
||||
it('reconciles the LEITUNGSTEAM flag down when the group is gone', async () => {
|
||||
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
|
||||
const existing = { id: 'u-1', authentikSub: 'sub-1', isLeitungsteam: true, memberships: [] };
|
||||
const prisma = {
|
||||
user: {
|
||||
findUnique: jest.fn().mockResolvedValue(existing),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(({ data }: { data: Record<string, unknown> }) =>
|
||||
Promise.resolve({ ...existing, ...data, memberships: [] }),
|
||||
),
|
||||
},
|
||||
};
|
||||
await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: { isLeitungsteam: false } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('recovers from a concurrent-create race (P2002) by re-reading', async () => {
|
||||
const sync = { capture: jest.fn() };
|
||||
const raced = { id: 'u-3', authentikSub: 'sub-1', isLeitungsteam: false, memberships: [] };
|
||||
const prisma = {
|
||||
user: {
|
||||
findUnique: jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(raced),
|
||||
create: jest.fn().mockRejectedValue(p2002()),
|
||||
update: jest.fn(),
|
||||
},
|
||||
};
|
||||
const res = await resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false);
|
||||
expect(res).toBe(raced);
|
||||
expect(sync.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rethrows a P2002 when the row still cannot be found', async () => {
|
||||
const sync = { capture: jest.fn() };
|
||||
const prisma = {
|
||||
user: {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn().mockRejectedValue(p2002()),
|
||||
update: jest.fn(),
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false),
|
||||
).rejects.toBeInstanceOf(Prisma.PrismaClientKnownRequestError);
|
||||
});
|
||||
|
||||
it('rethrows a non-P2002 error', async () => {
|
||||
const sync = { capture: jest.fn() };
|
||||
const prisma = {
|
||||
user: {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn().mockRejectedValue(new Error('db down')),
|
||||
update: jest.fn(),
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
resolveOrProvisionAuthentikUser(prisma as never, sync as never, CLAIMS, false),
|
||||
).rejects.toThrow('db down');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toAuthenticatedUser', () => {
|
||||
const row = {
|
||||
id: 'u-1',
|
||||
authentikSub: 'sub-1',
|
||||
email: 'a@b.org',
|
||||
isLeitungsteam: false,
|
||||
memberships: [
|
||||
{ kcId: 'kc-1', gemeindeId: 'gem-1', role: Role.GEMEINDE_VERANTWORTLICHER },
|
||||
],
|
||||
};
|
||||
|
||||
it('maps membership rows straight through when not Leitungsteam', () => {
|
||||
const res = toAuthenticatedUser(row as never);
|
||||
expect(res.memberships).toEqual([
|
||||
{ kcId: 'kc-1', gemeindeId: 'gem-1', role: Role.GEMEINDE_VERANTWORTLICHER },
|
||||
]);
|
||||
});
|
||||
|
||||
it('prepends a synthetic global LEITUNGSTEAM membership when the flag is set', () => {
|
||||
const res = toAuthenticatedUser({ ...row, isLeitungsteam: true } as never);
|
||||
expect(res.memberships[0]).toEqual({
|
||||
kcId: GLOBAL_LT_KC_ID,
|
||||
gemeindeId: null,
|
||||
role: Role.LEITUNGSTEAM,
|
||||
});
|
||||
expect(res.memberships).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
import { Prisma, Role, SyncOperation } from '@prisma/client';
|
||||
import { PrismaClient } from '../prisma/prisma.module';
|
||||
import { SyncService } from '../sync/sync.service';
|
||||
import { AuthenticatedUser } from './authenticated-request';
|
||||
|
||||
export interface AuthentikClaims {
|
||||
sub: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
}
|
||||
|
||||
/// Authentik users don't necessarily have an email set. Fall back to a stable,
|
||||
/// per-user placeholder so provisioning still has a unique handle for the row.
|
||||
export function authentikEmail(p: {
|
||||
email?: string;
|
||||
preferred_username?: string;
|
||||
sub: string;
|
||||
}): string {
|
||||
const e = p.email?.trim();
|
||||
if (e) return e.toLowerCase();
|
||||
return `${p.preferred_username?.trim() || p.sub}@no-email.authentik`.toLowerCase();
|
||||
}
|
||||
|
||||
/// Placeholder kcId for the synthetic, global LEITUNGSTEAM membership. RolesGuard
|
||||
/// never compares it (LT short-circuits the KC check), it only needs to exist.
|
||||
export const GLOBAL_LT_KC_ID = '*';
|
||||
|
||||
type UserWithActiveMemberships = Prisma.UserGetPayload<{
|
||||
include: { memberships: true };
|
||||
}>;
|
||||
|
||||
/// Resolves an Authentik identity to its local `User`, creating one from the
|
||||
/// token claims on first login (JIT provisioning), and reconciling the
|
||||
/// `isLeitungsteam` flag with the caller's current Authentik group membership
|
||||
/// on every login. A brand-new user has no `Membership` and therefore no
|
||||
/// rights until one is granted (the onboarding approval flow) or the LT flag
|
||||
/// is set. Shared by AuthentikStrategy and the WS token path so both behave
|
||||
/// identically.
|
||||
export async function resolveOrProvisionAuthentikUser(
|
||||
prisma: PrismaClient,
|
||||
sync: SyncService,
|
||||
claims: AuthentikClaims,
|
||||
isLeitungsteam: boolean,
|
||||
): Promise<UserWithActiveMemberships> {
|
||||
const user = await loadOrCreate(prisma, sync, claims);
|
||||
|
||||
if (user.isLeitungsteam !== isLeitungsteam) {
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { isLeitungsteam },
|
||||
include: { memberships: { where: { status: 'ACTIVE' } } },
|
||||
});
|
||||
await sync.capture('User', SyncOperation.UPDATE, updated.id, {
|
||||
...updated,
|
||||
memberships: undefined,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
async function loadOrCreate(
|
||||
prisma: PrismaClient,
|
||||
sync: SyncService,
|
||||
claims: AuthentikClaims,
|
||||
): Promise<UserWithActiveMemberships> {
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { authentikSub: claims.sub },
|
||||
include: { memberships: { where: { status: 'ACTIVE' } } },
|
||||
});
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
authentikSub: claims.sub,
|
||||
email: claims.email.toLowerCase(),
|
||||
firstName: claims.firstName,
|
||||
lastName: claims.lastName,
|
||||
},
|
||||
});
|
||||
await sync.capture('User', SyncOperation.CREATE, user.id, user);
|
||||
return { ...user, memberships: [] };
|
||||
} catch (err) {
|
||||
// Lost a race with a concurrent first login — the row exists now.
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { authentikSub: claims.sub },
|
||||
include: { memberships: { where: { status: 'ACTIVE' } } },
|
||||
});
|
||||
if (user) {
|
||||
return user;
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a provisioned user row to the request-scoped shape, prepending a
|
||||
/// synthetic global LEITUNGSTEAM membership when the flag is set.
|
||||
export function toAuthenticatedUser(user: UserWithActiveMemberships): AuthenticatedUser {
|
||||
const memberships = user.memberships.map((m) => ({
|
||||
kcId: m.kcId,
|
||||
gemeindeId: m.gemeindeId,
|
||||
role: m.role,
|
||||
}));
|
||||
if (user.isLeitungsteam) {
|
||||
memberships.unshift({
|
||||
kcId: GLOBAL_LT_KC_ID,
|
||||
gemeindeId: null,
|
||||
role: Role.LEITUNGSTEAM,
|
||||
});
|
||||
}
|
||||
return {
|
||||
userId: user.id,
|
||||
authentikSub: user.authentikSub,
|
||||
email: user.email,
|
||||
memberships,
|
||||
};
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
import {
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Role } from '@prisma/client';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { TeamAuthService } from './team-auth.service';
|
||||
|
||||
/// Covers the branching in invite redemption and password login. Prisma and
|
||||
/// SyncService are faked in memory; bcrypt/jsonwebtoken run for real.
|
||||
|
||||
const SECRET = 'test-team-secret';
|
||||
|
||||
interface InviteRow {
|
||||
id: string;
|
||||
kcId: string;
|
||||
gemeindeId: string;
|
||||
token: string;
|
||||
email: string | null;
|
||||
maxUses: number | null;
|
||||
usedCount: number;
|
||||
expiresAt: Date | null;
|
||||
revokedAt: Date | null;
|
||||
}
|
||||
|
||||
function makeService(seed: {
|
||||
invites?: InviteRow[];
|
||||
users?: { id: string; email: string; passwordHash: string | null }[];
|
||||
}) {
|
||||
const invites = [...(seed.invites ?? [])];
|
||||
const users = [...(seed.users ?? [])].map((u) => ({
|
||||
firstName: 'X',
|
||||
lastName: 'Y',
|
||||
authentikSub: null,
|
||||
kcId: null,
|
||||
createdAt: new Date(),
|
||||
memberships: [] as unknown[],
|
||||
...u,
|
||||
}));
|
||||
|
||||
const prisma = {
|
||||
user: {
|
||||
findUnique: jest.fn(({ where }: { where: { email?: string; id?: string } }) =>
|
||||
Promise.resolve(
|
||||
users.find(
|
||||
(u) =>
|
||||
(where.email !== undefined && u.email === where.email) ||
|
||||
(where.id !== undefined && u.id === where.id),
|
||||
) ?? null,
|
||||
),
|
||||
),
|
||||
findFirst: jest.fn(({ where }: { where: { id: string } }) =>
|
||||
Promise.resolve(users.find((u) => u.id === where.id && u.passwordHash) ?? null),
|
||||
),
|
||||
create: jest.fn(({ data }: { data: Record<string, unknown> }) => {
|
||||
const row = { id: `u-${users.length + 1}`, memberships: [], ...data } as never;
|
||||
users.push(row);
|
||||
return Promise.resolve(row);
|
||||
}),
|
||||
},
|
||||
membership: {
|
||||
create: jest.fn(({ data }: { data: Record<string, unknown> }) =>
|
||||
Promise.resolve({ id: `m-1`, ...data }),
|
||||
),
|
||||
},
|
||||
teamerInvite: {
|
||||
findUnique: jest.fn(({ where }: { where: { token: string } }) =>
|
||||
Promise.resolve(invites.find((i) => i.token === where.token) ?? null),
|
||||
),
|
||||
update: jest.fn(({ where, data }: { where: { id: string }; data: { usedCount: { increment: number } } }) => {
|
||||
const inv = invites.find((i) => i.id === where.id)!;
|
||||
inv.usedCount += data.usedCount.increment;
|
||||
return Promise.resolve(inv);
|
||||
}),
|
||||
},
|
||||
};
|
||||
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
|
||||
const config = { getOrThrow: jest.fn().mockReturnValue(SECRET) };
|
||||
|
||||
const service = new TeamAuthService(prisma as never, config as never, sync as never);
|
||||
return { service, prisma, sync, users, invites };
|
||||
}
|
||||
|
||||
function invite(overrides: Partial<InviteRow> = {}): InviteRow {
|
||||
return {
|
||||
id: 'inv-1',
|
||||
kcId: 'kc-1',
|
||||
gemeindeId: 'gem-1',
|
||||
token: 'tok-1',
|
||||
email: null,
|
||||
maxUses: null,
|
||||
usedCount: 0,
|
||||
expiresAt: null,
|
||||
revokedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const base = {
|
||||
token: 'tok-1',
|
||||
firstName: 'Mara',
|
||||
lastName: 'Klein',
|
||||
password: 'supersecret',
|
||||
};
|
||||
|
||||
describe('TeamAuthService.registerFromInvite', () => {
|
||||
it('rejects an unknown token', async () => {
|
||||
const { service } = makeService({ invites: [] });
|
||||
await expect(
|
||||
service.registerFromInvite({ ...base, email: 'm@example.org' }),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('rejects a revoked invite', async () => {
|
||||
const { service } = makeService({ invites: [invite({ revokedAt: new Date() })] });
|
||||
await expect(
|
||||
service.registerFromInvite({ ...base, email: 'm@example.org' }),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('rejects an expired invite', async () => {
|
||||
const { service } = makeService({
|
||||
invites: [invite({ expiresAt: new Date(Date.now() - 1000) })],
|
||||
});
|
||||
await expect(
|
||||
service.registerFromInvite({ ...base, email: 'm@example.org' }),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('rejects an invite that is used up', async () => {
|
||||
const { service } = makeService({
|
||||
invites: [invite({ maxUses: 2, usedCount: 2 })],
|
||||
});
|
||||
await expect(
|
||||
service.registerFromInvite({ ...base, email: 'm@example.org' }),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('requires an email for a group-link invite', async () => {
|
||||
const { service } = makeService({ invites: [invite({ email: null })] });
|
||||
await expect(service.registerFromInvite({ ...base })).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an email that does not match a personal invite', async () => {
|
||||
const { service } = makeService({
|
||||
invites: [invite({ email: 'pinned@example.org' })],
|
||||
});
|
||||
await expect(
|
||||
service.registerFromInvite({ ...base, email: 'other@example.org' }),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('rejects when an account with that email already exists', async () => {
|
||||
const { service } = makeService({
|
||||
invites: [invite()],
|
||||
users: [{ id: 'u-x', email: 'm@example.org', passwordHash: 'h' }],
|
||||
});
|
||||
await expect(
|
||||
service.registerFromInvite({ ...base, email: 'm@example.org' }),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('creates a local Teamer + GEMEINDE_TEAMER membership and burns one use', async () => {
|
||||
const { service, prisma, sync, invites } = makeService({ invites: [invite()] });
|
||||
const res = await service.registerFromInvite({ ...base, email: 'M@Example.org' });
|
||||
|
||||
expect(res.accessToken).toEqual(expect.any(String));
|
||||
expect(prisma.user.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
email: 'm@example.org',
|
||||
kcId: 'kc-1',
|
||||
passwordHash: expect.any(String),
|
||||
}),
|
||||
});
|
||||
const createdHash = prisma.user.create.mock.calls[0][0].data.passwordHash as string;
|
||||
expect(await bcrypt.compare('supersecret', createdHash)).toBe(true);
|
||||
expect(prisma.membership.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
kcId: 'kc-1',
|
||||
gemeindeId: 'gem-1',
|
||||
role: Role.GEMEINDE_TEAMER,
|
||||
}),
|
||||
});
|
||||
expect(invites[0].usedCount).toBe(1);
|
||||
expect(sync.capture).toHaveBeenCalledWith('User', 'CREATE', expect.any(String), expect.anything());
|
||||
expect(sync.capture).toHaveBeenCalledWith('Membership', 'CREATE', expect.any(String), expect.anything());
|
||||
expect(sync.capture).toHaveBeenCalledWith('TeamerInvite', 'UPDATE', expect.any(String), expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
describe('TeamAuthService.login', () => {
|
||||
it('rejects an unknown email', async () => {
|
||||
const { service } = makeService({ users: [] });
|
||||
await expect(service.login('nobody@example.org', 'x')).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a user without a password hash (Authentik-only account)', async () => {
|
||||
const { service } = makeService({
|
||||
users: [{ id: 'u-1', email: 'lt@example.org', passwordHash: null }],
|
||||
});
|
||||
await expect(service.login('lt@example.org', 'x')).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a wrong password', async () => {
|
||||
const { service } = makeService({
|
||||
users: [
|
||||
{ id: 'u-1', email: 't@example.org', passwordHash: bcrypt.hashSync('right', 10) },
|
||||
],
|
||||
});
|
||||
await expect(service.login('t@example.org', 'wrong')).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('issues a token for correct credentials', async () => {
|
||||
const { service } = makeService({
|
||||
users: [
|
||||
{ id: 'u-1', email: 't@example.org', passwordHash: bcrypt.hashSync('right', 10) },
|
||||
],
|
||||
});
|
||||
const res = await service.login('T@example.org', 'right');
|
||||
expect(res.accessToken).toEqual(expect.any(String));
|
||||
});
|
||||
});
|
||||
@@ -1,154 +0,0 @@
|
||||
import {
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Role, SyncOperation } from '@prisma/client';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import { PrismaClient } from '../prisma/prisma.module';
|
||||
import { SyncService } from '../sync/sync.service';
|
||||
import { AuthenticatedUser } from './authenticated-request';
|
||||
import { toAuthenticatedUser } from './provision-user';
|
||||
|
||||
export interface TeamJwtPayload {
|
||||
sub: string;
|
||||
typ: 'team';
|
||||
}
|
||||
|
||||
const TOKEN_TTL = '12h';
|
||||
const BCRYPT_ROUNDS = 10;
|
||||
|
||||
/// Local (non-Authentik) auth for Gemeinde Teamer: password login plus
|
||||
/// redemption of a TeamerInvite issued by a Gemeinde Verantwortliche/r. Team
|
||||
/// tokens are signed with TEAM_JWT_SECRET and carry `typ: 'team'` so they are
|
||||
/// never mistaken for a guest token.
|
||||
@Injectable()
|
||||
export class TeamAuthService {
|
||||
private readonly secret: string;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly config: ConfigService,
|
||||
private readonly sync: SyncService,
|
||||
) {
|
||||
this.secret = config.getOrThrow<string>('TEAM_JWT_SECRET');
|
||||
}
|
||||
|
||||
async login(email: string, password: string): Promise<{ accessToken: string }> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { email: email.toLowerCase() },
|
||||
include: { memberships: true },
|
||||
});
|
||||
if (!user || !user.passwordHash) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
const ok = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!ok) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
return { accessToken: this.sign(user.id) };
|
||||
}
|
||||
|
||||
/// Redeems an invite token and creates the local Teamer account + its
|
||||
/// GEMEINDE_TEAMER membership for the invite's Gemeinde.
|
||||
async registerFromInvite(input: {
|
||||
token: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
password: string;
|
||||
email?: string;
|
||||
}): Promise<{ accessToken: string }> {
|
||||
const invite = await this.prisma.teamerInvite.findUnique({
|
||||
where: { token: input.token },
|
||||
});
|
||||
if (!invite || invite.revokedAt) {
|
||||
throw new NotFoundException('Unknown or revoked invite');
|
||||
}
|
||||
if (invite.expiresAt && invite.expiresAt.getTime() < Date.now()) {
|
||||
throw new ForbiddenException('Invite has expired');
|
||||
}
|
||||
if (invite.maxUses !== null && invite.usedCount >= invite.maxUses) {
|
||||
throw new ForbiddenException('Invite has already been used up');
|
||||
}
|
||||
|
||||
if (
|
||||
invite.email &&
|
||||
input.email &&
|
||||
input.email.toLowerCase() !== invite.email.toLowerCase()
|
||||
) {
|
||||
throw new ForbiddenException('Email does not match this invite');
|
||||
}
|
||||
const email = (invite.email ?? input.email ?? '').toLowerCase();
|
||||
if (!email) {
|
||||
throw new ConflictException('This invite requires an email address');
|
||||
}
|
||||
if (await this.prisma.user.findUnique({ where: { email } })) {
|
||||
throw new ConflictException('An account with this email already exists');
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(input.password, BCRYPT_ROUNDS);
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
firstName: input.firstName,
|
||||
lastName: input.lastName,
|
||||
passwordHash,
|
||||
kcId: invite.kcId,
|
||||
},
|
||||
});
|
||||
const membership = await this.prisma.membership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kcId: invite.kcId,
|
||||
gemeindeId: invite.gemeindeId,
|
||||
role: Role.GEMEINDE_TEAMER,
|
||||
},
|
||||
});
|
||||
const updatedInvite = await this.prisma.teamerInvite.update({
|
||||
where: { id: invite.id },
|
||||
data: { usedCount: { increment: 1 } },
|
||||
});
|
||||
|
||||
await this.sync.capture('User', SyncOperation.CREATE, user.id, user);
|
||||
await this.sync.capture('Membership', SyncOperation.CREATE, membership.id, membership);
|
||||
await this.sync.capture('TeamerInvite', SyncOperation.UPDATE, updatedInvite.id, updatedInvite);
|
||||
|
||||
return { accessToken: this.sign(user.id) };
|
||||
}
|
||||
|
||||
private sign(userId: string): string {
|
||||
const payload: TeamJwtPayload = { sub: userId, typ: 'team' };
|
||||
return jwt.sign(payload, this.secret, { expiresIn: TOKEN_TTL });
|
||||
}
|
||||
|
||||
/// Verifies a raw team token (used by the WS handshake path, outside passport).
|
||||
async verify(token: string): Promise<AuthenticatedUser> {
|
||||
let payload: TeamJwtPayload;
|
||||
try {
|
||||
payload = jwt.verify(token, this.secret) as TeamJwtPayload;
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid team token');
|
||||
}
|
||||
if (payload.typ !== 'team' || !payload.sub) {
|
||||
throw new UnauthorizedException('Not a team token');
|
||||
}
|
||||
return this.resolve(payload.sub);
|
||||
}
|
||||
|
||||
async resolve(userId: string): Promise<AuthenticatedUser> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { id: userId, passwordHash: { not: null } },
|
||||
include: { memberships: { where: { status: 'ACTIVE' } } },
|
||||
});
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Team account no longer exists');
|
||||
}
|
||||
// Same shape as the Authentik path, incl. the synthetic global
|
||||
// LEITUNGSTEAM membership when `isLeitungsteam` is set on the row.
|
||||
return toAuthenticatedUser(user);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { AuthenticatedUser } from './authenticated-request';
|
||||
import { TeamAuthService, TeamJwtPayload } from './team-auth.service';
|
||||
|
||||
/// Verifies the local JWT issued to Gemeinde Teamer by TeamAuthService and
|
||||
/// resolves it to the same AuthenticatedUser shape as AuthentikStrategy, so
|
||||
/// downstream RolesGuard / controllers treat both member kinds identically.
|
||||
@Injectable()
|
||||
export class TeamJwtStrategy extends PassportStrategy(Strategy, 'team') {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
private readonly teamAuth: TeamAuthService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
secretOrKey: config.getOrThrow<string>('TEAM_JWT_SECRET'),
|
||||
});
|
||||
}
|
||||
|
||||
validate(payload: TeamJwtPayload): Promise<AuthenticatedUser> {
|
||||
return this.teamAuth.resolve(payload.sub);
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import * as jwksRsa from 'jwks-rsa';
|
||||
import { PrismaClient } from '../prisma/prisma.module';
|
||||
import { SyncService } from '../sync/sync.service';
|
||||
import { AuthenticatedUser } from './authenticated-request';
|
||||
import { GuestJwtPayload } from './guest-auth.service';
|
||||
import { TeamAuthService } from './team-auth.service';
|
||||
import {
|
||||
AuthentikClaims,
|
||||
authentikEmail,
|
||||
resolveOrProvisionAuthentikUser,
|
||||
toAuthenticatedUser,
|
||||
} from './provision-user';
|
||||
|
||||
/// Verifies raw bearer tokens outside the HTTP/passport pipeline, needed for
|
||||
/// the WebSocket handshake where AuthGuard('authentik'|'guest') don't apply.
|
||||
@Injectable()
|
||||
export class TokenVerificationService {
|
||||
private readonly issuerUrl: string;
|
||||
private readonly jwks: jwksRsa.JwksClient;
|
||||
private readonly leitungsteamGroup: string;
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly guestJwt: JwtService,
|
||||
private readonly teamAuth: TeamAuthService,
|
||||
private readonly sync: SyncService,
|
||||
) {
|
||||
// See AuthentikStrategy: normalise the trailing slash, accept both forms.
|
||||
this.issuerUrl = config.getOrThrow<string>('AUTHENTIK_ISSUER_URL').replace(/\/+$/, '');
|
||||
this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true });
|
||||
this.leitungsteamGroup = config.get<string>('AUTHENTIK_LEITUNGSTEAM_GROUP', 'Leitungsteam');
|
||||
}
|
||||
|
||||
/// Verifies an Authentik token's signature and returns its identity claims
|
||||
/// plus whether the caller is in the Leitungsteam group, without requiring
|
||||
/// a local User to exist yet (used by the onboarding self-registration
|
||||
/// path, which provisions that User).
|
||||
async verifyAuthentikClaims(
|
||||
token: string,
|
||||
): Promise<AuthentikClaims & { isLeitungsteam: boolean }> {
|
||||
const decoded = jwt.decode(token, { complete: true });
|
||||
const kid = decoded?.header.kid;
|
||||
if (!kid) {
|
||||
throw new UnauthorizedException('Malformed Authentik token');
|
||||
}
|
||||
const key = await this.jwks.getSigningKey(kid);
|
||||
const payload = jwt.verify(token, key.getPublicKey(), {
|
||||
issuer: [this.issuerUrl, `${this.issuerUrl}/`],
|
||||
algorithms: ['RS256'],
|
||||
}) as jwt.JwtPayload & {
|
||||
email?: string;
|
||||
given_name?: string;
|
||||
family_name?: string;
|
||||
preferred_username?: string;
|
||||
name?: string;
|
||||
groups?: string[];
|
||||
};
|
||||
const sub = payload.sub;
|
||||
if (!sub) {
|
||||
throw new UnauthorizedException('Authentik token missing subject');
|
||||
}
|
||||
return {
|
||||
sub,
|
||||
email: authentikEmail({
|
||||
email: payload.email,
|
||||
preferred_username: payload.preferred_username,
|
||||
sub,
|
||||
}),
|
||||
firstName: payload.given_name ?? payload.preferred_username ?? payload.name ?? '',
|
||||
lastName: payload.family_name ?? '',
|
||||
isLeitungsteam: (payload.groups ?? []).includes(this.leitungsteamGroup),
|
||||
};
|
||||
}
|
||||
|
||||
async verifyAuthentik(token: string): Promise<AuthenticatedUser> {
|
||||
const { isLeitungsteam, ...claims } = await this.verifyAuthentikClaims(token);
|
||||
const user = await resolveOrProvisionAuthentikUser(
|
||||
this.prisma,
|
||||
this.sync,
|
||||
claims,
|
||||
isLeitungsteam,
|
||||
);
|
||||
return toAuthenticatedUser(user);
|
||||
}
|
||||
|
||||
async verifyGuest(token: string): Promise<GuestJwtPayload> {
|
||||
return this.guestJwt.verifyAsync<GuestJwtPayload>(token);
|
||||
}
|
||||
|
||||
/// Tries Authentik, then a local team (Teamer) token, then a guest token.
|
||||
async verifyEither(token: string): Promise<
|
||||
{ kind: 'user'; user: AuthenticatedUser } | { kind: 'guest'; guest: GuestJwtPayload }
|
||||
> {
|
||||
try {
|
||||
return { kind: 'user', user: await this.verifyAuthentik(token) };
|
||||
} catch {
|
||||
// not an Authentik token
|
||||
}
|
||||
try {
|
||||
return { kind: 'user', user: await this.teamAuth.verify(token) };
|
||||
} catch {
|
||||
return { kind: 'guest', guest: await this.verifyGuest(token) };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { AuthenticatedUser } from '../auth/authenticated-request';
|
||||
import { GuestJwtPayload } from '../auth/guest-auth.service';
|
||||
import { ChatCaller } from './chat.service';
|
||||
|
||||
function isGuestPayload(user: unknown): user is GuestJwtPayload {
|
||||
return !!user && typeof user === 'object' && 'guestId' in user;
|
||||
}
|
||||
|
||||
/// req.user is either an AuthenticatedUser (Authentik) or a GuestJwtPayload,
|
||||
/// depending on which strategy AuthGuard(['authentik','guest']) picked.
|
||||
export function resolveChatCaller(user: AuthenticatedUser | GuestJwtPayload): ChatCaller {
|
||||
return isGuestPayload(user) ? { kind: 'guest', guest: user } : { kind: 'user', user };
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Body, Controller, Get, Param, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { ChatService } from './chat.service';
|
||||
import { CreateChannelDto } from './dto/create-channel.dto';
|
||||
import { CreateDirectChannelDto } from './dto/create-direct-channel.dto';
|
||||
import { Roles } from '../common/roles.decorator';
|
||||
import { RolesGuard } from '../common/roles.guard';
|
||||
import { Role } from '../common/role.enum';
|
||||
import { AuthenticatedRequest } from '../auth/authenticated-request';
|
||||
import { GuestJwtPayload } from '../auth/guest-auth.service';
|
||||
import { resolveChatCaller } from './caller.util';
|
||||
|
||||
type ChatRequest = AuthenticatedRequest & { user?: AuthenticatedRequest['user'] | GuestJwtPayload };
|
||||
|
||||
@Controller('chat')
|
||||
export class ChatController {
|
||||
constructor(private readonly chat: ChatService) {}
|
||||
|
||||
/// Channel administration (Gemeinde-Gruppen, LT-Kanäle, Broadcasts) is Leitungsteam-only.
|
||||
@Post(':kcId/channels')
|
||||
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
||||
@Roles(Role.LEITUNGSTEAM)
|
||||
createChannel(@Param('kcId') kcId: string, @Body() dto: CreateChannelDto) {
|
||||
return this.chat.createChannel(kcId, dto.type, dto.gemeindeId);
|
||||
}
|
||||
|
||||
/// Any two team members of the same KC can start a direct conversation
|
||||
/// (Authentik-backed members and local Gemeinde Teamer alike).
|
||||
@Post('direct')
|
||||
@UseGuards(AuthGuard(['authentik', 'team']))
|
||||
createDirectChannel(@Body() dto: CreateDirectChannelDto, @Req() req: AuthenticatedRequest) {
|
||||
return this.chat.getOrCreateDirectChannel(dto.kcId, req.user!.userId, dto.otherUserId);
|
||||
}
|
||||
|
||||
@Get(':kcId/channels')
|
||||
@UseGuards(AuthGuard(['authentik', 'team', 'guest']))
|
||||
listChannels(@Param('kcId') kcId: string, @Req() req: ChatRequest) {
|
||||
return this.chat.listChannelsForCaller(kcId, resolveChatCaller(req.user!));
|
||||
}
|
||||
|
||||
@Get('channels/:channelId/messages')
|
||||
@UseGuards(AuthGuard(['authentik', 'team', 'guest']))
|
||||
listMessages(@Param('channelId') channelId: string, @Req() req: ChatRequest) {
|
||||
return this.chat.listMessages(channelId, resolveChatCaller(req.user!));
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import {
|
||||
ConnectedSocket,
|
||||
MessageBody,
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
SubscribeMessage,
|
||||
WebSocketGateway,
|
||||
} from '@nestjs/websockets';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { IncomingMessage } from 'http';
|
||||
import { WebSocket } from 'ws';
|
||||
import { TokenVerificationService } from '../auth/token-verification.service';
|
||||
import { ChatCaller, ChatService } from './chat.service';
|
||||
|
||||
/// Raw `ws` gateway (no socket.io rooms available), so channel membership is
|
||||
/// tracked manually per connected socket. Auth happens once at handshake via
|
||||
/// a `?token=` query param since passport guards don't run for WS upgrades.
|
||||
///
|
||||
/// The per-socket caller is stored as a *promise*: the token check is async
|
||||
/// and a client can send `chat:join` before it resolves, so handlers await
|
||||
/// the stored promise instead of assuming it's already populated.
|
||||
@WebSocketGateway({ path: '/chat' })
|
||||
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
private readonly logger = new Logger(ChatGateway.name);
|
||||
private readonly callers = new Map<WebSocket, Promise<ChatCaller>>();
|
||||
private readonly rooms = new Map<string, Set<WebSocket>>();
|
||||
|
||||
constructor(
|
||||
private readonly tokenVerification: TokenVerificationService,
|
||||
private readonly chat: ChatService,
|
||||
) {}
|
||||
|
||||
handleConnection(client: WebSocket, request: IncomingMessage) {
|
||||
const token = new URL(request.url ?? '', 'http://localhost').searchParams.get('token');
|
||||
if (!token) {
|
||||
client.close(4001, 'Missing token');
|
||||
return;
|
||||
}
|
||||
const pending = this.tokenVerification.verifyEither(token).catch((err) => {
|
||||
this.logger.warn(`WS auth failed: ${(err as Error).message}`);
|
||||
client.close(4001, 'Unauthorized');
|
||||
throw err;
|
||||
});
|
||||
this.callers.set(client, pending);
|
||||
}
|
||||
|
||||
handleDisconnect(client: WebSocket) {
|
||||
this.callers.delete(client);
|
||||
for (const members of this.rooms.values()) {
|
||||
members.delete(client);
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage('chat:join')
|
||||
async onJoin(
|
||||
@ConnectedSocket() client: WebSocket,
|
||||
@MessageBody() data: { channelId: string },
|
||||
) {
|
||||
const caller = await this.resolveCaller(client);
|
||||
await this.chat.assertCanRead(data.channelId, caller);
|
||||
this.roomFor(data.channelId).add(client);
|
||||
return { event: 'chat:joined', data: { channelId: data.channelId } };
|
||||
}
|
||||
|
||||
@SubscribeMessage('chat:send')
|
||||
async onSend(
|
||||
@ConnectedSocket() client: WebSocket,
|
||||
@MessageBody() data: { channelId: string; body: string },
|
||||
) {
|
||||
const caller = await this.resolveCaller(client);
|
||||
const message = await this.chat.sendMessage(data.channelId, caller, data.body);
|
||||
this.broadcast(data.channelId, { event: 'chat:message', data: message });
|
||||
return { event: 'chat:sent', data: { id: message.id } };
|
||||
}
|
||||
|
||||
private async resolveCaller(client: WebSocket): Promise<ChatCaller> {
|
||||
const pending = this.callers.get(client);
|
||||
if (!pending) {
|
||||
client.close(4001, 'Unauthorized');
|
||||
throw new Error('Unauthorized WS client');
|
||||
}
|
||||
try {
|
||||
return await pending;
|
||||
} catch {
|
||||
client.close(4001, 'Unauthorized');
|
||||
throw new Error('Unauthorized WS client');
|
||||
}
|
||||
}
|
||||
|
||||
private roomFor(channelId: string): Set<WebSocket> {
|
||||
let room = this.rooms.get(channelId);
|
||||
if (!room) {
|
||||
room = new Set();
|
||||
this.rooms.set(channelId, room);
|
||||
}
|
||||
return room;
|
||||
}
|
||||
|
||||
private broadcast(channelId: string, payload: unknown) {
|
||||
const room = this.rooms.get(channelId);
|
||||
if (!room) return;
|
||||
const json = JSON.stringify(payload);
|
||||
for (const socket of room) {
|
||||
if (socket.readyState === socket.OPEN) {
|
||||
socket.send(json);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { ChatService } from './chat.service';
|
||||
import { ChatGateway } from './chat.gateway';
|
||||
import { ChatController } from './chat.controller';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [ChatController],
|
||||
providers: [ChatService, ChatGateway],
|
||||
})
|
||||
export class ChatModule {}
|
||||
@@ -1,188 +0,0 @@
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ChatChannelType, Role, SyncOperation } from '@prisma/client';
|
||||
import { PrismaClient } from '../prisma/prisma.module';
|
||||
import { AuthenticatedUser } from '../auth/authenticated-request';
|
||||
import { GuestJwtPayload } from '../auth/guest-auth.service';
|
||||
import { SyncService } from '../sync/sync.service';
|
||||
import { PushService } from '../push/push.service';
|
||||
|
||||
export type ChatCaller =
|
||||
| { kind: 'user'; user: AuthenticatedUser }
|
||||
| { kind: 'guest'; guest: GuestJwtPayload };
|
||||
|
||||
const CHANNEL_TITLES: Record<ChatChannelType, string> = {
|
||||
[ChatChannelType.GEMEINDE_GRUPPE]: 'Gemeinde-Gruppe',
|
||||
[ChatChannelType.DIREKT]: 'Direktnachricht',
|
||||
[ChatChannelType.LT_UEBERGREIFEND]: 'Leitungsteam',
|
||||
[ChatChannelType.BROADCAST]: 'Ankündigung',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ChatService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly sync: SyncService,
|
||||
private readonly push: PushService,
|
||||
) {}
|
||||
|
||||
async createChannel(kcId: string, type: ChatChannelType, gemeindeId?: string) {
|
||||
const channel = await this.prisma.chatChannel.create({ data: { kcId, type, gemeindeId } });
|
||||
await this.sync.capture('ChatChannel', SyncOperation.CREATE, channel.id, channel);
|
||||
return channel;
|
||||
}
|
||||
|
||||
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) {
|
||||
const channel = await this.assertCanWrite(channelId, caller);
|
||||
const message = await this.prisma.chatMessage.create({
|
||||
data: {
|
||||
channelId,
|
||||
body,
|
||||
senderUserId: caller.kind === 'user' ? caller.user.userId : null,
|
||||
senderGuestId: caller.kind === 'guest' ? caller.guest.guestId : null,
|
||||
},
|
||||
});
|
||||
await this.sync.capture('ChatMessage', SyncOperation.CREATE, message.id, message);
|
||||
|
||||
void this.push.notifyChannel(
|
||||
channelId,
|
||||
{
|
||||
title: CHANNEL_TITLES[channel?.type ?? ChatChannelType.GEMEINDE_GRUPPE],
|
||||
body: body.length > 140 ? `${body.slice(0, 137)}…` : body,
|
||||
data: { channelId },
|
||||
},
|
||||
{
|
||||
userId: caller.kind === 'user' ? caller.user.userId : null,
|
||||
guestId: caller.kind === 'guest' ? caller.guest.guestId : null,
|
||||
},
|
||||
);
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
async listMessages(channelId: string, caller: ChatCaller) {
|
||||
await this.assertCanRead(channelId, caller);
|
||||
return this.prisma.chatMessage.findMany({
|
||||
where: { channelId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { IsEnum, IsOptional, IsString } from 'class-validator';
|
||||
import { ChatChannelType } from '@prisma/client';
|
||||
|
||||
export class CreateChannelDto {
|
||||
@IsEnum(ChatChannelType)
|
||||
type!: ChatChannelType;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
gemeindeId?: string;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class CreateDirectChannelDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
kcId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
otherUserId!: string;
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
/// Re-exported from the Prisma client so guards and strategies share one
|
||||
/// enum type with the database schema. Guests are not part of this enum
|
||||
/// since they authenticate separately and never hold elevated rights.
|
||||
export { Role } from '@prisma/client';
|
||||
@@ -1,7 +0,0 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { Role } from './role.enum';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
|
||||
/// Marks a route as requiring at least one of the given roles (scope-checked by RolesGuard).
|
||||
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
|
||||
@@ -1,50 +0,0 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Role } from './role.enum';
|
||||
import { ROLES_KEY } from './roles.decorator';
|
||||
import { AuthenticatedRequest } from '../auth/authenticated-request';
|
||||
|
||||
/// Checks the caller holds one of the required roles, scoped to the KC in the
|
||||
/// request (route param `kcId`, falling back to body.kcId). LEITUNGSTEAM
|
||||
/// memberships are global and satisfy any KC scope.
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!requiredRoles || requiredRoles.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
const user = request.user;
|
||||
if (!user) {
|
||||
throw new ForbiddenException('Not authenticated');
|
||||
}
|
||||
|
||||
const kcId = request.params?.kcId ?? request.body?.kcId;
|
||||
const hasRole = user.memberships.some((membership) => {
|
||||
if (!requiredRoles.includes(membership.role)) {
|
||||
return false;
|
||||
}
|
||||
if (membership.role === Role.LEITUNGSTEAM) {
|
||||
return true;
|
||||
}
|
||||
return kcId ? membership.kcId === kcId : true;
|
||||
});
|
||||
|
||||
if (!hasRole) {
|
||||
throw new ForbiddenException('Insufficient role for this KC');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { IsEnum } from 'class-validator';
|
||||
import { FileVisibility } from '@prisma/client';
|
||||
|
||||
export class UploadFileDto {
|
||||
@IsEnum(FileVisibility)
|
||||
visibility!: FileVisibility;
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Response } from 'express';
|
||||
import { FilesService } from './files.service';
|
||||
import { UploadFileDto } from './dto/upload-file.dto';
|
||||
import { Roles } from '../common/roles.decorator';
|
||||
import { RolesGuard } from '../common/roles.guard';
|
||||
import { Role } from '../common/role.enum';
|
||||
import { AuthenticatedRequest } from '../auth/authenticated-request';
|
||||
import { GuestJwtPayload } from '../auth/guest-auth.service';
|
||||
import { allowedVisibilitiesForUser, GUEST_ALLOWED_VISIBILITIES } from './visibility.util';
|
||||
|
||||
type FileCallerRequest = AuthenticatedRequest & { user?: AuthenticatedRequest['user'] | GuestJwtPayload };
|
||||
|
||||
function isGuest(user: unknown): user is GuestJwtPayload {
|
||||
return !!user && typeof user === 'object' && 'guestId' in user;
|
||||
}
|
||||
|
||||
@Controller('files')
|
||||
export class FilesController {
|
||||
constructor(private readonly files: FilesService) {}
|
||||
|
||||
/// Only the Leitungsteam uploads files (per KC), tagged with a visibility tier.
|
||||
@Post(':kcId')
|
||||
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||
@Roles(Role.LEITUNGSTEAM)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
upload(
|
||||
@Param('kcId') kcId: string,
|
||||
@Body() dto: UploadFileDto,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
) {
|
||||
return this.files.upload(kcId, dto.visibility, file.originalname, file.buffer, req.user!.userId);
|
||||
}
|
||||
|
||||
@Get(':kcId')
|
||||
@UseGuards(AuthGuard(['authentik', 'team', 'guest']))
|
||||
list(@Param('kcId') kcId: string, @Req() req: FileCallerRequest) {
|
||||
const allowed = isGuest(req.user)
|
||||
? GUEST_ALLOWED_VISIBILITIES
|
||||
: allowedVisibilitiesForUser(req.user!, kcId);
|
||||
return this.files.listForCaller(kcId, allowed);
|
||||
}
|
||||
|
||||
@Get('download/:fileId')
|
||||
@UseGuards(AuthGuard(['authentik', 'team', 'guest']))
|
||||
async download(
|
||||
@Param('fileId') fileId: string,
|
||||
@Req() req: FileCallerRequest,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const meta = await this.files.getFileOrThrow(fileId);
|
||||
const allowed = isGuest(req.user)
|
||||
? GUEST_ALLOWED_VISIBILITIES
|
||||
: allowedVisibilitiesForUser(req.user!, meta.kcId);
|
||||
const { file, data } = await this.files.downloadForCaller(fileId, allowed);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${file.filename}"`);
|
||||
res.send(data);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { FilesService } from './files.service';
|
||||
import { FilesController } from './files.controller';
|
||||
import { STORAGE_PROVIDER } from './storage/storage-provider';
|
||||
import { WebDavStorageProvider } from './storage/webdav-storage.provider';
|
||||
import { S3StorageProvider } from './storage/s3-storage.provider';
|
||||
|
||||
@Module({
|
||||
controllers: [FilesController],
|
||||
providers: [
|
||||
FilesService,
|
||||
{
|
||||
// Nextcloud (WebDAV) is the default target; STORAGE_PROVIDER=s3 switches to S3-compatible storage.
|
||||
provide: STORAGE_PROVIDER,
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) =>
|
||||
config.get<string>('STORAGE_PROVIDER') === 's3'
|
||||
? new S3StorageProvider(config)
|
||||
: new WebDavStorageProvider(config),
|
||||
},
|
||||
],
|
||||
})
|
||||
export class FilesModule {}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { FileVisibility, SyncOperation } from '@prisma/client';
|
||||
import { PrismaClient } from '../prisma/prisma.module';
|
||||
import { STORAGE_PROVIDER, StorageProvider } from './storage/storage-provider';
|
||||
import { SyncService } from '../sync/sync.service';
|
||||
|
||||
@Injectable()
|
||||
export class FilesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
@Inject(STORAGE_PROVIDER) private readonly storage: StorageProvider,
|
||||
private readonly sync: SyncService,
|
||||
) {}
|
||||
|
||||
async upload(
|
||||
kcId: string,
|
||||
visibility: FileVisibility,
|
||||
filename: string,
|
||||
data: Buffer,
|
||||
uploadedById: string,
|
||||
) {
|
||||
const storageKey = await this.storage.upload(kcId, filename, data);
|
||||
const file = await this.prisma.file.create({
|
||||
data: { kcId, storageKey, filename, visibility, uploadedById },
|
||||
});
|
||||
// Note: only metadata is replicated here; storageKey only resolves if
|
||||
// local and cloud share the same Nextcloud/S3 backend (see sync docs).
|
||||
await this.sync.capture('File', SyncOperation.CREATE, file.id, file);
|
||||
return file;
|
||||
}
|
||||
|
||||
listForCaller(kcId: string, allowedVisibilities: FileVisibility[]) {
|
||||
return this.prisma.file.findMany({
|
||||
where: { kcId, visibility: { in: allowedVisibilities } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async downloadForCaller(fileId: string, allowedVisibilities: FileVisibility[]) {
|
||||
const file = await this.getFileOrThrow(fileId);
|
||||
if (!allowedVisibilities.includes(file.visibility)) {
|
||||
throw new ForbiddenException('Not permitted to access this file');
|
||||
}
|
||||
const data = await this.storage.download(file.storageKey);
|
||||
return { file, data };
|
||||
}
|
||||
|
||||
getFileOrThrow(fileId: string) {
|
||||
return this.prisma.file.findUniqueOrThrow({ where: { id: fileId } }).catch(() => {
|
||||
throw new NotFoundException('File not found');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { randomUUID } from 'crypto';
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { StorageProvider } from './storage-provider';
|
||||
|
||||
/// S3-compatible object storage (AWS S3, MinIO, etc.).
|
||||
@Injectable()
|
||||
export class S3StorageProvider implements StorageProvider {
|
||||
private readonly client: S3Client;
|
||||
private readonly bucket: string;
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
this.bucket = config.getOrThrow<string>('S3_BUCKET');
|
||||
this.client = new S3Client({
|
||||
region: config.get<string>('S3_REGION') ?? 'auto',
|
||||
endpoint: config.get<string>('S3_ENDPOINT'),
|
||||
forcePathStyle: config.get<string>('S3_FORCE_PATH_STYLE') === 'true',
|
||||
credentials: {
|
||||
accessKeyId: config.getOrThrow<string>('S3_ACCESS_KEY_ID'),
|
||||
secretAccessKey: config.getOrThrow<string>('S3_SECRET_ACCESS_KEY'),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async upload(kcId: string, filename: string, data: Buffer): Promise<string> {
|
||||
const storageKey = `${kcId}/${randomUUID()}-${filename}`;
|
||||
await this.client.send(
|
||||
new PutObjectCommand({ Bucket: this.bucket, Key: storageKey, Body: data }),
|
||||
);
|
||||
return storageKey;
|
||||
}
|
||||
|
||||
async download(storageKey: string): Promise<Buffer> {
|
||||
const result = await this.client.send(
|
||||
new GetObjectCommand({ Bucket: this.bucket, Key: storageKey }),
|
||||
);
|
||||
const chunks: Uint8Array[] = [];
|
||||
for await (const chunk of result.Body as AsyncIterable<Uint8Array>) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
async delete(storageKey: string): Promise<void> {
|
||||
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: storageKey }));
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
/// Abstraction over the external file storage backend (Nextcloud via WebDAV,
|
||||
/// or S3-compatible object storage). Implementations only need to move raw
|
||||
/// bytes; visibility/ownership metadata lives in the `File` Prisma model.
|
||||
export interface StorageProvider {
|
||||
upload(kcId: string, filename: string, data: Buffer): Promise<string>;
|
||||
download(storageKey: string): Promise<Buffer>;
|
||||
delete(storageKey: string): Promise<void>;
|
||||
}
|
||||
|
||||
export const STORAGE_PROVIDER = Symbol('STORAGE_PROVIDER');
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { createClient, WebDAVClient } from 'webdav';
|
||||
import { StorageProvider } from './storage-provider';
|
||||
|
||||
/// Nextcloud (or any WebDAV server) as file storage backend.
|
||||
@Injectable()
|
||||
export class WebDavStorageProvider implements StorageProvider {
|
||||
private readonly client: WebDAVClient;
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
this.client = createClient(config.getOrThrow<string>('WEBDAV_URL'), {
|
||||
username: config.getOrThrow<string>('WEBDAV_USERNAME'),
|
||||
password: config.getOrThrow<string>('WEBDAV_PASSWORD'),
|
||||
});
|
||||
}
|
||||
|
||||
async upload(kcId: string, filename: string, data: Buffer): Promise<string> {
|
||||
const dir = `/${kcId}`;
|
||||
if (!(await this.client.exists(dir))) {
|
||||
await this.client.createDirectory(dir, { recursive: true });
|
||||
}
|
||||
const storageKey = `${dir}/${randomUUID()}-${filename}`;
|
||||
await this.client.putFileContents(storageKey, data, { overwrite: false });
|
||||
return storageKey;
|
||||
}
|
||||
|
||||
async download(storageKey: string): Promise<Buffer> {
|
||||
const content = await this.client.getFileContents(storageKey);
|
||||
return Buffer.isBuffer(content) ? content : Buffer.from(content as ArrayBuffer);
|
||||
}
|
||||
|
||||
async delete(storageKey: string): Promise<void> {
|
||||
await this.client.deleteFile(storageKey);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { FileVisibility, Role } from '@prisma/client';
|
||||
import { AuthenticatedUser } from '../auth/authenticated-request';
|
||||
|
||||
/// Maps the caller's role for a given KC to the file visibility tiers they may see.
|
||||
/// LEITUNGSTEAM is global (per RolesGuard convention) and sees everything.
|
||||
export function allowedVisibilitiesForUser(
|
||||
user: AuthenticatedUser,
|
||||
kcId: string,
|
||||
): FileVisibility[] {
|
||||
const isLt = user.memberships.some((m) => m.role === Role.LEITUNGSTEAM);
|
||||
if (isLt) {
|
||||
return [FileVisibility.ALLE, FileVisibility.ALLE_AUSSER_KONFIS, FileVisibility.NUR_LT];
|
||||
}
|
||||
const isTeamMemberForKc = user.memberships.some((m) => m.kcId === kcId);
|
||||
if (isTeamMemberForKc) {
|
||||
return [FileVisibility.ALLE, FileVisibility.ALLE_AUSSER_KONFIS];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export const GUEST_ALLOWED_VISIBILITIES: FileVisibility[] = [FileVisibility.ALLE];
|
||||
@@ -1,11 +0,0 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class CreateGemeindeDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
kcId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name!: string;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateGemeindeDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name!: string;
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { GemeindeService } from './gemeinde.service';
|
||||
import { CreateGemeindeDto } from './dto/create-gemeinde.dto';
|
||||
import { UpdateGemeindeDto } from './dto/update-gemeinde.dto';
|
||||
import { Roles } from '../common/roles.decorator';
|
||||
import { RolesGuard } from '../common/roles.guard';
|
||||
import { Role } from '../common/role.enum';
|
||||
|
||||
/// Gemeinde (congregation) management. Reserved for the Leitungsteam, which is
|
||||
/// global across all KCs (see RolesGuard); Gemeinde Verantwortliche/Teamer
|
||||
/// learn their own Gemeinde from their Membership, not from this endpoint.
|
||||
@Controller('gemeinde')
|
||||
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||
@Roles(Role.LEITUNGSTEAM)
|
||||
export class GemeindeController {
|
||||
constructor(private readonly gemeinde: GemeindeService) {}
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: CreateGemeindeDto) {
|
||||
return this.gemeinde.create(dto.kcId, dto.name);
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@Query('kcId') kcId: string) {
|
||||
return this.gemeinde.list(kcId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(@Param('id') id: string) {
|
||||
return this.gemeinde.get(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateGemeindeDto) {
|
||||
return this.gemeinde.update(id, dto.name);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.gemeinde.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GemeindeService } from './gemeinde.service';
|
||||
import { GemeindeController } from './gemeinde.controller';
|
||||
|
||||
@Module({
|
||||
providers: [GemeindeService],
|
||||
controllers: [GemeindeController],
|
||||
})
|
||||
export class GemeindeModule {}
|
||||
@@ -1,81 +0,0 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma, SyncOperation } from '@prisma/client';
|
||||
import { PrismaClient } from '../prisma/prisma.module';
|
||||
import { SyncService } from '../sync/sync.service';
|
||||
|
||||
/// CRUD for Gemeinden (congregations) within a KC. Creating/renaming/deleting
|
||||
/// is Leitungsteam-only (see GemeindeController); other team roles may list
|
||||
/// and read the Gemeinden of their KC for onboarding/assignment UIs.
|
||||
@Injectable()
|
||||
export class GemeindeService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly sync: SyncService,
|
||||
) {}
|
||||
|
||||
async create(kcId: string, name: string) {
|
||||
const kc = await this.prisma.kc.findUnique({ where: { id: kcId } });
|
||||
if (!kc) {
|
||||
throw new NotFoundException('KC not found');
|
||||
}
|
||||
try {
|
||||
const gemeinde = await this.prisma.gemeinde.create({ data: { kcId, name } });
|
||||
await this.sync.capture('Gemeinde', SyncOperation.CREATE, gemeinde.id, gemeinde);
|
||||
return gemeinde;
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('A Gemeinde with this name already exists in this KC');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
list(kcId: string) {
|
||||
return this.prisma.gemeinde.findMany({
|
||||
where: { kcId },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async get(id: string) {
|
||||
const gemeinde = await this.prisma.gemeinde.findUnique({ where: { id } });
|
||||
if (!gemeinde) {
|
||||
throw new NotFoundException('Gemeinde not found');
|
||||
}
|
||||
return gemeinde;
|
||||
}
|
||||
|
||||
async update(id: string, name: string) {
|
||||
await this.get(id);
|
||||
try {
|
||||
const gemeinde = await this.prisma.gemeinde.update({
|
||||
where: { id },
|
||||
data: { name },
|
||||
});
|
||||
await this.sync.capture('Gemeinde', SyncOperation.UPDATE, gemeinde.id, gemeinde);
|
||||
return gemeinde;
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
err.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('A Gemeinde with this name already exists in this KC');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
await this.get(id);
|
||||
const gemeinde = await this.prisma.gemeinde.delete({ where: { id } });
|
||||
await this.sync.capture('Gemeinde', SyncOperation.DELETE, gemeinde.id, gemeinde);
|
||||
return gemeinde;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class CreateKcDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name!: string;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { KcService } from './kc.service';
|
||||
import { CreateKcDto } from './dto/create-kc.dto';
|
||||
import { Roles } from '../common/roles.decorator';
|
||||
import { RolesGuard } from '../common/roles.guard';
|
||||
import { Role } from '../common/role.enum';
|
||||
|
||||
@Controller('kc')
|
||||
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||
export class KcController {
|
||||
constructor(private readonly kc: KcService) {}
|
||||
|
||||
/// Only the Leitungsteam may create new KC events.
|
||||
@Post()
|
||||
@Roles(Role.LEITUNGSTEAM)
|
||||
create(@Body() dto: CreateKcDto) {
|
||||
return this.kc.createKc(dto.name);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Roles(Role.LEITUNGSTEAM)
|
||||
list() {
|
||||
return this.kc.listKcs();
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { KcService } from './kc.service';
|
||||
import { KcController } from './kc.controller';
|
||||
|
||||
@Module({
|
||||
providers: [KcService],
|
||||
controllers: [KcController],
|
||||
})
|
||||
export class KcModule {}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { SyncOperation } from '@prisma/client';
|
||||
import { PrismaClient } from '../prisma/prisma.module';
|
||||
import { SyncService } from '../sync/sync.service';
|
||||
|
||||
@Injectable()
|
||||
export class KcService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly sync: SyncService,
|
||||
) {}
|
||||
|
||||
async createKc(name: string) {
|
||||
const kc = await this.prisma.kc.create({
|
||||
data: { name, inviteCode: randomBytes(6).toString('hex') },
|
||||
});
|
||||
await this.sync.capture('Kc', SyncOperation.CREATE, kc.id, kc);
|
||||
return kc;
|
||||
}
|
||||
|
||||
listKcs() {
|
||||
return this.prisma.kc.findMany();
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { MailMessage, MailProvider } from './mail-provider';
|
||||
|
||||
/// Default provider: doesn't send anything, just logs that it would have.
|
||||
/// Keeps the invite flow working before SMTP is configured.
|
||||
export class LogMailProvider implements MailProvider {
|
||||
private readonly logger = new Logger('MailProvider');
|
||||
|
||||
async send(message: MailMessage): Promise<boolean> {
|
||||
this.logger.log(
|
||||
`[log-only] would send "${message.subject}" to ${message.to}: ${message.text}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
/// Abstraction over the outbound email backend. Default is a no-send provider
|
||||
/// that only logs (fine for dev and for deployments that don't do email yet);
|
||||
/// MAIL_PROVIDER=smtp switches to a real SMTP transport.
|
||||
export interface MailMessage {
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
html?: string;
|
||||
}
|
||||
|
||||
export interface MailProvider {
|
||||
/// Resolves true if the message was handed off to the transport, false if
|
||||
/// it was dropped (e.g. the log provider). Never throws for delivery
|
||||
/// problems — callers treat email as best-effort.
|
||||
send(message: MailMessage): Promise<boolean>;
|
||||
}
|
||||
|
||||
export const MAIL_PROVIDER = Symbol('MAIL_PROVIDER');
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { MAIL_PROVIDER } from './mail-provider';
|
||||
import { LogMailProvider } from './log-mail.provider';
|
||||
import { SmtpMailProvider } from './smtp-mail.provider';
|
||||
import { MailService } from './mail.service';
|
||||
|
||||
/// Global so any feature module can inject MailService. Provider defaults to
|
||||
/// log-only; MAIL_PROVIDER=smtp switches to a real SMTP transport.
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
MailService,
|
||||
{
|
||||
provide: MAIL_PROVIDER,
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) =>
|
||||
config.get<string>('MAIL_PROVIDER') === 'smtp'
|
||||
? new SmtpMailProvider(config)
|
||||
: new LogMailProvider(),
|
||||
},
|
||||
],
|
||||
exports: [MailService],
|
||||
})
|
||||
export class MailModule {}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { MAIL_PROVIDER, MailProvider } from './mail-provider';
|
||||
|
||||
@Injectable()
|
||||
export class MailService {
|
||||
private readonly appBaseUrl: string;
|
||||
|
||||
constructor(
|
||||
@Inject(MAIL_PROVIDER) private readonly provider: MailProvider,
|
||||
config: ConfigService,
|
||||
) {
|
||||
this.appBaseUrl = (config.get<string>('APP_BASE_URL') ?? 'http://localhost:3000').replace(
|
||||
/\/$/,
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
/// Sends a personal Gemeinde-Teamer invite. Returns whether it was handed
|
||||
/// to the transport (false for the log-only provider or on failure).
|
||||
sendTeamerInvite(opts: {
|
||||
to: string;
|
||||
kcName: string;
|
||||
gemeindeName: string;
|
||||
token: string;
|
||||
expiresAt: Date | null;
|
||||
}): Promise<boolean> {
|
||||
const link = `${this.appBaseUrl}/?teamerInviteToken=${encodeURIComponent(opts.token)}`;
|
||||
const expiry = opts.expiresAt
|
||||
? `\n\nDer Link gilt bis ${opts.expiresAt.toISOString()}.`
|
||||
: '';
|
||||
return this.provider.send({
|
||||
to: opts.to,
|
||||
subject: `Einladung als Teamer:in – ${opts.gemeindeName} (${opts.kcName})`,
|
||||
text:
|
||||
`Hallo,\n\ndu wurdest als Teamer:in für die Gemeinde "${opts.gemeindeName}" ` +
|
||||
`beim ${opts.kcName} eingeladen.\n\n` +
|
||||
`Konto anlegen: ${link}\n\n` +
|
||||
`Falls der Link nicht funktioniert, nutze diesen Einladungscode: ${opts.token}` +
|
||||
`${expiry}\n`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import { MailMessage, MailProvider } from './mail-provider';
|
||||
|
||||
/// SMTP transport (MAIL_PROVIDER=smtp). Delivery failures are logged and
|
||||
/// swallowed — callers treat email as best-effort.
|
||||
export class SmtpMailProvider implements MailProvider {
|
||||
private readonly logger = new Logger('MailProvider');
|
||||
private readonly from: string;
|
||||
private readonly transport: nodemailer.Transporter;
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
this.from = config.getOrThrow<string>('MAIL_FROM');
|
||||
this.transport = nodemailer.createTransport({
|
||||
host: config.getOrThrow<string>('SMTP_HOST'),
|
||||
port: Number(config.get<string>('SMTP_PORT') ?? 587),
|
||||
secure: config.get<string>('SMTP_SECURE') === 'true',
|
||||
auth: config.get<string>('SMTP_USER')
|
||||
? {
|
||||
user: config.getOrThrow<string>('SMTP_USER'),
|
||||
pass: config.getOrThrow<string>('SMTP_PASS'),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async send(message: MailMessage): Promise<boolean> {
|
||||
try {
|
||||
await this.transport.sendMail({
|
||||
from: this.from,
|
||||
to: message.to,
|
||||
subject: message.subject,
|
||||
text: message.text,
|
||||
html: message.html,
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to send "${message.subject}" to ${message.to}: ${(err as Error).message}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { WsAdapter } from '@nestjs/platform-ws';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.setGlobalPrefix('api');
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||
app.enableCors();
|
||||
app.useWebSocketAdapter(new WsAdapter(app));
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
}
|
||||
bootstrap();
|
||||
@@ -1,11 +0,0 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class RegisterVerantwortlicheDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
inviteCode!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
gemeindeId!: string;
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Headers,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { OnboardingService } from './onboarding.service';
|
||||
import { RegisterVerantwortlicheDto } from './dto/register-verantwortliche.dto';
|
||||
import { Roles } from '../common/roles.decorator';
|
||||
import { RolesGuard } from '../common/roles.guard';
|
||||
import { Role } from '../common/role.enum';
|
||||
|
||||
function bearer(header?: string): string | undefined {
|
||||
return header?.startsWith('Bearer ') ? header.slice('Bearer '.length) : undefined;
|
||||
}
|
||||
|
||||
@Controller('onboarding')
|
||||
export class OnboardingController {
|
||||
constructor(private readonly onboarding: OnboardingService) {}
|
||||
|
||||
/// Public lookup: invite code -> KC name + selectable Gemeinden.
|
||||
@Get('kc/:inviteCode')
|
||||
resolveInvite(@Param('inviteCode') inviteCode: string) {
|
||||
return this.onboarding.resolveInvite(inviteCode);
|
||||
}
|
||||
|
||||
/// Self-registration as Gemeinde Verantwortliche/r. Authenticated by the
|
||||
/// caller's raw Authentik bearer token (no local Membership required yet).
|
||||
@Post('verantwortliche')
|
||||
registerVerantwortliche(
|
||||
@Body() dto: RegisterVerantwortlicheDto,
|
||||
@Headers('authorization') authorization?: string,
|
||||
) {
|
||||
return this.onboarding.registerVerantwortliche(
|
||||
bearer(authorization),
|
||||
dto.inviteCode,
|
||||
dto.gemeindeId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Leitungsteam: review and act on pending self-registrations.
|
||||
@Get('requests')
|
||||
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||
@Roles(Role.LEITUNGSTEAM)
|
||||
listRequests(@Query('kcId') kcId: string) {
|
||||
return this.onboarding.listRequests(kcId);
|
||||
}
|
||||
|
||||
@Post('requests/:membershipId/approve')
|
||||
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||
@Roles(Role.LEITUNGSTEAM)
|
||||
approve(@Param('membershipId') membershipId: string) {
|
||||
return this.onboarding.approve(membershipId);
|
||||
}
|
||||
|
||||
@Post('requests/:membershipId/reject')
|
||||
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||
@Roles(Role.LEITUNGSTEAM)
|
||||
reject(@Param('membershipId') membershipId: string) {
|
||||
return this.onboarding.reject(membershipId);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { OnboardingService } from './onboarding.service';
|
||||
import { OnboardingController } from './onboarding.controller';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
providers: [OnboardingService],
|
||||
controllers: [OnboardingController],
|
||||
})
|
||||
export class OnboardingModule {}
|
||||
@@ -1,224 +0,0 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { MembershipStatus, Role } from '@prisma/client';
|
||||
import { OnboardingService } from './onboarding.service';
|
||||
|
||||
/// Prisma / Sync / TokenVerification faked in memory.
|
||||
|
||||
const CLAIMS = {
|
||||
sub: 'authentik-sub-1',
|
||||
email: 'Vera@example.org',
|
||||
firstName: 'Vera',
|
||||
lastName: 'Wong',
|
||||
};
|
||||
|
||||
function makeService(seed: {
|
||||
kc?: { id: string; name: string; inviteCode: string; isActive: boolean } | null;
|
||||
gemeinde?: { id: string; name: string; kcId: string } | null;
|
||||
user?: { id: string; authentikSub: string } | null;
|
||||
membership?: {
|
||||
id: string;
|
||||
status: MembershipStatus;
|
||||
userId: string;
|
||||
kcId: string;
|
||||
gemeindeId: string;
|
||||
} | null;
|
||||
tokenThrows?: boolean;
|
||||
}) {
|
||||
const state = {
|
||||
membership: seed.membership ?? null,
|
||||
};
|
||||
|
||||
const prisma = {
|
||||
kc: {
|
||||
findUnique: jest.fn().mockResolvedValue(
|
||||
seed.kc === undefined
|
||||
? { id: 'kc-1', name: 'KC 2026', inviteCode: 'code-1', isActive: true, gemeinden: [] }
|
||||
: seed.kc,
|
||||
),
|
||||
},
|
||||
gemeinde: {
|
||||
findUnique: jest.fn().mockResolvedValue(
|
||||
seed.gemeinde === undefined ? { id: 'gem-1', name: 'Nord', kcId: 'kc-1' } : seed.gemeinde,
|
||||
),
|
||||
},
|
||||
user: {
|
||||
findUnique: jest.fn().mockResolvedValue(seed.user ?? null),
|
||||
create: jest.fn(({ data }: { data: Record<string, unknown> }) =>
|
||||
Promise.resolve({ id: 'u-new', ...data }),
|
||||
),
|
||||
},
|
||||
membership: {
|
||||
findUnique: jest.fn(() => Promise.resolve(state.membership)),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
create: jest.fn(({ data }: { data: Record<string, unknown> }) => {
|
||||
state.membership = { id: 'mem-new', ...data } as never;
|
||||
return Promise.resolve(state.membership);
|
||||
}),
|
||||
update: jest.fn(({ data }: { data: { status: MembershipStatus } }) => {
|
||||
state.membership = { ...state.membership!, ...data };
|
||||
return Promise.resolve(state.membership);
|
||||
}),
|
||||
delete: jest.fn(() => Promise.resolve(state.membership!)),
|
||||
},
|
||||
};
|
||||
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
|
||||
const tokens = {
|
||||
verifyAuthentikClaims: seed.tokenThrows
|
||||
? jest.fn().mockRejectedValue(new UnauthorizedException('bad token'))
|
||||
: jest.fn().mockResolvedValue(CLAIMS),
|
||||
};
|
||||
|
||||
const service = new OnboardingService(prisma as never, sync as never, tokens as never);
|
||||
return { service, prisma, sync, tokens };
|
||||
}
|
||||
|
||||
describe('OnboardingService.resolveInvite', () => {
|
||||
it('404s an unknown code', async () => {
|
||||
const { service } = makeService({ kc: null });
|
||||
await expect(service.resolveInvite('nope')).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('404s an inactive KC', async () => {
|
||||
const { service } = makeService({
|
||||
kc: { id: 'kc-1', name: 'KC', inviteCode: 'c', isActive: false },
|
||||
});
|
||||
await expect(service.resolveInvite('c')).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('returns the KC name and its Gemeinden', async () => {
|
||||
const { service, prisma } = makeService({});
|
||||
prisma.kc.findUnique = jest.fn().mockResolvedValue({
|
||||
id: 'kc-1',
|
||||
name: 'KC 2026',
|
||||
isActive: true,
|
||||
gemeinden: [{ id: 'gem-1', name: 'Nord' }],
|
||||
});
|
||||
await expect(service.resolveInvite('code-1')).resolves.toEqual({
|
||||
kcId: 'kc-1',
|
||||
kcName: 'KC 2026',
|
||||
gemeinden: [{ id: 'gem-1', name: 'Nord' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('OnboardingService.registerVerantwortliche', () => {
|
||||
it('rejects a missing token', async () => {
|
||||
const { service } = makeService({});
|
||||
await expect(
|
||||
service.registerVerantwortliche(undefined, 'code-1', 'gem-1'),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('propagates an invalid token', async () => {
|
||||
const { service } = makeService({ tokenThrows: true });
|
||||
await expect(
|
||||
service.registerVerantwortliche('t', 'code-1', 'gem-1'),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('404s an unknown invite code', async () => {
|
||||
const { service } = makeService({ kc: null });
|
||||
await expect(
|
||||
service.registerVerantwortliche('t', 'bad', 'gem-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('400s when the Gemeinde is not part of the KC', async () => {
|
||||
const { service } = makeService({ gemeinde: { id: 'gem-9', name: 'X', kcId: 'other-kc' } });
|
||||
await expect(
|
||||
service.registerVerantwortliche('t', 'code-1', 'gem-9'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('provisions the user and creates a PENDING membership', async () => {
|
||||
const { service, prisma, sync } = makeService({ user: null, membership: null });
|
||||
const res = await service.registerVerantwortliche('t', 'code-1', 'gem-1');
|
||||
|
||||
expect(prisma.user.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
authentikSub: 'authentik-sub-1',
|
||||
email: 'vera@example.org',
|
||||
}),
|
||||
});
|
||||
expect(prisma.membership.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
role: Role.GEMEINDE_VERANTWORTLICHER,
|
||||
status: MembershipStatus.PENDING,
|
||||
gemeindeId: 'gem-1',
|
||||
}),
|
||||
});
|
||||
expect(res).toMatchObject({ status: MembershipStatus.PENDING, kcName: 'KC 2026' });
|
||||
expect(sync.capture).toHaveBeenCalledWith('User', 'CREATE', expect.any(String), expect.anything());
|
||||
expect(sync.capture).toHaveBeenCalledWith('Membership', 'CREATE', expect.any(String), expect.anything());
|
||||
});
|
||||
|
||||
it('does not re-create the user when one already exists', async () => {
|
||||
const { service, prisma } = makeService({
|
||||
user: { id: 'u-1', authentikSub: 'authentik-sub-1' },
|
||||
membership: null,
|
||||
});
|
||||
await service.registerVerantwortliche('t', 'code-1', 'gem-1');
|
||||
expect(prisma.user.create).not.toHaveBeenCalled();
|
||||
expect(prisma.membership.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the existing membership status without creating a second one', async () => {
|
||||
const { service, prisma } = makeService({
|
||||
user: { id: 'u-1', authentikSub: 'authentik-sub-1' },
|
||||
membership: {
|
||||
id: 'mem-1',
|
||||
status: MembershipStatus.ACTIVE,
|
||||
userId: 'u-1',
|
||||
kcId: 'kc-1',
|
||||
gemeindeId: 'gem-1',
|
||||
},
|
||||
});
|
||||
const res = await service.registerVerantwortliche('t', 'code-1', 'gem-1');
|
||||
expect(res).toMatchObject({ membershipId: 'mem-1', status: MembershipStatus.ACTIVE });
|
||||
expect(prisma.membership.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('OnboardingService.approve / reject', () => {
|
||||
const pending = {
|
||||
id: 'mem-1',
|
||||
status: MembershipStatus.PENDING,
|
||||
userId: 'u-1',
|
||||
kcId: 'kc-1',
|
||||
gemeindeId: 'gem-1',
|
||||
};
|
||||
|
||||
it('404s approving an unknown request', async () => {
|
||||
const { service } = makeService({ membership: null });
|
||||
await expect(service.approve('mem-x')).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('400s approving a non-pending request', async () => {
|
||||
const { service } = makeService({
|
||||
membership: { ...pending, status: MembershipStatus.ACTIVE },
|
||||
});
|
||||
await expect(service.approve('mem-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('flips the status to ACTIVE and captures the update', async () => {
|
||||
const { service, prisma, sync } = makeService({ membership: { ...pending } });
|
||||
await service.approve('mem-1');
|
||||
expect(prisma.membership.update).toHaveBeenCalledWith({
|
||||
where: { id: 'mem-1' },
|
||||
data: { status: MembershipStatus.ACTIVE },
|
||||
});
|
||||
expect(sync.capture).toHaveBeenCalledWith('Membership', 'UPDATE', 'mem-1', expect.anything());
|
||||
});
|
||||
|
||||
it('deletes on reject and captures the delete', async () => {
|
||||
const { service, prisma, sync } = makeService({ membership: { ...pending } });
|
||||
const res = await service.reject('mem-1');
|
||||
expect(res).toEqual({ id: 'mem-1' });
|
||||
expect(prisma.membership.delete).toHaveBeenCalledWith({ where: { id: 'mem-1' } });
|
||||
expect(sync.capture).toHaveBeenCalledWith('Membership', 'DELETE', 'mem-1', { id: 'mem-1' });
|
||||
});
|
||||
});
|
||||
@@ -1,136 +0,0 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { MembershipStatus, Role, SyncOperation } from '@prisma/client';
|
||||
import { PrismaClient } from '../prisma/prisma.module';
|
||||
import { SyncService } from '../sync/sync.service';
|
||||
import { TokenVerificationService } from '../auth/token-verification.service';
|
||||
import { resolveOrProvisionAuthentikUser } from '../auth/provision-user';
|
||||
|
||||
/// Self-service onboarding for Gemeinde Verantwortliche. The person signs in
|
||||
/// with their Konfi-Castle-ID (Authentik) and submits a KC invite code plus
|
||||
/// the Gemeinde they belong to; this provisions their local User (JIT) and a
|
||||
/// PENDING membership that a Leitungsteam member must approve before it grants
|
||||
/// any rights.
|
||||
@Injectable()
|
||||
export class OnboardingService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly sync: SyncService,
|
||||
private readonly tokens: TokenVerificationService,
|
||||
) {}
|
||||
|
||||
/// Public: resolves an invite code to the KC name and its Gemeinden so the
|
||||
/// registrant can pick theirs. The code itself is the shared secret.
|
||||
async resolveInvite(inviteCode: string) {
|
||||
const kc = await this.prisma.kc.findUnique({
|
||||
where: { inviteCode },
|
||||
include: {
|
||||
gemeinden: { select: { id: true, name: true }, orderBy: { name: 'asc' } },
|
||||
},
|
||||
});
|
||||
if (!kc || !kc.isActive) {
|
||||
throw new NotFoundException('Unknown or inactive KC invite code');
|
||||
}
|
||||
return { kcId: kc.id, kcName: kc.name, gemeinden: kc.gemeinden };
|
||||
}
|
||||
|
||||
async registerVerantwortliche(token: string | undefined, inviteCode: string, gemeindeId: string) {
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('Missing Authentik bearer token');
|
||||
}
|
||||
const { isLeitungsteam, ...claims } = await this.tokens.verifyAuthentikClaims(token);
|
||||
|
||||
const kc = await this.prisma.kc.findUnique({ where: { inviteCode } });
|
||||
if (!kc || !kc.isActive) {
|
||||
throw new NotFoundException('Unknown or inactive KC invite code');
|
||||
}
|
||||
const gemeinde = await this.prisma.gemeinde.findUnique({ where: { id: gemeindeId } });
|
||||
if (!gemeinde || gemeinde.kcId !== kc.id) {
|
||||
throw new BadRequestException('Gemeinde does not belong to this KC');
|
||||
}
|
||||
|
||||
const user = await resolveOrProvisionAuthentikUser(
|
||||
this.prisma,
|
||||
this.sync,
|
||||
claims,
|
||||
isLeitungsteam,
|
||||
);
|
||||
|
||||
const existing = await this.prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_kcId_gemeindeId: { userId: user.id, kcId: kc.id, gemeindeId },
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
return this.summary(existing.id, existing.status, kc.name, gemeinde.name);
|
||||
}
|
||||
|
||||
const membership = await this.prisma.membership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kcId: kc.id,
|
||||
gemeindeId,
|
||||
role: Role.GEMEINDE_VERANTWORTLICHER,
|
||||
status: MembershipStatus.PENDING,
|
||||
},
|
||||
});
|
||||
await this.sync.capture('Membership', SyncOperation.CREATE, membership.id, membership);
|
||||
return this.summary(membership.id, membership.status, kc.name, gemeinde.name);
|
||||
}
|
||||
|
||||
async listRequests(kcId: string) {
|
||||
return this.prisma.membership.findMany({
|
||||
where: {
|
||||
kcId,
|
||||
status: MembershipStatus.PENDING,
|
||||
role: Role.GEMEINDE_VERANTWORTLICHER,
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, email: true, firstName: true, lastName: true } },
|
||||
gemeinde: { select: { id: true, name: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async approve(membershipId: string) {
|
||||
await this.getPendingOrThrow(membershipId);
|
||||
const membership = await this.prisma.membership.update({
|
||||
where: { id: membershipId },
|
||||
data: { status: MembershipStatus.ACTIVE },
|
||||
});
|
||||
await this.sync.capture('Membership', SyncOperation.UPDATE, membership.id, membership);
|
||||
return membership;
|
||||
}
|
||||
|
||||
async reject(membershipId: string) {
|
||||
await this.getPendingOrThrow(membershipId);
|
||||
const membership = await this.prisma.membership.delete({ where: { id: membershipId } });
|
||||
await this.sync.capture('Membership', SyncOperation.DELETE, membership.id, { id: membership.id });
|
||||
return { id: membership.id };
|
||||
}
|
||||
|
||||
private async getPendingOrThrow(membershipId: string) {
|
||||
const membership = await this.prisma.membership.findUnique({ where: { id: membershipId } });
|
||||
if (!membership) {
|
||||
throw new NotFoundException('Request not found');
|
||||
}
|
||||
if (membership.status !== MembershipStatus.PENDING) {
|
||||
throw new BadRequestException('Request is not pending');
|
||||
}
|
||||
return membership;
|
||||
}
|
||||
|
||||
private summary(
|
||||
membershipId: string,
|
||||
status: MembershipStatus,
|
||||
kcName: string,
|
||||
gemeindeName: string,
|
||||
) {
|
||||
return { membershipId, status, kcName, gemeindeName };
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
/// Shared Prisma connection; injected wherever DB access is needed.
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: PrismaClient,
|
||||
useFactory: () => new PrismaClient(),
|
||||
},
|
||||
],
|
||||
exports: [PrismaClient],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
|
||||
export { PrismaClient };
|
||||
@@ -1,16 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import { PushNotification, PushProvider, PushResult } from './push-provider';
|
||||
|
||||
interface ServiceAccount {
|
||||
client_email: string;
|
||||
private_key: string;
|
||||
token_uri: string;
|
||||
}
|
||||
|
||||
/// Firebase Cloud Messaging HTTP v1. Auth is a service-account JWT exchanged
|
||||
/// for an OAuth access token (no google-auth-library dependency — jsonwebtoken
|
||||
/// is already here). Delivery failures are logged and swallowed.
|
||||
export class FcmPushProvider implements PushProvider {
|
||||
private readonly logger = new Logger('PushProvider');
|
||||
private readonly projectId: string;
|
||||
private readonly sa: ServiceAccount;
|
||||
private accessToken: { value: string; expiresAt: number } | null = null;
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
this.projectId = config.getOrThrow<string>('FCM_PROJECT_ID');
|
||||
const path = config.getOrThrow<string>('GOOGLE_APPLICATION_CREDENTIALS');
|
||||
this.sa = JSON.parse(readFileSync(path, 'utf8')) as ServiceAccount;
|
||||
}
|
||||
|
||||
async sendToTokens(tokens: string[], n: PushNotification): Promise<PushResult> {
|
||||
if (tokens.length === 0) return { sent: 0, invalidTokens: [] };
|
||||
let accessToken: string;
|
||||
try {
|
||||
accessToken = await this.getAccessToken();
|
||||
} catch (err) {
|
||||
this.logger.error(`FCM auth failed: ${(err as Error).message}`);
|
||||
return { sent: 0, invalidTokens: [] };
|
||||
}
|
||||
|
||||
const url = `https://fcm.googleapis.com/v1/projects/${this.projectId}/messages:send`;
|
||||
const invalidTokens: string[] = [];
|
||||
let sent = 0;
|
||||
|
||||
await Promise.all(
|
||||
tokens.map(async (token) => {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: {
|
||||
token,
|
||||
notification: { title: n.title, body: n.body },
|
||||
data: n.data,
|
||||
webpush: { fcmOptions: {} },
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
sent += 1;
|
||||
} else if (res.status === 404 || res.status === 400) {
|
||||
invalidTokens.push(token);
|
||||
} else {
|
||||
this.logger.warn(`FCM send ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`FCM send error: ${(err as Error).message}`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return { sent, invalidTokens };
|
||||
}
|
||||
|
||||
private async getAccessToken(): Promise<string> {
|
||||
if (this.accessToken && this.accessToken.expiresAt > Date.now() + 60_000) {
|
||||
return this.accessToken.value;
|
||||
}
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const assertion = jwt.sign(
|
||||
{
|
||||
iss: this.sa.client_email,
|
||||
scope: 'https://www.googleapis.com/auth/firebase.messaging',
|
||||
aud: this.sa.token_uri,
|
||||
iat: now,
|
||||
exp: now + 3600,
|
||||
},
|
||||
this.sa.private_key,
|
||||
{ algorithm: 'RS256' },
|
||||
);
|
||||
const res = await fetch(this.sa.token_uri, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
assertion,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`token exchange ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
const json = (await res.json()) as { access_token: string; expires_in: number };
|
||||
this.accessToken = {
|
||||
value: json.access_token,
|
||||
expiresAt: Date.now() + json.expires_in * 1000,
|
||||
};
|
||||
return json.access_token;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { PushNotification, PushProvider, PushResult } from './push-provider';
|
||||
|
||||
/// Default provider: doesn't send, just logs. Keeps the app working before
|
||||
/// FCM credentials are configured.
|
||||
export class LogPushProvider implements PushProvider {
|
||||
private readonly logger = new Logger('PushProvider');
|
||||
|
||||
async sendToTokens(tokens: string[], n: PushNotification): Promise<PushResult> {
|
||||
this.logger.log(
|
||||
`[log-only] would push "${n.title}" to ${tokens.length} device(s): ${n.body}`,
|
||||
);
|
||||
return { sent: 0, invalidTokens: [] };
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
/// Abstraction over the push backend. Default is a no-send provider that only
|
||||
/// logs; PUSH_PROVIDER=fcm switches to Firebase Cloud Messaging (HTTP v1).
|
||||
export interface PushNotification {
|
||||
title: string;
|
||||
body: string;
|
||||
data?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface PushResult {
|
||||
sent: number;
|
||||
/// Tokens FCM reported as permanently invalid — the caller prunes them.
|
||||
invalidTokens: string[];
|
||||
}
|
||||
|
||||
export interface PushProvider {
|
||||
/// Best-effort: never throws for delivery problems.
|
||||
sendToTokens(tokens: string[], notification: PushNotification): Promise<PushResult>;
|
||||
}
|
||||
|
||||
export const PUSH_PROVIDER = Symbol('PUSH_PROVIDER');
|
||||
@@ -1,29 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PUSH_PROVIDER } from './push-provider';
|
||||
import { LogPushProvider } from './log-push.provider';
|
||||
import { FcmPushProvider } from './fcm-push.provider';
|
||||
import { PushService } from './push.service';
|
||||
import { PushController } from './push.controller';
|
||||
|
||||
/// Global so ChatService can inject PushService. Provider defaults to
|
||||
/// log-only; PUSH_PROVIDER=fcm switches to Firebase Cloud Messaging.
|
||||
@Global()
|
||||
@Module({
|
||||
controllers: [PushController],
|
||||
providers: [
|
||||
PushService,
|
||||
{
|
||||
provide: PUSH_PROVIDER,
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) =>
|
||||
config.get<string>('PUSH_PROVIDER') === 'fcm'
|
||||
? new FcmPushProvider(config)
|
||||
: new LogPushProvider(),
|
||||
},
|
||||
],
|
||||
exports: [PushService],
|
||||
})
|
||||
export class PushModule {}
|
||||
@@ -1,151 +0,0 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { ChatChannelType, SyncOperation } from '@prisma/client';
|
||||
import { PrismaClient } from '../prisma/prisma.module';
|
||||
import { SyncService } from '../sync/sync.service';
|
||||
import type { ChatCaller } from '../chat/chat.service';
|
||||
import { PUSH_PROVIDER, PushNotification, PushProvider } from './push-provider';
|
||||
|
||||
@Injectable()
|
||||
export class PushService {
|
||||
private readonly logger = new Logger(PushService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly sync: SyncService,
|
||||
@Inject(PUSH_PROVIDER) private readonly provider: PushProvider,
|
||||
) {}
|
||||
|
||||
/// Upsert a device token for the current caller (team user or guest).
|
||||
async register(token: string, platform: string, caller: ChatCaller) {
|
||||
const owner =
|
||||
caller.kind === 'user'
|
||||
? { userId: caller.user.userId, guestAccountId: null }
|
||||
: { userId: null, guestAccountId: caller.guest.guestId };
|
||||
const row = await this.prisma.deviceToken.upsert({
|
||||
where: { token },
|
||||
create: { token, platform, ...owner },
|
||||
update: { platform, lastSeenAt: new Date(), ...owner },
|
||||
});
|
||||
await this.sync.capture('DeviceToken', SyncOperation.UPDATE, row.id, row);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async unregister(token: string) {
|
||||
const existing = await this.prisma.deviceToken.findUnique({ where: { token } });
|
||||
if (!existing) return { ok: true };
|
||||
await this.prisma.deviceToken.delete({ where: { token } });
|
||||
await this.sync.capture('DeviceToken', SyncOperation.DELETE, existing.id, {
|
||||
id: existing.id,
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/// Fan a chat message out as a push to everyone who can read the channel,
|
||||
/// minus the sender. Best-effort — never throws into the caller.
|
||||
async notifyChannel(
|
||||
channelId: string,
|
||||
notification: PushNotification,
|
||||
exclude: { userId?: string | null; guestId?: string | null } = {},
|
||||
): Promise<void> {
|
||||
try {
|
||||
const channel = await this.prisma.chatChannel.findUnique({
|
||||
where: { id: channelId },
|
||||
include: { participants: { select: { userId: true } } },
|
||||
});
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { IsArray, IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class IngestEntriesDto {
|
||||
@IsArray()
|
||||
@IsNotEmpty()
|
||||
entries!: unknown[];
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
import { SyncService } from './sync.service';
|
||||
|
||||
/// Periodically pushes/pulls against the configured peer when enabled. Safe
|
||||
/// to fail silently (e.g. no internet at an on-site event) - just retries
|
||||
/// on the next tick.
|
||||
@Injectable()
|
||||
export class SyncSchedulerService {
|
||||
private readonly logger = new Logger(SyncSchedulerService.name);
|
||||
|
||||
constructor(
|
||||
private readonly sync: SyncService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
@Interval(30_000)
|
||||
async tick() {
|
||||
if (this.config.get<string>('SYNC_ENABLED') !== 'true') return;
|
||||
const peerUrl = this.config.get<string>('SYNC_PEER_URL');
|
||||
const peerSecret = this.config.get<string>('SYNC_SHARED_SECRET');
|
||||
if (!peerUrl || !peerSecret) return;
|
||||
|
||||
try {
|
||||
await this.sync.pushToPeer(peerUrl, peerSecret);
|
||||
await this.sync.pullFromPeer(peerUrl, peerSecret);
|
||||
} catch (err) {
|
||||
this.logger.debug(`Sync with peer skipped: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Request } from 'express';
|
||||
|
||||
/// Server-to-server auth for /sync/*: a shared secret header, not a user token.
|
||||
@Injectable()
|
||||
export class SyncSecretGuard implements CanActivate {
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const expected = this.config.getOrThrow<string>('SYNC_SHARED_SECRET');
|
||||
if (request.headers['x-sync-secret'] !== expected) {
|
||||
throw new ForbiddenException('Invalid sync secret');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { SyncService } from './sync.service';
|
||||
import { SyncSecretGuard } from './sync-secret.guard';
|
||||
import { IngestEntriesDto } from './dto/ingest-entries.dto';
|
||||
import { Roles } from '../common/roles.decorator';
|
||||
import { RolesGuard } from '../common/roles.guard';
|
||||
import { Role } from '../common/role.enum';
|
||||
|
||||
@Controller('sync')
|
||||
export class SyncController {
|
||||
constructor(
|
||||
private readonly sync: SyncService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
/// Peer pushes its new entries to us.
|
||||
@Post('ingest')
|
||||
@UseGuards(SyncSecretGuard)
|
||||
async ingest(@Body() dto: IngestEntriesDto) {
|
||||
await this.sync.applyIncoming(dto.entries as never);
|
||||
return { applied: dto.entries.length };
|
||||
}
|
||||
|
||||
/// Peer pulls our new entries since their last known sequence.
|
||||
@Get('export')
|
||||
@UseGuards(SyncSecretGuard)
|
||||
async export(@Query('since') since: string) {
|
||||
const entries = await this.sync.getEntriesSince(Number(since) || 0);
|
||||
return { entries };
|
||||
}
|
||||
|
||||
/// Manual on-demand push+pull against the configured peer (Leitungsteam-only).
|
||||
@Post('trigger')
|
||||
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||
@Roles(Role.LEITUNGSTEAM)
|
||||
async trigger() {
|
||||
const peerUrl = this.config.getOrThrow<string>('SYNC_PEER_URL');
|
||||
const peerSecret = this.config.getOrThrow<string>('SYNC_SHARED_SECRET');
|
||||
const pushed = await this.sync.pushToPeer(peerUrl, peerSecret);
|
||||
const pulled = await this.sync.pullFromPeer(peerUrl, peerSecret);
|
||||
return { ...pushed, ...pulled };
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { SyncService } from './sync.service';
|
||||
import { SyncController } from './sync.controller';
|
||||
import { SyncSchedulerService } from './sync-scheduler.service';
|
||||
|
||||
/// Global so every feature module can inject SyncService to capture its
|
||||
/// mutations without each one importing SyncModule explicitly.
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [ScheduleModule.forRoot()],
|
||||
controllers: [SyncController],
|
||||
providers: [SyncService, SyncSchedulerService],
|
||||
exports: [SyncService],
|
||||
})
|
||||
export class SyncModule {}
|
||||
@@ -1,158 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { SyncOperation } from '@prisma/client';
|
||||
import { PrismaClient } from '../prisma/prisma.module';
|
||||
|
||||
const SYNCED_MODELS = [
|
||||
'Kc',
|
||||
'Gemeinde',
|
||||
'User',
|
||||
'Membership',
|
||||
'TeamerInvite',
|
||||
'GuestAccount',
|
||||
'Wahl',
|
||||
'Workshop',
|
||||
'Teilnehmer',
|
||||
'ForceZuteilung',
|
||||
'Zuteilung',
|
||||
'File',
|
||||
'ChatChannel',
|
||||
'ChatMessage',
|
||||
'DeviceToken',
|
||||
] as const;
|
||||
export type SyncedModel = (typeof SYNCED_MODELS)[number];
|
||||
|
||||
interface IncomingEntry {
|
||||
sequence: number;
|
||||
model: string;
|
||||
recordId: string;
|
||||
operation: SyncOperation;
|
||||
payload: Record<string, unknown>;
|
||||
originId: string;
|
||||
}
|
||||
|
||||
/// Replicates mutations between the local (on-site) and cloud server. The
|
||||
/// local server is the sole source of truth while an event is live, so
|
||||
/// incoming entries are applied with simple upserts - no conflict resolution
|
||||
/// is needed by design (see plan doc).
|
||||
@Injectable()
|
||||
export class SyncService {
|
||||
private readonly logger = new Logger(SyncService.name);
|
||||
readonly serverId: string;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly config: ConfigService,
|
||||
) {
|
||||
this.serverId = config.getOrThrow<string>('SERVER_ID');
|
||||
}
|
||||
|
||||
/// Called by feature services right after a mutation to append it to the replication log.
|
||||
async capture(model: SyncedModel, operation: SyncOperation, recordId: string, payload: object) {
|
||||
await this.prisma.syncLogEntry.create({
|
||||
data: {
|
||||
model,
|
||||
recordId,
|
||||
operation,
|
||||
payload: payload as never,
|
||||
originId: this.serverId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getEntriesSince(sequence: number, limit = 500) {
|
||||
return this.prisma.syncLogEntry.findMany({
|
||||
where: { sequence: { gt: sequence } },
|
||||
orderBy: { sequence: 'asc' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
/// Applies entries received from a peer; never re-captures them, which is
|
||||
/// what prevents echo loops between the two servers.
|
||||
async applyIncoming(entries: IncomingEntry[]) {
|
||||
for (const entry of entries) {
|
||||
if (entry.originId === this.serverId) continue;
|
||||
const delegate = this.delegateFor(entry.model);
|
||||
if (!delegate) {
|
||||
this.logger.warn(`Skipping sync entry for unknown model "${entry.model}"`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (entry.operation === SyncOperation.DELETE) {
|
||||
await delegate.delete({ where: { id: entry.recordId } });
|
||||
} else {
|
||||
await delegate.upsert({
|
||||
where: { id: entry.recordId },
|
||||
create: entry.payload,
|
||||
update: entry.payload,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Failed to apply sync entry ${entry.model}/${entry.recordId}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async pushToPeer(peerUrl: string, peerSecret: string) {
|
||||
const peerId = new URL(peerUrl).host;
|
||||
const cursor = await this.getOrCreateCursor(peerId);
|
||||
const entries = await this.getEntriesSince(cursor.lastPushedSequence);
|
||||
if (entries.length === 0) return { pushed: 0 };
|
||||
|
||||
const res = await fetch(`${peerUrl}/sync/ingest`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-sync-secret': peerSecret },
|
||||
body: JSON.stringify({ entries }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Peer rejected sync push: ${res.status}`);
|
||||
}
|
||||
await this.prisma.syncCursor.update({
|
||||
where: { peerId },
|
||||
data: { lastPushedSequence: entries[entries.length - 1].sequence },
|
||||
});
|
||||
return { pushed: entries.length };
|
||||
}
|
||||
|
||||
async pullFromPeer(peerUrl: string, peerSecret: string) {
|
||||
const peerId = new URL(peerUrl).host;
|
||||
const cursor = await this.getOrCreateCursor(peerId);
|
||||
const res = await fetch(`${peerUrl}/sync/export?since=${cursor.lastPulledSequence}`, {
|
||||
headers: { 'x-sync-secret': peerSecret },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Peer rejected sync pull: ${res.status}`);
|
||||
}
|
||||
const { entries } = (await res.json()) as { entries: IncomingEntry[] };
|
||||
if (entries.length === 0) return { pulled: 0 };
|
||||
|
||||
await this.applyIncoming(entries);
|
||||
await this.prisma.syncCursor.update({
|
||||
where: { peerId },
|
||||
data: { lastPulledSequence: entries[entries.length - 1].sequence },
|
||||
});
|
||||
return { pulled: entries.length };
|
||||
}
|
||||
|
||||
private async getOrCreateCursor(peerId: string) {
|
||||
return this.prisma.syncCursor.upsert({
|
||||
where: { peerId },
|
||||
create: { peerId },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
private delegateFor(model: string) {
|
||||
if (!SYNCED_MODELS.includes(model as SyncedModel)) return null;
|
||||
const key = (model.charAt(0).toLowerCase() + model.slice(1)) as keyof PrismaClient;
|
||||
// Generic dispatch across models is inherent to a replication log; each
|
||||
// delegate exposes the same upsert/delete shape we need here.
|
||||
return this.prisma[key] as unknown as {
|
||||
upsert: (args: { where: { id: string }; create: object; update: object }) => Promise<unknown>;
|
||||
delete: (args: { where: { id: string } }) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { IsEmail, IsInt, IsOptional, Min } from 'class-validator';
|
||||
|
||||
export class CreateTeamerInviteDto {
|
||||
/// Set for a personal invite pinned to one address; omit for a shareable
|
||||
/// group link.
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
/// Max redemptions. Defaults to 1 for a personal invite, unlimited for a
|
||||
/// group link.
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
maxUses?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
expiresInHours?: number;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class CreateTeamerDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
firstName!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
lastName!: string;
|
||||
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { TeamerService } from './teamer.service';
|
||||
import { CreateTeamerDto } from './dto/create-teamer.dto';
|
||||
import { CreateTeamerInviteDto } from './dto/create-teamer-invite.dto';
|
||||
import { Roles } from '../common/roles.decorator';
|
||||
import { RolesGuard } from '../common/roles.guard';
|
||||
import { Role } from '../common/role.enum';
|
||||
import { AuthenticatedRequest } from '../auth/authenticated-request';
|
||||
|
||||
/// Gemeinde Teamer administration. The coarse guard admits Leitungsteam and
|
||||
/// Gemeinde Verantwortliche (Authentik tokens, or a local `isLeitungsteam`
|
||||
/// account via a team token); TeamerService then checks the caller is
|
||||
/// actually responsible for `:gemeindeId`.
|
||||
@Controller('gemeinde/:gemeindeId')
|
||||
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||
@Roles(Role.LEITUNGSTEAM, Role.GEMEINDE_VERANTWORTLICHER)
|
||||
export class TeamerController {
|
||||
constructor(private readonly teamer: TeamerService) {}
|
||||
|
||||
@Post('teamer')
|
||||
create(
|
||||
@Param('gemeindeId') gemeindeId: string,
|
||||
@Body() dto: CreateTeamerDto,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
) {
|
||||
return this.teamer.createTeamer(req.user!, gemeindeId, dto);
|
||||
}
|
||||
|
||||
@Get('teamer')
|
||||
list(@Param('gemeindeId') gemeindeId: string, @Req() req: AuthenticatedRequest) {
|
||||
return this.teamer.listTeamer(req.user!, gemeindeId);
|
||||
}
|
||||
|
||||
@Delete('teamer/:userId')
|
||||
remove(
|
||||
@Param('gemeindeId') gemeindeId: string,
|
||||
@Param('userId') userId: string,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
) {
|
||||
return this.teamer.removeTeamer(req.user!, gemeindeId, userId);
|
||||
}
|
||||
|
||||
@Post('teamer-invites')
|
||||
createInvite(
|
||||
@Param('gemeindeId') gemeindeId: string,
|
||||
@Body() dto: CreateTeamerInviteDto,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
) {
|
||||
return this.teamer.createInvite(req.user!, gemeindeId, dto);
|
||||
}
|
||||
|
||||
@Get('teamer-invites')
|
||||
listInvites(@Param('gemeindeId') gemeindeId: string, @Req() req: AuthenticatedRequest) {
|
||||
return this.teamer.listInvites(req.user!, gemeindeId);
|
||||
}
|
||||
|
||||
@Delete('teamer-invites/:inviteId')
|
||||
revokeInvite(
|
||||
@Param('gemeindeId') gemeindeId: string,
|
||||
@Param('inviteId') inviteId: string,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
) {
|
||||
return this.teamer.revokeInvite(req.user!, gemeindeId, inviteId);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TeamerService } from './teamer.service';
|
||||
import { TeamerController } from './teamer.controller';
|
||||
|
||||
@Module({
|
||||
providers: [TeamerService],
|
||||
controllers: [TeamerController],
|
||||
})
|
||||
export class TeamerModule {}
|
||||
@@ -1,183 +0,0 @@
|
||||
import {
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Role } from '@prisma/client';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { TeamerService } from './teamer.service';
|
||||
import { AuthenticatedUser } from '../auth/authenticated-request';
|
||||
|
||||
/// Focus: the Gemeinde-scope check (assertCanManage) and the create/invite
|
||||
/// branching. Prisma + SyncService faked in memory.
|
||||
|
||||
const GEMEINDE = { id: 'gem-1', name: 'Nord', kcId: 'kc-1', createdAt: new Date() };
|
||||
|
||||
function caller(memberships: AuthenticatedUser['memberships']): AuthenticatedUser {
|
||||
return { userId: 'caller-1', authentikSub: 'sub-1', email: 'c@example.org', memberships };
|
||||
}
|
||||
const LT = caller([{ kcId: 'kc-1', gemeindeId: null, role: Role.LEITUNGSTEAM }]);
|
||||
const VERANTW_GEM1 = caller([
|
||||
{ kcId: 'kc-1', gemeindeId: 'gem-1', role: Role.GEMEINDE_VERANTWORTLICHER },
|
||||
]);
|
||||
const VERANTW_GEM2 = caller([
|
||||
{ kcId: 'kc-1', gemeindeId: 'gem-2', role: Role.GEMEINDE_VERANTWORTLICHER },
|
||||
]);
|
||||
|
||||
function makeService(opts: { gemeinde?: typeof GEMEINDE | null; existingEmails?: string[] } = {}) {
|
||||
const gemeinde = opts.gemeinde === undefined ? GEMEINDE : opts.gemeinde;
|
||||
const emails = new Set(opts.existingEmails ?? []);
|
||||
const created: Record<string, unknown> = {};
|
||||
|
||||
const prisma = {
|
||||
gemeinde: { findUnique: jest.fn().mockResolvedValue(gemeinde) },
|
||||
kc: { findUnique: jest.fn().mockResolvedValue({ name: 'KC 2026' }) },
|
||||
user: {
|
||||
findUnique: jest.fn(({ where }: { where: { email: string } }) =>
|
||||
Promise.resolve(emails.has(where.email) ? { id: 'dup', email: where.email } : null),
|
||||
),
|
||||
create: jest.fn(({ data }: { data: Record<string, unknown> }) => {
|
||||
created.user = { id: 'u-1', createdAt: new Date(), ...data };
|
||||
return Promise.resolve(created.user);
|
||||
}),
|
||||
delete: jest.fn().mockResolvedValue({ id: 'u-1' }),
|
||||
},
|
||||
membership: {
|
||||
create: jest.fn(({ data }: { data: Record<string, unknown> }) => {
|
||||
created.membership = { id: 'm-1', ...data };
|
||||
return Promise.resolve(created.membership);
|
||||
}),
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
teamerInvite: {
|
||||
create: jest.fn(({ data }: { data: Record<string, unknown> }) =>
|
||||
Promise.resolve({ id: 'inv-1', usedCount: 0, revokedAt: null, ...data }),
|
||||
),
|
||||
},
|
||||
};
|
||||
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
|
||||
const mail = { sendTeamerInvite: jest.fn().mockResolvedValue(true) };
|
||||
const service = new TeamerService(prisma as never, sync as never, mail as never);
|
||||
return { service, prisma, sync, mail, created };
|
||||
}
|
||||
|
||||
describe('TeamerService scope check', () => {
|
||||
it('404s when the Gemeinde does not exist', async () => {
|
||||
const { service } = makeService({ gemeinde: null });
|
||||
await expect(service.listTeamer(LT, 'gem-x')).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('lets the Leitungsteam manage any Gemeinde', async () => {
|
||||
const { service, prisma } = makeService();
|
||||
await expect(service.listTeamer(LT, 'gem-1')).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('lets a Verantwortliche/r manage their own Gemeinde', async () => {
|
||||
const { service, prisma } = makeService();
|
||||
await expect(service.listTeamer(VERANTW_GEM1, 'gem-1')).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('forbids a Verantwortliche/r from managing a different Gemeinde', async () => {
|
||||
const { service } = makeService();
|
||||
await expect(service.listTeamer(VERANTW_GEM2, 'gem-1')).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TeamerService.createTeamer', () => {
|
||||
it('rejects a duplicate email', async () => {
|
||||
const { service } = makeService({ existingEmails: ['dup@example.org'] });
|
||||
await expect(
|
||||
service.createTeamer(VERANTW_GEM1, 'gem-1', {
|
||||
firstName: 'A',
|
||||
lastName: 'B',
|
||||
email: 'dup@example.org',
|
||||
password: 'password1',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('creates a hashed local account + GEMEINDE_TEAMER membership and hides the hash', async () => {
|
||||
const { service, created, sync } = makeService();
|
||||
const res = await service.createTeamer(VERANTW_GEM1, 'gem-1', {
|
||||
firstName: 'Ada',
|
||||
lastName: 'Lo',
|
||||
email: 'Ada@Example.org',
|
||||
password: 'password1',
|
||||
});
|
||||
|
||||
expect(res).not.toHaveProperty('passwordHash');
|
||||
expect(res.email).toBe('ada@example.org');
|
||||
expect((created.user as { kcId: string }).kcId).toBe('kc-1');
|
||||
expect(
|
||||
await bcrypt.compare('password1', (created.user as { passwordHash: string }).passwordHash),
|
||||
).toBe(true);
|
||||
expect((created.membership as { role: Role }).role).toBe(Role.GEMEINDE_TEAMER);
|
||||
expect((created.membership as { gemeindeId: string }).gemeindeId).toBe('gem-1');
|
||||
expect(sync.capture).toHaveBeenCalledWith('User', 'CREATE', 'u-1', expect.anything());
|
||||
expect(sync.capture).toHaveBeenCalledWith('Membership', 'CREATE', 'm-1', expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
describe('TeamerService.createInvite', () => {
|
||||
it('defaults a group link to unlimited uses, no expiry, and sends no email', async () => {
|
||||
const { service, mail } = makeService();
|
||||
const inv = await service.createInvite(LT, 'gem-1', {});
|
||||
expect(inv.email).toBeNull();
|
||||
expect(inv.maxUses).toBeNull();
|
||||
expect(inv.expiresAt).toBeNull();
|
||||
expect(inv.token).toEqual(expect.any(String));
|
||||
expect(inv.emailSent).toBe(false);
|
||||
expect(mail.sendTeamerInvite).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('defaults a personal invite to a single use, lowercases the email, and mails it', async () => {
|
||||
const { service, mail } = makeService();
|
||||
const inv = await service.createInvite(LT, 'gem-1', { email: 'New@Example.org' });
|
||||
expect(inv.email).toBe('new@example.org');
|
||||
expect(inv.maxUses).toBe(1);
|
||||
expect(inv.emailSent).toBe(true);
|
||||
expect(mail.sendTeamerInvite).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ to: 'new@example.org', gemeindeName: 'Nord', kcName: 'KC 2026' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('still returns the invite when the mail transport drops it', async () => {
|
||||
const { service, mail } = makeService();
|
||||
mail.sendTeamerInvite.mockResolvedValueOnce(false);
|
||||
const inv = await service.createInvite(LT, 'gem-1', { email: 'x@example.org' });
|
||||
expect(inv.emailSent).toBe(false);
|
||||
expect(inv.token).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('turns expiresInHours into a concrete expiry', async () => {
|
||||
const { service } = makeService();
|
||||
const before = Date.now();
|
||||
const inv = await service.createInvite(LT, 'gem-1', { expiresInHours: 48 });
|
||||
const ms = (inv.expiresAt as Date).getTime() - before;
|
||||
expect(ms).toBeGreaterThan(47 * 3600_000);
|
||||
expect(ms).toBeLessThan(49 * 3600_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TeamerService.removeTeamer', () => {
|
||||
it('404s when the user is not a local Teamer of that Gemeinde', async () => {
|
||||
const { service } = makeService();
|
||||
await expect(service.removeTeamer(LT, 'gem-1', 'u-9')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes the account and captures a User DELETE', async () => {
|
||||
const { service, prisma, sync } = makeService();
|
||||
prisma.membership.findFirst = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ userId: 'u-1', gemeindeId: 'gem-1', user: { passwordHash: 'h' } });
|
||||
const res = await service.removeTeamer(LT, 'gem-1', 'u-1');
|
||||
expect(res).toEqual({ id: 'u-1' });
|
||||
expect(prisma.user.delete).toHaveBeenCalledWith({ where: { id: 'u-1' } });
|
||||
expect(sync.capture).toHaveBeenCalledWith('User', 'DELETE', 'u-1', { id: 'u-1' });
|
||||
});
|
||||
});
|
||||
@@ -1,201 +0,0 @@
|
||||
import {
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { Role, SyncOperation } from '@prisma/client';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { PrismaClient } from '../prisma/prisma.module';
|
||||
import { SyncService } from '../sync/sync.service';
|
||||
import { MailService } from '../mail/mail.service';
|
||||
import { AuthenticatedUser } from '../auth/authenticated-request';
|
||||
import { CreateTeamerInviteDto } from './dto/create-teamer-invite.dto';
|
||||
|
||||
const BCRYPT_ROUNDS = 10;
|
||||
|
||||
type PublicUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
/// Management of local Gemeinde Teamer accounts and their invites. Callable by
|
||||
/// the Leitungsteam (any Gemeinde) or by a Gemeinde Verantwortliche/r for
|
||||
/// their own Gemeinde only.
|
||||
@Injectable()
|
||||
export class TeamerService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly sync: SyncService,
|
||||
private readonly mail: MailService,
|
||||
) {}
|
||||
|
||||
async createTeamer(
|
||||
caller: AuthenticatedUser,
|
||||
gemeindeId: string,
|
||||
input: { firstName: string; lastName: string; email: string; password: string },
|
||||
): Promise<PublicUser> {
|
||||
const gemeinde = await this.assertCanManage(caller, gemeindeId);
|
||||
const email = input.email.toLowerCase();
|
||||
if (await this.prisma.user.findUnique({ where: { email } })) {
|
||||
throw new ConflictException('An account with this email already exists');
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(input.password, BCRYPT_ROUNDS);
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
firstName: input.firstName,
|
||||
lastName: input.lastName,
|
||||
passwordHash,
|
||||
kcId: gemeinde.kcId,
|
||||
},
|
||||
});
|
||||
const membership = await this.prisma.membership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kcId: gemeinde.kcId,
|
||||
gemeindeId,
|
||||
role: Role.GEMEINDE_TEAMER,
|
||||
},
|
||||
});
|
||||
await this.sync.capture('User', SyncOperation.CREATE, user.id, user);
|
||||
await this.sync.capture('Membership', SyncOperation.CREATE, membership.id, membership);
|
||||
return toPublicUser(user);
|
||||
}
|
||||
|
||||
async listTeamer(caller: AuthenticatedUser, gemeindeId: string): Promise<PublicUser[]> {
|
||||
await this.assertCanManage(caller, gemeindeId);
|
||||
const memberships = await this.prisma.membership.findMany({
|
||||
where: { gemeindeId, role: Role.GEMEINDE_TEAMER },
|
||||
include: { user: true },
|
||||
orderBy: { user: { lastName: 'asc' } },
|
||||
});
|
||||
return memberships.map((m) => toPublicUser(m.user));
|
||||
}
|
||||
|
||||
async removeTeamer(
|
||||
caller: AuthenticatedUser,
|
||||
gemeindeId: string,
|
||||
userId: string,
|
||||
): Promise<{ id: string }> {
|
||||
await this.assertCanManage(caller, gemeindeId);
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: { userId, gemeindeId, role: Role.GEMEINDE_TEAMER },
|
||||
include: { user: true },
|
||||
});
|
||||
if (!membership || !membership.user.passwordHash) {
|
||||
throw new NotFoundException('No local Teamer account for this Gemeinde');
|
||||
}
|
||||
await this.prisma.user.delete({ where: { id: userId } });
|
||||
await this.sync.capture('User', SyncOperation.DELETE, userId, { id: userId });
|
||||
return { id: userId };
|
||||
}
|
||||
|
||||
async createInvite(
|
||||
caller: AuthenticatedUser,
|
||||
gemeindeId: string,
|
||||
dto: CreateTeamerInviteDto,
|
||||
) {
|
||||
const gemeinde = await this.assertCanManage(caller, gemeindeId);
|
||||
const email = dto.email?.toLowerCase() ?? null;
|
||||
const maxUses = dto.maxUses ?? (email ? 1 : null);
|
||||
const expiresAt = dto.expiresInHours
|
||||
? new Date(Date.now() + dto.expiresInHours * 3600_000)
|
||||
: null;
|
||||
|
||||
const invite = await this.prisma.teamerInvite.create({
|
||||
data: {
|
||||
kcId: gemeinde.kcId,
|
||||
gemeindeId,
|
||||
token: randomBytes(24).toString('base64url'),
|
||||
email,
|
||||
maxUses,
|
||||
expiresAt,
|
||||
createdByUserId: caller.userId,
|
||||
},
|
||||
});
|
||||
await this.sync.capture('TeamerInvite', SyncOperation.CREATE, invite.id, invite);
|
||||
|
||||
// Personal invites go out by email (best-effort); group links are shared
|
||||
// by the Verantwortliche/r directly.
|
||||
let emailSent = false;
|
||||
if (email) {
|
||||
const kc = await this.prisma.kc.findUnique({
|
||||
where: { id: gemeinde.kcId },
|
||||
select: { name: true },
|
||||
});
|
||||
emailSent = await this.mail.sendTeamerInvite({
|
||||
to: email,
|
||||
kcName: kc?.name ?? '',
|
||||
gemeindeName: gemeinde.name,
|
||||
token: invite.token,
|
||||
expiresAt: invite.expiresAt,
|
||||
});
|
||||
}
|
||||
return { ...invite, emailSent };
|
||||
}
|
||||
|
||||
async listInvites(caller: AuthenticatedUser, gemeindeId: string) {
|
||||
await this.assertCanManage(caller, gemeindeId);
|
||||
return this.prisma.teamerInvite.findMany({
|
||||
where: { gemeindeId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async revokeInvite(caller: AuthenticatedUser, gemeindeId: string, inviteId: string) {
|
||||
await this.assertCanManage(caller, gemeindeId);
|
||||
const invite = await this.prisma.teamerInvite.findFirst({
|
||||
where: { id: inviteId, gemeindeId },
|
||||
});
|
||||
if (!invite) {
|
||||
throw new NotFoundException('Invite not found');
|
||||
}
|
||||
const updated = await this.prisma.teamerInvite.update({
|
||||
where: { id: inviteId },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
await this.sync.capture('TeamerInvite', SyncOperation.UPDATE, updated.id, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// LT may manage every Gemeinde; a Verantwortliche/r only the one they hold
|
||||
/// that role for. Returns the Gemeinde (for its kcId) on success.
|
||||
private async assertCanManage(caller: AuthenticatedUser, gemeindeId: string) {
|
||||
const gemeinde = await this.prisma.gemeinde.findUnique({ where: { id: gemeindeId } });
|
||||
if (!gemeinde) {
|
||||
throw new NotFoundException('Gemeinde not found');
|
||||
}
|
||||
const isLeitungsteam = caller.memberships.some(
|
||||
(m) => m.role === Role.LEITUNGSTEAM,
|
||||
);
|
||||
const isVerantwortlich = caller.memberships.some(
|
||||
(m) => m.role === Role.GEMEINDE_VERANTWORTLICHER && m.gemeindeId === gemeindeId,
|
||||
);
|
||||
if (!isLeitungsteam && !isVerantwortlich) {
|
||||
throw new ForbiddenException('Not responsible for this Gemeinde');
|
||||
}
|
||||
return gemeinde;
|
||||
}
|
||||
}
|
||||
|
||||
function toPublicUser(user: {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
createdAt: Date;
|
||||
}): PublicUser {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
createdAt: user.createdAt,
|
||||
};
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class CreateForceZuteilungDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
teilnehmerId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
workshopId!: string;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class CreateWahlDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
kcId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
datumsSchluessel!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
teil!: string;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { IsInt, IsNotEmpty, IsString, Min } from 'class-validator';
|
||||
|
||||
export class CreateWorkshopDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name!: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
kapazitaet!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
minTeilnehmer: number = 0;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { ArrayMaxSize, ArrayNotEmpty, IsArray, IsString } from 'class-validator';
|
||||
|
||||
/// Ordered workshop-id preferences, most preferred first (up to 3, matching
|
||||
/// the original plugin's wunsch1..wunsch3).
|
||||
export class SubmitTeilnehmerDto {
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@ArrayMaxSize(3)
|
||||
@IsString({ each: true })
|
||||
prioritaeten!: string[];
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
|
||||
export class UpdateWahlDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isOpen?: boolean;
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Response } from 'express';
|
||||
import { WahlService } from './wahl.service';
|
||||
import { ZuteilungService } from './zuteilung.service';
|
||||
import { CreateWahlDto } from './dto/create-wahl.dto';
|
||||
import { UpdateWahlDto } from './dto/update-wahl.dto';
|
||||
import { CreateWorkshopDto } from './dto/create-workshop.dto';
|
||||
import { SubmitTeilnehmerDto } from './dto/submit-teilnehmer.dto';
|
||||
import { CreateForceZuteilungDto } from './dto/create-force-zuteilung.dto';
|
||||
import { Roles } from '../common/roles.decorator';
|
||||
import { RolesGuard } from '../common/roles.guard';
|
||||
import { Role } from '../common/role.enum';
|
||||
import { GuestAuthenticatedRequest } from '../auth/authenticated-request';
|
||||
|
||||
@Controller('wahl')
|
||||
export class WahlController {
|
||||
constructor(
|
||||
private readonly wahl: WahlService,
|
||||
private readonly zuteilung: ZuteilungService,
|
||||
) {}
|
||||
|
||||
/// Wahl-/Workshop-/Force-Zuteilung-Verwaltung ist ausschließlich Sache des
|
||||
/// Leitungsteams (global über alle KCs, siehe RolesGuard).
|
||||
@Post()
|
||||
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||
@Roles(Role.LEITUNGSTEAM)
|
||||
createWahl(@Body() dto: CreateWahlDto) {
|
||||
return this.wahl.createWahl(dto.kcId, dto.name, dto.datumsSchluessel, dto.teil);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||
@Roles(Role.LEITUNGSTEAM)
|
||||
listWahlen(@Query('kcId') kcId: string) {
|
||||
return this.wahl.listWahlen(kcId);
|
||||
}
|
||||
|
||||
@Patch(':wahlId')
|
||||
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||
@Roles(Role.LEITUNGSTEAM)
|
||||
updateWahl(@Param('wahlId') wahlId: string, @Body() dto: UpdateWahlDto) {
|
||||
return this.wahl.updateWahl(wahlId, { isOpen: dto.isOpen });
|
||||
}
|
||||
|
||||
@Get(':wahlId/teilnehmer')
|
||||
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||
@Roles(Role.LEITUNGSTEAM)
|
||||
listTeilnehmer(@Param('wahlId') wahlId: string) {
|
||||
return this.wahl.listTeilnehmer(wahlId);
|
||||
}
|
||||
|
||||
@Post(':wahlId/workshops')
|
||||
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||
@Roles(Role.LEITUNGSTEAM)
|
||||
createWorkshop(@Param('wahlId') wahlId: string, @Body() dto: CreateWorkshopDto) {
|
||||
return this.wahl.createWorkshop(wahlId, dto.name, dto.kapazitaet, dto.minTeilnehmer);
|
||||
}
|
||||
|
||||
@Get(':wahlId/workshops')
|
||||
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||
@Roles(Role.LEITUNGSTEAM)
|
||||
listWorkshops(@Param('wahlId') wahlId: string) {
|
||||
return this.wahl.listWorkshops(wahlId);
|
||||
}
|
||||
|
||||
@Post(':wahlId/force-zuteilung')
|
||||
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||
@Roles(Role.LEITUNGSTEAM)
|
||||
createForceZuteilung(
|
||||
@Param('wahlId') wahlId: string,
|
||||
@Body() dto: CreateForceZuteilungDto,
|
||||
) {
|
||||
return this.wahl.createForceZuteilung(wahlId, dto.teilnehmerId, dto.workshopId);
|
||||
}
|
||||
|
||||
/// Everything a guest needs to fill in the Wahl: open Wahlen for their KC,
|
||||
/// each with its workshops and the guest's own current priorities (if any).
|
||||
@Get('guest/overview')
|
||||
@UseGuards(AuthGuard('guest'))
|
||||
guestOverview(@Req() req: GuestAuthenticatedRequest) {
|
||||
const guest = req.user!;
|
||||
return this.wahl.guestOverview(guest.kcId, guest.guestId);
|
||||
}
|
||||
|
||||
/// The guest's own assignment result per Wahl they took part in.
|
||||
@Get('guest/results')
|
||||
@UseGuards(AuthGuard('guest'))
|
||||
guestResults(@Req() req: GuestAuthenticatedRequest) {
|
||||
const guest = req.user!;
|
||||
return this.wahl.guestResults(guest.kcId, guest.guestId);
|
||||
}
|
||||
|
||||
/// Guests submit their own workshop preferences (guest JWT, not Authentik).
|
||||
@Post(':wahlId/teilnehmer')
|
||||
@UseGuards(AuthGuard('guest'))
|
||||
submitTeilnehmer(
|
||||
@Param('wahlId') wahlId: string,
|
||||
@Body() dto: SubmitTeilnehmerDto,
|
||||
@Req() req: GuestAuthenticatedRequest,
|
||||
) {
|
||||
const guest = req.user!;
|
||||
return this.wahl.submitTeilnehmer(wahlId, guest.guestId, guest.kcId, dto.prioritaeten);
|
||||
}
|
||||
|
||||
@Post(':wahlId/zuteilung/run')
|
||||
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||
@Roles(Role.LEITUNGSTEAM)
|
||||
runZuteilung(@Param('wahlId') wahlId: string) {
|
||||
return this.zuteilung.run(wahlId);
|
||||
}
|
||||
|
||||
@Get(':wahlId/zuteilung')
|
||||
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||
@Roles(Role.LEITUNGSTEAM)
|
||||
getZuteilung(@Param('wahlId') wahlId: string) {
|
||||
return this.zuteilung.getResults(wahlId);
|
||||
}
|
||||
|
||||
@Get(':wahlId/zuteilung/csv')
|
||||
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||
@Roles(Role.LEITUNGSTEAM)
|
||||
async exportCsv(@Param('wahlId') wahlId: string, @Res() res: Response) {
|
||||
const csv = await this.zuteilung.exportCsv(wahlId);
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="zuteilung-${wahlId}.csv"`);
|
||||
res.send(csv);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WahlService } from './wahl.service';
|
||||
import { ZuteilungService } from './zuteilung.service';
|
||||
import { WahlController } from './wahl.controller';
|
||||
|
||||
@Module({
|
||||
providers: [WahlService, ZuteilungService],
|
||||
controllers: [WahlController],
|
||||
})
|
||||
export class WahlModule {}
|
||||
@@ -1,190 +0,0 @@
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { SyncOperation } from '@prisma/client';
|
||||
import { PrismaClient } from '../prisma/prisma.module';
|
||||
import { SyncService } from '../sync/sync.service';
|
||||
|
||||
@Injectable()
|
||||
export class WahlService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly sync: SyncService,
|
||||
) {}
|
||||
|
||||
async createWahl(kcId: string, name: string, datumsSchluessel: string, teil: string) {
|
||||
const wahl = await this.prisma.wahl.create({
|
||||
data: { kcId, name, datumsSchluessel, teil },
|
||||
});
|
||||
await this.sync.capture('Wahl', SyncOperation.CREATE, wahl.id, wahl);
|
||||
return wahl;
|
||||
}
|
||||
|
||||
listWahlen(kcId: string) {
|
||||
return this.prisma.wahl.findMany({ where: { kcId } });
|
||||
}
|
||||
|
||||
async updateWahl(wahlId: string, data: { isOpen?: boolean }) {
|
||||
await this.getWahlOrThrow(wahlId);
|
||||
const wahl = await this.prisma.wahl.update({ where: { id: wahlId }, data });
|
||||
await this.sync.capture('Wahl', SyncOperation.UPDATE, wahl.id, wahl);
|
||||
return wahl;
|
||||
}
|
||||
|
||||
/// LT view of who took part in a Wahl, with their priorities and any
|
||||
/// existing Force-Zuteilung.
|
||||
async listTeilnehmer(wahlId: string) {
|
||||
await this.getWahlOrThrow(wahlId);
|
||||
const rows = await this.prisma.teilnehmer.findMany({
|
||||
where: { wahlId },
|
||||
orderBy: { guestAccount: { lastName: 'asc' } },
|
||||
include: {
|
||||
guestAccount: { select: { firstName: true, lastName: true } },
|
||||
forceZuteilung: { select: { workshopId: true } },
|
||||
},
|
||||
});
|
||||
return rows.map((t) => ({
|
||||
id: t.id,
|
||||
name: `${t.guestAccount.firstName} ${t.guestAccount.lastName}`.trim(),
|
||||
prioritaeten: (t.prioritaeten as string[] | null) ?? [],
|
||||
forcedWorkshopId: t.forceZuteilung?.workshopId ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
/// Guest-facing view: open Wahlen for the guest's KC, each with its
|
||||
/// workshops and the guest's own current priorities (null if not submitted).
|
||||
async guestOverview(kcId: string, guestAccountId: string) {
|
||||
const [kc, wahlen] = await Promise.all([
|
||||
this.prisma.kc.findUnique({ where: { id: kcId }, select: { id: true, name: true } }),
|
||||
this.prisma.wahl.findMany({
|
||||
where: { kcId, isOpen: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: {
|
||||
workshops: {
|
||||
select: { id: true, name: true, kapazitaet: true },
|
||||
orderBy: { name: 'asc' },
|
||||
},
|
||||
teilnehmer: {
|
||||
where: { guestAccountId },
|
||||
select: { prioritaeten: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
kc,
|
||||
wahlen: wahlen.map((w) => ({
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
datumsSchluessel: w.datumsSchluessel,
|
||||
teil: w.teil,
|
||||
workshops: w.workshops,
|
||||
meinePrioritaeten: (w.teilnehmer[0]?.prioritaeten as string[] | undefined) ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/// Guest-facing result view: for every Wahl in the guest's KC where they
|
||||
/// took part, their assignment (workshop name + wish rank), or a pending
|
||||
/// marker if the algorithm has not run for them yet.
|
||||
async guestResults(kcId: string, guestAccountId: string) {
|
||||
const teilnahmen = await this.prisma.teilnehmer.findMany({
|
||||
where: { guestAccountId, wahl: { kcId } },
|
||||
orderBy: { wahl: { createdAt: 'asc' } },
|
||||
select: {
|
||||
wahl: { select: { id: true, name: true, datumsSchluessel: true, teil: true } },
|
||||
zuteilung: { select: { workshopId: true, wunschRang: true, isForced: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const workshopIds = teilnahmen
|
||||
.map((t) => t.zuteilung?.workshopId)
|
||||
.filter((id): id is string => !!id);
|
||||
const workshops = workshopIds.length
|
||||
? await this.prisma.workshop.findMany({
|
||||
where: { id: { in: workshopIds } },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
: [];
|
||||
const nameById = new Map(workshops.map((w) => [w.id, w.name]));
|
||||
|
||||
return teilnahmen.map((t) => {
|
||||
const z = t.zuteilung;
|
||||
return {
|
||||
wahl: t.wahl,
|
||||
status: !z ? 'PENDING' : z.workshopId ? 'ASSIGNED' : 'UNASSIGNED',
|
||||
workshopName: z?.workshopId ? (nameById.get(z.workshopId) ?? null) : null,
|
||||
wunschRang: z?.wunschRang ?? null,
|
||||
isForced: z?.isForced ?? false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async createWorkshop(
|
||||
wahlId: string,
|
||||
name: string,
|
||||
kapazitaet: number,
|
||||
minTeilnehmer: number,
|
||||
) {
|
||||
await this.getWahlOrThrow(wahlId);
|
||||
const workshop = await this.prisma.workshop.create({
|
||||
data: { wahlId, name, kapazitaet, minTeilnehmer },
|
||||
});
|
||||
await this.sync.capture('Workshop', SyncOperation.CREATE, workshop.id, workshop);
|
||||
return workshop;
|
||||
}
|
||||
|
||||
listWorkshops(wahlId: string) {
|
||||
return this.prisma.workshop.findMany({ where: { wahlId } });
|
||||
}
|
||||
|
||||
async createForceZuteilung(wahlId: string, teilnehmerId: string, workshopId: string) {
|
||||
const [teilnehmer, workshop] = await Promise.all([
|
||||
this.prisma.teilnehmer.findUnique({ where: { id: teilnehmerId } }),
|
||||
this.prisma.workshop.findUnique({ where: { id: workshopId } }),
|
||||
]);
|
||||
if (!teilnehmer || teilnehmer.wahlId !== wahlId) {
|
||||
throw new NotFoundException('Teilnehmer not found in this Wahl');
|
||||
}
|
||||
if (!workshop || workshop.wahlId !== wahlId) {
|
||||
throw new NotFoundException('Workshop not found in this Wahl');
|
||||
}
|
||||
const force = await this.prisma.forceZuteilung.upsert({
|
||||
where: { teilnehmerId },
|
||||
create: { wahlId, teilnehmerId, workshopId },
|
||||
update: { workshopId },
|
||||
});
|
||||
await this.sync.capture('ForceZuteilung', SyncOperation.UPDATE, force.id, force);
|
||||
return force;
|
||||
}
|
||||
|
||||
/// Guests submit their own choices; only allowed for their own KC and while the Wahl is open.
|
||||
async submitTeilnehmer(
|
||||
wahlId: string,
|
||||
guestAccountId: string,
|
||||
guestKcId: string,
|
||||
prioritaeten: string[],
|
||||
) {
|
||||
const wahl = await this.getWahlOrThrow(wahlId);
|
||||
if (wahl.kcId !== guestKcId) {
|
||||
throw new ForbiddenException('Guest does not belong to this KC');
|
||||
}
|
||||
if (!wahl.isOpen) {
|
||||
throw new ForbiddenException('Wahl is closed');
|
||||
}
|
||||
const teilnehmer = await this.prisma.teilnehmer.upsert({
|
||||
where: { wahlId_guestAccountId: { 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;
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { SyncOperation } from '@prisma/client';
|
||||
import { ZuteilungService } from './zuteilung.service';
|
||||
|
||||
/// Unit tests for the assignment algorithm ported from the WP plugin's
|
||||
/// kc_run_zuteilung. Prisma and SyncService are faked in-memory; assertions
|
||||
/// run against the `zuteilung.createMany` payload the service builds.
|
||||
|
||||
interface WorkshopFixture {
|
||||
id: string;
|
||||
name: string;
|
||||
kapazitaet: number;
|
||||
minTeilnehmer: number;
|
||||
}
|
||||
interface TeilnehmerFixture {
|
||||
id: string;
|
||||
prioritaeten: string[];
|
||||
}
|
||||
interface ForceFixture {
|
||||
id: string;
|
||||
teilnehmerId: string;
|
||||
workshopId: string;
|
||||
}
|
||||
|
||||
interface CreatedRow {
|
||||
teilnehmerId: string;
|
||||
workshopId: string | null;
|
||||
wunschRang: number;
|
||||
isForced: boolean;
|
||||
}
|
||||
|
||||
const WAHL_ID = 'wahl-1';
|
||||
|
||||
function makeService(fixture: {
|
||||
workshops: WorkshopFixture[];
|
||||
teilnehmer: TeilnehmerFixture[];
|
||||
forces?: ForceFixture[];
|
||||
wahlExists?: boolean;
|
||||
}) {
|
||||
let lastCreateMany: CreatedRow[] = [];
|
||||
|
||||
const workshops = fixture.workshops.map((w) => ({ ...w, wahlId: WAHL_ID }));
|
||||
const teilnehmer = fixture.teilnehmer.map((t) => ({
|
||||
id: t.id,
|
||||
wahlId: WAHL_ID,
|
||||
guestAccountId: `guest-${t.id}`,
|
||||
prioritaeten: t.prioritaeten,
|
||||
createdAt: new Date(),
|
||||
}));
|
||||
const forces = (fixture.forces ?? []).map((f) => ({ ...f, wahlId: WAHL_ID }));
|
||||
|
||||
const prisma = {
|
||||
wahl: {
|
||||
findUnique: jest
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
fixture.wahlExists === false ? null : { id: WAHL_ID, kcId: 'kc-1' },
|
||||
),
|
||||
},
|
||||
workshop: { findMany: jest.fn().mockResolvedValue(workshops) },
|
||||
teilnehmer: { findMany: jest.fn().mockResolvedValue(teilnehmer) },
|
||||
forceZuteilung: { findMany: jest.fn().mockResolvedValue(forces) },
|
||||
zuteilung: {
|
||||
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
createMany: jest.fn().mockImplementation(({ data }: { data: CreatedRow[] }) => {
|
||||
lastCreateMany = data;
|
||||
return Promise.resolve({ count: data.length });
|
||||
}),
|
||||
findMany: jest
|
||||
.fn()
|
||||
.mockImplementation(() =>
|
||||
Promise.resolve(lastCreateMany.map((row, i) => ({ id: `zut-${i}`, ...row }))),
|
||||
),
|
||||
},
|
||||
};
|
||||
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
const service = new ZuteilungService(prisma as never, sync as never);
|
||||
return { service, prisma, sync, rows: () => lastCreateMany };
|
||||
}
|
||||
|
||||
function rowFor(rows: CreatedRow[], teilnehmerId: string): CreatedRow {
|
||||
const row = rows.find((r) => r.teilnehmerId === teilnehmerId);
|
||||
if (!row) throw new Error(`no zuteilung row for ${teilnehmerId}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
describe('ZuteilungService', () => {
|
||||
it('throws NotFound when the Wahl does not exist', async () => {
|
||||
const { service } = makeService({
|
||||
workshops: [],
|
||||
teilnehmer: [],
|
||||
wahlExists: false,
|
||||
});
|
||||
await expect(service.run(WAHL_ID)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('clears previous Zuteilungen before recomputing', async () => {
|
||||
const { service, prisma } = makeService({
|
||||
workshops: [{ id: 'ws-a', name: 'A', kapazitaet: 5, minTeilnehmer: 0 }],
|
||||
teilnehmer: [{ id: 't1', prioritaeten: ['ws-a'] }],
|
||||
});
|
||||
await service.run(WAHL_ID);
|
||||
expect(prisma.zuteilung.deleteMany).toHaveBeenCalledWith({
|
||||
where: { teilnehmer: { wahlId: WAHL_ID } },
|
||||
});
|
||||
});
|
||||
|
||||
it('honours Force-Zuteilungen over the participant wishes', async () => {
|
||||
const { service, rows } = makeService({
|
||||
workshops: [
|
||||
{ id: 'ws-a', name: 'A', kapazitaet: 5, minTeilnehmer: 0 },
|
||||
{ id: 'ws-b', name: 'B', kapazitaet: 5, minTeilnehmer: 0 },
|
||||
],
|
||||
teilnehmer: [{ id: 't1', prioritaeten: ['ws-b'] }],
|
||||
forces: [{ id: 'f1', teilnehmerId: 't1', workshopId: 'ws-a' }],
|
||||
});
|
||||
await service.run(WAHL_ID);
|
||||
expect(rowFor(rows(), 't1')).toEqual({
|
||||
teilnehmerId: 't1',
|
||||
workshopId: 'ws-a',
|
||||
wunschRang: 0,
|
||||
isForced: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('assigns a first-wish workshop when capacity allows', async () => {
|
||||
const { service, rows } = makeService({
|
||||
workshops: [{ id: 'ws-a', name: 'A', kapazitaet: 3, minTeilnehmer: 0 }],
|
||||
teilnehmer: [{ id: 't1', prioritaeten: ['ws-a'] }],
|
||||
});
|
||||
await service.run(WAHL_ID);
|
||||
expect(rowFor(rows(), 't1')).toMatchObject({
|
||||
workshopId: 'ws-a',
|
||||
wunschRang: 1,
|
||||
isForced: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the next wish once a workshop is full', async () => {
|
||||
const { service, rows } = makeService({
|
||||
workshops: [
|
||||
{ id: 'ws-a', name: 'A', kapazitaet: 1, minTeilnehmer: 0 },
|
||||
{ id: 'ws-b', name: 'B', kapazitaet: 5, minTeilnehmer: 0 },
|
||||
],
|
||||
teilnehmer: [
|
||||
{ id: 't1', prioritaeten: ['ws-a', 'ws-b'] },
|
||||
{ id: 't2', prioritaeten: ['ws-a', 'ws-b'] },
|
||||
],
|
||||
});
|
||||
await service.run(WAHL_ID);
|
||||
const placed = [rowFor(rows(), 't1'), rowFor(rows(), 't2')]
|
||||
.map((r) => `${r.workshopId}:${r.wunschRang}`)
|
||||
.sort();
|
||||
// One keeps the 1st wish (ws-a), the other slides to the 2nd wish (ws-b).
|
||||
expect(placed).toEqual(['ws-a:1', 'ws-b:2']);
|
||||
});
|
||||
|
||||
it('leaves a participant unassigned when no capacity is left anywhere', async () => {
|
||||
const { service, rows } = makeService({
|
||||
workshops: [{ id: 'ws-a', name: 'A', kapazitaet: 0, minTeilnehmer: 0 }],
|
||||
teilnehmer: [{ id: 't1', prioritaeten: [] }],
|
||||
});
|
||||
await service.run(WAHL_ID);
|
||||
expect(rowFor(rows(), 't1')).toEqual({
|
||||
teilnehmerId: 't1',
|
||||
workshopId: null,
|
||||
wunschRang: -1,
|
||||
isForced: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('dissolves a workshop that stays below minTeilnehmer and reassigns via remaining wishes', async () => {
|
||||
const { service, rows } = makeService({
|
||||
workshops: [
|
||||
{ id: 'ws-a', name: 'A', kapazitaet: 10, minTeilnehmer: 3 },
|
||||
{ id: 'ws-b', name: 'B', kapazitaet: 10, minTeilnehmer: 0 },
|
||||
],
|
||||
teilnehmer: [
|
||||
{ id: 't1', prioritaeten: ['ws-a', 'ws-b'] },
|
||||
{ id: 't2', prioritaeten: ['ws-a', 'ws-b'] },
|
||||
],
|
||||
});
|
||||
await service.run(WAHL_ID);
|
||||
// ws-a got 2 (< min 3) -> dissolved; both fall through to their 2nd wish.
|
||||
for (const id of ['t1', 't2']) {
|
||||
expect(rowFor(rows(), id)).toMatchObject({ workshopId: 'ws-b', wunschRang: 2 });
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a workshop that exactly meets minTeilnehmer', async () => {
|
||||
const { service, rows } = makeService({
|
||||
workshops: [{ id: 'ws-a', name: 'A', kapazitaet: 10, minTeilnehmer: 2 }],
|
||||
teilnehmer: [
|
||||
{ id: 't1', prioritaeten: ['ws-a'] },
|
||||
{ id: 't2', prioritaeten: ['ws-a'] },
|
||||
],
|
||||
});
|
||||
await service.run(WAHL_ID);
|
||||
for (const id of ['t1', 't2']) {
|
||||
expect(rowFor(rows(), id)).toMatchObject({ workshopId: 'ws-a', wunschRang: 1 });
|
||||
}
|
||||
});
|
||||
|
||||
it('captures one CREATE sync entry per resulting Zuteilung', async () => {
|
||||
const { service, sync } = makeService({
|
||||
workshops: [{ id: 'ws-a', name: 'A', kapazitaet: 5, minTeilnehmer: 0 }],
|
||||
teilnehmer: [
|
||||
{ id: 't1', prioritaeten: ['ws-a'] },
|
||||
{ id: 't2', prioritaeten: ['ws-a'] },
|
||||
],
|
||||
});
|
||||
await service.run(WAHL_ID);
|
||||
expect(sync.capture).toHaveBeenCalledTimes(2);
|
||||
expect(sync.capture).toHaveBeenCalledWith(
|
||||
'Zuteilung',
|
||||
SyncOperation.CREATE,
|
||||
expect.any(String),
|
||||
expect.objectContaining({ teilnehmerId: expect.any(String) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,212 +0,0 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { SyncOperation, Teilnehmer } from '@prisma/client';
|
||||
import { PrismaClient } from '../prisma/prisma.module';
|
||||
import { SyncService } from '../sync/sync.service';
|
||||
|
||||
type TeilnehmerRow = Teilnehmer;
|
||||
|
||||
interface ZuteilungResult {
|
||||
workshopId: string | null;
|
||||
wunschRang: number;
|
||||
isForced: boolean;
|
||||
}
|
||||
|
||||
/// Port of the WP plugin's kc_run_zuteilung: force-assignments first, then up
|
||||
/// to 3 wish rounds, then random fill of the rest, then a consolidation pass
|
||||
/// that dissolves workshops which stayed below their minTeilnehmer.
|
||||
@Injectable()
|
||||
export class ZuteilungService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly sync: SyncService,
|
||||
) {}
|
||||
|
||||
async run(wahlId: string) {
|
||||
const wahl = await this.prisma.wahl.findUnique({ where: { id: wahlId } });
|
||||
if (!wahl) {
|
||||
throw new NotFoundException('Wahl not found');
|
||||
}
|
||||
|
||||
const [workshops, teilnehmerList, forces] = await Promise.all([
|
||||
this.prisma.workshop.findMany({ where: { wahlId } }),
|
||||
this.prisma.teilnehmer.findMany({ where: { wahlId } }),
|
||||
this.prisma.forceZuteilung.findMany({ where: { wahlId } }),
|
||||
]);
|
||||
|
||||
await this.prisma.zuteilung.deleteMany({
|
||||
where: { teilnehmer: { wahlId } },
|
||||
});
|
||||
|
||||
const caps = new Map(workshops.map((w) => [w.id, w.kapazitaet]));
|
||||
const results = new Map<string, ZuteilungResult>();
|
||||
|
||||
const tryAssign = (
|
||||
teilnehmerId: string,
|
||||
workshopId: string,
|
||||
wunschRang: number,
|
||||
isForced: boolean,
|
||||
): boolean => {
|
||||
const cap = caps.get(workshopId) ?? 0;
|
||||
if (cap <= 0) return false;
|
||||
caps.set(workshopId, cap - 1);
|
||||
results.set(teilnehmerId, { workshopId, wunschRang, isForced });
|
||||
return true;
|
||||
};
|
||||
|
||||
// 1) Force-Zuteilungen haben Vorrang
|
||||
for (const force of forces) {
|
||||
const teilnehmer = teilnehmerList.find((t) => t.id === force.teilnehmerId);
|
||||
if (!teilnehmer || results.has(teilnehmer.id)) continue;
|
||||
tryAssign(teilnehmer.id, force.workshopId, 0, true);
|
||||
}
|
||||
|
||||
// 2) Verbleibende Teilnehmer mischen
|
||||
let remaining = shuffle(teilnehmerList.filter((t) => !results.has(t.id)));
|
||||
|
||||
// 3) Wunschrunden 1..3
|
||||
for (let wunschRang = 1; wunschRang <= 3; wunschRang++) {
|
||||
const notAssigned: TeilnehmerRow[] = [];
|
||||
for (const teilnehmer of remaining) {
|
||||
const wunsch = readPrioritaeten(teilnehmer.prioritaeten)[wunschRang - 1];
|
||||
if (!wunsch || !tryAssign(teilnehmer.id, wunsch, wunschRang, false)) {
|
||||
notAssigned.push(teilnehmer);
|
||||
}
|
||||
}
|
||||
remaining = shuffle(notAssigned);
|
||||
}
|
||||
|
||||
// 4) Rest zufällig auf freie Workshops verteilen, sonst unzugeteilt
|
||||
for (const teilnehmer of remaining) {
|
||||
const freeWorkshopId = pickRandomFreeWorkshop(caps);
|
||||
if (freeWorkshopId) {
|
||||
tryAssign(teilnehmer.id, freeWorkshopId, 99, false);
|
||||
} else {
|
||||
results.set(teilnehmer.id, { workshopId: null, wunschRang: -1, isForced: false });
|
||||
}
|
||||
}
|
||||
|
||||
// 5) Konsolidierung: Workshops unter minTeilnehmer auflösen und neu verteilen
|
||||
consolidateUnderfilledWorkshops(workshops, teilnehmerList, results, caps);
|
||||
|
||||
await this.prisma.zuteilung.createMany({
|
||||
data: Array.from(results.entries()).map(([teilnehmerId, r]) => ({
|
||||
teilnehmerId,
|
||||
workshopId: r.workshopId,
|
||||
wunschRang: r.wunschRang,
|
||||
isForced: r.isForced,
|
||||
})),
|
||||
});
|
||||
|
||||
const created = await this.prisma.zuteilung.findMany({ where: { teilnehmer: { wahlId } } });
|
||||
for (const row of created) {
|
||||
await this.sync.capture('Zuteilung', SyncOperation.CREATE, row.id, row);
|
||||
}
|
||||
|
||||
return this.getResults(wahlId);
|
||||
}
|
||||
|
||||
async getResults(wahlId: string) {
|
||||
return this.prisma.zuteilung.findMany({
|
||||
where: { teilnehmer: { wahlId } },
|
||||
include: {
|
||||
teilnehmer: { include: { guestAccount: true } },
|
||||
workshop: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async exportCsv(wahlId: string): Promise<string> {
|
||||
const rows = await this.getResults(wahlId);
|
||||
const header = 'Vorname;Nachname;Workshop;WunschRang;Erzwungen';
|
||||
const lines = rows.map((r) => {
|
||||
const vorname = r.teilnehmer.guestAccount.firstName;
|
||||
const nachname = r.teilnehmer.guestAccount.lastName;
|
||||
const workshop = r.workshop?.name ?? 'UNZUGETEILT';
|
||||
return `${vorname};${nachname};${workshop};${r.wunschRang};${r.isForced ? 'ja' : 'nein'}`;
|
||||
});
|
||||
return [header, ...lines].join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
/// Dissolves workshops that got some participants but stayed below their
|
||||
/// minTeilnehmer, freeing their capacity and reassigning displaced
|
||||
/// participants (preferring their remaining wishes, then any free workshop).
|
||||
function consolidateUnderfilledWorkshops(
|
||||
workshops: { id: string; minTeilnehmer: number }[],
|
||||
teilnehmerList: TeilnehmerRow[],
|
||||
results: Map<string, ZuteilungResult>,
|
||||
caps: Map<string, number>,
|
||||
) {
|
||||
const countByWorkshop = new Map<string, number>();
|
||||
for (const r of results.values()) {
|
||||
if (r.workshopId) {
|
||||
countByWorkshop.set(r.workshopId, (countByWorkshop.get(r.workshopId) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const failing = workshops.filter((w) => {
|
||||
const count = countByWorkshop.get(w.id) ?? 0;
|
||||
return count > 0 && w.minTeilnehmer > 0 && count < w.minTeilnehmer;
|
||||
});
|
||||
if (failing.length === 0) return;
|
||||
|
||||
const failingIds = new Set(failing.map((w) => w.id));
|
||||
const toReassign: string[] = [];
|
||||
for (const [teilnehmerId, r] of results.entries()) {
|
||||
if (r.workshopId && failingIds.has(r.workshopId)) {
|
||||
caps.set(r.workshopId, (caps.get(r.workshopId) ?? 0) + 1);
|
||||
toReassign.push(teilnehmerId);
|
||||
results.delete(teilnehmerId);
|
||||
}
|
||||
}
|
||||
|
||||
const assign = (teilnehmerId: string, workshopId: string, wunschRang: number): boolean => {
|
||||
const cap = caps.get(workshopId) ?? 0;
|
||||
if (cap <= 0) return false;
|
||||
caps.set(workshopId, cap - 1);
|
||||
results.set(teilnehmerId, { workshopId, wunschRang, isForced: false });
|
||||
return true;
|
||||
};
|
||||
|
||||
for (const teilnehmerId of toReassign) {
|
||||
const teilnehmer = teilnehmerList.find((t) => t.id === teilnehmerId);
|
||||
const wuensche = teilnehmer ? readPrioritaeten(teilnehmer.prioritaeten) : [];
|
||||
let reassigned = false;
|
||||
for (let wunschRang = 1; wunschRang <= wuensche.length; wunschRang++) {
|
||||
const choice = wuensche[wunschRang - 1];
|
||||
if (choice && !failingIds.has(choice) && assign(teilnehmerId, choice, wunschRang)) {
|
||||
reassigned = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!reassigned) {
|
||||
const freeWorkshopId = pickRandomFreeWorkshop(caps, failingIds);
|
||||
if (freeWorkshopId) {
|
||||
assign(teilnehmerId, freeWorkshopId, 99);
|
||||
} else {
|
||||
results.set(teilnehmerId, { workshopId: null, wunschRang: -1, isForced: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readPrioritaeten(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : [];
|
||||
}
|
||||
|
||||
function shuffle<T>(items: T[]): T[] {
|
||||
const copy = [...items];
|
||||
for (let i = copy.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[copy[i], copy[j]] = [copy[j], copy[i]];
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
function pickRandomFreeWorkshop(caps: Map<string, number>, exclude?: Set<string>): string | null {
|
||||
const free = [...caps.entries()].filter(
|
||||
([id, cap]) => cap > 0 && !(exclude && exclude.has(id)),
|
||||
);
|
||||
if (free.length === 0) return null;
|
||||
return free[Math.floor(Math.random() * free.length)][0];
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user