Files
ApiCore/ApiCore-Backend/src/queens/queens.service.ts
T
Nick SzaunigandClaude Opus 5 42ceb33844
Backend Image / build-and-push (push) Successful in 2m3s
Frontend Image / build-and-push (push) Successful in 1m41s
feat: allow correcting honey measurements and queen evaluations
Both records could be created but never changed. A water-content reading
decides whether a batch may be sold as honey at all, and queen ratings drive
breeding decisions - a slipped digit in either is exactly the kind of wrong
value that must not be left standing.

- New PATCH /honey-batches/measurements/:measurementId behind
  honey-batches:measure, and PATCH /queens/evaluations/:evaluationId behind
  queens:evaluate. Both are addressed by the child record's own id, so the
  routes take two path segments and do not collide with PATCH /:id.
- Both log before and after data, like every other correction.
- The measurement list on the honey batch page and the rating list on the queen
  page gained an edit dialog; the rating dialog reuses the existing criteria
  list rather than restating it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-19 08:15:54 +02:00

354 lines
10 KiB
TypeScript

import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { CalendarSyncService } from '../calendar-sync/calendar-sync.service';
import { Queen, QueenStatus } from '../../generated/prisma';
import { CreateQueenDto } from './dto/create-queen.dto';
import { UpdateQueenDto } from './dto/update-queen.dto';
import { AssignColonyDto } from './dto/assign-colony.dto';
import { CreateQueenEvaluationDto } from './dto/create-queen-evaluation.dto';
import { UpdateQueenEvaluationDto } from './dto/update-queen-evaluation.dto';
export interface QueenFilter {
status?: QueenStatus;
}
export interface QueenLineageAncestor {
id: string;
code: string;
hatchYear: number | null;
status: QueenStatus;
mother: QueenLineageAncestor | null;
}
export interface QueenLineageDescendant {
id: string;
code: string;
hatchYear: number | null;
status: QueenStatus;
daughters: QueenLineageDescendant[];
}
export interface QueenLineage {
queen: QueenLineageAncestor;
descendants: QueenLineageDescendant[];
}
const MAX_LINEAGE_DEPTH = 8;
// Once a queen reaches one of these statuses she is no longer really "in"
// whatever colony currently points at her - a colony's currentQueenId and
// status would otherwise silently go stale (still showing a dead/lost
// queen as current, never flagged QUEENLESS) until someone happens to
// notice and fix it by hand.
const QUEEN_NO_LONGER_AVAILABLE_STATUSES: QueenStatus[] = [
'LOST',
'DECEASED',
'REPLACED',
'SOLD',
];
const TERMINAL_COLONY_STATUSES = ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'];
@Injectable()
export class QueensService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly webhooks: WebhooksService,
private readonly calendarSync: CalendarSyncService,
) {}
async findAll(filter: QueenFilter = {}): Promise<Queen[]> {
return this.prisma.queen.findMany({
where: { status: filter.status },
orderBy: { code: 'asc' },
});
}
async findById(id: string): Promise<Queen> {
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<Queen> {
if (dto.motherQueenId) {
await this.assertMotherQueenExists(dto.motherQueenId);
}
const queen = await this.prisma.queen.create({ data: { ...dto } });
await this.auditLog.record({
entityType: 'Queen',
entityId: queen.id,
action: 'CREATE',
userId: actingUserId,
afterData: queen,
});
await this.webhooks.dispatch('queen.created', queen);
return queen;
}
async update(
id: string,
dto: UpdateQueenDto,
actingUserId: string,
): Promise<Queen> {
const before = await this.findById(id);
if (dto.motherQueenId) {
if (dto.motherQueenId === id) {
throw new BadRequestException('A queen cannot be her own mother');
}
await this.assertMotherQueenExists(dto.motherQueenId);
}
const becameUnavailable =
dto.status !== undefined &&
dto.status !== before.status &&
QUEEN_NO_LONGER_AVAILABLE_STATUSES.includes(dto.status);
const queen = await this.prisma.$transaction(async (tx) => {
const updated = await tx.queen.update({
where: { id },
data: { ...dto },
});
if (becameUnavailable) {
const colony = await tx.colony.findUnique({
where: { currentQueenId: id },
});
if (colony) {
await tx.colony.update({
where: { id: colony.id },
data: {
currentQueenId: null,
status: TERMINAL_COLONY_STATUSES.includes(colony.status)
? undefined
: 'QUEENLESS',
},
});
await tx.queenColonyHistory.updateMany({
where: { queenId: id, colonyId: colony.id, endDate: null },
data: { endDate: new Date() },
});
}
}
return updated;
});
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<Queen> {
const queen = await this.findById(id);
if (QUEEN_NO_LONGER_AVAILABLE_STATUSES.includes(queen.status)) {
throw new ConflictException(
'Die Königin ist nicht mehr verfügbar für eine Zuweisung - korrigiere zuerst ihren Status, falls dies ein Versehen war',
);
}
const colony = await this.prisma.colony.findUnique({
where: { id: dto.colonyId },
});
if (!colony) {
throw new NotFoundException('Colony not found');
}
const history = 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 },
});
return 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 },
});
const updated = await this.findById(id);
await this.webhooks.dispatch('queen.replaced', updated);
await this.calendarSync.enqueue('QUEEN_ASSIGNMENT', history.id);
return updated;
}
async getHistory(id: string) {
await this.findById(id);
return this.prisma.queenColonyHistory.findMany({
where: { queenId: id },
orderBy: { startDate: 'desc' },
});
}
async getEvaluations(id: string) {
await this.findById(id);
return this.prisma.queenEvaluation.findMany({
where: { queenId: id },
orderBy: { date: 'desc' },
});
}
async updateEvaluation(
evaluationId: string,
dto: UpdateQueenEvaluationDto,
actingUserId: string,
) {
const before = await this.prisma.queenEvaluation.findUnique({
where: { id: evaluationId },
});
if (!before) {
throw new NotFoundException('Queen evaluation not found');
}
const evaluation = await this.prisma.queenEvaluation.update({
where: { id: evaluationId },
data: { ...dto, date: dto.date ? new Date(dto.date) : undefined },
});
await this.auditLog.record({
entityType: 'QueenEvaluation',
entityId: evaluationId,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: evaluation,
});
return evaluation;
}
async addEvaluation(
id: string,
dto: CreateQueenEvaluationDto,
actingUserId: string,
) {
await this.findById(id);
const evaluation = await this.prisma.queenEvaluation.create({
data: {
queenId: id,
colonyId: dto.colonyId,
date: new Date(dto.date),
colonyStrength: dto.colonyStrength,
gentleness: dto.gentleness,
combBuild: dto.combBuild,
swarmingTendency: dto.swarmingTendency,
broodPattern: dto.broodPattern,
honeyYield: dto.honeyYield,
notes: dto.notes,
userId: actingUserId,
},
});
await this.auditLog.record({
entityType: 'QueenEvaluation',
entityId: evaluation.id,
action: 'CREATE',
userId: actingUserId,
afterData: evaluation,
});
return evaluation;
}
async getLineage(id: string): Promise<QueenLineage> {
const queen = await this.findById(id);
const ancestorChain = await this.buildAncestorChain(
queen,
MAX_LINEAGE_DEPTH,
);
const descendants = await this.buildDescendantTree(id, MAX_LINEAGE_DEPTH);
return { queen: ancestorChain, descendants };
}
private async assertMotherQueenExists(motherQueenId: string): Promise<void> {
const mother = await this.prisma.queen.findUnique({
where: { id: motherQueenId },
});
if (!mother) {
throw new NotFoundException('Mother queen not found');
}
}
private async buildAncestorChain(
queen: Queen,
depthRemaining: number,
): Promise<QueenLineageAncestor> {
const node: QueenLineageAncestor = {
id: queen.id,
code: queen.code,
hatchYear: queen.hatchYear,
status: queen.status,
mother: null,
};
if (queen.motherQueenId && depthRemaining > 0) {
const mother = await this.prisma.queen.findUnique({
where: { id: queen.motherQueenId },
});
if (mother) {
node.mother = await this.buildAncestorChain(mother, depthRemaining - 1);
}
}
return node;
}
private async buildDescendantTree(
queenId: string,
depthRemaining: number,
): Promise<QueenLineageDescendant[]> {
if (depthRemaining <= 0) {
return [];
}
const daughters = await this.prisma.queen.findMany({
where: { motherQueenId: queenId },
orderBy: { code: 'asc' },
});
return Promise.all(
daughters.map(async (daughter) => ({
id: daughter.id,
code: daughter.code,
hatchYear: daughter.hatchYear,
status: daughter.status,
daughters: await this.buildDescendantTree(
daughter.id,
depthRemaining - 1,
),
})),
);
}
}