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:
@@ -36,6 +36,10 @@ client's host - no separate web server is needed.
|
|||||||
- `auth/` — Authentik resource-server strategy (`AuthGuard('authentik')`) +
|
- `auth/` — Authentik resource-server strategy (`AuthGuard('authentik')`) +
|
||||||
guest invite-code login issuing a locally-signed JWT (`AuthGuard('guest')`).
|
guest invite-code login issuing a locally-signed JWT (`AuthGuard('guest')`).
|
||||||
- `kc/` — KC (event) creation/listing, Leitungsteam-only.
|
- `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
|
- `wahl/` — Wahl/Workshop administration (Leitungsteam-only), guest
|
||||||
Teilnehmer submission, Force-Zuteilung overrides, and `ZuteilungService`:
|
Teilnehmer submission, Force-Zuteilung overrides, and `ZuteilungService`:
|
||||||
a faithful port of the WP plugin's `kc_run_zuteilung` (force-assignments →
|
a faithful port of the WP plugin's `kc_run_zuteilung` (force-assignments →
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { join } from 'path';
|
|||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { KcModule } from './kc/kc.module';
|
import { KcModule } from './kc/kc.module';
|
||||||
|
import { GemeindeModule } from './gemeinde/gemeinde.module';
|
||||||
import { WahlModule } from './wahl/wahl.module';
|
import { WahlModule } from './wahl/wahl.module';
|
||||||
import { FilesModule } from './files/files.module';
|
import { FilesModule } from './files/files.module';
|
||||||
import { ChatModule } from './chat/chat.module';
|
import { ChatModule } from './chat/chat.module';
|
||||||
@@ -23,6 +24,7 @@ import { SyncModule } from './sync/sync.module';
|
|||||||
SyncModule,
|
SyncModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
KcModule,
|
KcModule,
|
||||||
|
GemeindeModule,
|
||||||
WahlModule,
|
WahlModule,
|
||||||
FilesModule,
|
FilesModule,
|
||||||
ChatModule,
|
ChatModule,
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateGemeindeDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
kcId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
name!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsNotEmpty, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateGemeindeDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
name!: string;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ Vollständiges Schema: [backend/prisma/schema.prisma](backend/prisma/schema.pris
|
|||||||
### Bekannte Einschränkungen / offene Punkte
|
### 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.
|
- **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).
|
- **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.
|
- **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 | – |
|
| `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` |
|
| `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` |
|
| `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)` |
|
| `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` |
|
| `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` |
|
| `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
|
## 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.
|
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.
|
2. Authentik-Admin-API-Integration für automatische Teamer-Provisionierung durch Gemeinde Verantwortliche.
|
||||||
3. Authentik-Admin-API-Integration für automatische Teamer-Provisionierung durch Gemeinde Verantwortliche.
|
3. Push-Benachrichtigungen (FCM/APNs) für Chat/Ankündigungen.
|
||||||
4. 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.
|
||||||
5. Echte Infrastruktur (Postgres, Authentik, Nextcloud/S3) aufsetzen und die in Abschnitt 7 offenen Verifikationsschritte durchführen.
|
|
||||||
|
|||||||
Reference in New Issue
Block a user