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
@@ -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(
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user