diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index 4884c0c..98477ec 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -9,6 +9,7 @@ import { AuthModule } from './auth/auth.module'; import { ApiariesModule } from './apiaries/apiaries.module'; import { ColoniesModule } from './colonies/colonies.module'; import { QueensModule } from './queens/queens.module'; +import { InspectionsModule } from './inspections/inspections.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { RolesGuard } from './auth/guards/roles.guard'; @@ -23,6 +24,7 @@ import { RolesGuard } from './auth/guards/roles.guard'; ApiariesModule, ColoniesModule, QueensModule, + InspectionsModule, ], controllers: [], providers: [ diff --git a/ApiCore-Backend/src/inspections/colony-inspections.controller.ts b/ApiCore-Backend/src/inspections/colony-inspections.controller.ts new file mode 100644 index 0000000..88b1ebc --- /dev/null +++ b/ApiCore-Backend/src/inspections/colony-inspections.controller.ts @@ -0,0 +1,15 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { InspectionsService } from './inspections.service'; + +@ApiTags('inspections') +@ApiBearerAuth() +@Controller('colonies') +export class ColonyInspectionsController { + constructor(private readonly inspectionsService: InspectionsService) {} + + @Get(':id/inspections') + findByColony(@Param('id') id: string) { + return this.inspectionsService.findByColony(id); + } +} diff --git a/ApiCore-Backend/src/inspections/dto/create-inspection.dto.ts b/ApiCore-Backend/src/inspections/dto/create-inspection.dto.ts new file mode 100644 index 0000000..f147ca8 --- /dev/null +++ b/ApiCore-Backend/src/inspections/dto/create-inspection.dto.ts @@ -0,0 +1,114 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsBoolean, + IsDateString, + IsInt, + IsNumber, + IsOptional, + IsString, +} from 'class-validator'; + +export class CreateInspectionDto { + @ApiProperty() + @IsString() + colonyId!: string; + + @ApiProperty() + @IsDateString() + date!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + weather?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + temperature?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + colonyStrength?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + broodAmount?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + broodPattern?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + openBroodPresent?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + cappedBroodPresent?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + eggsPresent?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + queenSeen?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + queenCellsPresent?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + swarmMood?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + gentleness?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + combBehavior?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + feedStatus?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + honeySuperStatus?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + healthIssues?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + varroaIssues?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + measures?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} diff --git a/ApiCore-Backend/src/inspections/dto/create-task-from-inspection.dto.ts b/ApiCore-Backend/src/inspections/dto/create-task-from-inspection.dto.ts new file mode 100644 index 0000000..aa48a94 --- /dev/null +++ b/ApiCore-Backend/src/inspections/dto/create-task-from-inspection.dto.ts @@ -0,0 +1,29 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsDateString, IsEnum, IsOptional, IsString } from 'class-validator'; +import { TaskPriority } from '../../../generated/prisma'; + +export class CreateTaskFromInspectionDto { + @ApiProperty() + @IsString() + title!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ enum: TaskPriority }) + @IsOptional() + @IsEnum(TaskPriority) + priority?: TaskPriority; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + dueDate?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + assignedUserId?: string; +} diff --git a/ApiCore-Backend/src/inspections/inspections.controller.ts b/ApiCore-Backend/src/inspections/inspections.controller.ts new file mode 100644 index 0000000..f0cc7eb --- /dev/null +++ b/ApiCore-Backend/src/inspections/inspections.controller.ts @@ -0,0 +1,45 @@ +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 { CreateInspectionDto } from './dto/create-inspection.dto'; +import { CreateTaskFromInspectionDto } from './dto/create-task-from-inspection.dto'; +import { InspectionsService } from './inspections.service'; + +@ApiTags('inspections') +@ApiBearerAuth() +@Controller('inspections') +export class InspectionsController { + constructor(private readonly inspectionsService: InspectionsService) {} + + @Get() + findAll() { + return this.inspectionsService.findAll(); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.inspectionsService.findById(id); + } + + @Roles(Role.ADMIN, Role.BEEKEEPER, Role.STAFF) + @Post() + create( + @Body() dto: CreateInspectionDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.inspectionsService.create(dto, actor.id); + } + + @Roles(Role.ADMIN, Role.BEEKEEPER, Role.STAFF) + @Post(':id/create-task') + createTask( + @Param('id') id: string, + @Body() dto: CreateTaskFromInspectionDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.inspectionsService.createTaskFromInspection(id, dto, actor.id); + } +} diff --git a/ApiCore-Backend/src/inspections/inspections.module.ts b/ApiCore-Backend/src/inspections/inspections.module.ts new file mode 100644 index 0000000..4d81fd9 --- /dev/null +++ b/ApiCore-Backend/src/inspections/inspections.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { InspectionsController } from './inspections.controller'; +import { ColonyInspectionsController } from './colony-inspections.controller'; +import { InspectionsService } from './inspections.service'; + +@Module({ + controllers: [InspectionsController, ColonyInspectionsController], + providers: [InspectionsService], + exports: [InspectionsService], +}) +export class InspectionsModule {} diff --git a/ApiCore-Backend/src/inspections/inspections.service.spec.ts b/ApiCore-Backend/src/inspections/inspections.service.spec.ts new file mode 100644 index 0000000..6aa2ff7 --- /dev/null +++ b/ApiCore-Backend/src/inspections/inspections.service.spec.ts @@ -0,0 +1,81 @@ +import { NotFoundException } from '@nestjs/common'; +import { InspectionsService } from './inspections.service'; +import { PrismaService } from '../prisma/prisma.service'; + +describe('InspectionsService', () => { + let service: InspectionsService; + let prisma: { + inspection: { + findMany: jest.Mock; + findUnique: jest.Mock; + create: jest.Mock; + }; + task: { create: jest.Mock }; + }; + + const inspection = { + id: 'inspection-1', + colonyId: 'colony-1', + date: new Date(), + }; + + beforeEach(() => { + prisma = { + inspection: { + findMany: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + }, + task: { create: jest.fn() }, + }; + service = new InspectionsService(prisma as unknown as PrismaService); + }); + + it('findById throws NotFoundException when missing', async () => { + prisma.inspection.findUnique.mockResolvedValue(null); + await expect(service.findById('missing')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('create stores the acting user as the inspector', async () => { + prisma.inspection.create.mockResolvedValue(inspection); + + await service.create( + { colonyId: 'colony-1', date: '2026-01-01' }, + 'user-1', + ); + + const [[createArg]] = prisma.inspection.create.mock.calls as [ + [{ data: { colonyId: string; userId: string } }], + ]; + expect(createArg.data.colonyId).toBe('colony-1'); + expect(createArg.data.userId).toBe('user-1'); + }); + + it('createTaskFromInspection links the task to the inspection and its colony', async () => { + prisma.inspection.findUnique.mockResolvedValue(inspection); + prisma.task.create.mockResolvedValue({ id: 'task-1' }); + + await service.createTaskFromInspection( + 'inspection-1', + { title: 'Nachschauen' }, + 'user-1', + ); + + const [[taskArg]] = prisma.task.create.mock.calls as [ + [ + { + data: { + sourceInspectionId: string; + relatedType: string; + relatedId: string; + }; + }, + ], + ]; + expect(taskArg.data.sourceInspectionId).toBe('inspection-1'); + expect(taskArg.data.relatedType).toBe('COLONY'); + expect(taskArg.data.relatedId).toBe('colony-1'); + }); +}); diff --git a/ApiCore-Backend/src/inspections/inspections.service.ts b/ApiCore-Backend/src/inspections/inspections.service.ts new file mode 100644 index 0000000..4a879fb --- /dev/null +++ b/ApiCore-Backend/src/inspections/inspections.service.ts @@ -0,0 +1,61 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { Inspection, Task } from '../../generated/prisma'; +import { CreateInspectionDto } from './dto/create-inspection.dto'; +import { CreateTaskFromInspectionDto } from './dto/create-task-from-inspection.dto'; + +@Injectable() +export class InspectionsService { + constructor(private readonly prisma: PrismaService) {} + + async findAll(): Promise { + return this.prisma.inspection.findMany({ orderBy: { date: 'desc' } }); + } + + async findById(id: string): Promise { + const inspection = await this.prisma.inspection.findUnique({ + where: { id }, + }); + if (!inspection) { + throw new NotFoundException('Inspection not found'); + } + return inspection; + } + + async findByColony(colonyId: string): Promise { + return this.prisma.inspection.findMany({ + where: { colonyId }, + orderBy: { date: 'desc' }, + }); + } + + async create( + dto: CreateInspectionDto, + actingUserId: string, + ): Promise { + return this.prisma.inspection.create({ + data: { ...dto, date: new Date(dto.date), userId: actingUserId }, + }); + } + + async createTaskFromInspection( + id: string, + dto: CreateTaskFromInspectionDto, + actingUserId: string, + ): Promise { + const inspection = await this.findById(id); + return this.prisma.task.create({ + data: { + title: dto.title, + description: dto.description, + priority: dto.priority, + dueDate: dto.dueDate ? new Date(dto.dueDate) : undefined, + assignedUserId: dto.assignedUserId, + createdByUserId: actingUserId, + relatedType: 'COLONY', + relatedId: inspection.colonyId, + sourceInspectionId: inspection.id, + }, + }); + } +} diff --git a/ApiCore-Backend/test/inspections.e2e-spec.ts b/ApiCore-Backend/test/inspections.e2e-spec.ts new file mode 100644 index 0000000..5fe07a3 --- /dev/null +++ b/ApiCore-Backend/test/inspections.e2e-spec.ts @@ -0,0 +1,130 @@ +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 InspectionBody { + id: string; + colonyId: string; +} + +interface TaskBody { + id: string; + sourceInspectionId: string | null; +} + +describe('Inspections (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const staffEmail = `inspections-e2e-staff-${Date.now()}@example.com`; + const readonlyEmail = `inspections-e2e-readonly-${Date.now()}@example.com`; + const password = 'SuperSecret123!'; + let staffId: string; + let readonlyId: string; + let staffToken: string; + let readonlyToken: string; + let apiaryId: string; + let colonyId: string; + let inspectionId: string; + let taskId: string; + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + + const passwordHash = await bcrypt.hash(password, 12); + const staff = await prisma.user.create({ + data: { email: staffEmail, passwordHash, name: 'Staff', role: 'STAFF' }, + }); + staffId = staff.id; + const readonly = await prisma.user.create({ + data: { + email: readonlyEmail, + passwordHash, + name: 'Readonly', + role: 'READONLY', + }, + }); + readonlyId = readonly.id; + staffToken = (await login(app, staffEmail, password)).accessToken; + readonlyToken = (await login(app, readonlyEmail, 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 () => { + if (taskId) { + await prisma.task.delete({ where: { id: taskId } }); + } + await prisma.inspection.deleteMany({ where: { colonyId } }); + await prisma.colony.deleteMany({ where: { id: colonyId } }); + await prisma.apiary.deleteMany({ where: { id: apiaryId } }); + await prisma.refreshToken.deleteMany({ + where: { userId: { in: [staffId, readonlyId] } }, + }); + await prisma.auditLog.deleteMany({ + where: { userId: { in: [staffId, readonlyId] } }, + }); + await prisma.user.deleteMany({ + where: { id: { in: [staffId, readonlyId] } }, + }); + await app.close(); + }); + + it('rejects create for a READONLY user', () => { + return request(app.getHttpServer()) + .post('/api/v1/inspections') + .set('Authorization', `Bearer ${readonlyToken}`) + .send({ colonyId, date: new Date().toISOString() }) + .expect(403); + }); + + it('lets a STAFF user document an inspection', async () => { + const response = await request(app.getHttpServer()) + .post('/api/v1/inspections') + .set('Authorization', `Bearer ${staffToken}`) + .send({ + colonyId, + date: new Date().toISOString(), + queenSeen: true, + colonyStrength: 8, + }) + .expect(201); + inspectionId = (response.body as InspectionBody).id; + expect(inspectionId).toEqual(expect.any(String)); + }); + + it('lists inspections for the colony', async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v1/colonies/${colonyId}/inspections`) + .set('Authorization', `Bearer ${readonlyToken}`) + .expect(200); + const inspections = response.body as InspectionBody[]; + expect(inspections.some((entry) => entry.id === inspectionId)).toBe(true); + }); + + it('creates a follow-up task from the inspection', async () => { + const response = await request(app.getHttpServer()) + .post(`/api/v1/inspections/${inspectionId}/create-task`) + .set('Authorization', `Bearer ${staffToken}`) + .send({ title: 'Varroabehandlung planen' }) + .expect(201); + const task = response.body as TaskBody; + taskId = task.id; + expect(task.sourceInspectionId).toBe(inspectionId); + }); +}); diff --git a/TASKS.md b/TASKS.md index da12413..49d96b8 100644 --- a/TASKS.md +++ b/TASKS.md @@ -50,7 +50,7 @@ Abgeleitet aus [`docs/lastenheft.md`](docs/lastenheft.md) Abschnitt 26. Reihenfo - Backend: `InspectionsModule` - Endpunkte: `GET/POST /api/v1/inspections`, `GET /api/v1/inspections/:id`, `GET /api/v1/colonies/:id/inspections`, `POST /api/v1/inspections/:id/create-task` - Frontend: `/inspections/new` (mobile-optimiertes Schnellformular), `/colonies/[id]` Tab "Durchsichten" -- [ ] Backend-Modul fertig, getestet, grün, committet +- [x] Backend-Modul fertig, getestet, grün, committet - [ ] Frontend-Views fertig, getestet, grün, committet ### 6. Aufgaben