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:
@@ -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 };
|
||||
}
|
||||
@@ -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!));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class CreateDirectChannelDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
kcId!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
otherUserId!: string;
|
||||
}
|
||||
Reference in New Issue
Block a user