feat: T4 wave 3 - notifications per user, suggestions API, varroa bands, split/queen events

- Schema III: per-user notifications (broadcast fan-out migration,
  category, link, requiredPermissions filter), notification preferences
  and settings endpoints, calendar withdrawal switch, occupiedFrames
- Suggestions API: list/summary with permission visibility and paging,
  accept/dismiss/reopen incl. bulk, SuggestionView on all responses
- Varroa traffic light by season and method, young colonies, treatment
  effect window, sample-based percentage; shared withdrawal-window helper
  on Berlin calendar days; GET /varroa-measurements/thresholds
- ColonySplit and QueenAssigned domain events with calendar and webhook
  handlers
- Outbox claims compare against now() rounded to timestamp(3); fixes the
  flaky e2e claims of rows written with SQL now()

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 14:25:24 +02:00
1 parent d99a697739
commit f468dc5f75
87 files changed
+6022 -510

No files matched your search

@@ -1,17 +1,25 @@
import { Controller, Get, Param } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { VarroaBandsService } from './varroa-bands.service';
import { VarroaService } from './varroa.service';
@ApiTags('varroa-measurements')
@ApiBearerAuth()
@Controller('colonies')
export class ColonyVarroaMeasurementsController {
constructor(private readonly varroaService: VarroaService) {}
constructor(
private readonly varroaService: VarroaService,
private readonly varroaBandsService: VarroaBandsService,
) {}
@RequirePermission('varroa:read')
@Get(':id/varroa-measurements')
findByColony(@Param('id') id: string) {
return this.varroaService.findByColony(id);
/** The measurements of the colony, each with its rating (light, band, reason). */
async findByColony(@Param('id') id: string) {
return this.varroaBandsService.rate(
id,
await this.varroaService.findByColony(id),
);
}
}
@@ -6,6 +6,7 @@ import {
IsNumber,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';
import { VarroaMethod } from '../../../generated/prisma';
@@ -35,6 +36,18 @@ export class CreateVarroaMeasurementDto {
@Min(0)
miteCount?: number;
@ApiPropertyOptional({
description:
'Sample size in bees (powdered sugar, washing); the percentage is mites / sample * 100.',
minimum: 1,
maximum: 2000,
})
@IsOptional()
@IsInt()
@Min(1)
@Max(2000)
sampleBees?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@@ -0,0 +1,29 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, Matches, MaxLength } from 'class-validator';
const MAX_ID_LENGTH = 64;
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
/**
* GET /varroa-measurements/thresholds. The date is only shape-checked here;
* an impossible day (2026-02-30) is rejected by the service.
*/
export class VarroaThresholdsQuery {
@ApiPropertyOptional({
description: 'Volk, für dessen Typ die Bänder gelten',
})
@IsOptional()
@IsString()
@MaxLength(MAX_ID_LENGTH)
@Matches(/^[A-Za-z0-9_-]+$/, { message: 'colonyId ist ungültig' })
colonyId?: string;
@ApiPropertyOptional({
description: 'Messtag (JJJJ-MM-TT), ohne Angabe heute (Berlin)',
example: '2026-08-10',
})
@IsOptional()
@IsString()
@Matches(DAY_PATTERN, { message: 'date muss ein Tag (JJJJ-MM-TT) sein' })
date?: string;
}
@@ -0,0 +1,149 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { VarroaBandsService } from './varroa-bands.service';
const d = (day: string): Date => new Date(`${day}T00:00:00.000Z`);
describe('VarroaBandsService', () => {
let service: VarroaBandsService;
let prisma: {
automationRule: { findUnique: jest.Mock };
colony: { findUnique: jest.Mock };
treatment: { findMany: jest.Mock };
};
beforeEach(() => {
prisma = {
// No row: the built-in (active) rule applies.
automationRule: { findUnique: jest.fn().mockResolvedValue(null) },
colony: { findUnique: jest.fn() },
treatment: { findMany: jest.fn().mockResolvedValue([]) },
};
service = new VarroaBandsService(prisma as unknown as PrismaService);
});
describe('thresholds', () => {
it('halves the natural mite fall bands for a young colony', async () => {
prisma.colony.findUnique.mockResolvedValue({
colonyType: 'YOUNG_COLONY',
});
const result = await service.thresholds({
colonyId: 'c1',
date: '2026-06-10',
});
expect(result.active).toBe(true);
expect(result.bands.NATURAL_MITE_FALL).toMatchObject({
yellowFrom: 2.5,
redAbove: 5,
youngColony: true,
});
});
it('returns no powdered sugar band in April', async () => {
const result = await service.thresholds({ date: '2026-04-10' });
expect(result.bands.POWDERED_SUGAR).toBeUndefined();
expect(result.bands.NATURAL_MITE_FALL).toBeDefined();
});
it('names the recheck day while a treatment of the colony is in effect', async () => {
prisma.colony.findUnique.mockResolvedValue({
colonyType: 'PRODUCTION_COLONY',
});
prisma.treatment.findMany.mockResolvedValue([
{ date: d('2026-08-02'), startDate: d('2026-07-20'), endDate: null },
]);
const result = await service.thresholds({
colonyId: 'c1',
date: '2026-08-07',
});
expect(result.postTreatmentUntil).toBe('2026-08-16');
});
it('returns no bands while the rule is switched off', async () => {
prisma.automationRule.findUnique.mockResolvedValue({
id: 'r',
active: false,
config: {},
actionTemplate: null,
});
await expect(service.thresholds({ date: '2026-08-10' })).resolves.toEqual(
{
day: '2026-08-10',
bands: {},
postTreatmentUntil: null,
active: false,
},
);
});
it('rejects an unknown colony with 404', async () => {
prisma.colony.findUnique.mockResolvedValue(null);
await expect(
service.thresholds({ colonyId: 'missing' }),
).rejects.toBeInstanceOf(NotFoundException);
});
it.each(['2026-02-30', '2026-13-01', '10.08.2026', 'x'])(
'rejects the invalid date %s with 400',
async (date) => {
await expect(service.thresholds({ date })).rejects.toBeInstanceOf(
BadRequestException,
);
},
);
});
describe('rate', () => {
const measurement = {
id: 'm1',
colonyId: 'c1',
date: d('2026-08-20'),
method: 'NATURAL_MITE_FALL' as const,
calculatedValue: 12,
};
it('adds the rating of every measurement', async () => {
prisma.colony.findUnique.mockResolvedValue({
colonyType: 'PRODUCTION_COLONY',
});
const [rated] = await service.rate('c1', [measurement]);
expect(rated).toMatchObject({ id: 'm1' });
expect(rated.rating).toMatchObject({
light: 'red',
reasonCode: 'NATURAL_FALL_SEASON',
});
});
it('rates nothing while the rule is switched off', async () => {
prisma.automationRule.findUnique.mockResolvedValue({
id: 'r',
active: false,
config: {},
actionTemplate: null,
});
const [rated] = await service.rate('c1', [measurement]);
expect(rated.rating).toEqual({
light: null,
reasonCode: null,
band: null,
postTreatmentUntil: null,
});
});
it('does not read the colony for an empty list', async () => {
await expect(service.rate('c1', [])).resolves.toEqual([]);
expect(prisma.colony.findUnique).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,113 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { ColonyType, VarroaMeasurement } from '../../generated/prisma';
import { readRule } from '../automation/rule-config';
import {
bandsFor,
rateMeasurement,
type VarroaBands,
type VarroaRating,
} from '../automation/varroa-bands';
import { berlinToday, dayToDate } from '../common/dates';
import { PrismaService } from '../prisma/prisma.service';
import { treatmentWindowsIn } from '../treatments/treatment-windows';
export interface VarroaThresholds extends VarroaBands {
/** false while the VARROA_THRESHOLD rule is switched off: no bands. */
active: boolean;
}
type RatableMeasurement = Pick<
VarroaMeasurement,
'date' | 'method' | 'calculatedValue'
>;
const NO_RATING: VarroaRating = {
light: null,
reasonCode: null,
band: null,
postTreatmentUntil: null,
};
function validDay(day: string): string {
try {
dayToDate(day);
} catch {
throw new BadRequestException('Bitte einen gültigen Tag angeben.');
}
return day;
}
/**
* The varroa bands (green/yellow/red limits) of the VARROA_THRESHOLD rule for
* a day and colony, and the rating of measurements against them.
*/
@Injectable()
export class VarroaBandsService {
constructor(private readonly prisma: PrismaService) {}
async thresholds(query: {
colonyId?: string;
date?: string;
}): Promise<VarroaThresholds> {
const day = validDay(query.date ?? berlinToday());
const rule = await readRule(this.prisma, 'VARROA_THRESHOLD');
const colonyType =
query.colonyId === undefined
? null
: await this.colonyTypeOf(query.colonyId);
if (!rule.active) {
return { day, bands: {}, postTreatmentUntil: null, active: false };
}
const treatments =
query.colonyId === undefined
? []
: await treatmentWindowsIn(this.prisma, query.colonyId);
return {
...bandsFor(day, colonyType, rule.config, treatments),
active: true,
};
}
/** Each measurement of one colony with its rating. */
async rate<M extends RatableMeasurement>(
colonyId: string,
measurements: readonly M[],
): Promise<(M & { rating: VarroaRating })[]> {
if (measurements.length === 0) {
return [];
}
const rule = await readRule(this.prisma, 'VARROA_THRESHOLD');
if (!rule.active) {
return measurements.map((m) => ({ ...m, rating: NO_RATING }));
}
const colony = await this.prisma.colony.findUnique({
where: { id: colonyId },
select: { colonyType: true },
});
const treatments = await treatmentWindowsIn(this.prisma, colonyId);
return measurements.map((m) => ({
...m,
rating: rateMeasurement(
m,
colony?.colonyType ?? null,
rule.config,
treatments,
),
}));
}
private async colonyTypeOf(colonyId: string): Promise<ColonyType> {
const colony = await this.prisma.colony.findUnique({
where: { id: colonyId },
select: { colonyType: true },
});
if (!colony) {
throw new NotFoundException('Colony not found');
}
return colony.colonyType;
}
}
@@ -1,21 +0,0 @@
import { Injectable } from '@nestjs/common';
import { varroaThresholdOf } from '../automation/varroa-threshold';
import { PrismaService } from '../prisma/prisma.service';
export interface VarroaThreshold {
// null when the VARROA_THRESHOLD rule is switched off.
thresholdMitesPerDay: number | null;
}
@Injectable()
export class VarroaThresholdService {
constructor(private readonly prisma: PrismaService) {}
async current(): Promise<VarroaThreshold> {
const rule = await this.prisma.automationRule.findUnique({
where: { triggerType: 'VARROA_THRESHOLD' },
select: { active: true, config: true },
});
return { thresholdMitesPerDay: varroaThresholdOf(rule) };
}
}
@@ -1,4 +1,12 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
@@ -7,7 +15,8 @@ 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';
import { VarroaBandsService } from './varroa-bands.service';
import { VarroaThresholdsQuery } from './dto/varroa-thresholds.query';
import { Idempotent } from '../common/idempotency/idempotent.decorator';
@ApiTags('varroa-measurements')
@@ -16,7 +25,7 @@ import { Idempotent } from '../common/idempotency/idempotent.decorator';
export class VarroaController {
constructor(
private readonly varroaService: VarroaService,
private readonly varroaThresholdService: VarroaThresholdService,
private readonly varroaBandsService: VarroaBandsService,
) {}
@RequirePermission('varroa:read')
@@ -25,11 +34,11 @@ export class VarroaController {
return this.varroaService.findAll();
}
/** Effective alarm threshold; declared before :id so it is not shadowed. */
/** Bands in force on a day for a colony; declared before :id so it is not shadowed. */
@RequirePermission('varroa:read', 'varroa:create')
@Get('threshold')
getThreshold() {
return this.varroaThresholdService.current();
@Get('thresholds')
getThresholds(@Query() query: VarroaThresholdsQuery) {
return this.varroaBandsService.thresholds(query);
}
@RequirePermission('varroa:read')
+2 -2
View File
@@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
import { VarroaController } from './varroa.controller';
import { ColonyVarroaMeasurementsController } from './colony-varroa-measurements.controller';
import { VarroaService } from './varroa.service';
import { VarroaThresholdService } from './varroa-threshold.service';
import { VarroaBandsService } from './varroa-bands.service';
/**
* The threshold rule and the varroa.measured webhook follow from the
@@ -11,7 +11,7 @@ import { VarroaThresholdService } from './varroa-threshold.service';
*/
@Module({
controllers: [VarroaController, ColonyVarroaMeasurementsController],
providers: [VarroaService, VarroaThresholdService],
providers: [VarroaService, VarroaBandsService],
exports: [VarroaService],
})
export class VarroaModule {}
@@ -284,6 +284,57 @@ describe('VarroaService', () => {
expect(createArg.data.calculatedValue).toBeUndefined();
});
it('stores mites per 100 bees for powdered sugar with a sample size', async () => {
tx.varroaMeasurement.create.mockResolvedValue(measurement);
await service.create(
{
colonyId: 'colony-1',
date: '2026-07-01',
method: 'POWDERED_SUGAR',
miteCount: 10,
sampleBees: 500,
},
'user-1',
);
const [[createArg]] = tx.varroaMeasurement.create.mock.calls as [
[{ data: { calculatedValue?: number; sampleBees?: number } }],
];
expect(createArg.data.calculatedValue).toBe(2);
expect(createArg.data.sampleBees).toBe(500);
});
it('does not derive a percentage from the sample for natural mite fall or without a sample', async () => {
tx.varroaMeasurement.create.mockResolvedValue(measurement);
await service.create(
{
colonyId: 'colony-1',
date: '2026-07-01',
method: 'WASHING_METHOD',
miteCount: 10,
},
'user-1',
);
await service.create(
{
colonyId: 'colony-1',
date: '2026-07-01',
method: 'NATURAL_MITE_FALL',
miteCount: 10,
sampleBees: 500,
},
'user-1',
);
const calls = tx.varroaMeasurement.create.mock.calls as [
{ data: { calculatedValue?: number } },
][];
expect(calls[0][0].data.calculatedValue).toBeUndefined();
expect(calls[1][0].data.calculatedValue).toBeUndefined();
});
it('respects an explicitly provided calculatedValue instead of computing one', async () => {
tx.varroaMeasurement.create.mockResolvedValue(measurement);
@@ -373,6 +424,16 @@ describe('VarroaService', () => {
).resolves.toBe(20);
});
it('rounds mites/day to two decimals so the stored value rates as shown', async () => {
// 7203 / 720 days = 10.0042: stored as 10, which is yellow, not red.
await expect(
storedCalculatedValueAfter(naturalMiteFall, {
miteCount: 7203,
durationMinutes: 720 * 24 * 60,
}),
).resolves.toBe(10);
});
it('recomputes mites/day when the duration changes', async () => {
await expect(
storedCalculatedValueAfter(naturalMiteFall, {
@@ -443,6 +504,108 @@ describe('VarroaService', () => {
).resolves.toBe(15);
});
describe('with a sample size', () => {
const washed = {
...measurement,
method: 'WASHING_METHOD',
miteCount: 10,
durationMinutes: null,
sampleBees: 500,
calculatedValue: 2,
};
it('recomputes the percentage when the sample size changes', async () => {
await expect(
storedCalculatedValueAfter(washed, { sampleBees: 250 }),
).resolves.toBe(4);
});
it('update with sampleBees null clears the sample and keeps a manually entered value', async () => {
tx.varroaMeasurement.findUnique.mockResolvedValue({
...washed,
calculatedValue: 3,
});
tx.varroaMeasurement.update.mockResolvedValue(measurement);
await service.update(
'varroa-1',
{ sampleBees: null as unknown as number },
'user-1',
);
const [[updateArg]] = tx.varroaMeasurement.update.mock.calls as [
[
{
data: {
sampleBees?: number | null;
calculatedValue?: number | null;
};
},
],
];
expect(updateArg.data.sampleBees).toBeNull();
expect(updateArg.data.calculatedValue).toBeUndefined();
});
it('clears a derived percentage when the sample is removed', async () => {
await expect(
storedCalculatedValueAfter(washed, {
sampleBees: null as unknown as number,
}),
).resolves.toBeNull();
});
it('rounds the derived percentage to two decimals', async () => {
await expect(
storedCalculatedValueAfter(washed, {
miteCount: 7,
sampleBees: 300,
}),
).resolves.toBe(2.33);
});
it('recomputes the percentage when the mite count changes', async () => {
await expect(
storedCalculatedValueAfter(washed, { miteCount: 15 }),
).resolves.toBe(3);
});
it('leaves a manual percentage alone when only the notes change', async () => {
await expect(
storedCalculatedValueAfter(
{ ...washed, calculatedValue: 3 },
{ notes: 'x' },
),
).resolves.toBeUndefined();
});
it('derives the percentage when the method switches from drone brood', async () => {
await expect(
storedCalculatedValueAfter(
{ ...washed, method: 'DRONE_BROOD_CONTROL', calculatedValue: 9 },
{ method: 'POWDERED_SUGAR' },
),
).resolves.toBe(2);
});
it('clears the percentage when the method switches to drone brood', async () => {
await expect(
storedCalculatedValueAfter(washed, {
method: 'DRONE_BROOD_CONTROL',
}),
).resolves.toBeNull();
});
it('keeps a changed percentage as a manual override', async () => {
await expect(
storedCalculatedValueAfter(washed, {
sampleBees: 250,
calculatedValue: 7,
}),
).resolves.toBe(7);
});
});
// @IsOptional lets an explicit null through the validation pipe.
const clearedValue = { calculatedValue: null as unknown as number };
+74 -26
View File
@@ -19,16 +19,19 @@ import { BulkCreateVarroaMeasurementDto } from './dto/bulk-create-varroa-measure
import { UpdateVarroaMeasurementDto } from './dto/update-varroa-measurement.dto';
const MINUTES_PER_DAY = 24 * 60;
const PERCENT = 100;
const DECIMALS_FACTOR = 100;
// Stored and rated value are the same: rounded once, on write.
function roundToTwoDecimals(value: number): number {
return Math.round(value * DECIMALS_FACTOR) / DECIMALS_FACTOR;
}
// Natural mite fall is counted on a sticky-board insert left in the hive
// for some duration and is conventionally normalized to a daily rate
// ("Milben/Tag") - both the VARROA_THRESHOLD automation config
// (thresholdMitesPerDay) and the annual PDF report already assume
// calculatedValue is in that unit. The other methods (Puderzucker,
// Waschmethode, Drohnenbrutkontrolle) report a different, non-time-based
// metric (mites per fixed bee sample); there is no single verified
// formula for those here, so calculatedValue is left for the user to
// enter manually for those methods instead of guessing one.
// ("Milben/Tag"). Powdered sugar and washing report the infestation as mites
// per 100 bees of a sample of sampleBees (percent). The drone brood check has
// no verified formula, so its calculatedValue is left for the user to enter.
function computeNaturalMiteFallPerDay(
miteCount: number | null | undefined,
durationMinutes: number | null | undefined,
@@ -39,16 +42,44 @@ function computeNaturalMiteFallPerDay(
if (!durationMinutes || durationMinutes <= 0) {
return undefined;
}
return (miteCount / durationMinutes) * MINUTES_PER_DAY;
return roundToTwoDecimals((miteCount / durationMinutes) * MINUTES_PER_DAY);
}
function deriveCalculatedValue(
method: VarroaMethod,
function computeInfestationPercent(
miteCount: number | null | undefined,
durationMinutes: number | null | undefined,
sampleBees: number | null | undefined,
): number | undefined {
return method === 'NATURAL_MITE_FALL'
? computeNaturalMiteFallPerDay(miteCount, durationMinutes)
if (miteCount === null || miteCount === undefined) {
return undefined;
}
if (!sampleBees || sampleBees <= 0) {
return undefined;
}
return roundToTwoDecimals((miteCount / sampleBees) * PERCENT);
}
function isPercentMethod(method: VarroaMethod): boolean {
return method === 'POWDERED_SUGAR' || method === 'WASHING_METHOD';
}
interface Derivation {
method: VarroaMethod;
miteCount?: number | null;
durationMinutes?: number | null;
sampleBees?: number | null;
}
function deriveCalculatedValue({
method,
miteCount,
durationMinutes,
sampleBees,
}: Derivation): number | undefined {
if (method === 'NATURAL_MITE_FALL') {
return computeNaturalMiteFallPerDay(miteCount, durationMinutes);
}
return isPercentMethod(method)
? computeInfestationPercent(miteCount, sampleBees)
: undefined;
}
@@ -69,18 +100,25 @@ function calculatedValueForUpdate(
if (sent !== undefined && sent !== null && sent !== before.calculatedValue) {
return sent;
}
const method = dto.method ?? before.method;
const miteCount =
dto.miteCount === undefined ? before.miteCount : dto.miteCount;
const durationMinutes =
dto.durationMinutes === undefined
? before.durationMinutes
: dto.durationMinutes;
const merged: Derivation = {
method: dto.method ?? before.method,
miteCount: dto.miteCount === undefined ? before.miteCount : dto.miteCount,
durationMinutes:
dto.durationMinutes === undefined
? before.durationMinutes
: dto.durationMinutes,
sampleBees:
dto.sampleBees === undefined ? before.sampleBees : dto.sampleBees,
};
const { method } = merged;
const derivationInputChanged =
method !== before.method ||
(method === 'NATURAL_MITE_FALL' &&
(miteCount !== before.miteCount ||
durationMinutes !== before.durationMinutes));
(merged.miteCount !== before.miteCount ||
merged.durationMinutes !== before.durationMinutes)) ||
(isPercentMethod(method) &&
(merged.miteCount !== before.miteCount ||
merged.sampleBees !== before.sampleBees));
// An explicit null asks for the value to be cleared - like a create
// without a value, that means "derive it where a rule exists".
const mustDerive =
@@ -88,8 +126,20 @@ function calculatedValueForUpdate(
if (!mustDerive) {
return undefined;
}
const derived = deriveCalculatedValue(merged);
// A percentage that no longer derives (the sample was removed) is cleared
// only if it was derived itself; a value entered by hand stays.
if (
derived === undefined &&
sent !== null &&
method === before.method &&
isPercentMethod(method) &&
before.calculatedValue !== deriveCalculatedValue(before)
) {
return undefined;
}
// null clears a value the merged record no longer supports.
return deriveCalculatedValue(method, miteCount, durationMinutes) ?? null;
return derived ?? null;
}
// The row a new measurement is stored as - for create and createBulk alike,
@@ -101,9 +151,7 @@ function measurementRow(
): Prisma.VarroaMeasurementUncheckedCreateInput {
return {
...dto,
calculatedValue:
dto.calculatedValue ??
deriveCalculatedValue(dto.method, dto.miteCount, dto.durationMinutes),
calculatedValue: dto.calculatedValue ?? deriveCalculatedValue(dto),
date: new Date(dto.date),
userId: actingUserId,
};