17 Commits
Author SHA1 Message Date
linus fb4b1e21cf Merge pull request 'fix(ci): stop mounting repo over /app in CybeDefend container' (#2) from fix/cybedefend-scan-app-mount into main
CybeDefend Security Scan / cybedefend_scan (push) Failing after 2m55s
2026-09-12 11:48:32 +00:00
linus 858c43a6aa fix(ci): stop mounting repo over /app in CybeDefend container
CybeDefend Security Scan / cybedefend_scan (pull_request) Successful in 24s
The CybeDefend CLI image runs from /app/cybedefend (its own binary and
sources live there). Mounting the checked-out repo at -v $WORKSPACE:/app
shadowed that binary, so the container failed with:

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 12:23:33 +02:00
linusandClaude Sonnet 5 be13d8350b build: Docker setup (compose: postgres + all-in-one api image)
- Dockerfile: 3-stage — Flutter web build, NestJS build, slim node runtime.
  Runtime copies dist + node_modules + prisma + the web bundle
  (WEB_CLIENT_DIR=/app/web), runs `prisma migrate deploy` then `node
  dist/main.js`. One container serves client + API on :3000.
- docker-compose.yml: postgres:16-alpine with a healthcheck + the api
  service; config from backend/.env (Compose v2 strips quotes),
  DATABASE_URL + GOOGLE_APPLICATION_CREDENTIALS overridden for the
  container, serviceAccount.json bind-mounted read-only.
- .dockerignore keeps node_modules/build/secrets out of the context.

Not run here (no Docker on this box); the stack also runs natively against
the local Postgres.

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 10:00:58 +02:00
41 changed files with 1857 additions and 117 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"projectId": "5fe999f9-fbff-4a09-a987-48c4e7540b38"
}
+17
View File
@@ -0,0 +1,17 @@
**/node_modules
**/.dart_tool
**/coverage
**/*.log
.git
.github
backend/dist
# Flutter platform scaffolding / caches — the build/web bundle IS needed.
client/app/android
client/app/ios
client/app/linux
client/app/macos
client/app/windows
client/app/.dart_tool
# Secrets: passed at runtime via env_file / bind mount, never baked in.
backend/.env
backend/serviceAccount.json
+9 -1
View File
@@ -7,7 +7,7 @@ AUTHENTIK_ISSUER_URL="https://sso.konfi-castle.com/application/o/konfi-castle-ap
# Name of the Authentik group whose members are Leitungsteam. Mirrored to
# User.isLeitungsteam on every login (the access token must carry a `groups`
# claim; add the "groups" scope to the Authentik provider).
AUTHENTIK_LEITUNGSTEAM_GROUP="Leitungsteam"
AUTHENTIK_LEITUNGSTEAM_GROUP="KC-APP-LT"
# Secret used to sign guest/Konfi session tokens (local accounts only)
GUEST_JWT_SECRET="change-me"
@@ -31,6 +31,14 @@ SMTP_SECURE="false"
SMTP_USER=""
SMTP_PASS=""
# Push: defaults to "log" (no delivery). Set PUSH_PROVIDER=fcm plus
# FCM_PROJECT_ID and GOOGLE_APPLICATION_CREDENTIALS (path to a Firebase
# service-account JSON with the "Firebase Cloud Messaging API" enabled) to
# send real notifications via FCM HTTP v1.
PUSH_PROVIDER="log"
FCM_PROJECT_ID="konfi-castle-app"
GOOGLE_APPLICATION_CREDENTIALS="/absolute/path/to/serviceAccount.json"
# File storage: defaults to Nextcloud via WebDAV; set STORAGE_PROVIDER=s3 to
# use an S3-compatible bucket instead (see S3_* vars below).
STORAGE_PROVIDER="webdav"
+51
View File
@@ -0,0 +1,51 @@
name: CybeDefend Security Scan
on:
push:
branches:
- main
- master
- 'feat/**'
pull_request:
branches:
- main
- master
jobs:
cybedefend_scan:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Run CybeDefend Security Scan
env:
CYBEDEFEND_PAT: ${{ secrets.CYBEDEFEND_PAT }}
CYBEDEFEND_PROJECT_ID: ${{ secrets.CYBEDEFEND_PROJECT_ID }}
run: |
docker run --rm \
-v "${{ gitea.workspace }}":/src -w /src \
-e CYBEDEFEND_PAT="$CYBEDEFEND_PAT" \
-e CYBEDEFEND_PROJECT_ID="$CYBEDEFEND_PROJECT_ID" \
ghcr.io/cybedefend/cybedefend-cli:latest \
scan --dir . --region eu --ci --break-on-severity critical
- name: Fetch detailed SARIF results
if: always()
env:
CYBEDEFEND_PAT: ${{ secrets.CYBEDEFEND_PAT }}
CYBEDEFEND_PROJECT_ID: ${{ secrets.CYBEDEFEND_PROJECT_ID }}
run: |
docker run --rm \
-v "${{ gitea.workspace }}":/src -w /src \
-e CYBEDEFEND_PAT="$CYBEDEFEND_PAT" \
-e CYBEDEFEND_PROJECT_ID="$CYBEDEFEND_PROJECT_ID" \
ghcr.io/cybedefend/cybedefend-cli:latest \
results --project-id "$CYBEDEFEND_PROJECT_ID" --all --output sarif --filename results.sarif --ci
- name: Upload scan results as artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: cybedefend-results
path: results.sarif
+4
View File
@@ -3,3 +3,7 @@ dist
coverage
.env
*.log
# Firebase service account (secret)
serviceAccount.json
*.serviceAccount.json
+34
View File
@@ -0,0 +1,34 @@
# syntax=docker/dockerfile:1
#
# Server-only image (this repo has no Flutter client). The web client is
# built in the KC-APP client repo and its `build/web` output is mounted
# into the container at runtime via WEB_CLIENT_DIR (see docker-compose.yml).
# --- 1. Backend build ---------------------------------------------------------
FROM node:20-bookworm-slim AS api-build
WORKDIR /src
# Prisma detects the OpenSSL version at `generate` time to pick the matching
# query engine binary; without OpenSSL present here it silently defaults to
# openssl-1.1.x, which then fails to load in the runtime stage (openssl 3.0.x).
RUN apt-get update && apt-get install -y --no-install-recommends openssl \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npx prisma generate && npm run build
# --- 2. Runtime -------------------------------------------------------------
FROM node:20-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
# Prisma needs OpenSSL at runtime.
RUN apt-get update && apt-get install -y --no-install-recommends openssl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=api-build /src/node_modules ./node_modules
COPY --from=api-build /src/dist ./dist
COPY --from=api-build /src/prisma ./prisma
# Web client bundle is bind-mounted at runtime, not baked into the image;
# app.module reads WEB_CLIENT_DIR. See docker-compose.yml.
EXPOSE 3000
# Apply pending migrations, then boot.
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main.js"]
+38 -16
View File
@@ -1,7 +1,7 @@
# KC-App Backend
NestJS API for the KC-App platform (see repo root README + plan for
architecture context).
NestJS API for the KC-App platform. Split out of the main KC-APP monorepo
(https://git.konfi-castle.com/linus/KC-APP); the Flutter clients live there.
## Setup
@@ -83,6 +83,13 @@ client's host - no separate web server is needed.
`MailService.sendTeamerInvite()` composes the personal-invite email with a
link built from `APP_BASE_URL`. Delivery is best-effort — failures are
logged and swallowed, never blocking the invite.
- `push/` — global `PushProvider` abstraction; default `log`, `PUSH_PROVIDER=fcm`
uses FCM HTTP v1 (service-account JWT → OAuth token, no extra dep;
`FCM_PROJECT_ID`, `GOOGLE_APPLICATION_CREDENTIALS`). `DeviceToken` rows
(bound to a `User` or `GuestAccount`) via `POST /push/register` +
`/unregister`. `PushService.notifyChannel()` resolves a channel's readable
audience → their tokens (minus the sender) → send, pruning invalid ones;
`ChatService.sendMessage()` fires it best-effort.
- `wahl/` — Wahl/Workshop administration (Leitungsteam-only), guest
Teilnehmer submission, Force-Zuteilung overrides, and `ZuteilungService`:
a faithful port of the WP plugin's `kc_run_zuteilung` (force-assignments →
@@ -96,13 +103,28 @@ client's host - no separate web server is needed.
(`WEBDAV_*` env vars), switchable to S3-compatible storage with
`STORAGE_PROVIDER=s3` (`S3_*` env vars).
- `chat/` — Gemeinde-Gruppenchat, 1:1 Direktnachrichten, Leitungsteam-über-
greifende Kanäle und Broadcast (Konfis lesen nur). Channel administration
and message history are plain REST (`ChatController`); real-time send/
receive is a raw `ws` gateway (`ChatGateway`, path `/chat`) since passport
guards don't apply to WS upgrades — auth happens once via `?token=` at
connect time (`TokenVerificationService` tries Authentik JWKS, then falls
back to a guest token). Access rules live in `ChatService` and are shared
between the REST and WS entry points.
greifende Kanäle, Broadcast (Konfis lesen nur), and free-form `GRUPPE`
chats. Channel administration and message history are plain REST
(`ChatController`); real-time send/receive is a raw `ws` gateway
(`ChatGateway`, path `/chat`) since passport guards don't apply to WS
upgrades — auth happens once via `?token=` at connect time
(`TokenVerificationService` tries Authentik JWKS, then falls back to a
guest token). Access rules live in `ChatService` and are shared between
the REST and WS entry points.
- `POST /chat/:kcId/gruppen` lets a Leitungsteam member (any KC) or a
Gemeinde Verantwortliche/r (their own KC — `RolesGuard`'s kcId scoping)
create a `GRUPPE` channel with any mix of team users and Konfis (guests)
from that KC as initial participants (`participantUserIds`,
`participantGuestIds`); the creator is always included. Unlike
`GEMEINDE_GRUPPE`, membership isn't derived from `Gemeinde` — every
participant is an explicit `ChatParticipant` row, so a Konfi (who always
belongs to exactly one Gemeinde) can be added regardless of which
Gemeinde the chat's creator manages.
- `POST` / `DELETE /chat/gruppen/:channelId/participants` (body
`{ userId }` or `{ guestId }`) add/remove a participant afterwards.
Allowed for the channel's creator, any Leitungsteam member, or a
Verantwortliche/r of that KC — not the participants themselves, and not
guests.
- `sync/` — replicates mutations between the local (on-site) and cloud
server. `SyncService.capture()` is called by feature services right after
a write, appending an entry to the append-only `SyncLogEntry` log tagged
@@ -119,11 +141,11 @@ client's host - no separate web server is needed.
- `common/``Role` enum, `@Roles()` decorator, `RolesGuard` (KC-scoped,
Leitungsteam roles are global across all KCs).
All planned backend phases are implemented. `npm test` runs Jest unit tests
(`ZuteilungService`, `TeamAuthService`, `TeamerService`, `OnboardingService`,
All planned backend features are implemented (`prisma/migrations/` holds the
schema history). `npm test` runs Jest unit tests (`ZuteilungService`,
`TeamAuthService`, `TeamerService`, `OnboardingService`,
`resolveOrProvisionAuthentikUser` / `toAuthenticatedUser`; Prisma mocked).
Remaining work: the Flutter clients (see repo root README), push
notifications, and the first real Prisma migration (only `schema.prisma`
exists so far). Ops notes: the Authentik provider must emit a `groups` claim
for the LT check, and `MAIL_PROVIDER=smtp` + `SMTP_*` must be set for invite
emails to actually leave the box.
Ops notes to go live: the Authentik provider must emit a `groups` claim for
the LT check; `MAIL_PROVIDER=smtp` + `SMTP_*` for invite emails;
`PUSH_PROVIDER=fcm` + a Firebase service-account JSON for push; and real
Nextcloud/S3 credentials for file storage.
+21
View File
@@ -0,0 +1,21 @@
// TEMPORARY TEST FILE — intentionally vulnerable code to trigger CybeDefend scan
// Safe to delete after the scan demo.
const AWS_ACCESS_KEY = "AKIAABCDEFGHIJKLMNOP"; // hardcoded secret (should trigger secret scanner)
const DB_PASSWORD = "SuperSecret123!"; // hardcoded credential
const mysql = require('mysql');
function getUser(db, userId) {
// SQL injection: string concatenation of user input directly into query
const query = "SELECT * FROM users WHERE id = '" + userId + "'";
return db.query(query);
}
function runCommand(userInput) {
const { exec } = require('child_process');
// command injection: unsanitized user input passed to shell
exec("echo " + userInput);
}
module.exports = { getUser, runCommand, AWS_ACCESS_KEY, DB_PASSWORD };
+48
View File
@@ -0,0 +1,48 @@
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: kcapp
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d kcapp"]
interval: 5s
timeout: 5s
retries: 10
api:
build:
context: .
dockerfile: Dockerfile
depends_on:
db:
condition: service_healthy
# All non-DB config comes from .env (needs Docker Compose v2, which
# strips surrounding quotes). DATABASE_URL and the FCM credential path
# are overridden below for the container.
env_file:
- .env
environment:
DATABASE_URL: postgresql://postgres:postgres@db:5432/kcapp?schema=public
PORT: "3000"
GOOGLE_APPLICATION_CREDENTIALS: /app/serviceAccount.json
APP_BASE_URL: http://localhost:3010
WEB_CLIENT_DIR: /app/web
ports:
- "3010:3000"
volumes:
# Firebase service account — kept out of the image, mounted read-only.
- ./serviceAccount.json:/app/serviceAccount.json:ro
# Pre-built Flutter web bundle, built separately in the KC-APP client
# repo (flutter build web --release) and mounted read-only here.
# Set WEB_CLIENT_BUILD_PATH (e.g. in .env) to that build/web directory;
# defaults to a sibling ../KC-APP checkout.
- ${WEB_CLIENT_BUILD_PATH:-../KC-APP/client/app/build/web}:/app/web:ro
volumes:
pgdata:
@@ -0,0 +1,21 @@
-- CreateTable
CREATE TABLE "DeviceToken" (
"id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"platform" TEXT NOT NULL,
"userId" TEXT,
"guestAccountId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "DeviceToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "DeviceToken_token_key" ON "DeviceToken"("token");
-- AddForeignKey
ALTER TABLE "DeviceToken" ADD CONSTRAINT "DeviceToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DeviceToken" ADD CONSTRAINT "DeviceToken_guestAccountId_fkey" FOREIGN KEY ("guestAccountId") REFERENCES "GuestAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,43 @@
-- DropIndex
DROP INDEX "Teilnehmer_wahlId_guestAccountId_key";
-- AlterTable
ALTER TABLE "Wahl" ADD COLUMN "beschreibung" TEXT,
ADD COLUMN "phasenAnzahl" INTEGER NOT NULL DEFAULT 1;
-- AlterTable
ALTER TABLE "Workshop" ADD COLUMN "beschreibung" TEXT,
ADD COLUMN "phase" INTEGER NOT NULL DEFAULT 1;
-- AlterTable
ALTER TABLE "Teilnehmer" ADD COLUMN "phase" INTEGER NOT NULL DEFAULT 1;
-- CreateTable
CREATE TABLE "VerantwortlicheInvite" (
"id" TEXT NOT NULL,
"kcId" TEXT NOT NULL,
"gemeindeId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"email" TEXT,
"maxUses" INTEGER,
"usedCount" INTEGER NOT NULL DEFAULT 0,
"expiresAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
"createdByUserId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "VerantwortlicheInvite_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "VerantwortlicheInvite_token_key" ON "VerantwortlicheInvite"("token");
-- CreateIndex
CREATE UNIQUE INDEX "Teilnehmer_wahlId_guestAccountId_phase_key" ON "Teilnehmer"("wahlId", "guestAccountId", "phase");
-- AddForeignKey
ALTER TABLE "VerantwortlicheInvite" ADD CONSTRAINT "VerantwortlicheInvite_kcId_fkey" FOREIGN KEY ("kcId") REFERENCES "Kc"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VerantwortlicheInvite" ADD CONSTRAINT "VerantwortlicheInvite_gemeindeId_fkey" FOREIGN KEY ("gemeindeId") REFERENCES "Gemeinde"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,17 @@
-- AlterEnum
ALTER TYPE "ChatChannelType" ADD VALUE 'GRUPPE';
-- AlterTable
ALTER TABLE "ChatChannel" ADD COLUMN "createdByUserId" TEXT,
ADD COLUMN "name" TEXT;
-- AlterTable
ALTER TABLE "ChatParticipant" ADD COLUMN "guestAccountId" TEXT,
ALTER COLUMN "userId" DROP NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "ChatParticipant_channelId_guestAccountId_key" ON "ChatParticipant"("channelId", "guestAccountId");
-- AddForeignKey
ALTER TABLE "ChatParticipant" ADD CONSTRAINT "ChatParticipant_guestAccountId_fkey" FOREIGN KEY ("guestAccountId") REFERENCES "GuestAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+105 -33
View File
@@ -16,14 +16,15 @@ model Kc {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
gemeinden Gemeinde[]
memberships Membership[]
wahlen Wahl[]
files File[]
channels ChatChannel[]
guests GuestAccount[]
localUsers User[]
teamerInvites TeamerInvite[]
gemeinden Gemeinde[]
memberships Membership[]
wahlen Wahl[]
files File[]
channels ChatChannel[]
guests GuestAccount[]
localUsers User[]
teamerInvites TeamerInvite[]
verantwortlicheInvites VerantwortlicheInvite[]
}
/// A local congregation/community participating in one Kc.
@@ -33,10 +34,11 @@ model Gemeinde {
kcId String
createdAt DateTime @default(now())
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
memberships Membership[]
guests GuestAccount[]
teamerInvites TeamerInvite[]
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
memberships Membership[]
guests GuestAccount[]
teamerInvites TeamerInvite[]
verantwortlicheInvites VerantwortlicheInvite[]
@@unique([kcId, name])
}
@@ -78,6 +80,7 @@ model User {
memberships Membership[]
messages ChatMessage[]
chatParticipations ChatParticipant[]
deviceTokens DeviceToken[]
}
/// Scopes a User's role to a specific Kc (and Gemeinde, if applicable).
@@ -107,10 +110,28 @@ model GuestAccount {
lastName String
createdAt DateTime @default(now())
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade)
messages ChatMessage[]
teilnehmer Teilnehmer[]
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade)
messages ChatMessage[]
teilnehmer Teilnehmer[]
deviceTokens DeviceToken[]
chatParticipations ChatParticipant[]
}
/// A push-notification target (FCM registration token) bound to whoever
/// registered it — a team `User` or a `GuestAccount`. Replicated so a
/// notification can be sent from either server.
model DeviceToken {
id String @id @default(cuid())
token String @unique
platform String
userId String?
guestAccountId String?
createdAt DateTime @default(now())
lastSeenAt DateTime @default(now())
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
guestAccount GuestAccount? @relation(fields: [guestAccountId], references: [id], onDelete: Cascade)
}
/// Invitation issued by a Gemeinde Verantwortliche/r so new Gemeinde Teamer
@@ -134,13 +155,42 @@ model TeamerInvite {
gemeinde Gemeinde @relation(fields: [gemeindeId], references: [id], onDelete: Cascade)
}
/// Invitation issued by a Leitungsteam member so a person can register as
/// Gemeinde Verantwortliche/r for a specific Gemeinde via their
/// Konfi-Castle-ID (Authentik) — skips the self-registration approval step
/// since a Leitungsteam member is vouching for them directly. A group link
/// leaves `email` null and may be redeemed up to `maxUses` times (null =
/// unlimited); a personal invite pins `email` and defaults to a single use.
model VerantwortlicheInvite {
id String @id @default(cuid())
kcId String
gemeindeId String
token String @unique
email String?
maxUses Int?
usedCount Int @default(0)
expiresAt DateTime?
revokedAt DateTime?
createdByUserId String
createdAt DateTime @default(now())
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
gemeinde Gemeinde @relation(fields: [gemeindeId], references: [id], onDelete: Cascade)
}
/// A workshop election, scoped to a Kc; name carries a date key + "Teil".
/// `phasenAnzahl` mirrors the WP plugin's `anzahl_einheiten`: a Wahl can run
/// several independent phases (e.g. morning/afternoon), each with its own
/// workshops, its own guest submission, and its own assignment run — a guest
/// submits once per phase, not once for the whole Wahl.
model Wahl {
id String @id @default(cuid())
kcId String
name String
datumsSchluessel String
teil String
beschreibung String?
phasenAnzahl Int @default(1)
isOpen Boolean @default(true)
createdAt DateTime @default(now())
@@ -150,22 +200,29 @@ model Wahl {
forceZuteilungen ForceZuteilung[]
}
/// A workshop offered in one phase of a Wahl. `phase` is 1-based and must be
/// <= the owning Wahl's `phasenAnzahl`.
model Workshop {
id String @id @default(cuid())
id String @id @default(cuid())
wahlId String
phase Int @default(1)
name String
beschreibung String?
kapazitaet Int
minTeilnehmer Int @default(0)
minTeilnehmer Int @default(0)
wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade)
zuteilungen Zuteilung[]
forceZuteilungen ForceZuteilung[]
}
/// A participant's submitted choices for a Wahl.
/// A participant's submitted choices for one phase of a Wahl. A guest submits
/// separately per phase (matching the WP plugin), so the same guest can have
/// one row per (wahlId, phase).
model Teilnehmer {
id String @id @default(cuid())
wahlId String
phase Int @default(1)
guestAccountId String
prioritaeten Json
createdAt DateTime @default(now())
@@ -175,7 +232,7 @@ model Teilnehmer {
zuteilung Zuteilung?
forceZuteilung ForceZuteilung?
@@unique([wahlId, guestAccountId])
@@unique([wahlId, guestAccountId, phase])
}
/// Manual override set by LT before running the assignment algorithm; takes precedence.
@@ -226,32 +283,47 @@ enum ChatChannelType {
DIREKT
LT_UEBERGREIFEND
BROADCAST
/// Freely composed group chat: created by a Leitungsteam member or a
/// Gemeinde Verantwortliche/r (for their own KC), with an explicit,
/// mutable participant list (team users and/or guests) via ChatParticipant
/// - unlike GEMEINDE_GRUPPE, membership is not derived from Gemeinde.
GRUPPE
}
model ChatChannel {
id String @id @default(cuid())
kcId String
type ChatChannelType
gemeindeId String?
createdAt DateTime @default(now())
id String @id @default(cuid())
kcId String
type ChatChannelType
gemeindeId String?
/// Display name; used by GRUPPE channels (optional for other types).
name String?
/// Who created the channel; only set for GRUPPE so far. Used to let the
/// creator manage participants alongside Leitungsteam/Verantwortliche.
createdByUserId String?
createdAt DateTime @default(now())
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
messages ChatMessage[]
participants ChatParticipant[]
}
/// Explicit membership for DIREKT (1:1) channels; other channel types derive
/// access from Membership/Gemeinde instead of this table.
/// Explicit membership for DIREKT (1:1) and GRUPPE channels; other channel
/// types derive access from Membership/Gemeinde instead of this table.
/// Exactly one of userId/guestAccountId is set per row.
model ChatParticipant {
id String @id @default(cuid())
channelId String
userId String
createdAt DateTime @default(now())
id String @id @default(cuid())
channelId String
userId String?
guestAccountId String?
createdAt DateTime @default(now())
channel ChatChannel @relation(fields: [channelId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
channel ChatChannel @relation(fields: [channelId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
guestAccount GuestAccount? @relation(fields: [guestAccountId], references: [id], onDelete: Cascade)
@@unique([channelId, userId])
@@unique([channelId, guestAccountId])
}
model ChatMessage {
+2
View File
@@ -5,6 +5,7 @@ import { existsSync } from 'fs';
import { join } from 'path';
import { PrismaModule } from './prisma/prisma.module';
import { MailModule } from './mail/mail.module';
import { PushModule } from './push/push.module';
import { AuthModule } from './auth/auth.module';
import { KcModule } from './kc/kc.module';
import { GemeindeModule } from './gemeinde/gemeinde.module';
@@ -35,6 +36,7 @@ const webRoot =
}),
PrismaModule,
MailModule,
PushModule,
SyncModule,
AuthModule,
KcModule,
+7 -3
View File
@@ -1,4 +1,4 @@
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
import { BadRequestException, Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { GuestAuthService } from './guest-auth.service';
import { TeamAuthService } from './team-auth.service';
@@ -49,10 +49,14 @@ export class AuthController {
return this.guestAuth.createGuest(dto.inviteCode, dto.firstName, dto.lastName);
}
/// Password login for local Gemeinde Teamer accounts.
/// Password login for local Gemeinde Teamer accounts — by Gemeinde name
/// (the normal path) or by email (legacy/personal accounts).
@Post('team-login')
teamLogin(@Body() dto: TeamLoginDto) {
return this.teamAuth.login(dto.email, dto.password);
if (!dto.email && !dto.gemeindeName) {
throw new BadRequestException('email or gemeindeName is required');
}
return this.teamAuth.login({ email: dto.email, gemeindeName: dto.gemeindeName }, dto.password);
}
/// Self-registration for a Gemeinde Teamer via an invite token/link.
+11 -5
View File
@@ -7,13 +7,19 @@ import { Request } from 'express';
import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service';
import { AuthenticatedUser } from './authenticated-request';
import { resolveOrProvisionAuthentikUser, toAuthenticatedUser } from './provision-user';
import {
authentikEmail,
resolveOrProvisionAuthentikUser,
toAuthenticatedUser,
} from './provision-user';
interface AuthentikJwtPayload {
sub: string;
email?: string;
given_name?: string;
family_name?: string;
preferred_username?: string;
name?: string;
groups?: string[];
}
@@ -52,8 +58,8 @@ export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') {
}
async validate(payload: AuthentikJwtPayload): Promise<AuthenticatedUser> {
if (!payload.email) {
throw new UnauthorizedException('Authentik token missing email claim');
if (!payload.sub) {
throw new UnauthorizedException('Authentik token missing subject');
}
const isLeitungsteam = (payload.groups ?? []).includes(this.leitungsteamGroup);
const user = await resolveOrProvisionAuthentikUser(
@@ -61,8 +67,8 @@ export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') {
this.sync,
{
sub: payload.sub,
email: payload.email,
firstName: payload.given_name ?? '',
email: authentikEmail(payload),
firstName: payload.given_name ?? payload.preferred_username ?? payload.name ?? '',
lastName: payload.family_name ?? '',
},
isLeitungsteam,
+13 -2
View File
@@ -1,8 +1,19 @@
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
/// Team login accepts EITHER an email (legacy/personal Verantwortliche
/// accounts) OR a Gemeinde name (the normal Teamer login path, since a
/// Teamer thinks of their login as "meine Gemeinde", not their email).
/// At least one of email/gemeindeName is required; enforced in the
/// controller rather than a custom validator to keep this DTO simple.
export class TeamLoginDto {
@IsOptional()
@IsEmail()
email!: string;
email?: string;
@IsOptional()
@IsString()
@IsNotEmpty()
gemeindeName?: string;
@IsString()
@IsNotEmpty()
+21 -3
View File
@@ -20,6 +20,11 @@ export class GuestAuthService {
private readonly sync: SyncService,
) {}
/// Redeems a KC invite code for a guest/Konfi session. If a guest account
/// with the same (trimmed, case-insensitive) name already exists for this
/// KC, reuses it instead of creating a new one — this is what lets a Konfi
/// "log back in" with the same code + name and keep their chat history /
/// Workshop-Wahl submission instead of losing it to a fresh blank account.
async createGuest(
inviteCode: string,
firstName: string,
@@ -30,10 +35,23 @@ export class GuestAuthService {
throw new NotFoundException('Unknown or inactive KC invite code');
}
const guest = await this.prisma.guestAccount.create({
data: { kcId: kc.id, firstName, lastName },
const trimmedFirst = firstName.trim();
const trimmedLast = lastName.trim();
let guest = await this.prisma.guestAccount.findFirst({
where: {
kcId: kc.id,
firstName: { equals: trimmedFirst, mode: 'insensitive' },
lastName: { equals: trimmedLast, mode: 'insensitive' },
},
});
await this.sync.capture('GuestAccount', SyncOperation.CREATE, guest.id, guest);
if (!guest) {
guest = await this.prisma.guestAccount.create({
data: { kcId: kc.id, firstName: trimmedFirst, lastName: trimmedLast },
});
await this.sync.capture('GuestAccount', SyncOperation.CREATE, guest.id, guest);
}
const payload: GuestJwtPayload = {
guestId: guest.id,
+12
View File
@@ -10,6 +10,18 @@ export interface AuthentikClaims {
lastName: string;
}
/// Authentik users don't necessarily have an email set. Fall back to a stable,
/// per-user placeholder so provisioning still has a unique handle for the row.
export function authentikEmail(p: {
email?: string;
preferred_username?: string;
sub: string;
}): string {
const e = p.email?.trim();
if (e) return e.toLowerCase();
return `${p.preferred_username?.trim() || p.sub}@no-email.authentik`.toLowerCase();
}
/// Placeholder kcId for the synthetic, global LEITUNGSTEAM membership. RolesGuard
/// never compares it (LT short-circuits the KC check), it only needs to exist.
export const GLOBAL_LT_KC_ID = '*';
+87 -11
View File
@@ -28,6 +28,10 @@ interface InviteRow {
function makeService(seed: {
invites?: InviteRow[];
users?: { id: string; email: string; passwordHash: string | null }[];
memberships?: {
gemeindeName: string;
user: { id: string; passwordHash: string | null };
}[];
}) {
const invites = [...(seed.invites ?? [])];
const users = [...(seed.users ?? [])].map((u) => ({
@@ -39,6 +43,7 @@ function makeService(seed: {
memberships: [] as unknown[],
...u,
}));
const memberships = seed.memberships ?? [];
const prisma = {
user: {
@@ -64,6 +69,21 @@ function makeService(seed: {
create: jest.fn(({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({ id: `m-1`, ...data }),
),
findMany: jest.fn(
({
where,
}: {
where: { gemeinde: { name: { equals: string; mode: string } } };
}) =>
Promise.resolve(
memberships
.filter(
(m) =>
m.gemeindeName.toLowerCase() === where.gemeinde.name.equals.toLowerCase(),
)
.map((m) => ({ user: m.user })),
),
),
},
teamerInvite: {
findUnique: jest.fn(({ where }: { where: { token: string } }) =>
@@ -195,18 +215,18 @@ describe('TeamAuthService.registerFromInvite', () => {
describe('TeamAuthService.login', () => {
it('rejects an unknown email', async () => {
const { service } = makeService({ users: [] });
await expect(service.login('nobody@example.org', 'x')).rejects.toBeInstanceOf(
UnauthorizedException,
);
await expect(
service.login({ email: 'nobody@example.org' }, 'x'),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('rejects a user without a password hash (Authentik-only account)', async () => {
const { service } = makeService({
users: [{ id: 'u-1', email: 'lt@example.org', passwordHash: null }],
});
await expect(service.login('lt@example.org', 'x')).rejects.toBeInstanceOf(
UnauthorizedException,
);
await expect(
service.login({ email: 'lt@example.org' }, 'x'),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('rejects a wrong password', async () => {
@@ -215,18 +235,74 @@ describe('TeamAuthService.login', () => {
{ id: 'u-1', email: 't@example.org', passwordHash: bcrypt.hashSync('right', 10) },
],
});
await expect(service.login('t@example.org', 'wrong')).rejects.toBeInstanceOf(
UnauthorizedException,
);
await expect(
service.login({ email: 't@example.org' }, 'wrong'),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('issues a token for correct credentials', async () => {
it('issues a token for correct credentials by email', async () => {
const { service } = makeService({
users: [
{ id: 'u-1', email: 't@example.org', passwordHash: bcrypt.hashSync('right', 10) },
],
});
const res = await service.login('T@example.org', 'right');
const res = await service.login({ email: 'T@example.org' }, 'right');
expect(res.accessToken).toEqual(expect.any(String));
});
it('rejects when neither email nor gemeindeName is given', async () => {
const { service } = makeService({});
await expect(service.login({}, 'x')).rejects.toBeInstanceOf(UnauthorizedException);
});
it('rejects an unknown Gemeinde name', async () => {
const { service } = makeService({});
await expect(
service.login({ gemeindeName: 'Nirgendwo' }, 'x'),
).rejects.toBeInstanceOf(UnauthorizedException);
});
it('logs in by Gemeinde name, matching case-insensitively and trimmed', async () => {
const { service } = makeService({
memberships: [
{
gemeindeName: 'Musterstadt',
user: { id: 'u-1', passwordHash: bcrypt.hashSync('right', 10) },
},
],
});
const res = await service.login({ gemeindeName: ' musterstadt ' }, 'right');
expect(res.accessToken).toEqual(expect.any(String));
});
it('tries every Teamer account for a Gemeinde until one password matches', async () => {
const { service } = makeService({
memberships: [
{
gemeindeName: 'Musterstadt',
user: { id: 'u-1', passwordHash: bcrypt.hashSync('wrong-one', 10) },
},
{
gemeindeName: 'Musterstadt',
user: { id: 'u-2', passwordHash: bcrypt.hashSync('right', 10) },
},
],
});
const res = await service.login({ gemeindeName: 'Musterstadt' }, 'right');
expect(res.accessToken).toEqual(expect.any(String));
});
it('rejects a Gemeinde login when no account password matches', async () => {
const { service } = makeService({
memberships: [
{
gemeindeName: 'Musterstadt',
user: { id: 'u-1', passwordHash: bcrypt.hashSync('right', 10) },
},
],
});
await expect(
service.login({ gemeindeName: 'Musterstadt' }, 'wrong'),
).rejects.toBeInstanceOf(UnauthorizedException);
});
});
+36 -11
View File
@@ -38,19 +38,44 @@ export class TeamAuthService {
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 },
/// Logs a Teamer in by email (legacy) OR by Gemeinde name — the normal
/// path, since a Teamer thinks of their login as "meine Gemeinde" rather
/// than an email address. A Gemeinde can have several Teamer accounts, so
/// a name lookup tries the password against every active GEMEINDE_TEAMER
/// membership for that Gemeinde (case-insensitive, trimmed name) until one
/// matches, rather than assuming a 1:1 Gemeinde-to-account mapping.
async login(
credentials: { email?: string; gemeindeName?: string },
password: string,
): Promise<{ accessToken: string }> {
if (credentials.email) {
const user = await this.prisma.user.findUnique({
where: { email: credentials.email.toLowerCase() },
});
if (!user || !user.passwordHash || !(await bcrypt.compare(password, user.passwordHash))) {
throw new UnauthorizedException('Invalid credentials');
}
return { accessToken: this.sign(user.id) };
}
const gemeindeName = credentials.gemeindeName?.trim();
if (!gemeindeName) {
throw new UnauthorizedException('Invalid credentials');
}
const memberships = await this.prisma.membership.findMany({
where: {
role: Role.GEMEINDE_TEAMER,
status: 'ACTIVE',
gemeinde: { name: { equals: gemeindeName, mode: 'insensitive' } },
},
include: { user: true },
});
if (!user || !user.passwordHash) {
throw new UnauthorizedException('Invalid credentials');
for (const m of memberships) {
if (m.user.passwordHash && (await bcrypt.compare(password, m.user.passwordHash))) {
return { accessToken: this.sign(m.user.id) };
}
}
const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok) {
throw new UnauthorizedException('Invalid credentials');
}
return { accessToken: this.sign(user.id) };
throw new UnauthorizedException('Invalid credentials');
}
/// Redeems an invite token and creates the local Teamer account + its
+13 -5
View File
@@ -10,6 +10,7 @@ import { GuestJwtPayload } from './guest-auth.service';
import { TeamAuthService } from './team-auth.service';
import {
AuthentikClaims,
authentikEmail,
resolveOrProvisionAuthentikUser,
toAuthenticatedUser,
} from './provision-user';
@@ -55,15 +56,22 @@ export class TokenVerificationService {
email?: string;
given_name?: string;
family_name?: string;
preferred_username?: string;
name?: string;
groups?: string[];
};
if (!payload.sub || !payload.email) {
throw new UnauthorizedException('Authentik token missing subject or email');
const sub = payload.sub;
if (!sub) {
throw new UnauthorizedException('Authentik token missing subject');
}
return {
sub: payload.sub,
email: payload.email,
firstName: payload.given_name ?? '',
sub,
email: authentikEmail({
email: payload.email,
preferred_username: payload.preferred_username,
sub,
}),
firstName: payload.given_name ?? payload.preferred_username ?? payload.name ?? '',
lastName: payload.family_name ?? '',
isLeitungsteam: (payload.groups ?? []).includes(this.leitungsteamGroup),
};
+74 -2
View File
@@ -1,8 +1,10 @@
import { Body, Controller, Get, Param, Post, Req, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Post, Req, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ChatService } from './chat.service';
import { ChatGateway } from './chat.gateway';
import { CreateChannelDto } from './dto/create-channel.dto';
import { CreateDirectChannelDto } from './dto/create-direct-channel.dto';
import { AddParticipantDto } from './dto/add-participant.dto';
import { Roles } from '../common/roles.decorator';
import { RolesGuard } from '../common/roles.guard';
import { Role } from '../common/role.enum';
@@ -14,7 +16,10 @@ type ChatRequest = AuthenticatedRequest & { user?: AuthenticatedRequest['user']
@Controller('chat')
export class ChatController {
constructor(private readonly chat: ChatService) {}
constructor(
private readonly chat: ChatService,
private readonly gateway: ChatGateway,
) {}
/// Channel administration (Gemeinde-Gruppen, LT-Kanäle, Broadcasts) is Leitungsteam-only.
@Post(':kcId/channels')
@@ -24,6 +29,73 @@ export class ChatController {
return this.chat.createChannel(kcId, dto.type, dto.gemeindeId);
}
/// Free-form group chat ("Gruppenchat"): a Leitungsteam member (any KC) or
/// a Gemeinde Verantwortliche/r (their own KC, enforced by RolesGuard's
/// kcId scoping) can create one and pick any mix of team users and Konfis
/// (guests) from this KC as initial participants.
@Post(':kcId/gruppen')
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM, Role.GEMEINDE_VERANTWORTLICHER)
createGruppe(
@Param('kcId') kcId: string,
@Body() dto: CreateChannelDto,
@Req() req: AuthenticatedRequest,
) {
return this.chat.createGruppe(kcId, dto.name, req.user!.userId, {
userIds: dto.participantUserIds,
guestIds: dto.participantGuestIds,
});
}
/// Candidates (team users + Konfis) a caller may add to a Gruppenchat in
/// this KC. Allowed for LT or a Verantwortliche/r of this KC.
@Get(':kcId/gruppen/participant-candidates')
@UseGuards(AuthGuard(['authentik', 'team']))
listPossibleParticipants(@Param('kcId') kcId: string, @Req() req: AuthenticatedRequest) {
return this.chat.listPossibleParticipants(kcId, req.user!);
}
/// Add a team user or Konfi to a Gruppenchat. Allowed for the channel's
/// creator, any Leitungsteam member, or a Verantwortliche/r of that KC.
@Post('gruppen/:channelId/participants')
@UseGuards(AuthGuard(['authentik', 'team']))
addParticipant(
@Param('channelId') channelId: string,
@Body() dto: AddParticipantDto,
@Req() req: AuthenticatedRequest,
) {
return this.chat
.addParticipant(
channelId,
{ kind: 'user', user: req.user! },
{ userId: dto.userId, guestId: dto.guestId },
)
.then((result) => {
this.gateway.notifyParticipantsChanged(channelId);
return result;
});
}
/// Remove a team user or Konfi from a Gruppenchat. Same authorization as add.
@Delete('gruppen/:channelId/participants')
@UseGuards(AuthGuard(['authentik', 'team']))
removeParticipant(
@Param('channelId') channelId: string,
@Body() dto: AddParticipantDto,
@Req() req: AuthenticatedRequest,
) {
return this.chat
.removeParticipant(
channelId,
{ kind: 'user', user: req.user! },
{ userId: dto.userId, guestId: dto.guestId },
)
.then((result) => {
this.gateway.notifyParticipantsChanged(channelId);
return result;
});
}
/// Any two team members of the same KC can start a direct conversation
/// (Authentik-backed members and local Gemeinde Teamer alike).
@Post('direct')
+8
View File
@@ -106,4 +106,12 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
}
}
}
/// Called by ChatController after add/removeParticipant so anyone with the
/// channel already open (e.g. the creator's participant-management UI)
/// gets a live update. Newly added participants join the room themselves
/// via `chat:join` once they open the chat.
notifyParticipantsChanged(channelId: string) {
this.broadcast(channelId, { event: 'chat:participants-changed', data: { channelId } });
}
}
+280
View File
@@ -0,0 +1,280 @@
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { ChatChannelType, Role } from '@prisma/client';
import { ChatService } from './chat.service';
import { AuthenticatedUser } from '../auth/authenticated-request';
import { GuestJwtPayload } from '../auth/guest-auth.service';
/// Focus: GRUPPE channel creation + participant management authorization
/// (creator / Leitungsteam / Verantwortliche/r of that KC), and read/write
/// access for team users and guests. Prisma + Sync + Push faked in memory.
function userCaller(userId: string, memberships: AuthenticatedUser['memberships']) {
return {
kind: 'user' as const,
user: { userId, authentikSub: `sub-${userId}`, email: `${userId}@example.org`, memberships },
};
}
function guestCaller(guestId: string, kcId: string, gemeindeId: string | null = null) {
const guest: GuestJwtPayload = { guestId, kcId, gemeindeId };
return { kind: 'guest' as const, guest };
}
const LT = userCaller('lt-1', [{ kcId: 'kc-1', gemeindeId: null, role: Role.LEITUNGSTEAM }]);
const VERANTW = userCaller('ver-1', [
{ kcId: 'kc-1', gemeindeId: 'gem-1', role: Role.GEMEINDE_VERANTWORTLICHER },
]);
const TEAMER = userCaller('teamer-1', [
{ kcId: 'kc-1', gemeindeId: 'gem-1', role: Role.GEMEINDE_TEAMER },
]);
function makeService(
opts: {
channels?: Record<string, any>;
memberships?: { kcId: string; userId: string }[];
guests?: { id: string; kcId: string }[];
} = {},
) {
const channels: Record<string, any> = opts.channels ?? {};
const memberships = opts.memberships ?? [];
const guests = opts.guests ?? [];
const participants: any[] = [];
let participantSeq = 0;
const prisma = {
chatChannel: {
create: jest.fn(({ data, include }: any) => {
const id = `chan-${Object.keys(channels).length + 1}`;
const created = { id, ...data, participants: [] };
if (data.participants?.create) {
for (const p of data.participants.create) {
const row = { id: `part-${++participantSeq}`, channelId: id, userId: null, guestAccountId: null, ...p };
participants.push(row);
created.participants.push(row);
}
}
channels[id] = created;
return Promise.resolve(include ? created : { id, ...data });
}),
findUnique: jest.fn(({ where, include }: any) => {
const channel = channels[where.id];
if (!channel) return Promise.resolve(null);
if (include?.participants) {
const seeded = participants.filter((p) => p.channelId === channel.id);
const fallback = Array.isArray(channel.participants) ? channel.participants : [];
return Promise.resolve({
...channel,
participants: seeded.length ? seeded : fallback,
});
}
return Promise.resolve(channel);
}),
findMany: jest.fn().mockResolvedValue([]),
},
membership: {
findMany: jest.fn(({ where }: any) => {
const ids: string[] = where.userId.in;
const rows = memberships.filter((m) => m.kcId === where.kcId && ids.includes(m.userId));
const seen = new Set<string>();
const distinct = rows.filter((r) => (seen.has(r.userId) ? false : (seen.add(r.userId), true)));
return Promise.resolve(distinct);
}),
findFirst: jest.fn(({ where }: any) =>
Promise.resolve(memberships.find((m) => m.kcId === where.kcId && m.userId === where.userId) ?? null),
),
count: jest.fn().mockResolvedValue(0),
},
guestAccount: {
count: jest.fn(({ where }: any) =>
Promise.resolve(guests.filter((g) => where.id.in.includes(g.id) && g.kcId === where.kcId).length),
),
findFirst: jest.fn(({ where }: any) =>
Promise.resolve(guests.find((g) => g.id === where.id && g.kcId === where.kcId) ?? null),
),
},
chatParticipant: {
upsert: jest.fn(({ create }: any) => {
const existing = participants.find(
(p) =>
p.channelId === create.channelId &&
p.userId === (create.userId ?? null) &&
p.guestAccountId === (create.guestAccountId ?? null),
);
if (existing) return Promise.resolve(existing);
const row = { id: `part-${++participantSeq}`, userId: null, guestAccountId: null, ...create };
participants.push(row);
return Promise.resolve(row);
}),
findFirst: jest.fn(({ where }: any) =>
Promise.resolve(
participants.find(
(p) =>
p.channelId === where.channelId &&
(where.userId === undefined || p.userId === where.userId) &&
(where.guestAccountId === undefined || p.guestAccountId === where.guestAccountId),
) ?? null,
),
),
delete: jest.fn(({ where }: any) => {
const idx = participants.findIndex((p) => p.id === where.id);
const [removed] = participants.splice(idx, 1);
return Promise.resolve(removed);
}),
},
chatMessage: { create: jest.fn(), findMany: jest.fn() },
};
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
const push = { notifyChannel: jest.fn().mockResolvedValue(undefined) };
const service = new ChatService(prisma as never, sync as never, push as never);
return { service, prisma, sync, push, channels, participants };
}
describe('ChatService.createGruppe', () => {
it('creates a GRUPPE channel with the creator plus given team/guest participants', async () => {
const { service, sync } = makeService({
memberships: [{ kcId: 'kc-1', userId: 'ver-1' }, { kcId: 'kc-1', userId: 'teamer-1' }],
guests: [{ id: 'guest-1', kcId: 'kc-1' }],
});
const channel = await service.createGruppe('kc-1', 'Ausflugsplanung', 'ver-1', {
userIds: ['teamer-1'],
guestIds: ['guest-1'],
});
expect(channel.type).toBe(ChatChannelType.GRUPPE);
expect(channel.createdByUserId).toBe('ver-1');
const userIds = channel.participants.map((p: any) => p.userId).filter(Boolean);
const guestIds = channel.participants.map((p: any) => p.guestAccountId).filter(Boolean);
expect(userIds.sort()).toEqual(['teamer-1', 'ver-1']);
expect(guestIds).toEqual(['guest-1']);
expect(sync.capture).toHaveBeenCalledWith('ChatChannel', 'CREATE', channel.id, expect.anything());
});
it('does not duplicate the creator if already listed as a participant', async () => {
const { service } = makeService({ memberships: [{ kcId: 'kc-1', userId: 'ver-1' }] });
const channel = await service.createGruppe('kc-1', 'X', 'ver-1', { userIds: ['ver-1'] });
const userIds = channel.participants.map((p: any) => p.userId).filter(Boolean);
expect(userIds).toEqual(['ver-1']);
});
it('rejects a participant who is not a member of the KC', async () => {
const { service } = makeService({ memberships: [] });
await expect(
service.createGruppe('kc-1', 'X', 'ver-1', { userIds: ['ghost'] }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects a guest who is not part of the KC', async () => {
const { service } = makeService({ guests: [{ id: 'guest-1', kcId: 'kc-2' }] });
await expect(
service.createGruppe('kc-1', 'X', 'ver-1', { guestIds: ['guest-1'] }),
).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('ChatService participant management', () => {
function seedGruppe() {
const channels = {
'chan-1': { id: 'chan-1', kcId: 'kc-1', type: ChatChannelType.GRUPPE, createdByUserId: 'ver-1', gemeindeId: null },
};
return channels;
}
it('lets the creator add a team user', async () => {
const { service } = makeService({
channels: seedGruppe(),
memberships: [{ kcId: 'kc-1', userId: 'teamer-1' }],
});
const p = await service.addParticipant('chan-1', VERANTW, { userId: 'teamer-1' });
expect(p.userId).toBe('teamer-1');
});
it('lets a Leitungsteam member add a guest even if not the creator', async () => {
const { service } = makeService({
channels: seedGruppe(),
guests: [{ id: 'guest-1', kcId: 'kc-1' }],
});
const p = await service.addParticipant('chan-1', LT, { guestId: 'guest-1' });
expect(p.guestAccountId).toBe('guest-1');
});
it('forbids a plain Teamer (not creator, not LT, not Verantwortliche/r) from managing participants', async () => {
const { service } = makeService({ channels: seedGruppe() });
await expect(
service.addParticipant('chan-1', TEAMER, { userId: 'teamer-1' }),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('forbids guests from managing participants', async () => {
const { service } = makeService({ channels: seedGruppe() });
await expect(
service.addParticipant('chan-1', guestCaller('g-1', 'kc-1') as never, { userId: 'x' }),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('404s for a non-GRUPPE channel', async () => {
const channels = {
'chan-2': { id: 'chan-2', kcId: 'kc-1', type: ChatChannelType.GEMEINDE_GRUPPE, createdByUserId: null },
};
const { service } = makeService({ channels });
await expect(
service.addParticipant('chan-2', LT, { userId: 'teamer-1' }),
).rejects.toBeInstanceOf(NotFoundException);
});
it('rejects adding a user not in the KC', async () => {
const { service } = makeService({ channels: seedGruppe(), memberships: [] });
await expect(
service.addParticipant('chan-1', LT, { userId: 'ghost' }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('removes a participant and is a no-op if already absent', async () => {
const { service } = makeService({
channels: seedGruppe(),
memberships: [{ kcId: 'kc-1', userId: 'teamer-1' }],
});
await service.addParticipant('chan-1', VERANTW, { userId: 'teamer-1' });
const res = await service.removeParticipant('chan-1', VERANTW, { userId: 'teamer-1' });
expect(res).toEqual({ ok: true });
const res2 = await service.removeParticipant('chan-1', VERANTW, { userId: 'teamer-1' });
expect(res2).toEqual({ ok: true });
});
});
describe('ChatService GRUPPE read/write access', () => {
function seedGruppeWithParticipants(participants: any[]) {
return {
'chan-1': {
id: 'chan-1',
kcId: 'kc-1',
type: ChatChannelType.GRUPPE,
createdByUserId: 'ver-1',
gemeindeId: null,
participants,
},
};
}
it('lets a listed guest read messages', async () => {
const { service } = makeService({
channels: seedGruppeWithParticipants([{ userId: null, guestAccountId: 'guest-1' }]),
});
await expect(
service.assertCanRead('chan-1', guestCaller('guest-1', 'kc-1') as never),
).resolves.toBeDefined();
});
it('forbids a guest not in the participant list', async () => {
const { service } = makeService({
channels: seedGruppeWithParticipants([{ userId: null, guestAccountId: 'guest-1' }]),
});
await expect(
service.assertCanRead('chan-1', guestCaller('guest-2', 'kc-1') as never),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('forbids a team user not in the participant list', async () => {
const { service } = makeService({
channels: seedGruppeWithParticipants([{ userId: 'someone-else', guestAccountId: null }]),
});
await expect(service.assertCanRead('chan-1', TEAMER)).rejects.toBeInstanceOf(ForbiddenException);
});
});
+260 -9
View File
@@ -1,19 +1,34 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { ChatChannelType, Role, SyncOperation } from '@prisma/client';
import { PrismaClient } from '../prisma/prisma.module';
import { AuthenticatedUser } from '../auth/authenticated-request';
import { GuestJwtPayload } from '../auth/guest-auth.service';
import { SyncService } from '../sync/sync.service';
import { PushService } from '../push/push.service';
export type ChatCaller =
| { kind: 'user'; user: AuthenticatedUser }
| { kind: 'guest'; guest: GuestJwtPayload };
const CHANNEL_TITLES: Record<ChatChannelType, string> = {
[ChatChannelType.GEMEINDE_GRUPPE]: 'Gemeinde-Gruppe',
[ChatChannelType.DIREKT]: 'Direktnachricht',
[ChatChannelType.LT_UEBERGREIFEND]: 'Leitungsteam',
[ChatChannelType.BROADCAST]: 'Ankündigung',
[ChatChannelType.GRUPPE]: 'Gruppenchat',
};
export interface CreateGruppeParticipants {
userIds?: string[];
guestIds?: string[];
}
@Injectable()
export class ChatService {
constructor(
private readonly prisma: PrismaClient,
private readonly sync: SyncService,
private readonly push: PushService,
) {}
async createChannel(kcId: string, type: ChatChannelType, gemeindeId?: string) {
@@ -22,6 +37,206 @@ export class ChatService {
return channel;
}
/// Free-form group chat: created by a Leitungsteam member (any KC) or a
/// Gemeinde Verantwortliche/r (their own KC — enforced by the RolesGuard's
/// kcId scoping at the controller level). Konfis (guests) may be included
/// directly, unlike DIREKT/GEMEINDE_GRUPPE channels which are team-only.
async createGruppe(
kcId: string,
name: string | undefined,
createdByUserId: string,
participants: CreateGruppeParticipants,
) {
const userIds = [...new Set(participants.userIds ?? [])];
const guestIds = [...new Set(participants.guestIds ?? [])];
if (userIds.length) {
// A user may show up under more than one Gemeinde membership; just
// make sure every requested id resolves to at least one row for this KC.
const distinctUsers = await this.prisma.membership.findMany({
where: { kcId, userId: { in: userIds } },
select: { userId: true },
distinct: ['userId'],
});
if (distinctUsers.length !== userIds.length) {
throw new BadRequestException('One or more users are not part of this KC');
}
}
if (guestIds.length) {
const guestCount = await this.prisma.guestAccount.count({
where: { id: { in: guestIds }, kcId },
});
if (guestCount !== guestIds.length) {
throw new BadRequestException('One or more guests are not part of this KC');
}
}
const channel = await this.prisma.chatChannel.create({
data: {
kcId,
type: ChatChannelType.GRUPPE,
name,
createdByUserId,
participants: {
create: [
...(userIds.includes(createdByUserId) ? [] : [{ userId: createdByUserId }]),
...userIds.map((userId) => ({ userId })),
...guestIds.map((guestAccountId) => ({ guestAccountId })),
],
},
},
include: { participants: true },
});
await this.sync.capture('ChatChannel', SyncOperation.CREATE, channel.id, channel);
return channel;
}
/// Candidates a caller may add to a GRUPPE channel in this KC: every team
/// member (any Gemeinde) plus every Konfi/guest, so a Verantwortliche/r can
/// pick across Gemeinde boundaries as intended. Same authorization as
/// creating a Gruppenchat (LT or Verantwortliche/r of this KC).
async listPossibleParticipants(kcId: string, caller: AuthenticatedUser) {
const isLt = caller.memberships.some((m) => m.role === Role.LEITUNGSTEAM);
const isVerantwortlicherHere = caller.memberships.some(
(m) => m.kcId === kcId && m.role === Role.GEMEINDE_VERANTWORTLICHER,
);
if (!isLt && !isVerantwortlicherHere) {
throw new ForbiddenException('Not allowed to list participants for this KC');
}
const [memberships, guests] = await Promise.all([
this.prisma.membership.findMany({
where: { kcId, status: 'ACTIVE' },
include: { user: { select: { id: true, firstName: true, lastName: true, email: true } } },
orderBy: { user: { lastName: 'asc' } },
}),
this.prisma.guestAccount.findMany({
where: { kcId },
select: { id: true, firstName: true, lastName: true, gemeindeId: true },
orderBy: { lastName: 'asc' },
}),
]);
const seenUsers = new Set<string>();
const users = [];
for (const m of memberships) {
if (seenUsers.has(m.userId)) continue;
seenUsers.add(m.userId);
users.push({
userId: m.user.id,
firstName: m.user.firstName,
lastName: m.user.lastName,
email: m.user.email,
role: m.role,
gemeindeId: m.gemeindeId,
});
}
return {
users,
guests: guests.map((g) => ({
guestId: g.id,
firstName: g.firstName,
lastName: g.lastName,
gemeindeId: g.gemeindeId,
})),
};
}
/// Adds a team user or a guest/Konfi to an existing GRUPPE channel. Only
/// the channel's creator or a Leitungsteam member may manage participants.
async addParticipant(
channelId: string,
caller: ChatCaller,
target: { userId?: string; guestId?: string },
) {
const channel = await this.getGruppeForManagementOrThrow(channelId, caller);
if (!target.userId && !target.guestId) {
throw new BadRequestException('userId or guestId is required');
}
if (target.userId && target.guestId) {
throw new BadRequestException('Provide either userId or guestId, not both');
}
if (target.userId) {
const isMember = await this.prisma.membership.findFirst({
where: { kcId: channel.kcId, userId: target.userId },
});
if (!isMember) {
throw new BadRequestException('User is not part of this KC');
}
const participant = await this.prisma.chatParticipant.upsert({
where: { channelId_userId: { channelId, userId: target.userId } },
create: { channelId, userId: target.userId },
update: {},
});
await this.sync.capture('ChatParticipant', SyncOperation.CREATE, participant.id, participant);
return participant;
}
const guest = await this.prisma.guestAccount.findFirst({
where: { id: target.guestId, kcId: channel.kcId },
});
if (!guest) {
throw new BadRequestException('Guest is not part of this KC');
}
const participant = await this.prisma.chatParticipant.upsert({
where: { channelId_guestAccountId: { channelId, guestAccountId: target.guestId! } },
create: { channelId, guestAccountId: target.guestId },
update: {},
});
await this.sync.capture('ChatParticipant', SyncOperation.CREATE, participant.id, participant);
return participant;
}
/// Removes a team user or a guest/Konfi from a GRUPPE channel. Same
/// authorization as addParticipant.
async removeParticipant(
channelId: string,
caller: ChatCaller,
target: { userId?: string; guestId?: string },
) {
await this.getGruppeForManagementOrThrow(channelId, caller);
if (!target.userId && !target.guestId) {
throw new BadRequestException('userId or guestId is required');
}
const existing = await this.prisma.chatParticipant.findFirst({
where: {
channelId,
userId: target.userId ?? undefined,
guestAccountId: target.guestId ?? undefined,
},
});
if (!existing) return { ok: true };
await this.prisma.chatParticipant.delete({ where: { id: existing.id } });
await this.sync.capture('ChatParticipant', SyncOperation.DELETE, existing.id, { id: existing.id });
return { ok: true };
}
private async getGruppeForManagementOrThrow(channelId: string, caller: ChatCaller) {
if (caller.kind !== 'user') {
throw new ForbiddenException('Guests may not manage channel participants');
}
const channel = await this.prisma.chatChannel.findUnique({ where: { id: channelId } });
if (!channel || channel.type !== ChatChannelType.GRUPPE) {
throw new NotFoundException('Gruppenchat not found');
}
const { user } = caller;
const isLt = user.memberships.some((m) => m.role === Role.LEITUNGSTEAM);
const isCreator = channel.createdByUserId === user.userId;
const isVerantwortlicherHere = user.memberships.some(
(m) => m.kcId === channel.kcId && m.role === Role.GEMEINDE_VERANTWORTLICHER,
);
if (!isLt && !isCreator && !isVerantwortlicherHere) {
throw new ForbiddenException('Not allowed to manage this Gruppenchat');
}
return channel;
}
async getOrCreateDirectChannel(kcId: string, userAId: string, userBId: string) {
const existing = await this.prisma.chatChannel.findFirst({
where: {
@@ -48,7 +263,13 @@ export class ChatService {
async listChannelsForCaller(kcId: string, caller: ChatCaller) {
if (caller.kind === 'guest') {
return this.prisma.chatChannel.findMany({
where: { kcId, type: ChatChannelType.BROADCAST },
where: {
kcId,
OR: [
{ type: ChatChannelType.BROADCAST },
{ type: ChatChannelType.GRUPPE, participants: { some: { guestAccountId: caller.guest.guestId } } },
],
},
});
}
const { user } = caller;
@@ -66,6 +287,7 @@ export class ChatService {
{ type: ChatChannelType.BROADCAST },
{ type: ChatChannelType.GEMEINDE_GRUPPE, gemeindeId: { in: gemeindeIds } },
{ type: ChatChannelType.DIREKT, participants: { some: { userId: user.userId } } },
{ type: ChatChannelType.GRUPPE, participants: { some: { userId: user.userId } } },
],
},
});
@@ -93,14 +315,22 @@ export class ChatService {
}
if (caller.kind === 'guest') {
const allowed = channel.type === ChatChannelType.BROADCAST && mode === 'read';
if (!allowed) {
throw new ForbiddenException('Guests may only read broadcast channels');
if (channel.type === ChatChannelType.BROADCAST && mode === 'read') {
if (caller.guest.kcId !== channel.kcId) {
throw new ForbiddenException('Guest does not belong to this KC');
}
return channel;
}
if (caller.guest.kcId !== channel.kcId) {
throw new ForbiddenException('Guest does not belong to this KC');
if (channel.type === ChatChannelType.GRUPPE) {
const isParticipant = channel.participants.some(
(p) => p.guestAccountId === caller.guest.guestId,
);
if (!isParticipant) {
throw new ForbiddenException('Not a participant of this Gruppenchat');
}
return channel;
}
return channel;
throw new ForbiddenException('Guests may only read broadcast channels or their Gruppenchats');
}
const { user } = caller;
@@ -136,13 +366,20 @@ export class ChatService {
}
return channel;
}
case ChatChannelType.GRUPPE: {
const isParticipant = channel.participants.some((p) => p.userId === user.userId);
if (!isParticipant) {
throw new ForbiddenException('Not a participant of this Gruppenchat');
}
return channel;
}
default:
throw new ForbiddenException('Unknown channel type');
}
}
async sendMessage(channelId: string, caller: ChatCaller, body: string) {
await this.assertCanWrite(channelId, caller);
const channel = await this.assertCanWrite(channelId, caller);
const message = await this.prisma.chatMessage.create({
data: {
channelId,
@@ -152,6 +389,20 @@ export class ChatService {
},
});
await this.sync.capture('ChatMessage', SyncOperation.CREATE, message.id, message);
void this.push.notifyChannel(
channelId,
{
title: CHANNEL_TITLES[channel?.type ?? ChatChannelType.GEMEINDE_GRUPPE],
body: body.length > 140 ? `${body.slice(0, 137)}` : body,
data: { channelId },
},
{
userId: caller.kind === 'user' ? caller.user.userId : null,
guestId: caller.kind === 'guest' ? caller.guest.guestId : null,
},
);
return message;
}
+13
View File
@@ -0,0 +1,13 @@
import { IsOptional, IsString } from 'class-validator';
/// Exactly one of userId/guestId must be set; validated in the service since
/// class-validator doesn't express "exactly one of" declaratively.
export class AddParticipantDto {
@IsOptional()
@IsString()
userId?: string;
@IsOptional()
@IsString()
guestId?: string;
}
+21 -1
View File
@@ -1,4 +1,4 @@
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { ArrayUnique, IsArray, IsEnum, IsOptional, IsString } from 'class-validator';
import { ChatChannelType } from '@prisma/client';
export class CreateChannelDto {
@@ -8,4 +8,24 @@ export class CreateChannelDto {
@IsOptional()
@IsString()
gemeindeId?: string;
/// Display name; used for GRUPPE channels.
@IsOptional()
@IsString()
name?: string;
/// Initial participants for a GRUPPE channel (team users). More can be
/// added/removed later via the participants endpoints.
@IsOptional()
@IsArray()
@ArrayUnique()
@IsString({ each: true })
participantUserIds?: string[];
/// Initial guest/Konfi participants for a GRUPPE channel.
@IsOptional()
@IsArray()
@ArrayUnique()
@IsString({ each: true })
participantGuestIds?: string[];
}
+1 -1
View File
@@ -34,7 +34,7 @@ export class FilesController {
/// Only the Leitungsteam uploads files (per KC), tagged with a visibility tier.
@Post(':kcId')
@UseGuards(AuthGuard('authentik'), RolesGuard)
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
@UseInterceptors(FileInterceptor('file'))
upload(
+145 -5
View File
@@ -5,16 +5,15 @@ import {
UnauthorizedException,
} from '@nestjs/common';
import { MembershipStatus, Role, SyncOperation } from '@prisma/client';
import { randomBytes } from 'crypto';
import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service';
import { TokenVerificationService } from '../auth/token-verification.service';
import { resolveOrProvisionAuthentikUser } from '../auth/provision-user';
import { AuthenticatedUser } from '../auth/authenticated-request';
/// Self-service onboarding for Gemeinde Verantwortliche. The person signs in
/// with their Konfi-Castle-ID (Authentik) and submits a KC invite code plus
/// the Gemeinde they belong to; this provisions their local User (JIT) and a
/// PENDING membership that a Leitungsteam member must approve before it grants
/// any rights.
/// Self-service onboarding for Gemeinde Verantwortliche, plus the
/// Leitungsteam-initiated shortcut that skips the approval step entirely.
@Injectable()
export class OnboardingService {
constructor(
@@ -133,4 +132,145 @@ export class OnboardingService {
) {
return { membershipId, status, kcName, gemeindeName };
}
// --- Leitungsteam-issued Verantwortliche invites ---
// Skips the PENDING approval step: an LT member vouching for someone
// directly is enough, unlike self-registration which needs review.
async createInvite(
caller: AuthenticatedUser,
gemeindeId: string,
dto: { email?: string; maxUses?: number; expiresInHours?: number },
) {
if (!caller.memberships.some((m) => m.role === Role.LEITUNGSTEAM)) {
throw new UnauthorizedException('Only Leitungsteam can issue this invite');
}
const gemeinde = await this.prisma.gemeinde.findUnique({ where: { id: gemeindeId } });
if (!gemeinde) {
throw new NotFoundException('Gemeinde not found');
}
const email = dto.email?.toLowerCase() ?? null;
const maxUses = dto.maxUses ?? (email ? 1 : null);
const expiresAt = dto.expiresInHours
? new Date(Date.now() + dto.expiresInHours * 3600_000)
: null;
const invite = await this.prisma.verantwortlicheInvite.create({
data: {
kcId: gemeinde.kcId,
gemeindeId,
token: randomBytes(24).toString('base64url'),
email,
maxUses,
expiresAt,
createdByUserId: caller.userId,
},
});
await this.sync.capture('VerantwortlicheInvite', SyncOperation.CREATE, invite.id, invite);
return invite;
}
async listInvites(caller: AuthenticatedUser, gemeindeId: string) {
if (!caller.memberships.some((m) => m.role === Role.LEITUNGSTEAM)) {
throw new UnauthorizedException('Only Leitungsteam can view this');
}
return this.prisma.verantwortlicheInvite.findMany({
where: { gemeindeId },
orderBy: { createdAt: 'desc' },
});
}
async revokeInvite(caller: AuthenticatedUser, gemeindeId: string, inviteId: string) {
if (!caller.memberships.some((m) => m.role === Role.LEITUNGSTEAM)) {
throw new UnauthorizedException('Only Leitungsteam can revoke this');
}
const invite = await this.prisma.verantwortlicheInvite.findFirst({
where: { id: inviteId, gemeindeId },
});
if (!invite) {
throw new NotFoundException('Invite not found');
}
const updated = await this.prisma.verantwortlicheInvite.update({
where: { id: inviteId },
data: { revokedAt: new Date() },
});
await this.sync.capture('VerantwortlicheInvite', SyncOperation.UPDATE, updated.id, updated);
return updated;
}
/// Redeems an LT-issued invite: provisions/updates the caller's Authentik
/// User and grants an immediately-ACTIVE GEMEINDE_VERANTWORTLICHER
/// membership (no approval step, unlike self-registration).
async redeemInvite(token: string | undefined, inviteToken: string) {
if (!token) {
throw new UnauthorizedException('Missing Authentik bearer token');
}
const invite = await this.prisma.verantwortlicheInvite.findUnique({
where: { token: inviteToken },
});
if (!invite || invite.revokedAt) {
throw new NotFoundException('Unknown or revoked invite');
}
if (invite.expiresAt && invite.expiresAt.getTime() < Date.now()) {
throw new BadRequestException('Invite has expired');
}
if (invite.maxUses !== null && invite.usedCount >= invite.maxUses) {
throw new BadRequestException('Invite has already been used up');
}
const { isLeitungsteam, ...claims } = await this.tokens.verifyAuthentikClaims(token);
if (invite.email && invite.email !== claims.email.toLowerCase()) {
throw new BadRequestException('This invite is pinned to a different account');
}
const user = await resolveOrProvisionAuthentikUser(
this.prisma,
this.sync,
claims,
isLeitungsteam,
);
const existing = await this.prisma.membership.findUnique({
where: {
userId_kcId_gemeindeId: {
userId: user.id,
kcId: invite.kcId,
gemeindeId: invite.gemeindeId,
},
},
});
const membership = existing
? await this.prisma.membership.update({
where: { id: existing.id },
data: { status: MembershipStatus.ACTIVE, role: Role.GEMEINDE_VERANTWORTLICHER },
})
: await this.prisma.membership.create({
data: {
userId: user.id,
kcId: invite.kcId,
gemeindeId: invite.gemeindeId,
role: Role.GEMEINDE_VERANTWORTLICHER,
status: MembershipStatus.ACTIVE,
},
});
await this.sync.capture(
'Membership',
existing ? SyncOperation.UPDATE : SyncOperation.CREATE,
membership.id,
membership,
);
const updatedInvite = await this.prisma.verantwortlicheInvite.update({
where: { id: invite.id },
data: { usedCount: { increment: 1 } },
});
await this.sync.capture(
'VerantwortlicheInvite',
SyncOperation.UPDATE,
updatedInvite.id,
updatedInvite,
);
return { membershipId: membership.id, status: membership.status };
}
}
+16
View File
@@ -0,0 +1,16 @@
import { IsIn, IsNotEmpty, IsString } from 'class-validator';
export class RegisterDeviceDto {
@IsString()
@IsNotEmpty()
token!: string;
@IsIn(['web', 'android', 'ios'])
platform!: 'web' | 'android' | 'ios';
}
export class UnregisterDeviceDto {
@IsString()
@IsNotEmpty()
token!: string;
}
+110
View File
@@ -0,0 +1,110 @@
import { readFileSync } from 'fs';
import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as jwt from 'jsonwebtoken';
import { PushNotification, PushProvider, PushResult } from './push-provider';
interface ServiceAccount {
client_email: string;
private_key: string;
token_uri: string;
}
/// Firebase Cloud Messaging HTTP v1. Auth is a service-account JWT exchanged
/// for an OAuth access token (no google-auth-library dependency — jsonwebtoken
/// is already here). Delivery failures are logged and swallowed.
export class FcmPushProvider implements PushProvider {
private readonly logger = new Logger('PushProvider');
private readonly projectId: string;
private readonly sa: ServiceAccount;
private accessToken: { value: string; expiresAt: number } | null = null;
constructor(config: ConfigService) {
this.projectId = config.getOrThrow<string>('FCM_PROJECT_ID');
const path = config.getOrThrow<string>('GOOGLE_APPLICATION_CREDENTIALS');
this.sa = JSON.parse(readFileSync(path, 'utf8')) as ServiceAccount;
}
async sendToTokens(tokens: string[], n: PushNotification): Promise<PushResult> {
if (tokens.length === 0) return { sent: 0, invalidTokens: [] };
let accessToken: string;
try {
accessToken = await this.getAccessToken();
} catch (err) {
this.logger.error(`FCM auth failed: ${(err as Error).message}`);
return { sent: 0, invalidTokens: [] };
}
const url = `https://fcm.googleapis.com/v1/projects/${this.projectId}/messages:send`;
const invalidTokens: string[] = [];
let sent = 0;
await Promise.all(
tokens.map(async (token) => {
try {
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: {
token,
notification: { title: n.title, body: n.body },
data: n.data,
webpush: { fcmOptions: {} },
},
}),
});
if (res.ok) {
sent += 1;
} else if (res.status === 404 || res.status === 400) {
invalidTokens.push(token);
} else {
this.logger.warn(`FCM send ${res.status}: ${await res.text()}`);
}
} catch (err) {
this.logger.warn(`FCM send error: ${(err as Error).message}`);
}
}),
);
return { sent, invalidTokens };
}
private async getAccessToken(): Promise<string> {
if (this.accessToken && this.accessToken.expiresAt > Date.now() + 60_000) {
return this.accessToken.value;
}
const now = Math.floor(Date.now() / 1000);
const assertion = jwt.sign(
{
iss: this.sa.client_email,
scope: 'https://www.googleapis.com/auth/firebase.messaging',
aud: this.sa.token_uri,
iat: now,
exp: now + 3600,
},
this.sa.private_key,
{ algorithm: 'RS256' },
);
const res = await fetch(this.sa.token_uri, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion,
}),
});
if (!res.ok) {
throw new Error(`token exchange ${res.status}: ${await res.text()}`);
}
const json = (await res.json()) as { access_token: string; expires_in: number };
this.accessToken = {
value: json.access_token,
expiresAt: Date.now() + json.expires_in * 1000,
};
return json.access_token;
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Logger } from '@nestjs/common';
import { PushNotification, PushProvider, PushResult } from './push-provider';
/// Default provider: doesn't send, just logs. Keeps the app working before
/// FCM credentials are configured.
export class LogPushProvider implements PushProvider {
private readonly logger = new Logger('PushProvider');
async sendToTokens(tokens: string[], n: PushNotification): Promise<PushResult> {
this.logger.log(
`[log-only] would push "${n.title}" to ${tokens.length} device(s): ${n.body}`,
);
return { sent: 0, invalidTokens: [] };
}
}
+20
View File
@@ -0,0 +1,20 @@
/// Abstraction over the push backend. Default is a no-send provider that only
/// logs; PUSH_PROVIDER=fcm switches to Firebase Cloud Messaging (HTTP v1).
export interface PushNotification {
title: string;
body: string;
data?: Record<string, string>;
}
export interface PushResult {
sent: number;
/// Tokens FCM reported as permanently invalid — the caller prunes them.
invalidTokens: string[];
}
export interface PushProvider {
/// Best-effort: never throws for delivery problems.
sendToTokens(tokens: string[], notification: PushNotification): Promise<PushResult>;
}
export const PUSH_PROVIDER = Symbol('PUSH_PROVIDER');
+29
View File
@@ -0,0 +1,29 @@
import { Body, Controller, Post, Req, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { PushService } from './push.service';
import { RegisterDeviceDto, UnregisterDeviceDto } from './dto/register-device.dto';
// Import the util directly (not via chat.service) to keep the module graph acyclic.
import { resolveChatCaller } from '../chat/caller.util';
import { AuthenticatedRequest } from '../auth/authenticated-request';
import { GuestJwtPayload } from '../auth/guest-auth.service';
type PushRequest = AuthenticatedRequest & {
user?: AuthenticatedRequest['user'] | GuestJwtPayload;
};
@Controller('push')
export class PushController {
constructor(private readonly push: PushService) {}
@Post('register')
@UseGuards(AuthGuard(['authentik', 'team', 'guest']))
register(@Body() dto: RegisterDeviceDto, @Req() req: PushRequest) {
return this.push.register(dto.token, dto.platform, resolveChatCaller(req.user!));
}
@Post('unregister')
@UseGuards(AuthGuard(['authentik', 'team', 'guest']))
unregister(@Body() dto: UnregisterDeviceDto) {
return this.push.unregister(dto.token);
}
}
+27
View File
@@ -0,0 +1,27 @@
import { Global, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PUSH_PROVIDER } from './push-provider';
import { LogPushProvider } from './log-push.provider';
import { FcmPushProvider } from './fcm-push.provider';
import { PushService } from './push.service';
import { PushController } from './push.controller';
/// Global so ChatService can inject PushService. Provider defaults to
/// log-only; PUSH_PROVIDER=fcm switches to Firebase Cloud Messaging.
@Global()
@Module({
controllers: [PushController],
providers: [
PushService,
{
provide: PUSH_PROVIDER,
inject: [ConfigService],
useFactory: (config: ConfigService) =>
config.get<string>('PUSH_PROVIDER') === 'fcm'
? new FcmPushProvider(config)
: new LogPushProvider(),
},
],
exports: [PushService],
})
export class PushModule {}
+163
View File
@@ -0,0 +1,163 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { ChatChannelType, SyncOperation } from '@prisma/client';
import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service';
import type { ChatCaller } from '../chat/chat.service';
import { PUSH_PROVIDER, PushNotification, PushProvider } from './push-provider';
@Injectable()
export class PushService {
private readonly logger = new Logger(PushService.name);
constructor(
private readonly prisma: PrismaClient,
private readonly sync: SyncService,
@Inject(PUSH_PROVIDER) private readonly provider: PushProvider,
) {}
/// Upsert a device token for the current caller (team user or guest).
async register(token: string, platform: string, caller: ChatCaller) {
const owner =
caller.kind === 'user'
? { userId: caller.user.userId, guestAccountId: null }
: { userId: null, guestAccountId: caller.guest.guestId };
const row = await this.prisma.deviceToken.upsert({
where: { token },
create: { token, platform, ...owner },
update: { platform, lastSeenAt: new Date(), ...owner },
});
await this.sync.capture('DeviceToken', SyncOperation.UPDATE, row.id, row);
return { ok: true };
}
async unregister(token: string) {
const existing = await this.prisma.deviceToken.findUnique({ where: { token } });
if (!existing) return { ok: true };
await this.prisma.deviceToken.delete({ where: { token } });
await this.sync.capture('DeviceToken', SyncOperation.DELETE, existing.id, {
id: existing.id,
});
return { ok: true };
}
/// Fan a chat message out as a push to everyone who can read the channel,
/// minus the sender. Best-effort — never throws into the caller.
async notifyChannel(
channelId: string,
notification: PushNotification,
exclude: { userId?: string | null; guestId?: string | null } = {},
): Promise<void> {
try {
const channel = await this.prisma.chatChannel.findUnique({
where: { id: channelId },
include: { participants: { select: { userId: true, guestAccountId: true } } },
});
if (!channel) return;
const { userIds, guestIds } = await this.audience(channel);
const tokens = await this.prisma.deviceToken.findMany({
where: {
OR: [
userIds.length ? { userId: { in: userIds } } : undefined,
guestIds.length ? { guestAccountId: { in: guestIds } } : undefined,
].filter(Boolean) as object[],
NOT: {
OR: [
exclude.userId ? { userId: exclude.userId } : undefined,
exclude.guestId ? { guestAccountId: exclude.guestId } : undefined,
].filter(Boolean) as object[],
},
},
select: { token: true },
});
if (tokens.length === 0) return;
const { invalidTokens } = await this.provider.sendToTokens(
tokens.map((t) => t.token),
notification,
);
if (invalidTokens.length) {
await this.prisma.deviceToken.deleteMany({
where: { token: { in: invalidTokens } },
});
}
} catch (err) {
this.logger.warn(`notifyChannel failed: ${(err as Error).message}`);
}
}
private async audience(channel: {
kcId: string;
type: ChatChannelType;
gemeindeId: string | null;
participants: { userId: string | null; guestAccountId: string | null }[];
}): Promise<{ userIds: string[]; guestIds: string[] }> {
if (channel.type === ChatChannelType.DIREKT) {
return {
userIds: channel.participants.map((p) => p.userId).filter((id): id is string => !!id),
guestIds: [],
};
}
if (channel.type === ChatChannelType.GRUPPE) {
return {
userIds: channel.participants.map((p) => p.userId).filter((id): id is string => !!id),
guestIds: channel.participants
.map((p) => p.guestAccountId)
.filter((id): id is string => !!id),
};
}
const ltUsers = await this.prisma.user.findMany({
where: {
OR: [
{ isLeitungsteam: true },
{ memberships: { some: { kcId: channel.kcId, role: 'LEITUNGSTEAM' } } },
],
},
select: { id: true },
});
const ltIds = ltUsers.map((u) => u.id);
if (channel.type === ChatChannelType.LT_UEBERGREIFEND) {
return { userIds: ltIds, guestIds: [] };
}
if (channel.type === ChatChannelType.GEMEINDE_GRUPPE) {
const [members, guests] = await Promise.all([
this.prisma.membership.findMany({
where: {
kcId: channel.kcId,
gemeindeId: channel.gemeindeId,
status: 'ACTIVE',
},
select: { userId: true },
}),
this.prisma.guestAccount.findMany({
where: { kcId: channel.kcId, gemeindeId: channel.gemeindeId },
select: { id: true },
}),
]);
return {
userIds: [...new Set([...ltIds, ...members.map((m) => m.userId)])],
guestIds: guests.map((g) => g.id),
};
}
// BROADCAST: everyone in the KC.
const [members, guests] = await Promise.all([
this.prisma.membership.findMany({
where: { kcId: channel.kcId, status: 'ACTIVE' },
select: { userId: true },
}),
this.prisma.guestAccount.findMany({
where: { kcId: channel.kcId },
select: { id: true },
}),
]);
return {
userIds: [...new Set([...ltIds, ...members.map((m) => m.userId)])],
guestIds: guests.map((g) => g.id),
};
}
}
+3
View File
@@ -9,6 +9,7 @@ const SYNCED_MODELS = [
'User',
'Membership',
'TeamerInvite',
'VerantwortlicheInvite',
'GuestAccount',
'Wahl',
'Workshop',
@@ -17,7 +18,9 @@ const SYNCED_MODELS = [
'Zuteilung',
'File',
'ChatChannel',
'ChatParticipant',
'ChatMessage',
'DeviceToken',
] as const;
export type SyncedModel = (typeof SYNCED_MODELS)[number];
+7
View File
@@ -0,0 +1,7 @@
import { IsBoolean, IsOptional } from 'class-validator';
export class UpdateWahlDto {
@IsOptional()
@IsBoolean()
isOpen?: boolean;
}
+24 -8
View File
@@ -3,6 +3,7 @@ import {
Controller,
Get,
Param,
Patch,
Post,
Query,
Req,
@@ -14,6 +15,7 @@ import { Response } from 'express';
import { WahlService } from './wahl.service';
import { ZuteilungService } from './zuteilung.service';
import { CreateWahlDto } from './dto/create-wahl.dto';
import { UpdateWahlDto } from './dto/update-wahl.dto';
import { CreateWorkshopDto } from './dto/create-workshop.dto';
import { SubmitTeilnehmerDto } from './dto/submit-teilnehmer.dto';
import { CreateForceZuteilungDto } from './dto/create-force-zuteilung.dto';
@@ -32,35 +34,49 @@ export class WahlController {
/// Wahl-/Workshop-/Force-Zuteilung-Verwaltung ist ausschließlich Sache des
/// Leitungsteams (global über alle KCs, siehe RolesGuard).
@Post()
@UseGuards(AuthGuard('authentik'), RolesGuard)
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
createWahl(@Body() dto: CreateWahlDto) {
return this.wahl.createWahl(dto.kcId, dto.name, dto.datumsSchluessel, dto.teil);
}
@Get()
@UseGuards(AuthGuard('authentik'), RolesGuard)
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
listWahlen(@Query('kcId') kcId: string) {
return this.wahl.listWahlen(kcId);
}
@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'), RolesGuard)
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
createWorkshop(@Param('wahlId') wahlId: string, @Body() dto: CreateWorkshopDto) {
return this.wahl.createWorkshop(wahlId, dto.name, dto.kapazitaet, dto.minTeilnehmer);
}
@Get(':wahlId/workshops')
@UseGuards(AuthGuard('authentik'), RolesGuard)
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
listWorkshops(@Param('wahlId') wahlId: string) {
return this.wahl.listWorkshops(wahlId);
}
@Post(':wahlId/force-zuteilung')
@UseGuards(AuthGuard('authentik'), RolesGuard)
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
createForceZuteilung(
@Param('wahlId') wahlId: string,
@@ -99,21 +115,21 @@ export class WahlController {
}
@Post(':wahlId/zuteilung/run')
@UseGuards(AuthGuard('authentik'), RolesGuard)
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
runZuteilung(@Param('wahlId') wahlId: string) {
return this.zuteilung.run(wahlId);
}
@Get(':wahlId/zuteilung')
@UseGuards(AuthGuard('authentik'), RolesGuard)
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
getZuteilung(@Param('wahlId') wahlId: string) {
return this.zuteilung.getResults(wahlId);
}
@Get(':wahlId/zuteilung/csv')
@UseGuards(AuthGuard('authentik'), RolesGuard)
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
async exportCsv(@Param('wahlId') wahlId: string, @Res() res: Response) {
const csv = await this.zuteilung.exportCsv(wahlId);
+28 -1
View File
@@ -22,6 +22,33 @@ export class WahlService {
return this.prisma.wahl.findMany({ where: { kcId } });
}
async updateWahl(wahlId: string, data: { isOpen?: boolean }) {
await this.getWahlOrThrow(wahlId);
const wahl = await this.prisma.wahl.update({ where: { id: wahlId }, data });
await this.sync.capture('Wahl', SyncOperation.UPDATE, wahl.id, wahl);
return wahl;
}
/// LT view of who took part in a Wahl, with their priorities and any
/// existing Force-Zuteilung.
async listTeilnehmer(wahlId: string) {
await this.getWahlOrThrow(wahlId);
const rows = await this.prisma.teilnehmer.findMany({
where: { wahlId },
orderBy: { guestAccount: { lastName: 'asc' } },
include: {
guestAccount: { select: { firstName: true, lastName: true } },
forceZuteilung: { select: { workshopId: true } },
},
});
return rows.map((t) => ({
id: t.id,
name: `${t.guestAccount.firstName} ${t.guestAccount.lastName}`.trim(),
prioritaeten: (t.prioritaeten as string[] | null) ?? [],
forcedWorkshopId: t.forceZuteilung?.workshopId ?? null,
}));
}
/// Guest-facing view: open Wahlen for the guest's KC, each with its
/// workshops and the guest's own current priorities (null if not submitted).
async guestOverview(kcId: string, guestAccountId: string) {
@@ -145,7 +172,7 @@ export class WahlService {
throw new ForbiddenException('Wahl is closed');
}
const teilnehmer = await this.prisma.teilnehmer.upsert({
where: { wahlId_guestAccountId: { wahlId, guestAccountId } },
where: { wahlId_guestAccountId_phase: { wahlId, guestAccountId, phase: 1 } },
create: { wahlId, guestAccountId, prioritaeten },
update: { prioritaeten },
});