Files
ApiCore/ApiCore-Backend/src/queens/queens.service.ts
T
Nick.RosenloecherandClaude Opus 5.5 81927d629a
Backend E2E / e2e (push) Successful in 5m24s
Backend Image / build-and-push (push) Successful in 5m3s
Frontend Image / build-and-push (push) Failing after 14m46s
feat(queens): verdrängte Königin bekommt ihren Endstatus automatisch
Entscheidung Inhaber: Zieht eine neue Königin ein, wird die bisherige
„Ersetzt“; Vereinigen und Auflösen eines Volks setzen dessen Königin auf
„Ersetzt“, ein Winterverlust auf „Eingegangen“. Nur aus einem verfügbaren
Status (ACTIVE, RESERVE, MATED, UNMATED), über updateStatusIn in derselben
Transaktion mit Archivdatum und Audit (QueensService.retireIn). Ein von
Hand gesetzter Endstatus eines Volks fragt weiter über den Aufräum-
Vorschlag nach.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-19 17:03:22 +02:00

528 lines
16 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 { DomainEventsService } from '../domain-events/domain-events.service';
import { utcDay } from '../domain-events/domain-event-types';
import { assertTransactionClient } from '../prisma/db-client';
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
import { Prisma, 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',
];
/**
* deactivatedAt for a queen moving to `status`: the /queens history and the
* queen lifespans in the statistics read it. Stamped when she becomes
* unavailable (the first date kept on a move between such statuses), cleared
* when a status is corrected back to an available one.
*/
function deactivatedAtFor(before: Queen, status: QueenStatus): Date | null {
if (QUEEN_NO_LONGER_AVAILABLE_STATUSES.includes(status)) {
return before.deactivatedAt ?? new Date();
}
return null;
}
@Injectable()
export class QueensService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly webhooks: WebhooksService,
private readonly events: DomainEventsService,
) {}
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,
deactivatedAt:
dto.status === undefined
? undefined
: deactivatedAtFor(before, dto.status),
},
});
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;
}
/**
* Differs from update(): this is the status alone, on the caller's
* transaction, with the audit entry inside it. update() also lets go of
* the colony that holds a queen who becomes unavailable (and audits after
* its transaction); here that case is a 409, because the clean-up of an
* ended colony must never unlink a colony she was re-assigned to.
*
* Sets the status of a queen on the caller's transaction: locks the queen,
* reads it afterwards, audits the change. For a status that takes her out
* of use it refuses while a colony still holds her (409) - the colony
* side has to let go of her first (releaseQueenIn, assignColony); this
* method never touches a colony. The same status again changes nothing.
*/
async updateStatusIn(
tx: Prisma.TransactionClient,
id: string,
status: QueenStatus,
actingUserId: string,
): Promise<Queen> {
assertTransactionClient(tx, 'QueensService.updateStatusIn');
await lockRowForUpdate(tx, 'Queen', id);
const before = await tx.queen.findUnique({ where: { id } });
if (!before) {
throw new NotFoundException('Queen not found');
}
if (before.status === status) {
return before;
}
if (QUEEN_NO_LONGER_AVAILABLE_STATUSES.includes(status)) {
const holder = await tx.colony.findUnique({
where: { currentQueenId: id },
select: { id: true },
});
if (holder) {
throw new ConflictException(
'Die Königin sitzt noch in einem Volk - bitte zuerst dort lösen',
);
}
}
const updated = await tx.queen.update({
where: { id },
data: { status, deactivatedAt: deactivatedAtFor(before, status) },
});
await this.auditLog.record(
{
entityType: 'Queen',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: updated,
},
tx,
);
return updated;
}
/**
* Retires a queen who just left her colony with it (displaced by a new
* queen, colony merged, dissolved or died): sets `status` through
* updateStatusIn while she is still recorded as available, so she moves to
* the archive. A status that already takes her out of use stays. The
* caller has released her from the colony first.
*/
async retireIn(
tx: Prisma.TransactionClient,
id: string,
status: QueenStatus,
actingUserId: string,
): Promise<void> {
assertTransactionClient(tx, 'QueensService.retireIn');
await lockRowForUpdate(tx, 'Queen', id);
const queen = await tx.queen.findUnique({ where: { id } });
if (!queen || QUEEN_NO_LONGER_AVAILABLE_STATUSES.includes(queen.status)) {
return;
}
await this.updateStatusIn(tx, id, status, actingUserId);
}
async assignColony(
id: string,
dto: AssignColonyDto,
actingUserId: string,
): Promise<Queen> {
await this.findById(id);
// Only to know which colony to lock; the transaction re-reads it.
const preRead = await this.prisma.colony.findUnique({
where: { currentQueenId: id },
select: { id: true },
});
return this.prisma.$transaction(async (tx) => {
// Colonies first (one statement, ascending ids), then the queen: the
// order every writer of both uses.
await lockRowsForUpdate(tx, 'Colony', [
dto.colonyId,
...(preRead ? [preRead.id] : []),
]);
await lockRowForUpdate(tx, 'Queen', id);
const queen = await tx.queen.findUnique({ where: { id } });
if (!queen) {
throw new NotFoundException('Queen not found');
}
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 tx.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');
}
// 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?.id ?? null) !== (preRead?.id ?? null)) {
throw new ConflictException(
'Die Königin wurde gerade umgesetzt - bitte erneut versuchen',
);
}
const now = new Date();
if (colony.currentQueenId) {
await tx.queenColonyHistory.updateMany({
where: {
queenId: colony.currentQueenId,
colonyId: colony.id,
endDate: null,
},
data: { endDate: now },
});
}
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,
},
});
if (colony.currentQueenId) {
await this.retireIn(
tx,
colony.currentQueenId,
'REPLACED',
actingUserId,
);
}
const history = await tx.queenColonyHistory.create({
data: {
queenId: queen.id,
colonyId: colony.id,
startDate: now,
notes: dto.notes,
},
});
await this.auditLog.record(
{
entityType: 'Queen',
entityId: queen.id,
action: 'UPDATE',
userId: actingUserId,
beforeData: queen,
afterData: { ...queen, assignedColonyId: colony.id },
},
tx,
);
await this.events.record(tx, {
type: 'QueenAssigned',
aggregateType: 'Queen',
aggregateId: queen.id,
actorUserId: actingUserId,
payload: {
colonyId: colony.id,
historyId: history.id,
assignedOn: utcDay(now),
previousColonyId: previousColony?.id ?? null,
},
});
return tx.queen.findUniqueOrThrow({ where: { id } });
});
}
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,
),
})),
);
}
}