diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index f08d1c9..eb2855a 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -12,6 +12,7 @@ import { QueensModule } from './queens/queens.module'; import { InspectionsModule } from './inspections/inspections.module'; import { TasksModule } from './tasks/tasks.module'; import { VarroaModule } from './varroa/varroa.module'; +import { TreatmentsModule } from './treatments/treatments.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { RolesGuard } from './auth/guards/roles.guard'; @@ -29,6 +30,7 @@ import { RolesGuard } from './auth/guards/roles.guard'; InspectionsModule, TasksModule, VarroaModule, + TreatmentsModule, ], controllers: [], providers: [ diff --git a/ApiCore-Backend/src/treatments/colony-treatments.controller.ts b/ApiCore-Backend/src/treatments/colony-treatments.controller.ts new file mode 100644 index 0000000..c881dac --- /dev/null +++ b/ApiCore-Backend/src/treatments/colony-treatments.controller.ts @@ -0,0 +1,15 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { TreatmentsService } from './treatments.service'; + +@ApiTags('treatments') +@ApiBearerAuth() +@Controller('colonies') +export class ColonyTreatmentsController { + constructor(private readonly treatmentsService: TreatmentsService) {} + + @Get(':id/treatments') + findByColony(@Param('id') id: string) { + return this.treatmentsService.findByColony(id); + } +} diff --git a/ApiCore-Backend/src/treatments/dto/create-treatment.dto.ts b/ApiCore-Backend/src/treatments/dto/create-treatment.dto.ts new file mode 100644 index 0000000..d2ff58f --- /dev/null +++ b/ApiCore-Backend/src/treatments/dto/create-treatment.dto.ts @@ -0,0 +1,56 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsDateString, IsOptional, IsString } from 'class-validator'; + +export class CreateTreatmentDto { + @ApiProperty() + @IsString() + colonyId!: string; + + @ApiProperty() + @IsDateString() + date!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + startDate?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + endDate?: string; + + @ApiProperty() + @IsString() + product!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + activeIngredient?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + amount?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + method?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + reason?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + batchNumber?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} diff --git a/ApiCore-Backend/src/treatments/treatments.controller.ts b/ApiCore-Backend/src/treatments/treatments.controller.ts new file mode 100644 index 0000000..e9160e3 --- /dev/null +++ b/ApiCore-Backend/src/treatments/treatments.controller.ts @@ -0,0 +1,34 @@ +import { Body, Controller, Get, Param, 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 { CreateTreatmentDto } from './dto/create-treatment.dto'; +import { TreatmentsService } from './treatments.service'; + +@ApiTags('treatments') +@ApiBearerAuth() +@Controller('treatments') +export class TreatmentsController { + constructor(private readonly treatmentsService: TreatmentsService) {} + + @Get() + findAll() { + return this.treatmentsService.findAll(); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.treatmentsService.findById(id); + } + + @Roles(Role.ADMIN, Role.BEEKEEPER) + @Post() + create( + @Body() dto: CreateTreatmentDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.treatmentsService.create(dto, actor.id); + } +} diff --git a/ApiCore-Backend/src/treatments/treatments.module.ts b/ApiCore-Backend/src/treatments/treatments.module.ts new file mode 100644 index 0000000..53b63d1 --- /dev/null +++ b/ApiCore-Backend/src/treatments/treatments.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TreatmentsController } from './treatments.controller'; +import { ColonyTreatmentsController } from './colony-treatments.controller'; +import { TreatmentsService } from './treatments.service'; + +@Module({ + controllers: [TreatmentsController, ColonyTreatmentsController], + providers: [TreatmentsService], + exports: [TreatmentsService], +}) +export class TreatmentsModule {} diff --git a/ApiCore-Backend/src/treatments/treatments.service.spec.ts b/ApiCore-Backend/src/treatments/treatments.service.spec.ts new file mode 100644 index 0000000..b8c8717 --- /dev/null +++ b/ApiCore-Backend/src/treatments/treatments.service.spec.ts @@ -0,0 +1,72 @@ +import { NotFoundException } from '@nestjs/common'; +import { TreatmentsService } from './treatments.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; + +describe('TreatmentsService', () => { + let service: TreatmentsService; + let prisma: { + treatment: { + findMany: jest.Mock; + findUnique: jest.Mock; + create: jest.Mock; + }; + }; + let auditLog: { record: jest.Mock }; + + const treatment = { + id: 'treatment-1', + colonyId: 'colony-1', + date: new Date(), + }; + + beforeEach(() => { + prisma = { + treatment: { + findMany: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + }, + }; + auditLog = { record: jest.fn() }; + service = new TreatmentsService( + prisma as unknown as PrismaService, + auditLog as unknown as AuditLogService, + ); + }); + + it('findById throws NotFoundException when missing', async () => { + prisma.treatment.findUnique.mockResolvedValue(null); + await expect(service.findById('missing')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('create stores the acting user and product', async () => { + prisma.treatment.create.mockResolvedValue(treatment); + + await service.create( + { colonyId: 'colony-1', date: '2026-01-01', product: 'Ameisensäure' }, + 'user-1', + ); + + const [[createArg]] = prisma.treatment.create.mock.calls as [ + [{ data: { colonyId: string; userId: string; product: string } }], + ]; + expect(createArg.data.colonyId).toBe('colony-1'); + expect(createArg.data.userId).toBe('user-1'); + expect(createArg.data.product).toBe('Ameisensäure'); + }); + + it('findByColony orders results chronologically', async () => { + prisma.treatment.findMany.mockResolvedValue([treatment]); + + await service.findByColony('colony-1'); + + const [[queryArg]] = prisma.treatment.findMany.mock.calls as [ + [{ where: { colonyId: string }; orderBy: { date: string } }], + ]; + expect(queryArg.where.colonyId).toBe('colony-1'); + expect(queryArg.orderBy.date).toBe('asc'); + }); +}); diff --git a/ApiCore-Backend/src/treatments/treatments.service.ts b/ApiCore-Backend/src/treatments/treatments.service.ts new file mode 100644 index 0000000..30286f1 --- /dev/null +++ b/ApiCore-Backend/src/treatments/treatments.service.ts @@ -0,0 +1,55 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; +import { Treatment } from '../../generated/prisma'; +import { CreateTreatmentDto } from './dto/create-treatment.dto'; + +@Injectable() +export class TreatmentsService { + constructor( + private readonly prisma: PrismaService, + private readonly auditLog: AuditLogService, + ) {} + + async findAll(): Promise { + return this.prisma.treatment.findMany({ orderBy: { date: 'desc' } }); + } + + async findById(id: string): Promise { + const treatment = await this.prisma.treatment.findUnique({ where: { id } }); + if (!treatment) { + throw new NotFoundException('Treatment not found'); + } + return treatment; + } + + async findByColony(colonyId: string): Promise { + return this.prisma.treatment.findMany({ + where: { colonyId }, + orderBy: { date: 'asc' }, + }); + } + + async create( + dto: CreateTreatmentDto, + actingUserId: string, + ): Promise { + const treatment = await this.prisma.treatment.create({ + data: { + ...dto, + date: new Date(dto.date), + startDate: dto.startDate ? new Date(dto.startDate) : undefined, + endDate: dto.endDate ? new Date(dto.endDate) : undefined, + userId: actingUserId, + }, + }); + await this.auditLog.record({ + entityType: 'Treatment', + entityId: treatment.id, + action: 'CREATE', + userId: actingUserId, + afterData: treatment, + }); + return treatment; + } +} diff --git a/ApiCore-Backend/test/treatments.e2e-spec.ts b/ApiCore-Backend/test/treatments.e2e-spec.ts new file mode 100644 index 0000000..c4e8cd9 --- /dev/null +++ b/ApiCore-Backend/test/treatments.e2e-spec.ts @@ -0,0 +1,114 @@ +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 TreatmentBody { + id: string; + colonyId: string; +} + +describe('Treatments (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const beekeeperEmail = `treatments-e2e-beekeeper-${Date.now()}@example.com`; + const staffEmail = `treatments-e2e-staff-${Date.now()}@example.com`; + const password = 'SuperSecret123!'; + let beekeeperId: string; + let staffId: string; + let beekeeperToken: string; + let staffToken: string; + let apiaryId: string; + let colonyId: string; + let treatmentId: 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 staff = await prisma.user.create({ + data: { email: staffEmail, passwordHash, name: 'Staff', role: 'STAFF' }, + }); + staffId = staff.id; + beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; + staffToken = (await login(app, staffEmail, password)).accessToken; + + const apiary = await prisma.apiary.create({ + data: { name: `Stand ${Date.now()}` }, + }); + apiaryId = apiary.id; + const colony = await prisma.colony.create({ + data: { + code: `V-${Date.now()}`, + apiaryId, + colonyType: 'PRODUCTION_COLONY', + }, + }); + colonyId = colony.id; + }); + + afterAll(async () => { + await prisma.treatment.deleteMany({ where: { colonyId } }); + await prisma.colony.deleteMany({ where: { id: colonyId } }); + await prisma.apiary.deleteMany({ where: { id: apiaryId } }); + await prisma.refreshToken.deleteMany({ + where: { userId: { in: [beekeeperId, staffId] } }, + }); + await prisma.auditLog.deleteMany({ + where: { userId: { in: [beekeeperId, staffId] } }, + }); + await prisma.user.deleteMany({ + where: { id: { in: [beekeeperId, staffId] } }, + }); + await app.close(); + }); + + it('rejects create for a STAFF user', () => { + return request(app.getHttpServer()) + .post('/api/v1/treatments') + .set('Authorization', `Bearer ${staffToken}`) + .send({ + colonyId, + date: new Date().toISOString(), + product: 'Ameisensäure', + }) + .expect(403); + }); + + it('lets a BEEKEEPER user record a treatment', async () => { + const response = await request(app.getHttpServer()) + .post('/api/v1/treatments') + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ + colonyId, + date: new Date().toISOString(), + product: 'Ameisensäure', + amount: '15 ml', + }) + .expect(201); + treatmentId = (response.body as TreatmentBody).id; + expect(treatmentId).toEqual(expect.any(String)); + }); + + it('lists treatments for the colony', async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v1/colonies/${colonyId}/treatments`) + .set('Authorization', `Bearer ${staffToken}`) + .expect(200); + const treatments = response.body as TreatmentBody[]; + expect(treatments.some((entry) => entry.id === treatmentId)).toBe(true); + }); +}); diff --git a/TASKS.md b/TASKS.md index f2bb3cb..7a063e4 100644 --- a/TASKS.md +++ b/TASKS.md @@ -74,7 +74,7 @@ Abgeleitet aus [`docs/lastenheft.md`](docs/lastenheft.md) Abschnitt 26. Reihenfo - Backend: `TreatmentsModule` - Endpunkte: `GET/POST /api/v1/treatments`, `GET /api/v1/colonies/:id/treatments` - Frontend: `/treatments/new`, `/colonies/[id]` Tab "Behandlungen" -- [ ] Backend-Modul fertig, getestet, grün, committet +- [x] Backend-Modul fertig, getestet, grün, committet - [ ] Frontend-Views fertig, getestet, grün, committet ### 9. Fütterungen