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,22 @@
|
||||
import {
|
||||
describeNullContracts,
|
||||
NullContract,
|
||||
} from '../common/validation/null-contract.spec-helper';
|
||||
import { UpdateColonyDto } from '../colonies/dto/update-colony.dto';
|
||||
import { UpdateApiaryRestrictionDto } from './dto/update-apiary-restriction.dto';
|
||||
|
||||
const contracts: Record<string, NullContract> = {
|
||||
UpdateApiaryRestrictionDto: {
|
||||
dto: UpdateApiaryRestrictionDto,
|
||||
notNull: ['disease', 'startsOn'],
|
||||
clearable: ['endsOn', 'authority', 'reference', 'notes'],
|
||||
},
|
||||
// The reason is only read, never stored: null means "no override".
|
||||
UpdateColonyDto: {
|
||||
dto: UpdateColonyDto,
|
||||
notNull: ['status'],
|
||||
clearable: ['restrictionOverrideReason'],
|
||||
},
|
||||
};
|
||||
|
||||
describeNullContracts('Apiary restriction DTOs', contracts);
|
||||
@@ -0,0 +1,82 @@
|
||||
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 { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import { Idempotent } from '../common/idempotency/idempotent.decorator';
|
||||
import { ApiaryRestrictionsService } from './apiary-restrictions.service';
|
||||
import { CreateApiaryRestrictionDto } from './dto/create-apiary-restriction.dto';
|
||||
import { RestrictionListQuery } from './dto/restriction-list-query.dto';
|
||||
import { UpdateApiaryRestrictionDto } from './dto/update-apiary-restriction.dto';
|
||||
|
||||
// Full paths on an empty prefix: the list lives under /apiary-restrictions,
|
||||
// the per-apiary read under /apiaries/:id/restrictions.
|
||||
@ApiTags('apiary-restrictions')
|
||||
@ApiBearerAuth()
|
||||
@Controller()
|
||||
export class ApiaryRestrictionsController {
|
||||
constructor(
|
||||
private readonly restrictions: ApiaryRestrictionsService,
|
||||
private readonly permissions: PermissionResolverService,
|
||||
) {}
|
||||
|
||||
// Authority, file reference and notes are for compliance:read only.
|
||||
private async mayReadDetails(actor: AuthenticatedUser): Promise<boolean> {
|
||||
return (await this.permissions.getPermissions(actor.id)).has(
|
||||
'compliance:read',
|
||||
);
|
||||
}
|
||||
|
||||
@RequirePermission('apiaries:read')
|
||||
@Get('apiary-restrictions')
|
||||
async findAll(
|
||||
@Query() query: RestrictionListQuery,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.restrictions.list(
|
||||
query.active,
|
||||
await this.mayReadDetails(actor),
|
||||
);
|
||||
}
|
||||
|
||||
@RequirePermission('apiaries:read')
|
||||
@Get('apiaries/:id/restrictions')
|
||||
async findForApiary(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.restrictions.findForApiary(
|
||||
id,
|
||||
await this.mayReadDetails(actor),
|
||||
);
|
||||
}
|
||||
|
||||
@RequirePermission('compliance:manage')
|
||||
@Idempotent()
|
||||
@Post('apiary-restrictions')
|
||||
create(
|
||||
@Body() dto: CreateApiaryRestrictionDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.restrictions.create(dto, actor.id);
|
||||
}
|
||||
|
||||
@RequirePermission('compliance:manage')
|
||||
@Patch('apiary-restrictions/:id')
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateApiaryRestrictionDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.restrictions.update(id, dto, actor.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ApiaryRestrictionsController } from './apiary-restrictions.controller';
|
||||
import { ApiaryRestrictionsService } from './apiary-restrictions.service';
|
||||
|
||||
/**
|
||||
* Not needed in WorkerModule: the service injects no permission resolver and
|
||||
* the guard helper (compliance/restricted-zone.ts) is a plain function.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [ApiaryRestrictionsController],
|
||||
providers: [ApiaryRestrictionsService],
|
||||
exports: [ApiaryRestrictionsService],
|
||||
})
|
||||
export class ApiaryRestrictionsModule {}
|
||||
@@ -0,0 +1,295 @@
|
||||
import { Prisma } from '../../generated/prisma';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { ApiaryRestrictionsService } from './apiary-restrictions.service';
|
||||
|
||||
const day = (value: string): Date => new Date(`${value}T00:00:00.000Z`);
|
||||
|
||||
describe('ApiaryRestrictionsService', () => {
|
||||
let service: ApiaryRestrictionsService;
|
||||
let tx: {
|
||||
$queryRaw: jest.Mock;
|
||||
apiary: { findMany: jest.Mock };
|
||||
apiaryRestriction: {
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
};
|
||||
};
|
||||
let prisma: {
|
||||
apiary: { findUnique: jest.Mock };
|
||||
apiaryRestriction: { findMany: jest.Mock; findUnique: jest.Mock };
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
tx = {
|
||||
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||
apiary: { findMany: jest.fn() },
|
||||
apiaryRestriction: {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
};
|
||||
prisma = {
|
||||
apiary: { findUnique: jest.fn() },
|
||||
apiaryRestriction: { findMany: jest.fn(), findUnique: jest.fn() },
|
||||
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
|
||||
callback(tx),
|
||||
),
|
||||
};
|
||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new ApiaryRestrictionsService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
);
|
||||
});
|
||||
|
||||
function lockStatements(): string[] {
|
||||
return (tx.$queryRaw.mock.calls as [Prisma.Sql][]).map(
|
||||
([query]) => query.sql,
|
||||
);
|
||||
}
|
||||
|
||||
describe('create', () => {
|
||||
const dto = {
|
||||
apiaryIds: ['a2', 'a1'],
|
||||
disease: 'Faulbrut',
|
||||
startsOn: '2026-10-01',
|
||||
};
|
||||
|
||||
it('creates one row per apiary in one transaction, audits each, locks the apiaries first', async () => {
|
||||
tx.apiary.findMany.mockResolvedValue([{ id: 'a1' }, { id: 'a2' }]);
|
||||
tx.apiaryRestriction.create.mockImplementation(
|
||||
({ data }: { data: { apiaryId: string } }) =>
|
||||
Promise.resolve({ id: `r-${data.apiaryId}`, ...data }),
|
||||
);
|
||||
|
||||
const created = await service.create(dto, 'user-1');
|
||||
|
||||
expect(created.map((row) => row.apiaryId)).toEqual(['a1', 'a2']);
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
expect(lockStatements()).toEqual([
|
||||
'SELECT id FROM "Apiary" WHERE id = ANY(?) ORDER BY id FOR UPDATE',
|
||||
]);
|
||||
const [[firstRow]] = tx.apiaryRestriction.create.mock.calls as [
|
||||
[{ data: Record<string, unknown> }],
|
||||
];
|
||||
expect(firstRow.data).toMatchObject({
|
||||
apiaryId: 'a1',
|
||||
disease: 'Faulbrut',
|
||||
startsOn: day('2026-10-01'),
|
||||
createdByUserId: 'user-1',
|
||||
});
|
||||
expect(auditLog.record).toHaveBeenCalledTimes(2);
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'ApiaryRestriction',
|
||||
action: 'CREATE',
|
||||
userId: 'user-1',
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
tx.apiaryRestriction.create.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls all back when one apiary is unknown (404 after the lock, nothing written)', async () => {
|
||||
tx.apiary.findMany.mockResolvedValue([{ id: 'a1' }]);
|
||||
|
||||
await expect(service.create(dto, 'user-1')).rejects.toMatchObject({
|
||||
status: 404,
|
||||
});
|
||||
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
expect(tx.apiaryRestriction.create).not.toHaveBeenCalled();
|
||||
expect(auditLog.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an endsOn before startsOn with 400 before any lock', async () => {
|
||||
await expect(
|
||||
service.create({ ...dto, endsOn: '2026-09-30' }, 'user-1'),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects no apiary and more than 50 apiaries with 400', async () => {
|
||||
const ids = Array.from({ length: 51 }, (_, index) => `a${index}`);
|
||||
|
||||
await expect(
|
||||
service.create({ ...dto, apiaryIds: ids }, 'user-1'),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
await expect(
|
||||
service.create({ ...dto, apiaryIds: [] }, 'user-1'),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
const stored = {
|
||||
id: 'r1',
|
||||
apiaryId: 'a1',
|
||||
disease: 'Faulbrut',
|
||||
startsOn: day('2026-10-01'),
|
||||
endsOn: null as Date | null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
prisma.apiaryRestriction.findUnique.mockResolvedValue(stored);
|
||||
tx.apiaryRestriction.findUnique.mockResolvedValue(stored);
|
||||
tx.apiaryRestriction.update.mockImplementation(
|
||||
({ data }: { data: object }) => Promise.resolve({ ...stored, ...data }),
|
||||
);
|
||||
});
|
||||
|
||||
it('locks the apiary FOR UPDATE, then the restriction, then reads before', async () => {
|
||||
await service.update('r1', { endsOn: '2026-10-20' }, 'user-1');
|
||||
|
||||
expect(lockStatements()).toEqual([
|
||||
'SELECT id FROM "Apiary" WHERE id = ANY(?) ORDER BY id FOR UPDATE',
|
||||
'SELECT id FROM "ApiaryRestriction" WHERE id = ? FOR UPDATE',
|
||||
]);
|
||||
expect(tx.$queryRaw.mock.invocationCallOrder[1]).toBeLessThan(
|
||||
tx.apiaryRestriction.findUnique.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('ending a restriction is an update of endsOn, nothing is deleted', async () => {
|
||||
const result = await service.update(
|
||||
'r1',
|
||||
{ endsOn: '2026-10-20' },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(tx.apiaryRestriction.update).toHaveBeenCalledWith({
|
||||
where: { id: 'r1' },
|
||||
data: { endsOn: day('2026-10-20') },
|
||||
});
|
||||
expect(result.endsOn).toEqual(day('2026-10-20'));
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entityType: 'ApiaryRestriction',
|
||||
entityId: 'r1',
|
||||
action: 'UPDATE',
|
||||
beforeData: stored,
|
||||
}),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('null clears endsOn', async () => {
|
||||
tx.apiaryRestriction.findUnique.mockResolvedValue({
|
||||
...stored,
|
||||
endsOn: day('2026-10-20'),
|
||||
});
|
||||
|
||||
await service.update('r1', { endsOn: null }, 'user-1');
|
||||
|
||||
expect(tx.apiaryRestriction.update).toHaveBeenCalledWith({
|
||||
where: { id: 'r1' },
|
||||
data: { endsOn: null },
|
||||
});
|
||||
});
|
||||
|
||||
it('checks endsOn >= startsOn on the merged state (400)', async () => {
|
||||
await expect(
|
||||
service.update('r1', { endsOn: '2026-09-30' }, 'user-1'),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
await expect(
|
||||
service.update('r1', { startsOn: '2026-11-01' }, 'user-1'),
|
||||
).resolves.toBeDefined();
|
||||
tx.apiaryRestriction.findUnique.mockResolvedValue({
|
||||
...stored,
|
||||
endsOn: day('2026-10-20'),
|
||||
});
|
||||
await expect(
|
||||
service.update('r1', { startsOn: '2026-11-01' }, 'user-1'),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
expect(tx.apiaryRestriction.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('answers 404 for an unknown restriction without locking', async () => {
|
||||
prisma.apiaryRestriction.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.update('missing', { notes: 'x' }, 'user-1'),
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
expect(tx.$queryRaw).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reads', () => {
|
||||
it('findActive lists only restrictions active today, with the apiary name', async () => {
|
||||
prisma.apiaryRestriction.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'r1',
|
||||
apiaryId: 'a1',
|
||||
apiary: { name: 'Nord' },
|
||||
disease: 'Faulbrut',
|
||||
startsOn: day('2000-01-01'),
|
||||
endsOn: null,
|
||||
},
|
||||
{
|
||||
id: 'r2',
|
||||
apiaryId: 'a2',
|
||||
apiary: { name: 'Sued' },
|
||||
disease: 'Faulbrut',
|
||||
startsOn: day('2000-01-01'),
|
||||
endsOn: day('2000-01-02'),
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(service.findActive()).resolves.toEqual([
|
||||
{
|
||||
id: 'r1',
|
||||
apiaryId: 'a1',
|
||||
apiaryName: 'Nord',
|
||||
disease: 'Faulbrut',
|
||||
startsOn: '2000-01-01',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('without compliance:read the lists carry no authority, reference, notes or creator', async () => {
|
||||
const row = {
|
||||
id: 'r1',
|
||||
apiaryId: 'a1',
|
||||
disease: 'Faulbrut',
|
||||
authority: 'Veterinaeramt',
|
||||
reference: 'AZ 12/3',
|
||||
notes: 'intern',
|
||||
createdByUserId: 'u1',
|
||||
startsOn: day('2026-10-01'),
|
||||
endsOn: null,
|
||||
};
|
||||
prisma.apiaryRestriction.findMany.mockResolvedValue([row]);
|
||||
prisma.apiary.findUnique.mockResolvedValue({ id: 'a1' });
|
||||
const expected = [
|
||||
{
|
||||
id: 'r1',
|
||||
apiaryId: 'a1',
|
||||
disease: 'Faulbrut',
|
||||
startsOn: '2026-10-01',
|
||||
endsOn: null,
|
||||
},
|
||||
];
|
||||
|
||||
await expect(service.list(undefined, false)).resolves.toEqual(expected);
|
||||
await expect(service.findForApiary('a1', false)).resolves.toEqual(
|
||||
expected,
|
||||
);
|
||||
await expect(service.list(undefined, true)).resolves.toEqual([row]);
|
||||
});
|
||||
|
||||
it('findForApiary answers 404 for an unknown apiary', async () => {
|
||||
prisma.apiary.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findForApiary('missing')).rejects.toMatchObject({
|
||||
status: 404,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ApiaryRestriction } from '../../generated/prisma';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { berlinToday, dayToDate } from '../common/dates';
|
||||
import { optionalDate } from '../common/patch-values';
|
||||
import {
|
||||
ActiveRestrictionInfo,
|
||||
isRestrictionActive,
|
||||
startedRestrictions,
|
||||
toActiveRestrictionInfo,
|
||||
} from '../compliance/restricted-zone';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
|
||||
import {
|
||||
CreateApiaryRestrictionDto,
|
||||
MAX_RESTRICTION_APIARIES,
|
||||
} from './dto/create-apiary-restriction.dto';
|
||||
import { UpdateApiaryRestrictionDto } from './dto/update-apiary-restriction.dto';
|
||||
|
||||
const AUDIT_ENTITY = 'ApiaryRestriction';
|
||||
|
||||
/** What a reader without compliance:read sees: no authority, reference, notes or creator. */
|
||||
export interface RestrictionSummary {
|
||||
id: string;
|
||||
apiaryId: string;
|
||||
disease: string;
|
||||
startsOn: string;
|
||||
endsOn: string | null;
|
||||
}
|
||||
|
||||
function toSummary(row: ApiaryRestriction): RestrictionSummary {
|
||||
return {
|
||||
id: row.id,
|
||||
apiaryId: row.apiaryId,
|
||||
disease: row.disease,
|
||||
startsOn: row.startsOn.toISOString().slice(0, 10),
|
||||
endsOn: row.endsOn ? row.endsOn.toISOString().slice(0, 10) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function assertEndNotBeforeStart(startsOn: Date, endsOn: Date | null): void {
|
||||
if (endsOn !== null && endsOn < startsOn) {
|
||||
throw new BadRequestException(
|
||||
'Das Ende der Sperre darf nicht vor ihrem Beginn liegen',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restricted zones (Sperrbezirke) of apiaries. A restriction is never deleted:
|
||||
* it ends by an endsOn date, so the colony history stays explainable.
|
||||
*
|
||||
* Lock order (Task 1): the apiary rows FOR UPDATE first, then the restriction
|
||||
* row. A colony path holds its apiaries FOR SHARE until it commits, so a
|
||||
* restriction and a move into that apiary queue up behind each other.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ApiaryRestrictionsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
) {}
|
||||
|
||||
/** One row per apiary, all in one transaction; an unknown apiary rolls all back. */
|
||||
async create(
|
||||
dto: CreateApiaryRestrictionDto,
|
||||
actingUserId: string,
|
||||
): Promise<ApiaryRestriction[]> {
|
||||
const apiaryIds = [...new Set(dto.apiaryIds)].sort();
|
||||
if (apiaryIds.length < 1 || apiaryIds.length > MAX_RESTRICTION_APIARIES) {
|
||||
throw new BadRequestException(
|
||||
`Es sind 1 bis ${MAX_RESTRICTION_APIARIES} Stände möglich`,
|
||||
);
|
||||
}
|
||||
const startsOn = dayToDate(dto.startsOn);
|
||||
const endsOn = dto.endsOn ? dayToDate(dto.endsOn) : null;
|
||||
assertEndNotBeforeStart(startsOn, endsOn);
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await lockRowsForUpdate(tx, 'Apiary', apiaryIds);
|
||||
const known = await tx.apiary.findMany({
|
||||
where: { id: { in: apiaryIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (known.length !== apiaryIds.length) {
|
||||
throw new NotFoundException('Bienenstand nicht gefunden');
|
||||
}
|
||||
const created: ApiaryRestriction[] = [];
|
||||
for (const apiaryId of apiaryIds) {
|
||||
const restriction = await tx.apiaryRestriction.create({
|
||||
data: {
|
||||
apiaryId,
|
||||
disease: dto.disease,
|
||||
authority: dto.authority,
|
||||
reference: dto.reference,
|
||||
startsOn,
|
||||
endsOn,
|
||||
notes: dto.notes,
|
||||
createdByUserId: actingUserId,
|
||||
},
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: AUDIT_ENTITY,
|
||||
entityId: restriction.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: restriction,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
created.push(restriction);
|
||||
}
|
||||
return created;
|
||||
});
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateApiaryRestrictionDto,
|
||||
actingUserId: string,
|
||||
): Promise<ApiaryRestriction> {
|
||||
const seen = await this.prisma.apiaryRestriction.findUnique({
|
||||
where: { id },
|
||||
select: { apiaryId: true },
|
||||
});
|
||||
if (!seen) {
|
||||
throw new NotFoundException('Sperrbezirk nicht gefunden');
|
||||
}
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await lockRowsForUpdate(tx, 'Apiary', [seen.apiaryId]);
|
||||
await lockRowForUpdate(tx, 'ApiaryRestriction', id);
|
||||
const before = await tx.apiaryRestriction.findUnique({ where: { id } });
|
||||
if (!before) {
|
||||
throw new NotFoundException('Sperrbezirk nicht gefunden');
|
||||
}
|
||||
const startsOnPatch =
|
||||
dto.startsOn === undefined ? undefined : dayToDate(dto.startsOn);
|
||||
const startsOn = startsOnPatch ?? before.startsOn;
|
||||
const endsOn = optionalDate(dto.endsOn);
|
||||
assertEndNotBeforeStart(
|
||||
startsOn,
|
||||
endsOn === undefined ? before.endsOn : endsOn,
|
||||
);
|
||||
const updated = await tx.apiaryRestriction.update({
|
||||
where: { id },
|
||||
data: {
|
||||
disease: dto.disease,
|
||||
authority: dto.authority,
|
||||
reference: dto.reference,
|
||||
startsOn: startsOnPatch,
|
||||
endsOn,
|
||||
notes: dto.notes,
|
||||
},
|
||||
});
|
||||
await this.auditLog.record(
|
||||
{
|
||||
entityType: AUDIT_ENTITY,
|
||||
entityId: id,
|
||||
action: 'UPDATE',
|
||||
userId: actingUserId,
|
||||
beforeData: before,
|
||||
afterData: updated,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
/** The restrictions in force today (Berlin day), with the apiary name. */
|
||||
async findActive(): Promise<ActiveRestrictionInfo[]> {
|
||||
const today = berlinToday();
|
||||
const started = await startedRestrictions(this.prisma, undefined, today);
|
||||
return started
|
||||
.filter((row) => isRestrictionActive(row, today))
|
||||
.map(toActiveRestrictionInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every restriction (newest first); `active` narrows to in force / ended.
|
||||
* Authority, file reference, notes and the creator are for callers with
|
||||
* compliance:read (`detailed`); everybody else gets the summary.
|
||||
*/
|
||||
async list(
|
||||
active?: boolean,
|
||||
detailed = true,
|
||||
): Promise<
|
||||
ApiaryRestriction[] | ActiveRestrictionInfo[] | RestrictionSummary[]
|
||||
> {
|
||||
if (active === true) {
|
||||
return this.findActive();
|
||||
}
|
||||
const rows = await this.prisma.apiaryRestriction.findMany({
|
||||
orderBy: [{ startsOn: 'desc' }, { id: 'asc' }],
|
||||
});
|
||||
const wanted =
|
||||
active === false
|
||||
? rows.filter((row) => !isRestrictionActive(row, berlinToday()))
|
||||
: rows;
|
||||
return detailed ? wanted : wanted.map(toSummary);
|
||||
}
|
||||
|
||||
async findForApiary(
|
||||
apiaryId: string,
|
||||
detailed = true,
|
||||
): Promise<ApiaryRestriction[] | RestrictionSummary[]> {
|
||||
const apiary = await this.prisma.apiary.findUnique({
|
||||
where: { id: apiaryId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!apiary) {
|
||||
throw new NotFoundException('Bienenstand nicht gefunden');
|
||||
}
|
||||
const rows = await this.prisma.apiaryRestriction.findMany({
|
||||
where: { apiaryId },
|
||||
orderBy: [{ startsOn: 'desc' }, { id: 'asc' }],
|
||||
});
|
||||
return detailed ? rows : rows.map(toSummary);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export const MAX_RESTRICTION_APIARIES = 50;
|
||||
const MAX_ID_LENGTH = 64;
|
||||
const MAX_TEXT_LENGTH = 200;
|
||||
const MAX_NOTE_LENGTH = 2000;
|
||||
const ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const DAY_MESSAGE = 'muss ein Datum im Format JJJJ-MM-TT sein';
|
||||
|
||||
/** A restricted zone declared by the authority, for one or several apiaries. */
|
||||
export class CreateApiaryRestrictionDto {
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
description: 'Ein Datensatz je Stand, 1 bis 50 Stände',
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(MAX_RESTRICTION_APIARIES)
|
||||
@ArrayUnique()
|
||||
@IsString({ each: true })
|
||||
@Matches(ID_PATTERN, { each: true })
|
||||
@MaxLength(MAX_ID_LENGTH, { each: true })
|
||||
apiaryIds!: string[];
|
||||
|
||||
@ApiProperty({ description: 'Seuche, z. B. Amerikanische Faulbrut' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Matches(/\S/, { message: 'disease darf nicht nur aus Leerzeichen bestehen' })
|
||||
@MaxLength(MAX_TEXT_LENGTH)
|
||||
disease!: string;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true, description: 'Anordnende Behörde' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_TEXT_LENGTH)
|
||||
authority?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true, description: 'Aktenzeichen' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_TEXT_LENGTH)
|
||||
reference?: string | null;
|
||||
|
||||
@ApiProperty({ example: '2026-10-01', description: 'Erster Tag der Sperre' })
|
||||
@Matches(DAY_PATTERN, { message: `startsOn ${DAY_MESSAGE}` })
|
||||
@IsDateString({ strict: true })
|
||||
startsOn!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
example: '2026-12-01',
|
||||
description: 'Letzter Tag der Sperre; leer = bis auf Weiteres',
|
||||
})
|
||||
@IsOptional()
|
||||
@Matches(DAY_PATTERN, { message: `endsOn ${DAY_MESSAGE}` })
|
||||
@IsDateString({ strict: true })
|
||||
endsOn?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(MAX_NOTE_LENGTH)
|
||||
notes?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
import { booleanQuery } from '../../veterinary-medicines/dto/medicine-list-query.dto';
|
||||
|
||||
export class RestrictionListQuery {
|
||||
@ApiPropertyOptional({
|
||||
description: 'true: nur heute geltende Sperren; false: nur beendete',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(booleanQuery)
|
||||
@IsBoolean()
|
||||
active?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { OmitType, PartialType } from '@nestjs/swagger';
|
||||
import { CreateApiaryRestrictionDto } from './create-apiary-restriction.dto';
|
||||
|
||||
/**
|
||||
* The apiary of a restriction is fixed: a restriction for another stand is a
|
||||
* new restriction. Omitted stays, null clears a nullable field (endsOn ends
|
||||
* the restriction as open again), null on disease or startsOn is a 400.
|
||||
*/
|
||||
export class UpdateApiaryRestrictionDto extends PartialType(
|
||||
OmitType(CreateApiaryRestrictionDto, ['apiaryIds'] as const),
|
||||
{ skipNullProperties: false },
|
||||
) {}
|
||||
Reference in new issue
Block a user