feat: Wahl-Phasen, Verantwortliche-Invites, Auth fixes, GRUPPE chat #1
@@ -8,3 +8,29 @@ AUTHENTIK_ISSUER_URL="https://authentik.example.org/application/o/kc-app"
|
|||||||
GUEST_JWT_SECRET="change-me"
|
GUEST_JWT_SECRET="change-me"
|
||||||
|
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
|
# File storage: defaults to Nextcloud via WebDAV; set STORAGE_PROVIDER=s3 to
|
||||||
|
# use an S3-compatible bucket instead (see S3_* vars below).
|
||||||
|
STORAGE_PROVIDER="webdav"
|
||||||
|
WEBDAV_URL="https://nextcloud.example.org/remote.php/dav/files/kc-app"
|
||||||
|
WEBDAV_USERNAME="kc-app"
|
||||||
|
WEBDAV_PASSWORD="change-me"
|
||||||
|
|
||||||
|
# Only used when STORAGE_PROVIDER=s3
|
||||||
|
S3_BUCKET="kc-app"
|
||||||
|
S3_REGION="auto"
|
||||||
|
S3_ENDPOINT=""
|
||||||
|
S3_FORCE_PATH_STYLE="false"
|
||||||
|
S3_ACCESS_KEY_ID=""
|
||||||
|
S3_SECRET_ACCESS_KEY=""
|
||||||
|
|
||||||
|
# Unique id for THIS server instance (local on-site vs. cloud); used to tag
|
||||||
|
# replication log entries and avoid echoing changes back to their origin.
|
||||||
|
SERVER_ID="change-me-uuid"
|
||||||
|
|
||||||
|
# Local/cloud sync: set on the LOCAL (on-site) server to periodically push/
|
||||||
|
# pull against the cloud instance's API base URL. Leave SYNC_ENABLED=false
|
||||||
|
# on the cloud server (it only needs to expose /sync/ingest + /sync/export).
|
||||||
|
SYNC_ENABLED="false"
|
||||||
|
SYNC_PEER_URL="https://kc-app-cloud.example.org/api"
|
||||||
|
SYNC_SHARED_SECRET="change-me"
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ npx prisma migrate dev --name init # requires a running PostgreSQL instance
|
|||||||
npm run start:dev
|
npm run start:dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The API is served under `/api` (see `app.setGlobalPrefix('api')` in
|
||||||
|
`main.ts`); everything else (`/`, `/app.js`, ...) is served statically from
|
||||||
|
`../client/web` via `ServeStaticModule`, so the backend doubles as the web
|
||||||
|
client's host - no separate web server is needed.
|
||||||
|
|
||||||
## Auth model
|
## Auth model
|
||||||
|
|
||||||
- Team members (Leitungsteam, Gemeinde Verantwortliche, Gemeinde Teamer) are
|
- Team members (Leitungsteam, Gemeinde Verantwortliche, Gemeinde Teamer) are
|
||||||
@@ -28,10 +33,44 @@ npm run start:dev
|
|||||||
## Modules implemented so far
|
## Modules implemented so far
|
||||||
|
|
||||||
- `prisma/` — shared `PrismaClient` provider.
|
- `prisma/` — shared `PrismaClient` provider.
|
||||||
- `auth/` — Authentik resource-server strategy + guest invite-code login.
|
- `auth/` — Authentik resource-server strategy (`AuthGuard('authentik')`) +
|
||||||
|
guest invite-code login issuing a locally-signed JWT (`AuthGuard('guest')`).
|
||||||
- `kc/` — KC (event) creation/listing, Leitungsteam-only.
|
- `kc/` — KC (event) creation/listing, Leitungsteam-only.
|
||||||
|
- `wahl/` — Wahl/Workshop administration (Leitungsteam-only), guest
|
||||||
|
Teilnehmer submission, Force-Zuteilung overrides, and `ZuteilungService`:
|
||||||
|
a faithful port of the WP plugin's `kc_run_zuteilung` (force-assignments →
|
||||||
|
up to 3 wish rounds → random fill → consolidation of workshops that stay
|
||||||
|
below `minTeilnehmer`), plus CSV export (`GET /wahl/:id/zuteilung/csv`).
|
||||||
|
- `files/` — Leitungsteam-only upload (`POST /files/:kcId`, multipart) tagged
|
||||||
|
with a `FileVisibility` tier; list/download (`GET /files/:kcId`,
|
||||||
|
`GET /files/download/:fileId`) accept either an Authentik or a guest token
|
||||||
|
and filter by the caller's allowed visibility tiers. Storage is behind a
|
||||||
|
`StorageProvider` abstraction: defaults to Nextcloud via WebDAV
|
||||||
|
(`WEBDAV_*` env vars), switchable to S3-compatible storage with
|
||||||
|
`STORAGE_PROVIDER=s3` (`S3_*` env vars).
|
||||||
|
- `chat/` — Gemeinde-Gruppenchat, 1:1 Direktnachrichten, Leitungsteam-über-
|
||||||
|
greifende Kanäle und Broadcast (Konfis lesen nur). Channel administration
|
||||||
|
and message history are plain REST (`ChatController`); real-time send/
|
||||||
|
receive is a raw `ws` gateway (`ChatGateway`, path `/chat`) since passport
|
||||||
|
guards don't apply to WS upgrades — auth happens once via `?token=` at
|
||||||
|
connect time (`TokenVerificationService` tries Authentik JWKS, then falls
|
||||||
|
back to a guest token). Access rules live in `ChatService` and are shared
|
||||||
|
between the REST and WS entry points.
|
||||||
|
- `sync/` — replicates mutations between the local (on-site) and cloud
|
||||||
|
server. `SyncService.capture()` is called by feature services right after
|
||||||
|
a write, appending an entry to the append-only `SyncLogEntry` log tagged
|
||||||
|
with this server's `SERVER_ID`. The local server (set `SYNC_ENABLED=true`,
|
||||||
|
`SYNC_PEER_URL`) periodically pushes its new entries to the cloud's
|
||||||
|
`POST /sync/ingest` and pulls the cloud's via `GET /sync/export`
|
||||||
|
(`SyncSchedulerService`, every 30s), both guarded by `SYNC_SHARED_SECRET`
|
||||||
|
(`SyncSecretGuard`) rather than user auth. No conflict resolution is
|
||||||
|
implemented by design — the local server is the sole source of truth
|
||||||
|
while an event is live. `POST /sync/trigger` lets a Leitungsteam member
|
||||||
|
force an immediate push+pull. Known gap: only entity metadata is
|
||||||
|
replicated; uploaded file bytes only resolve on both sides if local and
|
||||||
|
cloud share the same Nextcloud/S3 backend.
|
||||||
- `common/` — `Role` enum, `@Roles()` decorator, `RolesGuard` (KC-scoped,
|
- `common/` — `Role` enum, `@Roles()` decorator, `RolesGuard` (KC-scoped,
|
||||||
Leitungsteam roles are global across all KCs).
|
Leitungsteam roles are global across all KCs).
|
||||||
|
|
||||||
Not yet implemented: Wahl/Workshop/Zuteilung engine, file sharing, chat
|
All planned backend phases are implemented; remaining work is the Flutter
|
||||||
realtime gateway, local/cloud sync engine.
|
clients (see repo root README).
|
||||||
|
|||||||
Generated
+922
-13
File diff suppressed because it is too large
Load Diff
+16
-2
@@ -21,6 +21,7 @@
|
|||||||
"prisma:migrate": "prisma migrate dev"
|
"prisma:migrate": "prisma migrate dev"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.679.0",
|
||||||
"@nestjs/common": "^10.4.15",
|
"@nestjs/common": "^10.4.15",
|
||||||
"@nestjs/config": "^3.3.0",
|
"@nestjs/config": "^3.3.0",
|
||||||
"@nestjs/core": "^10.4.15",
|
"@nestjs/core": "^10.4.15",
|
||||||
@@ -28,15 +29,20 @@
|
|||||||
"@nestjs/passport": "^10.0.3",
|
"@nestjs/passport": "^10.0.3",
|
||||||
"@nestjs/platform-express": "^10.4.15",
|
"@nestjs/platform-express": "^10.4.15",
|
||||||
"@nestjs/platform-ws": "^10.4.15",
|
"@nestjs/platform-ws": "^10.4.15",
|
||||||
|
"@nestjs/schedule": "^4.1.1",
|
||||||
|
"@nestjs/serve-static": "^4.0.2",
|
||||||
"@nestjs/websockets": "^10.4.15",
|
"@nestjs/websockets": "^10.4.15",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
"jwks-rsa": "^3.1.0",
|
"jwks-rsa": "^3.1.0",
|
||||||
|
"multer": "^2.0.1",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
|
"webdav": "^5.7.1",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -45,6 +51,8 @@
|
|||||||
"@nestjs/testing": "^10.4.15",
|
"@nestjs/testing": "^10.4.15",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
|
"@types/jsonwebtoken": "^9.0.7",
|
||||||
|
"@types/multer": "^1.4.12",
|
||||||
"@types/node": "^20.17.9",
|
"@types/node": "^20.17.9",
|
||||||
"@types/passport": "^1.0.17",
|
"@types/passport": "^1.0.17",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
@@ -67,13 +75,19 @@
|
|||||||
"typescript": "^5.6.3"
|
"typescript": "^5.6.3"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": ["js", "json", "ts"],
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"testRegex": ".*\\.spec\\.ts$",
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
},
|
},
|
||||||
"collectCoverageFrom": ["**/*.(t|j)s"],
|
"collectCoverageFrom": [
|
||||||
|
"**/*.(t|j)s"
|
||||||
|
],
|
||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
"testEnvironment": "node"
|
"testEnvironment": "node"
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-3
@@ -55,6 +55,7 @@ model User {
|
|||||||
|
|
||||||
memberships Membership[]
|
memberships Membership[]
|
||||||
messages ChatMessage[]
|
messages ChatMessage[]
|
||||||
|
chatParticipations ChatParticipant[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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).
|
||||||
@@ -102,6 +103,7 @@ model Wahl {
|
|||||||
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||||
workshops Workshop[]
|
workshops Workshop[]
|
||||||
teilnehmer Teilnehmer[]
|
teilnehmer Teilnehmer[]
|
||||||
|
forceZuteilungen ForceZuteilung[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Workshop {
|
model Workshop {
|
||||||
@@ -109,9 +111,11 @@ model Workshop {
|
|||||||
wahlId String
|
wahlId String
|
||||||
name String
|
name String
|
||||||
kapazitaet Int
|
kapazitaet Int
|
||||||
|
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[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A participant's submitted choices for a Wahl.
|
/// A participant's submitted choices for a Wahl.
|
||||||
@@ -125,20 +129,34 @@ model Teilnehmer {
|
|||||||
wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade)
|
wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade)
|
||||||
guestAccount GuestAccount @relation(fields: [guestAccountId], references: [id], onDelete: Cascade)
|
guestAccount GuestAccount @relation(fields: [guestAccountId], references: [id], onDelete: Cascade)
|
||||||
zuteilung Zuteilung?
|
zuteilung Zuteilung?
|
||||||
|
forceZuteilung ForceZuteilung?
|
||||||
|
|
||||||
@@unique([wahlId, guestAccountId])
|
@@unique([wahlId, guestAccountId])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of the assignment algorithm (or a manual force-assignment) for one Teilnehmer.
|
/// Manual override set by LT before running the assignment algorithm; takes precedence.
|
||||||
|
model ForceZuteilung {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
wahlId String
|
||||||
|
teilnehmerId String @unique
|
||||||
|
workshopId String
|
||||||
|
|
||||||
|
wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade)
|
||||||
|
teilnehmer Teilnehmer @relation(fields: [teilnehmerId], references: [id], onDelete: Cascade)
|
||||||
|
workshop Workshop @relation(fields: [workshopId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of the assignment algorithm for one Teilnehmer; workshopId is null if unassigned (no capacity left).
|
||||||
model Zuteilung {
|
model Zuteilung {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
teilnehmerId String @unique
|
teilnehmerId String @unique
|
||||||
workshopId String
|
workshopId String?
|
||||||
|
wunschRang Int @default(-1)
|
||||||
isForced Boolean @default(false)
|
isForced Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
teilnehmer Teilnehmer @relation(fields: [teilnehmerId], references: [id], onDelete: Cascade)
|
teilnehmer Teilnehmer @relation(fields: [teilnehmerId], references: [id], onDelete: Cascade)
|
||||||
workshop Workshop @relation(fields: [workshopId], references: [id], onDelete: Cascade)
|
workshop Workshop? @relation(fields: [workshopId], references: [id], onDelete: SetNull)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum FileVisibility {
|
enum FileVisibility {
|
||||||
@@ -175,6 +193,21 @@ model ChatChannel {
|
|||||||
|
|
||||||
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
|
||||||
messages ChatMessage[]
|
messages ChatMessage[]
|
||||||
|
participants ChatParticipant[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicit membership for DIREKT (1:1) channels; other channel types derive
|
||||||
|
/// access from Membership/Gemeinde instead of this table.
|
||||||
|
model ChatParticipant {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
channelId String
|
||||||
|
userId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
channel ChatChannel @relation(fields: [channelId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([channelId, userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model ChatMessage {
|
model ChatMessage {
|
||||||
@@ -189,3 +222,33 @@ model ChatMessage {
|
|||||||
senderUser User? @relation(fields: [senderUserId], references: [id])
|
senderUser User? @relation(fields: [senderUserId], references: [id])
|
||||||
senderGuest GuestAccount? @relation(fields: [senderGuestId], references: [id])
|
senderGuest GuestAccount? @relation(fields: [senderGuestId], references: [id])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum SyncOperation {
|
||||||
|
CREATE
|
||||||
|
UPDATE
|
||||||
|
DELETE
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append-only log of local mutations, replicated to the peer server (local
|
||||||
|
/// <-> cloud). `originId` is the SERVER_ID that made the change, so applying
|
||||||
|
/// an incoming entry never gets re-captured/re-pushed back (no echo loops).
|
||||||
|
model SyncLogEntry {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
sequence Int @default(autoincrement())
|
||||||
|
model String
|
||||||
|
recordId String
|
||||||
|
operation SyncOperation
|
||||||
|
payload Json
|
||||||
|
originId String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-peer replication progress, kept on the side that initiates sync
|
||||||
|
/// (normally the local, on-site server, since it can always dial out to the
|
||||||
|
/// cloud even when the cloud can't reach into the event's local network).
|
||||||
|
model SyncCursor {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
peerId String @unique
|
||||||
|
lastPushedSequence Int @default(0)
|
||||||
|
lastPulledSequence Int @default(0)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,31 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||||
|
import { join } from 'path';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.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 { WahlModule } from './wahl/wahl.module';
|
||||||
|
import { FilesModule } from './files/files.module';
|
||||||
|
import { ChatModule } from './chat/chat.module';
|
||||||
|
import { SyncModule } from './sync/sync.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({ isGlobal: true }),
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
|
// Serves the plain static web client from ../client/web; the REST API
|
||||||
|
// lives under /api (see main.ts) so it never collides with these routes.
|
||||||
|
ServeStaticModule.forRoot({
|
||||||
|
rootPath: join(__dirname, '..', '..', 'client', 'web'),
|
||||||
|
exclude: ['/api*'],
|
||||||
|
}),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
SyncModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
KcModule,
|
KcModule,
|
||||||
|
WahlModule,
|
||||||
|
FilesModule,
|
||||||
|
ChatModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { PassportModule } from '@nestjs/passport';
|
|||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { GuestAuthService } from './guest-auth.service';
|
import { GuestAuthService } from './guest-auth.service';
|
||||||
import { AuthentikStrategy } from './authentik.strategy';
|
import { AuthentikStrategy } from './authentik.strategy';
|
||||||
|
import { GuestJwtStrategy } from './guest-jwt.strategy';
|
||||||
|
import { TokenVerificationService } from './token-verification.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -18,6 +20,7 @@ import { AuthentikStrategy } from './authentik.strategy';
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [GuestAuthService, AuthentikStrategy],
|
providers: [GuestAuthService, AuthentikStrategy, GuestJwtStrategy, TokenVerificationService],
|
||||||
|
exports: [TokenVerificationService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import { Role } from '../common/role.enum';
|
import { Role } from '../common/role.enum';
|
||||||
|
import { GuestJwtPayload } from './guest-auth.service';
|
||||||
|
|
||||||
export interface AuthenticatedMembership {
|
export interface AuthenticatedMembership {
|
||||||
kcId: string;
|
kcId: string;
|
||||||
@@ -18,3 +19,8 @@ export interface AuthenticatedUser {
|
|||||||
export interface AuthenticatedRequest extends Request {
|
export interface AuthenticatedRequest extends Request {
|
||||||
user?: AuthenticatedUser;
|
user?: AuthenticatedUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shape attached to req.user by GuestJwtStrategy for guest/Konfi-authenticated routes.
|
||||||
|
export interface GuestAuthenticatedRequest extends Request {
|
||||||
|
user?: GuestJwtPayload;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { SyncOperation } from '@prisma/client';
|
||||||
import { PrismaClient } from '../prisma/prisma.module';
|
import { PrismaClient } from '../prisma/prisma.module';
|
||||||
|
import { SyncService } from '../sync/sync.service';
|
||||||
|
|
||||||
export interface GuestJwtPayload {
|
export interface GuestJwtPayload {
|
||||||
guestId: string;
|
guestId: string;
|
||||||
@@ -15,6 +17,7 @@ export class GuestAuthService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaClient,
|
private readonly prisma: PrismaClient,
|
||||||
private readonly jwt: JwtService,
|
private readonly jwt: JwtService,
|
||||||
|
private readonly sync: SyncService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createGuest(
|
async createGuest(
|
||||||
@@ -30,6 +33,7 @@ export class GuestAuthService {
|
|||||||
const guest = await this.prisma.guestAccount.create({
|
const guest = await this.prisma.guestAccount.create({
|
||||||
data: { kcId: kc.id, firstName, lastName },
|
data: { kcId: kc.id, firstName, lastName },
|
||||||
});
|
});
|
||||||
|
await this.sync.capture('GuestAccount', SyncOperation.CREATE, guest.id, guest);
|
||||||
|
|
||||||
const payload: GuestJwtPayload = {
|
const payload: GuestJwtPayload = {
|
||||||
guestId: guest.id,
|
guestId: guest.id,
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { GuestJwtPayload } from './guest-auth.service';
|
||||||
|
|
||||||
|
/// Verifies the local JWT issued to guests/Konfis by GuestAuthService.
|
||||||
|
/// Kept separate from AuthentikStrategy since guests are never Authentik-backed.
|
||||||
|
@Injectable()
|
||||||
|
export class GuestJwtStrategy extends PassportStrategy(Strategy, 'guest') {
|
||||||
|
constructor(config: ConfigService) {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
secretOrKey: config.getOrThrow<string>('GUEST_JWT_SECRET'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
validate(payload: GuestJwtPayload): GuestJwtPayload {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import * as jwt from 'jsonwebtoken';
|
||||||
|
import * as jwksRsa from 'jwks-rsa';
|
||||||
|
import { PrismaClient } from '../prisma/prisma.module';
|
||||||
|
import { AuthenticatedUser } from './authenticated-request';
|
||||||
|
import { GuestJwtPayload } from './guest-auth.service';
|
||||||
|
|
||||||
|
/// Verifies raw bearer tokens outside the HTTP/passport pipeline, needed for
|
||||||
|
/// the WebSocket handshake where AuthGuard('authentik'|'guest') don't apply.
|
||||||
|
@Injectable()
|
||||||
|
export class TokenVerificationService {
|
||||||
|
private readonly issuerUrl: string;
|
||||||
|
private readonly jwks: jwksRsa.JwksClient;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly prisma: PrismaClient,
|
||||||
|
private readonly guestJwt: JwtService,
|
||||||
|
) {
|
||||||
|
this.issuerUrl = config.getOrThrow<string>('AUTHENTIK_ISSUER_URL');
|
||||||
|
this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyAuthentik(token: string): Promise<AuthenticatedUser> {
|
||||||
|
const decoded = jwt.decode(token, { complete: true });
|
||||||
|
const kid = decoded?.header.kid;
|
||||||
|
if (!kid) {
|
||||||
|
throw new UnauthorizedException('Malformed Authentik token');
|
||||||
|
}
|
||||||
|
const key = await this.jwks.getSigningKey(kid);
|
||||||
|
const payload = jwt.verify(token, key.getPublicKey(), {
|
||||||
|
issuer: this.issuerUrl,
|
||||||
|
algorithms: ['RS256'],
|
||||||
|
}) as jwt.JwtPayload;
|
||||||
|
if (!payload.sub) {
|
||||||
|
throw new UnauthorizedException('Authentik token missing subject');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { authentikSub: payload.sub },
|
||||||
|
include: { memberships: true },
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('User not provisioned locally yet');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
userId: user.id,
|
||||||
|
authentikSub: user.authentikSub,
|
||||||
|
email: user.email,
|
||||||
|
memberships: user.memberships.map((m) => ({
|
||||||
|
kcId: m.kcId,
|
||||||
|
gemeindeId: m.gemeindeId,
|
||||||
|
role: m.role,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyGuest(token: string): Promise<GuestJwtPayload> {
|
||||||
|
return this.guestJwt.verifyAsync<GuestJwtPayload>(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tries Authentik first (team member), then falls back to a guest token.
|
||||||
|
async verifyEither(token: string): Promise<
|
||||||
|
{ kind: 'user'; user: AuthenticatedUser } | { kind: 'guest'; guest: GuestJwtPayload }
|
||||||
|
> {
|
||||||
|
try {
|
||||||
|
return { kind: 'user', user: await this.verifyAuthentik(token) };
|
||||||
|
} catch {
|
||||||
|
return { kind: 'guest', guest: await this.verifyGuest(token) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { AuthenticatedUser } from '../auth/authenticated-request';
|
||||||
|
import { GuestJwtPayload } from '../auth/guest-auth.service';
|
||||||
|
import { ChatCaller } from './chat.service';
|
||||||
|
|
||||||
|
function isGuestPayload(user: unknown): user is GuestJwtPayload {
|
||||||
|
return !!user && typeof user === 'object' && 'guestId' in user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// req.user is either an AuthenticatedUser (Authentik) or a GuestJwtPayload,
|
||||||
|
/// depending on which strategy AuthGuard(['authentik','guest']) picked.
|
||||||
|
export function resolveChatCaller(user: AuthenticatedUser | GuestJwtPayload): ChatCaller {
|
||||||
|
return isGuestPayload(user) ? { kind: 'guest', guest: user } : { kind: 'user', user };
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Body, Controller, Get, Param, Post, Req, UseGuards } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { ChatService } from './chat.service';
|
||||||
|
import { CreateChannelDto } from './dto/create-channel.dto';
|
||||||
|
import { CreateDirectChannelDto } from './dto/create-direct-channel.dto';
|
||||||
|
import { Roles } from '../common/roles.decorator';
|
||||||
|
import { RolesGuard } from '../common/roles.guard';
|
||||||
|
import { Role } from '../common/role.enum';
|
||||||
|
import { AuthenticatedRequest } from '../auth/authenticated-request';
|
||||||
|
import { GuestJwtPayload } from '../auth/guest-auth.service';
|
||||||
|
import { resolveChatCaller } from './caller.util';
|
||||||
|
|
||||||
|
type ChatRequest = AuthenticatedRequest & { user?: AuthenticatedRequest['user'] | GuestJwtPayload };
|
||||||
|
|
||||||
|
@Controller('chat')
|
||||||
|
export class ChatController {
|
||||||
|
constructor(private readonly chat: ChatService) {}
|
||||||
|
|
||||||
|
/// Channel administration (Gemeinde-Gruppen, LT-Kanäle, Broadcasts) is Leitungsteam-only.
|
||||||
|
@Post(':kcId/channels')
|
||||||
|
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
||||||
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
|
createChannel(@Param('kcId') kcId: string, @Body() dto: CreateChannelDto) {
|
||||||
|
return this.chat.createChannel(kcId, dto.type, dto.gemeindeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Any two team members of the same KC can start a direct conversation.
|
||||||
|
@Post('direct')
|
||||||
|
@UseGuards(AuthGuard('authentik'))
|
||||||
|
createDirectChannel(@Body() dto: CreateDirectChannelDto, @Req() req: AuthenticatedRequest) {
|
||||||
|
return this.chat.getOrCreateDirectChannel(dto.kcId, req.user!.userId, dto.otherUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':kcId/channels')
|
||||||
|
@UseGuards(AuthGuard(['authentik', 'guest']))
|
||||||
|
listChannels(@Param('kcId') kcId: string, @Req() req: ChatRequest) {
|
||||||
|
return this.chat.listChannelsForCaller(kcId, resolveChatCaller(req.user!));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('channels/:channelId/messages')
|
||||||
|
@UseGuards(AuthGuard(['authentik', 'guest']))
|
||||||
|
listMessages(@Param('channelId') channelId: string, @Req() req: ChatRequest) {
|
||||||
|
return this.chat.listMessages(channelId, resolveChatCaller(req.user!));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import {
|
||||||
|
ConnectedSocket,
|
||||||
|
MessageBody,
|
||||||
|
OnGatewayConnection,
|
||||||
|
OnGatewayDisconnect,
|
||||||
|
SubscribeMessage,
|
||||||
|
WebSocketGateway,
|
||||||
|
} from '@nestjs/websockets';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { IncomingMessage } from 'http';
|
||||||
|
import { WebSocket } from 'ws';
|
||||||
|
import { TokenVerificationService } from '../auth/token-verification.service';
|
||||||
|
import { ChatCaller, ChatService } from './chat.service';
|
||||||
|
|
||||||
|
/// Raw `ws` gateway (no socket.io rooms available), so channel membership is
|
||||||
|
/// tracked manually per connected socket. Auth happens once at handshake via
|
||||||
|
/// a `?token=` query param since passport guards don't run for WS upgrades.
|
||||||
|
@WebSocketGateway({ path: '/chat' })
|
||||||
|
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||||
|
private readonly logger = new Logger(ChatGateway.name);
|
||||||
|
private readonly callers = new WeakMap<WebSocket, ChatCaller>();
|
||||||
|
private readonly rooms = new Map<string, Set<WebSocket>>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly tokenVerification: TokenVerificationService,
|
||||||
|
private readonly chat: ChatService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handleConnection(client: WebSocket, request: IncomingMessage) {
|
||||||
|
const token = new URL(request.url ?? '', 'http://localhost').searchParams.get('token');
|
||||||
|
if (!token) {
|
||||||
|
client.close(4001, 'Missing token');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.callers.set(client, await this.tokenVerification.verifyEither(token));
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`WS auth failed: ${(err as Error).message}`);
|
||||||
|
client.close(4001, 'Unauthorized');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDisconnect(client: WebSocket) {
|
||||||
|
this.callers.delete(client);
|
||||||
|
for (const members of this.rooms.values()) {
|
||||||
|
members.delete(client);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('chat:join')
|
||||||
|
async onJoin(
|
||||||
|
@ConnectedSocket() client: WebSocket,
|
||||||
|
@MessageBody() data: { channelId: string },
|
||||||
|
) {
|
||||||
|
const caller = this.requireCaller(client);
|
||||||
|
await this.chat.assertCanRead(data.channelId, caller);
|
||||||
|
this.roomFor(data.channelId).add(client);
|
||||||
|
return { event: 'chat:joined', data: { channelId: data.channelId } };
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('chat:send')
|
||||||
|
async onSend(
|
||||||
|
@ConnectedSocket() client: WebSocket,
|
||||||
|
@MessageBody() data: { channelId: string; body: string },
|
||||||
|
) {
|
||||||
|
const caller = this.requireCaller(client);
|
||||||
|
const message = await this.chat.sendMessage(data.channelId, caller, data.body);
|
||||||
|
this.broadcast(data.channelId, { event: 'chat:message', data: message });
|
||||||
|
return { event: 'chat:sent', data: { id: message.id } };
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireCaller(client: WebSocket): ChatCaller {
|
||||||
|
const caller = this.callers.get(client);
|
||||||
|
if (!caller) {
|
||||||
|
client.close(4001, 'Unauthorized');
|
||||||
|
throw new Error('Unauthorized WS client');
|
||||||
|
}
|
||||||
|
return caller;
|
||||||
|
}
|
||||||
|
|
||||||
|
private roomFor(channelId: string): Set<WebSocket> {
|
||||||
|
let room = this.rooms.get(channelId);
|
||||||
|
if (!room) {
|
||||||
|
room = new Set();
|
||||||
|
this.rooms.set(channelId, room);
|
||||||
|
}
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
private broadcast(channelId: string, payload: unknown) {
|
||||||
|
const room = this.rooms.get(channelId);
|
||||||
|
if (!room) return;
|
||||||
|
const json = JSON.stringify(payload);
|
||||||
|
for (const socket of room) {
|
||||||
|
if (socket.readyState === socket.OPEN) {
|
||||||
|
socket.send(json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { ChatService } from './chat.service';
|
||||||
|
import { ChatGateway } from './chat.gateway';
|
||||||
|
import { ChatController } from './chat.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule],
|
||||||
|
controllers: [ChatController],
|
||||||
|
providers: [ChatService, ChatGateway],
|
||||||
|
})
|
||||||
|
export class ChatModule {}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { ChatChannelType, Role, SyncOperation } from '@prisma/client';
|
||||||
|
import { PrismaClient } from '../prisma/prisma.module';
|
||||||
|
import { AuthenticatedUser } from '../auth/authenticated-request';
|
||||||
|
import { GuestJwtPayload } from '../auth/guest-auth.service';
|
||||||
|
import { SyncService } from '../sync/sync.service';
|
||||||
|
|
||||||
|
export type ChatCaller =
|
||||||
|
| { kind: 'user'; user: AuthenticatedUser }
|
||||||
|
| { kind: 'guest'; guest: GuestJwtPayload };
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ChatService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaClient,
|
||||||
|
private readonly sync: SyncService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createChannel(kcId: string, type: ChatChannelType, gemeindeId?: string) {
|
||||||
|
const channel = await this.prisma.chatChannel.create({ data: { kcId, type, gemeindeId } });
|
||||||
|
await this.sync.capture('ChatChannel', SyncOperation.CREATE, channel.id, channel);
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOrCreateDirectChannel(kcId: string, userAId: string, userBId: string) {
|
||||||
|
const existing = await this.prisma.chatChannel.findFirst({
|
||||||
|
where: {
|
||||||
|
kcId,
|
||||||
|
type: ChatChannelType.DIREKT,
|
||||||
|
AND: [
|
||||||
|
{ participants: { some: { userId: userAId } } },
|
||||||
|
{ participants: { some: { userId: userBId } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (existing) return existing;
|
||||||
|
const channel = await this.prisma.chatChannel.create({
|
||||||
|
data: {
|
||||||
|
kcId,
|
||||||
|
type: ChatChannelType.DIREKT,
|
||||||
|
participants: { create: [{ userId: userAId }, { userId: userBId }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.sync.capture('ChatChannel', SyncOperation.CREATE, channel.id, channel);
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listChannelsForCaller(kcId: string, caller: ChatCaller) {
|
||||||
|
if (caller.kind === 'guest') {
|
||||||
|
return this.prisma.chatChannel.findMany({
|
||||||
|
where: { kcId, type: ChatChannelType.BROADCAST },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const { user } = caller;
|
||||||
|
const isLt = user.memberships.some((m) => m.role === Role.LEITUNGSTEAM);
|
||||||
|
if (isLt) {
|
||||||
|
return this.prisma.chatChannel.findMany({ where: { kcId } });
|
||||||
|
}
|
||||||
|
const gemeindeIds = user.memberships
|
||||||
|
.filter((m) => m.kcId === kcId && m.gemeindeId)
|
||||||
|
.map((m) => m.gemeindeId as string);
|
||||||
|
return this.prisma.chatChannel.findMany({
|
||||||
|
where: {
|
||||||
|
kcId,
|
||||||
|
OR: [
|
||||||
|
{ type: ChatChannelType.BROADCAST },
|
||||||
|
{ type: ChatChannelType.GEMEINDE_GRUPPE, gemeindeId: { in: gemeindeIds } },
|
||||||
|
{ type: ChatChannelType.DIREKT, participants: { some: { userId: user.userId } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async assertCanRead(channelId: string, caller: ChatCaller) {
|
||||||
|
return this.getChannelForCallerOrThrow(channelId, caller, 'read');
|
||||||
|
}
|
||||||
|
|
||||||
|
async assertCanWrite(channelId: string, caller: ChatCaller) {
|
||||||
|
return this.getChannelForCallerOrThrow(channelId, caller, 'write');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getChannelForCallerOrThrow(
|
||||||
|
channelId: string,
|
||||||
|
caller: ChatCaller,
|
||||||
|
mode: 'read' | 'write',
|
||||||
|
) {
|
||||||
|
const channel = await this.prisma.chatChannel.findUnique({
|
||||||
|
where: { id: channelId },
|
||||||
|
include: { participants: true },
|
||||||
|
});
|
||||||
|
if (!channel) {
|
||||||
|
throw new NotFoundException('Channel not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (caller.kind === 'guest') {
|
||||||
|
const allowed = channel.type === ChatChannelType.BROADCAST && mode === 'read';
|
||||||
|
if (!allowed) {
|
||||||
|
throw new ForbiddenException('Guests may only read broadcast channels');
|
||||||
|
}
|
||||||
|
if (caller.guest.kcId !== channel.kcId) {
|
||||||
|
throw new ForbiddenException('Guest does not belong to this KC');
|
||||||
|
}
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = caller;
|
||||||
|
const isLt = user.memberships.some((m) => m.role === Role.LEITUNGSTEAM);
|
||||||
|
if (isLt) {
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
if (channel.kcId && !user.memberships.some((m) => m.kcId === channel.kcId)) {
|
||||||
|
throw new ForbiddenException('Not a member of this KC');
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (channel.type) {
|
||||||
|
case ChatChannelType.BROADCAST:
|
||||||
|
if (mode === 'write') {
|
||||||
|
throw new ForbiddenException('Only Leitungsteam may post broadcasts');
|
||||||
|
}
|
||||||
|
return channel;
|
||||||
|
case ChatChannelType.LT_UEBERGREIFEND:
|
||||||
|
throw new ForbiddenException('Leitungsteam-only channel');
|
||||||
|
case ChatChannelType.GEMEINDE_GRUPPE: {
|
||||||
|
const inGemeinde = user.memberships.some(
|
||||||
|
(m) => m.kcId === channel.kcId && m.gemeindeId === channel.gemeindeId,
|
||||||
|
);
|
||||||
|
if (!inGemeinde) {
|
||||||
|
throw new ForbiddenException('Not a member of this Gemeinde');
|
||||||
|
}
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
case ChatChannelType.DIREKT: {
|
||||||
|
const isParticipant = channel.participants.some((p) => p.userId === user.userId);
|
||||||
|
if (!isParticipant) {
|
||||||
|
throw new ForbiddenException('Not a participant of this conversation');
|
||||||
|
}
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new ForbiddenException('Unknown channel type');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(channelId: string, caller: ChatCaller, body: string) {
|
||||||
|
await this.assertCanWrite(channelId, caller);
|
||||||
|
const message = await this.prisma.chatMessage.create({
|
||||||
|
data: {
|
||||||
|
channelId,
|
||||||
|
body,
|
||||||
|
senderUserId: caller.kind === 'user' ? caller.user.userId : null,
|
||||||
|
senderGuestId: caller.kind === 'guest' ? caller.guest.guestId : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.sync.capture('ChatMessage', SyncOperation.CREATE, message.id, message);
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listMessages(channelId: string, caller: ChatCaller) {
|
||||||
|
await this.assertCanRead(channelId, caller);
|
||||||
|
return this.prisma.chatMessage.findMany({
|
||||||
|
where: { channelId },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsEnum, IsOptional, IsString } from 'class-validator';
|
||||||
|
import { ChatChannelType } from '@prisma/client';
|
||||||
|
|
||||||
|
export class CreateChannelDto {
|
||||||
|
@IsEnum(ChatChannelType)
|
||||||
|
type!: ChatChannelType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
gemeindeId?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateDirectChannelDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
kcId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
otherUserId!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsEnum } from 'class-validator';
|
||||||
|
import { FileVisibility } from '@prisma/client';
|
||||||
|
|
||||||
|
export class UploadFileDto {
|
||||||
|
@IsEnum(FileVisibility)
|
||||||
|
visibility!: FileVisibility;
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
UploadedFile,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { Response } from 'express';
|
||||||
|
import { FilesService } from './files.service';
|
||||||
|
import { UploadFileDto } from './dto/upload-file.dto';
|
||||||
|
import { Roles } from '../common/roles.decorator';
|
||||||
|
import { RolesGuard } from '../common/roles.guard';
|
||||||
|
import { Role } from '../common/role.enum';
|
||||||
|
import { AuthenticatedRequest } from '../auth/authenticated-request';
|
||||||
|
import { GuestJwtPayload } from '../auth/guest-auth.service';
|
||||||
|
import { allowedVisibilitiesForUser, GUEST_ALLOWED_VISIBILITIES } from './visibility.util';
|
||||||
|
|
||||||
|
type FileCallerRequest = AuthenticatedRequest & { user?: AuthenticatedRequest['user'] | GuestJwtPayload };
|
||||||
|
|
||||||
|
function isGuest(user: unknown): user is GuestJwtPayload {
|
||||||
|
return !!user && typeof user === 'object' && 'guestId' in user;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('files')
|
||||||
|
export class FilesController {
|
||||||
|
constructor(private readonly files: FilesService) {}
|
||||||
|
|
||||||
|
/// Only the Leitungsteam uploads files (per KC), tagged with a visibility tier.
|
||||||
|
@Post(':kcId')
|
||||||
|
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
||||||
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
upload(
|
||||||
|
@Param('kcId') kcId: string,
|
||||||
|
@Body() dto: UploadFileDto,
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
) {
|
||||||
|
return this.files.upload(kcId, dto.visibility, file.originalname, file.buffer, req.user!.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':kcId')
|
||||||
|
@UseGuards(AuthGuard(['authentik', 'guest']))
|
||||||
|
list(@Param('kcId') kcId: string, @Req() req: FileCallerRequest) {
|
||||||
|
const allowed = isGuest(req.user)
|
||||||
|
? GUEST_ALLOWED_VISIBILITIES
|
||||||
|
: allowedVisibilitiesForUser(req.user!, kcId);
|
||||||
|
return this.files.listForCaller(kcId, allowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('download/:fileId')
|
||||||
|
@UseGuards(AuthGuard(['authentik', 'guest']))
|
||||||
|
async download(
|
||||||
|
@Param('fileId') fileId: string,
|
||||||
|
@Req() req: FileCallerRequest,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
const meta = await this.files.getFileOrThrow(fileId);
|
||||||
|
const allowed = isGuest(req.user)
|
||||||
|
? GUEST_ALLOWED_VISIBILITIES
|
||||||
|
: allowedVisibilitiesForUser(req.user!, meta.kcId);
|
||||||
|
const { file, data } = await this.files.downloadForCaller(fileId, allowed);
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${file.filename}"`);
|
||||||
|
res.send(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { FilesService } from './files.service';
|
||||||
|
import { FilesController } from './files.controller';
|
||||||
|
import { STORAGE_PROVIDER } from './storage/storage-provider';
|
||||||
|
import { WebDavStorageProvider } from './storage/webdav-storage.provider';
|
||||||
|
import { S3StorageProvider } from './storage/s3-storage.provider';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [FilesController],
|
||||||
|
providers: [
|
||||||
|
FilesService,
|
||||||
|
{
|
||||||
|
// Nextcloud (WebDAV) is the default target; STORAGE_PROVIDER=s3 switches to S3-compatible storage.
|
||||||
|
provide: STORAGE_PROVIDER,
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) =>
|
||||||
|
config.get<string>('STORAGE_PROVIDER') === 's3'
|
||||||
|
? new S3StorageProvider(config)
|
||||||
|
: new WebDavStorageProvider(config),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class FilesModule {}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { FileVisibility, SyncOperation } from '@prisma/client';
|
||||||
|
import { PrismaClient } from '../prisma/prisma.module';
|
||||||
|
import { STORAGE_PROVIDER, StorageProvider } from './storage/storage-provider';
|
||||||
|
import { SyncService } from '../sync/sync.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FilesService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaClient,
|
||||||
|
@Inject(STORAGE_PROVIDER) private readonly storage: StorageProvider,
|
||||||
|
private readonly sync: SyncService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async upload(
|
||||||
|
kcId: string,
|
||||||
|
visibility: FileVisibility,
|
||||||
|
filename: string,
|
||||||
|
data: Buffer,
|
||||||
|
uploadedById: string,
|
||||||
|
) {
|
||||||
|
const storageKey = await this.storage.upload(kcId, filename, data);
|
||||||
|
const file = await this.prisma.file.create({
|
||||||
|
data: { kcId, storageKey, filename, visibility, uploadedById },
|
||||||
|
});
|
||||||
|
// Note: only metadata is replicated here; storageKey only resolves if
|
||||||
|
// local and cloud share the same Nextcloud/S3 backend (see sync docs).
|
||||||
|
await this.sync.capture('File', SyncOperation.CREATE, file.id, file);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
listForCaller(kcId: string, allowedVisibilities: FileVisibility[]) {
|
||||||
|
return this.prisma.file.findMany({
|
||||||
|
where: { kcId, visibility: { in: allowedVisibilities } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadForCaller(fileId: string, allowedVisibilities: FileVisibility[]) {
|
||||||
|
const file = await this.getFileOrThrow(fileId);
|
||||||
|
if (!allowedVisibilities.includes(file.visibility)) {
|
||||||
|
throw new ForbiddenException('Not permitted to access this file');
|
||||||
|
}
|
||||||
|
const data = await this.storage.download(file.storageKey);
|
||||||
|
return { file, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
getFileOrThrow(fileId: string) {
|
||||||
|
return this.prisma.file.findUniqueOrThrow({ where: { id: fileId } }).catch(() => {
|
||||||
|
throw new NotFoundException('File not found');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import {
|
||||||
|
DeleteObjectCommand,
|
||||||
|
GetObjectCommand,
|
||||||
|
PutObjectCommand,
|
||||||
|
S3Client,
|
||||||
|
} from '@aws-sdk/client-s3';
|
||||||
|
import { StorageProvider } from './storage-provider';
|
||||||
|
|
||||||
|
/// S3-compatible object storage (AWS S3, MinIO, etc.).
|
||||||
|
@Injectable()
|
||||||
|
export class S3StorageProvider implements StorageProvider {
|
||||||
|
private readonly client: S3Client;
|
||||||
|
private readonly bucket: string;
|
||||||
|
|
||||||
|
constructor(config: ConfigService) {
|
||||||
|
this.bucket = config.getOrThrow<string>('S3_BUCKET');
|
||||||
|
this.client = new S3Client({
|
||||||
|
region: config.get<string>('S3_REGION') ?? 'auto',
|
||||||
|
endpoint: config.get<string>('S3_ENDPOINT'),
|
||||||
|
forcePathStyle: config.get<string>('S3_FORCE_PATH_STYLE') === 'true',
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: config.getOrThrow<string>('S3_ACCESS_KEY_ID'),
|
||||||
|
secretAccessKey: config.getOrThrow<string>('S3_SECRET_ACCESS_KEY'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async upload(kcId: string, filename: string, data: Buffer): Promise<string> {
|
||||||
|
const storageKey = `${kcId}/${randomUUID()}-${filename}`;
|
||||||
|
await this.client.send(
|
||||||
|
new PutObjectCommand({ Bucket: this.bucket, Key: storageKey, Body: data }),
|
||||||
|
);
|
||||||
|
return storageKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
async download(storageKey: string): Promise<Buffer> {
|
||||||
|
const result = await this.client.send(
|
||||||
|
new GetObjectCommand({ Bucket: this.bucket, Key: storageKey }),
|
||||||
|
);
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
for await (const chunk of result.Body as AsyncIterable<Uint8Array>) {
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(storageKey: string): Promise<void> {
|
||||||
|
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: storageKey }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/// Abstraction over the external file storage backend (Nextcloud via WebDAV,
|
||||||
|
/// or S3-compatible object storage). Implementations only need to move raw
|
||||||
|
/// bytes; visibility/ownership metadata lives in the `File` Prisma model.
|
||||||
|
export interface StorageProvider {
|
||||||
|
upload(kcId: string, filename: string, data: Buffer): Promise<string>;
|
||||||
|
download(storageKey: string): Promise<Buffer>;
|
||||||
|
delete(storageKey: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STORAGE_PROVIDER = Symbol('STORAGE_PROVIDER');
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { createClient, WebDAVClient } from 'webdav';
|
||||||
|
import { StorageProvider } from './storage-provider';
|
||||||
|
|
||||||
|
/// Nextcloud (or any WebDAV server) as file storage backend.
|
||||||
|
@Injectable()
|
||||||
|
export class WebDavStorageProvider implements StorageProvider {
|
||||||
|
private readonly client: WebDAVClient;
|
||||||
|
|
||||||
|
constructor(config: ConfigService) {
|
||||||
|
this.client = createClient(config.getOrThrow<string>('WEBDAV_URL'), {
|
||||||
|
username: config.getOrThrow<string>('WEBDAV_USERNAME'),
|
||||||
|
password: config.getOrThrow<string>('WEBDAV_PASSWORD'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async upload(kcId: string, filename: string, data: Buffer): Promise<string> {
|
||||||
|
const dir = `/${kcId}`;
|
||||||
|
if (!(await this.client.exists(dir))) {
|
||||||
|
await this.client.createDirectory(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
const storageKey = `${dir}/${randomUUID()}-${filename}`;
|
||||||
|
await this.client.putFileContents(storageKey, data, { overwrite: false });
|
||||||
|
return storageKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
async download(storageKey: string): Promise<Buffer> {
|
||||||
|
const content = await this.client.getFileContents(storageKey);
|
||||||
|
return Buffer.isBuffer(content) ? content : Buffer.from(content as ArrayBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(storageKey: string): Promise<void> {
|
||||||
|
await this.client.deleteFile(storageKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { FileVisibility, Role } from '@prisma/client';
|
||||||
|
import { AuthenticatedUser } from '../auth/authenticated-request';
|
||||||
|
|
||||||
|
/// Maps the caller's role for a given KC to the file visibility tiers they may see.
|
||||||
|
/// LEITUNGSTEAM is global (per RolesGuard convention) and sees everything.
|
||||||
|
export function allowedVisibilitiesForUser(
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
kcId: string,
|
||||||
|
): FileVisibility[] {
|
||||||
|
const isLt = user.memberships.some((m) => m.role === Role.LEITUNGSTEAM);
|
||||||
|
if (isLt) {
|
||||||
|
return [FileVisibility.ALLE, FileVisibility.ALLE_AUSSER_KONFIS, FileVisibility.NUR_LT];
|
||||||
|
}
|
||||||
|
const isTeamMemberForKc = user.memberships.some((m) => m.kcId === kcId);
|
||||||
|
if (isTeamMemberForKc) {
|
||||||
|
return [FileVisibility.ALLE, FileVisibility.ALLE_AUSSER_KONFIS];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GUEST_ALLOWED_VISIBILITIES: FileVisibility[] = [FileVisibility.ALLE];
|
||||||
+10
-3
@@ -1,15 +1,22 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { randomBytes } from 'crypto';
|
import { randomBytes } from 'crypto';
|
||||||
|
import { SyncOperation } from '@prisma/client';
|
||||||
import { PrismaClient } from '../prisma/prisma.module';
|
import { PrismaClient } from '../prisma/prisma.module';
|
||||||
|
import { SyncService } from '../sync/sync.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class KcService {
|
export class KcService {
|
||||||
constructor(private readonly prisma: PrismaClient) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaClient,
|
||||||
|
private readonly sync: SyncService,
|
||||||
|
) {}
|
||||||
|
|
||||||
createKc(name: string) {
|
async createKc(name: string) {
|
||||||
return this.prisma.kc.create({
|
const kc = await this.prisma.kc.create({
|
||||||
data: { name, inviteCode: randomBytes(6).toString('hex') },
|
data: { name, inviteCode: randomBytes(6).toString('hex') },
|
||||||
});
|
});
|
||||||
|
await this.sync.capture('Kc', SyncOperation.CREATE, kc.id, kc);
|
||||||
|
return kc;
|
||||||
}
|
}
|
||||||
|
|
||||||
listKcs() {
|
listKcs() {
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { WsAdapter } from '@nestjs/platform-ws';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
|
app.setGlobalPrefix('api');
|
||||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||||
app.enableCors();
|
app.enableCors();
|
||||||
|
app.useWebSocketAdapter(new WsAdapter(app));
|
||||||
await app.listen(process.env.PORT ?? 3000);
|
await app.listen(process.env.PORT ?? 3000);
|
||||||
}
|
}
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsArray, IsNotEmpty } from 'class-validator';
|
||||||
|
|
||||||
|
export class IngestEntriesDto {
|
||||||
|
@IsArray()
|
||||||
|
@IsNotEmpty()
|
||||||
|
entries!: unknown[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Interval } from '@nestjs/schedule';
|
||||||
|
import { SyncService } from './sync.service';
|
||||||
|
|
||||||
|
/// Periodically pushes/pulls against the configured peer when enabled. Safe
|
||||||
|
/// to fail silently (e.g. no internet at an on-site event) - just retries
|
||||||
|
/// on the next tick.
|
||||||
|
@Injectable()
|
||||||
|
export class SyncSchedulerService {
|
||||||
|
private readonly logger = new Logger(SyncSchedulerService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly sync: SyncService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Interval(30_000)
|
||||||
|
async tick() {
|
||||||
|
if (this.config.get<string>('SYNC_ENABLED') !== 'true') return;
|
||||||
|
const peerUrl = this.config.get<string>('SYNC_PEER_URL');
|
||||||
|
const peerSecret = this.config.get<string>('SYNC_SHARED_SECRET');
|
||||||
|
if (!peerUrl || !peerSecret) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.sync.pushToPeer(peerUrl, peerSecret);
|
||||||
|
await this.sync.pullFromPeer(peerUrl, peerSecret);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.debug(`Sync with peer skipped: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Request } from 'express';
|
||||||
|
|
||||||
|
/// Server-to-server auth for /sync/*: a shared secret header, not a user token.
|
||||||
|
@Injectable()
|
||||||
|
export class SyncSecretGuard implements CanActivate {
|
||||||
|
constructor(private readonly config: ConfigService) {}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
const expected = this.config.getOrThrow<string>('SYNC_SHARED_SECRET');
|
||||||
|
if (request.headers['x-sync-secret'] !== expected) {
|
||||||
|
throw new ForbiddenException('Invalid sync secret');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { SyncService } from './sync.service';
|
||||||
|
import { SyncSecretGuard } from './sync-secret.guard';
|
||||||
|
import { IngestEntriesDto } from './dto/ingest-entries.dto';
|
||||||
|
import { Roles } from '../common/roles.decorator';
|
||||||
|
import { RolesGuard } from '../common/roles.guard';
|
||||||
|
import { Role } from '../common/role.enum';
|
||||||
|
|
||||||
|
@Controller('sync')
|
||||||
|
export class SyncController {
|
||||||
|
constructor(
|
||||||
|
private readonly sync: SyncService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/// Peer pushes its new entries to us.
|
||||||
|
@Post('ingest')
|
||||||
|
@UseGuards(SyncSecretGuard)
|
||||||
|
async ingest(@Body() dto: IngestEntriesDto) {
|
||||||
|
await this.sync.applyIncoming(dto.entries as never);
|
||||||
|
return { applied: dto.entries.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Peer pulls our new entries since their last known sequence.
|
||||||
|
@Get('export')
|
||||||
|
@UseGuards(SyncSecretGuard)
|
||||||
|
async export(@Query('since') since: string) {
|
||||||
|
const entries = await this.sync.getEntriesSince(Number(since) || 0);
|
||||||
|
return { entries };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manual on-demand push+pull against the configured peer (Leitungsteam-only).
|
||||||
|
@Post('trigger')
|
||||||
|
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
||||||
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
|
async trigger() {
|
||||||
|
const peerUrl = this.config.getOrThrow<string>('SYNC_PEER_URL');
|
||||||
|
const peerSecret = this.config.getOrThrow<string>('SYNC_SHARED_SECRET');
|
||||||
|
const pushed = await this.sync.pushToPeer(peerUrl, peerSecret);
|
||||||
|
const pulled = await this.sync.pullFromPeer(peerUrl, peerSecret);
|
||||||
|
return { ...pushed, ...pulled };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
import { SyncService } from './sync.service';
|
||||||
|
import { SyncController } from './sync.controller';
|
||||||
|
import { SyncSchedulerService } from './sync-scheduler.service';
|
||||||
|
|
||||||
|
/// Global so every feature module can inject SyncService to capture its
|
||||||
|
/// mutations without each one importing SyncModule explicitly.
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [ScheduleModule.forRoot()],
|
||||||
|
controllers: [SyncController],
|
||||||
|
providers: [SyncService, SyncSchedulerService],
|
||||||
|
exports: [SyncService],
|
||||||
|
})
|
||||||
|
export class SyncModule {}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { SyncOperation } from '@prisma/client';
|
||||||
|
import { PrismaClient } from '../prisma/prisma.module';
|
||||||
|
|
||||||
|
const SYNCED_MODELS = [
|
||||||
|
'Kc',
|
||||||
|
'Gemeinde',
|
||||||
|
'GuestAccount',
|
||||||
|
'Wahl',
|
||||||
|
'Workshop',
|
||||||
|
'Teilnehmer',
|
||||||
|
'ForceZuteilung',
|
||||||
|
'Zuteilung',
|
||||||
|
'File',
|
||||||
|
'ChatChannel',
|
||||||
|
'ChatMessage',
|
||||||
|
] as const;
|
||||||
|
export type SyncedModel = (typeof SYNCED_MODELS)[number];
|
||||||
|
|
||||||
|
interface IncomingEntry {
|
||||||
|
sequence: number;
|
||||||
|
model: string;
|
||||||
|
recordId: string;
|
||||||
|
operation: SyncOperation;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
originId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replicates mutations between the local (on-site) and cloud server. The
|
||||||
|
/// local server is the sole source of truth while an event is live, so
|
||||||
|
/// incoming entries are applied with simple upserts - no conflict resolution
|
||||||
|
/// is needed by design (see plan doc).
|
||||||
|
@Injectable()
|
||||||
|
export class SyncService {
|
||||||
|
private readonly logger = new Logger(SyncService.name);
|
||||||
|
readonly serverId: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaClient,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {
|
||||||
|
this.serverId = config.getOrThrow<string>('SERVER_ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called by feature services right after a mutation to append it to the replication log.
|
||||||
|
async capture(model: SyncedModel, operation: SyncOperation, recordId: string, payload: object) {
|
||||||
|
await this.prisma.syncLogEntry.create({
|
||||||
|
data: {
|
||||||
|
model,
|
||||||
|
recordId,
|
||||||
|
operation,
|
||||||
|
payload: payload as never,
|
||||||
|
originId: this.serverId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEntriesSince(sequence: number, limit = 500) {
|
||||||
|
return this.prisma.syncLogEntry.findMany({
|
||||||
|
where: { sequence: { gt: sequence } },
|
||||||
|
orderBy: { sequence: 'asc' },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies entries received from a peer; never re-captures them, which is
|
||||||
|
/// what prevents echo loops between the two servers.
|
||||||
|
async applyIncoming(entries: IncomingEntry[]) {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.originId === this.serverId) continue;
|
||||||
|
const delegate = this.delegateFor(entry.model);
|
||||||
|
if (!delegate) {
|
||||||
|
this.logger.warn(`Skipping sync entry for unknown model "${entry.model}"`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (entry.operation === SyncOperation.DELETE) {
|
||||||
|
await delegate.delete({ where: { id: entry.recordId } });
|
||||||
|
} else {
|
||||||
|
await delegate.upsert({
|
||||||
|
where: { id: entry.recordId },
|
||||||
|
create: entry.payload,
|
||||||
|
update: entry.payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to apply sync entry ${entry.model}/${entry.recordId}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async pushToPeer(peerUrl: string, peerSecret: string) {
|
||||||
|
const peerId = new URL(peerUrl).host;
|
||||||
|
const cursor = await this.getOrCreateCursor(peerId);
|
||||||
|
const entries = await this.getEntriesSince(cursor.lastPushedSequence);
|
||||||
|
if (entries.length === 0) return { pushed: 0 };
|
||||||
|
|
||||||
|
const res = await fetch(`${peerUrl}/sync/ingest`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'x-sync-secret': peerSecret },
|
||||||
|
body: JSON.stringify({ entries }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Peer rejected sync push: ${res.status}`);
|
||||||
|
}
|
||||||
|
await this.prisma.syncCursor.update({
|
||||||
|
where: { peerId },
|
||||||
|
data: { lastPushedSequence: entries[entries.length - 1].sequence },
|
||||||
|
});
|
||||||
|
return { pushed: entries.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
async pullFromPeer(peerUrl: string, peerSecret: string) {
|
||||||
|
const peerId = new URL(peerUrl).host;
|
||||||
|
const cursor = await this.getOrCreateCursor(peerId);
|
||||||
|
const res = await fetch(`${peerUrl}/sync/export?since=${cursor.lastPulledSequence}`, {
|
||||||
|
headers: { 'x-sync-secret': peerSecret },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Peer rejected sync pull: ${res.status}`);
|
||||||
|
}
|
||||||
|
const { entries } = (await res.json()) as { entries: IncomingEntry[] };
|
||||||
|
if (entries.length === 0) return { pulled: 0 };
|
||||||
|
|
||||||
|
await this.applyIncoming(entries);
|
||||||
|
await this.prisma.syncCursor.update({
|
||||||
|
where: { peerId },
|
||||||
|
data: { lastPulledSequence: entries[entries.length - 1].sequence },
|
||||||
|
});
|
||||||
|
return { pulled: entries.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getOrCreateCursor(peerId: string) {
|
||||||
|
return this.prisma.syncCursor.upsert({
|
||||||
|
where: { peerId },
|
||||||
|
create: { peerId },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private delegateFor(model: string) {
|
||||||
|
if (!SYNCED_MODELS.includes(model as SyncedModel)) return null;
|
||||||
|
const key = (model.charAt(0).toLowerCase() + model.slice(1)) as keyof PrismaClient;
|
||||||
|
// Generic dispatch across models is inherent to a replication log; each
|
||||||
|
// delegate exposes the same upsert/delete shape we need here.
|
||||||
|
return this.prisma[key] as unknown as {
|
||||||
|
upsert: (args: { where: { id: string }; create: object; update: object }) => Promise<unknown>;
|
||||||
|
delete: (args: { where: { id: string } }) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateForceZuteilungDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
teilnehmerId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
workshopId!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateWahlDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
kcId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
datumsSchluessel!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
teil!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { IsInt, IsNotEmpty, IsString, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateWorkshopDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
kapazitaet!: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
minTeilnehmer: number = 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { ArrayMaxSize, ArrayNotEmpty, IsArray, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
/// Ordered workshop-id preferences, most preferred first (up to 3, matching
|
||||||
|
/// the original plugin's wunsch1..wunsch3).
|
||||||
|
export class SubmitTeilnehmerDto {
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@ArrayMaxSize(3)
|
||||||
|
@IsString({ each: true })
|
||||||
|
prioritaeten!: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { Response } from 'express';
|
||||||
|
import { WahlService } from './wahl.service';
|
||||||
|
import { ZuteilungService } from './zuteilung.service';
|
||||||
|
import { CreateWahlDto } from './dto/create-wahl.dto';
|
||||||
|
import { CreateWorkshopDto } from './dto/create-workshop.dto';
|
||||||
|
import { SubmitTeilnehmerDto } from './dto/submit-teilnehmer.dto';
|
||||||
|
import { CreateForceZuteilungDto } from './dto/create-force-zuteilung.dto';
|
||||||
|
import { Roles } from '../common/roles.decorator';
|
||||||
|
import { RolesGuard } from '../common/roles.guard';
|
||||||
|
import { Role } from '../common/role.enum';
|
||||||
|
import { GuestAuthenticatedRequest } from '../auth/authenticated-request';
|
||||||
|
|
||||||
|
@Controller('wahl')
|
||||||
|
export class WahlController {
|
||||||
|
constructor(
|
||||||
|
private readonly wahl: WahlService,
|
||||||
|
private readonly zuteilung: ZuteilungService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/// Wahl-/Workshop-/Force-Zuteilung-Verwaltung ist ausschließlich Sache des
|
||||||
|
/// Leitungsteams (global über alle KCs, siehe RolesGuard).
|
||||||
|
@Post()
|
||||||
|
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
||||||
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
|
createWahl(@Body() dto: CreateWahlDto) {
|
||||||
|
return this.wahl.createWahl(dto.kcId, dto.name, dto.datumsSchluessel, dto.teil);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
||||||
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
|
listWahlen(@Query('kcId') kcId: string) {
|
||||||
|
return this.wahl.listWahlen(kcId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':wahlId/workshops')
|
||||||
|
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
||||||
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
|
createWorkshop(@Param('wahlId') wahlId: string, @Body() dto: CreateWorkshopDto) {
|
||||||
|
return this.wahl.createWorkshop(wahlId, dto.name, dto.kapazitaet, dto.minTeilnehmer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':wahlId/workshops')
|
||||||
|
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
||||||
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
|
listWorkshops(@Param('wahlId') wahlId: string) {
|
||||||
|
return this.wahl.listWorkshops(wahlId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':wahlId/force-zuteilung')
|
||||||
|
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
||||||
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
|
createForceZuteilung(
|
||||||
|
@Param('wahlId') wahlId: string,
|
||||||
|
@Body() dto: CreateForceZuteilungDto,
|
||||||
|
) {
|
||||||
|
return this.wahl.createForceZuteilung(wahlId, dto.teilnehmerId, dto.workshopId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Guests submit their own workshop preferences (guest JWT, not Authentik).
|
||||||
|
@Post(':wahlId/teilnehmer')
|
||||||
|
@UseGuards(AuthGuard('guest'))
|
||||||
|
submitTeilnehmer(
|
||||||
|
@Param('wahlId') wahlId: string,
|
||||||
|
@Body() dto: SubmitTeilnehmerDto,
|
||||||
|
@Req() req: GuestAuthenticatedRequest,
|
||||||
|
) {
|
||||||
|
const guest = req.user!;
|
||||||
|
return this.wahl.submitTeilnehmer(wahlId, guest.guestId, guest.kcId, dto.prioritaeten);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':wahlId/zuteilung/run')
|
||||||
|
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
||||||
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
|
runZuteilung(@Param('wahlId') wahlId: string) {
|
||||||
|
return this.zuteilung.run(wahlId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':wahlId/zuteilung')
|
||||||
|
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
||||||
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
|
getZuteilung(@Param('wahlId') wahlId: string) {
|
||||||
|
return this.zuteilung.getResults(wahlId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':wahlId/zuteilung/csv')
|
||||||
|
@UseGuards(AuthGuard('authentik'), RolesGuard)
|
||||||
|
@Roles(Role.LEITUNGSTEAM)
|
||||||
|
async exportCsv(@Param('wahlId') wahlId: string, @Res() res: Response) {
|
||||||
|
const csv = await this.zuteilung.exportCsv(wahlId);
|
||||||
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="zuteilung-${wahlId}.csv"`);
|
||||||
|
res.send(csv);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { WahlService } from './wahl.service';
|
||||||
|
import { ZuteilungService } from './zuteilung.service';
|
||||||
|
import { WahlController } from './wahl.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [WahlService, ZuteilungService],
|
||||||
|
controllers: [WahlController],
|
||||||
|
})
|
||||||
|
export class WahlModule {}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { SyncOperation } from '@prisma/client';
|
||||||
|
import { PrismaClient } from '../prisma/prisma.module';
|
||||||
|
import { SyncService } from '../sync/sync.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WahlService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaClient,
|
||||||
|
private readonly sync: SyncService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createWahl(kcId: string, name: string, datumsSchluessel: string, teil: string) {
|
||||||
|
const wahl = await this.prisma.wahl.create({
|
||||||
|
data: { kcId, name, datumsSchluessel, teil },
|
||||||
|
});
|
||||||
|
await this.sync.capture('Wahl', SyncOperation.CREATE, wahl.id, wahl);
|
||||||
|
return wahl;
|
||||||
|
}
|
||||||
|
|
||||||
|
listWahlen(kcId: string) {
|
||||||
|
return this.prisma.wahl.findMany({ where: { kcId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async createWorkshop(
|
||||||
|
wahlId: string,
|
||||||
|
name: string,
|
||||||
|
kapazitaet: number,
|
||||||
|
minTeilnehmer: number,
|
||||||
|
) {
|
||||||
|
await this.getWahlOrThrow(wahlId);
|
||||||
|
const workshop = await this.prisma.workshop.create({
|
||||||
|
data: { wahlId, name, kapazitaet, minTeilnehmer },
|
||||||
|
});
|
||||||
|
await this.sync.capture('Workshop', SyncOperation.CREATE, workshop.id, workshop);
|
||||||
|
return workshop;
|
||||||
|
}
|
||||||
|
|
||||||
|
listWorkshops(wahlId: string) {
|
||||||
|
return this.prisma.workshop.findMany({ where: { wahlId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async createForceZuteilung(wahlId: string, teilnehmerId: string, workshopId: string) {
|
||||||
|
const [teilnehmer, workshop] = await Promise.all([
|
||||||
|
this.prisma.teilnehmer.findUnique({ where: { id: teilnehmerId } }),
|
||||||
|
this.prisma.workshop.findUnique({ where: { id: workshopId } }),
|
||||||
|
]);
|
||||||
|
if (!teilnehmer || teilnehmer.wahlId !== wahlId) {
|
||||||
|
throw new NotFoundException('Teilnehmer not found in this Wahl');
|
||||||
|
}
|
||||||
|
if (!workshop || workshop.wahlId !== wahlId) {
|
||||||
|
throw new NotFoundException('Workshop not found in this Wahl');
|
||||||
|
}
|
||||||
|
const force = await this.prisma.forceZuteilung.upsert({
|
||||||
|
where: { teilnehmerId },
|
||||||
|
create: { wahlId, teilnehmerId, workshopId },
|
||||||
|
update: { workshopId },
|
||||||
|
});
|
||||||
|
await this.sync.capture('ForceZuteilung', SyncOperation.UPDATE, force.id, force);
|
||||||
|
return force;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Guests submit their own choices; only allowed for their own KC and while the Wahl is open.
|
||||||
|
async submitTeilnehmer(
|
||||||
|
wahlId: string,
|
||||||
|
guestAccountId: string,
|
||||||
|
guestKcId: string,
|
||||||
|
prioritaeten: string[],
|
||||||
|
) {
|
||||||
|
const wahl = await this.getWahlOrThrow(wahlId);
|
||||||
|
if (wahl.kcId !== guestKcId) {
|
||||||
|
throw new ForbiddenException('Guest does not belong to this KC');
|
||||||
|
}
|
||||||
|
if (!wahl.isOpen) {
|
||||||
|
throw new ForbiddenException('Wahl is closed');
|
||||||
|
}
|
||||||
|
const teilnehmer = await this.prisma.teilnehmer.upsert({
|
||||||
|
where: { wahlId_guestAccountId: { wahlId, guestAccountId } },
|
||||||
|
create: { wahlId, guestAccountId, prioritaeten },
|
||||||
|
update: { prioritaeten },
|
||||||
|
});
|
||||||
|
await this.sync.capture('Teilnehmer', SyncOperation.UPDATE, teilnehmer.id, teilnehmer);
|
||||||
|
return teilnehmer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getWahlOrThrow(wahlId: string) {
|
||||||
|
const wahl = await this.prisma.wahl.findUnique({ where: { id: wahlId } });
|
||||||
|
if (!wahl) {
|
||||||
|
throw new NotFoundException('Wahl not found');
|
||||||
|
}
|
||||||
|
return wahl;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { SyncOperation, Teilnehmer } from '@prisma/client';
|
||||||
|
import { PrismaClient } from '../prisma/prisma.module';
|
||||||
|
import { SyncService } from '../sync/sync.service';
|
||||||
|
|
||||||
|
type TeilnehmerRow = Teilnehmer;
|
||||||
|
|
||||||
|
interface ZuteilungResult {
|
||||||
|
workshopId: string | null;
|
||||||
|
wunschRang: number;
|
||||||
|
isForced: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Port of the WP plugin's kc_run_zuteilung: force-assignments first, then up
|
||||||
|
/// to 3 wish rounds, then random fill of the rest, then a consolidation pass
|
||||||
|
/// that dissolves workshops which stayed below their minTeilnehmer.
|
||||||
|
@Injectable()
|
||||||
|
export class ZuteilungService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaClient,
|
||||||
|
private readonly sync: SyncService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async run(wahlId: string) {
|
||||||
|
const wahl = await this.prisma.wahl.findUnique({ where: { id: wahlId } });
|
||||||
|
if (!wahl) {
|
||||||
|
throw new NotFoundException('Wahl not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [workshops, teilnehmerList, forces] = await Promise.all([
|
||||||
|
this.prisma.workshop.findMany({ where: { wahlId } }),
|
||||||
|
this.prisma.teilnehmer.findMany({ where: { wahlId } }),
|
||||||
|
this.prisma.forceZuteilung.findMany({ where: { wahlId } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await this.prisma.zuteilung.deleteMany({
|
||||||
|
where: { teilnehmer: { wahlId } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const caps = new Map(workshops.map((w) => [w.id, w.kapazitaet]));
|
||||||
|
const results = new Map<string, ZuteilungResult>();
|
||||||
|
|
||||||
|
const tryAssign = (
|
||||||
|
teilnehmerId: string,
|
||||||
|
workshopId: string,
|
||||||
|
wunschRang: number,
|
||||||
|
isForced: boolean,
|
||||||
|
): boolean => {
|
||||||
|
const cap = caps.get(workshopId) ?? 0;
|
||||||
|
if (cap <= 0) return false;
|
||||||
|
caps.set(workshopId, cap - 1);
|
||||||
|
results.set(teilnehmerId, { workshopId, wunschRang, isForced });
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1) Force-Zuteilungen haben Vorrang
|
||||||
|
for (const force of forces) {
|
||||||
|
const teilnehmer = teilnehmerList.find((t) => t.id === force.teilnehmerId);
|
||||||
|
if (!teilnehmer || results.has(teilnehmer.id)) continue;
|
||||||
|
tryAssign(teilnehmer.id, force.workshopId, 0, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Verbleibende Teilnehmer mischen
|
||||||
|
let remaining = shuffle(teilnehmerList.filter((t) => !results.has(t.id)));
|
||||||
|
|
||||||
|
// 3) Wunschrunden 1..3
|
||||||
|
for (let wunschRang = 1; wunschRang <= 3; wunschRang++) {
|
||||||
|
const notAssigned: TeilnehmerRow[] = [];
|
||||||
|
for (const teilnehmer of remaining) {
|
||||||
|
const wunsch = readPrioritaeten(teilnehmer.prioritaeten)[wunschRang - 1];
|
||||||
|
if (!wunsch || !tryAssign(teilnehmer.id, wunsch, wunschRang, false)) {
|
||||||
|
notAssigned.push(teilnehmer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remaining = shuffle(notAssigned);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Rest zufällig auf freie Workshops verteilen, sonst unzugeteilt
|
||||||
|
for (const teilnehmer of remaining) {
|
||||||
|
const freeWorkshopId = pickRandomFreeWorkshop(caps);
|
||||||
|
if (freeWorkshopId) {
|
||||||
|
tryAssign(teilnehmer.id, freeWorkshopId, 99, false);
|
||||||
|
} else {
|
||||||
|
results.set(teilnehmer.id, { workshopId: null, wunschRang: -1, isForced: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) Konsolidierung: Workshops unter minTeilnehmer auflösen und neu verteilen
|
||||||
|
consolidateUnderfilledWorkshops(workshops, teilnehmerList, results, caps);
|
||||||
|
|
||||||
|
await this.prisma.zuteilung.createMany({
|
||||||
|
data: Array.from(results.entries()).map(([teilnehmerId, r]) => ({
|
||||||
|
teilnehmerId,
|
||||||
|
workshopId: r.workshopId,
|
||||||
|
wunschRang: r.wunschRang,
|
||||||
|
isForced: r.isForced,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const created = await this.prisma.zuteilung.findMany({ where: { teilnehmer: { wahlId } } });
|
||||||
|
for (const row of created) {
|
||||||
|
await this.sync.capture('Zuteilung', SyncOperation.CREATE, row.id, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getResults(wahlId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getResults(wahlId: string) {
|
||||||
|
return this.prisma.zuteilung.findMany({
|
||||||
|
where: { teilnehmer: { wahlId } },
|
||||||
|
include: {
|
||||||
|
teilnehmer: { include: { guestAccount: true } },
|
||||||
|
workshop: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportCsv(wahlId: string): Promise<string> {
|
||||||
|
const rows = await this.getResults(wahlId);
|
||||||
|
const header = 'Vorname;Nachname;Workshop;WunschRang;Erzwungen';
|
||||||
|
const lines = rows.map((r) => {
|
||||||
|
const vorname = r.teilnehmer.guestAccount.firstName;
|
||||||
|
const nachname = r.teilnehmer.guestAccount.lastName;
|
||||||
|
const workshop = r.workshop?.name ?? 'UNZUGETEILT';
|
||||||
|
return `${vorname};${nachname};${workshop};${r.wunschRang};${r.isForced ? 'ja' : 'nein'}`;
|
||||||
|
});
|
||||||
|
return [header, ...lines].join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dissolves workshops that got some participants but stayed below their
|
||||||
|
/// minTeilnehmer, freeing their capacity and reassigning displaced
|
||||||
|
/// participants (preferring their remaining wishes, then any free workshop).
|
||||||
|
function consolidateUnderfilledWorkshops(
|
||||||
|
workshops: { id: string; minTeilnehmer: number }[],
|
||||||
|
teilnehmerList: TeilnehmerRow[],
|
||||||
|
results: Map<string, ZuteilungResult>,
|
||||||
|
caps: Map<string, number>,
|
||||||
|
) {
|
||||||
|
const countByWorkshop = new Map<string, number>();
|
||||||
|
for (const r of results.values()) {
|
||||||
|
if (r.workshopId) {
|
||||||
|
countByWorkshop.set(r.workshopId, (countByWorkshop.get(r.workshopId) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const failing = workshops.filter((w) => {
|
||||||
|
const count = countByWorkshop.get(w.id) ?? 0;
|
||||||
|
return count > 0 && w.minTeilnehmer > 0 && count < w.minTeilnehmer;
|
||||||
|
});
|
||||||
|
if (failing.length === 0) return;
|
||||||
|
|
||||||
|
const failingIds = new Set(failing.map((w) => w.id));
|
||||||
|
const toReassign: string[] = [];
|
||||||
|
for (const [teilnehmerId, r] of results.entries()) {
|
||||||
|
if (r.workshopId && failingIds.has(r.workshopId)) {
|
||||||
|
caps.set(r.workshopId, (caps.get(r.workshopId) ?? 0) + 1);
|
||||||
|
toReassign.push(teilnehmerId);
|
||||||
|
results.delete(teilnehmerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assign = (teilnehmerId: string, workshopId: string, wunschRang: number): boolean => {
|
||||||
|
const cap = caps.get(workshopId) ?? 0;
|
||||||
|
if (cap <= 0) return false;
|
||||||
|
caps.set(workshopId, cap - 1);
|
||||||
|
results.set(teilnehmerId, { workshopId, wunschRang, isForced: false });
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const teilnehmerId of toReassign) {
|
||||||
|
const teilnehmer = teilnehmerList.find((t) => t.id === teilnehmerId);
|
||||||
|
const wuensche = teilnehmer ? readPrioritaeten(teilnehmer.prioritaeten) : [];
|
||||||
|
let reassigned = false;
|
||||||
|
for (let wunschRang = 1; wunschRang <= wuensche.length; wunschRang++) {
|
||||||
|
const choice = wuensche[wunschRang - 1];
|
||||||
|
if (choice && !failingIds.has(choice) && assign(teilnehmerId, choice, wunschRang)) {
|
||||||
|
reassigned = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!reassigned) {
|
||||||
|
const freeWorkshopId = pickRandomFreeWorkshop(caps, failingIds);
|
||||||
|
if (freeWorkshopId) {
|
||||||
|
assign(teilnehmerId, freeWorkshopId, 99);
|
||||||
|
} else {
|
||||||
|
results.set(teilnehmerId, { workshopId: null, wunschRang: -1, isForced: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPrioritaeten(value: unknown): string[] {
|
||||||
|
return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffle<T>(items: T[]): T[] {
|
||||||
|
const copy = [...items];
|
||||||
|
for (let i = copy.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[copy[i], copy[j]] = [copy[j], copy[i]];
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickRandomFreeWorkshop(caps: Map<string, number>, exclude?: Set<string>): string | null {
|
||||||
|
const free = [...caps.entries()].filter(
|
||||||
|
([id, cap]) => cap > 0 && !(exclude && exclude.has(id)),
|
||||||
|
);
|
||||||
|
if (free.length === 0) return null;
|
||||||
|
return free[Math.floor(Math.random() * free.length)][0];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user