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
+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
}
}