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 { 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 }, }); 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 { 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 { 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, ), })), ); } }