Files
KC-APP-Server/src/chat/chat.service.ts
T
linus 288628f20e
CybeDefend Security Scan / cybedefend_scan (push) Failing after 19s
CybeDefend Security Scan / cybedefend_scan (pull_request) Failing after 1s
feat(chat): free-form GRUPPE channels with mutable participants
- ChatChannelType.GRUPPE: created by Leitungsteam (any KC) or a Gemeinde
  Verantwortliche/r (own KC), mixing team users and guests/Konfis as
  explicit ChatParticipant rows (unlike GEMEINDE_GRUPPE, membership is
  not derived from Gemeinde)
- POST /chat/:kcId/gruppen to create, GET participant-candidates, and
  POST/DELETE /chat/gruppen/:channelId/participants to manage membership
  (creator, LT, or Verantwortliche/r of that KC)
- ChatGateway broadcasts chat:participants-changed on membership change
- PushService updated for nullable ChatParticipant.userId + new
  guestAccountId column
- SyncService now replicates ChatParticipant
- Prisma migration + 14 new unit tests (75/75 passing), tsc clean
- CI: add .gitea/workflows/cybedefend-scan.yml + .cybedefend project config
2026-09-12 13:25:48 +02:00

417 lines
15 KiB
TypeScript

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, string> = {
[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<string>();
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' },
});
}
}