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 { return this.prisma.queen.findMany({ where: { status: filter.status }, orderBy: { code: 'asc' }, }); } async findById(id: string): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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, ), })), ); } }