import { BadRequestException, ConflictException, ForbiddenException, 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 type { AnyNewDomainEvent } from '../domain-events/domain-event-types'; import { colonyCreatedEvent } from './colony-created-event'; import { Colony, ColonyStatus, Prisma } 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 { releaseQueenIn } from './colony-lifecycle'; import { isLivingColonyStatus, TERMINAL_COLONY_STATUSES, } from './colony-status'; import { lockRowsForShare, lockRowsForUpdate } from '../prisma/row-lock'; import { colonyTransitionEvents, lockColony } from './colony-events'; import { berlinToday, dayToDate } from '../common/dates'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { QueensService } from '../queens/queens.service'; import { assertNoActiveRestriction, RestrictionCheck, RestrictionOverride, } from '../compliance/restricted-zone'; import { apiaryChangedMeanwhile, NOT_OVERRIDDEN, overrideNote, overrideTrace, } from './restriction-override'; function colonyMovedEvent( colonyId: string, fromApiaryId: string, toApiaryId: string, actorUserId: string, ): AnyNewDomainEvent<'ColonyMoved'> { return { type: 'ColonyMoved', aggregateType: 'Colony', aggregateId: colonyId, actorUserId, payload: { fromApiaryId, toApiaryId }, }; } /** The fields update() writes: the override reason is read, never stored. */ type ColonyPatch = Omit; /** * What update() hands to updateIn when it moves the colony to another apiary: * the apiary the unlocked read saw (the apiary rows were locked for it) and * the override the user may use. */ export interface ApiaryMoveGuard { seenApiaryId: string; override: RestrictionOverride; } const OVERRIDE_PERMISSION = 'compliance:manage'; 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 events: DomainEventsService, private readonly permissions: PermissionResolverService, private readonly queens: QueensService, ) {} 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). Read inside // the transaction, after the apiary rows are locked (FOR SHARE): the // apiary cannot be deactivated before this colony path commits. private async assertApiaryActiveIn( tx: Prisma.TransactionClient, apiaryId: string, ): Promise { const apiary = await tx.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', ); } } // Asked once per request, and only when a reason was sent (null counts as // omitted): the right to override is compliance:manage. private async overrideOf( reason: string | null | undefined, actingUserId: string, ): Promise { if (!reason) { return { reason: null, canOverride: false }; } const held = await this.permissions.getPermissions(actingUserId); // Spec 5.6: a reason without compliance:manage is a 403 - always, also // when no restriction is active. Checked before anything is read or locked. if (!held.has(OVERRIDE_PERMISSION)) { throw new ForbiddenException( 'Eine Begründung für eine Ausnahme vom Sperrbezirk darf nur mit dem Recht Prüfbereitschaft verwalten (compliance:manage) angegeben werden.', ); } return { reason, canOverride: true }; } async create(dto: CreateColonyDto, actingUserId: string): Promise { const { restrictionOverrideReason, ...data } = dto; const override = await this.overrideOf( restrictionOverrideReason, actingUserId, ); const colony = await this.prisma.$transaction(async (tx) => { await lockRowsForShare(tx, 'Apiary', [data.apiaryId]); await this.assertApiaryActiveIn(tx, data.apiaryId); const check = await assertNoActiveRestriction( tx, [data.apiaryId], berlinToday(), override, ); const created = await tx.colony.create({ data }); await this.auditLog.record( { entityType: 'Colony', entityId: created.id, action: 'CREATE', userId: actingUserId, afterData: { ...created, ...overrideTrace(check, override.reason) }, }, tx, ); await this.events.record(tx, colonyCreatedEvent(created, actingUserId)); return created; }); // The webhook stays on the direct path (the event is not forwarded). await this.webhooks.dispatch('colony.created', colony); return colony; } async update( id: string, dto: UpdateColonyDto, actingUserId: string, ): Promise { const { restrictionOverrideReason, ...patch } = dto; const override = await this.overrideOf( restrictionOverrideReason, actingUserId, ); const seen = await this.findById(id); // Only a change of the apiary involves apiaries: the old and the new one // are locked first (FOR SHARE, ascending), then the colony (Task 1 order). const movesApiary = patch.apiaryId !== undefined && patch.apiaryId !== seen.apiaryId; const { after } = await this.prisma.$transaction(async (tx) => { if (movesApiary) { await lockRowsForShare(tx, 'Apiary', [seen.apiaryId, patch.apiaryId!]); } return this.updateIn(tx, id, patch, actingUserId, { seenApiaryId: seen.apiaryId, override, }); }); return after; } /** * The core of update, for callers that already hold a transaction (an * inspection handing values over to the colony card). Locks the colony row * and reads "before" only afterwards (404 when missing); writes the apiary * history, the status events and the audit entry with the change. * * It locks no apiary itself. A patch that moves the colony to another * apiary needs `guard`: update() has then locked both apiaries (FOR SHARE) * before the colony, so the restriction check and the write cannot be * overtaken by a restriction or a deactivation. The inspection takeover * passes no apiary and no guard. */ async updateIn( tx: Prisma.TransactionClient, id: string, dto: ColonyPatch, actingUserId: string, guard?: ApiaryMoveGuard, ): Promise<{ before: Colony; after: Colony }> { const before = await lockColony(tx, id); const today = berlinToday(); const movesApiary = dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId; let check: RestrictionCheck = NOT_OVERRIDDEN; if (movesApiary) { if (!guard) { throw new Error( 'ColoniesService.updateIn: moving the apiary needs update()', ); } if (before.apiaryId !== guard.seenApiaryId) { throw apiaryChangedMeanwhile(); } await this.assertApiaryActiveIn(tx, dto.apiaryId!); check = await assertNoActiveRestriction( tx, [before.apiaryId, dto.apiaryId!], today, guard.override, ); } // An edit into an ended status ends the queen assignment like dissolve // does, before the status is written (the pointer is read under the lock). const endsColony = dto.status !== undefined && TERMINAL_COLONY_STATUSES.includes(dto.status) && isLivingColonyStatus(before.status); const releasedQueenId = endsColony ? await releaseQueenIn(tx, id, dayToDate(today)) : null; 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: overrideNote( check, guard?.override.reason, 'Korrektur über Volk bearbeiten', ), }, }); await this.events.record( tx, colonyMovedEvent(id, before.apiaryId, updated.apiaryId, actingUserId), ); } await this.events.recordAll( tx, colonyTransitionEvents(before, updated, actingUserId, { endedOn: today, releasedQueenId, via: 'EDIT', }), ); await this.auditLog.record( { entityType: 'Colony', entityId: id, action: 'UPDATE', userId: actingUserId, beforeData: before, afterData: { ...updated, ...overrideTrace(check, guard?.override.reason), }, }, tx, ); return { before, after: updated }; } async move( id: string, dto: MoveColonyDto, actingUserId: string, ): Promise { const { restrictionOverrideReason, ...data } = dto; const override = await this.overrideOf( restrictionOverrideReason, actingUserId, ); const seen = await this.findById(id); this.assertActive(seen); const colony = await this.prisma.$transaction(async (tx) => { await lockRowsForShare(tx, 'Apiary', [seen.apiaryId, data.apiaryId]); const before = await lockColony(tx, id); this.assertActive(before); if (before.apiaryId !== seen.apiaryId) { throw apiaryChangedMeanwhile(); } await this.assertApiaryActiveIn(tx, data.apiaryId); // A "move" within one apiary leaves the herd where it is. const check = before.apiaryId === data.apiaryId ? NOT_OVERRIDDEN : await assertNoActiveRestriction( tx, [before.apiaryId, data.apiaryId], berlinToday(), override, ); const updated = await tx.colony.update({ where: { id }, data: { apiaryId: data.apiaryId }, }); await tx.colonyApiaryHistory.create({ data: { colonyId: id, apiaryId: data.apiaryId, movedByUserId: actingUserId, notes: overrideNote(check, override.reason, data.notes), }, }); if (before.apiaryId !== updated.apiaryId) { await this.events.record( tx, colonyMovedEvent(id, before.apiaryId, updated.apiaryId, actingUserId), ); } await this.auditLog.record( { entityType: 'Colony', entityId: id, action: 'UPDATE', userId: actingUserId, beforeData: before, afterData: { ...updated, ...overrideTrace(check, override.reason) }, }, tx, ); return updated; }); return colony; } async split( id: string, dto: SplitColonyDto, actingUserId: string, ): Promise { await this.findById(id); const today = berlinToday(); const splitOn = dto.date ?? today; if (splitOn > today) { throw new BadRequestException('Das Datum liegt in der Zukunft.'); } const queenSource = dto.queenSource ?? null; const child = await this.prisma.$transaction(async (tx) => { const parent = await lockColony(tx, id); this.assertActive(parent); const created = await tx.colony.create({ data: { code: dto.code, apiaryId: parent.apiaryId, colonyType: dto.colonyType, notes: dto.notes, }, }); const lineage = await tx.colonyLineage.create({ data: { type: 'SPLIT', fromColonyId: parent.id, toColonyId: created.id, date: dayToDate(splitOn), queenSource, notes: dto.notes, }, }); await this.auditLog.record( { entityType: 'Colony', entityId: created.id, action: 'CREATE', userId: actingUserId, afterData: { ...created, splitFromColonyId: parent.id }, }, tx, ); await this.events.record(tx, colonyCreatedEvent(created, actingUserId)); await this.events.record(tx, { type: 'ColonySplit', aggregateType: 'Colony', aggregateId: created.id, actorUserId: actingUserId, payload: { parentColonyId: parent.id, lineageId: lineage.id, queenSource, splitOn, }, }); return created; }); return child; } async merge( id: string, dto: MergeColonyDto, actingUserId: string, ): Promise { const { restrictionOverrideReason, ...data } = dto; const survivor = await this.findById(id); this.assertActive(survivor); if (data.mergedColonyIds.includes(survivor.id)) { throw new BadRequestException( 'Ein Volk kann nicht mit sich selbst vereinigt werden', ); } if (new Set(data.mergedColonyIds).size !== data.mergedColonyIds.length) { throw new BadRequestException( 'Jedes Volk darf nur einmal zur Vereinigung ausgewählt werden', ); } const override = await this.overrideOf( restrictionOverrideReason, actingUserId, ); const today = berlinToday(); // The apiaries of the colonies as the unlocked read saw them: they are // locked first, and compared with the locked rows afterwards. const seenApiaries = new Map( ( await this.prisma.colony.findMany({ where: { id: { in: data.mergedColonyIds } }, select: { id: true, apiaryId: true }, }) ).map((colony) => [colony.id, colony.apiaryId]), ); await this.prisma.$transaction(async (tx) => { await lockRowsForShare(tx, 'Apiary', [ ...new Set([survivor.apiaryId, ...seenApiaries.values()]), ]); // One statement in id order, the same order bulk lockRowsForShare // uses: no deadlock with a concurrent bulk write. await lockRowsForUpdate(tx, 'Colony', [ survivor.id, ...data.mergedColonyIds, ]); const lockedSurvivor = await tx.colony.findUnique({ where: { id } }); if (!lockedSurvivor) { throw new NotFoundException('Colony not found'); } this.assertActive(lockedSurvivor); if (lockedSurvivor.apiaryId !== survivor.apiaryId) { throw apiaryChangedMeanwhile(); } const mergedRows: Colony[] = []; for (const mergedColonyId of data.mergedColonyIds) { const merged = await tx.colony.findUnique({ where: { id: mergedColonyId }, }); if (!merged) { throw new NotFoundException( 'Zu vereinigendes Volk wurde nicht gefunden', ); } this.assertActive(merged); if (seenApiaries.get(merged.id) !== merged.apiaryId) { throw apiaryChangedMeanwhile(); } mergedRows.push(merged); } // Uniting colonies of different apiaries brings bees from one stand to // the other; within one apiary nothing leaves it. const apiaryIds = new Set([ lockedSurvivor.apiaryId, ...mergedRows.map((merged) => merged.apiaryId), ]); const check = apiaryIds.size > 1 ? await assertNoActiveRestriction(tx, [...apiaryIds], today, override) : NOT_OVERRIDDEN; for (const merged of mergedRows) { // 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). const releasedQueenId = await releaseQueenIn( tx, merged.id, dayToDate(today), ); if (releasedQueenId) { await this.queens.retireIn( tx, releasedQueenId, 'REPLACED', actingUserId, ); } const mergedAway = await tx.colony.update({ where: { id: merged.id }, data: { status: 'MERGED', deactivatedAt: new Date() }, }); await this.events.recordAll( tx, colonyTransitionEvents(merged, mergedAway, actingUserId, { endedOn: today, releasedQueenId, via: 'MERGE', }), ); await tx.colonyLineage.create({ data: { type: 'MERGE', fromColonyId: merged.id, toColonyId: lockedSurvivor.id, date: new Date(), notes: data.notes, }, }); } await this.auditLog.record( { entityType: 'Colony', entityId: lockedSurvivor.id, action: 'UPDATE', userId: actingUserId, beforeData: lockedSurvivor, afterData: { ...lockedSurvivor, mergedColonyIds: data.mergedColonyIds, ...overrideTrace(check, override.reason), }, }, tx, ); }); return this.findById(survivor.id); } async dissolve(id: string, actingUserId: string): Promise { this.assertActive(await this.findById(id)); const colony = await this.prisma.$transaction(async (tx) => { const before = await lockColony(tx, 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 today = berlinToday(); const releasedQueenId = await releaseQueenIn(tx, id, dayToDate(today)); if (releasedQueenId) { await this.queens.retireIn( tx, releasedQueenId, 'REPLACED', actingUserId, ); } const updated = await tx.colony.update({ where: { id }, data: { status: 'DISSOLVED', deactivatedAt: new Date() }, }); await this.events.recordAll( tx, colonyTransitionEvents(before, updated, actingUserId, { endedOn: today, releasedQueenId, via: 'DISSOLVE', }), ); await this.auditLog.record( { entityType: 'Colony', entityId: id, action: 'DEACTIVATE', userId: actingUserId, beforeData: before, afterData: updated, }, tx, ); return updated; }); return colony; } async reactivate(id: string, actingUserId: string): Promise { await this.findById(id); return this.prisma.$transaction(async (tx) => { const before = await lockColony(tx, 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 updated = await tx.colony.update({ where: { id }, data: { status: 'ACTIVE', deactivatedAt: null }, }); // The clean-up of the end is moot now (colony row locked first, then // the suggestions: the order every writer uses). await tx.suggestion.updateMany({ where: { colonyId: id, intent: 'CLEANUP', status: 'OPEN' }, data: { status: 'OBSOLETE', decidedAt: new Date() }, }); await this.events.recordAll( tx, colonyTransitionEvents(before, updated, actingUserId, null), ); await this.auditLog.record( { entityType: 'Colony', entityId: id, action: 'UPDATE', userId: actingUserId, beforeData: before, afterData: updated, }, tx, ); return updated; }); } 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 }; } }