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
+44 -9
View File
@@ -8,19 +8,54 @@ for the full architecture and phased roadmap.
## Structure ## Structure
- `backend/` — NestJS API (Prisma/PostgreSQL, Authentik OIDC as resource - `backend/` — NestJS API (Prisma/PostgreSQL, Authentik OIDC as resource
server, guest/Konfi local accounts, roles/permissions foundation). See server, guest/Konfi local accounts, roles/permissions foundation, file
[backend/README.md](backend/README.md) for setup. sharing, chat, local/cloud sync). See [backend/README.md](backend/README.md)
- `client/` — planned Flutter app (mobile + web + desktop), not yet for setup. Also serves the web client (see below) directly, so it's the
scaffolded (Flutter is not installed in this environment). single entry point for the web experience.
- `client/web/` — minimal dependency-free HTML/CSS/JS placeholder web
client (guest join, Wahl submission, file list, chat) exercising the real
API, served by the backend at `/`. Will be replaced by the Flutter web
build once Flutter is available.
- `client/` (mobile/desktop) — planned Flutter app, not yet scaffolded
(Flutter is not installed in this environment).
## Status ## Status
Phase 0/1 foundation implemented: monorepo skeleton, Prisma data model (Kc, Phase 0/1 foundation implemented: monorepo skeleton, Prisma data model (Kc,
Gemeinde, User, Membership, GuestAccount, Wahl/Workshop/Teilnehmer/Zuteilung, Gemeinde, User, Membership, GuestAccount, Wahl/Workshop/Teilnehmer/Zuteilung,
File, Chat), Authentik JWT resource-server strategy, guest invite-code login, File, Chat), Authentik JWT resource-server strategy, guest invite-code login,
Role-based guard scoped per KC. Backend builds and boots cleanly Role-based guard scoped per KC.
(`npm run build`, `node dist/main.js`) but requires a real PostgreSQL
database and Authentik instance (see `backend/.env.example`) to run end to Phase 2 (Workshop-Wahl engine) implemented: Wahl/Workshop administration,
end. Remaining phases (Wahl-Engine, Dateifreigabe, Chat realtime, Lokal/Cloud guest Teilnehmer submission, Force-Zuteilung overrides, and the assignment
Sync, Flutter clients) are not yet implemented. algorithm ported from the WP plugin's `kc_run_zuteilung` (force-assignments →
wish rounds 1-3 → random fill → consolidation of underfilled workshops),
plus CSV export.
Phase 3 (Dateifreigabe) implemented: Leitungsteam-only upload tagged with a
visibility tier (alle / alle außer Konfis / nur LT), list/download for
Authentik or guest callers filtered by their allowed tiers, storage behind a
provider abstraction defaulting to Nextcloud/WebDAV (S3-compatible storage
as an alternative via `STORAGE_PROVIDER=s3`).
Phase 5 (Kommunikation) implemented: Gemeinde-Gruppenchat, 1:1-DMs, LT-
kanalübergreifende Kanäle, Broadcast (read-only für Konfis); channel/history
via REST, real-time send/receive via a raw WebSocket gateway authenticated
with the same Authentik/guest tokens as the REST API.
Phase 6 (Hybrid Lokal/Cloud-Server & Sync) implemented: an append-only
replication log (`SyncLogEntry`) captured by every feature service after its
writes; the local (on-site) server periodically pushes/pulls against the
cloud server's `/sync/ingest` + `/sync/export` endpoints (shared-secret
authenticated, not user auth). No conflict resolution needed by design - the
local server is the sole source of truth while an event is live.
The backend now also serves the web client directly (static files from
`client/web/`, API under `/api`), so the same process is the single entry
point for the web experience.
Backend builds and boots cleanly (`npm run build`, `node dist/main.js`) but
requires a real PostgreSQL database, Authentik instance, and Nextcloud/S3
credentials (see `backend/.env.example`) to run end to end. Remaining: the
Flutter clients (mobile/desktop; web has an interim plain-HTML client).
+26
View File
@@ -8,3 +8,29 @@ AUTHENTIK_ISSUER_URL="https://authentik.example.org/application/o/kc-app"
GUEST_JWT_SECRET="change-me" GUEST_JWT_SECRET="change-me"
PORT=3000 PORT=3000
# File storage: defaults to Nextcloud via WebDAV; set STORAGE_PROVIDER=s3 to
# use an S3-compatible bucket instead (see S3_* vars below).
STORAGE_PROVIDER="webdav"
WEBDAV_URL="https://nextcloud.example.org/remote.php/dav/files/kc-app"
WEBDAV_USERNAME="kc-app"
WEBDAV_PASSWORD="change-me"
# Only used when STORAGE_PROVIDER=s3
S3_BUCKET="kc-app"
S3_REGION="auto"
S3_ENDPOINT=""
S3_FORCE_PATH_STYLE="false"
S3_ACCESS_KEY_ID=""
S3_SECRET_ACCESS_KEY=""
# Unique id for THIS server instance (local on-site vs. cloud); used to tag
# replication log entries and avoid echoing changes back to their origin.
SERVER_ID="change-me-uuid"
# Local/cloud sync: set on the LOCAL (on-site) server to periodically push/
# pull against the cloud instance's API base URL. Leave SYNC_ENABLED=false
# on the cloud server (it only needs to expose /sync/ingest + /sync/export).
SYNC_ENABLED="false"
SYNC_PEER_URL="https://kc-app-cloud.example.org/api"
SYNC_SHARED_SECRET="change-me"
+42 -3
View File
@@ -13,6 +13,11 @@ npx prisma migrate dev --name init # requires a running PostgreSQL instance
npm run start:dev npm run start:dev
``` ```
The API is served under `/api` (see `app.setGlobalPrefix('api')` in
`main.ts`); everything else (`/`, `/app.js`, ...) is served statically from
`../client/web` via `ServeStaticModule`, so the backend doubles as the web
client's host - no separate web server is needed.
## Auth model ## Auth model
- Team members (Leitungsteam, Gemeinde Verantwortliche, Gemeinde Teamer) are - Team members (Leitungsteam, Gemeinde Verantwortliche, Gemeinde Teamer) are
@@ -28,10 +33,44 @@ npm run start:dev
## Modules implemented so far ## Modules implemented so far
- `prisma/` — shared `PrismaClient` provider. - `prisma/` — shared `PrismaClient` provider.
- `auth/` — Authentik resource-server strategy + guest invite-code login. - `auth/` — Authentik resource-server strategy (`AuthGuard('authentik')`) +
guest invite-code login issuing a locally-signed JWT (`AuthGuard('guest')`).
- `kc/` — KC (event) creation/listing, Leitungsteam-only. - `kc/` — KC (event) creation/listing, Leitungsteam-only.
- `wahl/` — Wahl/Workshop administration (Leitungsteam-only), guest
Teilnehmer submission, Force-Zuteilung overrides, and `ZuteilungService`:
a faithful port of the WP plugin's `kc_run_zuteilung` (force-assignments →
up to 3 wish rounds → random fill → consolidation of workshops that stay
below `minTeilnehmer`), plus CSV export (`GET /wahl/:id/zuteilung/csv`).
- `files/` — Leitungsteam-only upload (`POST /files/:kcId`, multipart) tagged
with a `FileVisibility` tier; list/download (`GET /files/:kcId`,
`GET /files/download/:fileId`) accept either an Authentik or a guest token
and filter by the caller's allowed visibility tiers. Storage is behind a
`StorageProvider` abstraction: defaults to Nextcloud via WebDAV
(`WEBDAV_*` env vars), switchable to S3-compatible storage with
`STORAGE_PROVIDER=s3` (`S3_*` env vars).
- `chat/` — Gemeinde-Gruppenchat, 1:1 Direktnachrichten, Leitungsteam-über-
greifende Kanäle und Broadcast (Konfis lesen nur). Channel administration
and message history are plain REST (`ChatController`); real-time send/
receive is a raw `ws` gateway (`ChatGateway`, path `/chat`) since passport
guards don't apply to WS upgrades — auth happens once via `?token=` at
connect time (`TokenVerificationService` tries Authentik JWKS, then falls
back to a guest token). Access rules live in `ChatService` and are shared
between the REST and WS entry points.
- `sync/` — replicates mutations between the local (on-site) and cloud
server. `SyncService.capture()` is called by feature services right after
a write, appending an entry to the append-only `SyncLogEntry` log tagged
with this server's `SERVER_ID`. The local server (set `SYNC_ENABLED=true`,
`SYNC_PEER_URL`) periodically pushes its new entries to the cloud's
`POST /sync/ingest` and pulls the cloud's via `GET /sync/export`
(`SyncSchedulerService`, every 30s), both guarded by `SYNC_SHARED_SECRET`
(`SyncSecretGuard`) rather than user auth. No conflict resolution is
implemented by design — the local server is the sole source of truth
while an event is live. `POST /sync/trigger` lets a Leitungsteam member
force an immediate push+pull. Known gap: only entity metadata is
replicated; uploaded file bytes only resolve on both sides if local and
cloud share the same Nextcloud/S3 backend.
- `common/``Role` enum, `@Roles()` decorator, `RolesGuard` (KC-scoped, - `common/``Role` enum, `@Roles()` decorator, `RolesGuard` (KC-scoped,
Leitungsteam roles are global across all KCs). Leitungsteam roles are global across all KCs).
Not yet implemented: Wahl/Workshop/Zuteilung engine, file sharing, chat All planned backend phases are implemented; remaining work is the Flutter
realtime gateway, local/cloud sync engine. clients (see repo root README).
+922 -13
View File
File diff suppressed because it is too large Load Diff
+16 -2
View File
@@ -21,6 +21,7 @@
"prisma:migrate": "prisma migrate dev" "prisma:migrate": "prisma migrate dev"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.679.0",
"@nestjs/common": "^10.4.15", "@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0", "@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15", "@nestjs/core": "^10.4.15",
@@ -28,15 +29,20 @@
"@nestjs/passport": "^10.0.3", "@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.15", "@nestjs/platform-express": "^10.4.15",
"@nestjs/platform-ws": "^10.4.15", "@nestjs/platform-ws": "^10.4.15",
"@nestjs/schedule": "^4.1.1",
"@nestjs/serve-static": "^4.0.2",
"@nestjs/websockets": "^10.4.15", "@nestjs/websockets": "^10.4.15",
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.1.0", "jwks-rsa": "^3.1.0",
"multer": "^2.0.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"webdav": "^5.7.1",
"ws": "^8.18.0" "ws": "^8.18.0"
}, },
"devDependencies": { "devDependencies": {
@@ -45,6 +51,8 @@
"@nestjs/testing": "^10.4.15", "@nestjs/testing": "^10.4.15",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.7",
"@types/multer": "^1.4.12",
"@types/node": "^20.17.9", "@types/node": "^20.17.9",
"@types/passport": "^1.0.17", "@types/passport": "^1.0.17",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
@@ -67,13 +75,19 @@
"typescript": "^5.6.3" "typescript": "^5.6.3"
}, },
"jest": { "jest": {
"moduleFileExtensions": ["js", "json", "ts"], "moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src", "rootDir": "src",
"testRegex": ".*\\.spec\\.ts$", "testRegex": ".*\\.spec\\.ts$",
"transform": { "transform": {
"^.+\\.(t|j)s$": "ts-jest" "^.+\\.(t|j)s$": "ts-jest"
}, },
"collectCoverageFrom": ["**/*.(t|j)s"], "collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage", "coverageDirectory": "../coverage",
"testEnvironment": "node" "testEnvironment": "node"
} }
+119 -56
View File
@@ -9,19 +9,19 @@ datasource db {
/// A Konfi-Castle event; the top-level tenant. One instance manages many KCs. /// A Konfi-Castle event; the top-level tenant. One instance manages many KCs.
model Kc { model Kc {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
inviteCode String @unique inviteCode String @unique
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
gemeinden Gemeinde[] gemeinden Gemeinde[]
memberships Membership[] memberships Membership[]
wahlen Wahl[] wahlen Wahl[]
files File[] files File[]
channels ChatChannel[] channels ChatChannel[]
guests GuestAccount[] guests GuestAccount[]
} }
/// A local congregation/community participating in one Kc. /// A local congregation/community participating in one Kc.
@@ -31,7 +31,7 @@ model Gemeinde {
kcId String kcId String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
memberships Membership[] memberships Membership[]
guests GuestAccount[] guests GuestAccount[]
@@ -46,15 +46,16 @@ enum Role {
/// Authentik-backed user (team member with elevated rights). /// Authentik-backed user (team member with elevated rights).
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
authentikSub String @unique authentikSub String @unique
email String @unique email String @unique
firstName String firstName String
lastName String lastName String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
memberships Membership[] memberships Membership[]
messages ChatMessage[] messages ChatMessage[]
chatParticipations ChatParticipant[]
} }
/// Scopes a User's role to a specific Kc (and Gemeinde, if applicable). /// Scopes a User's role to a specific Kc (and Gemeinde, if applicable).
@@ -83,62 +84,79 @@ model GuestAccount {
lastName String lastName String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade) gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade)
messages ChatMessage[] messages ChatMessage[]
teilnehmer Teilnehmer[] teilnehmer Teilnehmer[]
} }
/// A workshop election, scoped to a Kc; name carries a date key + "Teil". /// A workshop election, scoped to a Kc; name carries a date key + "Teil".
model Wahl { model Wahl {
id String @id @default(cuid()) id String @id @default(cuid())
kcId String kcId String
name String name String
datumsSchluessel String datumsSchluessel String
teil String teil String
isOpen Boolean @default(true) isOpen Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
workshops Workshop[] workshops Workshop[]
teilnehmer Teilnehmer[] teilnehmer Teilnehmer[]
forceZuteilungen ForceZuteilung[]
} }
model Workshop { model Workshop {
id String @id @default(cuid()) id String @id @default(cuid())
wahlId String wahlId String
name String name String
kapazitaet Int kapazitaet Int
minTeilnehmer Int @default(0)
wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade) wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade)
zuteilungen Zuteilung[] zuteilungen Zuteilung[]
forceZuteilungen ForceZuteilung[]
} }
/// A participant's submitted choices for a Wahl. /// A participant's submitted choices for a Wahl.
model Teilnehmer { model Teilnehmer {
id String @id @default(cuid()) id String @id @default(cuid())
wahlId String wahlId String
guestAccountId String guestAccountId String
prioritaeten Json prioritaeten Json
createdAt DateTime @default(now()) createdAt DateTime @default(now())
wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade) wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade)
guestAccount GuestAccount @relation(fields: [guestAccountId], references: [id], onDelete: Cascade) guestAccount GuestAccount @relation(fields: [guestAccountId], references: [id], onDelete: Cascade)
zuteilung Zuteilung? zuteilung Zuteilung?
forceZuteilung ForceZuteilung?
@@unique([wahlId, guestAccountId]) @@unique([wahlId, guestAccountId])
} }
/// Result of the assignment algorithm (or a manual force-assignment) for one Teilnehmer. /// Manual override set by LT before running the assignment algorithm; takes precedence.
model ForceZuteilung {
id String @id @default(cuid())
wahlId String
teilnehmerId String @unique
workshopId String
wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade)
teilnehmer Teilnehmer @relation(fields: [teilnehmerId], references: [id], onDelete: Cascade)
workshop Workshop @relation(fields: [workshopId], references: [id], onDelete: Cascade)
}
/// Result of the assignment algorithm for one Teilnehmer; workshopId is null if unassigned (no capacity left).
model Zuteilung { model Zuteilung {
id String @id @default(cuid()) id String @id @default(cuid())
teilnehmerId String @unique teilnehmerId String @unique
workshopId String workshopId String?
wunschRang Int @default(-1)
isForced Boolean @default(false) isForced Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
teilnehmer Teilnehmer @relation(fields: [teilnehmerId], references: [id], onDelete: Cascade) teilnehmer Teilnehmer @relation(fields: [teilnehmerId], references: [id], onDelete: Cascade)
workshop Workshop @relation(fields: [workshopId], references: [id], onDelete: Cascade) workshop Workshop? @relation(fields: [workshopId], references: [id], onDelete: SetNull)
} }
enum FileVisibility { enum FileVisibility {
@@ -173,19 +191,64 @@ model ChatChannel {
gemeindeId String? gemeindeId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
messages ChatMessage[] messages ChatMessage[]
participants ChatParticipant[]
}
/// Explicit membership for DIREKT (1:1) channels; other channel types derive
/// access from Membership/Gemeinde instead of this table.
model ChatParticipant {
id String @id @default(cuid())
channelId String
userId String
createdAt DateTime @default(now())
channel ChatChannel @relation(fields: [channelId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([channelId, userId])
} }
model ChatMessage { model ChatMessage {
id String @id @default(cuid()) id String @id @default(cuid())
channelId String channelId String
senderUserId String? senderUserId String?
senderGuestId String? senderGuestId String?
body String body String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
channel ChatChannel @relation(fields: [channelId], references: [id], onDelete: Cascade) channel ChatChannel @relation(fields: [channelId], references: [id], onDelete: Cascade)
senderUser User? @relation(fields: [senderUserId], references: [id]) senderUser User? @relation(fields: [senderUserId], references: [id])
senderGuest GuestAccount? @relation(fields: [senderGuestId], references: [id]) senderGuest GuestAccount? @relation(fields: [senderGuestId], references: [id])
} }
enum SyncOperation {
CREATE
UPDATE
DELETE
}
/// Append-only log of local mutations, replicated to the peer server (local
/// <-> cloud). `originId` is the SERVER_ID that made the change, so applying
/// an incoming entry never gets re-captured/re-pushed back (no echo loops).
model SyncLogEntry {
id String @id @default(cuid())
sequence Int @default(autoincrement())
model String
recordId String
operation SyncOperation
payload Json
originId String
createdAt DateTime @default(now())
}
/// Per-peer replication progress, kept on the side that initiates sync
/// (normally the local, on-site server, since it can always dial out to the
/// cloud even when the cloud can't reach into the event's local network).
model SyncCursor {
id String @id @default(cuid())
peerId String @unique
lastPushedSequence Int @default(0)
lastPulledSequence Int @default(0)
}
+16
View File
@@ -1,15 +1,31 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { KcModule } from './kc/kc.module'; import { KcModule } from './kc/kc.module';
import { WahlModule } from './wahl/wahl.module';
import { FilesModule } from './files/files.module';
import { ChatModule } from './chat/chat.module';
import { SyncModule } from './sync/sync.module';
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ isGlobal: true }), ConfigModule.forRoot({ isGlobal: true }),
// Serves the plain static web client from ../client/web; the REST API
// lives under /api (see main.ts) so it never collides with these routes.
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', '..', 'client', 'web'),
exclude: ['/api*'],
}),
PrismaModule, PrismaModule,
SyncModule,
AuthModule, AuthModule,
KcModule, KcModule,
WahlModule,
FilesModule,
ChatModule,
], ],
}) })
export class AppModule {} export class AppModule {}
+4 -1
View File
@@ -5,6 +5,8 @@ import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { GuestAuthService } from './guest-auth.service'; import { GuestAuthService } from './guest-auth.service';
import { AuthentikStrategy } from './authentik.strategy'; import { AuthentikStrategy } from './authentik.strategy';
import { GuestJwtStrategy } from './guest-jwt.strategy';
import { TokenVerificationService } from './token-verification.service';
@Module({ @Module({
imports: [ imports: [
@@ -18,6 +20,7 @@ import { AuthentikStrategy } from './authentik.strategy';
}), }),
], ],
controllers: [AuthController], controllers: [AuthController],
providers: [GuestAuthService, AuthentikStrategy], providers: [GuestAuthService, AuthentikStrategy, GuestJwtStrategy, TokenVerificationService],
exports: [TokenVerificationService],
}) })
export class AuthModule {} export class AuthModule {}
@@ -1,5 +1,6 @@
import { Request } from 'express'; import { Request } from 'express';
import { Role } from '../common/role.enum'; import { Role } from '../common/role.enum';
import { GuestJwtPayload } from './guest-auth.service';
export interface AuthenticatedMembership { export interface AuthenticatedMembership {
kcId: string; kcId: string;
@@ -18,3 +19,8 @@ export interface AuthenticatedUser {
export interface AuthenticatedRequest extends Request { export interface AuthenticatedRequest extends Request {
user?: AuthenticatedUser; user?: AuthenticatedUser;
} }
/// Shape attached to req.user by GuestJwtStrategy for guest/Konfi-authenticated routes.
export interface GuestAuthenticatedRequest extends Request {
user?: GuestJwtPayload;
}
+4
View File
@@ -1,6 +1,8 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { SyncOperation } from '@prisma/client';
import { PrismaClient } from '../prisma/prisma.module'; import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service';
export interface GuestJwtPayload { export interface GuestJwtPayload {
guestId: string; guestId: string;
@@ -15,6 +17,7 @@ export class GuestAuthService {
constructor( constructor(
private readonly prisma: PrismaClient, private readonly prisma: PrismaClient,
private readonly jwt: JwtService, private readonly jwt: JwtService,
private readonly sync: SyncService,
) {} ) {}
async createGuest( async createGuest(
@@ -30,6 +33,7 @@ export class GuestAuthService {
const guest = await this.prisma.guestAccount.create({ const guest = await this.prisma.guestAccount.create({
data: { kcId: kc.id, firstName, lastName }, data: { kcId: kc.id, firstName, lastName },
}); });
await this.sync.capture('GuestAccount', SyncOperation.CREATE, guest.id, guest);
const payload: GuestJwtPayload = { const payload: GuestJwtPayload = {
guestId: guest.id, guestId: guest.id,
+21
View File
@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { GuestJwtPayload } from './guest-auth.service';
/// Verifies the local JWT issued to guests/Konfis by GuestAuthService.
/// Kept separate from AuthentikStrategy since guests are never Authentik-backed.
@Injectable()
export class GuestJwtStrategy extends PassportStrategy(Strategy, 'guest') {
constructor(config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: config.getOrThrow<string>('GUEST_JWT_SECRET'),
});
}
validate(payload: GuestJwtPayload): GuestJwtPayload {
return payload;
}
}
@@ -0,0 +1,74 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import * as jwt from 'jsonwebtoken';
import * as jwksRsa from 'jwks-rsa';
import { PrismaClient } from '../prisma/prisma.module';
import { AuthenticatedUser } from './authenticated-request';
import { GuestJwtPayload } from './guest-auth.service';
/// Verifies raw bearer tokens outside the HTTP/passport pipeline, needed for
/// the WebSocket handshake where AuthGuard('authentik'|'guest') don't apply.
@Injectable()
export class TokenVerificationService {
private readonly issuerUrl: string;
private readonly jwks: jwksRsa.JwksClient;
constructor(
private readonly config: ConfigService,
private readonly prisma: PrismaClient,
private readonly guestJwt: JwtService,
) {
this.issuerUrl = config.getOrThrow<string>('AUTHENTIK_ISSUER_URL');
this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true });
}
async verifyAuthentik(token: string): Promise<AuthenticatedUser> {
const decoded = jwt.decode(token, { complete: true });
const kid = decoded?.header.kid;
if (!kid) {
throw new UnauthorizedException('Malformed Authentik token');
}
const key = await this.jwks.getSigningKey(kid);
const payload = jwt.verify(token, key.getPublicKey(), {
issuer: this.issuerUrl,
algorithms: ['RS256'],
}) as jwt.JwtPayload;
if (!payload.sub) {
throw new UnauthorizedException('Authentik token missing subject');
}
const user = await this.prisma.user.findUnique({
where: { authentikSub: payload.sub },
include: { memberships: true },
});
if (!user) {
throw new UnauthorizedException('User not provisioned locally yet');
}
return {
userId: user.id,
authentikSub: user.authentikSub,
email: user.email,
memberships: user.memberships.map((m) => ({
kcId: m.kcId,
gemeindeId: m.gemeindeId,
role: m.role,
})),
};
}
async verifyGuest(token: string): Promise<GuestJwtPayload> {
return this.guestJwt.verifyAsync<GuestJwtPayload>(token);
}
/// Tries Authentik first (team member), then falls back to a guest token.
async verifyEither(token: string): Promise<
{ kind: 'user'; user: AuthenticatedUser } | { kind: 'guest'; guest: GuestJwtPayload }
> {
try {
return { kind: 'user', user: await this.verifyAuthentik(token) };
} catch {
return { kind: 'guest', guest: await this.verifyGuest(token) };
}
}
}
+13
View File
@@ -0,0 +1,13 @@
import { AuthenticatedUser } from '../auth/authenticated-request';
import { GuestJwtPayload } from '../auth/guest-auth.service';
import { ChatCaller } from './chat.service';
function isGuestPayload(user: unknown): user is GuestJwtPayload {
return !!user && typeof user === 'object' && 'guestId' in user;
}
/// req.user is either an AuthenticatedUser (Authentik) or a GuestJwtPayload,
/// depending on which strategy AuthGuard(['authentik','guest']) picked.
export function resolveChatCaller(user: AuthenticatedUser | GuestJwtPayload): ChatCaller {
return isGuestPayload(user) ? { kind: 'guest', guest: user } : { kind: 'user', user };
}
+45
View File
@@ -0,0 +1,45 @@
import { Body, Controller, Get, Param, Post, Req, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ChatService } from './chat.service';
import { CreateChannelDto } from './dto/create-channel.dto';
import { CreateDirectChannelDto } from './dto/create-direct-channel.dto';
import { Roles } from '../common/roles.decorator';
import { RolesGuard } from '../common/roles.guard';
import { Role } from '../common/role.enum';
import { AuthenticatedRequest } from '../auth/authenticated-request';
import { GuestJwtPayload } from '../auth/guest-auth.service';
import { resolveChatCaller } from './caller.util';
type ChatRequest = AuthenticatedRequest & { user?: AuthenticatedRequest['user'] | GuestJwtPayload };
@Controller('chat')
export class ChatController {
constructor(private readonly chat: ChatService) {}
/// Channel administration (Gemeinde-Gruppen, LT-Kanäle, Broadcasts) is Leitungsteam-only.
@Post(':kcId/channels')
@UseGuards(AuthGuard('authentik'), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
createChannel(@Param('kcId') kcId: string, @Body() dto: CreateChannelDto) {
return this.chat.createChannel(kcId, dto.type, dto.gemeindeId);
}
/// Any two team members of the same KC can start a direct conversation.
@Post('direct')
@UseGuards(AuthGuard('authentik'))
createDirectChannel(@Body() dto: CreateDirectChannelDto, @Req() req: AuthenticatedRequest) {
return this.chat.getOrCreateDirectChannel(dto.kcId, req.user!.userId, dto.otherUserId);
}
@Get(':kcId/channels')
@UseGuards(AuthGuard(['authentik', 'guest']))
listChannels(@Param('kcId') kcId: string, @Req() req: ChatRequest) {
return this.chat.listChannelsForCaller(kcId, resolveChatCaller(req.user!));
}
@Get('channels/:channelId/messages')
@UseGuards(AuthGuard(['authentik', 'guest']))
listMessages(@Param('channelId') channelId: string, @Req() req: ChatRequest) {
return this.chat.listMessages(channelId, resolveChatCaller(req.user!));
}
}
+100
View File
@@ -0,0 +1,100 @@
import {
ConnectedSocket,
MessageBody,
OnGatewayConnection,
OnGatewayDisconnect,
SubscribeMessage,
WebSocketGateway,
} from '@nestjs/websockets';
import { Logger } from '@nestjs/common';
import { IncomingMessage } from 'http';
import { WebSocket } from 'ws';
import { TokenVerificationService } from '../auth/token-verification.service';
import { ChatCaller, ChatService } from './chat.service';
/// Raw `ws` gateway (no socket.io rooms available), so channel membership is
/// tracked manually per connected socket. Auth happens once at handshake via
/// a `?token=` query param since passport guards don't run for WS upgrades.
@WebSocketGateway({ path: '/chat' })
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
private readonly logger = new Logger(ChatGateway.name);
private readonly callers = new WeakMap<WebSocket, ChatCaller>();
private readonly rooms = new Map<string, Set<WebSocket>>();
constructor(
private readonly tokenVerification: TokenVerificationService,
private readonly chat: ChatService,
) {}
async handleConnection(client: WebSocket, request: IncomingMessage) {
const token = new URL(request.url ?? '', 'http://localhost').searchParams.get('token');
if (!token) {
client.close(4001, 'Missing token');
return;
}
try {
this.callers.set(client, await this.tokenVerification.verifyEither(token));
} catch (err) {
this.logger.warn(`WS auth failed: ${(err as Error).message}`);
client.close(4001, 'Unauthorized');
}
}
handleDisconnect(client: WebSocket) {
this.callers.delete(client);
for (const members of this.rooms.values()) {
members.delete(client);
}
}
@SubscribeMessage('chat:join')
async onJoin(
@ConnectedSocket() client: WebSocket,
@MessageBody() data: { channelId: string },
) {
const caller = this.requireCaller(client);
await this.chat.assertCanRead(data.channelId, caller);
this.roomFor(data.channelId).add(client);
return { event: 'chat:joined', data: { channelId: data.channelId } };
}
@SubscribeMessage('chat:send')
async onSend(
@ConnectedSocket() client: WebSocket,
@MessageBody() data: { channelId: string; body: string },
) {
const caller = this.requireCaller(client);
const message = await this.chat.sendMessage(data.channelId, caller, data.body);
this.broadcast(data.channelId, { event: 'chat:message', data: message });
return { event: 'chat:sent', data: { id: message.id } };
}
private requireCaller(client: WebSocket): ChatCaller {
const caller = this.callers.get(client);
if (!caller) {
client.close(4001, 'Unauthorized');
throw new Error('Unauthorized WS client');
}
return caller;
}
private roomFor(channelId: string): Set<WebSocket> {
let room = this.rooms.get(channelId);
if (!room) {
room = new Set();
this.rooms.set(channelId, room);
}
return room;
}
private broadcast(channelId: string, payload: unknown) {
const room = this.rooms.get(channelId);
if (!room) return;
const json = JSON.stringify(payload);
for (const socket of room) {
if (socket.readyState === socket.OPEN) {
socket.send(json);
}
}
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { ChatService } from './chat.service';
import { ChatGateway } from './chat.gateway';
import { ChatController } from './chat.controller';
@Module({
imports: [AuthModule],
controllers: [ChatController],
providers: [ChatService, ChatGateway],
})
export class ChatModule {}
+165
View File
@@ -0,0 +1,165 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { ChatChannelType, Role, SyncOperation } from '@prisma/client';
import { PrismaClient } from '../prisma/prisma.module';
import { AuthenticatedUser } from '../auth/authenticated-request';
import { GuestJwtPayload } from '../auth/guest-auth.service';
import { SyncService } from '../sync/sync.service';
export type ChatCaller =
| { kind: 'user'; user: AuthenticatedUser }
| { kind: 'guest'; guest: GuestJwtPayload };
@Injectable()
export class ChatService {
constructor(
private readonly prisma: PrismaClient,
private readonly sync: SyncService,
) {}
async createChannel(kcId: string, type: ChatChannelType, gemeindeId?: string) {
const channel = await this.prisma.chatChannel.create({ data: { kcId, type, gemeindeId } });
await this.sync.capture('ChatChannel', SyncOperation.CREATE, channel.id, channel);
return channel;
}
async getOrCreateDirectChannel(kcId: string, userAId: string, userBId: string) {
const existing = await this.prisma.chatChannel.findFirst({
where: {
kcId,
type: ChatChannelType.DIREKT,
AND: [
{ participants: { some: { userId: userAId } } },
{ participants: { some: { userId: userBId } } },
],
},
});
if (existing) return existing;
const channel = await this.prisma.chatChannel.create({
data: {
kcId,
type: ChatChannelType.DIREKT,
participants: { create: [{ userId: userAId }, { userId: userBId }] },
},
});
await this.sync.capture('ChatChannel', SyncOperation.CREATE, channel.id, channel);
return channel;
}
async listChannelsForCaller(kcId: string, caller: ChatCaller) {
if (caller.kind === 'guest') {
return this.prisma.chatChannel.findMany({
where: { kcId, type: ChatChannelType.BROADCAST },
});
}
const { user } = caller;
const isLt = user.memberships.some((m) => m.role === Role.LEITUNGSTEAM);
if (isLt) {
return this.prisma.chatChannel.findMany({ where: { kcId } });
}
const gemeindeIds = user.memberships
.filter((m) => m.kcId === kcId && m.gemeindeId)
.map((m) => m.gemeindeId as string);
return this.prisma.chatChannel.findMany({
where: {
kcId,
OR: [
{ type: ChatChannelType.BROADCAST },
{ type: ChatChannelType.GEMEINDE_GRUPPE, gemeindeId: { in: gemeindeIds } },
{ type: ChatChannelType.DIREKT, participants: { some: { userId: user.userId } } },
],
},
});
}
async assertCanRead(channelId: string, caller: ChatCaller) {
return this.getChannelForCallerOrThrow(channelId, caller, 'read');
}
async assertCanWrite(channelId: string, caller: ChatCaller) {
return this.getChannelForCallerOrThrow(channelId, caller, 'write');
}
private async getChannelForCallerOrThrow(
channelId: string,
caller: ChatCaller,
mode: 'read' | 'write',
) {
const channel = await this.prisma.chatChannel.findUnique({
where: { id: channelId },
include: { participants: true },
});
if (!channel) {
throw new NotFoundException('Channel not found');
}
if (caller.kind === 'guest') {
const allowed = channel.type === ChatChannelType.BROADCAST && mode === 'read';
if (!allowed) {
throw new ForbiddenException('Guests may only read broadcast channels');
}
if (caller.guest.kcId !== channel.kcId) {
throw new ForbiddenException('Guest does not belong to this KC');
}
return channel;
}
const { user } = caller;
const isLt = user.memberships.some((m) => m.role === Role.LEITUNGSTEAM);
if (isLt) {
return channel;
}
if (channel.kcId && !user.memberships.some((m) => m.kcId === channel.kcId)) {
throw new ForbiddenException('Not a member of this KC');
}
switch (channel.type) {
case ChatChannelType.BROADCAST:
if (mode === 'write') {
throw new ForbiddenException('Only Leitungsteam may post broadcasts');
}
return channel;
case ChatChannelType.LT_UEBERGREIFEND:
throw new ForbiddenException('Leitungsteam-only channel');
case ChatChannelType.GEMEINDE_GRUPPE: {
const inGemeinde = user.memberships.some(
(m) => m.kcId === channel.kcId && m.gemeindeId === channel.gemeindeId,
);
if (!inGemeinde) {
throw new ForbiddenException('Not a member of this Gemeinde');
}
return channel;
}
case ChatChannelType.DIREKT: {
const isParticipant = channel.participants.some((p) => p.userId === user.userId);
if (!isParticipant) {
throw new ForbiddenException('Not a participant of this conversation');
}
return channel;
}
default:
throw new ForbiddenException('Unknown channel type');
}
}
async sendMessage(channelId: string, caller: ChatCaller, body: string) {
await this.assertCanWrite(channelId, caller);
const message = await this.prisma.chatMessage.create({
data: {
channelId,
body,
senderUserId: caller.kind === 'user' ? caller.user.userId : null,
senderGuestId: caller.kind === 'guest' ? caller.guest.guestId : null,
},
});
await this.sync.capture('ChatMessage', SyncOperation.CREATE, message.id, message);
return message;
}
async listMessages(channelId: string, caller: ChatCaller) {
await this.assertCanRead(channelId, caller);
return this.prisma.chatMessage.findMany({
where: { channelId },
orderBy: { createdAt: 'asc' },
});
}
}
@@ -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;
}
+7
View File
@@ -0,0 +1,7 @@
import { IsEnum } from 'class-validator';
import { FileVisibility } from '@prisma/client';
export class UploadFileDto {
@IsEnum(FileVisibility)
visibility!: FileVisibility;
}
+73
View File
@@ -0,0 +1,73 @@
import {
Body,
Controller,
Get,
Param,
Post,
Req,
Res,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { AuthGuard } from '@nestjs/passport';
import { Response } from 'express';
import { FilesService } from './files.service';
import { UploadFileDto } from './dto/upload-file.dto';
import { Roles } from '../common/roles.decorator';
import { RolesGuard } from '../common/roles.guard';
import { Role } from '../common/role.enum';
import { AuthenticatedRequest } from '../auth/authenticated-request';
import { GuestJwtPayload } from '../auth/guest-auth.service';
import { allowedVisibilitiesForUser, GUEST_ALLOWED_VISIBILITIES } from './visibility.util';
type FileCallerRequest = AuthenticatedRequest & { user?: AuthenticatedRequest['user'] | GuestJwtPayload };
function isGuest(user: unknown): user is GuestJwtPayload {
return !!user && typeof user === 'object' && 'guestId' in user;
}
@Controller('files')
export class FilesController {
constructor(private readonly files: FilesService) {}
/// Only the Leitungsteam uploads files (per KC), tagged with a visibility tier.
@Post(':kcId')
@UseGuards(AuthGuard('authentik'), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
@UseInterceptors(FileInterceptor('file'))
upload(
@Param('kcId') kcId: string,
@Body() dto: UploadFileDto,
@UploadedFile() file: Express.Multer.File,
@Req() req: AuthenticatedRequest,
) {
return this.files.upload(kcId, dto.visibility, file.originalname, file.buffer, req.user!.userId);
}
@Get(':kcId')
@UseGuards(AuthGuard(['authentik', 'guest']))
list(@Param('kcId') kcId: string, @Req() req: FileCallerRequest) {
const allowed = isGuest(req.user)
? GUEST_ALLOWED_VISIBILITIES
: allowedVisibilitiesForUser(req.user!, kcId);
return this.files.listForCaller(kcId, allowed);
}
@Get('download/:fileId')
@UseGuards(AuthGuard(['authentik', 'guest']))
async download(
@Param('fileId') fileId: string,
@Req() req: FileCallerRequest,
@Res() res: Response,
) {
const meta = await this.files.getFileOrThrow(fileId);
const allowed = isGuest(req.user)
? GUEST_ALLOWED_VISIBILITIES
: allowedVisibilitiesForUser(req.user!, meta.kcId);
const { file, data } = await this.files.downloadForCaller(fileId, allowed);
res.setHeader('Content-Disposition', `attachment; filename="${file.filename}"`);
res.send(data);
}
}
+24
View File
@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { FilesService } from './files.service';
import { FilesController } from './files.controller';
import { STORAGE_PROVIDER } from './storage/storage-provider';
import { WebDavStorageProvider } from './storage/webdav-storage.provider';
import { S3StorageProvider } from './storage/s3-storage.provider';
@Module({
controllers: [FilesController],
providers: [
FilesService,
{
// Nextcloud (WebDAV) is the default target; STORAGE_PROVIDER=s3 switches to S3-compatible storage.
provide: STORAGE_PROVIDER,
inject: [ConfigService],
useFactory: (config: ConfigService) =>
config.get<string>('STORAGE_PROVIDER') === 's3'
? new S3StorageProvider(config)
: new WebDavStorageProvider(config),
},
],
})
export class FilesModule {}
+53
View File
@@ -0,0 +1,53 @@
import { ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { FileVisibility, SyncOperation } from '@prisma/client';
import { PrismaClient } from '../prisma/prisma.module';
import { STORAGE_PROVIDER, StorageProvider } from './storage/storage-provider';
import { SyncService } from '../sync/sync.service';
@Injectable()
export class FilesService {
constructor(
private readonly prisma: PrismaClient,
@Inject(STORAGE_PROVIDER) private readonly storage: StorageProvider,
private readonly sync: SyncService,
) {}
async upload(
kcId: string,
visibility: FileVisibility,
filename: string,
data: Buffer,
uploadedById: string,
) {
const storageKey = await this.storage.upload(kcId, filename, data);
const file = await this.prisma.file.create({
data: { kcId, storageKey, filename, visibility, uploadedById },
});
// Note: only metadata is replicated here; storageKey only resolves if
// local and cloud share the same Nextcloud/S3 backend (see sync docs).
await this.sync.capture('File', SyncOperation.CREATE, file.id, file);
return file;
}
listForCaller(kcId: string, allowedVisibilities: FileVisibility[]) {
return this.prisma.file.findMany({
where: { kcId, visibility: { in: allowedVisibilities } },
orderBy: { createdAt: 'desc' },
});
}
async downloadForCaller(fileId: string, allowedVisibilities: FileVisibility[]) {
const file = await this.getFileOrThrow(fileId);
if (!allowedVisibilities.includes(file.visibility)) {
throw new ForbiddenException('Not permitted to access this file');
}
const data = await this.storage.download(file.storageKey);
return { file, data };
}
getFileOrThrow(fileId: string) {
return this.prisma.file.findUniqueOrThrow({ where: { id: fileId } }).catch(() => {
throw new NotFoundException('File not found');
});
}
}
@@ -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);
}
}
+21
View File
@@ -0,0 +1,21 @@
import { FileVisibility, Role } from '@prisma/client';
import { AuthenticatedUser } from '../auth/authenticated-request';
/// Maps the caller's role for a given KC to the file visibility tiers they may see.
/// LEITUNGSTEAM is global (per RolesGuard convention) and sees everything.
export function allowedVisibilitiesForUser(
user: AuthenticatedUser,
kcId: string,
): FileVisibility[] {
const isLt = user.memberships.some((m) => m.role === Role.LEITUNGSTEAM);
if (isLt) {
return [FileVisibility.ALLE, FileVisibility.ALLE_AUSSER_KONFIS, FileVisibility.NUR_LT];
}
const isTeamMemberForKc = user.memberships.some((m) => m.kcId === kcId);
if (isTeamMemberForKc) {
return [FileVisibility.ALLE, FileVisibility.ALLE_AUSSER_KONFIS];
}
return [];
}
export const GUEST_ALLOWED_VISIBILITIES: FileVisibility[] = [FileVisibility.ALLE];
+10 -3
View File
@@ -1,15 +1,22 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { SyncOperation } from '@prisma/client';
import { PrismaClient } from '../prisma/prisma.module'; import { PrismaClient } from '../prisma/prisma.module';
import { SyncService } from '../sync/sync.service';
@Injectable() @Injectable()
export class KcService { export class KcService {
constructor(private readonly prisma: PrismaClient) {} constructor(
private readonly prisma: PrismaClient,
private readonly sync: SyncService,
) {}
createKc(name: string) { async createKc(name: string) {
return this.prisma.kc.create({ const kc = await this.prisma.kc.create({
data: { name, inviteCode: randomBytes(6).toString('hex') }, data: { name, inviteCode: randomBytes(6).toString('hex') },
}); });
await this.sync.capture('Kc', SyncOperation.CREATE, kc.id, kc);
return kc;
} }
listKcs() { listKcs() {
+3
View File
@@ -1,11 +1,14 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import { WsAdapter } from '@nestjs/platform-ws';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api');
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
app.enableCors(); app.enableCors();
app.useWebSocketAdapter(new WsAdapter(app));
await app.listen(process.env.PORT ?? 3000); await app.listen(process.env.PORT ?? 3000);
} }
bootstrap(); bootstrap();
@@ -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}`);
}
}
}
+18
View File
@@ -0,0 +1,18 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
/// Server-to-server auth for /sync/*: a shared secret header, not a user token.
@Injectable()
export class SyncSecretGuard implements CanActivate {
constructor(private readonly config: ConfigService) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<Request>();
const expected = this.config.getOrThrow<string>('SYNC_SHARED_SECRET');
if (request.headers['x-sync-secret'] !== expected) {
throw new ForbiddenException('Invalid sync secret');
}
return true;
}
}
+45
View File
@@ -0,0 +1,45 @@
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { SyncService } from './sync.service';
import { SyncSecretGuard } from './sync-secret.guard';
import { IngestEntriesDto } from './dto/ingest-entries.dto';
import { Roles } from '../common/roles.decorator';
import { RolesGuard } from '../common/roles.guard';
import { Role } from '../common/role.enum';
@Controller('sync')
export class SyncController {
constructor(
private readonly sync: SyncService,
private readonly config: ConfigService,
) {}
/// Peer pushes its new entries to us.
@Post('ingest')
@UseGuards(SyncSecretGuard)
async ingest(@Body() dto: IngestEntriesDto) {
await this.sync.applyIncoming(dto.entries as never);
return { applied: dto.entries.length };
}
/// Peer pulls our new entries since their last known sequence.
@Get('export')
@UseGuards(SyncSecretGuard)
async export(@Query('since') since: string) {
const entries = await this.sync.getEntriesSince(Number(since) || 0);
return { entries };
}
/// Manual on-demand push+pull against the configured peer (Leitungsteam-only).
@Post('trigger')
@UseGuards(AuthGuard('authentik'), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
async trigger() {
const peerUrl = this.config.getOrThrow<string>('SYNC_PEER_URL');
const peerSecret = this.config.getOrThrow<string>('SYNC_SHARED_SECRET');
const pushed = await this.sync.pushToPeer(peerUrl, peerSecret);
const pulled = await this.sync.pullFromPeer(peerUrl, peerSecret);
return { ...pushed, ...pulled };
}
}
+16
View File
@@ -0,0 +1,16 @@
import { Global, Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { SyncService } from './sync.service';
import { SyncController } from './sync.controller';
import { SyncSchedulerService } from './sync-scheduler.service';
/// Global so every feature module can inject SyncService to capture its
/// mutations without each one importing SyncModule explicitly.
@Global()
@Module({
imports: [ScheduleModule.forRoot()],
controllers: [SyncController],
providers: [SyncService, SyncSchedulerService],
exports: [SyncService],
})
export class SyncModule {}
+154
View File
@@ -0,0 +1,154 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SyncOperation } from '@prisma/client';
import { PrismaClient } from '../prisma/prisma.module';
const SYNCED_MODELS = [
'Kc',
'Gemeinde',
'GuestAccount',
'Wahl',
'Workshop',
'Teilnehmer',
'ForceZuteilung',
'Zuteilung',
'File',
'ChatChannel',
'ChatMessage',
] as const;
export type SyncedModel = (typeof SYNCED_MODELS)[number];
interface IncomingEntry {
sequence: number;
model: string;
recordId: string;
operation: SyncOperation;
payload: Record<string, unknown>;
originId: string;
}
/// Replicates mutations between the local (on-site) and cloud server. The
/// local server is the sole source of truth while an event is live, so
/// incoming entries are applied with simple upserts - no conflict resolution
/// is needed by design (see plan doc).
@Injectable()
export class SyncService {
private readonly logger = new Logger(SyncService.name);
readonly serverId: string;
constructor(
private readonly prisma: PrismaClient,
private readonly config: ConfigService,
) {
this.serverId = config.getOrThrow<string>('SERVER_ID');
}
/// Called by feature services right after a mutation to append it to the replication log.
async capture(model: SyncedModel, operation: SyncOperation, recordId: string, payload: object) {
await this.prisma.syncLogEntry.create({
data: {
model,
recordId,
operation,
payload: payload as never,
originId: this.serverId,
},
});
}
async getEntriesSince(sequence: number, limit = 500) {
return this.prisma.syncLogEntry.findMany({
where: { sequence: { gt: sequence } },
orderBy: { sequence: 'asc' },
take: limit,
});
}
/// Applies entries received from a peer; never re-captures them, which is
/// what prevents echo loops between the two servers.
async applyIncoming(entries: IncomingEntry[]) {
for (const entry of entries) {
if (entry.originId === this.serverId) continue;
const delegate = this.delegateFor(entry.model);
if (!delegate) {
this.logger.warn(`Skipping sync entry for unknown model "${entry.model}"`);
continue;
}
try {
if (entry.operation === SyncOperation.DELETE) {
await delegate.delete({ where: { id: entry.recordId } });
} else {
await delegate.upsert({
where: { id: entry.recordId },
create: entry.payload,
update: entry.payload,
});
}
} catch (err) {
this.logger.warn(
`Failed to apply sync entry ${entry.model}/${entry.recordId}: ${(err as Error).message}`,
);
}
}
}
async pushToPeer(peerUrl: string, peerSecret: string) {
const peerId = new URL(peerUrl).host;
const cursor = await this.getOrCreateCursor(peerId);
const entries = await this.getEntriesSince(cursor.lastPushedSequence);
if (entries.length === 0) return { pushed: 0 };
const res = await fetch(`${peerUrl}/sync/ingest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-sync-secret': peerSecret },
body: JSON.stringify({ entries }),
});
if (!res.ok) {
throw new Error(`Peer rejected sync push: ${res.status}`);
}
await this.prisma.syncCursor.update({
where: { peerId },
data: { lastPushedSequence: entries[entries.length - 1].sequence },
});
return { pushed: entries.length };
}
async pullFromPeer(peerUrl: string, peerSecret: string) {
const peerId = new URL(peerUrl).host;
const cursor = await this.getOrCreateCursor(peerId);
const res = await fetch(`${peerUrl}/sync/export?since=${cursor.lastPulledSequence}`, {
headers: { 'x-sync-secret': peerSecret },
});
if (!res.ok) {
throw new Error(`Peer rejected sync pull: ${res.status}`);
}
const { entries } = (await res.json()) as { entries: IncomingEntry[] };
if (entries.length === 0) return { pulled: 0 };
await this.applyIncoming(entries);
await this.prisma.syncCursor.update({
where: { peerId },
data: { lastPulledSequence: entries[entries.length - 1].sequence },
});
return { pulled: entries.length };
}
private async getOrCreateCursor(peerId: string) {
return this.prisma.syncCursor.upsert({
where: { peerId },
create: { peerId },
update: {},
});
}
private delegateFor(model: string) {
if (!SYNCED_MODELS.includes(model as SyncedModel)) return null;
const key = (model.charAt(0).toLowerCase() + model.slice(1)) as keyof PrismaClient;
// Generic dispatch across models is inherent to a replication log; each
// delegate exposes the same upsert/delete shape we need here.
return this.prisma[key] as unknown as {
upsert: (args: { where: { id: string }; create: object; update: object }) => Promise<unknown>;
delete: (args: { where: { id: string } }) => Promise<unknown>;
};
}
}
@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class CreateForceZuteilungDto {
@IsString()
@IsNotEmpty()
teilnehmerId!: string;
@IsString()
@IsNotEmpty()
workshopId!: string;
}
+19
View File
@@ -0,0 +1,19 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class CreateWahlDto {
@IsString()
@IsNotEmpty()
kcId!: string;
@IsString()
@IsNotEmpty()
name!: string;
@IsString()
@IsNotEmpty()
datumsSchluessel!: string;
@IsString()
@IsNotEmpty()
teil!: string;
}
@@ -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];
}
+92
View File
@@ -0,0 +1,92 @@
const state = { token: null, kcId: null, socket: null };
const $ = (id) => document.getElementById(id);
function decodeJwtPayload(token) {
try {
const [, payload] = token.split('.');
return JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
} catch {
return null;
}
}
$('guest-form').addEventListener('submit', async (event) => {
event.preventDefault();
const inviteCode = $('invite-code').value;
const firstName = $('first-name').value;
const lastName = $('last-name').value;
const res = await fetch('/api/auth/guest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ inviteCode, firstName, lastName }),
});
if (!res.ok) {
$('login-status').textContent = `Fehler: ${res.status}`;
return;
}
const { accessToken } = await res.json();
state.token = accessToken;
state.kcId = decodeJwtPayload(accessToken)?.kcId ?? null;
$('login-status').textContent = 'Angemeldet.';
$('login-section').hidden = true;
$('app-section').hidden = false;
});
$('submit-wahl').addEventListener('click', async () => {
const wahlId = $('wahl-id').value;
const prioritaeten = $('prioritaeten')
.value.split(',')
.map((s) => s.trim())
.filter(Boolean);
const res = await fetch(`/api/wahl/${wahlId}/teilnehmer`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${state.token}`,
},
body: JSON.stringify({ prioritaeten }),
});
$('wahl-status').textContent = res.ok ? 'Gespeichert.' : `Fehler: ${res.status}`;
});
$('load-files').addEventListener('click', async () => {
if (!state.kcId) return;
const res = await fetch(`/api/files/${state.kcId}`, {
headers: { Authorization: `Bearer ${state.token}` },
});
const files = res.ok ? await res.json() : [];
const list = $('file-list');
list.innerHTML = '';
for (const file of files) {
const li = document.createElement('li');
li.textContent = file.filename;
list.appendChild(li);
}
});
$('join-channel').addEventListener('click', () => {
const channelId = $('channel-id').value;
if (!channelId || !state.token) return;
if (state.socket) state.socket.close();
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
const socket = new WebSocket(`${protocol}://${location.host}/chat?token=${state.token}`);
state.socket = socket;
socket.addEventListener('open', () => {
socket.send(JSON.stringify({ event: 'chat:join', data: { channelId } }));
});
socket.addEventListener('message', (event) => {
const { event: name, data } = JSON.parse(event.data);
if (name !== 'chat:message') return;
const li = document.createElement('li');
li.textContent = data.body;
$('chat-log').appendChild(li);
});
});
+51
View File
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>KC-App</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header>
<h1>KC-App</h1>
<p class="subtitle">Web-Client (Platzhalter, bis der Flutter-Client bereitsteht)</p>
</header>
<main>
<section id="login-section">
<h2>Guest/Konfi-Zugang</h2>
<form id="guest-form">
<label>Einladungscode <input id="invite-code" name="inviteCode" required /></label>
<label>Vorname <input id="first-name" name="firstName" required /></label>
<label>Nachname <input id="last-name" name="lastName" required /></label>
<button type="submit">Beitreten</button>
</form>
<p id="login-status"></p>
</section>
<section id="app-section" hidden>
<h2>Wahl</h2>
<div>
<label>Wahl-ID <input id="wahl-id" /></label>
<label>Priorit&auml;ten (Workshop-IDs, Komma-getrennt) <input id="prioritaeten" /></label>
<button id="submit-wahl">Absenden</button>
</div>
<p id="wahl-status"></p>
<h2>Dateien</h2>
<button id="load-files">Dateien laden</button>
<ul id="file-list"></ul>
<h2>Chat (Broadcast)</h2>
<div>
<label>Channel-ID <input id="channel-id" /></label>
<button id="join-channel">Beitreten</button>
</div>
<ul id="chat-log"></ul>
</section>
</main>
<script src="app.js"></script>
</body>
</html>
+62
View File
@@ -0,0 +1,62 @@
body {
font-family: system-ui, sans-serif;
max-width: 640px;
margin: 2rem auto;
padding: 0 1rem;
color: #1a1a1a;
}
header {
margin-bottom: 2rem;
}
.subtitle {
color: #666;
font-size: 0.9rem;
}
section {
margin-bottom: 2rem;
}
form,
#app-section > div {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: 360px;
margin-bottom: 1rem;
}
label {
display: flex;
flex-direction: column;
font-size: 0.9rem;
gap: 0.25rem;
}
input {
padding: 0.4rem;
font-size: 1rem;
}
button {
padding: 0.5rem;
cursor: pointer;
}
#chat-log,
#file-list {
list-style: none;
padding: 0;
border: 1px solid #ddd;
border-radius: 4px;
max-height: 200px;
overflow-y: auto;
}
#chat-log li,
#file-list li {
padding: 0.4rem 0.6rem;
border-bottom: 1px solid #eee;
}
+106 -28
View File
@@ -1,37 +1,115 @@
# Plan: KC-App Multi-Tenant Event-, Wahl- und Kommunikationsplattform # Plan: KC-App Multi-Tenant Event-, Wahl- und Kommunikationsplattform
Neuentwicklung, die das WordPress-Plugin **Workshop-Wahlen** (Wahlen/Workshops/Teilnehmer/Zuteilungslogik) ablöst und um Rollen-/Rechteverwaltung via Authentik, gestaffelte Dateifreigabe, mehrstufigen Chat und eine Hybrid-Server-Architektur (online + lokal mit Sync) erweitert. Backend: **NestJS + PostgreSQL + Prisma**. Client: **Flutter** (eine Codebase für Mobile, Web, Desktop). Neuentwicklung, die das WordPress-Plugin **Workshop-Wahlen** (Wahlen/Workshops/Teilnehmer/Zuteilungslogik) ablöst und um Rollen-/Rechteverwaltung via Authentik, gestaffelte Dateifreigabe, mehrstufigen Chat und eine Hybrid-Server-Architektur (online + lokal mit Sync) erweitert. Backend: **NestJS 10 + PostgreSQL + Prisma 5**. Client: **Flutter** (eine Codebase für Mobile, Web, Desktop) — bis Flutter verfügbar ist, liefert das Backend selbst einen minimalen Platzhalter-Web-Client aus.
**Domänenmodell** > Status (Stand dieser Session): **Alle geplanten Backend-Phasen (06) sind implementiert und verifiziert** (Typecheck, Build, Boot-Test). Offen ist ausschließlich der Flutter-Client (Mobile/Desktop), da Flutter in dieser Umgebung nicht installiert ist.
- **KC** (Konfi-Castle-Event) = oberster Mandant. Eine App-Instanz verwaltet mehrere KCs parallel.
- Rollen: **Leitungsteam** (global über alle KCs, Authentik-Gruppe) > **Gemeinde Verantwortliche** (pro Gemeinde/KC, Authentik, verwalten nur eigene Teamer) > **Gemeinde Teamer** (von Verantwortlichen angelegt, Authentik) > **Guest/Konfi** (optionaler lokaler Account auf dem Server, Vor-/Nachname Pflicht, temporär pro KC, kein Authentik).
- Einstieg über KC-Code/QR: gewährt Guest-Zugang oder Vorregistrierung als Verantwortlicher/Teamer einer Gemeinde.
- Wahlen werden vom LT pro KC angelegt (Name mit Datumsschlüssel + "Teil").
- Dateien: Sichtbarkeitsstufen alle / alle außer Konfis / nur LT.
- Chat: Gruppenchat pro Gemeinde, 1:1-DMs, LT-übergreifende Kanäle, Broadcast (read-only für Konfis), Push via FCM/APNs.
- Server grundsätzlich online (Cloud); zusätzlich lokaler On-Site-Server pro Event, wird von Clients automatisch bevorzugt wenn im lokalen Netz erreichbar, ist während des Events alleinige Quelle der Wahrheit, synchronisiert danach mit Cloud (keine echten Schreibkonflikte durch dieses Design).
**Phasen** (jede unabhängig verifizierbar, Reihenfolge = Abhängigkeit; Phase 6 kann parallel zu 25 starten, sobald API-Verträge aus Phase 0/1 stehen) ---
1. **Fundament** Monorepo-Skeleton (backend/, client/, shared contracts), Datenmodell (KC, Gemeinde, User, Membership, Wahl, Workshop, Teilnehmer/Zuteilung, ChatChannel/Message, File+Visibility, InviteCode/QR), Authentik-OIDC-Integration + Authentik-Admin-API-Client für Provisionierung. ## 1. Domänenmodell
2. **Multi-Tenancy & Auth** Invite/QR-Code-Fluss (KC-Key → Guest oder Vorregistrierung), Permission-Guards je Rolle/Scope, Guest-Login (Name-Pflicht, temporär).
3. **Workshop-Wahl-Engine** Portierung von Wahlen/Workshops/Teilnehmer/Zuteilungslogik (inkl. Force-Zuteilung, Kapazitätsprüfung, CSV-Export) aus dem WP-Plugin; LT-Verwaltung pro KC; Konfi-Formular + Ergebnisanzeige im Client. *depends on 12* - **KC** (Konfi-Castle-Event) = oberster Mandant. Eine App-Instanz verwaltet mehrere KCs parallel. Felder: `name`, `inviteCode` (eindeutig, Basis für QR/Code-Einstieg), `isActive`.
4. **Dateifreigabe** Speicher-Abstraktion über Nextcloud/S3, Sichtbarkeitsstufen, LT-Upload-Verwaltung. *depends on 12, parallel mit 3* - **Gemeinde**: lokale Gemeinde/Kirchengemeinde innerhalb eines KC (`kcId` + `name`, eindeutig pro KC).
5. **Kommunikation** Gruppenchat/DM/LT-Kanäle/Broadcast, WebSocket-Transport, Push-Integration. *depends on 12, parallel mit 34* - **Rollenmodell** (Enum `Role`, Authentik-gestützt):
6. **Hybrid Lokal/Cloud-Server & Sync** gleiche Backend-Software als Cloud- oder Vor-Ort-Instanz deploybar, Client-seitige Auto-Discovery des lokalen Servers, Append-only-Change-Log-Sync, lokaler Server = alleinige Quelle der Wahrheit während Live-Events. *depends on 15 stabil* - **Leitungsteam (LT)** global über alle KCs hinweg (Authentik-Gruppe); bleibt LT auf jedem KC, bis die Authentik-Gruppenmitgliedschaft entfernt wird.
7. **Flutter-Clients** gemeinsame Codebase; Screens: Invite/Login, rollenspezifisches Dashboard, Wahl-Formular/Ergebnis, Dateien, Chat, Admin/Nutzerverwaltung. *iterativ parallel zu 36, sobald jeweilige API-Verträge stehen* - **Gemeinde Verantwortliche** pro Gemeinde/KC; verwalten **nur Nutzer ihrer eigenen Gemeinde** (legen Teamer an); Wahlen/Workshops macht ausschließlich LT.
- **Gemeinde Teamer** von Verantwortlichen angelegt, ebenfalls Authentik-Account.
- **Guest/Konfi** optionaler, rein lokaler Account auf dem jeweiligen Server (kein Authentik), Vor-/Nachname Pflicht, **temporär pro KC/Event** (nicht wiederverwendbar über mehrere Events).
- **Membership**: verknüpft `User` (Authentik) ⇄ `Kc` (+ optional `Gemeinde`) ⇄ `Role`. LT-Memberships lassen `gemeindeId` leer und gelten global.
- **Einstieg/Onboarding**: LT vergibt pro KC einen Code/QR (enthält den KC-Key). Damit erhält man sofortigen Guest-Zugang **oder** kann sich vorab als Gemeinde Verantwortlicher/Teamer einer Gemeinde registrieren.
- **Wahl** (Workshop-Wahl): von LT pro KC angelegt; Name trägt `datumsSchluessel` + `teil` (Bewusste Vereinfachung ggü. Original-Plugin: dort gibt es mehrere "Phasen" *innerhalb* einer Wahl via `Teilnehmer.phase`; hier ist stattdessen **eine Wahl = ein Teil/Phase**, gemäß expliziter Nutzer-Klarstellung).
- **Workshop**: `kapazitaet`, `minTeilnehmer` (für Konsolidierung unterbesetzter Workshops).
- **Teilnehmer**: Guest übermittelt `prioritaeten` (geordnete Workshop-ID-Liste, max. 3 entspricht wunsch1..wunsch3 im Original).
- **ForceZuteilung**: manuelle LT-Override vor Algorithmus-Lauf, hat Vorrang.
- **Zuteilung**: Ergebnis pro Teilnehmer (`workshopId` nullable = unzugeteilt, `wunschRang`, `isForced`).
- **Datei-Sichtbarkeit** (Enum `FileVisibility`): `ALLE` / `ALLE_AUSSER_KONFIS` / `NUR_LT`. Dateien werden vom LT hochgeladen, teilbar je nach KC-übergreifend/eingeschränkt gemäß Sichtbarkeitsstufe.
- **Chat** (Enum `ChatChannelType`): `GEMEINDE_GRUPPE`, `DIREKT` (1:1, explizite `ChatParticipant`-Zuordnung), `LT_UEBERGREIFEND`, `BROADCAST` (Konfis nur lesend).
- **Sync-Infrastruktur**: `SyncLogEntry` (Append-only-Replikationslog: `model`, `recordId`, `operation`, `payload`, `originId`, autoincrement `sequence`) + `SyncCursor` (pro Peer: `lastPushedSequence`/`lastPulledSequence`).
Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.prisma).
---
## 2. Architekturentscheidungen
| Bereich | Entscheidung | Begründung |
|---|---|---|
| Backend | NestJS 10 + PostgreSQL + Prisma 5 | bestätigt vom Nutzer; Nest 10 statt CLI-Default (siehe unten) |
| Client | Flutter, eine Codebase Mobile/Web/Desktop | vom Nutzer delegiert; noch nicht scaffoldbar (Flutter fehlt lokal) |
| Web-Interimslösung | Backend liefert `client/web/` (reines HTML/CSS/JS, kein Build-Schritt) über `ServeStaticModule` aus; REST-API liegt unter `/api/*` | Nutzerwunsch: "Server soll auch Web-Client bereitstellen"; vermeidet Kollision zwischen API-Routen und statischen Dateien |
| Auth (Team) | Authentik als OIDC Resource Server (JWKS-Verifikation), kein lokaler Autorisierungscode-Flow im Backend | Clients machen Authorization Code + PKCE direkt gegen Authentik; Backend validiert nur Access Token + löst lokale `Membership` auf |
| Auth (Guest) | Rein lokale JWTs (`GUEST_JWT_SECRET`), kein Authentik | explizite Nutzervorgabe: Konfi-Accounts sind nie in Authentik |
| Datei-Storage | Provider-Abstraktion (`StorageProvider`), Default **Nextcloud/WebDAV**, umschaltbar auf S3 via `STORAGE_PROVIDER=s3` | Nutzer bestätigte: Nextcloud-Zugangsdaten kommen aus `.env` |
| Chat-Transport | Raw `ws`-Gateway (`@nestjs/platform-ws`) statt Socket.IO | Passt zum schlanken REST-Stack; Rollen/Sichtbarkeits-Logik zentral in `ChatService`, geteilt zwischen REST und WS |
| Sync-Richtung | **Lokaler Server initiiert immer** Push *und* Pull gegen die Cloud-URL | Cloud kann i. d. R. nicht in ein lokales Eventnetzwerk zurückwählen (NAT); lokaler Server kann aber ausgehend zur Cloud verbinden, wenn Internet verfügbar ist |
| Sync-Konflikte | Keine Konfliktauflösung nötig | Nutzer bestätigte explizit: lokaler Server ist während eines laufenden Events alleinige Quelle der Wahrheit |
| Rollen-Scope-Guard | `RolesGuard` behandelt `LEITUNGSTEAM`-Memberships als global (kcId-Check wird übersprungen) | Spiegelt die Anforderung "LT bleibt LT auf allen KCs" direkt in der Autorisierungslogik |
### Bekannte Einschränkungen / offene Punkte
- **Datei-Bytes werden nicht repliziert** nur Metadaten (inkl. `storageKey`) laufen durchs Sync-Log. Lokaler und Cloud-Server müssen denselben Nextcloud/S3-Backend-Zugriff haben, damit ein `storageKey` auf beiden Seiten auflösbar ist.
- **Push-Benachrichtigungen (FCM/APNs)** sind im Plan vorgesehen, aber noch nicht implementiert (Teil der noch ausstehenden Flutter-Client-Arbeit).
- **Gemeinde-Verwaltung (CRUD)** existiert aktuell nur als Datenmodell; es gibt noch keinen eigenen `GemeindeController` zum Anlegen/Verwalten von Gemeinden durch LT (bisher nur implizit über Membership/GuestAccount referenziert). Sollte vor dem Produktivbetrieb ergänzt werden.
- **Authentik-Provisionierung**: Wenn ein Gemeinde Verantwortlicher einen Teamer anlegt, muss dieser aktuell weiterhin manuell (oder über eine noch zu bauende Authentik-Admin-API-Integration) in Authentik angelegt werden — das Backend erwartet, dass der `User` mit passendem `authentikSub` bereits existiert, bevor er sich einloggen kann.
---
## 3. Umgesetzte Backend-Module (Stand: alle Phasen abgeschlossen)
| Modul | Kernfunktion | Wichtige Endpunkte |
|---|---|---|
| `prisma/` | Geteilter `PrismaClient`-Provider | |
| `auth/` | Authentik-Resource-Server-Strategie (`AuthGuard('authentik')`), Guest-Login (`AuthGuard('guest')`), `TokenVerificationService` für manuelle Verifikation außerhalb des HTTP/Passport-Pfads (WS-Handshake) | `POST /api/auth/guest` |
| `kc/` | KC-Verwaltung, Leitungsteam-only | `POST /api/kc`, `GET /api/kc` |
| `wahl/` | Wahl-/Workshop-Verwaltung, Force-Zuteilung, Teilnehmer-Einreichung, `ZuteilungService` (Portierung von `kc_run_zuteilung`: Force-Zuteilungen → 3 Wunschrunden → Zufallsfüllung → Konsolidierung unterbesetzter Workshops), CSV-Export | `POST/GET /api/wahl`, `POST/GET /api/wahl/:id/workshops`, `POST /api/wahl/:id/force-zuteilung`, `POST /api/wahl/:id/teilnehmer` (Guest), `POST /api/wahl/:id/zuteilung/run`, `GET /api/wahl/:id/zuteilung(.csv)` |
| `files/` | Upload (LT-only, multipart) mit Sichtbarkeitsstufe; Liste/Download für Team oder Guest, gefiltert nach erlaubten Sichtbarkeitsstufen; `StorageProvider`-Abstraktion (WebDAV/Nextcloud Default, S3 optional) | `POST /api/files/:kcId`, `GET /api/files/:kcId`, `GET /api/files/download/:fileId` |
| `chat/` | Zentrale Zugriffslogik (`ChatService`) geteilt zwischen REST (`ChatController`) und WS (`ChatGateway`, Pfad `/chat`, eigene Token-Verifikation via `?token=`); Kanaltypen wie oben | `POST /api/chat/:kcId/channels`, `POST /api/chat/direct`, `GET /api/chat/:kcId/channels`, `GET /api/chat/channels/:id/messages`, WS-Events `chat:join`/`chat:send`/`chat:message` |
| `sync/` | Append-only Replikationslog + Peer-Sync (lokal ⇄ Cloud), `SyncSchedulerService` (alle 30s, wenn `SYNC_ENABLED=true`) | `POST /api/sync/ingest`, `GET /api/sync/export`, `POST /api/sync/trigger` (LT-only) |
| `common/` | `Role`-Enum, `@Roles()`-Decorator, `RolesGuard` (KC-scoped, LT global) | |
| Web-Client-Hosting | `ServeStaticModule` liefert `client/web/` aus; API unter globalem Prefix `/api` | `GET /` (index.html), `/app.js`, `/style.css` |
Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/README.md).
---
## 4. Tech-Stack-Stolpersteine (dokumentiert für Nachvollziehbarkeit)
- `npx @nestjs/cli new` mit aktuellen Defaults (Nest v12-Beta, ESM, Vitest, `@nestjs/observe`) löste einen reproduzierbaren npm-Arborist-Bug aus (`Cannot read properties of null (reading 'edgesOut')`). Workaround: `backend/package.json` wurde von Hand mit gepinnten, stabilen Versionen (Nest 10.x, Jest, CommonJS, TypeScript 5.x) erstellt statt über den CLI-Generator.
- Bei zusätzlichen offiziellen `@nestjs/*`-Paketen (`serve-static`, `schedule`) wurden die Peer-Dependencies vor der Installation geprüft (`npm view <pkg>@<version> peerDependencies`), da die jeweils neuesten Majors bereits Nest 11/12 voraussetzen und sonst mit `ERESOLVE` fehlschlagen. Gepinnt: `@nestjs/serve-static@4.0.2`, `@nestjs/schedule@4.1.1`.
- `multer` wurde von 1.x (bekannte CVEs) auf 2.x aktualisiert.
---
## 5. Phasenübersicht (Referenz, ursprüngliche Reihenfolge)
1. **Fundament** Monorepo-Skeleton, Datenmodell, Authentik-OIDC-Integration. ✅
2. **Multi-Tenancy & Auth** Invite/QR-Code-Fluss, Permission-Guards, Guest-Login. ✅
3. **Workshop-Wahl-Engine** Wahlen/Workshops/Zuteilungslogik/CSV-Export. ✅
4. **Dateifreigabe** Storage-Abstraktion, Sichtbarkeitsstufen. ✅
5. **Kommunikation** Chat (Gruppen/DM/LT/Broadcast), WebSocket. ✅ (Push-Integration noch offen)
6. **Hybrid Lokal/Cloud-Server & Sync** Replikationslog, Scheduler, Shared-Secret-Auth. ✅
7. **Flutter-Clients** gemeinsame Codebase; Screens: Invite/Login, rollenspezifisches Dashboard, Wahl-Formular/Ergebnis, Dateien, Chat, Admin/Nutzerverwaltung. ❌ **offen** (Flutter nicht installiert); Web-Interimslösung siehe Abschnitt 3.
---
## 6. Relevante Referenz
**Relevante Referenz**
- WP-Plugin als fachliche Vorlage für Zuteilungslogik: `includes/zuteilungslogik.php` (`kc_run_zuteilung`), Admin-Module `admin-wahlen.php`, `admin-workshops.php`, `admin-teilnehmer.php`, `admin-teamer.php`, `admin-zuteilungen.php`, Frontend-Shortcodes in `frontend-form.php`/`frontend-ergebnis.php` (git.konfi-castle.com/linus/Workshop-Wahlen). - WP-Plugin als fachliche Vorlage für Zuteilungslogik: `includes/zuteilungslogik.php` (`kc_run_zuteilung`), Admin-Module `admin-wahlen.php`, `admin-workshops.php`, `admin-teilnehmer.php`, `admin-teamer.php`, `admin-zuteilungen.php`, Frontend-Shortcodes in `frontend-form.php`/`frontend-ergebnis.php` (git.konfi-castle.com/linus/Workshop-Wahlen).
**Verifikation** ---
1. Nach Phase 1: Login-Flow testbar (LT via Authentik, Guest via KC-Code), Rechte-Guards per Integrationstests.
2. Nach Phase 3: Zuteilungslogik mit Testdaten gegen bekannte Ergebnisse aus dem alten Plugin validieren.
3. Nach Phase 6: Sync-Test — Änderungen am lokalen Server während simuliertem Offline-Zustand, danach Cloud-Abgleich prüfen.
4. Ende-zu-Ende: Rollenmatrix (LT/Verantwortlicher/Teamer/Guest) manuell in allen Kernfeatures durchspielen.
**Entscheidungen** ## 7. Verifikation (durchgeführt je Phase)
- Backend: NestJS + PostgreSQL + Prisma (bestätigt).
- Client: Flutter, eine Codebase für Mobile/Web/Desktop (auf Wunsch des Nutzers von mir entschieden). 1. Nach jeder Phase: `npx tsc -p tsconfig.build.json --noEmit`, `npx nest build`, sowie ein kurzer Boot-Test (`node dist/main.js`) zur Prüfung, dass der DI-Graph auflöst und alle Routen korrekt gemappt werden (ohne Live-DB/Authentik/Nextcloud).
- Zuteilungen-Konflikte: kein echtes Konfliktmodell nötig, da lokaler Server während Events alleinige Quelle der Wahrheit ist. 2. Sync-Modul: manuell verifiziert, dass `SyncSecretGuard` Requests ohne `x-sync-secret` mit 403 ablehnt und mit korrektem Secret durchlässt (DB-Fehler in der Sandbox ist erwartet, da kein Postgres läuft).
- WP-Plugin wird vollständig abgelöst, nicht weiterverwendet (nur als fachliche Vorlage). 3. Web-Client: `GET /` liefert die statische Seite (200), `GET /api/kc` trifft die echte, geschützte API (401 ohne Token).
4. Noch ausstehend (sobald echte Infrastruktur verfügbar ist): Zuteilungslogik gegen bekannte Testdaten aus dem alten Plugin validieren; Ende-zu-Ende-Rollenmatrix (LT/Verantwortlicher/Teamer/Guest) über alle Kernfeatures; echter Sync-Test zwischen zwei laufenden Instanzen (lokal + Cloud).
---
## 8. Nächste Schritte
1. Sobald Flutter verfügbar ist: Client-Grundgerüst aufsetzen (Mobile + Web + Desktop, eine Codebase), beginnend mit Invite/Login-Flow gegen die bestehende API.
2. `GemeindeController` ergänzen (LT-CRUD für Gemeinden), da bisher nur das Datenmodell existiert.
3. Authentik-Admin-API-Integration für automatische Teamer-Provisionierung durch Gemeinde Verantwortliche.
4. Push-Benachrichtigungen (FCM/APNs) für Chat/Ankündigungen.
5. Echte Infrastruktur (Postgres, Authentik, Nextcloud/S3) aufsetzen und die in Abschnitt 7 offenen Verifikationsschritte durchführen.