feat: T4b/T4c wave 9 - phenology reports, autopilot A4-A10, honey ledger, Telegram delivery
Backend Image / build-and-push (push) Successful in 4m26s
Frontend Image / build-and-push (push) Successful in 7m19s
Manifests / validate (push) Successful in 17s

- Phenology reports API with corrections and revokes; recompute per
  apiary via PhenologyRecomputeRequested, allowBackward for corrections;
  season changes are announced in the morning digest (changes since the
  previous digest, each once)
- Season autopilot A4-A10 with per-apiary sync (Apiary before Colony
  locks) and WinterCheckRecorded
- Honey batch ledger at runtime: initial amount, BOTTLING bookings,
  manual movements, one gram rounding, untracked bottlings never book
- Telegram delivery: planning, Berlin delivery times incl. DST, quiet
  hours, permission re-check and stale skip at send time, rate limits,
  cleanup of old deliveries
- ARCHITECTURE.md: assumptions 47-50 and updated sections 11.x

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 21:13:23 +02:00
1 parent 1971b2d9c4
commit 16cc557462
74 files changed
+9634 -340

No files matched your search

@@ -0,0 +1,22 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { CreatePhenologyReportDto } from './create-phenology-report.dto';
const errorsFor = async (body: object): Promise<string[]> =>
(await validate(plainToInstance(CreatePhenologyReportDto, body))).map(
(error) => error.property,
);
describe('CreatePhenologyReportDto', () => {
const base = { phase: 'SALWEIDE', date: '2026-03-10' };
it.each([undefined, null, 'a1'])('accepts apiaryId %p', async (apiaryId) => {
await expect(errorsFor({ ...base, apiaryId })).resolves.toEqual([]);
});
it('rejects an empty apiaryId', async () => {
await expect(errorsFor({ ...base, apiaryId: '' })).resolves.toEqual([
'apiaryId',
]);
});
});
@@ -0,0 +1,43 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
} from 'class-validator';
import { PhenologyPhase } from '../../../generated/prisma';
export const MAX_NOTES_LENGTH = 500;
export class CreatePhenologyReportDto {
@ApiProperty({ enum: PhenologyPhase })
@IsEnum(PhenologyPhase)
phase!: PhenologyPhase;
@ApiProperty({
description: 'Day of the observation (YYYY-MM-DD), not after today.',
example: '2026-03-20',
})
@Matches(/^\d{4}-\d{2}-\d{2}$/, {
message: 'date must be a day in the format YYYY-MM-DD',
})
date!: string;
@ApiPropertyOptional({
nullable: true,
description:
'The apiary the report is for; null or omitted = all apiaries.',
})
@IsOptional()
@IsString()
@IsNotEmpty()
apiaryId?: string | null;
@ApiPropertyOptional({ nullable: true, maxLength: MAX_NOTES_LENGTH })
@IsOptional()
@IsString()
@MaxLength(MAX_NOTES_LENGTH)
notes?: string | null;
}
@@ -0,0 +1,13 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
export class ListPhenologyReportsQuery {
@ApiPropertyOptional({ description: 'Calendar year; default: this year.' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(2000)
@Max(2100)
year?: number;
}
@@ -0,0 +1,11 @@
import { PartialType } from '@nestjs/swagger';
import { CreatePhenologyReportDto } from './create-phenology-report.dto';
/**
* apiaryId null widens the report to all apiaries, notes null clears the
* notes; phase and date cannot be null (400).
*/
export class UpdatePhenologyReportDto extends PartialType(
CreatePhenologyReportDto,
{ skipNullProperties: false },
) {}
@@ -0,0 +1,66 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { berlinToday } from '../common/dates';
import { Idempotent } from '../common/idempotency/idempotent.decorator';
import { CreatePhenologyReportDto } from './dto/create-phenology-report.dto';
import { ListPhenologyReportsQuery } from './dto/list-phenology-reports.query';
import { UpdatePhenologyReportDto } from './dto/update-phenology-report.dto';
import { PhenologyService } from './phenology.service';
@ApiTags('phenology')
@ApiBearerAuth()
@Controller('phenology')
export class PhenologyController {
constructor(private readonly phenology: PhenologyService) {}
/** Season state of every active apiary; no coordinates. */
@RequirePermission('apiaries:read')
@Get('state')
state() {
return this.phenology.state();
}
@RequirePermission('phenology:read')
@Get('reports')
list(@Query() query: ListPhenologyReportsQuery) {
return this.phenology.list(query.year ?? Number(berlinToday().slice(0, 4)));
}
@RequirePermission('phenology:create')
@Idempotent()
@Post('reports')
create(
@Body() dto: CreatePhenologyReportDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.phenology.create(dto, actor.id);
}
@RequirePermission('phenology:update')
@Patch('reports/:id')
update(
@Param('id') id: string,
@Body() dto: UpdatePhenologyReportDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.phenology.update(id, dto, actor.id);
}
@RequirePermission('phenology:update')
@Idempotent()
@Post('reports/:id/revoke')
revoke(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.phenology.revoke(id, actor.id);
}
}
@@ -0,0 +1,257 @@
import type { Prisma } from '../../generated/prisma';
import type {
AnyDomainEventEnvelope,
DomainEventType,
} from '../domain-events/domain-event-types';
import type { DomainEventsService } from '../domain-events/domain-events.service';
import { domainEventHandlerMetadata } from '../domain-events/on-domain-event.decorator';
import { PhenologyEventHandlers } from './phenology.handlers';
import type { PhenologyStateService } from './phenology-state.service';
// 2026-04-01 in Berlin, but still 2026-03-31 in UTC: the event day is the
// Berlin day.
const OCCURRED_AT = new Date('2026-03-31T22:30:00.000Z');
const EVENT_DAY = '2026-04-01';
// Delivery runs later (the fake clock): the later of the two days counts.
const DELIVERY_DAY = '2026-04-09';
const envelope = <T extends DomainEventType>(
type: T,
aggregateId: string,
payload: Record<string, unknown>,
) =>
({
type,
aggregateId,
payload,
occurredAt: OCCURRED_AT,
}) as unknown as AnyDomainEventEnvelope<T>;
describe('PhenologyEventHandlers', () => {
beforeAll(() => {
// Delivery runs much later than the event.
jest.useFakeTimers({ now: new Date('2026-04-09T10:00:00Z') });
});
afterAll(() => {
jest.useRealTimers();
});
let state: { recomputeIn: jest.Mock };
let events: { recordAll: jest.Mock };
let tx: { apiary: { findMany: jest.Mock } };
let handlers: PhenologyEventHandlers;
const asTx = () => tx as unknown as Prisma.TransactionClient;
beforeEach(() => {
state = { recomputeIn: jest.fn().mockResolvedValue({ changed: false }) };
events = { recordAll: jest.fn().mockResolvedValue(2) };
tx = {
apiary: {
findMany: jest.fn().mockResolvedValue([{ id: 'a1' }, { id: 'a2' }]),
},
};
handlers = new PhenologyEventHandlers(
state as unknown as PhenologyStateService,
events as unknown as DomainEventsService,
);
});
it('registers the handlers under their ids and has no season notification', () => {
const entries = (method: keyof PhenologyEventHandlers) =>
domainEventHandlerMetadata(
Object.getOwnPropertyDescriptor(
PhenologyEventHandlers.prototype,
method,
)?.value as object,
);
expect(entries('recompute')).toEqual([
{
types: ['PhenologyReported', 'PhenologyCorrected'],
handlerId: 'phenology.recompute',
},
]);
expect(entries('recomputeApiary')).toEqual([
{
types: ['PhenologyRecomputeRequested'],
handlerId: 'phenology.recompute-apiary',
},
]);
expect(
Object.getOwnPropertyNames(PhenologyEventHandlers.prototype),
).toEqual(['constructor', 'recompute', 'recomputeApiary']);
});
describe('recompute', () => {
it('a report for one apiary recomputes that apiary on the delivery day', async () => {
tx.apiary.findMany.mockResolvedValue([{ id: 'a1' }]);
await handlers.recompute(
envelope('PhenologyReported', 'r1', { apiaryId: 'a1' }),
asTx(),
);
expect(tx.apiary.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { status: 'ACTIVE', id: { in: ['a1'] } },
}),
);
expect(state.recomputeIn).toHaveBeenCalledTimes(1);
expect(state.recomputeIn).toHaveBeenCalledWith(tx, 'a1', DELIVERY_DAY, {
allowBackward: false,
});
expect(events.recordAll).not.toHaveBeenCalled();
});
it('a report for all apiaries records one follow-up event per active apiary', async () => {
await handlers.recompute(
envelope('PhenologyReported', 'r1', { apiaryId: null }),
asTx(),
);
expect(state.recomputeIn).not.toHaveBeenCalled();
expect(tx.apiary.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { status: 'ACTIVE' },
orderBy: { id: 'asc' },
}),
);
expect(events.recordAll).toHaveBeenCalledWith(tx, [
{
type: 'PhenologyRecomputeRequested',
aggregateType: 'Apiary',
aggregateId: 'a1',
payload: { apiaryId: 'a1', allowBackward: false, day: DELIVERY_DAY },
},
{
type: 'PhenologyRecomputeRequested',
aggregateType: 'Apiary',
aggregateId: 'a2',
payload: { apiaryId: 'a2', allowBackward: false, day: DELIVERY_DAY },
},
]);
});
it('a delayed delivery never writes an older day than the event', async () => {
jest.setSystemTime(new Date('2026-03-30T10:00:00Z'));
try {
tx.apiary.findMany.mockResolvedValue([{ id: 'a1' }]);
await handlers.recompute(
envelope('PhenologyReported', 'r1', { apiaryId: 'a1' }),
asTx(),
);
expect(state.recomputeIn).toHaveBeenCalledWith(tx, 'a1', EVENT_DAY, {
allowBackward: false,
});
} finally {
jest.setSystemTime(new Date('2026-04-09T10:00:00Z'));
}
});
it('a correction lets the phase go back', async () => {
await handlers.recompute(
envelope('PhenologyCorrected', 'r1', {
apiaryId: 'a1',
previousApiaryId: 'a1',
}),
asTx(),
);
expect(state.recomputeIn).toHaveBeenCalledWith(tx, 'a1', DELIVERY_DAY, {
allowBackward: true,
});
});
it('a scope move recomputes the old and the new apiary only', async () => {
tx.apiary.findMany.mockResolvedValue([{ id: 'a1' }, { id: 'a2' }]);
await handlers.recompute(
envelope('PhenologyCorrected', 'r1', {
apiaryId: 'a2',
previousApiaryId: 'a1',
}),
asTx(),
);
expect(tx.apiary.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { status: 'ACTIVE', id: { in: ['a1', 'a2'] } },
}),
);
expect(
(state.recomputeIn.mock.calls as unknown[][]).map((call) => call[1]),
).toEqual(['a1', 'a2']);
});
it.each([
['widened to all', { apiaryId: null, previousApiaryId: 'a1' }],
['narrowed from all', { apiaryId: 'a1', previousApiaryId: null }],
])('a correction %s fans out to all apiaries', async (_name, payload) => {
await handlers.recompute(
envelope('PhenologyCorrected', 'r1', payload),
asTx(),
);
expect(state.recomputeIn).not.toHaveBeenCalled();
expect(events.recordAll).toHaveBeenCalledTimes(1);
const [, recorded] = events.recordAll.mock.calls[0] as [
unknown,
{ payload: { allowBackward: boolean } }[],
];
expect(recorded).toHaveLength(2);
expect(recorded[0].payload.allowBackward).toBe(true);
});
it('lets an error propagate so the delivery is retried', async () => {
state.recomputeIn.mockRejectedValue(new Error('boom'));
await expect(
handlers.recompute(
envelope('PhenologyReported', 'r1', { apiaryId: 'a1' }),
asTx(),
),
).rejects.toThrow('boom');
});
});
describe('recomputeApiary', () => {
it('recomputes the one apiary on the requested day', async () => {
tx.apiary.findMany.mockResolvedValue([{ id: 'a2' }]);
await handlers.recomputeApiary(
envelope('PhenologyRecomputeRequested', 'a2', {
apiaryId: 'a2',
allowBackward: true,
day: '2026-04-01',
}),
asTx(),
);
expect(state.recomputeIn).toHaveBeenCalledWith(tx, 'a2', '2026-04-01', {
allowBackward: true,
});
});
it('skips an apiary that is no longer active', async () => {
tx.apiary.findMany.mockResolvedValue([]);
await handlers.recomputeApiary(
envelope('PhenologyRecomputeRequested', 'a2', {
apiaryId: 'a2',
allowBackward: false,
day: '2026-04-01',
}),
asTx(),
);
expect(state.recomputeIn).not.toHaveBeenCalled();
expect(tx.apiary.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'a2', status: 'ACTIVE' },
}),
);
});
});
});
@@ -0,0 +1,97 @@
import { Injectable } from '@nestjs/common';
import type { Prisma } from '../../generated/prisma';
import { berlinDayOf, berlinToday } from '../common/dates';
import type { AnyDomainEventEnvelope } from '../domain-events/domain-event-types';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { OnDomainEvent } from '../domain-events/on-domain-event.decorator';
import { PhenologyStateService } from './phenology-state.service';
type ReportEvent = AnyDomainEventEnvelope<
'PhenologyReported' | 'PhenologyCorrected'
>;
/** The apiaries a report event touches; null = all of them. */
function scopeOf(event: ReportEvent): string[] | null {
const scopes =
event.type === 'PhenologyCorrected'
? [event.payload.apiaryId, event.payload.previousApiaryId]
: [event.payload.apiaryId];
if (scopes.includes(null)) {
return null;
}
return [...new Set(scopes as string[])].sort();
}
/**
* The reactions to plant development reports. Pure DB writers through the
* delivery transaction; errors propagate so a delivery is retried. A season
* change is not announced one by one: the morning digest carries it.
*/
@Injectable()
export class PhenologyEventHandlers {
constructor(
private readonly state: PhenologyStateService,
private readonly events: DomainEventsService,
) {}
/**
* A report (or its correction or withdrawal) recomputes the season state of
* the apiaries it touched, on the later of the event's day and today (Berlin). After a correction the
* phase may fall back. One or two apiaries are recomputed right here, in id
* order; for all apiaries one PhenologyRecomputeRequested per active apiary
* is recorded, so each apiary is recomputed (and retried) on its own.
*/
@OnDomainEvent(
['PhenologyReported', 'PhenologyCorrected'],
'phenology.recompute',
)
async recompute(
event: ReportEvent,
tx: Prisma.TransactionClient,
): Promise<void> {
// A delayed delivery must not write an older day than the event's own.
const day = [berlinDayOf(event.occurredAt), berlinToday()].sort()[1];
const allowBackward = event.type === 'PhenologyCorrected';
const scope = scopeOf(event);
const apiaries = await tx.apiary.findMany({
where:
scope === null
? { status: 'ACTIVE' }
: { status: 'ACTIVE', id: { in: scope } },
select: { id: true },
orderBy: { id: 'asc' },
});
if (scope === null) {
await this.events.recordAll(
tx,
apiaries.map(({ id }) => ({
type: 'PhenologyRecomputeRequested' as const,
aggregateType: 'Apiary' as const,
aggregateId: id,
payload: { apiaryId: id, allowBackward, day },
})),
);
return;
}
for (const { id } of apiaries) {
await this.state.recomputeIn(tx, id, day, { allowBackward });
}
}
/** One apiary of a fan-out; an apiary that is no longer active is skipped. */
@OnDomainEvent('PhenologyRecomputeRequested', 'phenology.recompute-apiary')
async recomputeApiary(
event: AnyDomainEventEnvelope<'PhenologyRecomputeRequested'>,
tx: Prisma.TransactionClient,
): Promise<void> {
const { apiaryId, allowBackward, day } = event.payload;
const active = await tx.apiary.findMany({
where: { id: apiaryId, status: 'ACTIVE' },
select: { id: true },
});
if (active.length === 0) {
return;
}
await this.state.recomputeIn(tx, apiaryId, day, { allowBackward });
}
}
@@ -1,17 +1,27 @@
import { Module } from '@nestjs/common';
import { DomainEventsModule } from '../domain-events/domain-events.module';
import { SeasonModule } from '../season/season.module';
import { PhenologyStateService } from './phenology-state.service';
import { PhenologyController } from './phenology.controller';
import { PhenologyEventHandlers } from './phenology.handlers';
import { PhenologyService } from './phenology.service';
import { PhenologyStep } from './phenology.step';
/**
* The season state of apiaries (ApiaryPhenology): the service that computes
* it and the first step of the daily evaluation. Imported by AppModule and
* WorkerModule (the reports of Task 28 recompute through the service) and by
* JobsModule (the step).
* it, the first step of the daily evaluation, the plant development reports
* (API) and the event handlers that recompute after a report. Imported by AppModule and WorkerModule (the handlers)
* and by JobsModule (the step).
*/
@Module({
imports: [DomainEventsModule],
providers: [PhenologyStateService, PhenologyStep],
imports: [DomainEventsModule, SeasonModule],
controllers: [PhenologyController],
providers: [
PhenologyStateService,
PhenologyStep,
PhenologyService,
PhenologyEventHandlers,
],
exports: [PhenologyStateService, PhenologyStep],
})
export class PhenologyModule {}
@@ -0,0 +1,455 @@
import {
BadRequestException,
ConflictException,
NotFoundException,
} from '@nestjs/common';
import type { AuditLogService } from '../audit/audit-log.service';
import type { DomainEventsService } from '../domain-events/domain-events.service';
import type { PrismaService } from '../prisma/prisma.service';
import { PhenologyService } from './phenology.service';
const day = (value: string) => new Date(`${value}T00:00:00.000Z`);
/** Prisma leaves out the fields that are undefined. */
const defined = (data: object): Record<string, unknown> =>
Object.fromEntries(
Object.entries(data).filter(([, value]) => value !== undefined),
);
const stored = (overrides: Record<string, unknown> = {}) => ({
id: 'r1',
apiaryId: 'a1',
phase: 'SALWEIDE',
date: day('2026-03-10'),
notes: null,
userId: 'u0',
revokedAt: null,
createdAt: day('2026-03-10'),
updatedAt: day('2026-03-10'),
...overrides,
});
const seasonState = (overrides: Record<string, unknown> = {}) => ({
apiaryId: 'a1',
year: 2026,
phase: 'SALWEIDE',
phaseSource: 'REPORT',
reportedOn: null,
segment: 'FRUEHJAHR',
segmentSince: '2026-03-10',
springSince: '2026-03-10',
gts: 210.5,
gtsThroughDay: '2026-03-31',
note: null,
...overrides,
});
describe('PhenologyService', () => {
beforeAll(() => {
jest.useFakeTimers({ now: new Date('2026-04-01T10:00:00Z') });
});
afterAll(() => {
jest.useRealTimers();
});
let service: PhenologyService;
let tx: {
$queryRaw: jest.Mock;
apiary: { findUnique: jest.Mock };
phenologyReport: {
create: jest.Mock;
findUnique: jest.Mock;
update: jest.Mock;
};
};
let prisma: {
$transaction: jest.Mock;
apiary: { findUnique: jest.Mock; findMany: jest.Mock };
phenologyReport: { findMany: jest.Mock };
};
let audit: { record: jest.Mock };
let events: { record: jest.Mock };
let season: { statesFor: jest.Mock };
beforeEach(() => {
tx = {
$queryRaw: jest.fn().mockResolvedValue([{ id: 'r1' }]),
apiary: { findUnique: jest.fn().mockResolvedValue({ id: 'a1' }) },
phenologyReport: {
create: jest
.fn()
.mockImplementation(({ data }: { data: object }) =>
Promise.resolve(stored(defined(data))),
),
findUnique: jest.fn().mockResolvedValue(stored()),
update: jest
.fn()
.mockImplementation(({ data }: { data: object }) =>
Promise.resolve(stored(defined(data))),
),
},
};
prisma = {
$transaction: jest
.fn()
.mockImplementation((work: (client: typeof tx) => Promise<unknown>) =>
work(tx),
),
apiary: {
findUnique: jest.fn().mockResolvedValue({ id: 'a1' }),
findMany: jest.fn().mockResolvedValue([]),
},
phenologyReport: { findMany: jest.fn().mockResolvedValue([]) },
};
audit = { record: jest.fn() };
events = { record: jest.fn() };
season = { statesFor: jest.fn().mockResolvedValue(new Map()) };
service = new PhenologyService(
prisma as unknown as PrismaService,
audit as unknown as AuditLogService,
events as unknown as DomainEventsService,
season,
);
});
describe('create', () => {
it('writes report, audit entry and PhenologyReported in one transaction', async () => {
await service.create(
{ phase: 'SALWEIDE', date: '2026-03-10', apiaryId: 'a1' },
'u1',
);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(tx.phenologyReport.create).toHaveBeenCalledWith({
data: {
phase: 'SALWEIDE',
date: day('2026-03-10'),
apiaryId: 'a1',
notes: null,
userId: 'u1',
},
});
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'PhenologyReport',
action: 'CREATE',
userId: 'u1',
}),
tx,
);
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'PhenologyReported',
aggregateType: 'PhenologyReport',
aggregateId: 'r1',
actorUserId: 'u1',
payload: {
apiaryId: 'a1',
phase: 'SALWEIDE',
reportedOn: '2026-03-10',
},
});
});
it('stores apiaryId null for all apiaries', async () => {
await service.create({ phase: 'KIRSCHE', date: '2026-03-31' }, 'u1');
expect(tx.phenologyReport.create).toHaveBeenCalledWith({
data: expect.objectContaining({ apiaryId: null }) as object,
});
expect(tx.apiary.findUnique).not.toHaveBeenCalled();
});
it('rejects a day in the future with the German message', async () => {
await expect(
service.create({ phase: 'SALWEIDE', date: '2026-04-02' }, 'u1'),
).rejects.toThrow(
new BadRequestException('Das Datum liegt in der Zukunft.'),
);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it('accepts today (Berlin)', async () => {
await expect(
service.create({ phase: 'SALWEIDE', date: '2026-04-01' }, 'u1'),
).resolves.toBeDefined();
});
it('rejects a day that does not exist', async () => {
await expect(
service.create({ phase: 'SALWEIDE', date: '2026-02-30' }, 'u1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('answers 404 for an unknown apiary', async () => {
tx.apiary.findUnique.mockResolvedValue(null);
await expect(
service.create(
{ phase: 'SALWEIDE', date: '2026-03-10', apiaryId: 'nope' },
'u1',
),
).rejects.toBeInstanceOf(NotFoundException);
});
});
describe('update', () => {
it('locks the report, reads before afterwards, audits with beforeData and records PhenologyCorrected', async () => {
await service.update('r1', { phase: 'KIRSCHE' }, 'u1');
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
tx.phenologyReport.findUnique.mock.invocationCallOrder[0],
);
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({
action: 'UPDATE',
entityId: 'r1',
beforeData: expect.objectContaining({ phase: 'SALWEIDE' }) as object,
}),
tx,
);
expect(events.record).toHaveBeenCalledWith(
tx,
expect.objectContaining({
type: 'PhenologyCorrected',
aggregateId: 'r1',
payload: {
apiaryId: 'a1',
previousApiaryId: 'a1',
phase: 'KIRSCHE',
reportedOn: '2026-03-10',
},
}),
);
});
it('apiaryId null widens the report to all apiaries; the event names both scopes', async () => {
await service.update('r1', { apiaryId: null }, 'u1');
expect(tx.phenologyReport.update).toHaveBeenCalledWith({
where: { id: 'r1' },
data: expect.objectContaining({ apiaryId: null }) as object,
});
expect(events.record).toHaveBeenCalledWith(
tx,
expect.objectContaining({
payload: expect.objectContaining({
apiaryId: null,
previousApiaryId: 'a1',
}) as object,
}),
);
});
it('moving the report to another apiary: the event names the old and the new one', async () => {
tx.apiary.findUnique.mockResolvedValue({ id: 'a2' });
await service.update('r1', { apiaryId: 'a2' }, 'u1');
expect(events.record).toHaveBeenCalledWith(
tx,
expect.objectContaining({
payload: expect.objectContaining({
apiaryId: 'a2',
previousApiaryId: 'a1',
}) as object,
}),
);
});
it('a patch that changes nothing writes no audit entry and no event', async () => {
const result = await service.update(
'r1',
{
phase: 'SALWEIDE',
date: '2026-03-10',
apiaryId: 'a1',
notes: null,
},
'u1',
);
expect(result).toEqual(stored());
expect(tx.phenologyReport.update).not.toHaveBeenCalled();
expect(audit.record).not.toHaveBeenCalled();
expect(events.record).not.toHaveBeenCalled();
});
it('an unknown new apiary answers 404 inside the transaction', async () => {
tx.apiary.findUnique.mockResolvedValue(null);
await expect(
service.update('r1', { apiaryId: 'nope' }, 'u1'),
).rejects.toBeInstanceOf(NotFoundException);
expect(tx.phenologyReport.update).not.toHaveBeenCalled();
});
it('notes null clears the notes', async () => {
tx.phenologyReport.findUnique.mockResolvedValue(
stored({ notes: 'früh' }),
);
await service.update('r1', { notes: null }, 'u1');
expect(tx.phenologyReport.update).toHaveBeenCalledWith({
where: { id: 'r1' },
data: expect.objectContaining({ notes: null }) as object,
});
});
it('404 when the report is gone', async () => {
tx.$queryRaw.mockResolvedValue([]);
tx.phenologyReport.findUnique.mockResolvedValue(null);
await expect(
service.update('r1', { phase: 'KIRSCHE' }, 'u1'),
).rejects.toBeInstanceOf(NotFoundException);
});
it('409 for a revoked report', async () => {
tx.phenologyReport.findUnique.mockResolvedValue(
stored({ revokedAt: day('2026-03-11') }),
);
await expect(
service.update('r1', { phase: 'KIRSCHE' }, 'u1'),
).rejects.toBeInstanceOf(ConflictException);
});
it('rejects a future date', async () => {
await expect(
service.update('r1', { date: '2026-05-01' }, 'u1'),
).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('revoke', () => {
it('sets revokedAt, audits with beforeData and records PhenologyCorrected', async () => {
await service.revoke('r1', 'u1');
expect(tx.phenologyReport.update).toHaveBeenCalledWith({
where: { id: 'r1' },
data: { revokedAt: expect.any(Date) as Date },
});
expect(audit.record).toHaveBeenCalledWith(
expect.objectContaining({
action: 'UPDATE',
beforeData: expect.objectContaining({ revokedAt: null }) as object,
}),
tx,
);
expect(events.record).toHaveBeenCalledWith(
tx,
expect.objectContaining({
type: 'PhenologyCorrected',
payload: expect.objectContaining({
apiaryId: 'a1',
previousApiaryId: 'a1',
}) as object,
}),
);
});
it('409 when already revoked', async () => {
tx.phenologyReport.findUnique.mockResolvedValue(
stored({ revokedAt: day('2026-03-11') }),
);
await expect(service.revoke('r1', 'u1')).rejects.toBeInstanceOf(
ConflictException,
);
expect(tx.phenologyReport.update).not.toHaveBeenCalled();
});
it('404 when unknown', async () => {
tx.$queryRaw.mockResolvedValue([]);
tx.phenologyReport.findUnique.mockResolvedValue(null);
await expect(service.revoke('r1', 'u1')).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
describe('list', () => {
it('returns the reports of the year with days, apiary name and revokedAt', async () => {
prisma.phenologyReport.findMany.mockResolvedValue([
{
...stored({ revokedAt: day('2026-03-12') }),
apiary: { name: 'Hang' },
user: { name: 'Imker' },
},
{
...stored({ id: 'r2', apiaryId: null }),
apiary: null,
user: { name: 'Imker' },
},
]);
const views = await service.list(2026);
expect(prisma.phenologyReport.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { date: { gte: day('2026-01-01'), lt: day('2027-01-01') } },
}),
);
expect(views).toEqual([
expect.objectContaining({
id: 'r1',
apiaryName: 'Hang',
date: '2026-03-10',
revokedAt: '2026-03-12T00:00:00.000Z',
userName: 'Imker',
}),
expect.objectContaining({
id: 'r2',
apiaryId: null,
apiaryName: null,
revokedAt: null,
}),
]);
});
});
describe('state', () => {
it('lists active apiaries with phase and segment, the report day, no coordinates', async () => {
prisma.apiary.findMany.mockResolvedValue([{ id: 'a1', name: 'Hang' }]);
season.statesFor.mockResolvedValue(new Map([['a1', seasonState()]]));
prisma.phenologyReport.findMany.mockResolvedValue([
stored({ date: day('2026-03-12') }),
stored({ id: 'r2', date: day('2026-03-10'), apiaryId: null }),
stored({ id: 'r3', phase: 'KIRSCHE', date: day('2026-03-01') }),
]);
const states = await service.state();
expect(prisma.apiary.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { status: 'ACTIVE' } }),
);
expect(states).toEqual([
{
apiaryId: 'a1',
apiaryName: 'Hang',
phase: 'SALWEIDE',
segment: 'FRUEHJAHR',
phaseSource: 'REPORT',
reportedOn: '2026-03-10',
gts: 210.5,
gtsThroughDay: '2026-03-31',
note: null,
},
]);
expect(JSON.stringify(states)).not.toMatch(/latitude|longitude/);
});
it('reportedOn is null unless the phase comes from a report', async () => {
prisma.apiary.findMany.mockResolvedValue([{ id: 'a1', name: 'Hang' }]);
season.statesFor.mockResolvedValue(
new Map([['a1', seasonState({ phaseSource: 'GTS' })]]),
);
const [state] = await service.state();
expect(state.reportedOn).toBeNull();
});
});
});
@@ -0,0 +1,340 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
PhaseSource,
PhenologyPhase,
PhenologyReport,
Prisma,
SeasonSegment,
} from '../../generated/prisma';
import { AuditLogService } from '../audit/audit-log.service';
import { berlinToday, dayToDate } from '../common/dates';
import { utcDay } from '../domain-events/domain-event-types';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate, lockRowsForShare } from '../prisma/row-lock';
import type { SeasonNote } from '../season/season-types';
import { SeasonService } from '../season/season.service';
import type { CreatePhenologyReportDto } from './dto/create-phenology-report.dto';
import type { UpdatePhenologyReportDto } from './dto/update-phenology-report.dto';
const FUTURE_MESSAGE = 'Das Datum liegt in der Zukunft.';
const NOT_FOUND_MESSAGE = 'Meldung nicht gefunden.';
const REVOKED_MESSAGE = 'Die Meldung wurde schon zurückgezogen.';
export interface PhenologyReportView {
id: string;
apiaryId: string | null;
/** null = the report counts for all apiaries. */
apiaryName: string | null;
phase: PhenologyPhase;
/** YYYY-MM-DD */
date: string;
notes: string | null;
userId: string;
userName: string;
revokedAt: string | null;
createdAt: string;
}
/** The season state of an apiary as the API shows it: never coordinates. */
export interface PhenologyApiaryState {
apiaryId: string;
apiaryName: string;
phase: PhenologyPhase | null;
segment: SeasonSegment;
phaseSource: PhaseSource | null;
reportedOn: string | null;
gts: number | null;
gtsThroughDay: string | null;
note: SeasonNote;
}
/** A report day must exist in the calendar and must not lie after today. */
function assertReportDay(day: string): Date {
let date: Date;
try {
date = dayToDate(day);
} catch {
throw new BadRequestException('Das Datum ist ungültig.');
}
if (day > berlinToday()) {
throw new BadRequestException(FUTURE_MESSAGE);
}
return date;
}
function yearRange(year: number): { gte: Date; lt: Date } {
return {
gte: dayToDate(`${year}-01-01`),
lt: dayToDate(`${year + 1}-01-01`),
};
}
/**
* The plant development reports of the beekeeper (first bloom of Salweide,
* Kirsche, ...). A report changes the season state of its apiary (or of all);
* the recomputation runs from the events (PhenologyEventHandlers).
*/
@Injectable()
export class PhenologyService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly events: DomainEventsService,
private readonly season: SeasonService,
) {}
async create(
dto: CreatePhenologyReportDto,
userId: string,
): Promise<PhenologyReport> {
const date = assertReportDay(dto.date);
const apiaryId = dto.apiaryId ?? null;
return this.prisma.$transaction(async (tx) => {
await this.assertApiaryIn(tx, apiaryId);
const created = await tx.phenologyReport.create({
data: {
phase: dto.phase,
date,
apiaryId,
notes: dto.notes ?? null,
userId,
},
});
await this.auditLog.record(
{
entityType: 'PhenologyReport',
entityId: created.id,
action: 'CREATE',
userId,
afterData: created,
},
tx,
);
await this.events.record(tx, {
type: 'PhenologyReported',
aggregateType: 'PhenologyReport',
aggregateId: created.id,
actorUserId: userId,
payload: {
apiaryId: created.apiaryId,
phase: created.phase,
reportedOn: utcDay(created.date),
},
});
return created;
});
}
async update(
id: string,
dto: UpdatePhenologyReportDto,
userId: string,
): Promise<PhenologyReport> {
const date = dto.date === undefined ? undefined : assertReportDay(dto.date);
return this.prisma.$transaction(async (tx) => {
// Lock order: Apiary before PhenologyReport (LOCKABLE_TABLES).
await this.assertApiaryIn(tx, dto.apiaryId ?? null);
const before = await this.lockActive(tx, id);
const next = {
phase: dto.phase ?? before.phase,
date: date ?? before.date,
apiaryId: dto.apiaryId === undefined ? before.apiaryId : dto.apiaryId,
notes: dto.notes === undefined ? before.notes : dto.notes,
};
if (
next.phase === before.phase &&
next.date.getTime() === before.date.getTime() &&
next.apiaryId === before.apiaryId &&
next.notes === before.notes
) {
return before;
}
const updated = await tx.phenologyReport.update({
where: { id },
data: {
phase: dto.phase,
date,
apiaryId: dto.apiaryId,
notes: dto.notes,
},
});
await this.auditLog.record(
{
entityType: 'PhenologyReport',
entityId: id,
action: 'UPDATE',
userId,
beforeData: before,
afterData: updated,
},
tx,
);
await this.events.record(tx, {
type: 'PhenologyCorrected',
aggregateType: 'PhenologyReport',
aggregateId: id,
actorUserId: userId,
payload: {
apiaryId: updated.apiaryId,
previousApiaryId: before.apiaryId,
phase: updated.phase,
reportedOn: utcDay(updated.date),
},
});
return updated;
});
}
/** Withdraws a report; the apiaries it counted for are recomputed. */
async revoke(id: string, userId: string): Promise<PhenologyReport> {
return this.prisma.$transaction(async (tx) => {
const before = await this.lockActive(tx, id);
const revoked = await tx.phenologyReport.update({
where: { id },
data: { revokedAt: new Date() },
});
await this.auditLog.record(
{
entityType: 'PhenologyReport',
entityId: id,
action: 'UPDATE',
userId,
beforeData: before,
afterData: revoked,
},
tx,
);
await this.events.record(tx, {
type: 'PhenologyCorrected',
aggregateType: 'PhenologyReport',
aggregateId: id,
actorUserId: userId,
payload: {
apiaryId: revoked.apiaryId,
previousApiaryId: revoked.apiaryId,
phase: revoked.phase,
reportedOn: utcDay(revoked.date),
},
});
return revoked;
});
}
/** All reports of a year, withdrawn ones included (with revokedAt). */
async list(year: number): Promise<PhenologyReportView[]> {
const rows = await this.prisma.phenologyReport.findMany({
where: { date: yearRange(year) },
include: {
apiary: { select: { name: true } },
user: { select: { name: true } },
},
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }],
});
return rows.map((row) => ({
id: row.id,
apiaryId: row.apiaryId,
apiaryName: row.apiary?.name ?? null,
phase: row.phase,
date: utcDay(row.date),
notes: row.notes,
userId: row.userId,
userName: row.user.name,
revokedAt: row.revokedAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
}));
}
/** The season state of every active apiary. Never coordinates. */
async state(): Promise<PhenologyApiaryState[]> {
const today = berlinToday();
const apiaries = await this.prisma.apiary.findMany({
where: { status: 'ACTIVE' },
select: { id: true, name: true },
orderBy: { name: 'asc' },
});
const states = await this.season.statesFor(
this.prisma,
apiaries.map((apiary) => apiary.id),
today,
);
const reports = await this.prisma.phenologyReport.findMany({
where: { revokedAt: null, date: yearRange(Number(today.slice(0, 4))) },
select: { apiaryId: true, phase: true, date: true },
});
return apiaries.flatMap((apiary) => {
const state = states.get(apiary.id);
if (state === undefined) {
return [];
}
const reportedOn =
state.phaseSource === 'REPORT'
? (reports
.filter(
(report) =>
report.phase === state.phase &&
(report.apiaryId === null || report.apiaryId === apiary.id),
)
.map((report) => utcDay(report.date))
.sort()[0] ?? null)
: null;
return [
{
apiaryId: apiary.id,
apiaryName: apiary.name,
phase: state.phase,
segment: state.segment,
phaseSource: state.phaseSource,
reportedOn,
gts: state.gts,
gtsThroughDay: state.gtsThroughDay,
note: state.note,
},
];
});
}
/** Locks the report and reads it afterwards: 404 if gone, 409 if withdrawn. */
private async lockActive(
tx: Prisma.TransactionClient,
id: string,
): Promise<PhenologyReport> {
const exists = await lockRowForUpdate(tx, 'PhenologyReport', id);
const before = exists
? await tx.phenologyReport.findUnique({ where: { id } })
: null;
if (before === null) {
throw new NotFoundException(NOT_FOUND_MESSAGE);
}
if (before.revokedAt !== null) {
throw new ConflictException(REVOKED_MESSAGE);
}
return before;
}
/**
* 404 for an unknown apiary; the share lock keeps it from being deleted
* before the report that points at it is written.
*/
private async assertApiaryIn(
tx: Prisma.TransactionClient,
apiaryId: string | null,
): Promise<void> {
if (apiaryId === null) {
return;
}
await lockRowsForShare(tx, 'Apiary', [apiaryId]);
const apiary = await tx.apiary.findUnique({
where: { id: apiaryId },
select: { id: true },
});
if (apiary === null) {
throw new NotFoundException('Stand nicht gefunden.');
}
}
}