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 { 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 { 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 { 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 { 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 { 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 { 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 { 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.'); } } }