feat: T4 wave 5 - colony end cleanup, withdrawal chain, next steps, inspection takeover UI

- Colony end releases the queen in the same transaction; open
  suggestions become obsolete and a CLEANUP suggestion lists open
  tasks and the queen; apply-cleanup validates evidence and blocks
  reactivated colonies
- Withdrawal chain: plausibility warnings on harvest and bottling,
  conflict notifications also when a treatment is entered or corrected
  after the harvest (naming bottled lots), withdrawal spans in the
  calendar and M365 sync, Berlin-day end events
- Next 3 steps per colony and the "Heute" endpoint with ranking and an
  explicit merge table by intent
- Inspection-triggered rule suggestions follow the colony's latest
  inspection (resolve when the finding is gone, refresh when it stays)
- Frontend: colony card takeover in the inspection form, feed status as
  condition, occupied frames 0-40; legacy offline entries keep syncing
- Notifications and preferences are deleted with their user

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 16:28:07 +02:00
1 parent 02eb16924e
commit ef33e5549b
122 files changed
+10707 -504

No files matched your search

@@ -0,0 +1,303 @@
import type { Prisma } from '../../generated/prisma';
import type {
AnyDomainEventEnvelope,
DomainEventType,
} from '../domain-events/domain-event-types';
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
import type { NotificationsService } from '../notifications/notifications.service';
import type {
WithdrawalCheckService,
WithdrawalConflict,
} from './withdrawal-check.service';
import { WithdrawalEventHandlers } from './withdrawal.handlers';
const conflict: WithdrawalConflict = {
harvestId: 'h1',
harvestedOn: '2026-08-10',
colonyId: 'c1',
treatmentId: 't1',
product: 'Oxuvar',
untilDay: '2026-08-20',
};
const envelope = <T extends DomainEventType>(
type: T,
aggregateId: string,
payload: Record<string, unknown>,
) => ({ type, aggregateId, payload }) as unknown as AnyDomainEventEnvelope<T>;
const treatmentRow = (overrides: Record<string, unknown> = {}) => ({
id: 't1',
colonyId: 'c1',
product: 'Oxuvar',
date: new Date('2026-07-31T00:00:00.000Z'),
startDate: null,
endDate: null,
withdrawalPeriodDays: 20,
colony: { code: 'B3', status: 'ACTIVE' },
...overrides,
});
describe('WithdrawalEventHandlers', () => {
let check: {
conflictsForHarvests: jest.Mock;
conflictsForHoneyBatch: jest.Mock;
conflictsForTreatment: jest.Mock;
notifyConflictsIn: jest.Mock;
};
let notifications: { recordForPermission: jest.Mock };
let tx: {
treatment: { findUnique: jest.Mock; findMany: jest.Mock };
automationRule: { findUnique: jest.Mock };
};
let handlers: WithdrawalEventHandlers;
beforeEach(() => {
check = {
conflictsForHarvests: jest.fn().mockResolvedValue([conflict]),
conflictsForHoneyBatch: jest.fn().mockResolvedValue([conflict]),
conflictsForTreatment: jest.fn().mockResolvedValue([conflict]),
notifyConflictsIn: jest.fn().mockResolvedValue(1),
};
notifications = { recordForPermission: jest.fn().mockResolvedValue([]) };
tx = {
treatment: {
findUnique: jest.fn().mockResolvedValue(treatmentRow()),
findMany: jest.fn().mockResolvedValue([]),
},
automationRule: { findUnique: jest.fn().mockResolvedValue(null) },
};
handlers = new WithdrawalEventHandlers(
check as unknown as WithdrawalCheckService,
notifications as unknown as NotificationsService,
);
});
const asTx = () => tx as unknown as Prisma.TransactionClient;
it('registers the three handlers under their ids', () => {
const entries = (method: keyof WithdrawalEventHandlers) =>
domainEventHandlerMetadata(
Object.getOwnPropertyDescriptor(
WithdrawalEventHandlers.prototype,
method,
)?.value as object,
);
expect(entries('harvestCheck')).toEqual([
{
types: ['HarvestRecorded', 'HarvestCorrected'],
handlerId: 'withdrawal.harvest-check',
},
]);
expect(entries('bottlingCheck')).toEqual([
{
types: ['BatchBottled', 'BottlingCorrected'],
handlerId: 'withdrawal.bottling-check',
},
]);
expect(entries('treatmentCheck')).toEqual([
{
types: ['TreatmentRecorded', 'TreatmentCorrected'],
handlerId: 'withdrawal.treatment-check',
},
]);
expect(entries('withdrawalEnded')).toEqual([
{
types: ['WithdrawalEnded'],
handlerId: 'notifications.withdrawal-ended',
},
]);
});
describe('harvest check', () => {
it.each(['HarvestRecorded', 'HarvestCorrected'] as const)(
'notifies the conflicts of the harvest on %s',
async (type) => {
await handlers.harvestCheck(
envelope(type, 'h1', { apiaryId: 'a1', colonyIds: ['c1'] }),
asTx(),
);
expect(check.conflictsForHarvests).toHaveBeenCalledWith(tx, ['h1']);
expect(check.notifyConflictsIn).toHaveBeenCalledWith(tx, [conflict]);
},
);
});
describe('treatment check', () => {
it.each(['TreatmentRecorded', 'TreatmentCorrected'] as const)(
'notifies harvests that already lie in the window of the treatment on %s',
async (type) => {
await handlers.treatmentCheck(
envelope(type, 't1', { colonyId: 'c1' }),
asTx(),
);
expect(check.conflictsForTreatment).toHaveBeenCalledWith(tx, 't1');
expect(check.notifyConflictsIn).toHaveBeenCalledWith(tx, [conflict]);
},
);
});
describe('bottling check', () => {
it('checks the harvests behind the honey batch on BatchBottled', async () => {
await handlers.bottlingCheck(
envelope('BatchBottled', 'bb1', { honeyBatchId: 'hb1' }),
asTx(),
);
expect(check.conflictsForHoneyBatch).toHaveBeenCalledWith(tx, 'hb1');
expect(check.notifyConflictsIn).toHaveBeenCalledWith(tx, [conflict]);
});
it('checks on a corrected bottling too', async () => {
await handlers.bottlingCheck(
envelope('BottlingCorrected', 'bb1', {
honeyBatchId: 'hb1',
removed: false,
}),
asTx(),
);
expect(check.notifyConflictsIn).toHaveBeenCalledTimes(1);
});
it('does nothing for a removed bottling', async () => {
await handlers.bottlingCheck(
envelope('BottlingCorrected', 'bb1', {
honeyBatchId: 'hb1',
removed: true,
}),
asTx(),
);
expect(check.conflictsForHoneyBatch).not.toHaveBeenCalled();
expect(check.notifyConflictsIn).not.toHaveBeenCalled();
});
});
describe('withdrawal ended', () => {
const ended = () =>
envelope('WithdrawalEnded', 't1', {
colonyId: 'c1',
endedOn: '2026-08-20',
});
it('tells readiness to harvest once, with colony, product and end day', async () => {
await handlers.withdrawalEnded(ended(), asTx());
expect(notifications.recordForPermission).toHaveBeenCalledWith(
['harvests:create', 'treatments:read'],
{
priority: 'INFO',
category: 'WITHDRAWAL',
message:
'B3 ist wieder erntefähig. Wartezeit nach Oxuvar endete am 20.08.2026.',
relatedType: 'Colony',
relatedId: 'c1',
link: '/colonies/c1',
dedupeKeyPrefix: 'withdrawal-ended:t1:2026-08-20',
},
tx,
);
});
it('stays quiet while another window of the colony still runs', async () => {
tx.treatment.findMany.mockResolvedValue([
treatmentRow({
id: 't2',
date: new Date('2026-08-10T00:00:00.000Z'),
withdrawalPeriodDays: 14,
}),
]);
await handlers.withdrawalEnded(ended(), asTx());
expect(notifications.recordForPermission).not.toHaveBeenCalled();
});
it('ignores a window of another treatment that has ended or not begun', async () => {
tx.treatment.findMany.mockResolvedValue([
// Ended on 2026-08-20 itself: the day is free again.
treatmentRow({
id: 't2',
date: new Date('2026-07-01T00:00:00.000Z'),
withdrawalPeriodDays: 50,
}),
// Starts after the end day.
treatmentRow({
id: 't3',
date: new Date('2026-08-21T00:00:00.000Z'),
withdrawalPeriodDays: 10,
}),
]);
await handlers.withdrawalEnded(ended(), asTx());
expect(notifications.recordForPermission).toHaveBeenCalledTimes(1);
});
it('reads the other treatments of the colony without the ended one', async () => {
await handlers.withdrawalEnded(ended(), asTx());
expect(tx.treatment.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
colonyId: 'c1',
id: { not: 't1' },
withdrawalPeriodDays: { not: null },
},
}),
);
});
it('is silent when the rule is switched off', async () => {
tx.automationRule.findUnique.mockResolvedValue({
id: 'r1',
active: false,
config: { notify: true },
actionTemplate: null,
});
await handlers.withdrawalEnded(ended(), asTx());
expect(notifications.recordForPermission).not.toHaveBeenCalled();
});
it('is silent when notify is off', async () => {
tx.automationRule.findUnique.mockResolvedValue({
id: 'r1',
active: true,
config: { notify: false },
actionTemplate: null,
});
await handlers.withdrawalEnded(ended(), asTx());
expect(notifications.recordForPermission).not.toHaveBeenCalled();
});
it('is silent for a colony that no longer exists as a living unit', async () => {
tx.treatment.findUnique.mockResolvedValue(
treatmentRow({ colony: { code: 'B3', status: 'DISSOLVED' } }),
);
await handlers.withdrawalEnded(ended(), asTx());
expect(notifications.recordForPermission).not.toHaveBeenCalled();
});
it('is silent when the treatment is gone or its window moved since the event', async () => {
tx.treatment.findUnique.mockResolvedValueOnce(null);
await handlers.withdrawalEnded(ended(), asTx());
tx.treatment.findUnique.mockResolvedValueOnce(
treatmentRow({ withdrawalPeriodDays: 25 }),
);
await handlers.withdrawalEnded(ended(), asTx());
expect(notifications.recordForPermission).not.toHaveBeenCalled();
});
});
});