Data-integrity fixes found in a systematic review of ApiCore: - Bottling booked the honey batch amount from a value read outside the transaction and wrote an absolute result back, so two concurrent bottlings (double tap, two devices) could both pass the check and one deduction was lost. Bookings now use a conditional UPDATE inside the transaction; corrections and deletions give amounts back atomically. - Outgoing stock movements could drive stock below zero; the removal is now a conditional decrement that refuses when stock is insufficient. - Weak, conspicuous and queenless colonies were treated as "not active" and could not be moved, split or merged - uniting a queenless colony is the textbook merge. Only terminal statuses lock a colony now; merging a colony into itself and dissolving twice are rejected. - Moving a queen to another colony failed on the unique currentQueenId because the old colony kept pointing at her. The old colony now releases her (and becomes QUEENLESS); a queenless colony receiving a queen becomes ACTIVE; dissolved colonies cannot receive a queen. - Completing a task twice moved completedAt and re-sent the webhook; reopening a task kept completedAt. Recurring templates created tasks without a due date and caught up missed weeks one stale task at a time. - Automation rules created a new task and warning on every trigger (every stock removal below minimum). Tasks now record their source trigger and at most one open task per rule and object exists. The varroa threshold (mites per day) is only compared with natural-mite-fall rates, not with sample-based counts in another unit. - New opt-in Idempotency-Key support for record-creating endpoints, so the offline queue can retry after a lost response without duplicates. - Permission catalog sync crashed replicas booting concurrently on a fresh database (unique violation); it now uses createMany/skipDuplicates. - Server-side validation for inspection rating scales (1-5), unique colony/harvest lists and net weight <= gross weight. - Stale e2e expectations updated (permission count, trigger list, real PNG fixture for the file-signature check). Migration is additive only (nullable Task column, new table). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
388 lines
11 KiB
TypeScript
388 lines
11 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';
|
|
import {
|
|
TERMINAL_COLONY_STATUSES,
|
|
isLivingColonyStatus,
|
|
} from '../colonies/colony-status';
|
|
|
|
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',
|
|
];
|
|
|
|
@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');
|
|
}
|
|
if (!isLivingColonyStatus(colony.status)) {
|
|
throw new ConflictException(
|
|
`Das Volk besteht nicht mehr (Status: ${colony.status}) und kann keine Königin aufnehmen`,
|
|
);
|
|
}
|
|
if (colony.currentQueenId === queen.id) {
|
|
throw new ConflictException('Die Königin sitzt bereits in diesem Volk');
|
|
}
|
|
|
|
const history = await this.prisma.$transaction(async (tx) => {
|
|
const now = new Date();
|
|
if (colony.currentQueenId) {
|
|
await tx.queenColonyHistory.updateMany({
|
|
where: {
|
|
queenId: colony.currentQueenId,
|
|
colonyId: colony.id,
|
|
endDate: null,
|
|
},
|
|
data: { endDate: now },
|
|
});
|
|
}
|
|
// The colony the queen leaves must let go of her - currentQueenId is
|
|
// unique, and a stale pointer would both block this assignment and
|
|
// keep showing her in a colony she is no longer in.
|
|
const previousColony = await tx.colony.findUnique({
|
|
where: { currentQueenId: queen.id },
|
|
});
|
|
if (previousColony) {
|
|
await tx.colony.update({
|
|
where: { id: previousColony.id },
|
|
data: {
|
|
currentQueenId: null,
|
|
status: TERMINAL_COLONY_STATUSES.includes(previousColony.status)
|
|
? undefined
|
|
: 'QUEENLESS',
|
|
},
|
|
});
|
|
}
|
|
await tx.queenColonyHistory.updateMany({
|
|
where: { queenId: queen.id, endDate: null },
|
|
data: { endDate: now },
|
|
});
|
|
await tx.colony.update({
|
|
where: { id: colony.id },
|
|
data: {
|
|
currentQueenId: queen.id,
|
|
// Receiving a queen is what ends queenlessness; every other status
|
|
// (weak, conspicuous) describes something a queen does not fix.
|
|
status: colony.status === 'QUEENLESS' ? 'ACTIVE' : undefined,
|
|
},
|
|
});
|
|
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,
|
|
),
|
|
})),
|
|
);
|
|
}
|
|
}
|