feat(backend): implement phases 0-6 (auth, kc, wahl, files, chat, sync)
Full NestJS backend for the KC-App platform: - auth: Authentik OIDC resource-server strategy + guest invite-code JWT login, plus TokenVerificationService for the WS handshake path - kc: Leitungsteam-only KC (event) creation/listing - wahl: Wahl/Workshop admin, Force-Zuteilung overrides, ZuteilungService (port of the WP plugin's kc_run_zuteilung), CSV export - files: LT-only upload with visibility tiers; list/download filtered by caller tier; StorageProvider abstraction (WebDAV/Nextcloud default, S3) - chat: Gemeinde group / DM / LT-wide / broadcast channels; REST + raw ws gateway sharing ChatService access rules - sync: append-only SyncLogEntry replication log + local<->cloud push/pull scheduler, shared-secret guarded - common: Role enum, @Roles decorator, KC-scoped RolesGuard (LT global) - serves client/web/ interim static web client under / (API under /api) Typecheck, nest build and boot test pass; needs real Postgres/Authentik/ Nextcloud to run end to end. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,31 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
import { join } from 'path';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { AuthModule } from './auth/auth.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({
|
||||
imports: [
|
||||
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,
|
||||
SyncModule,
|
||||
AuthModule,
|
||||
KcModule,
|
||||
WahlModule,
|
||||
FilesModule,
|
||||
ChatModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { PassportModule } from '@nestjs/passport';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { GuestAuthService } from './guest-auth.service';
|
||||
import { AuthentikStrategy } from './authentik.strategy';
|
||||
import { GuestJwtStrategy } from './guest-jwt.strategy';
|
||||
import { TokenVerificationService } from './token-verification.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -18,6 +20,7 @@ import { AuthentikStrategy } from './authentik.strategy';
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [GuestAuthService, AuthentikStrategy],
|
||||
providers: [GuestAuthService, AuthentikStrategy, GuestJwtStrategy, TokenVerificationService],
|
||||
exports: [TokenVerificationService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Request } from 'express';
|
||||
import { Role } from '../common/role.enum';
|
||||
import { GuestJwtPayload } from './guest-auth.service';
|
||||
|
||||
export interface AuthenticatedMembership {
|
||||
kcId: string;
|
||||
@@ -18,3 +19,8 @@ export interface AuthenticatedUser {
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user?: AuthenticatedUser;
|
||||
}
|
||||
|
||||
/// Shape attached to req.user by GuestJwtStrategy for guest/Konfi-authenticated routes.
|
||||
export interface GuestAuthenticatedRequest extends Request {
|
||||
user?: GuestJwtPayload;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { SyncOperation } from '@prisma/client';
|
||||
import { PrismaClient } from '../prisma/prisma.module';
|
||||
import { SyncService } from '../sync/sync.service';
|
||||
|
||||
export interface GuestJwtPayload {
|
||||
guestId: string;
|
||||
@@ -15,6 +17,7 @@ export class GuestAuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly sync: SyncService,
|
||||
) {}
|
||||
|
||||
async createGuest(
|
||||
@@ -30,6 +33,7 @@ export class GuestAuthService {
|
||||
const guest = await this.prisma.guestAccount.create({
|
||||
data: { kcId: kc.id, firstName, lastName },
|
||||
});
|
||||
await this.sync.capture('GuestAccount', SyncOperation.CREATE, guest.id, guest);
|
||||
|
||||
const payload: GuestJwtPayload = {
|
||||
guestId: guest.id,
|
||||
|
||||
@@ -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 { randomBytes } from 'crypto';
|
||||
import { SyncOperation } from '@prisma/client';
|
||||
import { PrismaClient } from '../prisma/prisma.module';
|
||||
import { SyncService } from '../sync/sync.service';
|
||||
|
||||
@Injectable()
|
||||
export class KcService {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly sync: SyncService,
|
||||
) {}
|
||||
|
||||
createKc(name: string) {
|
||||
return this.prisma.kc.create({
|
||||
async createKc(name: string) {
|
||||
const kc = await this.prisma.kc.create({
|
||||
data: { name, inviteCode: randomBytes(6).toString('hex') },
|
||||
});
|
||||
await this.sync.capture('Kc', SyncOperation.CREATE, kc.id, kc);
|
||||
return kc;
|
||||
}
|
||||
|
||||
listKcs() {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { WsAdapter } from '@nestjs/platform-ws';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.setGlobalPrefix('api');
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||
app.enableCors();
|
||||
app.useWebSocketAdapter(new WsAdapter(app));
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
@@ -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