2 Commits
Author SHA1 Message Date
linus 327ce43404 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.
2026-09-09 11:11:32 +02:00
linus 60ee8111af Initial commit 2026-09-09 08:30:18 +00:00
26 changed files with 10912 additions and 3 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
+2 -2
View File
@@ -208,7 +208,7 @@ If you develop a new program, and you want it to be of the greatest possible use
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
KC-APP-Server KC-APP
Copyright (C) 2026 linus Copyright (C) 2026 linus
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
@@ -221,7 +221,7 @@ Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
KC-APP-Server Copyright (C) 2026 linus KC-APP Copyright (C) 2026 linus
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
+36 -1
View File
@@ -1,2 +1,37 @@
# KC-APP-Server # 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
}
}