From 5c92c810e11cc4e0bea8a6eea493bbf02a74fe6f Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Fri, 21 Aug 2026 08:50:50 +0200 Subject: [PATCH] feat(backend): Bienenstaende-Modul (Modul 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ApiariesModule: CRUD + Deactivate. Lesen fuer alle authentifizierten Rollen offen, Schreiben (Anlegen/Aendern/Deaktivieren) auf ADMIN und BEEKEEPER beschraenkt (Lastenheft §3.2: Imker verwaltet Bienenstaende). Audit-Log-Eintraege bei Create/Update/Deactivate ueber AuditLogService. 4 Unit-Tests + 4 e2e-Tests (inkl. Rollenpruefung READONLY vs. BEEKEEPER), Lint, Typecheck und Build gruen. --- .../src/apiaries/apiaries.controller.ts | 51 ++++++++ .../src/apiaries/apiaries.module.ts | 10 ++ .../src/apiaries/apiaries.service.spec.ts | 78 ++++++++++++ .../src/apiaries/apiaries.service.ts | 76 +++++++++++ .../src/apiaries/dto/create-apiary.dto.ts | 38 ++++++ .../src/apiaries/dto/update-apiary.dto.ts | 4 + ApiCore-Backend/src/app.module.ts | 2 + ApiCore-Backend/test/apiaries.e2e-spec.ts | 119 ++++++++++++++++++ TASKS.md | 2 +- 9 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 ApiCore-Backend/src/apiaries/apiaries.controller.ts create mode 100644 ApiCore-Backend/src/apiaries/apiaries.module.ts create mode 100644 ApiCore-Backend/src/apiaries/apiaries.service.spec.ts create mode 100644 ApiCore-Backend/src/apiaries/apiaries.service.ts create mode 100644 ApiCore-Backend/src/apiaries/dto/create-apiary.dto.ts create mode 100644 ApiCore-Backend/src/apiaries/dto/update-apiary.dto.ts create mode 100644 ApiCore-Backend/test/apiaries.e2e-spec.ts diff --git a/ApiCore-Backend/src/apiaries/apiaries.controller.ts b/ApiCore-Backend/src/apiaries/apiaries.controller.ts new file mode 100644 index 0000000..5f4beb3 --- /dev/null +++ b/ApiCore-Backend/src/apiaries/apiaries.controller.ts @@ -0,0 +1,51 @@ +import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { Role } from '../../generated/prisma'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import type { AuthenticatedUser } from '../auth/types/authenticated-user'; +import { CreateApiaryDto } from './dto/create-apiary.dto'; +import { UpdateApiaryDto } from './dto/update-apiary.dto'; +import { ApiariesService } from './apiaries.service'; + +@ApiTags('apiaries') +@ApiBearerAuth() +@Controller('apiaries') +export class ApiariesController { + constructor(private readonly apiariesService: ApiariesService) {} + + @Get() + findAll() { + return this.apiariesService.findAll(); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.apiariesService.findById(id); + } + + @Roles(Role.ADMIN, Role.BEEKEEPER) + @Post() + create( + @Body() dto: CreateApiaryDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.apiariesService.create(dto, actor.id); + } + + @Roles(Role.ADMIN, Role.BEEKEEPER) + @Patch(':id') + update( + @Param('id') id: string, + @Body() dto: UpdateApiaryDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.apiariesService.update(id, dto, actor.id); + } + + @Roles(Role.ADMIN, Role.BEEKEEPER) + @Post(':id/deactivate') + deactivate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) { + return this.apiariesService.deactivate(id, actor.id); + } +} diff --git a/ApiCore-Backend/src/apiaries/apiaries.module.ts b/ApiCore-Backend/src/apiaries/apiaries.module.ts new file mode 100644 index 0000000..6ab3ba7 --- /dev/null +++ b/ApiCore-Backend/src/apiaries/apiaries.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ApiariesController } from './apiaries.controller'; +import { ApiariesService } from './apiaries.service'; + +@Module({ + controllers: [ApiariesController], + providers: [ApiariesService], + exports: [ApiariesService], +}) +export class ApiariesModule {} diff --git a/ApiCore-Backend/src/apiaries/apiaries.service.spec.ts b/ApiCore-Backend/src/apiaries/apiaries.service.spec.ts new file mode 100644 index 0000000..20fcd2c --- /dev/null +++ b/ApiCore-Backend/src/apiaries/apiaries.service.spec.ts @@ -0,0 +1,78 @@ +import { NotFoundException } from '@nestjs/common'; +import { ApiariesService } from './apiaries.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; + +describe('ApiariesService', () => { + let service: ApiariesService; + let prisma: { + apiary: { + findMany: jest.Mock; + findUnique: jest.Mock; + create: jest.Mock; + update: jest.Mock; + }; + }; + let auditLog: { record: jest.Mock }; + + const existingApiary = { + id: 'apiary-1', + name: 'Heimstand', + status: 'ACTIVE', + deactivatedAt: null, + }; + + beforeEach(() => { + prisma = { + apiary: { + findMany: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + }; + auditLog = { record: jest.fn().mockResolvedValue(undefined) }; + service = new ApiariesService( + prisma as unknown as PrismaService, + auditLog as unknown as AuditLogService, + ); + }); + + it('findById throws NotFoundException when missing', async () => { + prisma.apiary.findUnique.mockResolvedValue(null); + await expect(service.findById('missing')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('create persists the apiary and records an audit entry', async () => { + prisma.apiary.create.mockResolvedValue(existingApiary); + + const result = await service.create({ name: 'Heimstand' }, 'user-1'); + + expect(result).toEqual(existingApiary); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'Apiary', + action: 'CREATE', + userId: 'user-1', + }), + ); + }); + + it('deactivate sets status to INACTIVE and records an audit entry', async () => { + prisma.apiary.findUnique.mockResolvedValue(existingApiary); + prisma.apiary.update.mockResolvedValue({ + ...existingApiary, + status: 'INACTIVE', + deactivatedAt: new Date(), + }); + + const result = await service.deactivate('apiary-1', 'user-1'); + + expect(result.status).toBe('INACTIVE'); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ entityType: 'Apiary', action: 'DEACTIVATE' }), + ); + }); +}); diff --git a/ApiCore-Backend/src/apiaries/apiaries.service.ts b/ApiCore-Backend/src/apiaries/apiaries.service.ts new file mode 100644 index 0000000..0980b2e --- /dev/null +++ b/ApiCore-Backend/src/apiaries/apiaries.service.ts @@ -0,0 +1,76 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; +import { Apiary } from '../../generated/prisma'; +import { CreateApiaryDto } from './dto/create-apiary.dto'; +import { UpdateApiaryDto } from './dto/update-apiary.dto'; + +@Injectable() +export class ApiariesService { + constructor( + private readonly prisma: PrismaService, + private readonly auditLog: AuditLogService, + ) {} + + async findAll(): Promise { + return this.prisma.apiary.findMany({ orderBy: { name: 'asc' } }); + } + + async findById(id: string): Promise { + const apiary = await this.prisma.apiary.findUnique({ where: { id } }); + if (!apiary) { + throw new NotFoundException('Apiary not found'); + } + return apiary; + } + + async create(dto: CreateApiaryDto, actingUserId: string): Promise { + const apiary = await this.prisma.apiary.create({ data: { ...dto } }); + await this.auditLog.record({ + entityType: 'Apiary', + entityId: apiary.id, + action: 'CREATE', + userId: actingUserId, + afterData: apiary, + }); + return apiary; + } + + async update( + id: string, + dto: UpdateApiaryDto, + actingUserId: string, + ): Promise { + const before = await this.findById(id); + const apiary = await this.prisma.apiary.update({ + where: { id }, + data: { ...dto }, + }); + await this.auditLog.record({ + entityType: 'Apiary', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: apiary, + }); + return apiary; + } + + async deactivate(id: string, actingUserId: string): Promise { + const before = await this.findById(id); + const apiary = await this.prisma.apiary.update({ + where: { id }, + data: { status: 'INACTIVE', deactivatedAt: new Date() }, + }); + await this.auditLog.record({ + entityType: 'Apiary', + entityId: id, + action: 'DEACTIVATE', + userId: actingUserId, + beforeData: before, + afterData: apiary, + }); + return apiary; + } +} diff --git a/ApiCore-Backend/src/apiaries/dto/create-apiary.dto.ts b/ApiCore-Backend/src/apiaries/dto/create-apiary.dto.ts new file mode 100644 index 0000000..135edbb --- /dev/null +++ b/ApiCore-Backend/src/apiaries/dto/create-apiary.dto.ts @@ -0,0 +1,38 @@ +import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger'; +import { IsLatitude, IsLongitude, IsOptional, IsString } from 'class-validator'; + +export class CreateApiaryDto { + @ApiProperty() + @IsString() + name!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + location?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsLatitude() + latitude?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsLongitude() + longitude?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + address?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} diff --git a/ApiCore-Backend/src/apiaries/dto/update-apiary.dto.ts b/ApiCore-Backend/src/apiaries/dto/update-apiary.dto.ts new file mode 100644 index 0000000..9f8c938 --- /dev/null +++ b/ApiCore-Backend/src/apiaries/dto/update-apiary.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateApiaryDto } from './create-apiary.dto'; + +export class UpdateApiaryDto extends PartialType(CreateApiaryDto) {} diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index 64a61c3..b456e91 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -6,6 +6,7 @@ import { HealthModule } from './health/health.module'; import { AuditModule } from './audit/audit.module'; import { UsersModule } from './users/users.module'; import { AuthModule } from './auth/auth.module'; +import { ApiariesModule } from './apiaries/apiaries.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { RolesGuard } from './auth/guards/roles.guard'; @@ -17,6 +18,7 @@ import { RolesGuard } from './auth/guards/roles.guard'; HealthModule, UsersModule, AuthModule, + ApiariesModule, ], controllers: [], providers: [ diff --git a/ApiCore-Backend/test/apiaries.e2e-spec.ts b/ApiCore-Backend/test/apiaries.e2e-spec.ts new file mode 100644 index 0000000..b3b7aae --- /dev/null +++ b/ApiCore-Backend/test/apiaries.e2e-spec.ts @@ -0,0 +1,119 @@ +import { INestApplication } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { createTestApp } from './utils/create-test-app'; +import { login } from './utils/api'; + +interface ApiaryBody { + id: string; + status: string; + notes: string | null; +} + +describe('Apiaries (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const beekeeperEmail = `apiaries-e2e-beekeeper-${Date.now()}@example.com`; + const readonlyEmail = `apiaries-e2e-readonly-${Date.now()}@example.com`; + const password = 'SuperSecret123!'; + let beekeeperId: string; + let readonlyId: string; + let beekeeperToken: string; + let readonlyToken: string; + let apiaryId: string; + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + + const passwordHash = await bcrypt.hash(password, 12); + const beekeeper = await prisma.user.create({ + data: { + email: beekeeperEmail, + passwordHash, + name: 'Beekeeper', + role: 'BEEKEEPER', + }, + }); + beekeeperId = beekeeper.id; + const readonly = await prisma.user.create({ + data: { + email: readonlyEmail, + passwordHash, + name: 'Readonly', + role: 'READONLY', + }, + }); + readonlyId = readonly.id; + + beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; + readonlyToken = (await login(app, readonlyEmail, password)).accessToken; + }); + + afterAll(async () => { + if (apiaryId) { + await prisma.apiary.delete({ where: { id: apiaryId } }); + } + await prisma.refreshToken.deleteMany({ + where: { userId: { in: [beekeeperId, readonlyId] } }, + }); + await prisma.auditLog.deleteMany({ + where: { userId: { in: [beekeeperId, readonlyId] } }, + }); + await prisma.user.deleteMany({ + where: { id: { in: [beekeeperId, readonlyId] } }, + }); + await app.close(); + }); + + it('rejects create for a READONLY user', () => { + return request(app.getHttpServer()) + .post('/api/v1/apiaries') + .set('Authorization', `Bearer ${readonlyToken}`) + .send({ name: 'Sollte nicht klappen' }) + .expect(403); + }); + + it('lets a BEEKEEPER create an apiary', async () => { + const response = await request(app.getHttpServer()) + .post('/api/v1/apiaries') + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ name: 'Heimstand', location: 'Garten' }) + .expect(201); + + const created = response.body as ApiaryBody; + expect(created.id).toEqual(expect.any(String)); + apiaryId = created.id; + }); + + it('lets a READONLY user list and read apiaries', async () => { + const list = await request(app.getHttpServer()) + .get('/api/v1/apiaries') + .set('Authorization', `Bearer ${readonlyToken}`) + .expect(200); + const apiaries = list.body as ApiaryBody[]; + expect(apiaries.some((apiary) => apiary.id === apiaryId)).toBe(true); + + await request(app.getHttpServer()) + .get(`/api/v1/apiaries/${apiaryId}`) + .set('Authorization', `Bearer ${readonlyToken}`) + .expect(200); + }); + + it('lets a BEEKEEPER update and deactivate an apiary', async () => { + const updated = await request(app.getHttpServer()) + .patch(`/api/v1/apiaries/${apiaryId}`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ notes: 'aktualisiert' }) + .expect(200); + expect((updated.body as ApiaryBody).notes).toBe('aktualisiert'); + + const deactivated = await request(app.getHttpServer()) + .post(`/api/v1/apiaries/${apiaryId}/deactivate`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(201); + expect((deactivated.body as ApiaryBody).status).toBe('INACTIVE'); + }); +}); diff --git a/TASKS.md b/TASKS.md index 91548102..45fd296 100644 --- a/TASKS.md +++ b/TASKS.md @@ -26,7 +26,7 @@ Abgeleitet aus [`docs/lastenheft.md`](docs/lastenheft.md) Abschnitt 26. Reihenfo - Backend: `ApiariesModule` - Endpunkte: `GET/POST /api/v1/apiaries`, `GET/PATCH /api/v1/apiaries/:id`, `POST /api/v1/apiaries/:id/deactivate` - Frontend: `/apiaries`, `/apiaries/[id]`, `/apiaries/new` -- [ ] Backend-Modul fertig, getestet, grün, committet +- [x] Backend-Modul fertig, getestet, grün, committet - [ ] Frontend-Views fertig, getestet, grün, committet ### 3. Völker