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:
1 parent
4609a0f9ec
commit
1a7331b6b2
19 files changed
+1076
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user