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,16 @@
|
||||
import { WinterChecksService } from './winter-checks.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { CreateWinterCheckDto } from './dto/create-winter-check.dto';
|
||||
|
||||
describe('WinterChecksService', () => {
|
||||
const livingColony = { id: 'c1', status: 'ACTIVE', apiaryId: 'a1' };
|
||||
const livingColony = {
|
||||
id: 'c1',
|
||||
status: 'ACTIVE',
|
||||
healthStatus: null,
|
||||
apiaryId: 'a1',
|
||||
};
|
||||
const lostCheck: CreateWinterCheckDto = {
|
||||
colonyId: 'c1',
|
||||
season: 'SPRING',
|
||||
@@ -16,8 +22,9 @@ describe('WinterChecksService', () => {
|
||||
|
||||
let service: WinterChecksService;
|
||||
let tx: {
|
||||
$queryRaw: jest.Mock;
|
||||
winterCheck: { create: jest.Mock };
|
||||
colony: { update: jest.Mock };
|
||||
colony: { update: jest.Mock; findUnique: jest.Mock };
|
||||
auditLog: { create: jest.Mock };
|
||||
};
|
||||
let prisma: {
|
||||
@@ -25,9 +32,11 @@ describe('WinterChecksService', () => {
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
let events: { recordAll: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
tx = {
|
||||
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||
winterCheck: {
|
||||
create: jest
|
||||
.fn()
|
||||
@@ -36,6 +45,7 @@ describe('WinterChecksService', () => {
|
||||
),
|
||||
},
|
||||
colony: {
|
||||
findUnique: jest.fn().mockResolvedValue(livingColony),
|
||||
update: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ ...livingColony, status: 'DECEASED' }),
|
||||
@@ -51,9 +61,11 @@ describe('WinterChecksService', () => {
|
||||
),
|
||||
};
|
||||
auditLog = { record: jest.fn() };
|
||||
events = { recordAll: jest.fn().mockResolvedValue(0) };
|
||||
service = new WinterChecksService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
events as unknown as DomainEventsService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -83,4 +95,72 @@ describe('WinterChecksService', () => {
|
||||
).map(([input]) => input.entityType);
|
||||
expect(auditedTypes).toEqual(['WinterCheck']);
|
||||
});
|
||||
|
||||
it('a winter loss with markColonyDeceased records ColonyEnded "DECEASED" in the same transaction', async () => {
|
||||
await service.create(lostCheck, 'u1');
|
||||
|
||||
expect(events.recordAll).toHaveBeenCalledWith(tx, [
|
||||
expect.objectContaining({
|
||||
type: 'ColonyStatusChanged',
|
||||
aggregateId: 'c1',
|
||||
actorUserId: 'u1',
|
||||
payload: {
|
||||
from: { status: 'ACTIVE', healthStatus: null },
|
||||
to: { status: 'DECEASED', healthStatus: null },
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: 'ColonyEnded',
|
||||
aggregateId: 'c1',
|
||||
payload: { status: 'DECEASED' },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('records no colony event when the colony is not to be marked deceased', async () => {
|
||||
await service.create({ ...lostCheck, markColonyDeceased: false }, 'u1');
|
||||
|
||||
expect(events.recordAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('audits the winter check inside the transaction', async () => {
|
||||
await service.create({ ...lostCheck, markColonyDeceased: false }, 'u1');
|
||||
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ entityType: 'WinterCheck', action: 'CREATE' }),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('locks the colony and uses the locked row as "before" for event and audit', async () => {
|
||||
tx.colony.findUnique.mockResolvedValue({
|
||||
...livingColony,
|
||||
healthStatus: 'CRITICAL',
|
||||
});
|
||||
|
||||
await service.create(lostCheck, 'u1');
|
||||
|
||||
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
expect(events.recordAll).toHaveBeenCalledWith(tx, [
|
||||
expect.objectContaining({
|
||||
payload: {
|
||||
from: { status: 'ACTIVE', healthStatus: 'CRITICAL' },
|
||||
to: { status: 'DECEASED', healthStatus: null },
|
||||
},
|
||||
}),
|
||||
expect.anything(),
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not mark a colony deceased that ended between read and lock', async () => {
|
||||
tx.colony.findUnique.mockResolvedValue({
|
||||
...livingColony,
|
||||
status: 'DISSOLVED',
|
||||
});
|
||||
|
||||
await service.create(lostCheck, 'u1');
|
||||
|
||||
expect(tx.colony.update).not.toHaveBeenCalled();
|
||||
expect(events.recordAll).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { DomainEventsService } from '../domain-events/domain-events.service';
|
||||
import { Colony, Prisma, WinterCheck } from '../../generated/prisma';
|
||||
import { isLivingColonyStatus } from '../colonies/colony-status';
|
||||
import { colonyTransitionEvents, lockColony } from '../colonies/colony-events';
|
||||
import { CreateWinterCheckDto } from './dto/create-winter-check.dto';
|
||||
import { UpdateWinterCheckDto } from './dto/update-winter-check.dto';
|
||||
import { WinterCheckQueryDto } from './dto/winter-check-query.dto';
|
||||
@@ -43,6 +45,7 @@ export class WinterChecksService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly events: DomainEventsService,
|
||||
) {}
|
||||
|
||||
findAll(query: WinterCheckQueryDto): Promise<WinterCheckWithColony[]> {
|
||||
@@ -126,21 +129,24 @@ export class WinterChecksService {
|
||||
});
|
||||
await this.markDeceasedIfRequested(
|
||||
tx,
|
||||
colony,
|
||||
colony.id,
|
||||
created,
|
||||
markColonyDeceased,
|
||||
actingUserId,
|
||||
);
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'WinterCheck',
|
||||
entityId: created.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: created,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return created;
|
||||
}),
|
||||
);
|
||||
await this.auditLog.record({
|
||||
entityType: 'WinterCheck',
|
||||
entityId: check.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: check,
|
||||
});
|
||||
return check;
|
||||
}
|
||||
|
||||
@@ -172,22 +178,25 @@ export class WinterChecksService {
|
||||
});
|
||||
await this.markDeceasedIfRequested(
|
||||
tx,
|
||||
colony,
|
||||
colony.id,
|
||||
updated,
|
||||
markColonyDeceased,
|
||||
actingUserId,
|
||||
);
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'WinterCheck',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: updated,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return updated;
|
||||
}),
|
||||
);
|
||||
await this.auditLog.record({
|
||||
entityType: 'WinterCheck',
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: check,
|
||||
});
|
||||
return check;
|
||||
}
|
||||
|
||||
@@ -215,17 +224,24 @@ export class WinterChecksService {
|
||||
*/
|
||||
private async markDeceasedIfRequested(
|
||||
tx: Prisma.TransactionClient,
|
||||
colony: Colony,
|
||||
colonyId: string,
|
||||
check: WinterCheck,
|
||||
requested: boolean | undefined,
|
||||
actingUserId: string,
|
||||
): Promise<void> {
|
||||
if (!requested || check.outcome !== 'LOST') return;
|
||||
// Lock and re-read: the status decision and the event/audit "before"
|
||||
// must come from the row as it is now, not from an earlier read.
|
||||
const colony = await lockColony(tx, colonyId);
|
||||
if (!isLivingColonyStatus(colony.status)) return;
|
||||
const after = await tx.colony.update({
|
||||
where: { id: colony.id },
|
||||
data: { status: 'DECEASED', deactivatedAt: check.date },
|
||||
});
|
||||
await this.events.recordAll(
|
||||
tx,
|
||||
colonyTransitionEvents(colony, after, actingUserId),
|
||||
);
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: 'Colony',
|
||||
|
||||
Reference in new issue
Block a user