Files
ApiCore/ApiCore-Backend/src/colonies/colonies.service.ts
T
Nick.RosenloecherandClaude Opus 5.5 81927d629a
Backend E2E / e2e (push) Successful in 5m24s
Backend Image / build-and-push (push) Successful in 5m3s
Frontend Image / build-and-push (push) Failing after 14m46s
feat(queens): verdrängte Königin bekommt ihren Endstatus automatisch
Entscheidung Inhaber: Zieht eine neue Königin ein, wird die bisherige
„Ersetzt“; Vereinigen und Auflösen eines Volks setzen dessen Königin auf
„Ersetzt“, ein Winterverlust auf „Eingegangen“. Nur aus einem verfügbaren
Status (ACTIVE, RESERVE, MATED, UNMATED), über updateStatusIn in derselben
Transaktion mit Archivdatum und Audit (QueensService.retireIn). Ein von
Hand gesetzter Endstatus eines Volks fragt weiter über den Aufräum-
Vorschlag nach.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-19 17:03:22 +02:00

681 lines
22 KiB
TypeScript

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<UpdateColonyDto, 'restrictionOverrideReason'>;
/**
* 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<Colony[]> {
return this.prisma.colony.findMany({
where: { status: filter.status, apiaryId: filter.apiaryId },
orderBy: { code: 'asc' },
});
}
async findById(id: string): Promise<Colony> {
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<void> {
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<RestrictionOverride> {
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<Colony> {
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<Colony> {
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<Colony> {
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<Colony> {
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<Colony> {
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<Colony> {
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<Colony> {
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 };
}
}