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:
@@ -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();
|
||||
}
|
||||
}
|
||||
+11
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user