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'; import { PushService } from '../push/push.service'; export type ChatCaller = | { kind: 'user'; user: AuthenticatedUser } | { kind: 'guest'; guest: GuestJwtPayload }; const CHANNEL_TITLES: Record = { [ChatChannelType.GEMEINDE_GRUPPE]: 'Gemeinde-Gruppe', [ChatChannelType.DIREKT]: 'Direktnachricht', [ChatChannelType.LT_UEBERGREIFEND]: 'Leitungsteam', [ChatChannelType.BROADCAST]: 'Ankündigung', }; @Injectable() export class ChatService { constructor( private readonly prisma: PrismaClient, private readonly sync: SyncService, private readonly push: PushService, ) {} 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) { const channel = 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); void this.push.notifyChannel( channelId, { title: CHANNEL_TITLES[channel?.type ?? ChatChannelType.GEMEINDE_GRUPPE], body: body.length > 140 ? `${body.slice(0, 137)}…` : body, data: { channelId }, }, { userId: caller.kind === 'user' ? caller.user.userId : null, guestId: caller.kind === 'guest' ? caller.guest.guestId : null, }, ); return message; } async listMessages(channelId: string, caller: ChatCaller) { await this.assertCanRead(channelId, caller); return this.prisma.chatMessage.findMany({ where: { channelId }, orderBy: { createdAt: 'asc' }, }); } }