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
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import { Body, Controller, Get, Param, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { ChatService } from './chat.service';
|
||||
import { ChatGateway } from './chat.gateway';
|
||||
import { CreateChannelDto } from './dto/create-channel.dto';
|
||||
import { CreateDirectChannelDto } from './dto/create-direct-channel.dto';
|
||||
import { AddParticipantDto } from './dto/add-participant.dto';
|
||||
import { Roles } from '../common/roles.decorator';
|
||||
import { RolesGuard } from '../common/roles.guard';
|
||||
import { Role } from '../common/role.enum';
|
||||
@@ -14,7 +16,10 @@ type ChatRequest = AuthenticatedRequest & { user?: AuthenticatedRequest['user']
|
||||
|
||||
@Controller('chat')
|
||||
export class ChatController {
|
||||
constructor(private readonly chat: ChatService) {}
|
||||
constructor(
|
||||
private readonly chat: ChatService,
|
||||
private readonly gateway: ChatGateway,
|
||||
) {}
|
||||
|
||||
/// Channel administration (Gemeinde-Gruppen, LT-Kanäle, Broadcasts) is Leitungsteam-only.
|
||||
@Post(':kcId/channels')
|
||||
@@ -24,6 +29,73 @@ export class ChatController {
|
||||
return this.chat.createChannel(kcId, dto.type, dto.gemeindeId);
|
||||
}
|
||||
|
||||
/// Free-form group chat ("Gruppenchat"): a Leitungsteam member (any KC) or
|
||||
/// a Gemeinde Verantwortliche/r (their own KC, enforced by RolesGuard's
|
||||
/// kcId scoping) can create one and pick any mix of team users and Konfis
|
||||
/// (guests) from this KC as initial participants.
|
||||
@Post(':kcId/gruppen')
|
||||
@UseGuards(AuthGuard(['authentik', 'team']), RolesGuard)
|
||||
@Roles(Role.LEITUNGSTEAM, Role.GEMEINDE_VERANTWORTLICHER)
|
||||
createGruppe(
|
||||
@Param('kcId') kcId: string,
|
||||
@Body() dto: CreateChannelDto,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
) {
|
||||
return this.chat.createGruppe(kcId, dto.name, req.user!.userId, {
|
||||
userIds: dto.participantUserIds,
|
||||
guestIds: dto.participantGuestIds,
|
||||
});
|
||||
}
|
||||
|
||||
/// Candidates (team users + Konfis) a caller may add to a Gruppenchat in
|
||||
/// this KC. Allowed for LT or a Verantwortliche/r of this KC.
|
||||
@Get(':kcId/gruppen/participant-candidates')
|
||||
@UseGuards(AuthGuard(['authentik', 'team']))
|
||||
listPossibleParticipants(@Param('kcId') kcId: string, @Req() req: AuthenticatedRequest) {
|
||||
return this.chat.listPossibleParticipants(kcId, req.user!);
|
||||
}
|
||||
|
||||
/// Add a team user or Konfi to a Gruppenchat. Allowed for the channel's
|
||||
/// creator, any Leitungsteam member, or a Verantwortliche/r of that KC.
|
||||
@Post('gruppen/:channelId/participants')
|
||||
@UseGuards(AuthGuard(['authentik', 'team']))
|
||||
addParticipant(
|
||||
@Param('channelId') channelId: string,
|
||||
@Body() dto: AddParticipantDto,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
) {
|
||||
return this.chat
|
||||
.addParticipant(
|
||||
channelId,
|
||||
{ kind: 'user', user: req.user! },
|
||||
{ userId: dto.userId, guestId: dto.guestId },
|
||||
)
|
||||
.then((result) => {
|
||||
this.gateway.notifyParticipantsChanged(channelId);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
/// Remove a team user or Konfi from a Gruppenchat. Same authorization as add.
|
||||
@Delete('gruppen/:channelId/participants')
|
||||
@UseGuards(AuthGuard(['authentik', 'team']))
|
||||
removeParticipant(
|
||||
@Param('channelId') channelId: string,
|
||||
@Body() dto: AddParticipantDto,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
) {
|
||||
return this.chat
|
||||
.removeParticipant(
|
||||
channelId,
|
||||
{ kind: 'user', user: req.user! },
|
||||
{ userId: dto.userId, guestId: dto.guestId },
|
||||
)
|
||||
.then((result) => {
|
||||
this.gateway.notifyParticipantsChanged(channelId);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
/// Any two team members of the same KC can start a direct conversation
|
||||
/// (Authentik-backed members and local Gemeinde Teamer alike).
|
||||
@Post('direct')
|
||||
|
||||
@@ -106,4 +106,12 @@ export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Called by ChatController after add/removeParticipant so anyone with the
|
||||
/// channel already open (e.g. the creator's participant-management UI)
|
||||
/// gets a live update. Newly added participants join the room themselves
|
||||
/// via `chat:join` once they open the chat.
|
||||
notifyParticipantsChanged(channelId: string) {
|
||||
this.broadcast(channelId, { event: 'chat:participants-changed', data: { channelId } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { ChatChannelType, Role } from '@prisma/client';
|
||||
import { ChatService } from './chat.service';
|
||||
import { AuthenticatedUser } from '../auth/authenticated-request';
|
||||
import { GuestJwtPayload } from '../auth/guest-auth.service';
|
||||
|
||||
/// Focus: GRUPPE channel creation + participant management authorization
|
||||
/// (creator / Leitungsteam / Verantwortliche/r of that KC), and read/write
|
||||
/// access for team users and guests. Prisma + Sync + Push faked in memory.
|
||||
|
||||
function userCaller(userId: string, memberships: AuthenticatedUser['memberships']) {
|
||||
return {
|
||||
kind: 'user' as const,
|
||||
user: { userId, authentikSub: `sub-${userId}`, email: `${userId}@example.org`, memberships },
|
||||
};
|
||||
}
|
||||
function guestCaller(guestId: string, kcId: string, gemeindeId: string | null = null) {
|
||||
const guest: GuestJwtPayload = { guestId, kcId, gemeindeId };
|
||||
return { kind: 'guest' as const, guest };
|
||||
}
|
||||
|
||||
const LT = userCaller('lt-1', [{ kcId: 'kc-1', gemeindeId: null, role: Role.LEITUNGSTEAM }]);
|
||||
const VERANTW = userCaller('ver-1', [
|
||||
{ kcId: 'kc-1', gemeindeId: 'gem-1', role: Role.GEMEINDE_VERANTWORTLICHER },
|
||||
]);
|
||||
const TEAMER = userCaller('teamer-1', [
|
||||
{ kcId: 'kc-1', gemeindeId: 'gem-1', role: Role.GEMEINDE_TEAMER },
|
||||
]);
|
||||
|
||||
function makeService(
|
||||
opts: {
|
||||
channels?: Record<string, any>;
|
||||
memberships?: { kcId: string; userId: string }[];
|
||||
guests?: { id: string; kcId: string }[];
|
||||
} = {},
|
||||
) {
|
||||
const channels: Record<string, any> = opts.channels ?? {};
|
||||
const memberships = opts.memberships ?? [];
|
||||
const guests = opts.guests ?? [];
|
||||
const participants: any[] = [];
|
||||
let participantSeq = 0;
|
||||
|
||||
const prisma = {
|
||||
chatChannel: {
|
||||
create: jest.fn(({ data, include }: any) => {
|
||||
const id = `chan-${Object.keys(channels).length + 1}`;
|
||||
const created = { id, ...data, participants: [] };
|
||||
if (data.participants?.create) {
|
||||
for (const p of data.participants.create) {
|
||||
const row = { id: `part-${++participantSeq}`, channelId: id, userId: null, guestAccountId: null, ...p };
|
||||
participants.push(row);
|
||||
created.participants.push(row);
|
||||
}
|
||||
}
|
||||
channels[id] = created;
|
||||
return Promise.resolve(include ? created : { id, ...data });
|
||||
}),
|
||||
findUnique: jest.fn(({ where, include }: any) => {
|
||||
const channel = channels[where.id];
|
||||
if (!channel) return Promise.resolve(null);
|
||||
if (include?.participants) {
|
||||
const seeded = participants.filter((p) => p.channelId === channel.id);
|
||||
const fallback = Array.isArray(channel.participants) ? channel.participants : [];
|
||||
return Promise.resolve({
|
||||
...channel,
|
||||
participants: seeded.length ? seeded : fallback,
|
||||
});
|
||||
}
|
||||
return Promise.resolve(channel);
|
||||
}),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
membership: {
|
||||
findMany: jest.fn(({ where }: any) => {
|
||||
const ids: string[] = where.userId.in;
|
||||
const rows = memberships.filter((m) => m.kcId === where.kcId && ids.includes(m.userId));
|
||||
const seen = new Set<string>();
|
||||
const distinct = rows.filter((r) => (seen.has(r.userId) ? false : (seen.add(r.userId), true)));
|
||||
return Promise.resolve(distinct);
|
||||
}),
|
||||
findFirst: jest.fn(({ where }: any) =>
|
||||
Promise.resolve(memberships.find((m) => m.kcId === where.kcId && m.userId === where.userId) ?? null),
|
||||
),
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
},
|
||||
guestAccount: {
|
||||
count: jest.fn(({ where }: any) =>
|
||||
Promise.resolve(guests.filter((g) => where.id.in.includes(g.id) && g.kcId === where.kcId).length),
|
||||
),
|
||||
findFirst: jest.fn(({ where }: any) =>
|
||||
Promise.resolve(guests.find((g) => g.id === where.id && g.kcId === where.kcId) ?? null),
|
||||
),
|
||||
},
|
||||
chatParticipant: {
|
||||
upsert: jest.fn(({ create }: any) => {
|
||||
const existing = participants.find(
|
||||
(p) =>
|
||||
p.channelId === create.channelId &&
|
||||
p.userId === (create.userId ?? null) &&
|
||||
p.guestAccountId === (create.guestAccountId ?? null),
|
||||
);
|
||||
if (existing) return Promise.resolve(existing);
|
||||
const row = { id: `part-${++participantSeq}`, userId: null, guestAccountId: null, ...create };
|
||||
participants.push(row);
|
||||
return Promise.resolve(row);
|
||||
}),
|
||||
findFirst: jest.fn(({ where }: any) =>
|
||||
Promise.resolve(
|
||||
participants.find(
|
||||
(p) =>
|
||||
p.channelId === where.channelId &&
|
||||
(where.userId === undefined || p.userId === where.userId) &&
|
||||
(where.guestAccountId === undefined || p.guestAccountId === where.guestAccountId),
|
||||
) ?? null,
|
||||
),
|
||||
),
|
||||
delete: jest.fn(({ where }: any) => {
|
||||
const idx = participants.findIndex((p) => p.id === where.id);
|
||||
const [removed] = participants.splice(idx, 1);
|
||||
return Promise.resolve(removed);
|
||||
}),
|
||||
},
|
||||
chatMessage: { create: jest.fn(), findMany: jest.fn() },
|
||||
};
|
||||
const sync = { capture: jest.fn().mockResolvedValue(undefined) };
|
||||
const push = { notifyChannel: jest.fn().mockResolvedValue(undefined) };
|
||||
const service = new ChatService(prisma as never, sync as never, push as never);
|
||||
return { service, prisma, sync, push, channels, participants };
|
||||
}
|
||||
|
||||
describe('ChatService.createGruppe', () => {
|
||||
it('creates a GRUPPE channel with the creator plus given team/guest participants', async () => {
|
||||
const { service, sync } = makeService({
|
||||
memberships: [{ kcId: 'kc-1', userId: 'ver-1' }, { kcId: 'kc-1', userId: 'teamer-1' }],
|
||||
guests: [{ id: 'guest-1', kcId: 'kc-1' }],
|
||||
});
|
||||
const channel = await service.createGruppe('kc-1', 'Ausflugsplanung', 'ver-1', {
|
||||
userIds: ['teamer-1'],
|
||||
guestIds: ['guest-1'],
|
||||
});
|
||||
expect(channel.type).toBe(ChatChannelType.GRUPPE);
|
||||
expect(channel.createdByUserId).toBe('ver-1');
|
||||
const userIds = channel.participants.map((p: any) => p.userId).filter(Boolean);
|
||||
const guestIds = channel.participants.map((p: any) => p.guestAccountId).filter(Boolean);
|
||||
expect(userIds.sort()).toEqual(['teamer-1', 'ver-1']);
|
||||
expect(guestIds).toEqual(['guest-1']);
|
||||
expect(sync.capture).toHaveBeenCalledWith('ChatChannel', 'CREATE', channel.id, expect.anything());
|
||||
});
|
||||
|
||||
it('does not duplicate the creator if already listed as a participant', async () => {
|
||||
const { service } = makeService({ memberships: [{ kcId: 'kc-1', userId: 'ver-1' }] });
|
||||
const channel = await service.createGruppe('kc-1', 'X', 'ver-1', { userIds: ['ver-1'] });
|
||||
const userIds = channel.participants.map((p: any) => p.userId).filter(Boolean);
|
||||
expect(userIds).toEqual(['ver-1']);
|
||||
});
|
||||
|
||||
it('rejects a participant who is not a member of the KC', async () => {
|
||||
const { service } = makeService({ memberships: [] });
|
||||
await expect(
|
||||
service.createGruppe('kc-1', 'X', 'ver-1', { userIds: ['ghost'] }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects a guest who is not part of the KC', async () => {
|
||||
const { service } = makeService({ guests: [{ id: 'guest-1', kcId: 'kc-2' }] });
|
||||
await expect(
|
||||
service.createGruppe('kc-1', 'X', 'ver-1', { guestIds: ['guest-1'] }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatService participant management', () => {
|
||||
function seedGruppe() {
|
||||
const channels = {
|
||||
'chan-1': { id: 'chan-1', kcId: 'kc-1', type: ChatChannelType.GRUPPE, createdByUserId: 'ver-1', gemeindeId: null },
|
||||
};
|
||||
return channels;
|
||||
}
|
||||
|
||||
it('lets the creator add a team user', async () => {
|
||||
const { service } = makeService({
|
||||
channels: seedGruppe(),
|
||||
memberships: [{ kcId: 'kc-1', userId: 'teamer-1' }],
|
||||
});
|
||||
const p = await service.addParticipant('chan-1', VERANTW, { userId: 'teamer-1' });
|
||||
expect(p.userId).toBe('teamer-1');
|
||||
});
|
||||
|
||||
it('lets a Leitungsteam member add a guest even if not the creator', async () => {
|
||||
const { service } = makeService({
|
||||
channels: seedGruppe(),
|
||||
guests: [{ id: 'guest-1', kcId: 'kc-1' }],
|
||||
});
|
||||
const p = await service.addParticipant('chan-1', LT, { guestId: 'guest-1' });
|
||||
expect(p.guestAccountId).toBe('guest-1');
|
||||
});
|
||||
|
||||
it('forbids a plain Teamer (not creator, not LT, not Verantwortliche/r) from managing participants', async () => {
|
||||
const { service } = makeService({ channels: seedGruppe() });
|
||||
await expect(
|
||||
service.addParticipant('chan-1', TEAMER, { userId: 'teamer-1' }),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('forbids guests from managing participants', async () => {
|
||||
const { service } = makeService({ channels: seedGruppe() });
|
||||
await expect(
|
||||
service.addParticipant('chan-1', guestCaller('g-1', 'kc-1') as never, { userId: 'x' }),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('404s for a non-GRUPPE channel', async () => {
|
||||
const channels = {
|
||||
'chan-2': { id: 'chan-2', kcId: 'kc-1', type: ChatChannelType.GEMEINDE_GRUPPE, createdByUserId: null },
|
||||
};
|
||||
const { service } = makeService({ channels });
|
||||
await expect(
|
||||
service.addParticipant('chan-2', LT, { userId: 'teamer-1' }),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('rejects adding a user not in the KC', async () => {
|
||||
const { service } = makeService({ channels: seedGruppe(), memberships: [] });
|
||||
await expect(
|
||||
service.addParticipant('chan-1', LT, { userId: 'ghost' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('removes a participant and is a no-op if already absent', async () => {
|
||||
const { service } = makeService({
|
||||
channels: seedGruppe(),
|
||||
memberships: [{ kcId: 'kc-1', userId: 'teamer-1' }],
|
||||
});
|
||||
await service.addParticipant('chan-1', VERANTW, { userId: 'teamer-1' });
|
||||
const res = await service.removeParticipant('chan-1', VERANTW, { userId: 'teamer-1' });
|
||||
expect(res).toEqual({ ok: true });
|
||||
const res2 = await service.removeParticipant('chan-1', VERANTW, { userId: 'teamer-1' });
|
||||
expect(res2).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatService GRUPPE read/write access', () => {
|
||||
function seedGruppeWithParticipants(participants: any[]) {
|
||||
return {
|
||||
'chan-1': {
|
||||
id: 'chan-1',
|
||||
kcId: 'kc-1',
|
||||
type: ChatChannelType.GRUPPE,
|
||||
createdByUserId: 'ver-1',
|
||||
gemeindeId: null,
|
||||
participants,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('lets a listed guest read messages', async () => {
|
||||
const { service } = makeService({
|
||||
channels: seedGruppeWithParticipants([{ userId: null, guestAccountId: 'guest-1' }]),
|
||||
});
|
||||
await expect(
|
||||
service.assertCanRead('chan-1', guestCaller('guest-1', 'kc-1') as never),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('forbids a guest not in the participant list', async () => {
|
||||
const { service } = makeService({
|
||||
channels: seedGruppeWithParticipants([{ userId: null, guestAccountId: 'guest-1' }]),
|
||||
});
|
||||
await expect(
|
||||
service.assertCanRead('chan-1', guestCaller('guest-2', 'kc-1') as never),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('forbids a team user not in the participant list', async () => {
|
||||
const { service } = makeService({
|
||||
channels: seedGruppeWithParticipants([{ userId: 'someone-else', guestAccountId: null }]),
|
||||
});
|
||||
await expect(service.assertCanRead('chan-1', TEAMER)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
});
|
||||
+236
-8
@@ -1,4 +1,4 @@
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
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';
|
||||
@@ -15,8 +15,14 @@ const CHANNEL_TITLES: Record<ChatChannelType, string> = {
|
||||
[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(
|
||||
@@ -31,6 +37,206 @@ export class ChatService {
|
||||
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: {
|
||||
@@ -57,7 +263,13 @@ export class ChatService {
|
||||
async listChannelsForCaller(kcId: string, caller: ChatCaller) {
|
||||
if (caller.kind === 'guest') {
|
||||
return this.prisma.chatChannel.findMany({
|
||||
where: { kcId, type: ChatChannelType.BROADCAST },
|
||||
where: {
|
||||
kcId,
|
||||
OR: [
|
||||
{ type: ChatChannelType.BROADCAST },
|
||||
{ type: ChatChannelType.GRUPPE, participants: { some: { guestAccountId: caller.guest.guestId } } },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
const { user } = caller;
|
||||
@@ -75,6 +287,7 @@ export class ChatService {
|
||||
{ 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 } } },
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -102,14 +315,22 @@ export class ChatService {
|
||||
}
|
||||
|
||||
if (caller.kind === 'guest') {
|
||||
const allowed = channel.type === ChatChannelType.BROADCAST && mode === 'read';
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException('Guests may only read broadcast channels');
|
||||
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 (caller.guest.kcId !== channel.kcId) {
|
||||
throw new ForbiddenException('Guest does not belong to this KC');
|
||||
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;
|
||||
}
|
||||
return channel;
|
||||
throw new ForbiddenException('Guests may only read broadcast channels or their Gruppenchats');
|
||||
}
|
||||
|
||||
const { user } = caller;
|
||||
@@ -145,6 +366,13 @@ export class ChatService {
|
||||
}
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
/// Exactly one of userId/guestId must be set; validated in the service since
|
||||
/// class-validator doesn't express "exactly one of" declaratively.
|
||||
export class AddParticipantDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
guestId?: string;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsEnum, IsOptional, IsString } from 'class-validator';
|
||||
import { ArrayUnique, IsArray, IsEnum, IsOptional, IsString } from 'class-validator';
|
||||
import { ChatChannelType } from '@prisma/client';
|
||||
|
||||
export class CreateChannelDto {
|
||||
@@ -8,4 +8,24 @@ export class CreateChannelDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
gemeindeId?: string;
|
||||
|
||||
/// Display name; used for GRUPPE channels.
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
/// Initial participants for a GRUPPE channel (team users). More can be
|
||||
/// added/removed later via the participants endpoints.
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@IsString({ each: true })
|
||||
participantUserIds?: string[];
|
||||
|
||||
/// Initial guest/Konfi participants for a GRUPPE channel.
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@IsString({ each: true })
|
||||
participantGuestIds?: string[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user