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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 11:36:38 +02:00
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');
});
});
+133 -80
View File
@@ -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;
}