feat: Wahl-Phasen, Verantwortliche-Invites, Auth fixes, GRUPPE chat #1

Merged
linus merged 28 commits from feat/backend-phases-0-6 into main 2026-09-12 11:26:16 +00:00
42 changed files with 2648 additions and 78 deletions
Showing only changes of commit 8ec127c0fb - Show all commits
+26
View File
@@ -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"
+42 -3
View File
@@ -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).
+922 -13
View File
File diff suppressed because it is too large Load Diff
+16 -2
View File
@@ -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"
} }
+119 -56
View File
@@ -9,19 +9,19 @@ datasource db {
/// A Konfi-Castle event; the top-level tenant. One instance manages many KCs. /// A Konfi-Castle event; the top-level tenant. One instance manages many KCs.
model Kc { model Kc {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
inviteCode String @unique inviteCode String @unique
isActive Boolean @default(true) isActive Boolean @default(true)
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[]
} }
/// A local congregation/community participating in one Kc. /// A local congregation/community participating in one Kc.
@@ -31,7 +31,7 @@ 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[]
@@ -46,15 +46,16 @@ enum Role {
/// Authentik-backed user (team member with elevated rights). /// Authentik-backed user (team member with elevated rights).
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
authentikSub String @unique authentikSub String @unique
email String @unique email String @unique
firstName String firstName String
lastName String lastName String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
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).
@@ -83,62 +84,79 @@ 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[]
} }
/// 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".
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
isOpen Boolean @default(true) isOpen Boolean @default(true)
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)
workshops Workshop[] workshops Workshop[]
teilnehmer Teilnehmer[] teilnehmer Teilnehmer[]
forceZuteilungen ForceZuteilung[]
} }
model Workshop { model Workshop {
id String @id @default(cuid()) id String @id @default(cuid())
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.
model Teilnehmer { model Teilnehmer {
id String @id @default(cuid()) id String @id @default(cuid())
wahlId String wahlId String
guestAccountId String guestAccountId String
prioritaeten Json prioritaeten Json
createdAt DateTime @default(now()) createdAt DateTime @default(now())
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 {
@@ -173,19 +191,64 @@ model ChatChannel {
gemeindeId String? gemeindeId 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)
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 {
id String @id @default(cuid()) id String @id @default(cuid())
channelId String channelId String
senderUserId String? senderUserId String?
senderGuestId String? senderGuestId String?
body String body String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
channel ChatChannel @relation(fields: [channelId], references: [id], onDelete: Cascade) channel ChatChannel @relation(fields: [channelId], references: [id], onDelete: Cascade)
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)
}
+16
View File
@@ -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 {}
+4 -1
View File
@@ -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 {}
+6
View File
@@ -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;
}
+4
View File
@@ -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,
+21
View File
@@ -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;
}
}
+74
View File
@@ -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) };
}
}
}
+13
View File
@@ -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 };
}
+45
View File
@@ -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!));
}
}
+100
View File
@@ -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);
}
}
}
}
+12
View File
@@ -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 {}
+165
View File
@@ -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' },
});
}
}
+11
View File
@@ -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;
}
+11
View File
@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class CreateDirectChannelDto {
@IsString()
@IsNotEmpty()
kcId!: string;
@IsString()
@IsNotEmpty()
otherUserId!: string;
}
+7
View File
@@ -0,0 +1,7 @@
import { IsEnum } from 'class-validator';
import { FileVisibility } from '@prisma/client';
export class UploadFileDto {
@IsEnum(FileVisibility)
visibility!: FileVisibility;
}
+73
View File
@@ -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);
}
}
+24
View File
@@ -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 {}
+53
View File
@@ -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');
});
}
}
+53
View File
@@ -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 }));
}
}
+10
View File
@@ -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);
}
}
+21
View File
@@ -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
View File
@@ -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() {
+3
View File
@@ -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();
+7
View File
@@ -0,0 +1,7 @@
import { IsArray, IsNotEmpty } from 'class-validator';
export class IngestEntriesDto {
@IsArray()
@IsNotEmpty()
entries!: unknown[];
}
+32
View File
@@ -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}`);
}
}
}
+18
View File
@@ -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;
}
}
+45
View File
@@ -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 };
}
}
+16
View File
@@ -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 {}
+154
View File
@@ -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;
}
+19
View File
@@ -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;
}
+15
View File
@@ -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;
}
+11
View File
@@ -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[];
}
+107
View File
@@ -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);
}
}
+10
View File
@@ -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 {}
+93
View File
@@ -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;
}
}
+212
View File
@@ -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];
}