feat: T6 wave 3 – treatment books from lot, label print lock, restricted zone, authority notices
- 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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in new issue
Block a user