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 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 16:25:14 +02:00
co-authored by Claude Sonnet 5
parent 8ec127c0fb
commit 648989a51b
7 changed files with 167 additions and 0 deletions
+2
View File
@@ -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,
+11
View File
@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class CreateGemeindeDto {
@IsString()
@IsNotEmpty()
kcId!: string;
@IsString()
@IsNotEmpty()
name!: string;
}
+7
View File
@@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class UpdateGemeindeDto {
@IsString()
@IsNotEmpty()
name!: string;
}
+53
View File
@@ -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);
}
}
+9
View File
@@ -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 {}
+81
View File
@@ -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;
}
}