feat(backend): implement phases 0-6 (auth, kc, wahl, files, chat, sync)

Full NestJS backend for the KC-App platform:
- auth: Authentik OIDC resource-server strategy + guest invite-code JWT
  login, plus TokenVerificationService for the WS handshake path
- kc: Leitungsteam-only KC (event) creation/listing
- wahl: Wahl/Workshop admin, Force-Zuteilung overrides, ZuteilungService
  (port of the WP plugin's kc_run_zuteilung), CSV export
- files: LT-only upload with visibility tiers; list/download filtered by
  caller tier; StorageProvider abstraction (WebDAV/Nextcloud default, S3)
- chat: Gemeinde group / DM / LT-wide / broadcast channels; REST + raw ws
  gateway sharing ChatService access rules
- sync: append-only SyncLogEntry replication log + local<->cloud
  push/pull scheduler, shared-secret guarded
- common: Role enum, @Roles decorator, KC-scoped RolesGuard (LT global)
- serves client/web/ interim static web client under / (API under /api)

Typecheck, nest build and boot test pass; needs real Postgres/Authentik/
Nextcloud to run end to end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 16:23:45 +02:00
co-authored by Claude Sonnet 5
parent 327ce43404
commit 8ec127c0fb
42 changed files with 2648 additions and 78 deletions
+13
View File
@@ -0,0 +1,13 @@
import { AuthenticatedUser } from '../auth/authenticated-request';
import { GuestJwtPayload } from '../auth/guest-auth.service';
import { ChatCaller } from './chat.service';
function isGuestPayload(user: unknown): user is GuestJwtPayload {
return !!user && typeof user === 'object' && 'guestId' in user;
}
/// req.user is either an AuthenticatedUser (Authentik) or a GuestJwtPayload,
/// depending on which strategy AuthGuard(['authentik','guest']) picked.
export function resolveChatCaller(user: AuthenticatedUser | GuestJwtPayload): ChatCaller {
return isGuestPayload(user) ? { kind: 'guest', guest: user } : { kind: 'user', user };
}
+45
View File
@@ -0,0 +1,45 @@
import { Body, Controller, Get, Param, Post, Req, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ChatService } from './chat.service';
import { CreateChannelDto } from './dto/create-channel.dto';
import { CreateDirectChannelDto } from './dto/create-direct-channel.dto';
import { Roles } from '../common/roles.decorator';
import { RolesGuard } from '../common/roles.guard';
import { Role } from '../common/role.enum';
import { AuthenticatedRequest } from '../auth/authenticated-request';
import { GuestJwtPayload } from '../auth/guest-auth.service';
import { resolveChatCaller } from './caller.util';
type ChatRequest = AuthenticatedRequest & { user?: AuthenticatedRequest['user'] | GuestJwtPayload };
@Controller('chat')
export class ChatController {
constructor(private readonly chat: ChatService) {}
/// Channel administration (Gemeinde-Gruppen, LT-Kanäle, Broadcasts) is Leitungsteam-only.
@Post(':kcId/channels')
@UseGuards(AuthGuard('authentik'), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
createChannel(@Param('kcId') kcId: string, @Body() dto: CreateChannelDto) {
return this.chat.createChannel(kcId, dto.type, dto.gemeindeId);
}
/// Any two team members of the same KC can start a direct conversation.
@Post('direct')
@UseGuards(AuthGuard('authentik'))
createDirectChannel(@Body() dto: CreateDirectChannelDto, @Req() req: AuthenticatedRequest) {
return this.chat.getOrCreateDirectChannel(dto.kcId, req.user!.userId, dto.otherUserId);
}
@Get(':kcId/channels')
@UseGuards(AuthGuard(['authentik', 'guest']))
listChannels(@Param('kcId') kcId: string, @Req() req: ChatRequest) {
return this.chat.listChannelsForCaller(kcId, resolveChatCaller(req.user!));
}
@Get('channels/:channelId/messages')
@UseGuards(AuthGuard(['authentik', 'guest']))
listMessages(@Param('channelId') channelId: string, @Req() req: ChatRequest) {
return this.chat.listMessages(channelId, resolveChatCaller(req.user!));
}
}
+100
View File
@@ -0,0 +1,100 @@
import {
ConnectedSocket,
MessageBody,
OnGatewayConnection,
OnGatewayDisconnect,
SubscribeMessage,
WebSocketGateway,
} from '@nestjs/websockets';
import { Logger } from '@nestjs/common';
import { IncomingMessage } from 'http';
import { WebSocket } from 'ws';
import { TokenVerificationService } from '../auth/token-verification.service';
import { ChatCaller, ChatService } from './chat.service';
/// Raw `ws` gateway (no socket.io rooms available), so channel membership is
/// tracked manually per connected socket. Auth happens once at handshake via
/// a `?token=` query param since passport guards don't run for WS upgrades.
@WebSocketGateway({ path: '/chat' })
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
private readonly logger = new Logger(ChatGateway.name);
private readonly callers = new WeakMap<WebSocket, ChatCaller>();
private readonly rooms = new Map<string, Set<WebSocket>>();
constructor(
private readonly tokenVerification: TokenVerificationService,
private readonly chat: ChatService,
) {}
async handleConnection(client: WebSocket, request: IncomingMessage) {
const token = new URL(request.url ?? '', 'http://localhost').searchParams.get('token');
if (!token) {
client.close(4001, 'Missing token');
return;
}
try {
this.callers.set(client, await this.tokenVerification.verifyEither(token));
} catch (err) {
this.logger.warn(`WS auth failed: ${(err as Error).message}`);
client.close(4001, 'Unauthorized');
}
}
handleDisconnect(client: WebSocket) {
this.callers.delete(client);
for (const members of this.rooms.values()) {
members.delete(client);
}
}
@SubscribeMessage('chat:join')
async onJoin(
@ConnectedSocket() client: WebSocket,
@MessageBody() data: { channelId: string },
) {
const caller = this.requireCaller(client);
await this.chat.assertCanRead(data.channelId, caller);
this.roomFor(data.channelId).add(client);
return { event: 'chat:joined', data: { channelId: data.channelId } };
}
@SubscribeMessage('chat:send')
async onSend(
@ConnectedSocket() client: WebSocket,
@MessageBody() data: { channelId: string; body: string },
) {
const caller = this.requireCaller(client);
const message = await this.chat.sendMessage(data.channelId, caller, data.body);
this.broadcast(data.channelId, { event: 'chat:message', data: message });
return { event: 'chat:sent', data: { id: message.id } };
}
private requireCaller(client: WebSocket): ChatCaller {
const caller = this.callers.get(client);
if (!caller) {
client.close(4001, 'Unauthorized');
throw new Error('Unauthorized WS client');
}
return caller;
}
private roomFor(channelId: string): Set<WebSocket> {
let room = this.rooms.get(channelId);
if (!room) {
room = new Set();
this.rooms.set(channelId, room);
}
return room;
}
private broadcast(channelId: string, payload: unknown) {
const room = this.rooms.get(channelId);
if (!room) return;
const json = JSON.stringify(payload);
for (const socket of room) {
if (socket.readyState === socket.OPEN) {
socket.send(json);
}
}
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { ChatService } from './chat.service';
import { ChatGateway } from './chat.gateway';
import { ChatController } from './chat.controller';
@Module({
imports: [AuthModule],
controllers: [ChatController],
providers: [ChatService, ChatGateway],
})
export class ChatModule {}
+165
View File
@@ -0,0 +1,165 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { ChatChannelType, Role, SyncOperation } from '@prisma/client';
import { PrismaClient } from '../prisma/prisma.module';
import { AuthenticatedUser } from '../auth/authenticated-request';
import { GuestJwtPayload } from '../auth/guest-auth.service';
import { SyncService } from '../sync/sync.service';
export type ChatCaller =
| { kind: 'user'; user: AuthenticatedUser }
| { kind: 'guest'; guest: GuestJwtPayload };
@Injectable()
export class ChatService {
constructor(
private readonly prisma: PrismaClient,
private readonly sync: SyncService,
) {}
async createChannel(kcId: string, type: ChatChannelType, gemeindeId?: string) {
const channel = await this.prisma.chatChannel.create({ data: { kcId, type, gemeindeId } });
await this.sync.capture('ChatChannel', SyncOperation.CREATE, channel.id, channel);
return channel;
}
async getOrCreateDirectChannel(kcId: string, userAId: string, userBId: string) {
const existing = await this.prisma.chatChannel.findFirst({
where: {
kcId,
type: ChatChannelType.DIREKT,
AND: [
{ participants: { some: { userId: userAId } } },
{ participants: { some: { userId: userBId } } },
],
},
});
if (existing) return existing;
const channel = await this.prisma.chatChannel.create({
data: {
kcId,
type: ChatChannelType.DIREKT,
participants: { create: [{ userId: userAId }, { userId: userBId }] },
},
});
await this.sync.capture('ChatChannel', SyncOperation.CREATE, channel.id, channel);
return channel;
}
async listChannelsForCaller(kcId: string, caller: ChatCaller) {
if (caller.kind === 'guest') {
return this.prisma.chatChannel.findMany({
where: { kcId, type: ChatChannelType.BROADCAST },
});
}
const { user } = caller;
const isLt = user.memberships.some((m) => m.role === Role.LEITUNGSTEAM);
if (isLt) {
return this.prisma.chatChannel.findMany({ where: { kcId } });
}
const gemeindeIds = user.memberships
.filter((m) => m.kcId === kcId && m.gemeindeId)
.map((m) => m.gemeindeId as string);
return this.prisma.chatChannel.findMany({
where: {
kcId,
OR: [
{ type: ChatChannelType.BROADCAST },
{ type: ChatChannelType.GEMEINDE_GRUPPE, gemeindeId: { in: gemeindeIds } },
{ type: ChatChannelType.DIREKT, participants: { some: { userId: user.userId } } },
],
},
});
}
async assertCanRead(channelId: string, caller: ChatCaller) {
return this.getChannelForCallerOrThrow(channelId, caller, 'read');
}
async assertCanWrite(channelId: string, caller: ChatCaller) {
return this.getChannelForCallerOrThrow(channelId, caller, 'write');
}
private async getChannelForCallerOrThrow(
channelId: string,
caller: ChatCaller,
mode: 'read' | 'write',
) {
const channel = await this.prisma.chatChannel.findUnique({
where: { id: channelId },
include: { participants: true },
});
if (!channel) {
throw new NotFoundException('Channel not found');
}
if (caller.kind === 'guest') {
const allowed = channel.type === ChatChannelType.BROADCAST && mode === 'read';
if (!allowed) {
throw new ForbiddenException('Guests may only read broadcast channels');
}
if (caller.guest.kcId !== channel.kcId) {
throw new ForbiddenException('Guest does not belong to this KC');
}
return channel;
}
const { user } = caller;
const isLt = user.memberships.some((m) => m.role === Role.LEITUNGSTEAM);
if (isLt) {
return channel;
}
if (channel.kcId && !user.memberships.some((m) => m.kcId === channel.kcId)) {
throw new ForbiddenException('Not a member of this KC');
}
switch (channel.type) {
case ChatChannelType.BROADCAST:
if (mode === 'write') {
throw new ForbiddenException('Only Leitungsteam may post broadcasts');
}
return channel;
case ChatChannelType.LT_UEBERGREIFEND:
throw new ForbiddenException('Leitungsteam-only channel');
case ChatChannelType.GEMEINDE_GRUPPE: {
const inGemeinde = user.memberships.some(
(m) => m.kcId === channel.kcId && m.gemeindeId === channel.gemeindeId,
);
if (!inGemeinde) {
throw new ForbiddenException('Not a member of this Gemeinde');
}
return channel;
}
case ChatChannelType.DIREKT: {
const isParticipant = channel.participants.some((p) => p.userId === user.userId);
if (!isParticipant) {
throw new ForbiddenException('Not a participant of this conversation');
}
return channel;
}
default:
throw new ForbiddenException('Unknown channel type');
}
}
async sendMessage(channelId: string, caller: ChatCaller, body: string) {
await this.assertCanWrite(channelId, caller);
const message = await this.prisma.chatMessage.create({
data: {
channelId,
body,
senderUserId: caller.kind === 'user' ? caller.user.userId : null,
senderGuestId: caller.kind === 'guest' ? caller.guest.guestId : null,
},
});
await this.sync.capture('ChatMessage', SyncOperation.CREATE, message.id, message);
return message;
}
async listMessages(channelId: string, caller: ChatCaller) {
await this.assertCanRead(channelId, caller);
return this.prisma.chatMessage.findMany({
where: { channelId },
orderBy: { createdAt: 'asc' },
});
}
}
+11
View File
@@ -0,0 +1,11 @@
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { ChatChannelType } from '@prisma/client';
export class CreateChannelDto {
@IsEnum(ChatChannelType)
type!: ChatChannelType;
@IsOptional()
@IsString()
gemeindeId?: string;
}
+11
View File
@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class CreateDirectChannelDto {
@IsString()
@IsNotEmpty()
kcId!: string;
@IsString()
@IsNotEmpty()
otherUserId!: string;
}