Colony.currentQueenId is unique across colonies. dissolve() and merge() set status to DISSOLVED/MERGED but never cleared currentQueenId, so a colony that still had a queen when it was dissolved or merged kept permanently "owning" her - any later attempt to assign that queen to a different colony (e.g. requeening another colony with her) would fail with a unique-constraint conflict, since the dead colony's stale reference was still occupying that slot. Both operations now clear currentQueenId and close the open QueenColonyHistory row for that queen, mirroring the bookkeeping assignColony() and the queen-status-transition fix already do elsewhere. The merge survivor keeps its own queen; a merged-away colony's queen is released, not auto-transferred, since which queen "wins" a merge is a beekeeper decision. Co-Authored-By: Claude Sonnet 5 <[email protected]>
293 lines
8.4 KiB
TypeScript
293 lines
8.4 KiB
TypeScript
import {
|
|
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';
|
|
|
|
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<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;
|
|
}
|
|
|
|
private assertActive(colony: Colony): void {
|
|
if (colony.status !== 'ACTIVE') {
|
|
throw new ConflictException(
|
|
`Colony ${colony.id} is not active (status: ${colony.status})`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async create(dto: CreateColonyDto, actingUserId: string): Promise<Colony> {
|
|
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<Colony> {
|
|
const before = await this.findById(id);
|
|
const colony = await this.prisma.colony.update({
|
|
where: { id },
|
|
data: { ...dto },
|
|
});
|
|
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<Colony> {
|
|
const before = await this.findById(id);
|
|
this.assertActive(before);
|
|
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<Colony> {
|
|
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<Colony> {
|
|
const survivor = await this.findById(id);
|
|
this.assertActive(survivor);
|
|
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(`Colony ${mergedColonyId} not found`);
|
|
}
|
|
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<Colony> {
|
|
const before = await this.findById(id);
|
|
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<Colony> {
|
|
const before = await this.findById(id);
|
|
if (before.status === 'ACTIVE') {
|
|
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 };
|
|
}
|
|
}
|