import { BadRequestException, 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', [ChatChannelType.GRUPPE]: 'Gruppenchat', }; export interface CreateGruppeParticipants { userIds?: string[]; guestIds?: string[]; } @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; } /// Free-form group chat: created by a Leitungsteam member (any KC) or a /// Gemeinde Verantwortliche/r (their own KC — enforced by the RolesGuard's /// kcId scoping at the controller level). Konfis (guests) may be included /// directly, unlike DIREKT/GEMEINDE_GRUPPE channels which are team-only. async createGruppe( kcId: string, name: string | undefined, createdByUserId: string, participants: CreateGruppeParticipants, ) { const userIds = [...new Set(participants.userIds ?? [])]; const guestIds = [...new Set(participants.guestIds ?? [])]; if (userIds.length) { // A user may show up under more than one Gemeinde membership; just // make sure every requested id resolves to at least one row for this KC. const distinctUsers = await this.prisma.membership.findMany({ where: { kcId, userId: { in: userIds } }, select: { userId: true }, distinct: ['userId'], }); if (distinctUsers.length !== userIds.length) { throw new BadRequestException('One or more users are not part of this KC'); } } if (guestIds.length) { const guestCount = await this.prisma.guestAccount.count({ where: { id: { in: guestIds }, kcId }, }); if (guestCount !== guestIds.length) { throw new BadRequestException('One or more guests are not part of this KC'); } } const channel = await this.prisma.chatChannel.create({ data: { kcId, type: ChatChannelType.GRUPPE, name, createdByUserId, participants: { create: [ ...(userIds.includes(createdByUserId) ? [] : [{ userId: createdByUserId }]), ...userIds.map((userId) => ({ userId })), ...guestIds.map((guestAccountId) => ({ guestAccountId })), ], }, }, include: { participants: true }, }); await this.sync.capture('ChatChannel', SyncOperation.CREATE, channel.id, channel); return channel; } /// Candidates a caller may add to a GRUPPE channel in this KC: every team /// member (any Gemeinde) plus every Konfi/guest, so a Verantwortliche/r can /// pick across Gemeinde boundaries as intended. Same authorization as /// creating a Gruppenchat (LT or Verantwortliche/r of this KC). async listPossibleParticipants(kcId: string, caller: AuthenticatedUser) { const isLt = caller.memberships.some((m) => m.role === Role.LEITUNGSTEAM); const isVerantwortlicherHere = caller.memberships.some( (m) => m.kcId === kcId && m.role === Role.GEMEINDE_VERANTWORTLICHER, ); if (!isLt && !isVerantwortlicherHere) { throw new ForbiddenException('Not allowed to list participants for this KC'); } const [memberships, guests] = await Promise.all([ this.prisma.membership.findMany({ where: { kcId, status: 'ACTIVE' }, include: { user: { select: { id: true, firstName: true, lastName: true, email: true } } }, orderBy: { user: { lastName: 'asc' } }, }), this.prisma.guestAccount.findMany({ where: { kcId }, select: { id: true, firstName: true, lastName: true, gemeindeId: true }, orderBy: { lastName: 'asc' }, }), ]); const seenUsers = new Set(); const users = []; for (const m of memberships) { if (seenUsers.has(m.userId)) continue; seenUsers.add(m.userId); users.push({ userId: m.user.id, firstName: m.user.firstName, lastName: m.user.lastName, email: m.user.email, role: m.role, gemeindeId: m.gemeindeId, }); } return { users, guests: guests.map((g) => ({ guestId: g.id, firstName: g.firstName, lastName: g.lastName, gemeindeId: g.gemeindeId, })), }; } /// Adds a team user or a guest/Konfi to an existing GRUPPE channel. Only /// the channel's creator or a Leitungsteam member may manage participants. async addParticipant( channelId: string, caller: ChatCaller, target: { userId?: string; guestId?: string }, ) { const channel = await this.getGruppeForManagementOrThrow(channelId, caller); if (!target.userId && !target.guestId) { throw new BadRequestException('userId or guestId is required'); } if (target.userId && target.guestId) { throw new BadRequestException('Provide either userId or guestId, not both'); } if (target.userId) { const isMember = await this.prisma.membership.findFirst({ where: { kcId: channel.kcId, userId: target.userId }, }); if (!isMember) { throw new BadRequestException('User is not part of this KC'); } const participant = await this.prisma.chatParticipant.upsert({ where: { channelId_userId: { channelId, userId: target.userId } }, create: { channelId, userId: target.userId }, update: {}, }); await this.sync.capture('ChatParticipant', SyncOperation.CREATE, participant.id, participant); return participant; } const guest = await this.prisma.guestAccount.findFirst({ where: { id: target.guestId, kcId: channel.kcId }, }); if (!guest) { throw new BadRequestException('Guest is not part of this KC'); } const participant = await this.prisma.chatParticipant.upsert({ where: { channelId_guestAccountId: { channelId, guestAccountId: target.guestId! } }, create: { channelId, guestAccountId: target.guestId }, update: {}, }); await this.sync.capture('ChatParticipant', SyncOperation.CREATE, participant.id, participant); return participant; } /// Removes a team user or a guest/Konfi from a GRUPPE channel. Same /// authorization as addParticipant. async removeParticipant( channelId: string, caller: ChatCaller, target: { userId?: string; guestId?: string }, ) { await this.getGruppeForManagementOrThrow(channelId, caller); if (!target.userId && !target.guestId) { throw new BadRequestException('userId or guestId is required'); } const existing = await this.prisma.chatParticipant.findFirst({ where: { channelId, userId: target.userId ?? undefined, guestAccountId: target.guestId ?? undefined, }, }); if (!existing) return { ok: true }; await this.prisma.chatParticipant.delete({ where: { id: existing.id } }); await this.sync.capture('ChatParticipant', SyncOperation.DELETE, existing.id, { id: existing.id }); return { ok: true }; } private async getGruppeForManagementOrThrow(channelId: string, caller: ChatCaller) { if (caller.kind !== 'user') { throw new ForbiddenException('Guests may not manage channel participants'); } const channel = await this.prisma.chatChannel.findUnique({ where: { id: channelId } }); if (!channel || channel.type !== ChatChannelType.GRUPPE) { throw new NotFoundException('Gruppenchat not found'); } const { user } = caller; const isLt = user.memberships.some((m) => m.role === Role.LEITUNGSTEAM); const isCreator = channel.createdByUserId === user.userId; const isVerantwortlicherHere = user.memberships.some( (m) => m.kcId === channel.kcId && m.role === Role.GEMEINDE_VERANTWORTLICHER, ); if (!isLt && !isCreator && !isVerantwortlicherHere) { throw new ForbiddenException('Not allowed to manage this Gruppenchat'); } 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, OR: [ { type: ChatChannelType.BROADCAST }, { type: ChatChannelType.GRUPPE, participants: { some: { guestAccountId: caller.guest.guestId } } }, ], }, }); } 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 } } }, { type: ChatChannelType.GRUPPE, 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') { if (channel.type === ChatChannelType.BROADCAST && mode === 'read') { if (caller.guest.kcId !== channel.kcId) { throw new ForbiddenException('Guest does not belong to this KC'); } return channel; } if (channel.type === ChatChannelType.GRUPPE) { const isParticipant = channel.participants.some( (p) => p.guestAccountId === caller.guest.guestId, ); if (!isParticipant) { throw new ForbiddenException('Not a participant of this Gruppenchat'); } return channel; } throw new ForbiddenException('Guests may only read broadcast channels or their Gruppenchats'); } 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; } case ChatChannelType.GRUPPE: { const isParticipant = channel.participants.some((p) => p.userId === user.userId); if (!isParticipant) { throw new ForbiddenException('Not a participant of this Gruppenchat'); } 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' }, }); } }