Files
KC-APP-Server/src/chat/chat.controller.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

119 lines
4.5 KiB
TypeScript

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';
import { AuthenticatedRequest } from '../auth/authenticated-request';
import { GuestJwtPayload } from '../auth/guest-auth.service';
import { resolveChatCaller } from './caller.util';
type ChatRequest = AuthenticatedRequest & { user?: AuthenticatedRequest['user'] | GuestJwtPayload };
@Controller('chat')
export class ChatController {
constructor(
private readonly chat: ChatService,
private readonly gateway: ChatGateway,
) {}
/// Channel administration (Gemeinde-Gruppen, LT-Kanäle, Broadcasts) is Leitungsteam-only.
@Post(':kcId/channels')
@UseGuards(AuthGuard('authentik'), RolesGuard)
@Roles(Role.LEITUNGSTEAM)
createChannel(@Param('kcId') kcId: string, @Body() dto: CreateChannelDto) {
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')
@UseGuards(AuthGuard(['authentik', 'team']))
createDirectChannel(@Body() dto: CreateDirectChannelDto, @Req() req: AuthenticatedRequest) {
return this.chat.getOrCreateDirectChannel(dto.kcId, req.user!.userId, dto.otherUserId);
}
@Get(':kcId/channels')
@UseGuards(AuthGuard(['authentik', 'team', 'guest']))
listChannels(@Param('kcId') kcId: string, @Req() req: ChatRequest) {
return this.chat.listChannelsForCaller(kcId, resolveChatCaller(req.user!));
}
@Get('channels/:channelId/messages')
@UseGuards(AuthGuard(['authentik', 'team', 'guest']))
listMessages(@Param('channelId') channelId: string, @Req() req: ChatRequest) {
return this.chat.listMessages(channelId, resolveChatCaller(req.user!));
}
}