import { BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import { ApiaryRestriction } from '../../generated/prisma'; import { AuditLogService } from '../audit/audit-log.service'; import { berlinToday, dayToDate } from '../common/dates'; import { optionalDate } from '../common/patch-values'; import { ActiveRestrictionInfo, isRestrictionActive, startedRestrictions, toActiveRestrictionInfo, } from '../compliance/restricted-zone'; import { PrismaService } from '../prisma/prisma.service'; import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock'; import { CreateApiaryRestrictionDto, MAX_RESTRICTION_APIARIES, } from './dto/create-apiary-restriction.dto'; import { UpdateApiaryRestrictionDto } from './dto/update-apiary-restriction.dto'; const AUDIT_ENTITY = 'ApiaryRestriction'; /** What a reader without compliance:read sees: no authority, reference, notes or creator. */ export interface RestrictionSummary { id: string; apiaryId: string; disease: string; startsOn: string; endsOn: string | null; } function toSummary(row: ApiaryRestriction): RestrictionSummary { return { id: row.id, apiaryId: row.apiaryId, disease: row.disease, startsOn: row.startsOn.toISOString().slice(0, 10), endsOn: row.endsOn ? row.endsOn.toISOString().slice(0, 10) : null, }; } function assertEndNotBeforeStart(startsOn: Date, endsOn: Date | null): void { if (endsOn !== null && endsOn < startsOn) { throw new BadRequestException( 'Das Ende der Sperre darf nicht vor ihrem Beginn liegen', ); } } /** * Restricted zones (Sperrbezirke) of apiaries. A restriction is never deleted: * it ends by an endsOn date, so the colony history stays explainable. * * Lock order (Task 1): the apiary rows FOR UPDATE first, then the restriction * row. A colony path holds its apiaries FOR SHARE until it commits, so a * restriction and a move into that apiary queue up behind each other. */ @Injectable() export class ApiaryRestrictionsService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, ) {} /** One row per apiary, all in one transaction; an unknown apiary rolls all back. */ async create( dto: CreateApiaryRestrictionDto, actingUserId: string, ): Promise { const apiaryIds = [...new Set(dto.apiaryIds)].sort(); if (apiaryIds.length < 1 || apiaryIds.length > MAX_RESTRICTION_APIARIES) { throw new BadRequestException( `Es sind 1 bis ${MAX_RESTRICTION_APIARIES} Stände möglich`, ); } const startsOn = dayToDate(dto.startsOn); const endsOn = dto.endsOn ? dayToDate(dto.endsOn) : null; assertEndNotBeforeStart(startsOn, endsOn); return this.prisma.$transaction(async (tx) => { await lockRowsForUpdate(tx, 'Apiary', apiaryIds); const known = await tx.apiary.findMany({ where: { id: { in: apiaryIds } }, select: { id: true }, }); if (known.length !== apiaryIds.length) { throw new NotFoundException('Bienenstand nicht gefunden'); } const created: ApiaryRestriction[] = []; for (const apiaryId of apiaryIds) { const restriction = await tx.apiaryRestriction.create({ data: { apiaryId, disease: dto.disease, authority: dto.authority, reference: dto.reference, startsOn, endsOn, notes: dto.notes, createdByUserId: actingUserId, }, }); await this.auditLog.record( { entityType: AUDIT_ENTITY, entityId: restriction.id, action: 'CREATE', userId: actingUserId, afterData: restriction, }, tx, ); created.push(restriction); } return created; }); } async update( id: string, dto: UpdateApiaryRestrictionDto, actingUserId: string, ): Promise { const seen = await this.prisma.apiaryRestriction.findUnique({ where: { id }, select: { apiaryId: true }, }); if (!seen) { throw new NotFoundException('Sperrbezirk nicht gefunden'); } return this.prisma.$transaction(async (tx) => { await lockRowsForUpdate(tx, 'Apiary', [seen.apiaryId]); await lockRowForUpdate(tx, 'ApiaryRestriction', id); const before = await tx.apiaryRestriction.findUnique({ where: { id } }); if (!before) { throw new NotFoundException('Sperrbezirk nicht gefunden'); } const startsOnPatch = dto.startsOn === undefined ? undefined : dayToDate(dto.startsOn); const startsOn = startsOnPatch ?? before.startsOn; const endsOn = optionalDate(dto.endsOn); assertEndNotBeforeStart( startsOn, endsOn === undefined ? before.endsOn : endsOn, ); const updated = await tx.apiaryRestriction.update({ where: { id }, data: { disease: dto.disease, authority: dto.authority, reference: dto.reference, startsOn: startsOnPatch, endsOn, notes: dto.notes, }, }); await this.auditLog.record( { entityType: AUDIT_ENTITY, entityId: id, action: 'UPDATE', userId: actingUserId, beforeData: before, afterData: updated, }, tx, ); return updated; }); } /** The restrictions in force today (Berlin day), with the apiary name. */ async findActive(): Promise { const today = berlinToday(); const started = await startedRestrictions(this.prisma, undefined, today); return started .filter((row) => isRestrictionActive(row, today)) .map(toActiveRestrictionInfo); } /** * Every restriction (newest first); `active` narrows to in force / ended. * Authority, file reference, notes and the creator are for callers with * compliance:read (`detailed`); everybody else gets the summary. */ async list( active?: boolean, detailed = true, ): Promise< ApiaryRestriction[] | ActiveRestrictionInfo[] | RestrictionSummary[] > { if (active === true) { return this.findActive(); } const rows = await this.prisma.apiaryRestriction.findMany({ orderBy: [{ startsOn: 'desc' }, { id: 'asc' }], }); const wanted = active === false ? rows.filter((row) => !isRestrictionActive(row, berlinToday())) : rows; return detailed ? wanted : wanted.map(toSummary); } async findForApiary( apiaryId: string, detailed = true, ): Promise { const apiary = await this.prisma.apiary.findUnique({ where: { id: apiaryId }, select: { id: true }, }); if (!apiary) { throw new NotFoundException('Bienenstand nicht gefunden'); } const rows = await this.prisma.apiaryRestriction.findMany({ where: { apiaryId }, orderBy: [{ startsOn: 'desc' }, { id: 'asc' }], }); return detailed ? rows : rows.map(toSummary); } }