feat: platform backbone and field capture complete (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed: - webhooks with delivery table, retries and stable delivery id - calendar handlers; a finished task removes its Outlook event - tasks, automations, inspections, varroa, treatments, feedings, harvests, colonies, wintering, stock and bottling record domain events in the same transaction; legacy post-commit calls removed - row locks through one helper (lockRowForUpdate, lockRowsForShare, lockRowsForUpdate) - job framework with JobRun, cli exit codes, cleanup, task templates, daily evaluation, hourly app-backup and the rekey job - system API and page "Verwaltung > System" with retry - @nestjs/schedule removed, guard tests against in-process schedulers Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed: - inspection photo offline without metadata - bulk capture for treatments, feedings and varroa (backend + frontend) - offline stand mode with service worker, offline pages and packs - offline session, cold start and logout on shared devices Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md. Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema drift; frontend lint, typecheck, 913 tests, build. Not rolled out. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
c01a38835a
commit
cdd30cf3ee
288 files changed
+39165
-2853
No files matched your search
@@ -1,10 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AutomationModule } from '../automation/automation.module';
|
||||
import { ColoniesController } from './colonies.controller';
|
||||
import { ColoniesService } from './colonies.service';
|
||||
|
||||
@Module({
|
||||
imports: [AutomationModule],
|
||||
controllers: [ColoniesController],
|
||||
providers: [ColoniesService],
|
||||
exports: [ColoniesService],
|
||||
|
||||
@@ -3,11 +3,12 @@ import { ColoniesService } from './colonies.service';
|
||||
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 { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
|
||||
describe('ColoniesService', () => {
|
||||
let service: ColoniesService;
|
||||
let tx: {
|
||||
$queryRaw: jest.Mock;
|
||||
colony: { create: jest.Mock; update: jest.Mock; findUnique: jest.Mock };
|
||||
colonyApiaryHistory: { create: jest.Mock };
|
||||
colonyLineage: { create: jest.Mock };
|
||||
@@ -27,6 +28,7 @@ describe('ColoniesService', () => {
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
let events: { record: jest.Mock; recordAll: jest.Mock };
|
||||
|
||||
const parentColony = {
|
||||
id: 'colony-1',
|
||||
@@ -38,6 +40,7 @@ describe('ColoniesService', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
tx = {
|
||||
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||
colony: { create: jest.fn(), update: jest.fn(), findUnique: jest.fn() },
|
||||
colonyApiaryHistory: { create: jest.fn() },
|
||||
colonyLineage: { create: jest.fn() },
|
||||
@@ -62,19 +65,30 @@ describe('ColoniesService', () => {
|
||||
callback(tx),
|
||||
),
|
||||
};
|
||||
// The locked re-read sees what the pre-read saw unless a test says otherwise.
|
||||
tx.colony.findUnique.mockImplementation(
|
||||
(args: unknown) => prisma.colony.findUnique(args) as Promise<unknown>,
|
||||
);
|
||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
||||
const automation = {
|
||||
evaluateColonyHealth: jest.fn().mockResolvedValue(undefined),
|
||||
events = {
|
||||
record: jest.fn().mockResolvedValue(undefined),
|
||||
recordAll: jest.fn().mockResolvedValue(0),
|
||||
};
|
||||
service = new ColoniesService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
webhooks as unknown as WebhooksService,
|
||||
automation as unknown as AutomationService,
|
||||
events as unknown as DomainEventsService,
|
||||
);
|
||||
});
|
||||
|
||||
function transitionTypes(): string[] {
|
||||
return (
|
||||
events.recordAll.mock.calls as [unknown, { type: string }[]][]
|
||||
).flatMap(([, recorded]) => recorded.map((event) => event.type));
|
||||
}
|
||||
|
||||
it('findById throws NotFoundException when missing', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue(null);
|
||||
await expect(service.findById('missing')).rejects.toBeInstanceOf(
|
||||
@@ -137,7 +151,11 @@ describe('ColoniesService', () => {
|
||||
status: 'ACTIVE',
|
||||
currentQueenId: 'queen-2',
|
||||
});
|
||||
tx.colony.update.mockResolvedValue({});
|
||||
tx.colony.update.mockResolvedValue({
|
||||
id: 'colony-3',
|
||||
status: 'MERGED',
|
||||
healthStatus: null,
|
||||
});
|
||||
tx.colonyLineage.create.mockResolvedValue({});
|
||||
|
||||
await service.merge(
|
||||
@@ -242,6 +260,7 @@ describe('ColoniesService', () => {
|
||||
expect(result.status).toBe('DISSOLVED');
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'DEACTIVATE' }),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -320,7 +339,7 @@ describe('ColoniesService', () => {
|
||||
await expect(
|
||||
service.reactivate('colony-1', 'user-1'),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(prisma.colony.update).not.toHaveBeenCalled();
|
||||
expect(tx.colony.update).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -330,7 +349,7 @@ describe('ColoniesService', () => {
|
||||
status: 'DISSOLVED',
|
||||
deactivatedAt: new Date(),
|
||||
});
|
||||
prisma.colony.update.mockResolvedValue({
|
||||
tx.colony.update.mockResolvedValue({
|
||||
...parentColony,
|
||||
status: 'ACTIVE',
|
||||
deactivatedAt: null,
|
||||
@@ -339,13 +358,15 @@ describe('ColoniesService', () => {
|
||||
const result = await service.reactivate('colony-1', 'user-1');
|
||||
|
||||
expect(result.status).toBe('ACTIVE');
|
||||
expect(prisma.colony.update).toHaveBeenCalledWith({
|
||||
expect(tx.colony.update).toHaveBeenCalledWith({
|
||||
where: { id: 'colony-1' },
|
||||
data: { status: 'ACTIVE', deactivatedAt: null },
|
||||
});
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'UPDATE' }),
|
||||
tx,
|
||||
);
|
||||
expect(transitionTypes()).toEqual(['ColonyStatusChanged']);
|
||||
});
|
||||
|
||||
it('writes an apiary-history entry when an edit corrects the apiary', async () => {
|
||||
@@ -375,4 +396,215 @@ describe('ColoniesService', () => {
|
||||
|
||||
expect(tx.colonyApiaryHistory.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update into CRITICAL health records ColonyStatusChanged with from/to snapshots', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue({
|
||||
...parentColony,
|
||||
healthStatus: null,
|
||||
});
|
||||
tx.colony.update.mockResolvedValue({
|
||||
...parentColony,
|
||||
healthStatus: 'CRITICAL',
|
||||
});
|
||||
|
||||
await service.update('colony-1', { healthStatus: 'CRITICAL' }, 'user-1');
|
||||
|
||||
expect(events.recordAll).toHaveBeenCalledWith(tx, [
|
||||
expect.objectContaining({
|
||||
type: 'ColonyStatusChanged',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: 'colony-1',
|
||||
actorUserId: 'user-1',
|
||||
payload: {
|
||||
from: { status: 'ACTIVE', healthStatus: null },
|
||||
to: { status: 'ACTIVE', healthStatus: 'CRITICAL' },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'UPDATE' }),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('update records no event when status and health stay the same', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||
tx.colony.update.mockResolvedValue(parentColony);
|
||||
|
||||
await service.update('colony-1', { notes: 'x' }, 'user-1');
|
||||
|
||||
expect(transitionTypes()).toEqual([]);
|
||||
});
|
||||
|
||||
it('dissolve records ColonyStatusChanged and ColonyEnded { status: "DISSOLVED" }', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||
tx.colony.update.mockResolvedValue({
|
||||
...parentColony,
|
||||
status: 'DISSOLVED',
|
||||
});
|
||||
|
||||
await service.dissolve('colony-1', 'user-1');
|
||||
|
||||
expect(events.recordAll).toHaveBeenCalledWith(tx, [
|
||||
expect.objectContaining({ type: 'ColonyStatusChanged' }),
|
||||
expect.objectContaining({
|
||||
type: 'ColonyEnded',
|
||||
aggregateId: 'colony-1',
|
||||
payload: { status: 'DISSOLVED' },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('merge records ColonyEnded "MERGED" for every merged colony', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||
tx.colony.findUnique.mockImplementation(
|
||||
({ where }: { where: { id: string } }) =>
|
||||
Promise.resolve({
|
||||
id: where.id,
|
||||
status: 'ACTIVE',
|
||||
healthStatus: null,
|
||||
currentQueenId: null,
|
||||
}),
|
||||
);
|
||||
tx.colony.update.mockImplementation(
|
||||
({ where }: { where: { id: string } }) =>
|
||||
Promise.resolve({ id: where.id, status: 'MERGED', healthStatus: null }),
|
||||
);
|
||||
|
||||
await service.merge(
|
||||
'colony-1',
|
||||
{ mergedColonyIds: ['colony-3', 'colony-4'] },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
const ended = (
|
||||
events.recordAll.mock.calls as [
|
||||
unknown,
|
||||
{ type: string; aggregateId: string; payload: { status?: string } }[],
|
||||
][]
|
||||
)
|
||||
.flatMap(([, recorded]) => recorded)
|
||||
.filter((event) => event.type === 'ColonyEnded');
|
||||
expect(ended.map((event) => event.aggregateId)).toEqual([
|
||||
'colony-3',
|
||||
'colony-4',
|
||||
]);
|
||||
expect(ended.map((event) => event.payload.status)).toEqual([
|
||||
'MERGED',
|
||||
'MERGED',
|
||||
]);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ entityId: 'colony-1', action: 'UPDATE' }),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('writes the audit entry of create, move and split inside the transaction', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||
tx.colony.create.mockResolvedValue({ id: 'colony-2' });
|
||||
tx.colony.update.mockResolvedValue({
|
||||
...parentColony,
|
||||
apiaryId: 'apiary-2',
|
||||
});
|
||||
|
||||
await service.create(
|
||||
{ code: 'V-9', apiaryId: 'apiary-1', colonyType: 'PRODUCTION_COLONY' },
|
||||
'user-1',
|
||||
);
|
||||
await service.move('colony-1', { apiaryId: 'apiary-2' }, 'user-1');
|
||||
await service.split(
|
||||
'colony-1',
|
||||
{ code: 'V-1-A', colonyType: 'NUCLEUS_COLONY' },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
const calls = auditLog.record.mock.calls as [unknown, unknown][];
|
||||
expect(calls).toHaveLength(3);
|
||||
expect(calls.every(([, client]) => client === tx)).toBe(true);
|
||||
});
|
||||
|
||||
it('locks the colony row before re-reading it in update, move, dissolve and reactivate', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||
tx.colony.update.mockResolvedValue(parentColony);
|
||||
|
||||
await service.update('colony-1', { notes: 'x' }, 'user-1');
|
||||
await service.move('colony-1', { apiaryId: 'apiary-2' }, 'user-1');
|
||||
await service.dissolve('colony-1', 'user-1');
|
||||
prisma.colony.findUnique.mockResolvedValue({
|
||||
...parentColony,
|
||||
status: 'DISSOLVED',
|
||||
});
|
||||
await service.reactivate('colony-1', 'user-1');
|
||||
|
||||
expect(tx.$queryRaw).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('uses the locked row, not the pre-read, as "before" for event and audit', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue({
|
||||
...parentColony,
|
||||
healthStatus: null,
|
||||
});
|
||||
// Another request made the colony CRITICAL between pre-read and lock.
|
||||
tx.colony.findUnique.mockResolvedValue({
|
||||
...parentColony,
|
||||
healthStatus: 'CRITICAL',
|
||||
});
|
||||
tx.colony.update.mockResolvedValue({
|
||||
...parentColony,
|
||||
healthStatus: 'GOOD',
|
||||
});
|
||||
|
||||
await service.update('colony-1', { healthStatus: 'GOOD' }, 'user-1');
|
||||
|
||||
expect(events.recordAll).toHaveBeenCalledWith(tx, [
|
||||
expect.objectContaining({
|
||||
payload: {
|
||||
from: { status: 'ACTIVE', healthStatus: 'CRITICAL' },
|
||||
to: { status: 'ACTIVE', healthStatus: 'GOOD' },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
beforeData: { ...parentColony, healthStatus: 'CRITICAL' },
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects dissolving a colony that ended between pre-read and lock', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||
tx.colony.findUnique.mockResolvedValue({
|
||||
...parentColony,
|
||||
status: 'SOLD',
|
||||
});
|
||||
|
||||
await expect(service.dissolve('colony-1', 'user-1')).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
expect(tx.colony.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('locks every merged colony', async () => {
|
||||
prisma.colony.findUnique.mockResolvedValue(parentColony);
|
||||
tx.colony.findUnique.mockImplementation(
|
||||
({ where }: { where: { id: string } }) =>
|
||||
Promise.resolve({ id: where.id, status: 'ACTIVE', healthStatus: null }),
|
||||
);
|
||||
tx.colony.update.mockImplementation(
|
||||
({ where }: { where: { id: string } }) =>
|
||||
Promise.resolve({ id: where.id, status: 'MERGED', healthStatus: null }),
|
||||
);
|
||||
|
||||
await service.merge(
|
||||
'colony-1',
|
||||
{ mergedColonyIds: ['colony-3', 'colony-4'] },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
// One multi-row statement, not one per colony.
|
||||
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
const [[query]] = tx.$queryRaw.mock.calls as [[{ sql: string }]];
|
||||
expect(query.sql).toContain('ANY(?) ORDER BY id FOR UPDATE');
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
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 { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { Colony, ColonyStatus } from '../../generated/prisma';
|
||||
import { CreateColonyDto } from './dto/create-colony.dto';
|
||||
import { UpdateColonyDto } from './dto/update-colony.dto';
|
||||
@@ -15,6 +15,8 @@ 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';
|
||||
import { lockRowsForUpdate } from '../prisma/row-lock';
|
||||
import { colonyTransitionEvents, lockColony } from './colony-events';
|
||||
|
||||
export interface ColonyFilter {
|
||||
status?: ColonyStatus;
|
||||
@@ -27,7 +29,7 @@ export class ColoniesService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly webhooks: WebhooksService,
|
||||
private readonly automation: AutomationService,
|
||||
private readonly events: DomainEventsService,
|
||||
) {}
|
||||
|
||||
async findAll(filter: ColonyFilter = {}): Promise<Colony[]> {
|
||||
@@ -77,14 +79,22 @@ export class ColoniesService {
|
||||
|
||||
async create(dto: CreateColonyDto, actingUserId: string): Promise<Colony> {
|
||||
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,
|
||||
const colony = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.colony.create({ data: { ...dto } });
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Colony',
|
||||
entityId: created.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: created,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return created;
|
||||
});
|
||||
// No domain event for a new colony in this stage - the webhook stays on
|
||||
// the direct path.
|
||||
await this.webhooks.dispatch('colony.created', colony);
|
||||
return colony;
|
||||
}
|
||||
@@ -94,14 +104,16 @@ export class ColoniesService {
|
||||
dto: UpdateColonyDto,
|
||||
actingUserId: string,
|
||||
): Promise<Colony> {
|
||||
const before = await this.findById(id);
|
||||
const movesApiary =
|
||||
dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId;
|
||||
if (movesApiary) {
|
||||
await this.assertApiaryActive(dto.apiaryId!);
|
||||
}
|
||||
// Fail fast with 404; everything else is decided on the locked row.
|
||||
await this.findById(id);
|
||||
|
||||
const colony = await this.prisma.$transaction(async (tx) => {
|
||||
const before = await lockColony(tx, id);
|
||||
const movesApiary =
|
||||
dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId;
|
||||
if (movesApiary) {
|
||||
await this.assertApiaryActive(dto.apiaryId!);
|
||||
}
|
||||
const updated = await tx.colony.update({
|
||||
where: { id },
|
||||
data: { ...dto },
|
||||
@@ -119,17 +131,23 @@ export class ColoniesService {
|
||||
},
|
||||
});
|
||||
}
|
||||
await this.events.recordAll(
|
||||
tx,
|
||||
colonyTransitionEvents(before, updated, actingUserId),
|
||||
);
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Colony',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: updated,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -138,10 +156,11 @@ export class ColoniesService {
|
||||
dto: MoveColonyDto,
|
||||
actingUserId: string,
|
||||
): Promise<Colony> {
|
||||
const before = await this.findById(id);
|
||||
this.assertActive(before);
|
||||
this.assertActive(await this.findById(id));
|
||||
await this.assertApiaryActive(dto.apiaryId);
|
||||
const colony = await this.prisma.$transaction(async (tx) => {
|
||||
const before = await lockColony(tx, id);
|
||||
this.assertActive(before);
|
||||
const updated = await tx.colony.update({
|
||||
where: { id },
|
||||
data: { apiaryId: dto.apiaryId },
|
||||
@@ -154,16 +173,19 @@ export class ColoniesService {
|
||||
notes: dto.notes,
|
||||
},
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Colony',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: updated,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return updated;
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'Colony',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: colony,
|
||||
});
|
||||
return colony;
|
||||
}
|
||||
|
||||
@@ -192,15 +214,18 @@ export class ColoniesService {
|
||||
notes: dto.notes,
|
||||
},
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Colony',
|
||||
entityId: created.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: { ...created, splitFromColonyId: parent.id },
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return created;
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'Colony',
|
||||
entityId: child.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: { ...child, splitFromColonyId: parent.id },
|
||||
});
|
||||
return child;
|
||||
}
|
||||
|
||||
@@ -222,6 +247,9 @@ export class ColoniesService {
|
||||
);
|
||||
}
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
// One statement in id order, the same order bulk lockRowsForShare
|
||||
// uses: no deadlock with a concurrent bulk write.
|
||||
await lockRowsForUpdate(tx, 'Colony', dto.mergedColonyIds);
|
||||
for (const mergedColonyId of dto.mergedColonyIds) {
|
||||
const merged = await tx.colony.findUnique({
|
||||
where: { id: mergedColonyId },
|
||||
@@ -238,7 +266,7 @@ export class ColoniesService {
|
||||
// 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({
|
||||
const mergedAway = await tx.colony.update({
|
||||
where: { id: mergedColonyId },
|
||||
data: {
|
||||
status: 'MERGED',
|
||||
@@ -246,6 +274,10 @@ export class ColoniesService {
|
||||
currentQueenId: null,
|
||||
},
|
||||
});
|
||||
await this.events.recordAll(
|
||||
tx,
|
||||
colonyTransitionEvents(merged, mergedAway, actingUserId),
|
||||
);
|
||||
if (merged.currentQueenId) {
|
||||
await tx.queenColonyHistory.updateMany({
|
||||
where: {
|
||||
@@ -266,24 +298,28 @@ export class ColoniesService {
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'Colony',
|
||||
entityId: survivor.id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: survivor,
|
||||
afterData: { ...survivor, mergedColonyIds: dto.mergedColonyIds },
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Colony',
|
||||
entityId: survivor.id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: survivor,
|
||||
afterData: { ...survivor, mergedColonyIds: dto.mergedColonyIds },
|
||||
},
|
||||
tx,
|
||||
);
|
||||
});
|
||||
return this.findById(survivor.id);
|
||||
}
|
||||
|
||||
async dissolve(id: string, actingUserId: string): Promise<Colony> {
|
||||
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);
|
||||
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 updated = await tx.colony.update({
|
||||
where: { id },
|
||||
data: {
|
||||
@@ -302,39 +338,56 @@ export class ColoniesService {
|
||||
data: { endDate: new Date() },
|
||||
});
|
||||
}
|
||||
await this.events.recordAll(
|
||||
tx,
|
||||
colonyTransitionEvents(before, updated, actingUserId),
|
||||
);
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Colony',
|
||||
entityId: id,
|
||||
action: 'DEACTIVATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: updated,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
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);
|
||||
// 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.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 },
|
||||
});
|
||||
await this.events.recordAll(
|
||||
tx,
|
||||
colonyTransitionEvents(before, updated, actingUserId),
|
||||
);
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Colony',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: updated,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return updated;
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'Colony',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: colony,
|
||||
});
|
||||
return colony;
|
||||
}
|
||||
|
||||
async getHistory(id: string) {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Colony } from '../../generated/prisma';
|
||||
import { colonyTransitionEvents } from './colony-events';
|
||||
|
||||
function colony(overrides: Partial<Colony> = {}): Colony {
|
||||
return {
|
||||
id: 'c1',
|
||||
status: 'ACTIVE',
|
||||
healthStatus: null,
|
||||
...overrides,
|
||||
} as Colony;
|
||||
}
|
||||
|
||||
describe('colonyTransitionEvents', () => {
|
||||
it('records nothing when neither status nor healthStatus changed', () => {
|
||||
expect(
|
||||
colonyTransitionEvents(colony(), colony({ notes: 'neu' }), 'u1'),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('records ColonyStatusChanged with from/to snapshots on a health change', () => {
|
||||
const events = colonyTransitionEvents(
|
||||
colony(),
|
||||
colony({ healthStatus: 'CRITICAL' }),
|
||||
'u1',
|
||||
);
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: 'ColonyStatusChanged',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: 'c1',
|
||||
actorUserId: 'u1',
|
||||
payload: {
|
||||
from: { status: 'ACTIVE', healthStatus: null },
|
||||
to: { status: 'ACTIVE', healthStatus: 'CRITICAL' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] as const)(
|
||||
'adds ColonyEnded { status: %s } on the transition from living to ended',
|
||||
(status) => {
|
||||
const events = colonyTransitionEvents(
|
||||
colony({ status: 'WEAK' }),
|
||||
colony({ status }),
|
||||
null,
|
||||
);
|
||||
expect(events.map((event) => event.type)).toEqual([
|
||||
'ColonyStatusChanged',
|
||||
'ColonyEnded',
|
||||
]);
|
||||
expect(events[1].payload).toEqual({ status });
|
||||
},
|
||||
);
|
||||
|
||||
it('does not add ColonyEnded between two ended statuses or on reactivation', () => {
|
||||
expect(
|
||||
colonyTransitionEvents(
|
||||
colony({ status: 'DISSOLVED' }),
|
||||
colony({ status: 'DECEASED' }),
|
||||
'u1',
|
||||
).map((event) => event.type),
|
||||
).toEqual(['ColonyStatusChanged']);
|
||||
expect(
|
||||
colonyTransitionEvents(
|
||||
colony({ status: 'DISSOLVED' }),
|
||||
colony({ status: 'ACTIVE' }),
|
||||
'u1',
|
||||
).map((event) => event.type),
|
||||
).toEqual(['ColonyStatusChanged']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { Colony, Prisma } from '../../generated/prisma';
|
||||
import { lockRowForUpdate } from '../prisma/row-lock';
|
||||
import {
|
||||
AnyNewDomainEvent,
|
||||
ColonyStateSnapshot,
|
||||
} from '../domain-events/domain-event-types';
|
||||
import { isLivingColonyStatus } from './colony-status';
|
||||
|
||||
type ColonyState = Pick<Colony, 'status' | 'healthStatus'>;
|
||||
|
||||
function snapshotOf(colony: ColonyState): ColonyStateSnapshot {
|
||||
return { status: colony.status, healthStatus: colony.healthStatus };
|
||||
}
|
||||
|
||||
/**
|
||||
* The domain events of one colony going from `before` to `after`:
|
||||
* ColonyStatusChanged as soon as status or healthStatus differs, plus
|
||||
* ColonyEnded when a living colony became an ended one. Empty when nothing
|
||||
* the events care about changed. The caller records them in the same
|
||||
* transaction as the colony update.
|
||||
*/
|
||||
export function colonyTransitionEvents(
|
||||
before: Pick<Colony, 'id' | 'status' | 'healthStatus'>,
|
||||
after: Pick<Colony, 'id' | 'status' | 'healthStatus'>,
|
||||
actorUserId: string | null,
|
||||
): AnyNewDomainEvent<'ColonyStatusChanged' | 'ColonyEnded'>[] {
|
||||
if (
|
||||
before.status === after.status &&
|
||||
before.healthStatus === after.healthStatus
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const events: AnyNewDomainEvent<'ColonyStatusChanged' | 'ColonyEnded'>[] = [
|
||||
{
|
||||
type: 'ColonyStatusChanged',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: after.id,
|
||||
actorUserId,
|
||||
payload: { from: snapshotOf(before), to: snapshotOf(after) },
|
||||
},
|
||||
];
|
||||
if (
|
||||
isLivingColonyStatus(before.status) &&
|
||||
!isLivingColonyStatus(after.status)
|
||||
) {
|
||||
events.push({
|
||||
type: 'ColonyEnded',
|
||||
aggregateType: 'Colony',
|
||||
aggregateId: after.id,
|
||||
actorUserId,
|
||||
payload: { status: after.status },
|
||||
});
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locks the colony row and re-reads it inside the transaction. Use the
|
||||
* result - not an earlier read - as "before" for checks, audit and
|
||||
* colonyTransitionEvents, so a concurrent change cannot be missed or
|
||||
* mislabelled.
|
||||
*/
|
||||
export async function lockColony(
|
||||
tx: Prisma.TransactionClient,
|
||||
id: string,
|
||||
): Promise<Colony> {
|
||||
await lockRowForUpdate(tx, 'Colony', id);
|
||||
const colony = await tx.colony.findUnique({ where: { id } });
|
||||
if (!colony) {
|
||||
throw new NotFoundException('Colony not found');
|
||||
}
|
||||
return colony;
|
||||
}
|
||||
Reference in new issue
Block a user