diff --git a/.cybedefend/config.json b/.cybedefend/config.json new file mode 100644 index 0000000..1b53543 --- /dev/null +++ b/.cybedefend/config.json @@ -0,0 +1,3 @@ +{ + "projectId": "5fe999f9-fbff-4a09-a987-48c4e7540b38" +} diff --git a/.gitea/workflows/cybedefend-scan.yml b/.gitea/workflows/cybedefend-scan.yml new file mode 100644 index 0000000..a06ab52 --- /dev/null +++ b/.gitea/workflows/cybedefend-scan.yml @@ -0,0 +1,51 @@ +name: CybeDefend Security Scan + +on: + push: + branches: + - main + - master + - 'feat/**' + pull_request: + branches: + - main + - master + +jobs: + cybedefend_scan: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run CybeDefend Security Scan + env: + CYBEDEFEND_PAT: ${{ secrets.CYBEDEFEND_PAT }} + CYBEDEFEND_PROJECT_ID: ${{ secrets.CYBEDEFEND_PROJECT_ID }} + run: | + docker run --rm \ + -v "${{ gitea.workspace }}":/app -w /app \ + -e CYBEDEFEND_PAT="$CYBEDEFEND_PAT" \ + -e CYBEDEFEND_PROJECT_ID="$CYBEDEFEND_PROJECT_ID" \ + ghcr.io/cybedefend/cybedefend-cli:latest \ + scan --dir . --region eu --ci --break-on-severity critical + + - name: Fetch detailed SARIF results + if: always() + env: + CYBEDEFEND_PAT: ${{ secrets.CYBEDEFEND_PAT }} + CYBEDEFEND_PROJECT_ID: ${{ secrets.CYBEDEFEND_PROJECT_ID }} + run: | + docker run --rm \ + -v "${{ gitea.workspace }}":/app -w /app \ + -e CYBEDEFEND_PAT="$CYBEDEFEND_PAT" \ + -e CYBEDEFEND_PROJECT_ID="$CYBEDEFEND_PROJECT_ID" \ + ghcr.io/cybedefend/cybedefend-cli:latest \ + results --project-id "$CYBEDEFEND_PROJECT_ID" --all --output sarif --filename results.sarif --ci + + - name: Upload scan results as artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: cybedefend-results + path: results.sarif diff --git a/README.md b/README.md index 15c89ed..2534320 100644 --- a/README.md +++ b/README.md @@ -103,13 +103,28 @@ client's host - no separate web server is needed. (`WEBDAV_*` env vars), switchable to S3-compatible storage with `STORAGE_PROVIDER=s3` (`S3_*` env vars). - `chat/` — Gemeinde-Gruppenchat, 1:1 Direktnachrichten, Leitungsteam-über- - greifende Kanäle und Broadcast (Konfis lesen nur). Channel administration - and message history are plain REST (`ChatController`); real-time send/ - receive is a raw `ws` gateway (`ChatGateway`, path `/chat`) since passport - guards don't apply to WS upgrades — auth happens once via `?token=` at - connect time (`TokenVerificationService` tries Authentik JWKS, then falls - back to a guest token). Access rules live in `ChatService` and are shared - between the REST and WS entry points. + greifende Kanäle, Broadcast (Konfis lesen nur), and free-form `GRUPPE` + chats. Channel administration and message history are plain REST + (`ChatController`); real-time send/receive is a raw `ws` gateway + (`ChatGateway`, path `/chat`) since passport guards don't apply to WS + upgrades — auth happens once via `?token=` at connect time + (`TokenVerificationService` tries Authentik JWKS, then falls back to a + guest token). Access rules live in `ChatService` and are shared between + the REST and WS entry points. + - `POST /chat/:kcId/gruppen` lets a Leitungsteam member (any KC) or a + Gemeinde Verantwortliche/r (their own KC — `RolesGuard`'s kcId scoping) + create a `GRUPPE` channel with any mix of team users and Konfis (guests) + from that KC as initial participants (`participantUserIds`, + `participantGuestIds`); the creator is always included. Unlike + `GEMEINDE_GRUPPE`, membership isn't derived from `Gemeinde` — every + participant is an explicit `ChatParticipant` row, so a Konfi (who always + belongs to exactly one Gemeinde) can be added regardless of which + Gemeinde the chat's creator manages. + - `POST` / `DELETE /chat/gruppen/:channelId/participants` (body + `{ userId }` or `{ guestId }`) add/remove a participant afterwards. + Allowed for the channel's creator, any Leitungsteam member, or a + Verantwortliche/r of that KC — not the participants themselves, and not + guests. - `sync/` — replicates mutations between the local (on-site) and cloud server. `SyncService.capture()` is called by feature services right after a write, appending an entry to the append-only `SyncLogEntry` log tagged diff --git a/prisma/migrations/20260912130000_chat_gruppe/migration.sql b/prisma/migrations/20260912130000_chat_gruppe/migration.sql new file mode 100644 index 0000000..8a85191 --- /dev/null +++ b/prisma/migrations/20260912130000_chat_gruppe/migration.sql @@ -0,0 +1,17 @@ +-- AlterEnum +ALTER TYPE "ChatChannelType" ADD VALUE 'GRUPPE'; + +-- AlterTable +ALTER TABLE "ChatChannel" ADD COLUMN "createdByUserId" TEXT, +ADD COLUMN "name" TEXT; + +-- AlterTable +ALTER TABLE "ChatParticipant" ADD COLUMN "guestAccountId" TEXT, +ALTER COLUMN "userId" DROP NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "ChatParticipant_channelId_guestAccountId_key" ON "ChatParticipant"("channelId", "guestAccountId"); + +-- AddForeignKey +ALTER TABLE "ChatParticipant" ADD CONSTRAINT "ChatParticipant_guestAccountId_fkey" FOREIGN KEY ("guestAccountId") REFERENCES "GuestAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ee3787d..4b05f3d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -16,14 +16,14 @@ model Kc { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - gemeinden Gemeinde[] - memberships Membership[] - wahlen Wahl[] - files File[] - channels ChatChannel[] - guests GuestAccount[] - localUsers User[] - teamerInvites TeamerInvite[] + gemeinden Gemeinde[] + memberships Membership[] + wahlen Wahl[] + files File[] + channels ChatChannel[] + guests GuestAccount[] + localUsers User[] + teamerInvites TeamerInvite[] verantwortlicheInvites VerantwortlicheInvite[] } @@ -34,10 +34,10 @@ model Gemeinde { kcId String createdAt DateTime @default(now()) - kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) - memberships Membership[] - guests GuestAccount[] - teamerInvites TeamerInvite[] + kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) + memberships Membership[] + guests GuestAccount[] + teamerInvites TeamerInvite[] verantwortlicheInvites VerantwortlicheInvite[] @@unique([kcId, name]) @@ -110,11 +110,12 @@ model GuestAccount { lastName String createdAt DateTime @default(now()) - kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) - gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade) - messages ChatMessage[] - teilnehmer Teilnehmer[] - deviceTokens DeviceToken[] + kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) + gemeinde Gemeinde? @relation(fields: [gemeindeId], references: [id], onDelete: Cascade) + messages ChatMessage[] + teilnehmer Teilnehmer[] + deviceTokens DeviceToken[] + chatParticipations ChatParticipant[] } /// A push-notification target (FCM registration token) bound to whoever @@ -202,13 +203,13 @@ model Wahl { /// A workshop offered in one phase of a Wahl. `phase` is 1-based and must be /// <= the owning Wahl's `phasenAnzahl`. model Workshop { - id String @id @default(cuid()) + id String @id @default(cuid()) wahlId String - phase Int @default(1) + phase Int @default(1) name String beschreibung String? kapazitaet Int - minTeilnehmer Int @default(0) + minTeilnehmer Int @default(0) wahl Wahl @relation(fields: [wahlId], references: [id], onDelete: Cascade) zuteilungen Zuteilung[] @@ -282,32 +283,47 @@ enum ChatChannelType { DIREKT LT_UEBERGREIFEND BROADCAST + /// Freely composed group chat: created by a Leitungsteam member or a + /// Gemeinde Verantwortliche/r (for their own KC), with an explicit, + /// mutable participant list (team users and/or guests) via ChatParticipant + /// - unlike GEMEINDE_GRUPPE, membership is not derived from Gemeinde. + GRUPPE } model ChatChannel { - id String @id @default(cuid()) - kcId String - type ChatChannelType - gemeindeId String? - createdAt DateTime @default(now()) + id String @id @default(cuid()) + kcId String + type ChatChannelType + gemeindeId String? + /// Display name; used by GRUPPE channels (optional for other types). + name String? + /// Who created the channel; only set for GRUPPE so far. Used to let the + /// creator manage participants alongside Leitungsteam/Verantwortliche. + createdByUserId String? + + createdAt DateTime @default(now()) kc Kc @relation(fields: [kcId], references: [id], onDelete: Cascade) messages ChatMessage[] participants ChatParticipant[] } -/// Explicit membership for DIREKT (1:1) channels; other channel types derive -/// access from Membership/Gemeinde instead of this table. +/// Explicit membership for DIREKT (1:1) and GRUPPE channels; other channel +/// types derive access from Membership/Gemeinde instead of this table. +/// Exactly one of userId/guestAccountId is set per row. model ChatParticipant { - id String @id @default(cuid()) - channelId String - userId String - createdAt DateTime @default(now()) + id String @id @default(cuid()) + channelId String + userId String? + guestAccountId String? + createdAt DateTime @default(now()) - channel ChatChannel @relation(fields: [channelId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + channel ChatChannel @relation(fields: [channelId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + guestAccount GuestAccount? @relation(fields: [guestAccountId], references: [id], onDelete: Cascade) @@unique([channelId, userId]) + @@unique([channelId, guestAccountId]) } model ChatMessage { diff --git a/src/chat/chat.controller.ts b/src/chat/chat.controller.ts index 325068f..ca513ee 100644 --- a/src/chat/chat.controller.ts +++ b/src/chat/chat.controller.ts @@ -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') diff --git a/src/chat/chat.gateway.ts b/src/chat/chat.gateway.ts index 932d0a1..15f4ae7 100644 --- a/src/chat/chat.gateway.ts +++ b/src/chat/chat.gateway.ts @@ -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 } }); + } } diff --git a/src/chat/chat.service.spec.ts b/src/chat/chat.service.spec.ts new file mode 100644 index 0000000..82999dd --- /dev/null +++ b/src/chat/chat.service.spec.ts @@ -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; + memberships?: { kcId: string; userId: string }[]; + guests?: { id: string; kcId: string }[]; + } = {}, +) { + const channels: Record = 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(); + 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); + }); +}); diff --git a/src/chat/chat.service.ts b/src/chat/chat.service.ts index 5e89d4e..30b29d3 100644 --- a/src/chat/chat.service.ts +++ b/src/chat/chat.service.ts @@ -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.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(); + 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'); } diff --git a/src/chat/dto/add-participant.dto.ts b/src/chat/dto/add-participant.dto.ts new file mode 100644 index 0000000..027d5f8 --- /dev/null +++ b/src/chat/dto/add-participant.dto.ts @@ -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; +} diff --git a/src/chat/dto/create-channel.dto.ts b/src/chat/dto/create-channel.dto.ts index c7d37c5..3d336df 100644 --- a/src/chat/dto/create-channel.dto.ts +++ b/src/chat/dto/create-channel.dto.ts @@ -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[]; } diff --git a/src/push/push.service.ts b/src/push/push.service.ts index d68a9ab..a432ede 100644 --- a/src/push/push.service.ts +++ b/src/push/push.service.ts @@ -50,7 +50,7 @@ export class PushService { try { const channel = await this.prisma.chatChannel.findUnique({ where: { id: channelId }, - include: { participants: { select: { userId: true } } }, + include: { participants: { select: { userId: true, guestAccountId: true } } }, }); if (!channel) return; @@ -90,10 +90,22 @@ export class PushService { kcId: string; type: ChatChannelType; gemeindeId: string | null; - participants: { userId: string }[]; + participants: { userId: string | null; guestAccountId: string | null }[]; }): Promise<{ userIds: string[]; guestIds: string[] }> { if (channel.type === ChatChannelType.DIREKT) { - return { userIds: channel.participants.map((p) => p.userId), guestIds: [] }; + return { + userIds: channel.participants.map((p) => p.userId).filter((id): id is string => !!id), + guestIds: [], + }; + } + + if (channel.type === ChatChannelType.GRUPPE) { + return { + userIds: channel.participants.map((p) => p.userId).filter((id): id is string => !!id), + guestIds: channel.participants + .map((p) => p.guestAccountId) + .filter((id): id is string => !!id), + }; } const ltUsers = await this.prisma.user.findMany({ diff --git a/src/sync/sync.service.ts b/src/sync/sync.service.ts index 2fc88d0..1c0609d 100644 --- a/src/sync/sync.service.ts +++ b/src/sync/sync.service.ts @@ -18,6 +18,7 @@ const SYNCED_MODELS = [ 'Zuteilung', 'File', 'ChatChannel', + 'ChatParticipant', 'ChatMessage', 'DeviceToken', ] as const;