Compare commits
17
Commits
847fed8dad
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb4b1e21cf | ||
|
|
858c43a6aa | ||
|
|
2fbfcf53be | ||
|
|
288628f20e | ||
|
|
1467c8bdf6 | ||
|
|
3bc40e5908 | ||
|
|
1614c19102 | ||
|
|
4902cfe85d | ||
|
|
2f76790135 | ||
|
|
cfa0070eab | ||
|
|
be13d8350b | ||
|
|
7c8f35f0f0 | ||
|
|
f12bb51f3e | ||
|
|
92e0029732 | ||
|
|
d8ff49480d | ||
|
|
6f4a446ae4 | ||
|
|
7da9a362e1 |
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"projectId": "5fe999f9-fbff-4a09-a987-48c4e7540b38"
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
# Name of the Authentik group whose members are Leitungsteam. Mirrored to
|
||||||
# User.isLeitungsteam on every login (the access token must carry a `groups`
|
# User.isLeitungsteam on every login (the access token must carry a `groups`
|
||||||
# claim; add the "groups" scope to the Authentik provider).
|
# 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)
|
# Secret used to sign guest/Konfi session tokens (local accounts only)
|
||||||
GUEST_JWT_SECRET="change-me"
|
GUEST_JWT_SECRET="change-me"
|
||||||
@@ -31,6 +31,14 @@ SMTP_SECURE="false"
|
|||||||
SMTP_USER=""
|
SMTP_USER=""
|
||||||
SMTP_PASS=""
|
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
|
# File storage: defaults to Nextcloud via WebDAV; set STORAGE_PROVIDER=s3 to
|
||||||
# use an S3-compatible bucket instead (see S3_* vars below).
|
# use an S3-compatible bucket instead (see S3_* vars below).
|
||||||
STORAGE_PROVIDER="webdav"
|
STORAGE_PROVIDER="webdav"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -3,3 +3,7 @@ dist
|
|||||||
coverage
|
coverage
|
||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Firebase service account (secret)
|
||||||
|
serviceAccount.json
|
||||||
|
*.serviceAccount.json
|
||||||
|
|||||||
+34
@@ -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"]
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# KC-App Backend
|
# KC-App Backend
|
||||||
|
|
||||||
NestJS API for the KC-App platform (see repo root README + plan for
|
NestJS API for the KC-App platform. Split out of the main KC-APP monorepo
|
||||||
architecture context).
|
(https://git.konfi-castle.com/linus/KC-APP); the Flutter clients live there.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
@@ -83,6 +83,13 @@ client's host - no separate web server is needed.
|
|||||||
`MailService.sendTeamerInvite()` composes the personal-invite email with a
|
`MailService.sendTeamerInvite()` composes the personal-invite email with a
|
||||||
link built from `APP_BASE_URL`. Delivery is best-effort — failures are
|
link built from `APP_BASE_URL`. Delivery is best-effort — failures are
|
||||||
logged and swallowed, never blocking the invite.
|
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
|
- `wahl/` — Wahl/Workshop administration (Leitungsteam-only), guest
|
||||||
Teilnehmer submission, Force-Zuteilung overrides, and `ZuteilungService`:
|
Teilnehmer submission, Force-Zuteilung overrides, and `ZuteilungService`:
|
||||||
a faithful port of the WP plugin's `kc_run_zuteilung` (force-assignments →
|
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
|
(`WEBDAV_*` env vars), switchable to S3-compatible storage with
|
||||||
`STORAGE_PROVIDER=s3` (`S3_*` env vars).
|
`STORAGE_PROVIDER=s3` (`S3_*` env vars).
|
||||||
- `chat/` — Gemeinde-Gruppenchat, 1:1 Direktnachrichten, Leitungsteam-über-
|
- `chat/` — Gemeinde-Gruppenchat, 1:1 Direktnachrichten, Leitungsteam-über-
|
||||||
greifende Kanäle und Broadcast (Konfis lesen nur). Channel administration
|
greifende Kanäle, Broadcast (Konfis lesen nur), and free-form `GRUPPE`
|
||||||
and message history are plain REST (`ChatController`); real-time send/
|
chats. Channel administration and message history are plain REST
|
||||||
receive is a raw `ws` gateway (`ChatGateway`, path `/chat`) since passport
|
(`ChatController`); real-time send/receive is a raw `ws` gateway
|
||||||
guards don't apply to WS upgrades — auth happens once via `?token=` at
|
(`ChatGateway`, path `/chat`) since passport guards don't apply to WS
|
||||||
connect time (`TokenVerificationService` tries Authentik JWKS, then falls
|
upgrades — auth happens once via `?token=` at connect time
|
||||||
back to a guest token). Access rules live in `ChatService` and are shared
|
(`TokenVerificationService` tries Authentik JWKS, then falls back to a
|
||||||
between the REST and WS entry points.
|
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
|
- `sync/` — replicates mutations between the local (on-site) and cloud
|
||||||
server. `SyncService.capture()` is called by feature services right after
|
server. `SyncService.capture()` is called by feature services right after
|
||||||
a write, appending an entry to the append-only `SyncLogEntry` log tagged
|
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,
|
- `common/` — `Role` enum, `@Roles()` decorator, `RolesGuard` (KC-scoped,
|
||||||
Leitungsteam roles are global across all KCs).
|
Leitungsteam roles are global across all KCs).
|
||||||
|
|
||||||
All planned backend phases are implemented. `npm test` runs Jest unit tests
|
All planned backend features are implemented (`prisma/migrations/` holds the
|
||||||
(`ZuteilungService`, `TeamAuthService`, `TeamerService`, `OnboardingService`,
|
schema history). `npm test` runs Jest unit tests (`ZuteilungService`,
|
||||||
|
`TeamAuthService`, `TeamerService`, `OnboardingService`,
|
||||||
`resolveOrProvisionAuthentikUser` / `toAuthenticatedUser`; Prisma mocked).
|
`resolveOrProvisionAuthentikUser` / `toAuthenticatedUser`; Prisma mocked).
|
||||||
Remaining work: the Flutter clients (see repo root README), push
|
Ops notes to go live: the Authentik provider must emit a `groups` claim for
|
||||||
notifications, and the first real Prisma migration (only `schema.prisma`
|
the LT check; `MAIL_PROVIDER=smtp` + `SMTP_*` for invite emails;
|
||||||
exists so far). Ops notes: the Authentik provider must emit a `groups` claim
|
`PUSH_PROVIDER=fcm` + a Firebase service-account JSON for push; and real
|
||||||
for the LT check, and `MAIL_PROVIDER=smtp` + `SMTP_*` must be set for invite
|
Nextcloud/S3 credentials for file storage.
|
||||||
emails to actually leave the box.
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
+43
@@ -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
@@ -16,14 +16,15 @@ model Kc {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
gemeinden Gemeinde[]
|
gemeinden Gemeinde[]
|
||||||
memberships Membership[]
|
memberships Membership[]
|
||||||
wahlen Wahl[]
|
wahlen Wahl[]
|
||||||
files File[]
|
files File[]
|
||||||
channels ChatChannel[]
|
channels ChatChannel[]
|
||||||
guests GuestAccount[]
|
guests GuestAccount[]
|
||||||
localUsers User[]
|
localUsers User[]
|
||||||
teamerInvites TeamerInvite[]
|
teamerInvites TeamerInvite[]
|
||||||
|
verantwortlicheInvites VerantwortlicheInvite[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A local congregation/community participating in one Kc.
|
/// A local congregation/community participating in one Kc.
|
||||||
@@ -33,10 +34,11 @@ model Gemeinde {
|
|||||||
kcId String
|
kcId String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||||
memberships Membership[]
|
memberships Membership[]
|
||||||
guests GuestAccount[]
|
guests GuestAccount[]
|
||||||
teamerInvites TeamerInvite[]
|
teamerInvites TeamerInvite[]
|
||||||
|
verantwortlicheInvites VerantwortlicheInvite[]
|
||||||
|
|
||||||
@@unique([kcId, name])
|
@@unique([kcId, name])
|
||||||
}
|
}
|
||||||
@@ -78,6 +80,7 @@ model User {
|
|||||||
memberships Membership[]
|
memberships Membership[]
|
||||||
messages ChatMessage[]
|
messages ChatMessage[]
|
||||||
chatParticipations ChatParticipant[]
|
chatParticipations ChatParticipant[]
|
||||||
|
deviceTokens DeviceToken[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scopes a User's role to a specific Kc (and Gemeinde, if applicable).
|
/// Scopes a User's role to a specific Kc (and Gemeinde, if applicable).
|
||||||
@@ -107,10 +110,28 @@ model GuestAccount {
|
|||||||
lastName String
|
lastName String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||||
gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade)
|
gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade)
|
||||||
messages ChatMessage[]
|
messages ChatMessage[]
|
||||||
teilnehmer Teilnehmer[]
|
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
|
/// 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)
|
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".
|
/// 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 {
|
model Wahl {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
kcId String
|
kcId String
|
||||||
name String
|
name String
|
||||||
datumsSchluessel String
|
datumsSchluessel String
|
||||||
teil String
|
teil String
|
||||||
|
beschreibung String?
|
||||||
|
phasenAnzahl Int @default(1)
|
||||||
isOpen Boolean @default(true)
|
isOpen Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@ -150,22 +200,29 @@ model Wahl {
|
|||||||
forceZuteilungen ForceZuteilung[]
|
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 {
|
model Workshop {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
wahlId String
|
wahlId String
|
||||||
|
phase Int @default(1)
|
||||||
name String
|
name String
|
||||||
|
beschreibung String?
|
||||||
kapazitaet Int
|
kapazitaet Int
|
||||||
minTeilnehmer Int @default(0)
|
minTeilnehmer Int @default(0)
|
||||||
|
|
||||||
wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade)
|
wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade)
|
||||||
zuteilungen Zuteilung[]
|
zuteilungen Zuteilung[]
|
||||||
forceZuteilungen ForceZuteilung[]
|
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 {
|
model Teilnehmer {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
wahlId String
|
wahlId String
|
||||||
|
phase Int @default(1)
|
||||||
guestAccountId String
|
guestAccountId String
|
||||||
prioritaeten Json
|
prioritaeten Json
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -175,7 +232,7 @@ model Teilnehmer {
|
|||||||
zuteilung Zuteilung?
|
zuteilung Zuteilung?
|
||||||
forceZuteilung ForceZuteilung?
|
forceZuteilung ForceZuteilung?
|
||||||
|
|
||||||
@@unique([wahlId, guestAccountId])
|
@@unique([wahlId, guestAccountId, phase])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Manual override set by LT before running the assignment algorithm; takes precedence.
|
/// Manual override set by LT before running the assignment algorithm; takes precedence.
|
||||||
@@ -226,32 +283,47 @@ enum ChatChannelType {
|
|||||||
DIREKT
|
DIREKT
|
||||||
LT_UEBERGREIFEND
|
LT_UEBERGREIFEND
|
||||||
BROADCAST
|
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 {
|
model ChatChannel {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
kcId String
|
kcId String
|
||||||
type ChatChannelType
|
type ChatChannelType
|
||||||
gemeindeId String?
|
gemeindeId String?
|
||||||
createdAt DateTime @default(now())
|
/// 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)
|
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||||
messages ChatMessage[]
|
messages ChatMessage[]
|
||||||
participants ChatParticipant[]
|
participants ChatParticipant[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Explicit membership for DIREKT (1:1) channels; other channel types derive
|
/// Explicit membership for DIREKT (1:1) and GRUPPE channels; other channel
|
||||||
/// access from Membership/Gemeinde instead of this table.
|
/// types derive access from Membership/Gemeinde instead of this table.
|
||||||
|
/// Exactly one of userId/guestAccountId is set per row.
|
||||||
model ChatParticipant {
|
model ChatParticipant {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
channelId String
|
channelId String
|
||||||
userId String
|
userId String?
|
||||||
createdAt DateTime @default(now())
|
guestAccountId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
channel ChatChannel @relation(fields: [channelId], references: [id], onDelete: Cascade)
|
channel ChatChannel @relation(fields: [channelId], references: [id], onDelete: Cascade)
|
||||||
user User @relation(fields: [userId], 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, userId])
|
||||||
|
@@unique([channelId, guestAccountId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model ChatMessage {
|
model ChatMessage {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { existsSync } from 'fs';
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { MailModule } from './mail/mail.module';
|
import { MailModule } from './mail/mail.module';
|
||||||
|
import { PushModule } from './push/push.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { KcModule } from './kc/kc.module';
|
import { KcModule } from './kc/kc.module';
|
||||||
import { GemeindeModule } from './gemeinde/gemeinde.module';
|
import { GemeindeModule } from './gemeinde/gemeinde.module';
|
||||||
@@ -35,6 +36,7 @@ const webRoot =
|
|||||||
}),
|
}),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
MailModule,
|
MailModule,
|
||||||
|
PushModule,
|
||||||
SyncModule,
|
SyncModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
KcModule,
|
KcModule,
|
||||||
|
|||||||
@@ -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 { AuthGuard } from '@nestjs/passport';
|
||||||
import { GuestAuthService } from './guest-auth.service';
|
import { GuestAuthService } from './guest-auth.service';
|
||||||
import { TeamAuthService } from './team-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);
|
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')
|
@Post('team-login')
|
||||||
teamLogin(@Body() dto: TeamLoginDto) {
|
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.
|
/// Self-registration for a Gemeinde Teamer via an invite token/link.
|
||||||
|
|||||||
@@ -7,13 +7,19 @@ import { Request } from 'express';
|
|||||||
import { PrismaClient } from '../prisma/prisma.module';
|
import { PrismaClient } from '../prisma/prisma.module';
|
||||||
import { SyncService } from '../sync/sync.service';
|
import { SyncService } from '../sync/sync.service';
|
||||||
import { AuthenticatedUser } from './authenticated-request';
|
import { AuthenticatedUser } from './authenticated-request';
|
||||||
import { resolveOrProvisionAuthentikUser, toAuthenticatedUser } from './provision-user';
|
import {
|
||||||
|
authentikEmail,
|
||||||
|
resolveOrProvisionAuthentikUser,
|
||||||
|
toAuthenticatedUser,
|
||||||
|
} from './provision-user';
|
||||||
|
|
||||||
interface AuthentikJwtPayload {
|
interface AuthentikJwtPayload {
|
||||||
sub: string;
|
sub: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
given_name?: string;
|
given_name?: string;
|
||||||
family_name?: string;
|
family_name?: string;
|
||||||
|
preferred_username?: string;
|
||||||
|
name?: string;
|
||||||
groups?: string[];
|
groups?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,8 +58,8 @@ export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async validate(payload: AuthentikJwtPayload): Promise<AuthenticatedUser> {
|
async validate(payload: AuthentikJwtPayload): Promise<AuthenticatedUser> {
|
||||||
if (!payload.email) {
|
if (!payload.sub) {
|
||||||
throw new UnauthorizedException('Authentik token missing email claim');
|
throw new UnauthorizedException('Authentik token missing subject');
|
||||||
}
|
}
|
||||||
const isLeitungsteam = (payload.groups ?? []).includes(this.leitungsteamGroup);
|
const isLeitungsteam = (payload.groups ?? []).includes(this.leitungsteamGroup);
|
||||||
const user = await resolveOrProvisionAuthentikUser(
|
const user = await resolveOrProvisionAuthentikUser(
|
||||||
@@ -61,8 +67,8 @@ export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') {
|
|||||||
this.sync,
|
this.sync,
|
||||||
{
|
{
|
||||||
sub: payload.sub,
|
sub: payload.sub,
|
||||||
email: payload.email,
|
email: authentikEmail(payload),
|
||||||
firstName: payload.given_name ?? '',
|
firstName: payload.given_name ?? payload.preferred_username ?? payload.name ?? '',
|
||||||
lastName: payload.family_name ?? '',
|
lastName: payload.family_name ?? '',
|
||||||
},
|
},
|
||||||
isLeitungsteam,
|
isLeitungsteam,
|
||||||
|
|||||||
@@ -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 {
|
export class TeamLoginDto {
|
||||||
|
@IsOptional()
|
||||||
@IsEmail()
|
@IsEmail()
|
||||||
email!: string;
|
email?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
gemeindeName?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ export class GuestAuthService {
|
|||||||
private readonly sync: SyncService,
|
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(
|
async createGuest(
|
||||||
inviteCode: string,
|
inviteCode: string,
|
||||||
firstName: string,
|
firstName: string,
|
||||||
@@ -30,10 +35,23 @@ export class GuestAuthService {
|
|||||||
throw new NotFoundException('Unknown or inactive KC invite code');
|
throw new NotFoundException('Unknown or inactive KC invite code');
|
||||||
}
|
}
|
||||||
|
|
||||||
const guest = await this.prisma.guestAccount.create({
|
const trimmedFirst = firstName.trim();
|
||||||
data: { kcId: kc.id, firstName, lastName },
|
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 = {
|
const payload: GuestJwtPayload = {
|
||||||
guestId: guest.id,
|
guestId: guest.id,
|
||||||
|
|||||||
@@ -10,6 +10,18 @@ export interface AuthentikClaims {
|
|||||||
lastName: string;
|
lastName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Authentik users don't necessarily have an email set. Fall back to a stable,
|
||||||
|
/// per-user placeholder so provisioning still has a unique handle for the row.
|
||||||
|
export function authentikEmail(p: {
|
||||||
|
email?: string;
|
||||||
|
preferred_username?: string;
|
||||||
|
sub: string;
|
||||||
|
}): string {
|
||||||
|
const e = p.email?.trim();
|
||||||
|
if (e) return e.toLowerCase();
|
||||||
|
return `${p.preferred_username?.trim() || p.sub}@no-email.authentik`.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
/// Placeholder kcId for the synthetic, global LEITUNGSTEAM membership. RolesGuard
|
/// Placeholder kcId for the synthetic, global LEITUNGSTEAM membership. RolesGuard
|
||||||
/// never compares it (LT short-circuits the KC check), it only needs to exist.
|
/// never compares it (LT short-circuits the KC check), it only needs to exist.
|
||||||
export const GLOBAL_LT_KC_ID = '*';
|
export const GLOBAL_LT_KC_ID = '*';
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ interface InviteRow {
|
|||||||
function makeService(seed: {
|
function makeService(seed: {
|
||||||
invites?: InviteRow[];
|
invites?: InviteRow[];
|
||||||
users?: { id: string; email: string; passwordHash: string | null }[];
|
users?: { id: string; email: string; passwordHash: string | null }[];
|
||||||
|
memberships?: {
|
||||||
|
gemeindeName: string;
|
||||||
|
user: { id: string; passwordHash: string | null };
|
||||||
|
}[];
|
||||||
}) {
|
}) {
|
||||||
const invites = [...(seed.invites ?? [])];
|
const invites = [...(seed.invites ?? [])];
|
||||||
const users = [...(seed.users ?? [])].map((u) => ({
|
const users = [...(seed.users ?? [])].map((u) => ({
|
||||||
@@ -39,6 +43,7 @@ function makeService(seed: {
|
|||||||
memberships: [] as unknown[],
|
memberships: [] as unknown[],
|
||||||
...u,
|
...u,
|
||||||
}));
|
}));
|
||||||
|
const memberships = seed.memberships ?? [];
|
||||||
|
|
||||||
const prisma = {
|
const prisma = {
|
||||||
user: {
|
user: {
|
||||||
@@ -64,6 +69,21 @@ function makeService(seed: {
|
|||||||
create: jest.fn(({ data }: { data: Record<string, unknown> }) =>
|
create: jest.fn(({ data }: { data: Record<string, unknown> }) =>
|
||||||
Promise.resolve({ id: `m-1`, ...data }),
|
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: {
|
teamerInvite: {
|
||||||
findUnique: jest.fn(({ where }: { where: { token: string } }) =>
|
findUnique: jest.fn(({ where }: { where: { token: string } }) =>
|
||||||
@@ -195,18 +215,18 @@ describe('TeamAuthService.registerFromInvite', () => {
|
|||||||
describe('TeamAuthService.login', () => {
|
describe('TeamAuthService.login', () => {
|
||||||
it('rejects an unknown email', async () => {
|
it('rejects an unknown email', async () => {
|
||||||
const { service } = makeService({ users: [] });
|
const { service } = makeService({ users: [] });
|
||||||
await expect(service.login('nobody@example.org', 'x')).rejects.toBeInstanceOf(
|
await expect(
|
||||||
UnauthorizedException,
|
service.login({ email: 'nobody@example.org' }, 'x'),
|
||||||
);
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a user without a password hash (Authentik-only account)', async () => {
|
it('rejects a user without a password hash (Authentik-only account)', async () => {
|
||||||
const { service } = makeService({
|
const { service } = makeService({
|
||||||
users: [{ id: 'u-1', email: 'lt@example.org', passwordHash: null }],
|
users: [{ id: 'u-1', email: 'lt@example.org', passwordHash: null }],
|
||||||
});
|
});
|
||||||
await expect(service.login('lt@example.org', 'x')).rejects.toBeInstanceOf(
|
await expect(
|
||||||
UnauthorizedException,
|
service.login({ email: 'lt@example.org' }, 'x'),
|
||||||
);
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a wrong password', async () => {
|
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) },
|
{ id: 'u-1', email: 't@example.org', passwordHash: bcrypt.hashSync('right', 10) },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
await expect(service.login('t@example.org', 'wrong')).rejects.toBeInstanceOf(
|
await expect(
|
||||||
UnauthorizedException,
|
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({
|
const { service } = makeService({
|
||||||
users: [
|
users: [
|
||||||
{ id: 'u-1', email: 't@example.org', passwordHash: bcrypt.hashSync('right', 10) },
|
{ 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));
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,19 +38,44 @@ export class TeamAuthService {
|
|||||||
this.secret = config.getOrThrow<string>('TEAM_JWT_SECRET');
|
this.secret = config.getOrThrow<string>('TEAM_JWT_SECRET');
|
||||||
}
|
}
|
||||||
|
|
||||||
async login(email: string, password: string): Promise<{ accessToken: string }> {
|
/// Logs a Teamer in by email (legacy) OR by Gemeinde name — the normal
|
||||||
const user = await this.prisma.user.findUnique({
|
/// path, since a Teamer thinks of their login as "meine Gemeinde" rather
|
||||||
where: { email: email.toLowerCase() },
|
/// than an email address. A Gemeinde can have several Teamer accounts, so
|
||||||
include: { memberships: true },
|
/// 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) {
|
for (const m of memberships) {
|
||||||
throw new UnauthorizedException('Invalid credentials');
|
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);
|
throw new UnauthorizedException('Invalid credentials');
|
||||||
if (!ok) {
|
|
||||||
throw new UnauthorizedException('Invalid credentials');
|
|
||||||
}
|
|
||||||
return { accessToken: this.sign(user.id) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Redeems an invite token and creates the local Teamer account + its
|
/// Redeems an invite token and creates the local Teamer account + its
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { GuestJwtPayload } from './guest-auth.service';
|
|||||||
import { TeamAuthService } from './team-auth.service';
|
import { TeamAuthService } from './team-auth.service';
|
||||||
import {
|
import {
|
||||||
AuthentikClaims,
|
AuthentikClaims,
|
||||||
|
authentikEmail,
|
||||||
resolveOrProvisionAuthentikUser,
|
resolveOrProvisionAuthentikUser,
|
||||||
toAuthenticatedUser,
|
toAuthenticatedUser,
|
||||||
} from './provision-user';
|
} from './provision-user';
|
||||||
@@ -55,15 +56,22 @@ export class TokenVerificationService {
|
|||||||
email?: string;
|
email?: string;
|
||||||
given_name?: string;
|
given_name?: string;
|
||||||
family_name?: string;
|
family_name?: string;
|
||||||
|
preferred_username?: string;
|
||||||
|
name?: string;
|
||||||
groups?: string[];
|
groups?: string[];
|
||||||
};
|
};
|
||||||
if (!payload.sub || !payload.email) {
|
const sub = payload.sub;
|
||||||
throw new UnauthorizedException('Authentik token missing subject or email');
|
if (!sub) {
|
||||||
|
throw new UnauthorizedException('Authentik token missing subject');
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
sub: payload.sub,
|
sub,
|
||||||
email: payload.email,
|
email: authentikEmail({
|
||||||
firstName: payload.given_name ?? '',
|
email: payload.email,
|
||||||
|
preferred_username: payload.preferred_username,
|
||||||
|
sub,
|
||||||
|
}),
|
||||||
|
firstName: payload.given_name ?? payload.preferred_username ?? payload.name ?? '',
|
||||||
lastName: payload.family_name ?? '',
|
lastName: payload.family_name ?? '',
|
||||||
isLeitungsteam: (payload.groups ?? []).includes(this.leitungsteamGroup),
|
isLeitungsteam: (payload.groups ?? []).includes(this.leitungsteamGroup),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 { AuthGuard } from '@nestjs/passport';
|
||||||
import { ChatService } from './chat.service';
|
import { ChatService } from './chat.service';
|
||||||
|
import { ChatGateway } from './chat.gateway';
|
||||||
import { CreateChannelDto } from './dto/create-channel.dto';
|
import { CreateChannelDto } from './dto/create-channel.dto';
|
||||||
import { CreateDirectChannelDto } from './dto/create-direct-channel.dto';
|
import { CreateDirectChannelDto } from './dto/create-direct-channel.dto';
|
||||||
|
import { AddParticipantDto } from './dto/add-participant.dto';
|
||||||
import { Roles } from '../common/roles.decorator';
|
import { Roles } from '../common/roles.decorator';
|
||||||
import { RolesGuard } from '../common/roles.guard';
|
import { RolesGuard } from '../common/roles.guard';
|
||||||
import { Role } from '../common/role.enum';
|
import { Role } from '../common/role.enum';
|
||||||
@@ -14,7 +16,10 @@ type ChatRequest = AuthenticatedRequest & { user?: AuthenticatedRequest['user']
|
|||||||
|
|
||||||
@Controller('chat')
|
@Controller('chat')
|
||||||
export class ChatController {
|
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.
|
/// Channel administration (Gemeinde-Gruppen, LT-Kanäle, Broadcasts) is Leitungsteam-only.
|
||||||
@Post(':kcId/channels')
|
@Post(':kcId/channels')
|
||||||
@@ -24,6 +29,73 @@ export class ChatController {
|
|||||||
return this.chat.createChannel(kcId, dto.type, dto.gemeindeId);
|
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
|
/// Any two team members of the same KC can start a direct conversation
|
||||||
/// (Authentik-backed members and local Gemeinde Teamer alike).
|
/// (Authentik-backed members and local Gemeinde Teamer alike).
|
||||||
@Post('direct')
|
@Post('direct')
|
||||||
|
|||||||
@@ -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 } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 { ChatChannelType, Role, SyncOperation } from '@prisma/client';
|
||||||
import { PrismaClient } from '../prisma/prisma.module';
|
import { PrismaClient } from '../prisma/prisma.module';
|
||||||
import { AuthenticatedUser } from '../auth/authenticated-request';
|
import { AuthenticatedUser } from '../auth/authenticated-request';
|
||||||
import { GuestJwtPayload } from '../auth/guest-auth.service';
|
import { GuestJwtPayload } from '../auth/guest-auth.service';
|
||||||
import { SyncService } from '../sync/sync.service';
|
import { SyncService } from '../sync/sync.service';
|
||||||
|
import { PushService } from '../push/push.service';
|
||||||
|
|
||||||
export type ChatCaller =
|
export type ChatCaller =
|
||||||
| { kind: 'user'; user: AuthenticatedUser }
|
| { kind: 'user'; user: AuthenticatedUser }
|
||||||
| { kind: 'guest'; guest: GuestJwtPayload };
|
| { 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()
|
@Injectable()
|
||||||
export class ChatService {
|
export class ChatService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaClient,
|
private readonly prisma: PrismaClient,
|
||||||
private readonly sync: SyncService,
|
private readonly sync: SyncService,
|
||||||
|
private readonly push: PushService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createChannel(kcId: string, type: ChatChannelType, gemeindeId?: string) {
|
async createChannel(kcId: string, type: ChatChannelType, gemeindeId?: string) {
|
||||||
@@ -22,6 +37,206 @@ export class ChatService {
|
|||||||
return channel;
|
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) {
|
async getOrCreateDirectChannel(kcId: string, userAId: string, userBId: string) {
|
||||||
const existing = await this.prisma.chatChannel.findFirst({
|
const existing = await this.prisma.chatChannel.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@@ -48,7 +263,13 @@ export class ChatService {
|
|||||||
async listChannelsForCaller(kcId: string, caller: ChatCaller) {
|
async listChannelsForCaller(kcId: string, caller: ChatCaller) {
|
||||||
if (caller.kind === 'guest') {
|
if (caller.kind === 'guest') {
|
||||||
return this.prisma.chatChannel.findMany({
|
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;
|
const { user } = caller;
|
||||||
@@ -66,6 +287,7 @@ export class ChatService {
|
|||||||
{ type: ChatChannelType.BROADCAST },
|
{ type: ChatChannelType.BROADCAST },
|
||||||
{ type: ChatChannelType.GEMEINDE_GRUPPE, gemeindeId: { in: gemeindeIds } },
|
{ type: ChatChannelType.GEMEINDE_GRUPPE, gemeindeId: { in: gemeindeIds } },
|
||||||
{ type: ChatChannelType.DIREKT, participants: { some: { userId: user.userId } } },
|
{ 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') {
|
if (caller.kind === 'guest') {
|
||||||
const allowed = channel.type === ChatChannelType.BROADCAST && mode === 'read';
|
if (channel.type === ChatChannelType.BROADCAST && mode === 'read') {
|
||||||
if (!allowed) {
|
if (caller.guest.kcId !== channel.kcId) {
|
||||||
throw new ForbiddenException('Guests may only read broadcast channels');
|
throw new ForbiddenException('Guest does not belong to this KC');
|
||||||
|
}
|
||||||
|
return channel;
|
||||||
}
|
}
|
||||||
if (caller.guest.kcId !== channel.kcId) {
|
if (channel.type === ChatChannelType.GRUPPE) {
|
||||||
throw new ForbiddenException('Guest does not belong to this KC');
|
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;
|
const { user } = caller;
|
||||||
@@ -136,13 +366,20 @@ export class ChatService {
|
|||||||
}
|
}
|
||||||
return channel;
|
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:
|
default:
|
||||||
throw new ForbiddenException('Unknown channel type');
|
throw new ForbiddenException('Unknown channel type');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendMessage(channelId: string, caller: ChatCaller, body: string) {
|
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({
|
const message = await this.prisma.chatMessage.create({
|
||||||
data: {
|
data: {
|
||||||
channelId,
|
channelId,
|
||||||
@@ -152,6 +389,20 @@ export class ChatService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await this.sync.capture('ChatMessage', SyncOperation.CREATE, message.id, message);
|
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;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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';
|
import { ChatChannelType } from '@prisma/client';
|
||||||
|
|
||||||
export class CreateChannelDto {
|
export class CreateChannelDto {
|
||||||
@@ -8,4 +8,24 @@ export class CreateChannelDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
gemeindeId?: string;
|
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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export class FilesController {
|
|||||||
|
|
||||||
/// Only the Leitungsteam uploads files (per KC), tagged with a visibility tier.
|
/// Only the Leitungsteam uploads files (per KC), tagged with a visibility tier.
|
||||||
@Post(':kcId')
|
@Post(':kcId')
|
||||||
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||||
@Roles(Role.LEITUNGSTEAM)
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
upload(
|
upload(
|
||||||
|
|||||||
@@ -5,16 +5,15 @@ import {
|
|||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { MembershipStatus, Role, SyncOperation } from '@prisma/client';
|
import { MembershipStatus, Role, SyncOperation } from '@prisma/client';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
import { PrismaClient } from '../prisma/prisma.module';
|
import { PrismaClient } from '../prisma/prisma.module';
|
||||||
import { SyncService } from '../sync/sync.service';
|
import { SyncService } from '../sync/sync.service';
|
||||||
import { TokenVerificationService } from '../auth/token-verification.service';
|
import { TokenVerificationService } from '../auth/token-verification.service';
|
||||||
import { resolveOrProvisionAuthentikUser } from '../auth/provision-user';
|
import { resolveOrProvisionAuthentikUser } from '../auth/provision-user';
|
||||||
|
import { AuthenticatedUser } from '../auth/authenticated-request';
|
||||||
|
|
||||||
/// Self-service onboarding for Gemeinde Verantwortliche. The person signs in
|
/// Self-service onboarding for Gemeinde Verantwortliche, plus the
|
||||||
/// with their Konfi-Castle-ID (Authentik) and submits a KC invite code plus
|
/// Leitungsteam-initiated shortcut that skips the approval step entirely.
|
||||||
/// the Gemeinde they belong to; this provisions their local User (JIT) and a
|
|
||||||
/// PENDING membership that a Leitungsteam member must approve before it grants
|
|
||||||
/// any rights.
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OnboardingService {
|
export class OnboardingService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -133,4 +132,145 @@ export class OnboardingService {
|
|||||||
) {
|
) {
|
||||||
return { membershipId, status, kcName, gemeindeName };
|
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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ const SYNCED_MODELS = [
|
|||||||
'User',
|
'User',
|
||||||
'Membership',
|
'Membership',
|
||||||
'TeamerInvite',
|
'TeamerInvite',
|
||||||
|
'VerantwortlicheInvite',
|
||||||
'GuestAccount',
|
'GuestAccount',
|
||||||
'Wahl',
|
'Wahl',
|
||||||
'Workshop',
|
'Workshop',
|
||||||
@@ -17,7 +18,9 @@ const SYNCED_MODELS = [
|
|||||||
'Zuteilung',
|
'Zuteilung',
|
||||||
'File',
|
'File',
|
||||||
'ChatChannel',
|
'ChatChannel',
|
||||||
|
'ChatParticipant',
|
||||||
'ChatMessage',
|
'ChatMessage',
|
||||||
|
'DeviceToken',
|
||||||
] as const;
|
] as const;
|
||||||
export type SyncedModel = (typeof SYNCED_MODELS)[number];
|
export type SyncedModel = (typeof SYNCED_MODELS)[number];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsBoolean, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateWahlDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isOpen?: boolean;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
Req,
|
Req,
|
||||||
@@ -14,6 +15,7 @@ import { Response } from 'express';
|
|||||||
import { WahlService } from './wahl.service';
|
import { WahlService } from './wahl.service';
|
||||||
import { ZuteilungService } from './zuteilung.service';
|
import { ZuteilungService } from './zuteilung.service';
|
||||||
import { CreateWahlDto } from './dto/create-wahl.dto';
|
import { CreateWahlDto } from './dto/create-wahl.dto';
|
||||||
|
import { UpdateWahlDto } from './dto/update-wahl.dto';
|
||||||
import { CreateWorkshopDto } from './dto/create-workshop.dto';
|
import { CreateWorkshopDto } from './dto/create-workshop.dto';
|
||||||
import { SubmitTeilnehmerDto } from './dto/submit-teilnehmer.dto';
|
import { SubmitTeilnehmerDto } from './dto/submit-teilnehmer.dto';
|
||||||
import { CreateForceZuteilungDto } from './dto/create-force-zuteilung.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
|
/// Wahl-/Workshop-/Force-Zuteilung-Verwaltung ist ausschließlich Sache des
|
||||||
/// Leitungsteams (global über alle KCs, siehe RolesGuard).
|
/// Leitungsteams (global über alle KCs, siehe RolesGuard).
|
||||||
@Post()
|
@Post()
|
||||||
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||||
@Roles(Role.LEITUNGSTEAM)
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
createWahl(@Body() dto: CreateWahlDto) {
|
createWahl(@Body() dto: CreateWahlDto) {
|
||||||
return this.wahl.createWahl(dto.kcId, dto.name, dto.datumsSchluessel, dto.teil);
|
return this.wahl.createWahl(dto.kcId, dto.name, dto.datumsSchluessel, dto.teil);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||||
@Roles(Role.LEITUNGSTEAM)
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
listWahlen(@Query('kcId') kcId: string) {
|
listWahlen(@Query('kcId') kcId: string) {
|
||||||
return this.wahl.listWahlen(kcId);
|
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')
|
@Post(':wahlId/workshops')
|
||||||
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||||
@Roles(Role.LEITUNGSTEAM)
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
createWorkshop(@Param('wahlId') wahlId: string, @Body() dto: CreateWorkshopDto) {
|
createWorkshop(@Param('wahlId') wahlId: string, @Body() dto: CreateWorkshopDto) {
|
||||||
return this.wahl.createWorkshop(wahlId, dto.name, dto.kapazitaet, dto.minTeilnehmer);
|
return this.wahl.createWorkshop(wahlId, dto.name, dto.kapazitaet, dto.minTeilnehmer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':wahlId/workshops')
|
@Get(':wahlId/workshops')
|
||||||
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||||
@Roles(Role.LEITUNGSTEAM)
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
listWorkshops(@Param('wahlId') wahlId: string) {
|
listWorkshops(@Param('wahlId') wahlId: string) {
|
||||||
return this.wahl.listWorkshops(wahlId);
|
return this.wahl.listWorkshops(wahlId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':wahlId/force-zuteilung')
|
@Post(':wahlId/force-zuteilung')
|
||||||
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||||
@Roles(Role.LEITUNGSTEAM)
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
createForceZuteilung(
|
createForceZuteilung(
|
||||||
@Param('wahlId') wahlId: string,
|
@Param('wahlId') wahlId: string,
|
||||||
@@ -99,21 +115,21 @@ export class WahlController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':wahlId/zuteilung/run')
|
@Post(':wahlId/zuteilung/run')
|
||||||
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||||
@Roles(Role.LEITUNGSTEAM)
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
runZuteilung(@Param('wahlId') wahlId: string) {
|
runZuteilung(@Param('wahlId') wahlId: string) {
|
||||||
return this.zuteilung.run(wahlId);
|
return this.zuteilung.run(wahlId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':wahlId/zuteilung')
|
@Get(':wahlId/zuteilung')
|
||||||
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||||
@Roles(Role.LEITUNGSTEAM)
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
getZuteilung(@Param('wahlId') wahlId: string) {
|
getZuteilung(@Param('wahlId') wahlId: string) {
|
||||||
return this.zuteilung.getResults(wahlId);
|
return this.zuteilung.getResults(wahlId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':wahlId/zuteilung/csv')
|
@Get(':wahlId/zuteilung/csv')
|
||||||
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||||
@Roles(Role.LEITUNGSTEAM)
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
async exportCsv(@Param('wahlId') wahlId: string, @Res() res: Response) {
|
async exportCsv(@Param('wahlId') wahlId: string, @Res() res: Response) {
|
||||||
const csv = await this.zuteilung.exportCsv(wahlId);
|
const csv = await this.zuteilung.exportCsv(wahlId);
|
||||||
|
|||||||
@@ -22,6 +22,33 @@ export class WahlService {
|
|||||||
return this.prisma.wahl.findMany({ where: { kcId } });
|
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
|
/// 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).
|
/// workshops and the guest's own current priorities (null if not submitted).
|
||||||
async guestOverview(kcId: string, guestAccountId: string) {
|
async guestOverview(kcId: string, guestAccountId: string) {
|
||||||
@@ -145,7 +172,7 @@ export class WahlService {
|
|||||||
throw new ForbiddenException('Wahl is closed');
|
throw new ForbiddenException('Wahl is closed');
|
||||||
}
|
}
|
||||||
const teilnehmer = await this.prisma.teilnehmer.upsert({
|
const teilnehmer = await this.prisma.teilnehmer.upsert({
|
||||||
where: { wahlId_guestAccountId: { wahlId, guestAccountId } },
|
where: { wahlId_guestAccountId_phase: { wahlId, guestAccountId, phase: 1 } },
|
||||||
create: { wahlId, guestAccountId, prioritaeten },
|
create: { wahlId, guestAccountId, prioritaeten },
|
||||||
update: { prioritaeten },
|
update: { prioritaeten },
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user