feat: T6 wave 3 – treatment books from lot, label print lock, restricted zone, authority notices
Backend Image / build-and-push (push) Successful in 5m13s
Frontend Image / build-and-push (push) Successful in 8m54s

- Treatments book their dose from a medicine lot (delta booking on edit,
  bulk all-or-nothing, idempotent); Bestandsbuch columns from the lot,
  FEHLT exactly where treatmentLogGaps reports a gap; Bestandsbuch export
  needs veterinary-medicines:read.
- Label printing: 409 LABEL_PARTICULARS_MISSING on gaps, proof print with
  watermark, PRINT audit before the PDF, check endpoints; digit rules only
  from the seeded ComplianceSettings row (fail closed).
- Restricted zone (Berlin day): create, update, move, merge and CSV import
  blocked inside an active restriction; override with reason and
  compliance:manage, audited, never stored on the colony; split, harvest
  and dissolve stay free; restriction details only with compliance:read.
- Authority notices: settings, herd snapshot, registration/herd change/TSK
  notices with one open draft per kind under the settings lock.
- Label check refinements (geometric reading order, number|unit pairs).
- ARCHITECTURE.md §12.6–12.8, Annahmen 71–77; spec §5.6 Berlin day.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-15 14:18:41 +02:00
1 parent c18860c75c
commit edc193f128
73 files changed
+9470 -171

No files matched your search

