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:
@@ -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