Files
ApiCore/ApiCore-Backend/src/compliance/authority-notices.service.ts
T
Nick SzaunigandClaude Opus 5.5 e712c5d4d1
Backend Image / build-and-push (push) Successful in 6m39s
Frontend Image / build-and-push (push) Successful in 14m30s
fix: T6 browser-check findings (compliance, treatments, labels)
- api-client accepts 2xx answers without body (revoke showed an error)
- authority notices: sentOn in the future is rejected (Berlin day) on
  create, mark-sent and PATCH; dialog defaults to the Berlin day
- service worker never caches /api/v1/site-settings
- treatments/new no longer overflows at 390 px (min-w-0 on form
  wrappers); dose with more than 3 decimals is an error, never rounded
- medicines: expired lots flagged, disposal over the rest warns
- label designer: side panel at lg, sample values instead of
  placeholders, compliance panel reopens on lg, labelled zoom buttons
- skip link to #main-content, headings on no-access pages

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-15 18:34:52 +02:00

469 lines
14 KiB
TypeScript

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:<year>": 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<AuthorityNotice[]> {
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<AuthorityNotice> {
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<AuthorityNotice | null> {
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<AuthorityNotice> {
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<AuthorityNotice> {
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<AuthorityNotice> {
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<AuthorityNotice> {
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<AuthorityNotice> {
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<AuthorityNotice> {
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<AuthorityNotice> {
// 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<AuthorityNotice> {
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<void> {
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<NoticeUpdate>,
): Promise<AuthorityNotice> {
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;
});
}
}