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:
@@ -1,2 +1,26 @@
|
||||
# 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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
.env
|
||||
*.log
|
||||
@@ -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.
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
Generated
+10215
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class CreateKcDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name!: string;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 2–5 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 1–2*
|
||||
4. **Dateifreigabe** – Speicher-Abstraktion über Nextcloud/S3, Sichtbarkeitsstufen, LT-Upload-Verwaltung. *depends on 1–2, parallel mit 3*
|
||||
5. **Kommunikation** – Gruppenchat/DM/LT-Kanäle/Broadcast, WebSocket-Transport, Push-Integration. *depends on 1–2, parallel mit 3–4*
|
||||
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 1–5 stabil*
|
||||
7. **Flutter-Clients** – gemeinsame Codebase; Screens: Invite/Login, rollenspezifisches Dashboard, Wahl-Formular/Ergebnis, Dateien, Chat, Admin/Nutzerverwaltung. *iterativ parallel zu 3–6, 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).
|
||||
Reference in New Issue
Block a user