feat: T4b/T4c wave 9 - phenology reports, autopilot A4-A10, honey ledger, Telegram delivery
- 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:
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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user