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]>
354 lines
10 KiB
TypeScript
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,
|
|
),
|
|
})),
|
|
);
|
|
}
|
|
}
|