- 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]>
469 lines
14 KiB
TypeScript
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;
|
|
});
|
|
}
|
|
}
|