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

@@ -0,0 +1,72 @@
import { plainToInstance } from 'class-transformer';
import { validate, ValidationError } from 'class-validator';
import { BulkCreateVarroaMeasurementDto } from './bulk-create-varroa-measurement.dto';
// Every constraint message, nested entries included.
function messagesOf(errors: ValidationError[]): string[] {
return errors.flatMap((error) => [
...Object.values(error.constraints ?? {}),
...messagesOf(error.children ?? []),
]);
}
async function check(body: Record<string, unknown>): Promise<string[]> {
const dto = plainToInstance(BulkCreateVarroaMeasurementDto, body);
return messagesOf(
await validate(dto, { whitelist: true, forbidNonWhitelisted: true }),
);
}
const shared = {
date: '2026-09-10',
method: 'NATURAL_MITE_FALL',
durationMinutes: 4320,
};
describe('BulkCreateVarroaMeasurementDto', () => {
it('accepts one entry per colony with its own mite count', async () => {
await expect(
check({
...shared,
entries: [
{ colonyId: 'colony-1', miteCount: 30, assessment: 'hoch' },
{ colonyId: 'colony-2', miteCount: 6 },
],
}),
).resolves.toEqual([]);
});
it('rejects an empty colonyId in an entry with the message of the colonyIds path', async () => {
await expect(
check({ ...shared, entries: [{ colonyId: '', miteCount: 3 }] }),
).resolves.toEqual(['colonyId enthält eine leere Volk-ID']);
});
it('rejects a colonyId that is no text', async () => {
await expect(
check({ ...shared, entries: [{ colonyId: 7, miteCount: 3 }] }),
).resolves.toContain('colonyId enthält eine ungültige Volk-ID');
});
it('rejects a colony that appears twice', async () => {
await expect(
check({
...shared,
entries: [
{ colonyId: 'colony-1', miteCount: 3 },
{ colonyId: 'colony-1', miteCount: 4 },
],
}),
).resolves.toEqual(['entries enthält ein Volk doppelt']);
});
it('rejects a shared mite count', async () => {
const messages = await check({
...shared,
miteCount: 5,
entries: [{ colonyId: 'colony-1' }],
});
expect(messages).toEqual(['property miteCount should not exist']);
});
});
@@ -0,0 +1,35 @@
import { OmitType, PickType } from '@nestjs/swagger';
import {
IsBulkEntries,
IsBulkEntryColonyId,
} from '../../common/bulk/bulk-validation';
import { CreateVarroaMeasurementDto } from './create-varroa-measurement.dto';
const OWN_READINGS = ['miteCount', 'calculatedValue', 'assessment'] as const;
/**
* What each colony has of its own in a bulk count: its mites and their
* reading, validated exactly as on a single measurement. The colony id
* follows the bulk rule (non-empty, as in colonyIds).
*/
export class BulkVarroaEntryDto extends PickType(
CreateVarroaMeasurementDto,
OWN_READINGS,
) {
@IsBulkEntryColonyId()
colonyId!: string;
}
/**
* One varroa count for several colonies: date, method, duration and notes
* are shared, every colony brings its own mite count in `entries` - there is
* no shared mite count. calculatedValue is derived per entry as for a single
* measurement unless the entry carries one.
*/
export class BulkCreateVarroaMeasurementDto extends OmitType(
CreateVarroaMeasurementDto,
['colonyId', ...OWN_READINGS] as const,
) {
@IsBulkEntries(BulkVarroaEntryDto)
entries!: BulkVarroaEntryDto[];
}
@@ -4,6 +4,7 @@ import { RequirePermission } from '../auth/decorators/require-permission.decorat
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateVarroaMeasurementDto } from './dto/create-varroa-measurement.dto';
import { BulkCreateVarroaMeasurementDto } from './dto/bulk-create-varroa-measurement.dto';
import { UpdateVarroaMeasurementDto } from './dto/update-varroa-measurement.dto';
import { VarroaService } from './varroa.service';
import { VarroaThresholdService } from './varroa-threshold.service';
@@ -47,6 +48,17 @@ export class VarroaController {
return this.varroaService.create(dto, actor.id);
}
/** One count for several colonies, each with its own mites - all or none. */
@RequirePermission('varroa:create')
@Idempotent()
@Post('bulk')
createBulk(
@Body() dto: BulkCreateVarroaMeasurementDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.varroaService.createBulk(dto, actor.id);
}
@RequirePermission('varroa:update')
@Patch(':id')
update(
+5 -2
View File
@@ -1,12 +1,15 @@
import { Module } from '@nestjs/common';
import { AutomationModule } from '../automation/automation.module';
import { VarroaController } from './varroa.controller';
import { ColonyVarroaMeasurementsController } from './colony-varroa-measurements.controller';
import { VarroaService } from './varroa.service';
import { VarroaThresholdService } from './varroa-threshold.service';
/**
* The threshold rule and the varroa.measured webhook follow from the
* measurement's domain events (DomainEventsModule is global), so no
* AutomationModule here.
*/
@Module({
imports: [AutomationModule],
controllers: [VarroaController, ColonyVarroaMeasurementsController],
providers: [VarroaService, VarroaThresholdService],
exports: [VarroaService],
+324 -35
View File
@@ -1,12 +1,25 @@
import { NotFoundException } from '@nestjs/common';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { VarroaService } from './varroa.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { AutomationService } from '../automation/automation.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { Prisma } from '../../generated/prisma';
import { BULK_TRANSACTION_TIMEOUT_MS } from '../common/bulk/bulk-limits';
describe('VarroaService', () => {
let service: VarroaService;
let tx: {
$queryRaw: jest.Mock;
varroaMeasurement: {
findUnique: jest.Mock;
create: jest.Mock;
update: jest.Mock;
};
colony: { findMany: jest.Mock };
};
let prisma: {
$connect: jest.Mock;
$transaction: jest.Mock;
varroaMeasurement: {
findMany: jest.Mock;
findUnique: jest.Mock;
@@ -15,7 +28,7 @@ describe('VarroaService', () => {
};
};
let auditLog: { record: jest.Mock };
let automation: { evaluateVarroaMeasurement: jest.Mock };
let events: { record: jest.Mock };
const measurement = {
id: 'varroa-1',
@@ -24,7 +37,20 @@ describe('VarroaService', () => {
};
beforeEach(() => {
tx = {
$queryRaw: jest.fn().mockResolvedValue([]),
varroaMeasurement: {
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
colony: { findMany: jest.fn().mockResolvedValue([]) },
};
prisma = {
$connect: jest.fn(),
$transaction: jest.fn((work: (client: typeof tx) => Promise<unknown>) =>
work(tx),
),
varroaMeasurement: {
findMany: jest.fn(),
findUnique: jest.fn(),
@@ -32,17 +58,23 @@ describe('VarroaService', () => {
update: jest.fn(),
},
};
auditLog = { record: jest.fn() };
automation = {
evaluateVarroaMeasurement: jest.fn().mockResolvedValue(undefined),
};
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
events = { record: jest.fn().mockResolvedValue(undefined) };
service = new VarroaService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
automation as unknown as AutomationService,
events as unknown as DomainEventsService,
);
});
it('has no side-effect dependency: the threshold rule and webhook follow from its events', () => {
expect(Reflect.getMetadata('design:paramtypes', VarroaService)).toEqual([
PrismaService,
AuditLogService,
DomainEventsService,
]);
});
it('findById throws NotFoundException when missing', async () => {
prisma.varroaMeasurement.findUnique.mockResolvedValue(null);
await expect(service.findById('missing')).rejects.toBeInstanceOf(
@@ -50,43 +82,154 @@ describe('VarroaService', () => {
);
});
it('create stores the acting user as the measurer', async () => {
prisma.varroaMeasurement.create.mockResolvedValue(measurement);
it('create writes measurement, audit entry and VarroaMeasured in one transaction', async () => {
tx.varroaMeasurement.create.mockResolvedValue(measurement);
await service.create(
const result = await service.create(
{ colonyId: 'colony-1', date: '2026-01-01', method: 'NATURAL_MITE_FALL' },
'user-1',
);
const [[createArg]] = prisma.varroaMeasurement.create.mock.calls as [
expect(result).toBe(measurement);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
const [[createArg]] = tx.varroaMeasurement.create.mock.calls as [
[{ data: { colonyId: string; userId: string } }],
];
// The acting user is the measurer.
expect(createArg.data.colonyId).toBe('colony-1');
expect(createArg.data.userId).toBe('user-1');
expect(auditLog.record).toHaveBeenCalledWith(
{
entityType: 'VarroaMeasurement',
entityId: 'varroa-1',
action: 'CREATE',
userId: 'user-1',
afterData: measurement,
},
tx,
);
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'VarroaMeasured',
aggregateType: 'VarroaMeasurement',
aggregateId: 'varroa-1',
payload: { colonyId: 'colony-1' },
actorUserId: 'user-1',
});
expect(prisma.varroaMeasurement.create).not.toHaveBeenCalled();
});
it('update writes an audit log entry and re-evaluates automation', async () => {
prisma.varroaMeasurement.findUnique.mockResolvedValue(measurement);
const updated = { ...measurement, miteCount: 12 };
prisma.varroaMeasurement.update.mockResolvedValue(updated);
it('create fails as a whole when its event cannot be written', async () => {
tx.varroaMeasurement.create.mockResolvedValue(measurement);
events.record.mockRejectedValue(new Error('outbox down'));
await expect(
service.create(
{
colonyId: 'colony-1',
date: '2026-01-01',
method: 'NATURAL_MITE_FALL',
},
'user-1',
),
).rejects.toThrow('outbox down');
});
it('update writes the correction, audit entry and VarroaCorrected in one transaction', async () => {
tx.varroaMeasurement.findUnique.mockResolvedValue(measurement);
const updated = { ...measurement, colonyId: 'colony-2', miteCount: 12 };
tx.varroaMeasurement.update.mockResolvedValue(updated);
const result = await service.update(
'varroa-1',
{ miteCount: 12 },
{ colonyId: 'colony-2', miteCount: 12 },
'user-1',
);
expect(result).toBe(updated);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(tx.varroaMeasurement.findUnique).toHaveBeenCalledWith({
where: { id: 'varroa-1' },
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
{
entityType: 'VarroaMeasurement',
entityId: 'varroa-1',
action: 'UPDATE',
userId: 'user-1',
beforeData: measurement,
afterData: updated,
}),
},
tx,
);
expect(automation.evaluateVarroaMeasurement).toHaveBeenCalledWith(updated);
// The threshold rule re-evaluates the correction from the event
// (automation.varroa-threshold), not from here.
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'VarroaCorrected',
aggregateType: 'VarroaMeasurement',
aggregateId: 'varroa-1',
payload: { colonyId: 'colony-2' },
actorUserId: 'user-1',
});
expect(prisma.varroaMeasurement.update).not.toHaveBeenCalled();
});
it('update locks the measurement row before re-reading it as "before"', async () => {
tx.varroaMeasurement.findUnique.mockResolvedValue(measurement);
tx.varroaMeasurement.update.mockResolvedValue(measurement);
await service.update('varroa-1', { miteCount: 3 }, 'user-1');
const [[lock]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]];
expect(lock.sql).toBe(
'SELECT id FROM "VarroaMeasurement" WHERE id = ? FOR UPDATE',
);
expect(lock.values).toEqual(['varroa-1']);
const [lockedAt] = tx.$queryRaw.mock.invocationCallOrder;
const [readAt] = tx.varroaMeasurement.findUnique.mock.invocationCallOrder;
expect(lockedAt).toBeLessThan(readAt);
});
it('update derives the rate from and audits against the locked row, not a read outside the transaction', async () => {
const stale = {
...measurement,
method: 'NATURAL_MITE_FALL',
miteCount: 45,
durationMinutes: 3 * 24 * 60,
calculatedValue: 15,
};
// Another request corrected the count to 60 while this one waited.
const locked = { ...stale, miteCount: 60, calculatedValue: 20 };
prisma.varroaMeasurement.findUnique.mockResolvedValue(stale);
tx.varroaMeasurement.findUnique.mockResolvedValue(locked);
tx.varroaMeasurement.update.mockResolvedValue(locked);
await service.update(
'varroa-1',
{ durationMinutes: 5 * 24 * 60 },
'user-1',
);
expect(prisma.varroaMeasurement.findUnique).not.toHaveBeenCalled();
const [[updateArg]] = tx.varroaMeasurement.update.mock.calls as [
[{ data: { calculatedValue?: number } }],
];
// 60 mites over 5 days, not the stale 45.
expect(updateArg.data.calculatedValue).toBe(12);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ beforeData: locked }),
tx,
);
});
it('update throws NotFoundException for a missing measurement and writes nothing', async () => {
tx.varroaMeasurement.findUnique.mockResolvedValue(null);
await expect(
service.update('missing', { miteCount: 3 }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
expect(tx.varroaMeasurement.update).not.toHaveBeenCalled();
expect(auditLog.record).not.toHaveBeenCalled();
expect(events.record).not.toHaveBeenCalled();
});
it('findByColony orders results chronologically', async () => {
@@ -103,7 +246,7 @@ describe('VarroaService', () => {
describe('calculatedValue auto-computation', () => {
it('computes mites/day for a natural mite fall count on create', async () => {
prisma.varroaMeasurement.create.mockResolvedValue(measurement);
tx.varroaMeasurement.create.mockResolvedValue(measurement);
await service.create(
{
@@ -116,14 +259,14 @@ describe('VarroaService', () => {
'user-1',
);
const [[createArg]] = prisma.varroaMeasurement.create.mock.calls as [
const [[createArg]] = tx.varroaMeasurement.create.mock.calls as [
[{ data: { calculatedValue?: number } }],
];
expect(createArg.data.calculatedValue).toBe(15);
});
it('does not compute calculatedValue for a non-time-based method', async () => {
prisma.varroaMeasurement.create.mockResolvedValue(measurement);
tx.varroaMeasurement.create.mockResolvedValue(measurement);
await service.create(
{
@@ -135,14 +278,14 @@ describe('VarroaService', () => {
'user-1',
);
const [[createArg]] = prisma.varroaMeasurement.create.mock.calls as [
const [[createArg]] = tx.varroaMeasurement.create.mock.calls as [
[{ data: { calculatedValue?: number } }],
];
expect(createArg.data.calculatedValue).toBeUndefined();
});
it('respects an explicitly provided calculatedValue instead of computing one', async () => {
prisma.varroaMeasurement.create.mockResolvedValue(measurement);
tx.varroaMeasurement.create.mockResolvedValue(measurement);
await service.create(
{
@@ -156,21 +299,21 @@ describe('VarroaService', () => {
'user-1',
);
const [[createArg]] = prisma.varroaMeasurement.create.mock.calls as [
const [[createArg]] = tx.varroaMeasurement.create.mock.calls as [
[{ data: { calculatedValue?: number } }],
];
expect(createArg.data.calculatedValue).toBe(99);
});
it('backfills calculatedValue on update when it was never set', async () => {
prisma.varroaMeasurement.findUnique.mockResolvedValue({
tx.varroaMeasurement.findUnique.mockResolvedValue({
...measurement,
method: 'NATURAL_MITE_FALL',
miteCount: null,
durationMinutes: null,
calculatedValue: null,
});
prisma.varroaMeasurement.update.mockResolvedValue(measurement);
tx.varroaMeasurement.update.mockResolvedValue(measurement);
await service.update(
'varroa-1',
@@ -178,25 +321,25 @@ describe('VarroaService', () => {
'user-1',
);
const [[updateArg]] = prisma.varroaMeasurement.update.mock.calls as [
const [[updateArg]] = tx.varroaMeasurement.update.mock.calls as [
[{ data: { calculatedValue?: number } }],
];
expect(updateArg.data.calculatedValue).toBe(20);
});
it('never overwrites an already-stored calculatedValue on an unrelated edit', async () => {
prisma.varroaMeasurement.findUnique.mockResolvedValue({
tx.varroaMeasurement.findUnique.mockResolvedValue({
...measurement,
method: 'NATURAL_MITE_FALL',
miteCount: 45,
durationMinutes: 3 * 24 * 60,
calculatedValue: 15,
});
prisma.varroaMeasurement.update.mockResolvedValue(measurement);
tx.varroaMeasurement.update.mockResolvedValue(measurement);
await service.update('varroa-1', { assessment: 'stabil' }, 'user-1');
const [[updateArg]] = prisma.varroaMeasurement.update.mock.calls as [
const [[updateArg]] = tx.varroaMeasurement.update.mock.calls as [
[{ data: { calculatedValue?: number } }],
];
expect(updateArg.data.calculatedValue).toBeUndefined();
@@ -215,10 +358,10 @@ describe('VarroaService', () => {
before: object,
dto: Parameters<VarroaService['update']>[1],
): Promise<number | null | undefined> {
prisma.varroaMeasurement.findUnique.mockResolvedValue(before);
prisma.varroaMeasurement.update.mockResolvedValue(measurement);
tx.varroaMeasurement.findUnique.mockResolvedValue(before);
tx.varroaMeasurement.update.mockResolvedValue(measurement);
await service.update('varroa-1', dto, 'user-1');
const [[updateArg]] = prisma.varroaMeasurement.update.mock.calls as [
const [[updateArg]] = tx.varroaMeasurement.update.mock.calls as [
[{ data: { calculatedValue?: number | null } }],
];
return updateArg.data.calculatedValue;
@@ -340,4 +483,150 @@ describe('VarroaService', () => {
});
});
});
describe('createBulk', () => {
const shared = {
date: '2026-09-10',
method: 'NATURAL_MITE_FALL' as const,
durationMinutes: 4320,
notes: 'Windel',
};
type CreateArg = {
data: Record<string, unknown> & {
colonyId: string;
calculatedValue?: number;
};
};
beforeEach(() => {
tx.colony.findMany.mockResolvedValue([
{ id: 'colony-1', status: 'ACTIVE', apiaryId: 'apiary-1' },
{ id: 'colony-2', status: 'ACTIVE', apiaryId: 'apiary-1' },
]);
tx.varroaMeasurement.create.mockImplementation(({ data }: CreateArg) =>
Promise.resolve({ ...data, id: `varroa-of-${data.colonyId}` }),
);
});
it('createBulk derives mites per day per entry and keeps each entry own values', async () => {
const result = await service.createBulk(
{
...shared,
entries: [
{ colonyId: 'colony-1', miteCount: 30, assessment: 'hoch' },
{ colonyId: 'colony-2', miteCount: 6 },
],
},
'user-1',
);
expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), {
timeout: BULK_TRANSACTION_TIMEOUT_MS,
});
const rows = (
tx.varroaMeasurement.create.mock.calls as [CreateArg][]
).map(([arg]) => arg.data);
expect(rows).toEqual([
expect.objectContaining({
...shared,
colonyId: 'colony-1',
miteCount: 30,
calculatedValue: 10,
assessment: 'hoch',
date: new Date('2026-09-10'),
userId: 'user-1',
}),
expect.objectContaining({
...shared,
colonyId: 'colony-2',
miteCount: 6,
calculatedValue: 2,
date: new Date('2026-09-10'),
userId: 'user-1',
}),
]);
expect(rows[1].assessment).toBeUndefined();
expect(rows[0]).not.toHaveProperty('entries');
expect(result.records).toHaveLength(2);
});
it('createBulk keeps a value entered by hand for an entry', async () => {
await service.createBulk(
{
...shared,
entries: [
{ colonyId: 'colony-1', miteCount: 30, calculatedValue: 7 },
],
},
'user-1',
);
const [[createArg]] = tx.varroaMeasurement.create.mock.calls as [
CreateArg,
][];
expect(createArg.data.calculatedValue).toBe(7);
});
it('createBulk writes an audit entry and a VarroaMeasured with the bulkId per entry', async () => {
const { bulkId } = await service.createBulk(
{
...shared,
entries: [
{ colonyId: 'colony-1', miteCount: 3 },
{ colonyId: 'colony-2', miteCount: 9 },
],
},
'user-1',
);
for (const colonyId of ['colony-1', 'colony-2']) {
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'VarroaMeasurement',
entityId: `varroa-of-${colonyId}`,
action: 'CREATE',
}),
tx,
);
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'VarroaMeasured',
aggregateType: 'VarroaMeasurement',
aggregateId: `varroa-of-${colonyId}`,
payload: { colonyId, bulkId },
actorUserId: 'user-1',
});
}
});
it('createBulk stores the same row as a single create', async () => {
await service.create(
{ ...shared, colonyId: 'colony-1', miteCount: 12 },
'user-1',
);
await service.createBulk(
{ ...shared, entries: [{ colonyId: 'colony-1', miteCount: 12 }] },
'user-1',
);
const [[single], [bulk]] = tx.varroaMeasurement.create.mock.calls as [
CreateArg,
][];
expect(bulk).toEqual(single);
});
it('createBulk writes nothing when a colony has ended', async () => {
tx.colony.findMany.mockResolvedValue([
{ id: 'colony-1', status: 'DECEASED', apiaryId: 'apiary-1' },
]);
await expect(
service.createBulk(
{ ...shared, entries: [{ colonyId: 'colony-1', miteCount: 1 }] },
'user-1',
),
).rejects.toBeInstanceOf(BadRequestException);
expect(tx.varroaMeasurement.create).not.toHaveBeenCalled();
expect(events.record).not.toHaveBeenCalled();
});
});
});
+134 -46
View File
@@ -1,9 +1,21 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { DbClient } from '../prisma/db-client';
import { lockRowForUpdate } from '../prisma/row-lock';
import { AuditLogService } from '../audit/audit-log.service';
import { AutomationService } from '../automation/automation.service';
import { VarroaMeasurement, VarroaMethod } from '../../generated/prisma';
import { DomainEventsService } from '../domain-events/domain-events.service';
import {
Prisma,
VarroaMeasurement,
VarroaMethod,
} from '../../generated/prisma';
import {
BulkCreateResult,
recordedForColony,
} from '../common/bulk/bulk-create';
import { runBulk } from '../common/bulk/run-bulk';
import { CreateVarroaMeasurementDto } from './dto/create-varroa-measurement.dto';
import { BulkCreateVarroaMeasurementDto } from './dto/bulk-create-varroa-measurement.dto';
import { UpdateVarroaMeasurementDto } from './dto/update-varroa-measurement.dto';
const MINUTES_PER_DAY = 24 * 60;
@@ -80,12 +92,36 @@ function calculatedValueForUpdate(
return deriveCalculatedValue(method, miteCount, durationMinutes) ?? null;
}
// The row a new measurement is stored as - for create and createBulk alike,
// so a bulk entry stores exactly what single entries would. A value entered
// by hand wins; otherwise the rate is derived where a rule exists.
function measurementRow(
dto: CreateVarroaMeasurementDto,
actingUserId: string,
): Prisma.VarroaMeasurementUncheckedCreateInput {
return {
...dto,
calculatedValue:
dto.calculatedValue ??
deriveCalculatedValue(dto.method, dto.miteCount, dto.durationMinutes),
date: new Date(dto.date),
userId: actingUserId,
};
}
/**
* Measurements are written together with their audit entry and domain event
* in one transaction: VarroaMeasured, VarroaCorrected. The threshold rule
* (automation.varroa-threshold, on both - a corrected count can cross the
* threshold) and the varroa.measured webhook (webhooks.forward) follow from
* the event, never from a direct call.
*/
@Injectable()
export class VarroaService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly automation: AutomationService,
private readonly events: DomainEventsService,
) {}
async findAll(): Promise<VarroaMeasurement[]> {
@@ -95,13 +131,7 @@ export class VarroaService {
}
async findById(id: string): Promise<VarroaMeasurement> {
const measurement = await this.prisma.varroaMeasurement.findUnique({
where: { id },
});
if (!measurement) {
throw new NotFoundException('Varroa measurement not found');
}
return measurement;
return this.findIn(this.prisma, id);
}
async findByColony(colonyId: string): Promise<VarroaMeasurement[]> {
@@ -115,26 +145,30 @@ export class VarroaService {
dto: CreateVarroaMeasurementDto,
actingUserId: string,
): Promise<VarroaMeasurement> {
const calculatedValue =
dto.calculatedValue ??
deriveCalculatedValue(dto.method, dto.miteCount, dto.durationMinutes);
const measurement = await this.prisma.varroaMeasurement.create({
data: {
...dto,
calculatedValue,
date: new Date(dto.date),
userId: actingUserId,
},
});
await this.auditLog.record({
entityType: 'VarroaMeasurement',
entityId: measurement.id,
action: 'CREATE',
userId: actingUserId,
afterData: measurement,
});
await this.automation.evaluateVarroaMeasurement(measurement);
return measurement;
return this.prisma.$transaction((tx) =>
this.recordIn(tx, measurementRow(dto, actingUserId), actingUserId),
);
}
/**
* One measurement per entry, all or none (see runBulk). Date, method,
* duration and notes are shared; mite count, rate and assessment are each
* colony's own. Every record gets its own audit entry and VarroaMeasured,
* so the threshold rule and webhook follow per colony.
*/
async createBulk(
dto: BulkCreateVarroaMeasurementDto,
actingUserId: string,
): Promise<BulkCreateResult<VarroaMeasurement>> {
const { entries, ...shared } = dto;
return runBulk(this.prisma, entries, (tx, entry, _colony, bulkId) =>
this.recordIn(
tx,
measurementRow({ ...shared, ...entry }, actingUserId),
actingUserId,
bulkId,
),
);
}
async update(
@@ -142,25 +176,79 @@ export class VarroaService {
dto: UpdateVarroaMeasurementDto,
actingUserId: string,
): Promise<VarroaMeasurement> {
const before = await this.findById(id);
const calculatedValue = calculatedValueForUpdate(before, dto);
const measurement = await this.prisma.varroaMeasurement.update({
where: { id },
data: {
...dto,
calculatedValue,
date: dto.date ? new Date(dto.date) : undefined,
return this.prisma.$transaction(async (tx) => {
// "before" is read under the row lock, so the rate is derived from
// and the audit entry describes the row this correction replaces -
// not one a concurrent correction already changed.
await lockRowForUpdate(tx, 'VarroaMeasurement', id);
const before = await this.findIn(tx, id);
const calculatedValue = calculatedValueForUpdate(before, dto);
const measurement = await tx.varroaMeasurement.update({
where: { id },
data: {
...dto,
calculatedValue,
date: dto.date ? new Date(dto.date) : undefined,
},
});
await this.auditLog.record(
{
entityType: 'VarroaMeasurement',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: measurement,
},
tx,
);
await this.events.record(tx, {
type: 'VarroaCorrected',
aggregateType: 'VarroaMeasurement',
aggregateId: id,
payload: { colonyId: measurement.colonyId },
actorUserId: actingUserId,
});
return measurement;
});
}
// Measurement, audit entry and VarroaMeasured in the caller's transaction;
// threshold rule and webhook follow from the event.
private async recordIn(
tx: Prisma.TransactionClient,
data: Prisma.VarroaMeasurementUncheckedCreateInput,
actingUserId: string,
bulkId?: string,
): Promise<VarroaMeasurement> {
const measurement = await tx.varroaMeasurement.create({ data });
await this.auditLog.record(
{
entityType: 'VarroaMeasurement',
entityId: measurement.id,
action: 'CREATE',
userId: actingUserId,
afterData: measurement,
},
tx,
);
await this.events.record(tx, {
type: 'VarroaMeasured',
aggregateType: 'VarroaMeasurement',
aggregateId: measurement.id,
payload: recordedForColony(measurement.colonyId, bulkId),
actorUserId: actingUserId,
});
await this.auditLog.record({
entityType: 'VarroaMeasurement',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: measurement,
return measurement;
}
private async findIn(db: DbClient, id: string): Promise<VarroaMeasurement> {
const measurement = await db.varroaMeasurement.findUnique({
where: { id },
});
await this.automation.evaluateVarroaMeasurement(measurement);
if (!measurement) {
throw new NotFoundException('Varroa measurement not found');
}
return measurement;
}
}