6 Commits
Author SHA1 Message Date
linus fb4b1e21cf Merge pull request 'fix(ci): stop mounting repo over /app in CybeDefend container' (#2) from fix/cybedefend-scan-app-mount into main
CybeDefend Security Scan / cybedefend_scan (push) Failing after 2m55s
2026-09-12 11:48:32 +00:00
linus 858c43a6aa fix(ci): stop mounting repo over /app in CybeDefend container
CybeDefend Security Scan / cybedefend_scan (pull_request) Successful in 24s
The CybeDefend CLI image runs from /app/cybedefend (its own binary and
sources live there). Mounting the checked-out repo at -v $WORKSPACE:/app
shadowed that binary, so the container failed with:

  exec: "/app/cybedefend": stat /app/cybedefend: no such file or directory

Mount the repo at /src instead so /app (and its entrypoint) stays intact.
Verified locally: docker run --rm -v <dir>:/src -w /src ghcr.io/cybedefend/cybedefend-cli:latest --help now runs correctly.
2026-09-12 13:48:12 +02:00
linus 2fbfcf53be Merge pull request 'feat: Wahl-Phasen, Verantwortliche-Invites, Auth fixes, GRUPPE chat' (#1) from feat/backend-phases-0-6 into main
CybeDefend Security Scan / cybedefend_scan (push) Failing after 57s
2026-09-12 11:26:16 +00:00
linus 288628f20e feat(chat): free-form GRUPPE channels with mutable participants
CybeDefend Security Scan / cybedefend_scan (push) Failing after 19s
CybeDefend Security Scan / cybedefend_scan (pull_request) Failing after 1s
- 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
linus 1467c8bdf6 test: add intentionally vulnerable file to trigger CybeDefend scan 2026-09-11 20:30:51 +02:00
linus 3bc40e5908 chore: map Postgres to host port 5433 to avoid local conflicts 2026-09-11 19:45:49 +02:00
15 changed files with 812 additions and 55 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"projectId": "5fe999f9-fbff-4a09-a987-48c4e7540b38"
}
+51
View File
@@ -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 }}":/src -w /src \
-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 }}":/src -w /src \
-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
+22 -7
View File
@@ -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
+21
View File
@@ -0,0 +1,21 @@
// TEMPORARY TEST FILE — intentionally vulnerable code to trigger CybeDefend scan
// Safe to delete after the scan demo.
const AWS_ACCESS_KEY = "AKIAABCDEFGHIJKLMNOP"; // hardcoded secret (should trigger secret scanner)
const DB_PASSWORD = "SuperSecret123!"; // hardcoded credential
const mysql = require('mysql');
function getUser(db, userId) {
// SQL injection: string concatenation of user input directly into query
const query = "SELECT * FROM users WHERE id = '" + userId + "'";
return db.query(query);
}
function runCommand(userInput) {
const { exec } = require('child_process');
// command injection: unsanitized user input passed to shell
exec("echo " + userInput);
}
module.exports = { getUser, runCommand, AWS_ACCESS_KEY, DB_PASSWORD };
+1 -1
View File
@@ -8,7 +8,7 @@ services:
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d kcapp"]
interval: 5s
@@ -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;
+49 -33
View File
@@ -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 {
+74 -2
View File
@@ -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')
+8
View File
@@ -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 } });
}
}
+280
View File
@@ -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
View File
@@ -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');
}
+13
View File
@@ -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;
}
+21 -1
View File
@@ -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[];
}
+15 -3
View File
@@ -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({
+1
View File
@@ -18,6 +18,7 @@ const SYNCED_MODELS = [
'Zuteilung',
'File',
'ChatChannel',
'ChatParticipant',
'ChatMessage',
'DeviceToken',
] as const;