feat: T4 wave 4 - rules suggest, takeover, chains, autopilot A1-A3, suggestions page, varroa bands UI

- Automation rules propose suggestions instead of creating tasks;
  notify per rule config; corrected varroa measurements refresh or
  resolve their suggestion
- Inspection values can be taken over into the colony card
  (occupiedFrames 0-40, strength deprecated); warning counts use the
  same visibility rule as the notification list
- Biotech chains: swarm check after queen cells, queen acceptance
  check, split schedules (raise own, queen cell, queen)
- Season autopilot core with A1 inspection due, A2 varroa measurement,
  A3 recheck after treatment; colony sync and daily step
- Frontend: suggestions page with groups, bulk accept, undo, paging,
  focus management and permission gating
- Frontend: varroa traffic light from seasonal bands, sample size,
  one range request for the colony chart
- Notifications: mark-read answers 404 for rows the user may no
  longer see

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 15:22:36 +02:00
1 parent 64a2b7dad9
commit 02eb16924e
142 files changed
+11847 -1251

No files matched your search

@@ -518,6 +518,44 @@ describe('ColoniesService', () => {
);
});
it('writing occupiedFrames leaves strength untouched', async () => {
prisma.colony.findUnique.mockResolvedValue({
...parentColony,
strength: 7,
});
tx.colony.update.mockResolvedValue({
...parentColony,
strength: 7,
occupiedFrames: 14,
});
await service.update('colony-1', { occupiedFrames: 14 }, 'user-1');
expect(tx.colony.update).toHaveBeenCalledWith({
where: { id: 'colony-1' },
data: { occupiedFrames: 14 },
});
});
it('updateIn locks the colony row before reading before', async () => {
tx.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.update.mockResolvedValue(parentColony);
const result = await service.updateIn(
tx as never,
'colony-1',
{ notes: 'x' },
'user-1',
);
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
tx.colony.findUnique.mock.invocationCallOrder[0],
);
expect(result.before.id).toBe('colony-1');
expect(result.after).toBe(parentColony);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it('update records no event when status and health stay the same', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.update.mockResolvedValue(parentColony);
@@ -8,7 +8,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { Colony, ColonyStatus } from '../../generated/prisma';
import { Colony, ColonyStatus, Prisma } from '../../generated/prisma';
import { CreateColonyDto } from './dto/create-colony.dto';
import { UpdateColonyDto } from './dto/update-colony.dto';
import { MoveColonyDto } from './dto/move-colony.dto';
@@ -105,51 +105,63 @@ export class ColoniesService {
dto: UpdateColonyDto,
actingUserId: string,
): Promise<Colony> {
// Fail fast with 404; everything else is decided on the locked row.
await this.findById(id);
const { after } = await this.prisma.$transaction((tx) =>
this.updateIn(tx, id, dto, actingUserId),
);
return after;
}
const colony = await this.prisma.$transaction(async (tx) => {
const before = await lockColony(tx, id);
const movesApiary =
dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId;
if (movesApiary) {
await this.assertApiaryActive(dto.apiaryId!);
}
const updated = await tx.colony.update({
where: { id },
data: { ...dto },
});
// Correcting the apiary has to leave the same trace a deliberate move
// does - otherwise the colony's location history would silently skip a
// stand it was recorded on.
if (movesApiary) {
await tx.colonyApiaryHistory.create({
data: {
colonyId: id,
apiaryId: updated.apiaryId,
movedByUserId: actingUserId,
notes: 'Korrektur über Volk bearbeiten',
},
});
}
await this.events.recordAll(
tx,
colonyTransitionEvents(before, updated, actingUserId),
);
await this.auditLog.record(
{
entityType: 'Colony',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: updated,
},
tx,
);
return updated;
/**
* The core of update, for callers that already hold a transaction (an
* inspection handing values over to the colony card). Locks the colony row
* and reads "before" only afterwards (404 when missing); writes the apiary
* history, the status events and the audit entry with the change.
*/
async updateIn(
tx: Prisma.TransactionClient,
id: string,
dto: UpdateColonyDto,
actingUserId: string,
): Promise<{ before: Colony; after: Colony }> {
const before = await lockColony(tx, id);
const movesApiary =
dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId;
if (movesApiary) {
await this.assertApiaryActive(dto.apiaryId!);
}
const updated = await tx.colony.update({
where: { id },
data: { ...dto },
});
return colony;
// Correcting the apiary has to leave the same trace a deliberate move
// does - otherwise the colony's location history would silently skip a
// stand it was recorded on.
if (movesApiary) {
await tx.colonyApiaryHistory.create({
data: {
colonyId: id,
apiaryId: updated.apiaryId,
movedByUserId: actingUserId,
notes: 'Korrektur über Volk bearbeiten',
},
});
}
await this.events.recordAll(
tx,
colonyTransitionEvents(before, updated, actingUserId),
);
await this.auditLog.record(
{
entityType: 'Colony',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: updated,
},
tx,
);
return { before, after: updated };
}
async move(
@@ -0,0 +1,117 @@
import {
broodConditionFromPattern,
planColonyTakeover,
} from './colony-takeover';
const inspection = {
colonyStrength: 12 as number | null,
broodPattern: 4 as number | null,
feedStatus: 'GOOD' as 'GOOD' | null,
};
const allowed = {
canUpdateColony: true,
colonyEnded: false,
hasNewerInspection: false,
};
describe('colony takeover', () => {
it.each([
[5, 'EXCELLENT'],
[4, 'GOOD'],
[3, 'AVERAGE'],
[2, 'WEAK'],
[1, 'CRITICAL'],
[null, null],
])('brood pattern %p -> %p', (pattern, expected) => {
expect(broodConditionFromPattern(pattern)).toBe(expected);
});
it('takes over only requested fields that have a value', () => {
const result = planColonyTakeover(
{ ...inspection, feedStatus: null },
['occupiedFrames', 'feedStatus'],
allowed,
);
expect(result.patch).toEqual({ occupiedFrames: 12 });
expect(result.takeover).toEqual({
applied: ['occupiedFrames'],
skipped: [{ field: 'feedStatus', reason: 'NO_VALUE' }],
});
});
it('maps the brood pattern to the brood status', () => {
const result = planColonyTakeover(inspection, ['broodStatus'], allowed);
expect(result.patch).toEqual({ broodStatus: 'GOOD' });
});
it('takes nothing over when nothing is requested', () => {
const result = planColonyTakeover(inspection, [], allowed);
expect(result.patch).toEqual({});
expect(result.takeover).toEqual({ applied: [], skipped: [] });
});
it('skips everything with NO_PERMISSION when the caller may not update colonies', () => {
const result = planColonyTakeover(
inspection,
['occupiedFrames', 'broodStatus', 'feedStatus'],
{ ...allowed, canUpdateColony: false, hasNewerInspection: true },
);
expect(result.patch).toEqual({});
expect(result.takeover.applied).toEqual([]);
expect(result.takeover.skipped).toEqual([
{ field: 'occupiedFrames', reason: 'NO_PERMISSION' },
{ field: 'broodStatus', reason: 'NO_PERMISSION' },
{ field: 'feedStatus', reason: 'NO_PERMISSION' },
]);
});
it('skips everything with NEWER_INSPECTION when a later inspection exists', () => {
const result = planColonyTakeover(inspection, ['occupiedFrames'], {
...allowed,
hasNewerInspection: true,
});
expect(result.patch).toEqual({});
expect(result.takeover.skipped).toEqual([
{ field: 'occupiedFrames', reason: 'NEWER_INSPECTION' },
]);
});
it('skips every field with COLONY_ENDED when the colony no longer exists', () => {
const result = planColonyTakeover(
inspection,
['occupiedFrames', 'feedStatus'],
{ ...allowed, colonyEnded: true },
);
expect(result.patch).toEqual({});
expect(result.takeover.skipped).toEqual([
{ field: 'occupiedFrames', reason: 'COLONY_ENDED' },
{ field: 'feedStatus', reason: 'COLONY_ENDED' },
]);
});
it.each([45, -1])(
'skips OUT_OF_RANGE for the value %p outside the colony card range',
(value) => {
const result = planColonyTakeover(
{ ...inspection, colonyStrength: value },
['occupiedFrames'],
allowed,
);
expect(result.patch).toEqual({});
expect(result.takeover.skipped).toEqual([
{ field: 'occupiedFrames', reason: 'OUT_OF_RANGE' },
]);
},
);
it('accepts the bounds 0 and 40', () => {
for (const value of [0, 40]) {
const result = planColonyTakeover(
{ ...inspection, colonyStrength: value },
['occupiedFrames'],
allowed,
);
expect(result.patch).toEqual({ occupiedFrames: value });
}
});
});
@@ -0,0 +1,121 @@
import type {
Colony,
ColonyCondition,
Inspection,
} from '../../generated/prisma';
export const COLONY_TAKEOVER_FIELDS = [
'occupiedFrames',
'broodStatus',
'feedStatus',
] as const;
export type ColonyTakeoverField = (typeof COLONY_TAKEOVER_FIELDS)[number];
export type TakeoverSkipReason =
| 'NO_VALUE'
| 'NO_PERMISSION'
| 'NEWER_INSPECTION'
| 'COLONY_ENDED'
| 'OUT_OF_RANGE';
export interface ColonyTakeover {
applied: ColonyTakeoverField[];
skipped: { field: ColonyTakeoverField; reason: TakeoverSkipReason }[];
}
export const MAX_OCCUPIED_FRAMES = 40;
const CONDITION_BY_BROOD_PATTERN: Record<number, ColonyCondition> = {
5: 'EXCELLENT',
4: 'GOOD',
3: 'AVERAGE',
2: 'WEAK',
1: 'CRITICAL',
};
/** Brood pattern 1-5 as the colony card's brood status; the mapping is an assumption. */
export function broodConditionFromPattern(
pattern: number | null,
): ColonyCondition | null {
if (pattern === null) {
return null;
}
return CONDITION_BY_BROOD_PATTERN[pattern] ?? null;
}
/** Only values that exist: a takeover never writes null. */
type TakeoverPatch = {
[K in ColonyTakeoverField]?: NonNullable<Colony[K]>;
};
type TakeoverSource = Pick<
Inspection,
'colonyStrength' | 'broodPattern' | 'feedStatus'
>;
interface FieldPlan {
patch: TakeoverPatch | null;
skip: TakeoverSkipReason | null;
}
function planField(
field: ColonyTakeoverField,
inspection: TakeoverSource,
): FieldPlan {
switch (field) {
case 'occupiedFrames': {
const frames = inspection.colonyStrength;
if (frames === null) {
return { patch: null, skip: 'NO_VALUE' };
}
if (frames < 0 || frames > MAX_OCCUPIED_FRAMES) {
return { patch: null, skip: 'OUT_OF_RANGE' };
}
return { patch: { occupiedFrames: frames }, skip: null };
}
case 'broodStatus': {
const status = broodConditionFromPattern(inspection.broodPattern);
return status === null
? { patch: null, skip: 'NO_VALUE' }
: { patch: { broodStatus: status }, skip: null };
}
case 'feedStatus':
return inspection.feedStatus === null
? { patch: null, skip: 'NO_VALUE' }
: { patch: { feedStatus: inspection.feedStatus }, skip: null };
}
}
/**
* What a saved inspection hands over to the colony card. Values come from the
* stored inspection only. A missing permission, an ended colony and a newer
* inspection each skip every requested field: a newer inspection blocks all
* fields, not only the one it would overwrite (spec 7.9).
*/
export function planColonyTakeover(
inspection: TakeoverSource,
requested: readonly ColonyTakeoverField[],
ctx: {
canUpdateColony: boolean;
colonyEnded: boolean;
hasNewerInspection: boolean;
},
): { patch: TakeoverPatch; takeover: ColonyTakeover } {
const patch: TakeoverPatch = {};
const takeover: ColonyTakeover = { applied: [], skipped: [] };
for (const field of requested) {
const plan: FieldPlan = !ctx.canUpdateColony
? { patch: null, skip: 'NO_PERMISSION' }
: ctx.colonyEnded
? { patch: null, skip: 'COLONY_ENDED' }
: ctx.hasNewerInspection
? { patch: null, skip: 'NEWER_INSPECTION' }
: planField(field, inspection);
if (plan.skip !== null) {
takeover.skipped.push({ field, reason: plan.skip });
continue;
}
Object.assign(patch, plan.patch);
takeover.applied.push(field);
}
return { patch, takeover };
}
@@ -25,13 +25,29 @@ export class CreateColonyDto {
@IsString()
originText?: string;
@ApiPropertyOptional({ minimum: 1, maximum: 10 })
@ApiPropertyOptional({
minimum: 1,
maximum: 10,
deprecated: true,
description: 'Volksstärke (alt, Skala 1–10)',
})
@IsOptional()
@IsInt()
@Min(1)
@Max(10)
strength?: number;
@ApiPropertyOptional({
minimum: 0,
maximum: 40,
description: 'Volksstärke in besetzten Waben',
})
@IsOptional()
@IsInt()
@Min(0)
@Max(40, { message: 'Höchstens 40 besetzte Waben.' })
occupiedFrames?: number;
@ApiPropertyOptional({ enum: ColonyCondition })
@IsOptional()
@IsEnum(ColonyCondition)
@@ -18,6 +18,7 @@ class UpdatableColonyFieldsDto extends PartialType(
'originText',
'colonyType',
'strength',
'occupiedFrames',
'broodStatus',
'feedStatus',
'healthStatus',