feat: initialize backend with NestJS, PostgreSQL, and Prisma

- Add package.json for backend dependencies and scripts.
- Create Prisma schema for multi-tenant event management.
- Implement main application module and configure global settings.
- Develop authentication module with JWT and Authentik integration.
- Create DTOs for guest account creation and KC management.
- Implement role-based access control with custom guards and decorators.
- Add services and controllers for managing KCs and guest accounts.
- Set up global validation and CORS in the main application entry point.
- Establish Prisma module for database access throughout the application.
- Document project plan and architecture for multi-tenant platform.
This commit is contained in:
2026-09-09 11:11:32 +02:00
parent 4c9cb814c1
commit e49eed871c
27 changed files with 10972 additions and 0 deletions
+24
View File
@@ -1,2 +1,26 @@
# KC-APP # KC-APP
Multi-tenant event, election and communication platform for Konfi-Castle
events (KCs), replacing the WordPress plugin "Workshop-Wahlen". See
[plan-kcAppMultiTenantPlatform.prompt.md](plan-kcAppMultiTenantPlatform.prompt.md)
for the full architecture and phased roadmap.
## Structure
- `backend/` — NestJS API (Prisma/PostgreSQL, Authentik OIDC as resource
server, guest/Konfi local accounts, roles/permissions foundation). See
[backend/README.md](backend/README.md) for setup.
- `client/` — planned Flutter app (mobile + web + desktop), not yet
scaffolded (Flutter is not installed in this environment).
## Status
Phase 0/1 foundation implemented: monorepo skeleton, Prisma data model (Kc,
Gemeinde, User, Membership, GuestAccount, Wahl/Workshop/Teilnehmer/Zuteilung,
File, Chat), Authentik JWT resource-server strategy, guest invite-code login,
Role-based guard scoped per KC. Backend builds and boots cleanly
(`npm run build`, `node dist/main.js`) but requires a real PostgreSQL
database and Authentik instance (see `backend/.env.example`) to run end to
end. Remaining phases (Wahl-Engine, Dateifreigabe, Chat realtime, Lokal/Cloud
Sync, Flutter clients) are not yet implemented.
+10
View File
@@ -0,0 +1,10 @@
# Postgres connection used by Prisma
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/kcapp?schema=public"
# Authentik OIDC issuer, e.g. https://auth.example.org/application/o/kc-app/
AUTHENTIK_ISSUER_URL="https://authentik.example.org/application/o/kc-app"
# Secret used to sign guest/Konfi session tokens (local accounts only)
GUEST_JWT_SECRET="change-me"
PORT=3000
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
coverage
.env
*.log
+37
View File
@@ -0,0 +1,37 @@
# KC-App Backend
NestJS API for the KC-App platform (see repo root README + plan for
architecture context).
## Setup
```bash
npm install
cp .env.example .env # then fill in DATABASE_URL / AUTHENTIK_ISSUER_URL / GUEST_JWT_SECRET
npx prisma generate
npx prisma migrate dev --name init # requires a running PostgreSQL instance
npm run start:dev
```
## Auth model
- Team members (Leitungsteam, Gemeinde Verantwortliche, Gemeinde Teamer) are
provisioned in Authentik; this API acts as an OIDC **resource server**,
verifying access tokens against Authentik's JWKS (`AuthentikStrategy`) and
then resolving local `Membership` rows to determine role + KC/Gemeinde
scope. Clients perform the actual Authorization Code + PKCE flow against
Authentik directly.
- Guests/Konfis get a temporary local account (first/last name required, no
Authentik) created via `POST /auth/guest` with a KC invite code, returning
a JWT signed with `GUEST_JWT_SECRET`.
## Modules implemented so far
- `prisma/` — shared `PrismaClient` provider.
- `auth/` — Authentik resource-server strategy + guest invite-code login.
- `kc/` — KC (event) creation/listing, Leitungsteam-only.
- `common/``Role` enum, `@Roles()` decorator, `RolesGuard` (KC-scoped,
Leitungsteam roles are global across all KCs).
Not yet implemented: Wahl/Workshop/Zuteilung engine, file sharing, chat
realtime gateway, local/cloud sync engine.
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+10215
View File
File diff suppressed because it is too large Load Diff
+80
View File
@@ -0,0 +1,80 @@
{
"name": "backend",
"version": "0.0.1",
"description": "KC-App backend (NestJS)",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:e2e": "jest --config ./test/jest-e2e.json",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev"
},
"dependencies": {
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/platform-ws": "^10.4.15",
"@nestjs/websockets": "^10.4.15",
"@prisma/client": "^5.22.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"jwks-rsa": "^3.1.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"ws": "^8.18.0"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@nestjs/testing": "^10.4.15",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.14",
"@types/node": "^20.17.9",
"@types/passport": "^1.0.17",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2",
"@types/ws": "^8.5.13",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"jest": "^29.7.0",
"prettier": "^3.4.2",
"prisma": "^5.22.0",
"source-map-support": "^0.5.21",
"supertest": "^6.3.4",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.6.3"
},
"jest": {
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": ["**/*.(t|j)s"],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
+191
View File
@@ -0,0 +1,191 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
/// A Konfi-Castle event; the top-level tenant. One instance manages many KCs.
model Kc {
id String @id @default(cuid())
name String
inviteCode String @unique
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
gemeinden Gemeinde[]
memberships Membership[]
wahlen Wahl[]
files File[]
channels ChatChannel[]
guests GuestAccount[]
}
/// A local congregation/community participating in one Kc.
model Gemeinde {
id String @id @default(cuid())
name String
kcId String
createdAt DateTime @default(now())
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
memberships Membership[]
guests GuestAccount[]
@@unique([kcId, name])
}
enum Role {
LEITUNGSTEAM
GEMEINDE_VERANTWORTLICHER
GEMEINDE_TEAMER
}
/// Authentik-backed user (team member with elevated rights).
model User {
id String @id @default(cuid())
authentikSub String @unique
email String @unique
firstName String
lastName String
createdAt DateTime @default(now())
memberships Membership[]
messages ChatMessage[]
}
/// Scopes a User's role to a specific Kc (and Gemeinde, if applicable).
/// LEITUNGSTEAM memberships apply to all Kcs implicitly and omit gemeindeId.
model Membership {
id String @id @default(cuid())
userId String
kcId String
gemeindeId String?
role Role
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade)
@@unique([userId, kcId, gemeindeId])
}
/// Local, non-Authentik account for Konfis/guests, scoped to one Kc/event.
model GuestAccount {
id String @id @default(cuid())
kcId String
gemeindeId String?
firstName String
lastName String
createdAt DateTime @default(now())
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade)
messages ChatMessage[]
teilnehmer Teilnehmer[]
}
/// A workshop election, scoped to a Kc; name carries a date key + "Teil".
model Wahl {
id String @id @default(cuid())
kcId String
name String
datumsSchluessel String
teil String
isOpen Boolean @default(true)
createdAt DateTime @default(now())
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
workshops Workshop[]
teilnehmer Teilnehmer[]
}
model Workshop {
id String @id @default(cuid())
wahlId String
name String
kapazitaet Int
wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade)
zuteilungen Zuteilung[]
}
/// A participant's submitted choices for a Wahl.
model Teilnehmer {
id String @id @default(cuid())
wahlId String
guestAccountId String
prioritaeten Json
createdAt DateTime @default(now())
wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade)
guestAccount GuestAccount @relation(fields: [guestAccountId], references: [id], onDelete: Cascade)
zuteilung Zuteilung?
@@unique([wahlId, guestAccountId])
}
/// Result of the assignment algorithm (or a manual force-assignment) for one Teilnehmer.
model Zuteilung {
id String @id @default(cuid())
teilnehmerId String @unique
workshopId String
isForced Boolean @default(false)
createdAt DateTime @default(now())
teilnehmer Teilnehmer @relation(fields: [teilnehmerId], references: [id], onDelete: Cascade)
workshop Workshop @relation(fields: [workshopId], references: [id], onDelete: Cascade)
}
enum FileVisibility {
ALLE
ALLE_AUSSER_KONFIS
NUR_LT
}
model File {
id String @id @default(cuid())
kcId String
storageKey String
filename String
visibility FileVisibility
uploadedById String
createdAt DateTime @default(now())
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
}
enum ChatChannelType {
GEMEINDE_GRUPPE
DIREKT
LT_UEBERGREIFEND
BROADCAST
}
model ChatChannel {
id String @id @default(cuid())
kcId String
type ChatChannelType
gemeindeId String?
createdAt DateTime @default(now())
kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade)
messages ChatMessage[]
}
model ChatMessage {
id String @id @default(cuid())
channelId String
senderUserId String?
senderGuestId String?
body String
createdAt DateTime @default(now())
channel ChatChannel @relation(fields: [channelId], references: [id], onDelete: Cascade)
senderUser User? @relation(fields: [senderUserId], references: [id])
senderGuest GuestAccount? @relation(fields: [senderGuestId], references: [id])
}
+15
View File
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module';
import { KcModule } from './kc/kc.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
PrismaModule,
AuthModule,
KcModule,
],
})
export class AppModule {}
+14
View File
@@ -0,0 +1,14 @@
import { Body, Controller, Post } from '@nestjs/common';
import { GuestAuthService } from './guest-auth.service';
import { CreateGuestDto } from './dto/create-guest.dto';
@Controller('auth')
export class AuthController {
constructor(private readonly guestAuth: GuestAuthService) {}
/// Redeems a KC invite code and registers a new temporary guest/Konfi account.
@Post('guest')
createGuest(@Body() dto: CreateGuestDto) {
return this.guestAuth.createGuest(dto.inviteCode, dto.firstName, dto.lastName);
}
}
+23
View File
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller';
import { GuestAuthService } from './guest-auth.service';
import { AuthentikStrategy } from './authentik.strategy';
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.getOrThrow<string>('GUEST_JWT_SECRET'),
signOptions: { expiresIn: '12h' },
}),
}),
],
controllers: [AuthController],
providers: [GuestAuthService, AuthentikStrategy],
})
export class AuthModule {}
+20
View File
@@ -0,0 +1,20 @@
import { Request } from 'express';
import { Role } from '../common/role.enum';
export interface AuthenticatedMembership {
kcId: string;
gemeindeId: string | null;
role: Role;
}
/// Shape attached to req.user by JwtStrategy after validating an access token.
export interface AuthenticatedUser {
userId: string;
authentikSub: string;
email: string;
memberships: AuthenticatedMembership[];
}
export interface AuthenticatedRequest extends Request {
user?: AuthenticatedUser;
}
+62
View File
@@ -0,0 +1,62 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { Strategy } from 'passport-jwt';
import * as jwksRsa from 'jwks-rsa';
import { Request } from 'express';
import { PrismaClient } from '../prisma/prisma.module';
import { AuthenticatedUser } from './authenticated-request';
interface AuthentikJwtPayload {
sub: string;
email: string;
given_name?: string;
family_name?: string;
}
/// Validates access tokens issued by Authentik (resource-server pattern):
/// signature is checked against Authentik's JWKS, then the local Membership
/// table decides what the user may do. Authentik itself is only the identity
/// source, never asked for authorization here.
@Injectable()
export class AuthentikStrategy extends PassportStrategy(Strategy, 'authentik') {
constructor(
config: ConfigService,
private readonly prisma: PrismaClient,
) {
const issuerUrl = config.getOrThrow<string>('AUTHENTIK_ISSUER_URL');
super({
jwtFromRequest: (req: Request) =>
req.headers.authorization?.startsWith('Bearer ')
? req.headers.authorization.slice('Bearer '.length)
: null,
secretOrKeyProvider: jwksRsa.passportJwtSecret({
jwksUri: `${issuerUrl}/jwks/`,
cache: true,
rateLimit: true,
}),
issuer: issuerUrl,
algorithms: ['RS256'],
});
}
async validate(payload: AuthentikJwtPayload): Promise<AuthenticatedUser> {
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,
})),
};
}
}
+15
View File
@@ -0,0 +1,15 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class CreateGuestDto {
@IsString()
@IsNotEmpty()
inviteCode!: string;
@IsString()
@IsNotEmpty()
firstName!: string;
@IsString()
@IsNotEmpty()
lastName!: string;
}
+41
View File
@@ -0,0 +1,41 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { PrismaClient } from '../prisma/prisma.module';
export interface GuestJwtPayload {
guestId: string;
kcId: string;
gemeindeId: string | null;
}
/// Guest/Konfi accounts are local to this server (never Authentik-backed),
/// created via a KC invite code, and scoped to that single KC.
@Injectable()
export class GuestAuthService {
constructor(
private readonly prisma: PrismaClient,
private readonly jwt: JwtService,
) {}
async createGuest(
inviteCode: string,
firstName: string,
lastName: string,
): Promise<{ accessToken: string }> {
const kc = await this.prisma.kc.findUnique({ where: { inviteCode } });
if (!kc || !kc.isActive) {
throw new NotFoundException('Unknown or inactive KC invite code');
}
const guest = await this.prisma.guestAccount.create({
data: { kcId: kc.id, firstName, lastName },
});
const payload: GuestJwtPayload = {
guestId: guest.id,
kcId: kc.id,
gemeindeId: guest.gemeindeId,
};
return { accessToken: await this.jwt.signAsync(payload) };
}
}
+4
View File
@@ -0,0 +1,4 @@
/// Re-exported from the Prisma client so guards and strategies share one
/// enum type with the database schema. Guests are not part of this enum
/// since they authenticate separately and never hold elevated rights.
export { Role } from '@prisma/client';
+7
View File
@@ -0,0 +1,7 @@
import { SetMetadata } from '@nestjs/common';
import { Role } from './role.enum';
export const ROLES_KEY = 'roles';
/// Marks a route as requiring at least one of the given roles (scope-checked by RolesGuard).
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
+50
View File
@@ -0,0 +1,50 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role } from './role.enum';
import { ROLES_KEY } from './roles.decorator';
import { AuthenticatedRequest } from '../auth/authenticated-request';
/// Checks the caller holds one of the required roles, scoped to the KC in the
/// request (route param `kcId`, falling back to body.kcId). LEITUNGSTEAM
/// memberships are global and satisfy any KC scope.
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const user = request.user;
if (!user) {
throw new ForbiddenException('Not authenticated');
}
const kcId = request.params?.kcId ?? request.body?.kcId;
const hasRole = user.memberships.some((membership) => {
if (!requiredRoles.includes(membership.role)) {
return false;
}
if (membership.role === Role.LEITUNGSTEAM) {
return true;
}
return kcId ? membership.kcId === kcId : true;
});
if (!hasRole) {
throw new ForbiddenException('Insufficient role for this KC');
}
return true;
}
}
+7
View File
@@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class CreateKcDto {
@IsString()
@IsNotEmpty()
name!: string;
}
+26
View File
@@ -0,0 +1,26 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { KcService } from './kc.service';
import { CreateKcDto } from './dto/create-kc.dto';
import { Roles } from '../common/roles.decorator';
import { RolesGuard } from '../common/roles.guard';
import { Role } from '../common/role.enum';
@Controller('kc')
@UseGuards(AuthGuard('authentik'), RolesGuard)
export class KcController {
constructor(private readonly kc: KcService) {}
/// Only the Leitungsteam may create new KC events.
@Post()
@Roles(Role.LEITUNGSTEAM)
create(@Body() dto: CreateKcDto) {
return this.kc.createKc(dto.name);
}
@Get()
@Roles(Role.LEITUNGSTEAM)
list() {
return this.kc.listKcs();
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { KcService } from './kc.service';
import { KcController } from './kc.controller';
@Module({
providers: [KcService],
controllers: [KcController],
})
export class KcModule {}
+18
View File
@@ -0,0 +1,18 @@
import { Injectable } from '@nestjs/common';
import { randomBytes } from 'crypto';
import { PrismaClient } from '../prisma/prisma.module';
@Injectable()
export class KcService {
constructor(private readonly prisma: PrismaClient) {}
createKc(name: string) {
return this.prisma.kc.create({
data: { name, inviteCode: randomBytes(6).toString('hex') },
});
}
listKcs() {
return this.prisma.kc.findMany();
}
}
+11
View File
@@ -0,0 +1,11 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
app.enableCors();
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
+17
View File
@@ -0,0 +1,17 @@
import { Global, Module } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
/// Shared Prisma connection; injected wherever DB access is needed.
@Global()
@Module({
providers: [
{
provide: PrismaClient,
useFactory: () => new PrismaClient(),
},
],
exports: [PrismaClient],
})
export class PrismaModule {}
export { PrismaClient };
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strict": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": false
}
}
+37
View File
@@ -0,0 +1,37 @@
# 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).
**Domänenmodell**
- **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.
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*
4. **Dateifreigabe** Speicher-Abstraktion über Nextcloud/S3, Sichtbarkeitsstufen, LT-Upload-Verwaltung. *depends on 12, parallel mit 3*
5. **Kommunikation** Gruppenchat/DM/LT-Kanäle/Broadcast, WebSocket-Transport, Push-Integration. *depends on 12, parallel mit 34*
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*
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*
**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).
**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**
- Backend: NestJS + PostgreSQL + Prisma (bestätigt).
- Client: Flutter, eine Codebase für Mobile/Web/Desktop (auf Wunsch des Nutzers von mir entschieden).
- Zuteilungen-Konflikte: kein echtes Konfliktmodell nötig, da lokaler Server während Events alleinige Quelle der Wahrheit ist.
- WP-Plugin wird vollständig abgelöst, nicht weiterverwendet (nur als fachliche Vorlage).