feat: Schwarmkontrolle-Protokoll

Neues SwarmControlCheck-Modul: strukturierte Weiselzellenkontrollen pro
Volk mit Zahl/Verdeckelungsstatus der Zellen, Schwarmstimmung, ergriffener
Maßnahme und optionalem naechsten Kontrolltermin.

Frontend: neue Karte auf der Volk-Detailseite, Erfassungsformular unter
/swarm-control/new sowie Quick-Add-Eintrag.
This commit is contained in:
Nick Szaunig committed 2026-08-25 09:12:50 +02:00
1 parent 4609a0f9ec
commit 1a7331b6b2
19 files changed
+1076

No files matched your search

@@ -0,0 +1,31 @@
-- CreateEnum
CREATE TYPE "SwarmMood" AS ENUM ('NONE', 'LOW', 'HIGH');
-- CreateEnum
CREATE TYPE "SwarmControlAction" AS ENUM ('NONE', 'QUEEN_CELLS_REMOVED', 'ARTIFICIAL_SWARM', 'COLONY_DIVIDED', 'OTHER');
-- CreateTable
CREATE TABLE "SwarmControlCheck" (
"id" TEXT NOT NULL,
"colonyId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"queenCellsFound" INTEGER,
"queenCellsCapped" BOOLEAN NOT NULL DEFAULT false,
"swarmMood" "SwarmMood" NOT NULL DEFAULT 'NONE',
"actionTaken" "SwarmControlAction" NOT NULL DEFAULT 'NONE',
"notes" TEXT,
"nextCheckDate" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SwarmControlCheck_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SwarmControlCheck_colonyId_date_idx" ON "SwarmControlCheck"("colonyId", "date");
-- AddForeignKey
ALTER TABLE "SwarmControlCheck" ADD CONSTRAINT "SwarmControlCheck_colonyId_fkey" FOREIGN KEY ("colonyId") REFERENCES "Colony"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SwarmControlCheck" ADD CONSTRAINT "SwarmControlCheck_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+39
View File
@@ -39,6 +39,7 @@ model User {
inspections Inspection[]
varroaMeasurements VarroaMeasurement[]
swarmControlChecks SwarmControlCheck[]
treatments Treatment[]
feedings Feeding[]
harvests Harvest[]
@@ -175,6 +176,7 @@ model Colony {
lineageAsResult ColonyLineage[] @relation("LineageTo")
inspections Inspection[]
varroaMeasurements VarroaMeasurement[]
swarmControlChecks SwarmControlCheck[]
treatments Treatment[]
feedings Feeding[]
harvests HarvestColony[]
@@ -457,6 +459,43 @@ model VarroaMeasurement {
@@index([colonyId, date])
}
// ---------------------------------------------------------------------------
// Schwarmkontrolle (jenseits Lastenheft: strukturiertes Protokoll für
// Weiselzellenkontrollen während der Schwarmzeit)
// ---------------------------------------------------------------------------
enum SwarmMood {
NONE
LOW
HIGH
}
enum SwarmControlAction {
NONE
QUEEN_CELLS_REMOVED
ARTIFICIAL_SWARM
COLONY_DIVIDED
OTHER
}
model SwarmControlCheck {
id String @id @default(cuid())
colonyId String
colony Colony @relation(fields: [colonyId], references: [id])
userId String
user User @relation(fields: [userId], references: [id])
date DateTime
queenCellsFound Int?
queenCellsCapped Boolean @default(false)
swarmMood SwarmMood @default(NONE)
actionTaken SwarmControlAction @default(NONE)
notes String?
nextCheckDate DateTime?
createdAt DateTime @default(now())
@@index([colonyId, date])
}
// ---------------------------------------------------------------------------
// Behandlung / Tierarzneimittel (Abschnitt 4.11)
// ---------------------------------------------------------------------------
+2
View File
@@ -34,6 +34,7 @@ import { AutomationModule } from './automation/automation.module';
import { ImportModule } from './import/import.module';
import { PublicModule } from './public/public.module';
import { TaskTemplatesModule } from './task-templates/task-templates.module';
import { SwarmControlModule } from './swarm-control/swarm-control.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { RolesGuard } from './auth/guards/roles.guard';
@@ -73,6 +74,7 @@ import { RolesGuard } from './auth/guards/roles.guard';
ImportModule,
PublicModule,
TaskTemplatesModule,
SwarmControlModule,
],
controllers: [],
providers: [
@@ -0,0 +1,15 @@
import { Controller, Get, Param } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { SwarmControlService } from './swarm-control.service';
@ApiTags('swarm-control-checks')
@ApiBearerAuth()
@Controller('colonies')
export class ColonySwarmControlChecksController {
constructor(private readonly swarmControlService: SwarmControlService) {}
@Get(':id/swarm-control-checks')
findByColony(@Param('id') id: string) {
return this.swarmControlService.findByColony(id);
}
}
@@ -0,0 +1,50 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsBoolean,
IsDateString,
IsEnum,
IsInt,
IsOptional,
IsString,
} from 'class-validator';
import { SwarmControlAction, SwarmMood } from '../../../generated/prisma';
export class CreateSwarmControlCheckDto {
@ApiProperty()
@IsString()
colonyId!: string;
@ApiProperty()
@IsDateString()
date!: string;
@ApiPropertyOptional()
@IsOptional()
@IsInt()
queenCellsFound?: number;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
queenCellsCapped?: boolean;
@ApiPropertyOptional({ enum: SwarmMood })
@IsOptional()
@IsEnum(SwarmMood)
swarmMood?: SwarmMood;
@ApiPropertyOptional({ enum: SwarmControlAction })
@IsOptional()
@IsEnum(SwarmControlAction)
actionTaken?: SwarmControlAction;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
nextCheckDate?: string;
}
@@ -0,0 +1,14 @@
import { PartialType, PickType } from '@nestjs/swagger';
import { CreateSwarmControlCheckDto } from './create-swarm-control-check.dto';
export class UpdateSwarmControlCheckDto extends PartialType(
PickType(CreateSwarmControlCheckDto, [
'date',
'queenCellsFound',
'queenCellsCapped',
'swarmMood',
'actionTaken',
'notes',
'nextCheckDate',
] as const),
) {}
@@ -0,0 +1,45 @@
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 { CreateSwarmControlCheckDto } from './dto/create-swarm-control-check.dto';
import { UpdateSwarmControlCheckDto } from './dto/update-swarm-control-check.dto';
import { SwarmControlService } from './swarm-control.service';
@ApiTags('swarm-control-checks')
@ApiBearerAuth()
@Controller('swarm-control-checks')
export class SwarmControlController {
constructor(private readonly swarmControlService: SwarmControlService) {}
@Get()
findAll() {
return this.swarmControlService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.swarmControlService.findById(id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@Post()
create(
@Body() dto: CreateSwarmControlCheckDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.swarmControlService.create(dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@Patch(':id')
update(
@Param('id') id: string,
@Body() dto: UpdateSwarmControlCheckDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.swarmControlService.update(id, dto, actor.id);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { SwarmControlController } from './swarm-control.controller';
import { ColonySwarmControlChecksController } from './colony-swarm-control-checks.controller';
import { SwarmControlService } from './swarm-control.service';
@Module({
controllers: [SwarmControlController, ColonySwarmControlChecksController],
providers: [SwarmControlService],
exports: [SwarmControlService],
})
export class SwarmControlModule {}
@@ -0,0 +1,111 @@
import { NotFoundException } from '@nestjs/common';
import { SwarmControlService } from './swarm-control.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
describe('SwarmControlService', () => {
let service: SwarmControlService;
let prisma: {
swarmControlCheck: {
findMany: jest.Mock;
findUnique: jest.Mock;
create: jest.Mock;
update: jest.Mock;
};
};
let auditLog: { record: jest.Mock };
const check = {
id: 'check-1',
colonyId: 'colony-1',
date: new Date('2026-05-01T00:00:00.000Z'),
};
beforeEach(() => {
prisma = {
swarmControlCheck: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
};
auditLog = { record: jest.fn() };
service = new SwarmControlService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
);
});
it('findById throws NotFoundException when missing', async () => {
prisma.swarmControlCheck.findUnique.mockResolvedValue(null);
await expect(service.findById('missing')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('create stores the acting user and converts date fields', async () => {
prisma.swarmControlCheck.create.mockResolvedValue(check);
await service.create(
{
colonyId: 'colony-1',
date: '2026-05-01',
nextCheckDate: '2026-05-08',
},
'user-1',
);
const [[createArg]] = prisma.swarmControlCheck.create.mock.calls as [
[
{
data: {
colonyId: string;
userId: string;
date: Date;
nextCheckDate: Date;
};
},
],
];
expect(createArg.data.colonyId).toBe('colony-1');
expect(createArg.data.userId).toBe('user-1');
expect(createArg.data.date).toBeInstanceOf(Date);
expect(createArg.data.nextCheckDate).toBeInstanceOf(Date);
});
it('update writes an audit log entry', async () => {
prisma.swarmControlCheck.findUnique.mockResolvedValue(check);
const updated = { ...check, queenCellsFound: 3, queenCellsCapped: true };
prisma.swarmControlCheck.update.mockResolvedValue(updated);
const result = await service.update(
'check-1',
{ queenCellsFound: 3, queenCellsCapped: true },
'user-1',
);
expect(result).toBe(updated);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'SwarmControlCheck',
entityId: 'check-1',
action: 'UPDATE',
beforeData: check,
afterData: updated,
}),
);
});
it('findByColony orders results chronologically', async () => {
prisma.swarmControlCheck.findMany.mockResolvedValue([check]);
await service.findByColony('colony-1');
const [[queryArg]] = prisma.swarmControlCheck.findMany.mock.calls as [
[{ where: { colonyId: string }; orderBy: { date: string } }],
];
expect(queryArg.where.colonyId).toBe('colony-1');
expect(queryArg.orderBy.date).toBe('asc');
});
});
@@ -0,0 +1,88 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { SwarmControlCheck } from '../../generated/prisma';
import { CreateSwarmControlCheckDto } from './dto/create-swarm-control-check.dto';
import { UpdateSwarmControlCheckDto } from './dto/update-swarm-control-check.dto';
@Injectable()
export class SwarmControlService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
) {}
async findAll(): Promise<SwarmControlCheck[]> {
return this.prisma.swarmControlCheck.findMany({
orderBy: { date: 'desc' },
});
}
async findById(id: string): Promise<SwarmControlCheck> {
const check = await this.prisma.swarmControlCheck.findUnique({
where: { id },
});
if (!check) {
throw new NotFoundException('Swarm control check not found');
}
return check;
}
async findByColony(colonyId: string): Promise<SwarmControlCheck[]> {
return this.prisma.swarmControlCheck.findMany({
where: { colonyId },
orderBy: { date: 'asc' },
});
}
async create(
dto: CreateSwarmControlCheckDto,
actingUserId: string,
): Promise<SwarmControlCheck> {
const check = await this.prisma.swarmControlCheck.create({
data: {
...dto,
date: new Date(dto.date),
nextCheckDate: dto.nextCheckDate
? new Date(dto.nextCheckDate)
: undefined,
userId: actingUserId,
},
});
await this.auditLog.record({
entityType: 'SwarmControlCheck',
entityId: check.id,
action: 'CREATE',
userId: actingUserId,
afterData: check,
});
return check;
}
async update(
id: string,
dto: UpdateSwarmControlCheckDto,
actingUserId: string,
): Promise<SwarmControlCheck> {
const before = await this.findById(id);
const check = await this.prisma.swarmControlCheck.update({
where: { id },
data: {
...dto,
date: dto.date ? new Date(dto.date) : undefined,
nextCheckDate: dto.nextCheckDate
? new Date(dto.nextCheckDate)
: undefined,
},
});
await this.auditLog.record({
entityType: 'SwarmControlCheck',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: check,
});
return check;
}
}
@@ -0,0 +1,127 @@
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 SwarmControlCheckBody {
id: string;
colonyId: string;
queenCellsCapped: boolean;
}
describe('Swarm control checks (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const beekeeperEmail = `swarm-control-e2e-beekeeper-${Date.now()}@example.com`;
const staffEmail = `swarm-control-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 checkId: 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.swarmControlCheck.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/swarm-control-checks')
.set('Authorization', `Bearer ${staffToken}`)
.send({ colonyId, date: new Date().toISOString() })
.expect(403);
});
it('lets a BEEKEEPER user record a check', async () => {
const response = await request(app.getHttpServer())
.post('/api/v1/swarm-control-checks')
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({
colonyId,
date: new Date().toISOString(),
queenCellsFound: 2,
queenCellsCapped: false,
swarmMood: 'LOW',
})
.expect(201);
checkId = (response.body as SwarmControlCheckBody).id;
expect(checkId).toEqual(expect.any(String));
});
it('lists checks for the colony', async () => {
const response = await request(app.getHttpServer())
.get(`/api/v1/colonies/${colonyId}/swarm-control-checks`)
.set('Authorization', `Bearer ${staffToken}`)
.expect(200);
const checks = response.body as SwarmControlCheckBody[];
expect(checks.some((entry) => entry.id === checkId)).toBe(true);
});
it('updates a check to record capped queen cells', async () => {
const response = await request(app.getHttpServer())
.patch(`/api/v1/swarm-control-checks/${checkId}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({
queenCellsCapped: true,
actionTaken: 'ARTIFICIAL_SWARM',
nextCheckDate: new Date().toISOString(),
})
.expect(200);
expect((response.body as SwarmControlCheckBody).queenCellsCapped).toBe(
true,
);
});
});