@@ -0,0 +1,86 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { Idempotent } from '../common/idempotency/idempotent.decorator';
import { AuthorityNoticesService } from './authority-notices.service';
import { CreateAuthorityNoticeDto } from './dto/create-authority-notice.dto';
import { MarkSentDto } from './dto/mark-sent.dto';
import { NoticeListQuery } from './dto/notice-list-query.dto';
import { UpdateAuthorityNoticeDto } from './dto/update-authority-notice.dto';
@ApiTags('authority-notices')
@ApiBearerAuth()
@Controller('authority-notices')
export class AuthorityNoticesController {
constructor(private readonly notices: AuthorityNoticesService) {}
@RequirePermission('compliance:read')
@Get()
findAll(@Query() query: NoticeListQuery) {
return this.notices.findAll(query);
}
@RequirePermission('compliance:read')
@Get(':id')
findOne(@Param('id') id: string) {
return this.notices.findOne(id);
}
@RequirePermission('compliance:manage')
@Idempotent()
@Post()
create(
@Body() dto: CreateAuthorityNoticeDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.notices.create(dto, actor.id);
}
@RequirePermission('compliance:manage')
@Patch(':id')
update(
@Param('id') id: string,
@Body() dto: UpdateAuthorityNoticeDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.notices.update(id, dto, actor.id);
}
@RequirePermission('compliance:manage')
@Post(':id/recount')
recount(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.notices.recount(id, actor.id);
}
@RequirePermission('compliance:manage')
@Post(':id/mark-sent')
markSent(
@Param('id') id: string,
@Body() dto: MarkSentDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.notices.markSent(id, dto, actor.id);
}
@RequirePermission('compliance:manage')
@Post(':id/reopen')
reopen(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.notices.reopen(id, actor.id);
}
@RequirePermission('compliance:manage')
@Post(':id/cancel')
cancel(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.notices.cancel(id, actor.id);
}
}
@@ -0,0 +1,579 @@
import {
BadRequestException,
ConflictException,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '../../generated/prisma';
import { AuditLogService } from '../audit/audit-log.service';
import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate } from '../prisma/row-lock';
import { AuthorityNoticesService } from './authority-notices.service';
jest.mock('../prisma/row-lock', () => ({
lockRowForUpdate: jest.fn().mockResolvedValue(true),
}));
const lockMock = lockRowForUpdate as jest.Mock;
const SETTINGS = {
id: 'singleton',
tskExcludedColonyTypes: [] as string[],
};
function notice(patch: Record<string, unknown> = {}) {
return {
id: 'n1',
kind: 'HERD_CHANGE',
status: 'DRAFT',
year: null,
referenceDate: new Date('2027-01-01T00:00:00.000Z'),
countedAt: new Date('2027-01-01T10:00:00.000Z'),
snapshot: { version: 1, apiaries: [], totalLiving: 2 },
totalColonies: 2,
sentOn: null,
sentVia: null,
reference: null,
notes: null,
openKey: 'HERD_CHANGE',
createdByUserId: 'u1',
createdAt: new Date(0),
updatedAt: new Date(0),
...patch,
};
}
function p2002(): Prisma.PrismaClientKnownRequestError {
return new Prisma.PrismaClientKnownRequestError('unique', {
code: 'P2002',
clientVersion: 'test',
});
}
describe('AuthorityNoticesService', () => {
let service: AuthorityNoticesService;
let tx: {
complianceSettings: { upsert: jest.Mock; findUniqueOrThrow: jest.Mock };
authorityNotice: {
findFirst: jest.Mock;
findUnique: jest.Mock;
findMany: jest.Mock;
create: jest.Mock;
update: jest.Mock;
};
apiary: { findMany: jest.Mock };
};
let prisma: {
$transaction: jest.Mock;
authorityNotice: { findFirst: jest.Mock; findMany: jest.Mock };
};
let auditLog: { record: jest.Mock };
// The baseline lookup and the open-draft lookup both use findFirst: the
// helper answers by the where clause.
function stubLookups(opts: {
baseline?: unknown;
openDraft?: unknown;
}): void {
tx.authorityNotice.findFirst.mockImplementation(
({ where }: { where: { openKey?: string; status?: string } }) =>
Promise.resolve(
where.openKey !== undefined
? (opts.openDraft ?? null)
: (opts.baseline ?? null),
),
);
}
beforeEach(() => {
lockMock.mockReset().mockResolvedValue(true);
tx = {
complianceSettings: {
upsert: jest.fn().mockResolvedValue(SETTINGS),
findUniqueOrThrow: jest.fn().mockResolvedValue(SETTINGS),
},
authorityNotice: {
findFirst: jest.fn().mockResolvedValue(null),
findUnique: jest.fn().mockResolvedValue(notice()),
findMany: jest.fn().mockResolvedValue([]),
create: jest
.fn()
.mockImplementation(({ data }: { data: object }) =>
Promise.resolve(notice(data as Record<string, unknown>)),
),
update: jest
.fn()
.mockImplementation(({ data }: { data: object }) =>
Promise.resolve(notice(data as Record<string, unknown>)),
),
},
apiary: {
findMany: jest.fn().mockResolvedValue([
{
id: 'a1',
name: 'Stand 1',
address: null,
location: null,
latitude: null,
longitude: null,
colonies: [
{ colonyType: 'PRODUCTION_COLONY' },
{ colonyType: 'NUCLEUS_COLONY' },
],
},
]),
},
};
prisma = {
$transaction: jest
.fn()
.mockImplementation((fn: (client: unknown) => unknown) => fn(tx)),
authorityNotice: {
findFirst: jest.fn().mockResolvedValue(null),
findMany: jest.fn().mockResolvedValue([]),
},
};
auditLog = { record: jest.fn() };
service = new AuthorityNoticesService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
);
});
describe('create', () => {
it('one open draft per kind and year', async () => {
stubLookups({
baseline: notice({ status: 'SENT' }),
openDraft: { id: 'existing' },
});
const attempt = service.create({ kind: 'HERD_CHANGE' }, 'u1');
await expect(attempt).rejects.toBeInstanceOf(ConflictException);
await expect(attempt).rejects.toMatchObject({
response: { code: 'DRAFT_EXISTS', existingId: 'existing' },
});
});
it('answers DRAFT_EXISTS when the unique key of the open draft is hit', async () => {
stubLookups({ baseline: notice({ status: 'SENT' }) });
tx.authorityNotice.create.mockRejectedValueOnce(p2002());
prisma.authorityNotice.findFirst.mockResolvedValue({ id: 'winner' });
await expect(
service.create({ kind: 'HERD_CHANGE' }, 'u1'),
).rejects.toMatchObject({
response: { code: 'DRAFT_EXISTS', existingId: 'winner' },
});
});
it('create with sentOn is SENT at once and has no openKey', async () => {
await service.create(
{ kind: 'REGISTRATION', sentOn: '2026-05-01' },
'u1',
);
expect(tx.authorityNotice.create).toHaveBeenCalledWith({
data: expect.objectContaining({
kind: 'REGISTRATION',
status: 'SENT',
sentOn: new Date('2026-05-01T00:00:00.000Z'),
openKey: null,
totalColonies: 2,
}) as unknown,
});
});
it('a notice created as sent does not collide with an open draft', async () => {
stubLookups({ openDraft: { id: 'existing' } });
await service.create(
{ kind: 'REGISTRATION', sentOn: '2026-05-01' },
'u1',
);
expect(tx.authorityNotice.create).toHaveBeenCalled();
});
it('rethrows a unique violation that no open draft explains', async () => {
stubLookups({ baseline: notice({ status: 'SENT' }) });
const error = p2002();
tx.authorityNotice.create.mockRejectedValueOnce(error);
prisma.authorityNotice.findFirst.mockResolvedValue(null);
await expect(service.create({ kind: 'HERD_CHANGE' }, 'u1')).rejects.toBe(
error,
);
});
it('a draft carries the open key and the counted snapshot', async () => {
await service.create({ kind: 'REGISTRATION' }, 'u1');
expect(tx.authorityNotice.create).toHaveBeenCalledWith({
data: expect.objectContaining({
status: 'DRAFT',
openKey: 'REGISTRATION',
sentOn: null,
totalColonies: 2,
}) as unknown,
});
});
it('herd change needs a baseline and registration needs none', async () => {
stubLookups({});
await expect(
service.create({ kind: 'HERD_CHANGE' }, 'u1'),
).rejects.toMatchObject({
response: expect.objectContaining({
message: expect.stringContaining('Erstanzeige') as unknown,
}) as unknown,
});
stubLookups({ baseline: notice({ status: 'SENT' }) });
await expect(
service.create({ kind: 'REGISTRATION' }, 'u1'),
).rejects.toBeInstanceOf(ConflictException);
});
it('TSK_REPORT needs a year and uses it in the open key', async () => {
await expect(
service.create({ kind: 'TSK_REPORT' }, 'u1'),
).rejects.toBeInstanceOf(BadRequestException);
await service.create({ kind: 'TSK_REPORT', year: 2027 }, 'u1');
expect(tx.authorityNotice.create).toHaveBeenCalledWith({
data: expect.objectContaining({
year: 2027,
openKey: 'TSK_REPORT:2027',
}) as unknown,
});
});
it('a year is only valid for the TSK report', async () => {
await expect(
service.create({ kind: 'REGISTRATION', year: 2027 }, 'u1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('the TSK count leaves out the excluded colony types', async () => {
tx.complianceSettings.findUniqueOrThrow.mockResolvedValue({
...SETTINGS,
tskExcludedColonyTypes: ['NUCLEUS_COLONY'],
});
await service.create({ kind: 'TSK_REPORT', year: 2027 }, 'u1');
expect(tx.authorityNotice.create).toHaveBeenCalledWith({
data: expect.objectContaining({ totalColonies: 1 }) as unknown,
});
});
it('locks the settings row as mutex before reading and audits the create', async () => {
await service.create({ kind: 'REGISTRATION' }, 'u1');
expect(lockMock).toHaveBeenCalledWith(
tx,
'ComplianceSettings',
'singleton',
);
expect(lockMock.mock.invocationCallOrder[0]).toBeLessThan(
tx.authorityNotice.findFirst.mock.invocationCallOrder[0],
);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'AuthorityNotice',
action: 'CREATE',
userId: 'u1',
}),
tx,
);
});
});
describe('createDraftIn', () => {
it('is the shared core: no actor needed and the caller owns the transaction', async () => {
await service.createDraftIn(
tx as never,
{
kind: 'TSK_REPORT',
year: 2027,
referenceDate: new Date('2027-01-01T00:00:00.000Z'),
},
null,
);
expect(prisma.$transaction).not.toHaveBeenCalled();
expect(tx.authorityNotice.create).toHaveBeenCalledWith({
data: expect.objectContaining({
createdByUserId: null,
openKey: 'TSK_REPORT:2027',
}) as unknown,
});
});
});
describe('transitions', () => {
it('recount only in DRAFT', async () => {
tx.authorityNotice.findUnique.mockResolvedValue(
notice({ status: 'SENT', openKey: null }),
);
await expect(service.recount('n1', 'u1')).rejects.toBeInstanceOf(
ConflictException,
);
tx.authorityNotice.findUnique.mockResolvedValue(notice());
await service.recount('n1', 'u1');
expect(tx.authorityNotice.update).toHaveBeenCalledWith({
where: { id: 'n1' },
data: expect.objectContaining({ totalColonies: 2 }) as unknown,
});
});
it('mark-sent on CANCELLED and recount on SENT answer 409', async () => {
tx.authorityNotice.findUnique.mockResolvedValue(
notice({ status: 'CANCELLED', openKey: null }),
);
await expect(service.markSent('n1', {}, 'u1')).rejects.toBeInstanceOf(
ConflictException,
);
tx.authorityNotice.findUnique.mockResolvedValue(
notice({ status: 'SENT', openKey: null }),
);
await expect(service.recount('n1', 'u1')).rejects.toBeInstanceOf(
ConflictException,
);
});
it('mark-sent defaults the sent day to the Berlin day, not the UTC day', async () => {
jest.useFakeTimers({ now: new Date('2027-03-01T23:30:00.000Z') });
try {
await service.markSent('n1', {}, 'u1');
} finally {
jest.useRealTimers();
}
expect(tx.authorityNotice.update).toHaveBeenCalledWith({
where: { id: 'n1' },
data: expect.objectContaining({
sentOn: new Date('2027-03-02T00:00:00.000Z'),
}) as unknown,
});
});
it('mark-sent sets SENT, the sent day and drops the open key', async () => {
await service.markSent(
'n1',
{ sentOn: '2027-02-02', sentVia: 'E-Mail' },
'u1',
);
expect(tx.authorityNotice.update).toHaveBeenCalledWith({
where: { id: 'n1' },
data: expect.objectContaining({
status: 'SENT',
sentOn: new Date('2027-02-02T00:00:00.000Z'),
sentVia: 'E-Mail',
openKey: null,
}) as unknown,
});
});
it('a reopened registration can be marked sent again', async () => {
tx.authorityNotice.findUnique.mockResolvedValue(
notice({ kind: 'REGISTRATION', openKey: 'REGISTRATION' }),
);
stubLookups({ baseline: notice({ id: 'other', status: 'SENT' }) });
await service.markSent('n1', {}, 'u1');
expect(tx.authorityNotice.update).toHaveBeenCalledWith({
where: { id: 'n1' },
data: expect.objectContaining({ status: 'SENT' }) as unknown,
});
});
it('reopen answers 409 while another draft of the kind exists', async () => {
tx.authorityNotice.findUnique.mockResolvedValue(
notice({ status: 'SENT', openKey: null }),
);
stubLookups({ openDraft: { id: 'other' } });
await expect(service.reopen('n1', 'u1')).rejects.toMatchObject({
response: { code: 'DRAFT_EXISTS', existingId: 'other' },
});
});
it('reopen turns a SENT notice back into a draft with its open key', async () => {
tx.authorityNotice.findUnique.mockResolvedValue(
notice({ status: 'SENT', openKey: null, sentOn: new Date(0) }),
);
await service.reopen('n1', 'u1');
expect(tx.authorityNotice.update).toHaveBeenCalledWith({
where: { id: 'n1' },
data: expect.objectContaining({
status: 'DRAFT',
openKey: 'HERD_CHANGE',
}) as unknown,
});
});
it('cancel only works on a draft and drops the open key', async () => {
await service.cancel('n1', 'u1');
expect(tx.authorityNotice.update).toHaveBeenCalledWith({
where: { id: 'n1' },
data: expect.objectContaining({
status: 'CANCELLED',
openKey: null,
}) as unknown,
});
tx.authorityNotice.findUnique.mockResolvedValue(
notice({ status: 'SENT', openKey: null }),
);
await expect(service.cancel('n1', 'u1')).rejects.toBeInstanceOf(
ConflictException,
);
});
it.each([
['recount', (s: AuthorityNoticesService) => s.recount('n1', 'u1')],
['markSent', (s: AuthorityNoticesService) => s.markSent('n1', {}, 'u1')],
['cancel', (s: AuthorityNoticesService) => s.cancel('n1', 'u1')],
[
'update',
(s: AuthorityNoticesService) => s.update('n1', { notes: 'x' }, 'u1'),
],
])(
'every transition locks the notice row and writes an audit entry (%s)',
async (_name, run) => {
await run(service);
expect(lockMock).toHaveBeenCalledWith(tx, 'AuthorityNotice', 'n1');
const lockOrder = lockMock.mock.invocationCallOrder.at(-1) ?? 0;
expect(lockOrder).toBeLessThan(
tx.authorityNotice.findUnique.mock.invocationCallOrder[0],
);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'AuthorityNotice',
entityId: 'n1',
action: 'UPDATE',
userId: 'u1',
beforeData: expect.anything() as unknown,
afterData: expect.anything() as unknown,
}),
tx,
);
},
);
it('reopen locks the settings mutex before the notice row', async () => {
tx.authorityNotice.findUnique.mockResolvedValue(
notice({ status: 'SENT', openKey: null }),
);
await service.reopen('n1', 'u1');
expect(lockMock.mock.calls.map((call: unknown[]) => call[1])).toEqual([
'ComplianceSettings',
'AuthorityNotice',
]);
});
it('answers 404 for a missing notice', async () => {
lockMock.mockResolvedValue(false);
await expect(service.recount('missing', 'u1')).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
describe('update', () => {
it('changes the corrected fields but answers 409 on CANCELLED', async () => {
await service.update(
'n1',
{ totalColonies: 7, notes: 'korrigiert' },
'u1',
);
expect(tx.authorityNotice.update).toHaveBeenCalledWith({
where: { id: 'n1' },
data: expect.objectContaining({
totalColonies: 7,
notes: 'korrigiert',
}) as unknown,
});
tx.authorityNotice.findUnique.mockResolvedValue(
notice({ status: 'CANCELLED', openKey: null }),
);
await expect(
service.update('n1', { notes: 'x' }, 'u1'),
).rejects.toBeInstanceOf(ConflictException);
});
it('a draft has no sent day', async () => {
await expect(
service.update('n1', { sentOn: '2027-01-01' }, 'u1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('changing the sent day takes the settings mutex first', async () => {
tx.authorityNotice.findUnique.mockResolvedValue(
notice({ kind: 'REGISTRATION', status: 'SENT', openKey: null }),
);
await service.update('n1', { sentOn: '2027-01-02' }, 'u1');
expect(lockMock.mock.calls.map((call: unknown[]) => call[1])).toEqual([
'ComplianceSettings',
'AuthorityNotice',
]);
});
it('other edits do not take the settings mutex', async () => {
await service.update('n1', { notes: 'x' }, 'u1');
expect(lockMock.mock.calls.map((call: unknown[]) => call[1])).toEqual([
'AuthorityNotice',
]);
});
it('a sent notice keeps its sent day', async () => {
tx.authorityNotice.findUnique.mockResolvedValue(
notice({ status: 'SENT', openKey: null, sentOn: new Date(0) }),
);
await expect(
service.update('n1', { sentOn: null }, 'u1'),
).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('findAll / findOne', () => {
it('filters by kind and year', async () => {
await service.findAll({ kind: 'TSK_REPORT', year: 2027 });
expect(prisma.authorityNotice.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { kind: 'TSK_REPORT', year: 2027 },
}),
);
});
it('answers 404 for an unknown id', async () => {
(
prisma.authorityNotice as unknown as { findUnique: jest.Mock }
).findUnique = jest.fn().mockResolvedValue(null);
await expect(service.findOne('x')).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
});
@@ -0,0 +1,385 @@
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, findBaselineIn } from './herd-status';
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');
}
}
@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;
}
/**
* 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 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> {
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 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;
});
}
}
@@ -0,0 +1,33 @@
import {
describeNullContracts,
NullContract,
} from '../common/validation/null-contract.spec-helper';
import { UpdateAuthorityNoticeDto } from './dto/update-authority-notice.dto';
import { UpdateComplianceSettingsDto } from './dto/update-compliance-settings.dto';
const contracts: Record<string, NullContract> = {
UpdateComplianceSettingsDto: {
dto: UpdateComplianceSettingsDto,
notNull: [
'noticeOnColonyCountChange',
'noticeOnApiaryChange',
'tskReferenceDay',
'tskExcludedColonyTypes',
'netQuantityDigitRules',
],
clearable: [
'veterinaryOfficeName',
'veterinaryOfficeAddress',
'registrationNumber',
'tskReportDueDay',
'tskMemberNumber',
],
},
UpdateAuthorityNoticeDto: {
dto: UpdateAuthorityNoticeDto,
notNull: ['totalColonies', 'referenceDate'],
clearable: ['sentOn', 'sentVia', 'reference', 'notes'],
},
};
describeNullContracts('Compliance DTO null contracts', contracts);
@@ -0,0 +1,29 @@
import { Body, Controller, Get, Patch } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { ComplianceSettingsService } from './compliance-settings.service';
import { UpdateComplianceSettingsDto } from './dto/update-compliance-settings.dto';
@ApiTags('compliance')
@ApiBearerAuth()
@Controller('compliance/settings')
export class ComplianceSettingsController {
constructor(private readonly settings: ComplianceSettingsService) {}
@RequirePermission('compliance:read')
@Get()
get() {
return this.settings.get();
}
@RequirePermission('compliance:manage')
@Patch()
update(
@Body() dto: UpdateComplianceSettingsDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.settings.update(dto, actor.id);
}
}
@@ -0,0 +1,236 @@
import { BadRequestException } from '@nestjs/common';
import { AuditLogService } from '../audit/audit-log.service';
import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate } from '../prisma/row-lock';
import { ComplianceSettingsService } from './compliance-settings.service';
jest.mock('../prisma/row-lock', () => ({
lockRowForUpdate: jest.fn().mockResolvedValue(true),
}));
const STORED = {
id: 'singleton',
veterinaryOfficeName: null,
veterinaryOfficeAddress: null,
registrationNumber: null,
noticeOnColonyCountChange: true,
noticeOnApiaryChange: true,
tskReferenceDay: '01-01',
tskReportDueDay: null,
tskMemberNumber: null,
tskExcludedColonyTypes: [],
netQuantityDigitRules: [
{ maxGrams: 50, minMm: 2 },
{ maxGrams: null, minMm: 6 },
],
updatedAt: new Date(0),
};
describe('ComplianceSettingsService', () => {
let service: ComplianceSettingsService;
let tx: {
complianceSettings: {
upsert: jest.Mock;
findUniqueOrThrow: jest.Mock;
update: jest.Mock;
};
};
let prisma: {
$transaction: jest.Mock;
complianceSettings: { findUnique: jest.Mock; upsert: jest.Mock };
};
let auditLog: { record: jest.Mock };
beforeEach(() => {
(lockRowForUpdate as jest.Mock).mockResolvedValue(true);
tx = {
complianceSettings: {
upsert: jest.fn().mockResolvedValue(STORED),
findUniqueOrThrow: jest.fn().mockResolvedValue(STORED),
update: jest
.fn()
.mockImplementation(({ data }: { data: object }) =>
Promise.resolve({ ...STORED, ...data }),
),
},
};
prisma = {
$transaction: jest
.fn()
.mockImplementation((fn: (client: unknown) => unknown) => fn(tx)),
complianceSettings: {
findUnique: jest.fn().mockResolvedValue(null),
upsert: jest.fn().mockResolvedValue(STORED),
},
};
auditLog = { record: jest.fn() };
service = new ComplianceSettingsService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
);
});
it('creates defaults on first read', async () => {
const settings = await service.get();
expect(prisma.complianceSettings.upsert).toHaveBeenCalledWith({
where: { id: 'singleton' },
update: {},
create: { id: 'singleton' },
});
expect(settings).toBe(STORED);
});
it('returns the stored row without creating one', async () => {
prisma.complianceSettings.findUnique.mockResolvedValue(STORED);
await service.get();
expect(prisma.complianceSettings.upsert).not.toHaveBeenCalled();
});
it('rejects 02-29 as reference day with 400', async () => {
await expect(
service.update({ tskReferenceDay: '02-29' }, 'u1'),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
service.update({ tskReportDueDay: '13-01' }, 'u1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects digit rules that are not ascending or whose last rule has a limit', async () => {
const attempts = [
[
{ maxGrams: 200, minMm: 3 },
{ maxGrams: 50, minMm: 2 },
{ maxGrams: null, minMm: 6 },
],
[
{ maxGrams: 50, minMm: 2 },
{ maxGrams: 50, minMm: 3 },
{ maxGrams: null, minMm: 6 },
],
[{ maxGrams: 50, minMm: 2 }],
[
{ maxGrams: null, minMm: 2 },
{ maxGrams: null, minMm: 6 },
],
[],
];
for (const rules of attempts) {
await expect(
service.update({ netQuantityDigitRules: rules }, 'u1'),
).rejects.toBeInstanceOf(BadRequestException);
}
});
it('rejects a digit height outside 0.5 to 20 mm and more than 10 rules', async () => {
await expect(
service.update(
{ netQuantityDigitRules: [{ maxGrams: null, minMm: 0.4 }] },
'u1',
),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
service.update(
{ netQuantityDigitRules: [{ maxGrams: null, minMm: 20.5 }] },
'u1',
),
).rejects.toBeInstanceOf(BadRequestException);
const eleven = Array.from({ length: 10 }, (_, index) => ({
maxGrams: (index + 1) * 10,
minMm: 2,
})).concat([{ maxGrams: null as never, minMm: 2 }]);
await expect(
service.update({ netQuantityDigitRules: eleven }, 'u1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('treats a missing maxGrams key like null', async () => {
await service.update(
{
netQuantityDigitRules: [
{ maxGrams: 50, minMm: 2 },
{ minMm: 6 } as never,
],
},
'u1',
);
const call = (
tx.complianceSettings.update.mock.calls as unknown[][]
)[0][0] as {
data: { netQuantityDigitRules: unknown };
};
expect(call.data.netQuantityDigitRules).toEqual([
{ maxGrams: 50, minMm: 2 },
{ maxGrams: null, minMm: 6 },
]);
await expect(
service.update(
{
netQuantityDigitRules: [{ minMm: 2 } as never, { minMm: 6 } as never],
},
'u1',
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects a colony type listed twice', async () => {
await expect(
service.update(
{ tskExcludedColonyTypes: ['NUCLEUS_COLONY', 'NUCLEUS_COLONY'] },
'u1',
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('stores valid values under the row lock with an audit entry', async () => {
await service.update(
{
veterinaryOfficeName: ' Veterinäramt ',
tskReferenceDay: '04-15',
tskReportDueDay: null,
netQuantityDigitRules: [{ maxGrams: null, minMm: 3 }],
},
'u1',
);
expect(lockRowForUpdate).toHaveBeenCalledWith(
tx,
'ComplianceSettings',
'singleton',
);
expect(tx.complianceSettings.update).toHaveBeenCalledWith({
where: { id: 'singleton' },
data: expect.objectContaining({
veterinaryOfficeName: 'Veterinäramt',
tskReferenceDay: '04-15',
tskReportDueDay: null,
netQuantityDigitRules: [{ maxGrams: null, minMm: 3 }],
}) as unknown,
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'ComplianceSettings',
entityId: 'singleton',
action: 'UPDATE',
userId: 'u1',
}),
tx,
);
});
it('an omitted field keeps the stored value', async () => {
await service.update({ noticeOnApiaryChange: false }, 'u1');
const call = (
tx.complianceSettings.update.mock.calls as unknown[][]
)[0][0] as {
data: Record<string, unknown>;
};
expect(call.data.noticeOnApiaryChange).toBe(false);
expect(call.data.tskReferenceDay).toBeUndefined();
expect(call.data.veterinaryOfficeName).toBeUndefined();
});
});
@@ -0,0 +1,150 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { ComplianceSettings } from '../../generated/prisma';
import { AuditLogService } from '../audit/audit-log.service';
import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate } from '../prisma/row-lock';
import { UpdateComplianceSettingsDto } from './dto/update-compliance-settings.dto';
import { complianceSettingsIn, COMPLIANCE_SETTINGS_ID } from './herd-status';
import { isValidMonthDay } from './tsk-day';
const AUDIT_ENTITY = 'ComplianceSettings';
const MIN_DIGIT_RULES = 1;
const MAX_DIGIT_RULES = 10;
const MIN_DIGIT_MM = 0.5;
const MAX_DIGIT_MM = 20;
interface DigitRuleInput {
maxGrams: number | null;
minMm: number;
}
// undefined keeps the stored value, null or blank clears it, anything else is
// stored trimmed.
function trimmedOrNull(
value: string | null | undefined,
): string | null | undefined {
if (value === undefined) {
return undefined;
}
return value?.trim() || null;
}
function assertMonthDay(field: string, value: string | null | undefined): void {
if (value != null && !isValidMonthDay(value)) {
throw new BadRequestException(
`${field} muss ein gültiger Tag im Format MM-TT sein (ohne 29. Februar)`,
);
}
}
/**
* 1 to 10 rules, the limits strictly ascending, only the last without a
* limit, every height between 0.5 and 20 mm. The values themselves are the
* owner's assumption - nothing here knows what the law asks for.
*/
function assertDigitRules(rules: readonly DigitRuleInput[]): void {
if (rules.length < MIN_DIGIT_RULES || rules.length > MAX_DIGIT_RULES) {
throw new BadRequestException(
`Es braucht ${MIN_DIGIT_RULES} bis ${MAX_DIGIT_RULES} Regeln für die Ziffernhöhe`,
);
}
let previous = 0;
rules.forEach((rule, index) => {
const isLast = index === rules.length - 1;
if (rule.minMm < MIN_DIGIT_MM || rule.minMm > MAX_DIGIT_MM) {
throw new BadRequestException(
`Die Ziffernhöhe muss zwischen ${MIN_DIGIT_MM} und ${MAX_DIGIT_MM} mm liegen`,
);
}
if (isLast) {
if (rule.maxGrams !== null) {
throw new BadRequestException(
'Die letzte Regel gilt für alles darüber und hat keine Obergrenze',
);
}
return;
}
if (rule.maxGrams === null || rule.maxGrams <= previous) {
throw new BadRequestException(
'Die Obergrenzen der Regeln müssen aufsteigen, nur die letzte hat keine',
);
}
previous = rule.maxGrams;
});
}
function assertUnique(values: readonly string[]): void {
if (new Set(values).size !== values.length) {
throw new BadRequestException('Eine Völkerart steht doppelt in der Liste');
}
}
@Injectable()
export class ComplianceSettingsService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
) {}
/** Find-or-create, like SiteSettings. */
get(): Promise<ComplianceSettings> {
return complianceSettingsIn(this.prisma);
}
async update(
dto: UpdateComplianceSettingsDto,
actingUserId: string,
): Promise<ComplianceSettings> {
assertMonthDay('Der Stichtag', dto.tskReferenceDay);
assertMonthDay('Die Meldefrist', dto.tskReportDueDay);
if (dto.tskExcludedColonyTypes) {
assertUnique(dto.tskExcludedColonyTypes);
}
const digitRules = dto.netQuantityDigitRules?.map((rule) => ({
maxGrams: rule.maxGrams ?? null,
minMm: rule.minMm,
}));
if (digitRules) {
assertDigitRules(digitRules);
}
return this.prisma.$transaction(async (tx) => {
// The row must exist to be locked; "before" is read after the lock.
await tx.complianceSettings.upsert({
where: { id: COMPLIANCE_SETTINGS_ID },
update: {},
create: { id: COMPLIANCE_SETTINGS_ID },
});
await lockRowForUpdate(tx, 'ComplianceSettings', COMPLIANCE_SETTINGS_ID);
const before = await tx.complianceSettings.findUniqueOrThrow({
where: { id: COMPLIANCE_SETTINGS_ID },
});
const settings = await tx.complianceSettings.update({
where: { id: COMPLIANCE_SETTINGS_ID },
data: {
veterinaryOfficeName: trimmedOrNull(dto.veterinaryOfficeName),
veterinaryOfficeAddress: trimmedOrNull(dto.veterinaryOfficeAddress),
registrationNumber: trimmedOrNull(dto.registrationNumber),
noticeOnColonyCountChange: dto.noticeOnColonyCountChange,
noticeOnApiaryChange: dto.noticeOnApiaryChange,
tskReferenceDay: dto.tskReferenceDay,
tskReportDueDay: dto.tskReportDueDay,
tskMemberNumber: trimmedOrNull(dto.tskMemberNumber),
tskExcludedColonyTypes: dto.tskExcludedColonyTypes,
netQuantityDigitRules: digitRules,
},
});
await this.auditLog.record(
{
entityType: AUDIT_ENTITY,
entityId: COMPLIANCE_SETTINGS_ID,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: settings,
},
tx,
);
return settings;
});
}
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { AuthorityNoticesController } from './authority-notices.controller';
import { AuthorityNoticesService } from './authority-notices.service';
import { ComplianceSettingsController } from './compliance-settings.controller';
import { ComplianceSettingsService } from './compliance-settings.service';
// No provider here may inject PermissionResolverService: the worker imports
// this module too (handlers arrive with the notification task).
@Module({
controllers: [ComplianceSettingsController, AuthorityNoticesController],
providers: [ComplianceSettingsService, AuthorityNoticesService],
exports: [ComplianceSettingsService, AuthorityNoticesService],
})
export class ComplianceModule {}
@@ -0,0 +1,50 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsDateString,
IsEnum,
IsInt,
IsOptional,
Matches,
Max,
Min,
} from 'class-validator';
import { AuthorityNoticeKind } from '../../../generated/prisma';
export const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
export const DAY_MESSAGE = 'muss ein Datum im Format JJJJ-MM-TT sein';
export const MIN_NOTICE_YEAR = 2000;
export const MAX_NOTICE_YEAR = 2100;
export class CreateAuthorityNoticeDto {
@ApiProperty({ enum: AuthorityNoticeKind })
@IsEnum(AuthorityNoticeKind)
kind!: AuthorityNoticeKind;
@ApiPropertyOptional({
description: 'Nur für die TSK-Zählung (dort Pflicht)',
})
@IsOptional()
@IsInt()
@Min(MIN_NOTICE_YEAR)
@Max(MAX_NOTICE_YEAR)
year?: number;
@ApiPropertyOptional({
example: '2027-01-01',
description: 'Vorgabe: heute (Berlin)',
})
@IsOptional()
@Matches(DAY_PATTERN, { message: `referenceDate ${DAY_MESSAGE}` })
@IsDateString({ strict: true })
referenceDate?: string;
@ApiPropertyOptional({
example: '2026-05-01',
description:
'Gesetzt: die Anzeige entsteht gleich als gesendet (Altbestand)',
})
@IsOptional()
@Matches(DAY_PATTERN, { message: `sentOn ${DAY_MESSAGE}` })
@IsDateString({ strict: true })
sentOn?: string;
}
@@ -0,0 +1,33 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
IsDateString,
IsOptional,
IsString,
Matches,
MaxLength,
} from 'class-validator';
import { DAY_MESSAGE, DAY_PATTERN } from './create-authority-notice.dto';
import { MAX_NOTICE_TEXT_LENGTH } from './update-authority-notice.dto';
export class MarkSentDto {
@ApiPropertyOptional({
example: '2027-01-15',
description: 'Vorgabe: heute (Berlin)',
})
@IsOptional()
@Matches(DAY_PATTERN, { message: `sentOn ${DAY_MESSAGE}` })
@IsDateString({ strict: true })
sentOn?: string;
@ApiPropertyOptional({ description: 'z. B. E-Mail, Post' })
@IsOptional()
@IsString()
@MaxLength(MAX_NOTICE_TEXT_LENGTH)
sentVia?: string;
@ApiPropertyOptional({ description: 'Aktenzeichen' })
@IsOptional()
@IsString()
@MaxLength(MAX_NOTICE_TEXT_LENGTH)
reference?: string;
}
@@ -0,0 +1,23 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator';
import { AuthorityNoticeKind } from '../../../generated/prisma';
import {
MAX_NOTICE_YEAR,
MIN_NOTICE_YEAR,
} from './create-authority-notice.dto';
export class NoticeListQuery {
@ApiPropertyOptional({ enum: AuthorityNoticeKind })
@IsOptional()
@IsEnum(AuthorityNoticeKind)
kind?: AuthorityNoticeKind;
@ApiPropertyOptional()
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(MIN_NOTICE_YEAR)
@Max(MAX_NOTICE_YEAR)
year?: number;
}
@@ -0,0 +1,55 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
IsDateString,
IsInt,
IsOptional,
IsString,
Matches,
MaxLength,
Min,
} from 'class-validator';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
import { DAY_MESSAGE, DAY_PATTERN } from './create-authority-notice.dto';
export const MAX_NOTICE_TEXT_LENGTH = 300;
export const MAX_NOTICE_NOTES_LENGTH = 2000;
export class UpdateAuthorityNoticeDto {
@ApiPropertyOptional({
description: 'Die angezeigte Zahl; bleibt korrigierbar',
})
@IsOptionalNotNull()
@IsInt()
@Min(0)
totalColonies?: number;
@ApiPropertyOptional({ example: '2027-01-01' })
@IsOptionalNotNull()
@Matches(DAY_PATTERN, { message: `referenceDate ${DAY_MESSAGE}` })
@IsDateString({ strict: true })
referenceDate?: string;
@ApiPropertyOptional({ nullable: true, example: '2027-01-15' })
@IsOptional()
@Matches(DAY_PATTERN, { message: `sentOn ${DAY_MESSAGE}` })
@IsDateString({ strict: true })
sentOn?: string | null;
@ApiPropertyOptional({ nullable: true, description: 'z. B. E-Mail, Post' })
@IsOptional()
@IsString()
@MaxLength(MAX_NOTICE_TEXT_LENGTH)
sentVia?: string | null;
@ApiPropertyOptional({ nullable: true, description: 'Aktenzeichen' })
@IsOptional()
@IsString()
@MaxLength(MAX_NOTICE_TEXT_LENGTH)
reference?: string | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(MAX_NOTICE_NOTES_LENGTH)
notes?: string | null;
}
@@ -0,0 +1,104 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsEnum,
IsNumber,
IsOptional,
IsString,
Matches,
MaxLength,
ValidateNested,
} from 'class-validator';
import { ColonyType } from '../../../generated/prisma';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
const MONTH_DAY_PATTERN = /^\d{2}-\d{2}$/;
const MONTH_DAY_MESSAGE = 'muss ein Tag im Format MM-TT sein';
const MAX_NAME_LENGTH = 200;
const MAX_ADDRESS_LENGTH = 300;
/** One row of the label digit height table. Annahme - vom Inhaber zu prüfen. */
export class NetQuantityDigitRuleDto {
@ApiProperty({
nullable: true,
description: 'Obergrenze in g, null = alles darüber',
})
@IsOptional()
@IsNumber({ allowNaN: false, allowInfinity: false })
maxGrams!: number | null;
@ApiProperty({ description: 'Mindesthöhe der Ziffern in mm' })
@IsNumber({ allowNaN: false, allowInfinity: false })
minMm!: number;
}
/**
* Legal values (deadlines, digit heights) are settings, not code. Every value
* here is an assumption the owner has to check.
*/
export class UpdateComplianceSettingsDto {
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(MAX_NAME_LENGTH)
veterinaryOfficeName?: string | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(MAX_ADDRESS_LENGTH)
veterinaryOfficeAddress?: string | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(MAX_NAME_LENGTH)
registrationNumber?: string | null;
@ApiPropertyOptional()
@IsOptionalNotNull()
@IsBoolean()
noticeOnColonyCountChange?: boolean;
@ApiPropertyOptional()
@IsOptionalNotNull()
@IsBoolean()
noticeOnApiaryChange?: boolean;
@ApiPropertyOptional({ example: '01-01', description: 'MM-TT, ohne 02-29' })
@IsOptionalNotNull()
@IsString()
@Matches(MONTH_DAY_PATTERN, {
message: `tskReferenceDay ${MONTH_DAY_MESSAGE}`,
})
tskReferenceDay?: string;
@ApiPropertyOptional({ nullable: true, example: '03-31' })
@IsOptional()
@IsString()
@Matches(MONTH_DAY_PATTERN, {
message: `tskReportDueDay ${MONTH_DAY_MESSAGE}`,
})
tskReportDueDay?: string | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(MAX_NAME_LENGTH)
tskMemberNumber?: string | null;
@ApiPropertyOptional({ enum: ColonyType, isArray: true })
@IsOptionalNotNull()
@IsArray()
@IsEnum(ColonyType, { each: true })
tskExcludedColonyTypes?: ColonyType[];
@ApiPropertyOptional({ type: [NetQuantityDigitRuleDto] })
@IsOptionalNotNull()
@IsArray()
@ValidateNested({ each: true })
@Type(() => NetQuantityDigitRuleDto)
netQuantityDigitRules?: NetQuantityDigitRuleDto[];
}
@@ -0,0 +1,182 @@
import { ColonyType } from '../../generated/prisma';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import {
buildHerdSnapshot,
herdDeviation,
HerdApiaryRow,
HerdSnapshot,
loadHerdApiaryRows,
} from './herd-snapshot';
const TAKEN_AT = new Date('2027-01-01T10:00:00.000Z');
const ENABLED = { noticeOnColonyCountChange: true, noticeOnApiaryChange: true };
const P = ColonyType.PRODUCTION_COLONY;
const N = ColonyType.NUCLEUS_COLONY;
function row(
apiaryId: string,
colonyTypes: ColonyType[],
patch: Partial<HerdApiaryRow> = {},
): HerdApiaryRow {
return {
apiaryId,
name: `Stand ${apiaryId}`,
address: `Weg ${apiaryId}`,
location: null,
latitude: 48.1,
longitude: 11.5,
colonyTypes,
...patch,
};
}
describe('buildHerdSnapshot', () => {
it('counts per apiary and per type', () => {
const snapshot = buildHerdSnapshot(
[row('a', [P, P, N]), row('b', [P])],
TAKEN_AT,
);
expect(snapshot.version).toBe(1);
expect(snapshot.takenAt).toBe(TAKEN_AT.toISOString());
expect(snapshot.totalLiving).toBe(4);
expect(snapshot.apiaries[0]).toMatchObject({
apiaryId: 'a',
livingColonies: 3,
byType: { PRODUCTION_COLONY: 2, NUCLEUS_COLONY: 1 },
});
});
it('excluded colony types do not count', () => {
const snapshot = buildHerdSnapshot([row('a', [P, N, N])], TAKEN_AT, [N]);
expect(snapshot.totalLiving).toBe(1);
expect(snapshot.apiaries[0].byType).toEqual({ PRODUCTION_COLONY: 1 });
});
it('keeps an apiary without colonies', () => {
const snapshot = buildHerdSnapshot([row('a', [])], TAKEN_AT);
expect(snapshot.apiaries).toHaveLength(1);
expect(snapshot.totalLiving).toBe(0);
});
});
describe('loadHerdApiaryRows', () => {
it('counts only living colonies at active apiaries', async () => {
const findMany = jest.fn().mockResolvedValue([
{
id: 'a',
name: 'A',
address: null,
location: null,
latitude: null,
longitude: null,
colonies: [{ colonyType: P }, { colonyType: N }],
},
]);
const db = { apiary: { findMany } };
const rows = await loadHerdApiaryRows(db as never);
const args = (findMany.mock.calls as unknown[][])[0][0] as {
where: unknown;
select: { colonies: { where: unknown } };
};
expect(args.where).toEqual({ status: 'ACTIVE' });
expect(args.select.colonies.where).toEqual(LIVING_COLONY_WHERE);
expect(rows).toEqual([
expect.objectContaining({ apiaryId: 'a', colonyTypes: [P, N] }),
]);
});
});
describe('herdDeviation', () => {
const base = [row('a', [P, P]), row('b', [P])];
function baselineOf(rows: HerdApiaryRow[], totalColonies?: number) {
const snapshot = buildHerdSnapshot(rows, TAKEN_AT);
return { snapshot, totalColonies: totalColonies ?? snapshot.totalLiving };
}
it('no deviation without changes', () => {
const deviation = herdDeviation(
baselineOf(base),
buildHerdSnapshot(base, TAKEN_AT),
ENABLED,
);
expect(deviation).toEqual({
triggered: false,
colonyCountChanged: false,
apiariesAdded: [],
apiariesRemoved: [],
apiariesRelocated: [],
});
});
it('count change triggers only when enabled', () => {
const current = buildHerdSnapshot([row('a', [P, P, P]), base[1]], TAKEN_AT);
expect(herdDeviation(baselineOf(base), current, ENABLED).triggered).toBe(
true,
);
const off = herdDeviation(baselineOf(base), current, {
...ENABLED,
noticeOnColonyCountChange: false,
});
expect(off.triggered).toBe(false);
expect(off.colonyCountChanged).toBe(true);
});
it('moving between apiaries keeps the total and triggers nothing', () => {
const current = buildHerdSnapshot(
[row('a', [P]), row('b', [P, P])],
TAKEN_AT,
);
const deviation = herdDeviation(baselineOf(base), current, ENABLED);
expect(deviation.colonyCountChanged).toBe(false);
expect(deviation.triggered).toBe(false);
});
it('new, removed or relocated apiary triggers when enabled', () => {
const added = buildHerdSnapshot([...base, row('c', [])], TAKEN_AT);
const removed = buildHerdSnapshot([base[0]], TAKEN_AT);
const relocated = buildHerdSnapshot(
[base[0], row('b', [P], { address: 'Neuer Weg 1' })],
TAKEN_AT,
);
const sameCount = (current: HerdSnapshot) =>
herdDeviation(baselineOf(base, current.totalLiving), current, ENABLED);
expect(sameCount(added)).toMatchObject({
triggered: true,
apiariesAdded: ['c'],
});
expect(sameCount(removed)).toMatchObject({
triggered: true,
apiariesRemoved: ['b'],
});
expect(sameCount(relocated)).toMatchObject({
triggered: true,
apiariesRelocated: ['b'],
});
expect(
herdDeviation(baselineOf(base, 3), relocated, {
...ENABLED,
noticeOnApiaryChange: false,
}).triggered,
).toBe(false);
});
it('compares the count with the corrected totalColonies of the baseline', () => {
const current = buildHerdSnapshot(base, TAKEN_AT);
const deviation = herdDeviation(baselineOf(base, 5), current, ENABLED);
expect(deviation.colonyCountChanged).toBe(true);
expect(deviation.triggered).toBe(true);
});
});
@@ -0,0 +1,161 @@
import { ColonyType, ComplianceSettings } from '../../generated/prisma';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import { DbClient } from '../prisma/db-client';
export const HERD_SNAPSHOT_VERSION = 1;
export interface HerdApiarySnapshot {
apiaryId: string;
name: string;
address: string | null;
location: string | null;
latitude: number | null;
longitude: number | null;
livingColonies: number;
byType: Partial<Record<ColonyType, number>>;
}
export interface HerdSnapshot {
version: 1;
takenAt: string;
apiaries: HerdApiarySnapshot[];
totalLiving: number;
}
/** An active apiary with the colony type of each living colony standing there. */
export interface HerdApiaryRow {
apiaryId: string;
name: string;
address: string | null;
location: string | null;
latitude: number | null;
longitude: number | null;
colonyTypes: ColonyType[];
}
export interface HerdDeviation {
triggered: boolean;
colonyCountChanged: boolean;
apiariesAdded: string[];
apiariesRemoved: string[];
apiariesRelocated: string[];
}
type DeviationSettings = Pick<
ComplianceSettings,
'noticeOnColonyCountChange' | 'noticeOnApiaryChange'
>;
/** Pure: counts the living colonies per apiary and per type. */
export function buildHerdSnapshot(
rows: readonly HerdApiaryRow[],
takenAt: Date,
excludedTypes: readonly ColonyType[] = [],
): HerdSnapshot {
const apiaries = rows.map((row): HerdApiarySnapshot => {
const byType: Partial<Record<ColonyType, number>> = {};
let livingColonies = 0;
for (const type of row.colonyTypes) {
if (excludedTypes.includes(type)) {
continue;
}
byType[type] = (byType[type] ?? 0) + 1;
livingColonies += 1;
}
return {
apiaryId: row.apiaryId,
name: row.name,
address: row.address,
location: row.location,
latitude: row.latitude,
longitude: row.longitude,
livingColonies,
byType,
};
});
return {
version: HERD_SNAPSHOT_VERSION,
takenAt: takenAt.toISOString(),
apiaries,
totalLiving: apiaries.reduce(
(sum, apiary) => sum + apiary.livingColonies,
0,
),
};
}
/** Active apiaries with their living colonies (LIVING_COLONY_WHERE). */
export async function loadHerdApiaryRows(
db: DbClient,
): Promise<HerdApiaryRow[]> {
const apiaries = await db.apiary.findMany({
where: { status: 'ACTIVE' },
select: {
id: true,
name: true,
address: true,
location: true,
latitude: true,
longitude: true,
colonies: { where: LIVING_COLONY_WHERE, select: { colonyType: true } },
},
orderBy: [{ name: 'asc' }, { id: 'asc' }],
});
return apiaries.map((apiary) => ({
apiaryId: apiary.id,
name: apiary.name,
address: apiary.address,
location: apiary.location,
latitude: apiary.latitude,
longitude: apiary.longitude,
colonyTypes: apiary.colonies.map((colony) => colony.colonyType),
}));
}
function sameSite(a: HerdApiarySnapshot, b: HerdApiarySnapshot): boolean {
return (
a.address === b.address &&
a.location === b.location &&
a.latitude === b.latitude &&
a.longitude === b.longitude
);
}
/**
* How the current herd differs from the baseline. The count is compared with
* the corrected `totalColonies` of the notice, not with its snapshot. A move
* between apiaries keeps the total and triggers nothing.
*/
export function herdDeviation(
baseline: { snapshot: HerdSnapshot; totalColonies: number },
current: HerdSnapshot,
settings: DeviationSettings,
): HerdDeviation {
const before = new Map(
baseline.snapshot.apiaries.map((apiary) => [apiary.apiaryId, apiary]),
);
const now = new Map(
current.apiaries.map((apiary) => [apiary.apiaryId, apiary]),
);
const apiariesAdded = [...now.keys()].filter((id) => !before.has(id));
const apiariesRemoved = [...before.keys()].filter((id) => !now.has(id));
const apiariesRelocated = [...now.entries()]
.filter(([id, apiary]) => {
const old = before.get(id);
return old !== undefined && !sameSite(old, apiary);
})
.map(([id]) => id);
const colonyCountChanged = baseline.totalColonies !== current.totalLiving;
const apiaryChanged =
apiariesAdded.length + apiariesRemoved.length + apiariesRelocated.length >
0;
return {
triggered:
(settings.noticeOnColonyCountChange && colonyCountChanged) ||
(settings.noticeOnApiaryChange && apiaryChanged),
colonyCountChanged,
apiariesAdded,
apiariesRemoved,
apiariesRelocated,
};
}
@@ -0,0 +1,82 @@
import { herdStatusIn } from './herd-status';
const NOW = new Date('2027-03-01T10:00:00.000Z');
function settings() {
return {
id: 'singleton',
noticeOnColonyCountChange: true,
noticeOnApiaryChange: true,
tskExcludedColonyTypes: [],
};
}
function dbWith(baseline: unknown) {
return {
complianceSettings: {
findUnique: jest.fn().mockResolvedValue(settings()),
upsert: jest.fn().mockResolvedValue(settings()),
},
authorityNotice: { findFirst: jest.fn().mockResolvedValue(baseline) },
apiary: {
findMany: jest.fn().mockResolvedValue([
{
id: 'a',
name: 'A',
address: null,
location: null,
latitude: null,
longitude: null,
colonies: [{ colonyType: 'PRODUCTION_COLONY' }],
},
]),
},
};
}
describe('herdStatusIn', () => {
it('no baseline means deviation null, not a herd change', async () => {
const status = await herdStatusIn(dbWith(null) as never, NOW);
expect(status.baseline).toBeNull();
expect(status.deviation).toBeNull();
expect(status.current.totalLiving).toBe(1);
});
it('the baseline is the newest SENT registration or herd change by sentOn and ignores TSK reports', async () => {
const db = dbWith(null);
await herdStatusIn(db as never, NOW);
expect(db.authorityNotice.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: {
status: 'SENT',
kind: { in: ['REGISTRATION', 'HERD_CHANGE'] },
},
orderBy: [{ sentOn: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
}),
);
});
it('reports the deviation against the baseline', async () => {
const baseline = {
id: 'n1',
totalColonies: 3,
snapshot: {
version: 1,
takenAt: NOW.toISOString(),
apiaries: [],
totalLiving: 3,
},
};
const status = await herdStatusIn(dbWith(baseline) as never, NOW);
expect(status.deviation).toMatchObject({
triggered: true,
colonyCountChanged: true,
apiariesAdded: ['a'],
});
});
});
@@ -0,0 +1,71 @@
import { AuthorityNotice, ComplianceSettings } from '../../generated/prisma';
import { DbClient } from '../prisma/db-client';
import {
buildHerdSnapshot,
herdDeviation,
HerdDeviation,
HerdSnapshot,
loadHerdApiaryRows,
} from './herd-snapshot';
export const COMPLIANCE_SETTINGS_ID = 'singleton';
export interface HerdStatus {
settings: ComplianceSettings;
baseline: AuthorityNotice | null;
current: HerdSnapshot;
/** null without a baseline: there is nothing to deviate from. */
deviation: HerdDeviation | null;
}
/** The settings row, created with its defaults when it does not exist yet. */
export async function complianceSettingsIn(
db: DbClient,
): Promise<ComplianceSettings> {
const existing = await db.complianceSettings.findUnique({
where: { id: COMPLIANCE_SETTINGS_ID },
});
if (existing) {
return existing;
}
return db.complianceSettings.upsert({
where: { id: COMPLIANCE_SETTINGS_ID },
update: {},
create: { id: COMPLIANCE_SETTINGS_ID },
});
}
/**
* The baseline is the newest SENT registration or herd change (by sentOn);
* TSK reports go to another recipient and do not count.
*/
export function findBaselineIn(db: DbClient): Promise<AuthorityNotice | null> {
return db.authorityNotice.findFirst({
where: { status: 'SENT', kind: { in: ['REGISTRATION', 'HERD_CHANGE'] } },
orderBy: [{ sentOn: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
});
}
export async function herdStatusIn(
db: DbClient,
now: Date,
): Promise<HerdStatus> {
const settings = await complianceSettingsIn(db);
const baseline = await findBaselineIn(db);
const current = buildHerdSnapshot(await loadHerdApiaryRows(db), now);
return {
settings,
baseline,
current,
deviation: baseline
? herdDeviation(
{
snapshot: baseline.snapshot as unknown as HerdSnapshot,
totalColonies: baseline.totalColonies,
},
current,
settings,
)
: null,
};
}
@@ -0,0 +1,178 @@
import { ForbiddenException } from '@nestjs/common';
import {
assertNoActiveRestriction,
isRestrictionActive,
} from './restricted-zone';
const day = (value: string): Date => new Date(`${value}T00:00:00.000Z`);
function row(overrides: Record<string, unknown> = {}) {
return {
id: 'r1',
apiaryId: 'a1',
apiary: { name: 'Stand Nord' },
disease: 'Faulbrut',
startsOn: day('2026-10-01'),
endsOn: null,
...overrides,
};
}
function fakeTx(rows: unknown[]) {
return { apiaryRestriction: { findMany: jest.fn().mockResolvedValue(rows) } };
}
interface HttpBody {
code: string;
message: string;
restrictionIds: string[];
restrictions: Record<string, string>[];
}
async function refusal(promise: Promise<unknown>): Promise<HttpBody> {
try {
await promise;
} catch (error) {
return (error as { getResponse: () => HttpBody }).getResponse();
}
throw new Error('expected a refusal');
}
describe('isRestrictionActive', () => {
it('active on the start and end day, inactive the day after', () => {
const restriction = {
startsOn: day('2026-10-01'),
endsOn: day('2026-10-10'),
};
expect(isRestrictionActive(restriction, '2026-09-30')).toBe(false);
expect(isRestrictionActive(restriction, '2026-10-01')).toBe(true);
expect(isRestrictionActive(restriction, '2026-10-10')).toBe(true);
expect(isRestrictionActive(restriction, '2026-10-11')).toBe(false);
});
it('an open end date means until further notice', () => {
const restriction = { startsOn: day('2026-10-01'), endsOn: null };
expect(isRestrictionActive(restriction, '2026-10-01')).toBe(true);
expect(isRestrictionActive(restriction, '2040-01-01')).toBe(true);
});
});
describe('assertNoActiveRestriction', () => {
it('passes without a query when no apiary is named', async () => {
const tx = fakeTx([]);
await expect(
assertNoActiveRestriction(tx as never, [], '2026-10-15'),
).resolves.toEqual({ overridden: false, restrictionIds: [] });
expect(tx.apiaryRestriction.findMany).not.toHaveBeenCalled();
});
it('passes when the only restriction ended yesterday', async () => {
const tx = fakeTx([row({ endsOn: day('2026-10-14') })]);
await expect(
assertNoActiveRestriction(tx as never, ['a1'], '2026-10-15'),
).resolves.toEqual({ overridden: false, restrictionIds: [] });
});
it('asks for each apiary once, with the unique ids', async () => {
const tx = fakeTx([]);
await assertNoActiveRestriction(
tx as never,
['a2', 'a1', 'a2'],
'2026-10-15',
);
const [[args]] = tx.apiaryRestriction.findMany.mock.calls as [
[{ where: { apiaryId: { in: string[] } } }],
];
expect([...args.where.apiaryId.in].sort()).toEqual(['a1', 'a2']);
});
it('refuses moving into and out of a restricted apiary', async () => {
const tx = fakeTx([row()]);
await expect(
assertNoActiveRestriction(tx as never, ['a0', 'a1'], '2026-10-15'),
).rejects.toMatchObject({ status: 409 });
});
it('the 409 body names apiary, disease and start', async () => {
const tx = fakeTx([row({ id: 'r9' })]);
const body = await refusal(
assertNoActiveRestriction(tx as never, ['a1'], '2026-10-15'),
);
expect(body.code).toBe('RESTRICTED_ZONE');
expect(body.restrictionIds).toEqual(['r9']);
expect(body.restrictions).toEqual([
{
id: 'r9',
apiaryId: 'a1',
apiaryName: 'Stand Nord',
disease: 'Faulbrut',
startsOn: '2026-10-01',
},
]);
expect(body.message).toContain('Stand Nord');
expect(body.message).toContain('Faulbrut');
expect(body.message).toContain('01.10.2026');
});
it('override needs compliance:manage and a reason', async () => {
const tx = fakeTx([row()]);
await expect(
assertNoActiveRestriction(tx as never, ['a1'], '2026-10-15', {
reason: 'Amtstierarzt hat es genehmigt',
canOverride: false,
}),
).rejects.toBeInstanceOf(ForbiddenException);
await expect(
assertNoActiveRestriction(tx as never, ['a1'], '2026-10-15', {
reason: 'Amtstierarzt hat es genehmigt',
canOverride: true,
}),
).resolves.toEqual({ overridden: true, restrictionIds: ['r1'] });
});
it.each([null, undefined])(
'a missing reason (%s) is no override even with the right',
async (reason) => {
const tx = fakeTx([row()]);
await expect(
assertNoActiveRestriction(tx as never, ['a1'], '2026-10-15', {
reason,
canOverride: true,
}),
).rejects.toMatchObject({ status: 409 });
},
);
it('a reason without an active restriction is ignored, even without the right', async () => {
const tx = fakeTx([]);
await expect(
assertNoActiveRestriction(tx as never, ['a1'], '2026-10-15', {
reason: 'Amtstierarzt hat es genehmigt',
canOverride: false,
}),
).resolves.toEqual({ overridden: false, restrictionIds: [] });
});
it('refuses the root client', async () => {
const root = {
$connect: jest.fn(),
apiaryRestriction: { findMany: jest.fn() },
};
await expect(
assertNoActiveRestriction(root as never, ['a1'], '2026-10-15'),
).rejects.toThrow('needs the transaction client');
});
});
@@ -0,0 +1,143 @@
import { ConflictException, ForbiddenException } from '@nestjs/common';
import { Prisma } from '../../generated/prisma';
import { dayToDate } from '../common/dates';
import { formatDayDe } from '../common/format-day';
import { assertTransactionClient, DbClient } from '../prisma/db-client';
/**
* What a caller may do about an active restriction: `reason` is the typed
* justification (null and undefined count as omitted), `canOverride` says
* whether the user holds compliance:manage.
*/
export interface RestrictionOverride {
reason: string | null | undefined;
canOverride: boolean;
}
export interface ActiveRestrictionInfo {
id: string;
apiaryId: string;
apiaryName: string;
disease: string;
startsOn: string;
}
export interface RestrictionCheck {
overridden: boolean;
restrictionIds: string[];
}
export const RESTRICTED_ZONE_CODE = 'RESTRICTED_ZONE';
const NO_RESTRICTION: RestrictionCheck = {
overridden: false,
restrictionIds: [],
};
function dayOf(value: Date): string {
return value.toISOString().slice(0, 10);
}
/**
* Active from the start day to the end day, both included; an open end date
* means until further notice. `day` is a Berlin calendar day (YYYY-MM-DD),
* the dates are stored at midnight UTC of their day.
*/
export function isRestrictionActive(
restriction: { startsOn: Date; endsOn: Date | null },
day: string,
): boolean {
return (
dayOf(restriction.startsOn) <= day &&
(restriction.endsOn === null || day <= dayOf(restriction.endsOn))
);
}
type RestrictionRow = Prisma.ApiaryRestrictionGetPayload<{
include: { apiary: { select: { name: true } } };
}>;
export function toActiveRestrictionInfo(
row: RestrictionRow,
): ActiveRestrictionInfo {
return {
id: row.id,
apiaryId: row.apiaryId,
apiaryName: row.apiary.name,
disease: row.disease,
startsOn: dayOf(row.startsOn),
};
}
/** The restrictions that started on or before `day` (the caller filters the end). */
export function startedRestrictions(
db: DbClient,
apiaryIds: readonly string[] | undefined,
day: string,
): Promise<RestrictionRow[]> {
return db.apiaryRestriction.findMany({
where: {
apiaryId: apiaryIds ? { in: [...apiaryIds] } : undefined,
startsOn: { lte: dayToDate(day) },
},
include: { apiary: { select: { name: true } } },
orderBy: [{ startsOn: 'asc' }, { id: 'asc' }],
});
}
function refusalMessage(infos: readonly ActiveRestrictionInfo[]): string {
const places = infos.map(
(info) =>
`„${info.apiaryName}“ (${info.disease}, seit ${formatDayDe(info.startsOn, true)})`,
);
return infos.length === 1
? `Der Bienenstand ${places[0]} liegt im Sperrbezirk. Völker dürfen dort nicht angelegt, umgesetzt oder vereinigt werden.`
: `Die Bienenstände ${places.join(', ')} liegen im Sperrbezirk. Völker dürfen dort nicht angelegt, umgesetzt oder vereinigt werden.`;
}
/**
* Refuses a colony being brought into, out of or between apiaries while one of
* them lies in an active restricted zone (409 RESTRICTED_ZONE). Runs inside the
* caller's transaction AFTER the apiary rows are locked (FOR SHARE), so a
* restriction cannot start between the check and the write.
*
* A reason with the right to use it (`canOverride`) lets the action through;
* a reason without the right is a 403. A reason without any active
* restriction is ignored. The caller writes the reason into audit and
* history, never onto the colony.
*/
export async function assertNoActiveRestriction(
tx: Prisma.TransactionClient,
apiaryIds: readonly string[],
day: string,
override?: RestrictionOverride,
): Promise<RestrictionCheck> {
assertTransactionClient(tx, 'assertNoActiveRestriction');
const ids = [...new Set(apiaryIds)];
if (ids.length === 0) {
return NO_RESTRICTION;
}
const active = (await startedRestrictions(tx, ids, day)).filter((row) =>
isRestrictionActive(row, day),
);
if (active.length === 0) {
return NO_RESTRICTION;
}
const restrictionIds = active.map((row) => row.id);
if (override?.reason) {
if (!override.canOverride) {
throw new ForbiddenException(
'Für eine Ausnahme vom Sperrbezirk fehlt das Recht Prüfbereitschaft verwalten (compliance:manage).',
);
}
return { overridden: true, restrictionIds };
}
const restrictions = active.map(toActiveRestrictionInfo);
throw new ConflictException({
statusCode: 409,
code: RESTRICTED_ZONE_CODE,
message: refusalMessage(restrictions),
restrictionIds,
restrictions,
});
}
@@ -0,0 +1,25 @@
import { isValidMonthDay, tskReferenceDayOf } from './tsk-day';
describe('isValidMonthDay', () => {
it.each(['02-30', '02-29', '1-1', '13-01', '00-10', 'ab-cd', '01-32', ''])(
'rejects %s',
(value) => {
expect(isValidMonthDay(value)).toBe(false);
},
);
it.each(['01-01', '12-31', '02-28', '04-30'])('accepts %s', (value) => {
expect(isValidMonthDay(value)).toBe(true);
});
});
describe('tskReferenceDayOf', () => {
it('joins year and month-day into a day', () => {
expect(tskReferenceDayOf(2027, '01-01')).toBe('2027-01-01');
expect(tskReferenceDayOf(2028, '12-31')).toBe('2028-12-31');
});
it('rejects an invalid month-day', () => {
expect(() => tskReferenceDayOf(2027, '02-29')).toThrow();
});
});
+25
View File
@@ -0,0 +1,25 @@
const MONTH_DAY_PATTERN = /^(\d{2})-(\d{2})$/;
// Days per month of a non-leap year: 02-29 is not a valid reference day, so
// the reference day exists in every year.
const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
/** MM-TT with a real month and a day that exists in a non-leap year. */
export function isValidMonthDay(value: string): boolean {
const match = MONTH_DAY_PATTERN.exec(value);
if (!match) {
return false;
}
const month = Number(match[1]);
const day = Number(match[2]);
return (
month >= 1 && month <= 12 && day >= 1 && day <= DAYS_IN_MONTH[month - 1]
);
}
/** The reference day (YYYY-MM-DD) of a year for a stored MM-TT. */
export function tskReferenceDayOf(year: number, monthDay: string): string {
if (!isValidMonthDay(monthDay)) {
throw new Error(`Invalid month-day: ${monthDay}`);
}
return `${String(year).padStart(4, '0')}-${monthDay}`;
}