import { BadRequestException, ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; import { AuthorityNotice, AuthorityNoticeKind, Prisma, } from '../../generated/prisma'; import { AuditLogService } from '../audit/audit-log.service'; import { berlinToday, dayToDate } from '../common/dates'; import { optionalDate } from '../common/patch-values'; import { PrismaService } from '../prisma/prisma.service'; import { lockRowForUpdate } from '../prisma/row-lock'; import { CreateAuthorityNoticeDto } from './dto/create-authority-notice.dto'; import { MarkSentDto } from './dto/mark-sent.dto'; import { UpdateAuthorityNoticeDto } from './dto/update-authority-notice.dto'; import { buildHerdSnapshot, loadHerdApiaryRows } from './herd-snapshot'; import { COMPLIANCE_SETTINGS_ID, complianceSettingsIn, findBaselineIn, } from './herd-status'; import { buildNoticePdfModel } from './pdf/authority-notice-model'; import { renderAuthorityNoticePdf } from './pdf/authority-notice-pdf'; const AUDIT_ENTITY = 'AuthorityNotice'; const LIST_LIMIT = 200; const UNIQUE_VIOLATION = 'P2002'; type NoticeUpdate = Prisma.AuthorityNoticeUncheckedUpdateInput; interface DraftInput { kind: AuthorityNoticeKind; year?: number; referenceDate: Date; } interface TransitionOptions { /** Takes the ComplianceSettings mutex first (the open key is involved). */ mutex?: boolean; } /** "REGISTRATION" | "HERD_CHANGE" | "TSK_REPORT:": at most one draft each. */ function openKeyOf(kind: AuthorityNoticeKind, year: number | null): string { return kind === 'TSK_REPORT' ? `TSK_REPORT:${year}` : kind; } function trimmedOrNull( value: string | null | undefined, ): string | null | undefined { if (value === undefined) { return undefined; } return value?.trim() || null; } function notAllowed(message: string): ConflictException { return new ConflictException({ statusCode: 409, message }); } function draftExists(existingId: string): ConflictException { return new ConflictException({ statusCode: 409, code: 'DRAFT_EXISTS', message: 'Es gibt schon einen offenen Entwurf dieser Anzeige.', existingId, }); } function isUniqueViolation(error: unknown): boolean { return ( error instanceof Prisma.PrismaClientKnownRequestError && error.code === UNIQUE_VIOLATION ); } function assertYearFits(kind: AuthorityNoticeKind, year?: number): void { if (kind === 'TSK_REPORT' && year === undefined) { throw new BadRequestException('Die TSK-Zählung braucht ein Jahr'); } if (kind !== 'TSK_REPORT' && year !== undefined) { throw new BadRequestException('Ein Jahr gibt es nur bei der TSK-Zählung'); } } /** A notice cannot have been sent tomorrow: sentOn must not lie after the Berlin day. */ function futureSentOnError( sentOn: string | null | undefined, ): BadRequestException | undefined { return sentOn && sentOn > berlinToday() ? new BadRequestException( 'Das Sendedatum darf nicht in der Zukunft liegen.', ) : undefined; } @Injectable() export class AuthorityNoticesService { constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, ) {} findAll(q: { kind?: AuthorityNoticeKind; year?: number; }): Promise { return this.prisma.authorityNotice.findMany({ where: { kind: q.kind, year: q.year }, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], take: LIST_LIMIT, }); } async findOne(id: string): Promise { const notice = await this.prisma.authorityNotice.findUnique({ where: { id }, }); if (!notice) { throw new NotFoundException('Anzeige nicht gefunden'); } return notice; } /** * The PDF of a notice. A change notice compares with the notice that was * the baseline when it was (or would be) sent. The file name carries the * id only, never user input. */ async pdf(id: string): Promise<{ buffer: Buffer; fileName: string }> { const notice = await this.findOne(id); const [site, settings, baseline] = await Promise.all([ this.prisma.siteSettings.findUnique({ where: { id: 'singleton' } }), complianceSettingsIn(this.prisma), notice.kind === 'HERD_CHANGE' ? this.baselineFor(notice) : Promise.resolve(null), ]); const model = buildNoticePdfModel({ notice, baseline, site: { holderName: site?.holderName ?? null, holderAddress: site?.holderAddress ?? null, }, settings, now: new Date(), }); return { buffer: await renderAuthorityNoticePdf(model), fileName: `anzeige-${notice.id}.pdf`, }; } /** A sent notice compares with the one sent before it, a draft with the newest. */ private baselineFor( notice: AuthorityNotice, ): Promise { if (notice.status !== 'SENT' || !notice.sentOn) { return findBaselineIn(this.prisma); } return this.prisma.authorityNotice.findFirst({ where: { status: 'SENT', kind: { in: ['REGISTRATION', 'HERD_CHANGE'] }, id: { not: notice.id }, OR: [ { sentOn: { lt: notice.sentOn } }, { sentOn: notice.sentOn, createdAt: { lt: notice.createdAt }, }, ], }, orderBy: [{ sentOn: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }], }); } /** * Creates a draft, or a SENT notice at once when `sentOn` is given (the * way to record what was already reported). A second open draft of the * same kind and year answers 409 DRAFT_EXISTS with the id of the first. */ async create( dto: CreateAuthorityNoticeDto, actingUserId: string, ): Promise { assertYearFits(dto.kind, dto.year); const futureError = futureSentOnError(dto.sentOn); if (futureError) { throw futureError; } const input = { kind: dto.kind, year: dto.year, referenceDate: dayToDate(dto.referenceDate ?? berlinToday()), sentOn: dto.sentOn ? dayToDate(dto.sentOn) : undefined, }; try { return await this.prisma.$transaction((tx) => this.insertIn(tx, input, actingUserId), ); } catch (error) { if (!isUniqueViolation(error)) { throw error; } const winner = await this.prisma.authorityNotice.findFirst({ where: { openKey: openKeyOf(dto.kind, dto.year ?? null) }, select: { id: true }, }); if (winner) { throw draftExists(winner.id); } throw error; } } /** The shared core of create: the daily TSK job calls it in its own transaction. */ createDraftIn( tx: Prisma.TransactionClient, input: DraftInput, userId: string | null, ): Promise { assertYearFits(input.kind, input.year); return this.insertIn(tx, input, userId); } /** Replaces the herd picture and the total; only a draft can be recounted. */ recount(id: string, actingUserId: string): Promise { return this.transition(id, actingUserId, {}, async (tx, before) => { if (before.status !== 'DRAFT') { throw notAllowed('Nur ein Entwurf kann neu gezählt werden.'); } const countedAt = new Date(); const snapshot = await this.countIn(tx, before.kind, countedAt); return { snapshot: snapshot as unknown as Prisma.InputJsonValue, totalColonies: snapshot.totalLiving, countedAt, }; }); } markSent( id: string, dto: MarkSentDto, actingUserId: string, ): Promise { const futureError = futureSentOnError(dto.sentOn); if (futureError) { return Promise.reject(futureError); } return this.transition(id, actingUserId, { mutex: true }, (_tx, before) => { if (before.status !== 'DRAFT') { return Promise.reject( notAllowed('Nur ein Entwurf kann als gesendet markiert werden.'), ); } return Promise.resolve({ status: 'SENT', sentOn: dayToDate(dto.sentOn ?? berlinToday()), sentVia: trimmedOrNull(dto.sentVia), reference: trimmedOrNull(dto.reference), openKey: null, }); }); } reopen(id: string, actingUserId: string): Promise { return this.transition( id, actingUserId, { mutex: true }, async (tx, before) => { if (before.status !== 'SENT') { throw notAllowed('Nur eine gesendete Anzeige kann geöffnet werden.'); } const openKey = openKeyOf(before.kind, before.year); const other = await tx.authorityNotice.findFirst({ where: { openKey }, select: { id: true }, }); if (other) { throw draftExists(other.id); } return { status: 'DRAFT', openKey, sentOn: null }; }, ); } cancel(id: string, actingUserId: string): Promise { return this.transition(id, actingUserId, {}, (_tx, before) => { if (before.status !== 'DRAFT') { return Promise.reject( notAllowed('Nur ein Entwurf kann verworfen werden.'), ); } return Promise.resolve({ status: 'CANCELLED', openKey: null }); }); } update( id: string, dto: UpdateAuthorityNoticeDto, actingUserId: string, ): Promise { // A new sent day can change which notice is the baseline: the mutex // comes first, in lock order, whenever the sent day is touched. const futureError = futureSentOnError(dto.sentOn); if (futureError) { return Promise.reject(futureError); } const mutex = dto.sentOn !== undefined; return this.transition(id, actingUserId, { mutex }, (_tx, before) => { if (before.status === 'CANCELLED') { return Promise.reject( notAllowed('Eine verworfene Anzeige kann nicht geändert werden.'), ); } if (before.status === 'DRAFT' && dto.sentOn != null) { return Promise.reject( new BadRequestException('Ein Entwurf hat kein Sendedatum.'), ); } if (before.status === 'SENT' && dto.sentOn === null) { return Promise.reject( new BadRequestException( 'Eine gesendete Anzeige braucht ihr Sendedatum', ), ); } return Promise.resolve({ totalColonies: dto.totalColonies, referenceDate: dto.referenceDate ? dayToDate(dto.referenceDate) : undefined, sentOn: optionalDate(dto.sentOn), sentVia: trimmedOrNull(dto.sentVia), reference: trimmedOrNull(dto.reference), notes: trimmedOrNull(dto.notes), }); }); } private async countIn( tx: Prisma.TransactionClient, kind: AuthorityNoticeKind, countedAt: Date, ) { const settings = await tx.complianceSettings.findUniqueOrThrow({ where: { id: COMPLIANCE_SETTINGS_ID }, }); return buildHerdSnapshot( await loadHerdApiaryRows(tx), countedAt, kind === 'TSK_REPORT' ? settings.tskExcludedColonyTypes : [], ); } private async insertIn( tx: Prisma.TransactionClient, input: DraftInput & { sentOn?: Date }, userId: string | null, ): Promise { await this.lockSettingsIn(tx); const baseline = await findBaselineIn(tx); if (input.kind === 'REGISTRATION' && baseline) { throw notAllowed('Die Erstanzeige ist schon erfasst.'); } if (input.kind === 'HERD_CHANGE' && !baseline) { throw notAllowed('Erst die Erstanzeige erfassen.'); } const openKey = openKeyOf(input.kind, input.year ?? null); // A notice recorded as already sent has no open key and cannot collide. const draft = input.sentOn ? null : await tx.authorityNotice.findFirst({ where: { openKey }, select: { id: true }, }); if (draft) { throw draftExists(draft.id); } const countedAt = new Date(); const snapshot = await this.countIn(tx, input.kind, countedAt); const created = await tx.authorityNotice.create({ data: { kind: input.kind, status: input.sentOn ? 'SENT' : 'DRAFT', year: input.year ?? null, referenceDate: input.referenceDate, countedAt, snapshot: snapshot as unknown as Prisma.InputJsonValue, totalColonies: snapshot.totalLiving, sentOn: input.sentOn ?? null, openKey: input.sentOn ? null : openKey, createdByUserId: userId, }, }); await this.auditLog.record( { entityType: AUDIT_ENTITY, entityId: created.id, action: 'CREATE', userId: userId ?? undefined, afterData: created, }, tx, ); return created; } /** The ComplianceSettings row is the mutex of everything around the open key. */ private async lockSettingsIn(tx: Prisma.TransactionClient): Promise { await tx.complianceSettings.upsert({ where: { id: COMPLIANCE_SETTINGS_ID }, update: {}, create: { id: COMPLIANCE_SETTINGS_ID }, }); await lockRowForUpdate(tx, 'ComplianceSettings', COMPLIANCE_SETTINGS_ID); } /** * One state change: lock (settings mutex first when asked, then the notice * row), read "before" under the lock, check the transition, write, audit. */ private async transition( id: string, actingUserId: string, options: TransitionOptions, change: ( tx: Prisma.TransactionClient, before: AuthorityNotice, ) => Promise, ): Promise { return this.prisma.$transaction(async (tx) => { if (options.mutex) { await this.lockSettingsIn(tx); } if (!(await lockRowForUpdate(tx, 'AuthorityNotice', id))) { throw new NotFoundException('Anzeige nicht gefunden'); } const before = await tx.authorityNotice.findUnique({ where: { id } }); if (!before) { throw new NotFoundException('Anzeige nicht gefunden'); } const after = await tx.authorityNotice.update({ where: { id }, data: await change(tx, before), }); await this.auditLog.record( { entityType: AUDIT_ENTITY, entityId: id, action: 'UPDATE', userId: actingUserId, beforeData: before, afterData: after, }, tx, ); return after; }); } }