From 100f5bc2aff03af3ee156ad2f8ef0b4a986a6261 Mon Sep 17 00:00:00 2001 From: linus Date: Wed, 9 Sep 2026 16:25:14 +0200 Subject: [PATCH] feat(backend): add GemeindeController for LT congregation CRUD Fills the plan's known gap where Gemeinde existed only as a Prisma model. GemeindeModule exposes Leitungsteam-only create/list/get/update/delete under /api/gemeinde, each mutation captured into the sync log like the other feature services. Unique-name-per-KC violations surface as 409. Docs (plan + backend README) updated to drop the gap and next-step item. Co-Authored-By: Claude Sonnet 5 --- backend/README.md | 4 + backend/src/app.module.ts | 2 + .../src/gemeinde/dto/create-gemeinde.dto.ts | 11 +++ .../src/gemeinde/dto/update-gemeinde.dto.ts | 7 ++ backend/src/gemeinde/gemeinde.controller.ts | 53 ++++++++++++ backend/src/gemeinde/gemeinde.module.ts | 9 +++ backend/src/gemeinde/gemeinde.service.ts | 81 +++++++++++++++++++ plan-kcAppMultiTenantPlatform.prompt.md | 10 +-- 8 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 backend/src/gemeinde/dto/create-gemeinde.dto.ts create mode 100644 backend/src/gemeinde/dto/update-gemeinde.dto.ts create mode 100644 backend/src/gemeinde/gemeinde.controller.ts create mode 100644 backend/src/gemeinde/gemeinde.module.ts create mode 100644 backend/src/gemeinde/gemeinde.service.ts diff --git a/backend/README.md b/backend/README.md index 93b5912..6d347c3 100644 --- a/backend/README.md +++ b/backend/README.md @@ -36,6 +36,10 @@ client's host - no separate web server is needed. - `auth/` — Authentik resource-server strategy (`AuthGuard('authentik')`) + guest invite-code login issuing a locally-signed JWT (`AuthGuard('guest')`). - `kc/` — KC (event) creation/listing, Leitungsteam-only. +- `gemeinde/` — Gemeinde (congregation) CRUD per KC (`POST /gemeinde`, + `GET /gemeinde?kcId=`, `GET/PATCH/DELETE /gemeinde/:id`), Leitungsteam-only. + Gemeinde Verantwortliche/Teamer get their own Gemeinde from their + `Membership`, not from this endpoint. - `wahl/` — Wahl/Workshop administration (Leitungsteam-only), guest Teilnehmer submission, Force-Zuteilung overrides, and `ZuteilungService`: a faithful port of the WP plugin's `kc_run_zuteilung` (force-assignments → diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index f916aaa..3ddb730 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -5,6 +5,7 @@ import { join } from 'path'; import { PrismaModule } from './prisma/prisma.module'; import { AuthModule } from './auth/auth.module'; import { KcModule } from './kc/kc.module'; +import { GemeindeModule } from './gemeinde/gemeinde.module'; import { WahlModule } from './wahl/wahl.module'; import { FilesModule } from './files/files.module'; import { ChatModule } from './chat/chat.module'; @@ -23,6 +24,7 @@ import { SyncModule } from './sync/sync.module'; SyncModule, AuthModule, KcModule, + GemeindeModule, WahlModule, FilesModule, ChatModule, diff --git a/backend/src/gemeinde/dto/create-gemeinde.dto.ts b/backend/src/gemeinde/dto/create-gemeinde.dto.ts new file mode 100644 index 0000000..e6def00 --- /dev/null +++ b/backend/src/gemeinde/dto/create-gemeinde.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class CreateGemeindeDto { + @IsString() + @IsNotEmpty() + kcId!: string; + + @IsString() + @IsNotEmpty() + name!: string; +} diff --git a/backend/src/gemeinde/dto/update-gemeinde.dto.ts b/backend/src/gemeinde/dto/update-gemeinde.dto.ts new file mode 100644 index 0000000..7714ffd --- /dev/null +++ b/backend/src/gemeinde/dto/update-gemeinde.dto.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class UpdateGemeindeDto { + @IsString() + @IsNotEmpty() + name!: string; +} diff --git a/backend/src/gemeinde/gemeinde.controller.ts b/backend/src/gemeinde/gemeinde.controller.ts new file mode 100644 index 0000000..5f404ef --- /dev/null +++ b/backend/src/gemeinde/gemeinde.controller.ts @@ -0,0 +1,53 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { GemeindeService } from './gemeinde.service'; +import { CreateGemeindeDto } from './dto/create-gemeinde.dto'; +import { UpdateGemeindeDto } from './dto/update-gemeinde.dto'; +import { Roles } from '../common/roles.decorator'; +import { RolesGuard } from '../common/roles.guard'; +import { Role } from '../common/role.enum'; + +/// Gemeinde (congregation) management. Reserved for the Leitungsteam, which is +/// global across all KCs (see RolesGuard); Gemeinde Verantwortliche/Teamer +/// learn their own Gemeinde from their Membership, not from this endpoint. +@Controller('gemeinde') +@UseGuards(AuthGuard('authentik'), RolesGuard) +@Roles(Role.LEITUNGSTEAM) +export class GemeindeController { + constructor(private readonly gemeinde: GemeindeService) {} + + @Post() + create(@Body() dto: CreateGemeindeDto) { + return this.gemeinde.create(dto.kcId, dto.name); + } + + @Get() + list(@Query('kcId') kcId: string) { + return this.gemeinde.list(kcId); + } + + @Get(':id') + get(@Param('id') id: string) { + return this.gemeinde.get(id); + } + + @Patch(':id') + update(@Param('id') id: string, @Body() dto: UpdateGemeindeDto) { + return this.gemeinde.update(id, dto.name); + } + + @Delete(':id') + remove(@Param('id') id: string) { + return this.gemeinde.remove(id); + } +} diff --git a/backend/src/gemeinde/gemeinde.module.ts b/backend/src/gemeinde/gemeinde.module.ts new file mode 100644 index 0000000..f383556 --- /dev/null +++ b/backend/src/gemeinde/gemeinde.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { GemeindeService } from './gemeinde.service'; +import { GemeindeController } from './gemeinde.controller'; + +@Module({ + providers: [GemeindeService], + controllers: [GemeindeController], +}) +export class GemeindeModule {} diff --git a/backend/src/gemeinde/gemeinde.service.ts b/backend/src/gemeinde/gemeinde.service.ts new file mode 100644 index 0000000..5311516 --- /dev/null +++ b/backend/src/gemeinde/gemeinde.service.ts @@ -0,0 +1,81 @@ +import { + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma, SyncOperation } from '@prisma/client'; +import { PrismaClient } from '../prisma/prisma.module'; +import { SyncService } from '../sync/sync.service'; + +/// CRUD for Gemeinden (congregations) within a KC. Creating/renaming/deleting +/// is Leitungsteam-only (see GemeindeController); other team roles may list +/// and read the Gemeinden of their KC for onboarding/assignment UIs. +@Injectable() +export class GemeindeService { + constructor( + private readonly prisma: PrismaClient, + private readonly sync: SyncService, + ) {} + + async create(kcId: string, name: string) { + const kc = await this.prisma.kc.findUnique({ where: { id: kcId } }); + if (!kc) { + throw new NotFoundException('KC not found'); + } + try { + const gemeinde = await this.prisma.gemeinde.create({ data: { kcId, name } }); + await this.sync.capture('Gemeinde', SyncOperation.CREATE, gemeinde.id, gemeinde); + return gemeinde; + } catch (err) { + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === 'P2002' + ) { + throw new ConflictException('A Gemeinde with this name already exists in this KC'); + } + throw err; + } + } + + list(kcId: string) { + return this.prisma.gemeinde.findMany({ + where: { kcId }, + orderBy: { name: 'asc' }, + }); + } + + async get(id: string) { + const gemeinde = await this.prisma.gemeinde.findUnique({ where: { id } }); + if (!gemeinde) { + throw new NotFoundException('Gemeinde not found'); + } + return gemeinde; + } + + async update(id: string, name: string) { + await this.get(id); + try { + const gemeinde = await this.prisma.gemeinde.update({ + where: { id }, + data: { name }, + }); + await this.sync.capture('Gemeinde', SyncOperation.UPDATE, gemeinde.id, gemeinde); + return gemeinde; + } catch (err) { + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === 'P2002' + ) { + throw new ConflictException('A Gemeinde with this name already exists in this KC'); + } + throw err; + } + } + + async remove(id: string) { + await this.get(id); + const gemeinde = await this.prisma.gemeinde.delete({ where: { id } }); + await this.sync.capture('Gemeinde', SyncOperation.DELETE, gemeinde.id, gemeinde); + return gemeinde; + } +} diff --git a/plan-kcAppMultiTenantPlatform.prompt.md b/plan-kcAppMultiTenantPlatform.prompt.md index 397b934..eb7db70 100644 --- a/plan-kcAppMultiTenantPlatform.prompt.md +++ b/plan-kcAppMultiTenantPlatform.prompt.md @@ -48,7 +48,7 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris ### Bekannte Einschränkungen / offene Punkte - **Datei-Bytes werden nicht repliziert** – nur Metadaten (inkl. `storageKey`) laufen durchs Sync-Log. Lokaler und Cloud-Server müssen denselben Nextcloud/S3-Backend-Zugriff haben, damit ein `storageKey` auf beiden Seiten auflösbar ist. - **Push-Benachrichtigungen (FCM/APNs)** sind im Plan vorgesehen, aber noch nicht implementiert (Teil der noch ausstehenden Flutter-Client-Arbeit). -- **Gemeinde-Verwaltung (CRUD)** existiert aktuell nur als Datenmodell; es gibt noch keinen eigenen `GemeindeController` zum Anlegen/Verwalten von Gemeinden durch LT (bisher nur implizit über Membership/GuestAccount referenziert). Sollte vor dem Produktivbetrieb ergänzt werden. +- **Gemeinde-Verwaltung (CRUD)**: über `GemeindeController` (LT-only) verfügbar — Anlegen/Auflisten/Lesen/Umbenennen/Löschen von Gemeinden pro KC. Gemeinde Verantwortliche/Teamer erfahren ihre eigene Gemeinde weiterhin aus der `Membership`, nicht über diesen Endpunkt. - **Authentik-Provisionierung**: Wenn ein Gemeinde Verantwortlicher einen Teamer anlegt, muss dieser aktuell weiterhin manuell (oder über eine noch zu bauende Authentik-Admin-API-Integration) in Authentik angelegt werden — das Backend erwartet, dass der `User` mit passendem `authentikSub` bereits existiert, bevor er sich einloggen kann. --- @@ -60,6 +60,7 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris | `prisma/` | Geteilter `PrismaClient`-Provider | – | | `auth/` | Authentik-Resource-Server-Strategie (`AuthGuard('authentik')`), Guest-Login (`AuthGuard('guest')`), `TokenVerificationService` für manuelle Verifikation außerhalb des HTTP/Passport-Pfads (WS-Handshake) | `POST /api/auth/guest` | | `kc/` | KC-Verwaltung, Leitungsteam-only | `POST /api/kc`, `GET /api/kc` | +| `gemeinde/` | Gemeinde-CRUD pro KC, Leitungsteam-only | `POST /api/gemeinde`, `GET /api/gemeinde?kcId=`, `GET/PATCH/DELETE /api/gemeinde/:id` | | `wahl/` | Wahl-/Workshop-Verwaltung, Force-Zuteilung, Teilnehmer-Einreichung, `ZuteilungService` (Portierung von `kc_run_zuteilung`: Force-Zuteilungen → 3 Wunschrunden → Zufallsfüllung → Konsolidierung unterbesetzter Workshops), CSV-Export | `POST/GET /api/wahl`, `POST/GET /api/wahl/:id/workshops`, `POST /api/wahl/:id/force-zuteilung`, `POST /api/wahl/:id/teilnehmer` (Guest), `POST /api/wahl/:id/zuteilung/run`, `GET /api/wahl/:id/zuteilung(.csv)` | | `files/` | Upload (LT-only, multipart) mit Sichtbarkeitsstufe; Liste/Download für Team oder Guest, gefiltert nach erlaubten Sichtbarkeitsstufen; `StorageProvider`-Abstraktion (WebDAV/Nextcloud Default, S3 optional) | `POST /api/files/:kcId`, `GET /api/files/:kcId`, `GET /api/files/download/:fileId` | | `chat/` | Zentrale Zugriffslogik (`ChatService`) geteilt zwischen REST (`ChatController`) und WS (`ChatGateway`, Pfad `/chat`, eigene Token-Verifikation via `?token=`); Kanaltypen wie oben | `POST /api/chat/:kcId/channels`, `POST /api/chat/direct`, `GET /api/chat/:kcId/channels`, `GET /api/chat/channels/:id/messages`, WS-Events `chat:join`/`chat:send`/`chat:message` | @@ -109,7 +110,6 @@ Details, Setup-Anleitung und `.env`-Variablen: [backend/README.md](backend/READM ## 8. Nächste Schritte 1. Sobald Flutter verfügbar ist: Client-Grundgerüst aufsetzen (Mobile + Web + Desktop, eine Codebase), beginnend mit Invite/Login-Flow gegen die bestehende API. -2. `GemeindeController` ergänzen (LT-CRUD für Gemeinden), da bisher nur das Datenmodell existiert. -3. Authentik-Admin-API-Integration für automatische Teamer-Provisionierung durch Gemeinde Verantwortliche. -4. Push-Benachrichtigungen (FCM/APNs) für Chat/Ankündigungen. -5. Echte Infrastruktur (Postgres, Authentik, Nextcloud/S3) aufsetzen und die in Abschnitt 7 offenen Verifikationsschritte durchführen. +2. Authentik-Admin-API-Integration für automatische Teamer-Provisionierung durch Gemeinde Verantwortliche. +3. Push-Benachrichtigungen (FCM/APNs) für Chat/Ankündigungen. +4. Echte Infrastruktur (Postgres, Authentik, Nextcloud/S3) aufsetzen und die in Abschnitt 7 offenen Verifikationsschritte durchführen.