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,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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user