diff --git a/README.md b/README.md index 93b5912..6d347c3 100644 --- a/README.md +++ b/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/src/app.module.ts b/src/app.module.ts index f916aaa..3ddb730 100644 --- a/src/app.module.ts +++ b/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/src/gemeinde/dto/create-gemeinde.dto.ts b/src/gemeinde/dto/create-gemeinde.dto.ts new file mode 100644 index 0000000..e6def00 --- /dev/null +++ b/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/src/gemeinde/dto/update-gemeinde.dto.ts b/src/gemeinde/dto/update-gemeinde.dto.ts new file mode 100644 index 0000000..7714ffd --- /dev/null +++ b/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/src/gemeinde/gemeinde.controller.ts b/src/gemeinde/gemeinde.controller.ts new file mode 100644 index 0000000..5f404ef --- /dev/null +++ b/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/src/gemeinde/gemeinde.module.ts b/src/gemeinde/gemeinde.module.ts new file mode 100644 index 0000000..f383556 --- /dev/null +++ b/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/src/gemeinde/gemeinde.service.ts b/src/gemeinde/gemeinde.service.ts new file mode 100644 index 0000000..5311516 --- /dev/null +++ b/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; + } +}