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:
2026-09-09 16:23:45 +02:00
co-authored by Claude Sonnet 5
parent e49eed871c
commit 7aba87368d
47 changed files with 3003 additions and 115 deletions
@@ -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;
}
@@ -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[];
}
+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];
}