diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index 5dfda78..4884c0c 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -8,6 +8,7 @@ import { UsersModule } from './users/users.module'; 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 { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { RolesGuard } from './auth/guards/roles.guard'; @@ -21,6 +22,7 @@ import { RolesGuard } from './auth/guards/roles.guard'; AuthModule, ApiariesModule, ColoniesModule, + QueensModule, ], controllers: [], providers: [ diff --git a/ApiCore-Backend/src/queens/dto/assign-colony.dto.ts b/ApiCore-Backend/src/queens/dto/assign-colony.dto.ts new file mode 100644 index 0000000..09f74e8 --- /dev/null +++ b/ApiCore-Backend/src/queens/dto/assign-colony.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString } from 'class-validator'; + +export class AssignColonyDto { + @ApiProperty() + @IsString() + colonyId!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} diff --git a/ApiCore-Backend/src/queens/dto/create-queen.dto.ts b/ApiCore-Backend/src/queens/dto/create-queen.dto.ts new file mode 100644 index 0000000..742054d --- /dev/null +++ b/ApiCore-Backend/src/queens/dto/create-queen.dto.ts @@ -0,0 +1,68 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsInt, IsOptional, IsString } from 'class-validator'; + +export class CreateQueenDto { + @ApiProperty() + @IsString() + code!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + hatchYear?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + breed?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + origin?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + breeder?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + markingColor?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + markingNumber?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + matingType?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + matingStatus?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + inseminationData?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + motherLine?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + fatherLine?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} diff --git a/ApiCore-Backend/src/queens/dto/update-queen.dto.ts b/ApiCore-Backend/src/queens/dto/update-queen.dto.ts new file mode 100644 index 0000000..72bf224 --- /dev/null +++ b/ApiCore-Backend/src/queens/dto/update-queen.dto.ts @@ -0,0 +1,11 @@ +import { ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { IsEnum, IsOptional } from 'class-validator'; +import { QueenStatus } from '../../../generated/prisma'; +import { CreateQueenDto } from './create-queen.dto'; + +export class UpdateQueenDto extends PartialType(CreateQueenDto) { + @ApiPropertyOptional({ enum: QueenStatus }) + @IsOptional() + @IsEnum(QueenStatus) + status?: QueenStatus; +} diff --git a/ApiCore-Backend/src/queens/queens.controller.ts b/ApiCore-Backend/src/queens/queens.controller.ts new file mode 100644 index 0000000..3b3f2b2 --- /dev/null +++ b/ApiCore-Backend/src/queens/queens.controller.ts @@ -0,0 +1,58 @@ +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 { CreateQueenDto } from './dto/create-queen.dto'; +import { UpdateQueenDto } from './dto/update-queen.dto'; +import { AssignColonyDto } from './dto/assign-colony.dto'; +import { QueensService } from './queens.service'; + +@ApiTags('queens') +@ApiBearerAuth() +@Controller('queens') +export class QueensController { + constructor(private readonly queensService: QueensService) {} + + @Get() + findAll() { + return this.queensService.findAll(); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.queensService.findById(id); + } + + @Get(':id/history') + getHistory(@Param('id') id: string) { + return this.queensService.getHistory(id); + } + + @Roles(Role.ADMIN, Role.BEEKEEPER) + @Post() + create(@Body() dto: CreateQueenDto, @CurrentUser() actor: AuthenticatedUser) { + return this.queensService.create(dto, actor.id); + } + + @Roles(Role.ADMIN, Role.BEEKEEPER) + @Patch(':id') + update( + @Param('id') id: string, + @Body() dto: UpdateQueenDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.queensService.update(id, dto, actor.id); + } + + @Roles(Role.ADMIN, Role.BEEKEEPER) + @Post(':id/assign-colony') + assignColony( + @Param('id') id: string, + @Body() dto: AssignColonyDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.queensService.assignColony(id, dto, actor.id); + } +} diff --git a/ApiCore-Backend/src/queens/queens.module.ts b/ApiCore-Backend/src/queens/queens.module.ts new file mode 100644 index 0000000..1dc8b83 --- /dev/null +++ b/ApiCore-Backend/src/queens/queens.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { QueensController } from './queens.controller'; +import { QueensService } from './queens.service'; + +@Module({ + controllers: [QueensController], + providers: [QueensService], + exports: [QueensService], +}) +export class QueensModule {} diff --git a/ApiCore-Backend/src/queens/queens.service.spec.ts b/ApiCore-Backend/src/queens/queens.service.spec.ts new file mode 100644 index 0000000..d91639d --- /dev/null +++ b/ApiCore-Backend/src/queens/queens.service.spec.ts @@ -0,0 +1,96 @@ +import { NotFoundException } from '@nestjs/common'; +import { QueensService } from './queens.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; + +describe('QueensService', () => { + let service: QueensService; + let tx: { + queenColonyHistory: { updateMany: jest.Mock; create: jest.Mock }; + colony: { update: jest.Mock }; + }; + let prisma: { + queen: { + findMany: jest.Mock; + findUnique: jest.Mock; + create: jest.Mock; + update: jest.Mock; + }; + colony: { findUnique: jest.Mock }; + queenColonyHistory: { findMany: jest.Mock }; + $transaction: jest.Mock; + }; + let auditLog: { record: jest.Mock }; + + const queen = { id: 'queen-1', code: 'Q-001', status: 'ACTIVE' }; + const colony = { id: 'colony-1', currentQueenId: null }; + + beforeEach(() => { + tx = { + queenColonyHistory: { updateMany: jest.fn(), create: jest.fn() }, + colony: { update: jest.fn() }, + }; + prisma = { + queen: { + findMany: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + colony: { findUnique: jest.fn() }, + queenColonyHistory: { findMany: jest.fn() }, + $transaction: jest.fn((callback: (txArg: typeof tx) => unknown) => + callback(tx), + ), + }; + auditLog = { record: jest.fn().mockResolvedValue(undefined) }; + service = new QueensService( + prisma as unknown as PrismaService, + auditLog as unknown as AuditLogService, + ); + }); + + it('findById throws NotFoundException when missing', async () => { + prisma.queen.findUnique.mockResolvedValue(null); + await expect(service.findById('missing')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('assignColony throws NotFoundException when the colony is missing', async () => { + prisma.queen.findUnique.mockResolvedValue(queen); + prisma.colony.findUnique.mockResolvedValue(null); + await expect( + service.assignColony('queen-1', { colonyId: 'missing' }, 'user-1'), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('assignColony closes the previous queen history, sets currentQueenId, and opens a new history row', async () => { + prisma.queen.findUnique.mockResolvedValue(queen); + prisma.colony.findUnique.mockResolvedValue({ + ...colony, + currentQueenId: 'queen-old', + }); + tx.colony.update.mockResolvedValue({}); + tx.queenColonyHistory.create.mockResolvedValue({}); + + await service.assignColony('queen-1', { colonyId: 'colony-1' }, 'user-1'); + + const [[closeArg]] = tx.queenColonyHistory.updateMany.mock.calls as [ + [{ where: { queenId: string; colonyId: string } }], + ]; + expect(closeArg.where.queenId).toBe('queen-old'); + expect(closeArg.where.colonyId).toBe('colony-1'); + expect(tx.colony.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'colony-1' }, + data: { currentQueenId: 'queen-1' }, + }), + ); + const [[historyArg]] = tx.queenColonyHistory.create.mock.calls as [ + [{ data: { queenId: string; colonyId: string } }], + ]; + expect(historyArg.data.queenId).toBe('queen-1'); + expect(historyArg.data.colonyId).toBe('colony-1'); + }); +}); diff --git a/ApiCore-Backend/src/queens/queens.service.ts b/ApiCore-Backend/src/queens/queens.service.ts new file mode 100644 index 0000000..e74b615 --- /dev/null +++ b/ApiCore-Backend/src/queens/queens.service.ts @@ -0,0 +1,122 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; +import { Queen } from '../../generated/prisma'; +import { CreateQueenDto } from './dto/create-queen.dto'; +import { UpdateQueenDto } from './dto/update-queen.dto'; +import { AssignColonyDto } from './dto/assign-colony.dto'; + +@Injectable() +export class QueensService { + constructor( + private readonly prisma: PrismaService, + private readonly auditLog: AuditLogService, + ) {} + + async findAll(): Promise { + return this.prisma.queen.findMany({ orderBy: { code: 'asc' } }); + } + + async findById(id: string): Promise { + const queen = await this.prisma.queen.findUnique({ where: { id } }); + if (!queen) { + throw new NotFoundException('Queen not found'); + } + return queen; + } + + async create(dto: CreateQueenDto, actingUserId: string): Promise { + const queen = await this.prisma.queen.create({ data: { ...dto } }); + await this.auditLog.record({ + entityType: 'Queen', + entityId: queen.id, + action: 'CREATE', + userId: actingUserId, + afterData: queen, + }); + return queen; + } + + async update( + id: string, + dto: UpdateQueenDto, + actingUserId: string, + ): Promise { + const before = await this.findById(id); + const queen = await this.prisma.queen.update({ + where: { id }, + data: { ...dto }, + }); + await this.auditLog.record({ + entityType: 'Queen', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: queen, + }); + return queen; + } + + async assignColony( + id: string, + dto: AssignColonyDto, + actingUserId: string, + ): Promise { + const queen = await this.findById(id); + const colony = await this.prisma.colony.findUnique({ + where: { id: dto.colonyId }, + }); + if (!colony) { + throw new NotFoundException('Colony not found'); + } + + await this.prisma.$transaction(async (tx) => { + if (colony.currentQueenId && colony.currentQueenId !== queen.id) { + await tx.queenColonyHistory.updateMany({ + where: { + queenId: colony.currentQueenId, + colonyId: colony.id, + endDate: null, + }, + data: { endDate: new Date() }, + }); + } + await tx.queenColonyHistory.updateMany({ + where: { queenId: queen.id, endDate: null }, + data: { endDate: new Date() }, + }); + await tx.colony.update({ + where: { id: colony.id }, + data: { currentQueenId: queen.id }, + }); + await tx.queenColonyHistory.create({ + data: { + queenId: queen.id, + colonyId: colony.id, + startDate: new Date(), + notes: dto.notes, + }, + }); + }); + + await this.auditLog.record({ + entityType: 'Queen', + entityId: queen.id, + action: 'UPDATE', + userId: actingUserId, + beforeData: queen, + afterData: { ...queen, assignedColonyId: colony.id }, + }); + + return this.findById(id); + } + + async getHistory(id: string) { + await this.findById(id); + return this.prisma.queenColonyHistory.findMany({ + where: { queenId: id }, + orderBy: { startDate: 'desc' }, + }); + } +} diff --git a/ApiCore-Backend/test/queens.e2e-spec.ts b/ApiCore-Backend/test/queens.e2e-spec.ts new file mode 100644 index 0000000..2e0baef --- /dev/null +++ b/ApiCore-Backend/test/queens.e2e-spec.ts @@ -0,0 +1,105 @@ +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 QueenBody { + id: string; + code: string; +} + +describe('Queens (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const beekeeperEmail = `queens-e2e-beekeeper-${Date.now()}@example.com`; + const password = 'SuperSecret123!'; + let beekeeperId: string; + let beekeeperToken: string; + let apiaryId: string; + let colonyId: string; + let queenId: 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; + beekeeperToken = (await login(app, beekeeperEmail, 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.queenColonyHistory.deleteMany({ where: { colonyId } }); + if (colonyId) { + await prisma.colony.update({ + where: { id: colonyId }, + data: { currentQueenId: null }, + }); + } + if (queenId) { + await prisma.queen.delete({ where: { id: queenId } }); + } + await prisma.colony.deleteMany({ where: { id: colonyId } }); + await prisma.apiary.deleteMany({ where: { id: apiaryId } }); + await prisma.refreshToken.deleteMany({ where: { userId: beekeeperId } }); + await prisma.auditLog.deleteMany({ where: { userId: beekeeperId } }); + await prisma.user.deleteMany({ where: { id: beekeeperId } }); + await app.close(); + }); + + it('lets a BEEKEEPER create a queen', async () => { + const response = await request(app.getHttpServer()) + .post('/api/v1/queens') + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ code: `Q-${Date.now()}`, breed: 'Carnica' }) + .expect(201); + queenId = (response.body as QueenBody).id; + expect(queenId).toEqual(expect.any(String)); + }); + + it('assigns the queen to a colony and records history', async () => { + await request(app.getHttpServer()) + .post(`/api/v1/queens/${queenId}/assign-colony`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ colonyId }) + .expect(201); + + const colony = await request(app.getHttpServer()) + .get(`/api/v1/colonies/${colonyId}`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + expect((colony.body as { currentQueenId: string }).currentQueenId).toBe( + queenId, + ); + + const history = await request(app.getHttpServer()) + .get(`/api/v1/queens/${queenId}/history`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + expect(history.body).toHaveLength(1); + }); +}); diff --git a/TASKS.md b/TASKS.md index 011ea9f..47508a1 100644 --- a/TASKS.md +++ b/TASKS.md @@ -42,7 +42,7 @@ Abgeleitet aus [`docs/lastenheft.md`](docs/lastenheft.md) Abschnitt 26. Reihenfo - Backend: `QueensModule` (inkl. `QueenColonyHistory`) - Endpunkte: `GET/POST /api/v1/queens`, `GET/PATCH /api/v1/queens/:id`, `POST /api/v1/queens/:id/assign-colony`, `GET /api/v1/queens/:id/history` - Frontend: `/queens`, `/queens/[id]`, `/queens/new` -- [ ] Backend-Modul fertig, getestet, grün, committet +- [x] Backend-Modul fertig, getestet, grün, committet - [ ] Frontend-Views fertig, getestet, grün, committet ### 5. Durchsichten