import { ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { DomainEventsService } from '../domain-events/domain-events.service'; import type { DomainEventPayloads } from '../domain-events/domain-event-types'; import { lockRowForUpdate } from '../prisma/row-lock'; import { LIVING_COLONY_WHERE } from '../colonies/colony-status'; import { Apiary, ApiaryStatus, Prisma } from '../../generated/prisma'; import { CreateApiaryDto } from './dto/create-apiary.dto'; import { UpdateApiaryDto } from './dto/update-apiary.dto'; export interface ApiaryFilter { status?: ApiaryStatus; location?: string; } type ApiaryChange = DomainEventPayloads['ApiaryChanged']['change']; // The fields a change of which counts as a new location of the apiary. const LOCATION_FIELDS = [ 'address', 'location', 'latitude', 'longitude', ] as const; @Injectable() export class ApiariesService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, private readonly events: DomainEventsService, ) {} async findAll(filter: ApiaryFilter = {}): Promise { return this.prisma.apiary.findMany({ where: { status: filter.status, location: filter.location ? { contains: filter.location, mode: 'insensitive' } : undefined, }, orderBy: { name: 'asc' }, }); } async findById(id: string): Promise { const apiary = await this.prisma.apiary.findUnique({ where: { id } }); if (!apiary) { throw new NotFoundException('Apiary not found'); } return apiary; } async create(dto: CreateApiaryDto, actingUserId: string): Promise { return this.prisma.$transaction(async (tx) => { const apiary = await tx.apiary.create({ data: { ...dto } }); await this.auditLog.record( { entityType: 'Apiary', entityId: apiary.id, action: 'CREATE', userId: actingUserId, afterData: apiary, }, tx, ); await this.recordChange(tx, apiary.id, 'CREATED', actingUserId); return apiary; }); } async update( id: string, dto: UpdateApiaryDto, actingUserId: string, ): Promise { return this.prisma.$transaction(async (tx) => { const before = await this.lockApiary(tx, id); const apiary = await tx.apiary.update({ where: { id }, data: { ...dto }, }); await this.auditLog.record( { entityType: 'Apiary', entityId: id, action: 'UPDATE', userId: actingUserId, beforeData: before, afterData: apiary, }, tx, ); if (LOCATION_FIELDS.some((field) => before[field] !== apiary[field])) { await this.recordChange(tx, id, 'LOCATION_CHANGED', actingUserId); } return apiary; }); } async deactivate(id: string, actingUserId: string): Promise { return this.prisma.$transaction(async (tx) => { const before = await this.lockApiary(tx, id); // Colony.apiaryId is a plain FK, not enforced against apiary status - // without this guard a colony can silently keep pointing at an // inactive apiary (still shows ACTIVE, still countable, but its // "location" has vanished from the active apiary list). Move the // colonies elsewhere or dissolve them first instead. Counted under the // apiary lock: the colony paths hold the apiary FOR SHARE, so a move // into it that is still running finishes first. const activeColonyCount = await tx.colony.count({ where: { apiaryId: id, ...LIVING_COLONY_WHERE }, }); if (activeColonyCount > 0) { throw new ConflictException( `Der Bienenstand hat noch ${activeColonyCount} aktive(s) Volk/Völker - zuerst verlegen oder auflösen`, ); } const apiary = await tx.apiary.update({ where: { id }, data: { status: 'INACTIVE', deactivatedAt: new Date() }, }); await this.auditLog.record( { entityType: 'Apiary', entityId: id, action: 'DEACTIVATE', userId: actingUserId, beforeData: before, afterData: apiary, }, tx, ); await this.recordChange(tx, id, 'DEACTIVATED', actingUserId); return apiary; }); } async reactivate(id: string, actingUserId: string): Promise { return this.prisma.$transaction(async (tx) => { const before = await this.lockApiary(tx, id); if (before.status === 'ACTIVE') { throw new ConflictException('Apiary is already active'); } const apiary = await tx.apiary.update({ where: { id }, data: { status: 'ACTIVE', deactivatedAt: null }, }); await this.auditLog.record( { entityType: 'Apiary', entityId: id, action: 'UPDATE', userId: actingUserId, beforeData: before, afterData: apiary, }, tx, ); await this.recordChange(tx, id, 'REACTIVATED', actingUserId); return apiary; }); } // Locks the apiary row and reads it afterwards: the result - not an earlier // read - is "before" for the comparison, the audit and the event. private async lockApiary( tx: Prisma.TransactionClient, id: string, ): Promise { await lockRowForUpdate(tx, 'Apiary', id); const apiary = await tx.apiary.findUnique({ where: { id } }); if (!apiary) { throw new NotFoundException('Apiary not found'); } return apiary; } private recordChange( tx: Prisma.TransactionClient, apiaryId: string, change: ApiaryChange, actorUserId: string, ): Promise { return this.events.record(tx, { type: 'ApiaryChanged', aggregateType: 'Apiary', aggregateId: apiaryId, actorUserId, payload: { change }, }); } }