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 { AutomationService } from '../automation/automation.service'; import { Colony, ColonyStatus } from '../../generated/prisma'; import { CreateColonyDto } from './dto/create-colony.dto'; import { UpdateColonyDto } from './dto/update-colony.dto'; import { MoveColonyDto } from './dto/move-colony.dto'; import { SplitColonyDto } from './dto/split-colony.dto'; import { MergeColonyDto } from './dto/merge-colony.dto'; import { isLivingColonyStatus } from './colony-status'; export interface ColonyFilter { status?: ColonyStatus; apiaryId?: string; } @Injectable() export class ColoniesService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, private readonly webhooks: WebhooksService, private readonly automation: AutomationService, ) {} async findAll(filter: ColonyFilter = {}): Promise { return this.prisma.colony.findMany({ where: { status: filter.status, apiaryId: filter.apiaryId }, orderBy: { code: 'asc' }, }); } async findById(id: string): Promise { const colony = await this.prisma.colony.findUnique({ where: { id } }); if (!colony) { throw new NotFoundException('Colony not found'); } return colony; } // Only a colony that no longer exists (dissolved, sold, dead, merged) is // locked. Weak, conspicuous or queenless colonies are still worked with - // uniting a queenless colony with another is the textbook case for merge. private assertActive(colony: Colony): void { if (!isLivingColonyStatus(colony.status)) { throw new ConflictException( `Das Volk besteht nicht mehr (Status: ${colony.status})`, ); } } // Colony.apiaryId is a plain FK with no built-in status check - without // this, a colony could be created or moved into an apiary that either // doesn't exist (a raw Prisma FK-constraint error instead of a clean // 404) or is deactivated (silently orphaning the colony the same way an // apiary deactivated out from under an existing colony would). private async assertApiaryActive(apiaryId: string): Promise { const apiary = await this.prisma.apiary.findUnique({ where: { id: apiaryId }, }); if (!apiary) { throw new NotFoundException('Bienenstand nicht gefunden'); } if (apiary.status !== 'ACTIVE') { throw new ConflictException( 'Der Bienenstand ist nicht aktiv und kann daher keine Völker aufnehmen', ); } } async create(dto: CreateColonyDto, actingUserId: string): Promise { await this.assertApiaryActive(dto.apiaryId); const colony = await this.prisma.colony.create({ data: { ...dto } }); await this.auditLog.record({ entityType: 'Colony', entityId: colony.id, action: 'CREATE', userId: actingUserId, afterData: colony, }); await this.webhooks.dispatch('colony.created', colony); return colony; } async update( id: string, dto: UpdateColonyDto, actingUserId: string, ): Promise { const before = await this.findById(id); const movesApiary = dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId; if (movesApiary) { await this.assertApiaryActive(dto.apiaryId!); } const colony = await this.prisma.$transaction(async (tx) => { const updated = await tx.colony.update({ where: { id }, data: { ...dto }, }); // Correcting the apiary has to leave the same trace a deliberate move // does - otherwise the colony's location history would silently skip a // stand it was recorded on. if (movesApiary) { await tx.colonyApiaryHistory.create({ data: { colonyId: id, apiaryId: updated.apiaryId, movedByUserId: actingUserId, notes: 'Korrektur über Volk bearbeiten', }, }); } return updated; }); await this.auditLog.record({ entityType: 'Colony', entityId: id, action: 'UPDATE', userId: actingUserId, beforeData: before, afterData: colony, }); await this.automation.evaluateColonyHealth(before, colony); return colony; } async move( id: string, dto: MoveColonyDto, actingUserId: string, ): Promise { const before = await this.findById(id); this.assertActive(before); await this.assertApiaryActive(dto.apiaryId); const colony = await this.prisma.$transaction(async (tx) => { const updated = await tx.colony.update({ where: { id }, data: { apiaryId: dto.apiaryId }, }); await tx.colonyApiaryHistory.create({ data: { colonyId: id, apiaryId: dto.apiaryId, movedByUserId: actingUserId, notes: dto.notes, }, }); return updated; }); await this.auditLog.record({ entityType: 'Colony', entityId: id, action: 'UPDATE', userId: actingUserId, beforeData: before, afterData: colony, }); return colony; } async split( id: string, dto: SplitColonyDto, actingUserId: string, ): Promise { const parent = await this.findById(id); this.assertActive(parent); const child = await this.prisma.$transaction(async (tx) => { const created = await tx.colony.create({ data: { code: dto.code, apiaryId: parent.apiaryId, colonyType: dto.colonyType, notes: dto.notes, }, }); await tx.colonyLineage.create({ data: { type: 'SPLIT', fromColonyId: parent.id, toColonyId: created.id, date: new Date(), notes: dto.notes, }, }); return created; }); await this.auditLog.record({ entityType: 'Colony', entityId: child.id, action: 'CREATE', userId: actingUserId, afterData: { ...child, splitFromColonyId: parent.id }, }); return child; } async merge( id: string, dto: MergeColonyDto, actingUserId: string, ): Promise { const survivor = await this.findById(id); this.assertActive(survivor); if (dto.mergedColonyIds.includes(survivor.id)) { throw new BadRequestException( 'Ein Volk kann nicht mit sich selbst vereinigt werden', ); } if (new Set(dto.mergedColonyIds).size !== dto.mergedColonyIds.length) { throw new BadRequestException( 'Jedes Volk darf nur einmal zur Vereinigung ausgewählt werden', ); } await this.prisma.$transaction(async (tx) => { for (const mergedColonyId of dto.mergedColonyIds) { const merged = await tx.colony.findUnique({ where: { id: mergedColonyId }, }); if (!merged) { throw new NotFoundException( 'Zu vereinigendes Volk wurde nicht gefunden', ); } this.assertActive(merged); // currentQueenId is unique across colonies - if it stayed set on // this now-dead colony, any later attempt to assign her queen to a // different colony would fail with a unique-constraint conflict. // The survivor keeps its own queen; merged-away queens are simply // released, not auto-transferred (which colony's queen "wins" a // merge is a beekeeper decision, not one to guess here). await tx.colony.update({ where: { id: mergedColonyId }, data: { status: 'MERGED', deactivatedAt: new Date(), currentQueenId: null, }, }); if (merged.currentQueenId) { await tx.queenColonyHistory.updateMany({ where: { queenId: merged.currentQueenId, colonyId: mergedColonyId, endDate: null, }, data: { endDate: new Date() }, }); } await tx.colonyLineage.create({ data: { type: 'MERGE', fromColonyId: mergedColonyId, toColonyId: survivor.id, date: new Date(), notes: dto.notes, }, }); } }); await this.auditLog.record({ entityType: 'Colony', entityId: survivor.id, action: 'UPDATE', userId: actingUserId, beforeData: survivor, afterData: { ...survivor, mergedColonyIds: dto.mergedColonyIds }, }); return this.findById(survivor.id); } async dissolve(id: string, actingUserId: string): Promise { const before = await this.findById(id); // Re-dissolving would overwrite deactivatedAt and the terminal status // (e.g. MERGED -> DISSOLVED), losing when and how the colony really ended. this.assertActive(before); const colony = await this.prisma.$transaction(async (tx) => { const updated = await tx.colony.update({ where: { id }, data: { status: 'DISSOLVED', deactivatedAt: new Date(), currentQueenId: null, }, }); if (before.currentQueenId) { await tx.queenColonyHistory.updateMany({ where: { queenId: before.currentQueenId, colonyId: id, endDate: null, }, data: { endDate: new Date() }, }); } return updated; }); await this.auditLog.record({ entityType: 'Colony', entityId: id, action: 'DEACTIVATE', userId: actingUserId, beforeData: before, afterData: colony, }); return colony; } async reactivate(id: string, actingUserId: string): Promise { const before = await this.findById(id); // Only an ended colony can be brought back. A weak or queenless one // still exists - reactivating it would just overwrite its condition. if (isLivingColonyStatus(before.status)) { throw new ConflictException('Colony is already active'); } const colony = await this.prisma.colony.update({ where: { id }, data: { status: 'ACTIVE', deactivatedAt: null }, }); await this.auditLog.record({ entityType: 'Colony', entityId: id, action: 'UPDATE', userId: actingUserId, beforeData: before, afterData: colony, }); return colony; } async getHistory(id: string) { await this.findById(id); const [apiaryHistory, queenHistory, lineageAsSource, lineageAsResult] = await Promise.all([ this.prisma.colonyApiaryHistory.findMany({ where: { colonyId: id }, orderBy: { movedAt: 'desc' }, }), this.prisma.queenColonyHistory.findMany({ where: { colonyId: id }, orderBy: { startDate: 'desc' }, }), this.prisma.colonyLineage.findMany({ where: { fromColonyId: id } }), this.prisma.colonyLineage.findMany({ where: { toColonyId: id } }), ]); return { apiaryHistory, queenHistory, lineageAsSource, lineageAsResult }; } }