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

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

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

No files matched your search

@@ -0,0 +1,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 },
) {}
+2
View File
@@ -61,6 +61,7 @@ import { AutopilotModule } from './autopilot/autopilot.module';
import { SystemModule } from './system/system.module';
import { WithdrawalModule } from './withdrawal/withdrawal.module';
import { VeterinaryMedicinesModule } from './veterinary-medicines/veterinary-medicines.module';
import { ComplianceModule } from './compliance/compliance.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { PermissionsGuard } from './auth/guards/permissions.guard';
import { PermissionsModule } from './auth/permissions/permissions.module';
@@ -134,6 +135,7 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
ApiKeysModule,
SystemModule,
VeterinaryMedicinesModule,
ComplianceModule,
],
controllers: [],
providers: [
@@ -1,8 +1,12 @@
import { Module } from '@nestjs/common';
import { ApiaryRestrictionsModule } from '../apiary-restrictions/apiary-restrictions.module';
import { ColoniesController } from './colonies.controller';
import { ColoniesService } from './colonies.service';
// ApiaryRestrictionsModule: the restricted-zone endpoints ride along with the
// colony paths that enforce them (AppModule imports ColoniesModule).
@Module({
imports: [ApiaryRestrictionsModule],
controllers: [ColoniesController],
providers: [ColoniesService],
exports: [ColoniesService],
@@ -4,6 +4,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { berlinToday } from '../common/dates';
import { expectOnlyIdPayloads } from '../domain-events/herd-payload.spec-helper';
@@ -16,6 +17,8 @@ describe('ColoniesService', () => {
colonyLineage: { create: jest.Mock };
queenColonyHistory: { updateMany: jest.Mock };
suggestion: { updateMany: jest.Mock };
apiary: { findUnique: jest.Mock };
apiaryRestriction: { findMany: jest.Mock };
};
let prisma: {
colony: {
@@ -32,6 +35,7 @@ describe('ColoniesService', () => {
};
let auditLog: { record: jest.Mock };
let events: { record: jest.Mock; recordAll: jest.Mock };
let resolver: { getPermissions: jest.Mock };
const parentColony = {
id: 'colony-1',
@@ -49,10 +53,12 @@ describe('ColoniesService', () => {
colonyLineage: { create: jest.fn() },
queenColonyHistory: { updateMany: jest.fn() },
suggestion: { updateMany: jest.fn() },
apiary: { findUnique: jest.fn() },
apiaryRestriction: { findMany: jest.fn().mockResolvedValue([]) },
};
prisma = {
colony: {
findMany: jest.fn(),
findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
@@ -69,6 +75,10 @@ describe('ColoniesService', () => {
callback(tx),
),
};
// The apiary is read inside the transaction now: same answers as before.
tx.apiary.findUnique.mockImplementation(
(args: unknown) => prisma.apiary.findUnique(args) as Promise<unknown>,
);
// The locked re-read sees what the pre-read saw unless a test says otherwise.
tx.colony.findUnique.mockImplementation(
(args: unknown) => prisma.colony.findUnique(args) as Promise<unknown>,
@@ -79,11 +89,17 @@ describe('ColoniesService', () => {
record: jest.fn().mockResolvedValue(undefined),
recordAll: jest.fn().mockResolvedValue(0),
};
resolver = {
getPermissions: jest
.fn()
.mockResolvedValue(new Set(['compliance:manage'])),
};
service = new ColoniesService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
webhooks as unknown as WebhooksService,
events as unknown as DomainEventsService,
resolver as unknown as PermissionResolverService,
);
});
@@ -237,17 +253,23 @@ describe('ColoniesService', () => {
});
it('merge marks the merged colony as MERGED, releases its queen, and records a MERGE lineage entry', async () => {
prisma.colony.findUnique
.mockResolvedValueOnce(parentColony)
.mockResolvedValueOnce({
...parentColony,
id: 'colony-1',
});
tx.colony.findUnique.mockResolvedValue({
id: 'colony-3',
status: 'ACTIVE',
currentQueenId: 'queen-2',
});
prisma.colony.findUnique.mockResolvedValue(parentColony);
prisma.colony.findMany.mockResolvedValue([
{ id: 'colony-3', apiaryId: 'apiary-1' },
]);
tx.colony.findUnique.mockImplementation(
({ where }: { where: { id: string } }) =>
Promise.resolve(
where.id === 'colony-1'
? parentColony
: {
id: 'colony-3',
apiaryId: 'apiary-1',
status: 'ACTIVE',
currentQueenId: 'queen-2',
},
),
);
tx.colony.update.mockResolvedValue({
id: 'colony-3',
status: 'MERGED',
@@ -725,10 +747,15 @@ describe('ColoniesService', () => {
it('merge records ColonyEnded "MERGED" for every merged colony', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
prisma.colony.findMany.mockResolvedValue([
{ id: 'colony-3', apiaryId: 'apiary-1' },
{ id: 'colony-4', apiaryId: 'apiary-1' },
]);
tx.colony.findUnique.mockImplementation(
({ where }: { where: { id: string } }) =>
Promise.resolve({
id: where.id,
apiaryId: 'apiary-1',
status: 'ACTIVE',
healthStatus: null,
currentQueenId: null,
@@ -809,7 +836,8 @@ describe('ColoniesService', () => {
});
await service.reactivate('colony-1', 'user-1');
expect(tx.$queryRaw).toHaveBeenCalledTimes(4);
// update 1, move 2 (apiary share + colony), dissolve 1, reactivate 1.
expect(tx.$queryRaw).toHaveBeenCalledTimes(5);
});
it('uses the locked row, not the pre-read, as "before" for event and audit', async () => {
@@ -860,9 +888,18 @@ describe('ColoniesService', () => {
it('locks every merged colony', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
prisma.colony.findMany.mockResolvedValue([
{ id: 'colony-3', apiaryId: 'apiary-1' },
{ id: 'colony-4', apiaryId: 'apiary-1' },
]);
tx.colony.findUnique.mockImplementation(
({ where }: { where: { id: string } }) =>
Promise.resolve({ id: where.id, status: 'ACTIVE', healthStatus: null }),
Promise.resolve({
id: where.id,
apiaryId: 'apiary-1',
status: 'ACTIVE',
healthStatus: null,
}),
);
tx.colony.update.mockImplementation(
({ where }: { where: { id: string } }) =>
@@ -875,10 +912,12 @@ describe('ColoniesService', () => {
'user-1',
);
// One multi-row statement, not one per colony.
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
const [[query]] = tx.$queryRaw.mock.calls as [[{ sql: string }]];
expect(query.sql).toContain('ANY(?) ORDER BY id FOR UPDATE');
// The apiary rows (share), then ONE multi-row statement for the colonies.
expect(tx.$queryRaw).toHaveBeenCalledTimes(2);
const [, [query]] = tx.$queryRaw.mock.calls as [unknown, [{ sql: string }]];
expect(query.sql).toContain(
'"Colony" WHERE id = ANY(?) ORDER BY id FOR UPDATE',
);
});
describe('a colony that ends through an edit', () => {
@@ -932,4 +971,521 @@ describe('ColoniesService', () => {
expect(tx.queenColonyHistory.updateMany).not.toHaveBeenCalled();
});
});
describe('restricted zone', () => {
const reason = 'Amtstierarzt hat die Verbringung genehmigt';
const reloadMessage = 'Der Stand wurde gerade geändert. Bitte neu laden.';
const startedLongAgo = new Date('2000-01-01T00:00:00.000Z');
// An open restriction on each of the given apiaries.
function restrict(...apiaryIds: string[]): void {
tx.apiaryRestriction.findMany.mockImplementation(
({ where }: { where: { apiaryId: { in: string[] } } }) =>
Promise.resolve(
apiaryIds
.filter((apiaryId) => where.apiaryId.in.includes(apiaryId))
.map((apiaryId) => ({
id: `r-${apiaryId}`,
apiaryId,
apiary: { name: `Stand ${apiaryId}` },
disease: 'Faulbrut',
startsOn: startedLongAgo,
endsOn: null,
})),
),
);
}
function lockSql(): string[] {
return (tx.$queryRaw.mock.calls as [{ sql: string }][]).map(
([query]) => query.sql,
);
}
function setupMove(): void {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.update.mockResolvedValue({
...parentColony,
apiaryId: 'apiary-2',
});
}
function setupMerge(mergedApiaryId = 'apiary-2'): void {
prisma.colony.findUnique.mockResolvedValue(parentColony);
prisma.colony.findMany.mockResolvedValue([
{ id: 'colony-3', apiaryId: mergedApiaryId },
]);
tx.colony.findUnique.mockImplementation(
({ where }: { where: { id: string } }) =>
Promise.resolve(
where.id === 'colony-1'
? parentColony
: {
id: 'colony-3',
apiaryId: mergedApiaryId,
status: 'ACTIVE',
currentQueenId: null,
},
),
);
tx.colony.update.mockResolvedValue({
id: 'colony-3',
status: 'MERGED',
healthStatus: null,
});
}
const createDto = {
code: 'V-9',
apiaryId: 'apiary-1',
colonyType: 'PRODUCTION_COLONY' as const,
};
it('split inside the apiary is allowed', async () => {
restrict('apiary-1');
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.create.mockResolvedValue({
id: 'colony-2',
apiaryId: 'apiary-1',
});
tx.colonyLineage.create.mockResolvedValue({ id: 'lineage-1' });
await expect(
service.split(
'colony-1',
{ code: 'V-001-A', colonyType: 'NUCLEUS_COLONY' },
'user-1',
),
).resolves.toBeDefined();
expect(tx.apiaryRestriction.findMany).not.toHaveBeenCalled();
});
it('create in a restricted apiary is refused', async () => {
restrict('apiary-1');
await expect(service.create(createDto, 'user-1')).rejects.toMatchObject({
status: 409,
response: { code: 'RESTRICTED_ZONE' },
});
expect(tx.colony.create).not.toHaveBeenCalled();
expect(auditLog.record).not.toHaveBeenCalled();
});
it.each([
['into', 'apiary-2'],
['out of', 'apiary-1'],
])('move %s a restricted apiary is refused', async (_way, restricted) => {
setupMove();
restrict(restricted);
await expect(
service.move('colony-1', { apiaryId: 'apiary-2' }, 'user-1'),
).rejects.toMatchObject({
status: 409,
response: { code: 'RESTRICTED_ZONE' },
});
expect(tx.colony.update).not.toHaveBeenCalled();
expect(tx.colonyApiaryHistory.create).not.toHaveBeenCalled();
});
it('move to the same apiary moves nothing and checks nothing', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.update.mockResolvedValue(parentColony);
restrict('apiary-1');
await expect(
service.move('colony-1', { apiaryId: 'apiary-1' }, 'user-1'),
).resolves.toBeDefined();
expect(tx.apiaryRestriction.findMany).not.toHaveBeenCalled();
});
it.each([
['the old apiary', 'apiary-1'],
['the new apiary', 'apiary-2'],
])('update with a changed apiary checks %s', async (_which, restricted) => {
setupMove();
restrict(restricted);
await expect(
service.update('colony-1', { apiaryId: 'apiary-2' }, 'user-1'),
).rejects.toMatchObject({
status: 409,
response: { code: 'RESTRICTED_ZONE' },
});
expect(tx.colony.update).not.toHaveBeenCalled();
});
it('a name change checks nothing and takes no apiary lock', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.update.mockResolvedValue(parentColony);
restrict('apiary-1', 'apiary-2');
await service.update('colony-1', { code: 'V-neu' }, 'user-1');
await service.update('colony-1', { apiaryId: 'apiary-1' }, 'user-1');
expect(tx.apiaryRestriction.findMany).not.toHaveBeenCalled();
expect(lockSql().every((sql) => !sql.includes('"Apiary"'))).toBe(true);
});
it('merge across apiaries is refused when one is restricted', async () => {
setupMerge('apiary-2');
restrict('apiary-2');
await expect(
service.merge('colony-1', { mergedColonyIds: ['colony-3'] }, 'user-1'),
).rejects.toMatchObject({
status: 409,
response: { code: 'RESTRICTED_ZONE' },
});
expect(tx.colony.update).not.toHaveBeenCalled();
expect(tx.colonyLineage.create).not.toHaveBeenCalled();
});
it('merge within one apiary is allowed even in a restricted apiary', async () => {
setupMerge('apiary-1');
restrict('apiary-1');
await expect(
service.merge('colony-1', { mergedColonyIds: ['colony-3'] }, 'user-1'),
).resolves.toBeDefined();
expect(tx.apiaryRestriction.findMany).not.toHaveBeenCalled();
});
const scenarios: Record<
string,
{
run: (restrictionOverrideReason: string) => Promise<unknown>;
setup: () => void;
restricted: string[];
}
> = {
create: {
setup: () => {
tx.colony.create.mockResolvedValue({
id: 'colony-9',
apiaryId: 'apiary-1',
});
},
restricted: ['apiary-1'],
run: (restrictionOverrideReason) =>
service.create({ ...createDto, restrictionOverrideReason }, 'user-1'),
},
update: {
setup: setupMove,
restricted: ['apiary-2'],
run: (restrictionOverrideReason) =>
service.update(
'colony-1',
{ apiaryId: 'apiary-2', restrictionOverrideReason },
'user-1',
),
},
move: {
setup: setupMove,
restricted: ['apiary-2'],
run: (restrictionOverrideReason) =>
service.move(
'colony-1',
{ apiaryId: 'apiary-2', restrictionOverrideReason },
'user-1',
),
},
merge: {
setup: () => setupMerge('apiary-2'),
restricted: ['apiary-2'],
run: (restrictionOverrideReason) =>
service.merge(
'colony-1',
{ mergedColonyIds: ['colony-3'], restrictionOverrideReason },
'user-1',
),
},
};
it.each(Object.keys(scenarios))(
'the override reason is never part of the data written to the colony (%s)',
async (name) => {
const scenario = scenarios[name];
scenario.setup();
restrict(...scenario.restricted);
await scenario.run(reason);
const written = JSON.stringify([
tx.colony.create.mock.calls,
tx.colony.update.mock.calls,
]);
expect(written).not.toContain(reason);
expect(written).not.toContain('restrictionOverrideReason');
},
);
it.each(Object.keys(scenarios))(
'an override writes the reason and the restriction ids into the audit entry (%s)',
async (name) => {
const scenario = scenarios[name];
scenario.setup();
restrict(...scenario.restricted);
await scenario.run(reason);
const audits = (
auditLog.record.mock.calls as [
{ afterData: { restrictionOverride?: unknown } },
][]
).filter(([entry]) => entry.afterData.restrictionOverride);
expect(audits).toHaveLength(1);
expect(audits[0][0].afterData.restrictionOverride).toEqual({
reason,
restrictionIds: [`r-${scenario.restricted[0]}`],
});
expect(resolver.getPermissions).toHaveBeenCalledTimes(1);
},
);
it('an override writes the reason into ColonyApiaryHistory.notes, in front of an existing note', async () => {
setupMove();
restrict('apiary-2');
await service.move(
'colony-1',
{
apiaryId: 'apiary-2',
notes: 'Wanderung',
restrictionOverrideReason: reason,
},
'user-1',
);
await service.update(
'colony-1',
{ apiaryId: 'apiary-2', restrictionOverrideReason: reason },
'user-1',
);
const notes = (
tx.colonyApiaryHistory.create.mock.calls as [
{ data: { notes: string } },
][]
).map(([call]) => call.data.notes);
expect(notes).toEqual([
`Ausnahme Sperrbezirk: ${reason}\nWanderung`,
`Ausnahme Sperrbezirk: ${reason}\nKorrektur über Volk bearbeiten`,
]);
});
it('an override without compliance:manage answers 403 and writes nothing', async () => {
setupMove();
restrict('apiary-2');
resolver.getPermissions.mockResolvedValue(new Set(['colonies:update']));
await expect(
service.move(
'colony-1',
{ apiaryId: 'apiary-2', restrictionOverrideReason: reason },
'user-1',
),
).rejects.toMatchObject({ status: 403 });
expect(tx.colony.update).not.toHaveBeenCalled();
});
it('a reason without an active restriction is ignored for a user with the right: no note, no audit field', async () => {
setupMove();
await service.move(
'colony-1',
{ apiaryId: 'apiary-2', restrictionOverrideReason: reason },
'user-1',
);
expect(
JSON.stringify(tx.colonyApiaryHistory.create.mock.calls),
).not.toContain('Ausnahme');
expect(JSON.stringify(auditLog.record.mock.calls)).not.toContain(
'restrictionOverride',
);
});
it.each(Object.keys(scenarios))(
'a reason without compliance:manage answers 403 up front, even without any restriction (%s)',
async (name) => {
const scenario = scenarios[name];
scenario.setup();
resolver.getPermissions.mockResolvedValue(new Set());
await expect(scenario.run(reason)).rejects.toMatchObject({
status: 403,
});
expect(prisma.$transaction).not.toHaveBeenCalled();
expect(tx.colony.update).not.toHaveBeenCalled();
expect(tx.colony.create).not.toHaveBeenCalled();
},
);
it('merge answers 404, not "moved meanwhile", when a merged colony vanished between read and lock', async () => {
setupMerge('apiary-2');
tx.colony.findUnique.mockImplementation(
({ where }: { where: { id: string } }) =>
Promise.resolve(where.id === 'colony-1' ? parentColony : null),
);
await expect(
service.merge('colony-1', { mergedColonyIds: ['colony-3'] }, 'user-1'),
).rejects.toMatchObject({ status: 404 });
});
it('a null reason counts as omitted and asks for no permission', async () => {
setupMove();
restrict('apiary-2');
await expect(
service.move(
'colony-1',
{ apiaryId: 'apiary-2', restrictionOverrideReason: null },
'user-1',
),
).rejects.toMatchObject({ status: 409 });
expect(resolver.getPermissions).not.toHaveBeenCalled();
});
it('the restriction check runs inside the transaction after the apiary rows (share, ascending) and then the colony row are locked', async () => {
setupMove();
await service.move('colony-1', { apiaryId: 'apiary-2' }, 'user-1');
expect(lockSql()).toEqual([
'SELECT id FROM "Apiary" WHERE id = ANY(?) ORDER BY id FOR SHARE',
'SELECT id FROM "Colony" WHERE id = ? FOR UPDATE',
]);
const [[shareQuery]] = tx.$queryRaw.mock.calls as [
[{ values: unknown[] }],
];
expect(shareQuery.values).toEqual([['apiary-1', 'apiary-2']]);
const order = [
tx.$queryRaw.mock.invocationCallOrder[0],
tx.$queryRaw.mock.invocationCallOrder[1],
tx.apiaryRestriction.findMany.mock.invocationCallOrder[0],
tx.colony.update.mock.invocationCallOrder[0],
];
expect(order).toEqual([...order].sort((a, b) => a - b));
expect(tx.apiary.findUnique.mock.invocationCallOrder[0]).toBeGreaterThan(
tx.$queryRaw.mock.invocationCallOrder[0],
);
});
it('create locks the apiary (share) before it checks and writes', async () => {
tx.colony.create.mockResolvedValue({
id: 'colony-9',
apiaryId: 'apiary-1',
});
await service.create(createDto, 'user-1');
expect(lockSql()).toEqual([
'SELECT id FROM "Apiary" WHERE id = ANY(?) ORDER BY id FOR SHARE',
]);
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
tx.apiaryRestriction.findMany.mock.invocationCallOrder[0],
);
expect(
tx.apiaryRestriction.findMany.mock.invocationCallOrder[0],
).toBeLessThan(tx.colony.create.mock.invocationCallOrder[0]);
});
it('merge locks the apiaries (share), then survivor and merged colonies in one statement', async () => {
setupMerge('apiary-2');
await service.merge(
'colony-1',
{ mergedColonyIds: ['colony-3'] },
'user-1',
);
expect(lockSql()).toEqual([
'SELECT id FROM "Apiary" WHERE id = ANY(?) ORDER BY id FOR SHARE',
'SELECT id FROM "Colony" WHERE id = ANY(?) ORDER BY id FOR UPDATE',
]);
expect(
tx.apiaryRestriction.findMany.mock.invocationCallOrder[0],
).toBeGreaterThan(tx.$queryRaw.mock.invocationCallOrder[1]);
});
it('update, move and merge answer 409 when the colony changed its apiary between the unlocked read and the lock', async () => {
const movedAway = { ...parentColony, apiaryId: 'apiary-3' };
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.findUnique.mockResolvedValue(movedAway);
await expect(
service.update('colony-1', { apiaryId: 'apiary-2' }, 'user-1'),
).rejects.toMatchObject({ status: 409, message: reloadMessage });
await expect(
service.move('colony-1', { apiaryId: 'apiary-2' }, 'user-1'),
).rejects.toMatchObject({ status: 409, message: reloadMessage });
setupMerge('apiary-2');
tx.colony.findUnique.mockImplementation(
({ where }: { where: { id: string } }) =>
Promise.resolve(
where.id === 'colony-1'
? parentColony
: { id: 'colony-3', apiaryId: 'apiary-3', status: 'ACTIVE' },
),
);
await expect(
service.merge('colony-1', { mergedColonyIds: ['colony-3'] }, 'user-1'),
).rejects.toMatchObject({ status: 409, message: reloadMessage });
tx.colony.findUnique.mockImplementation(
({ where }: { where: { id: string } }) =>
Promise.resolve(
where.id === 'colony-1'
? movedAway
: { id: 'colony-3', apiaryId: 'apiary-2', status: 'ACTIVE' },
),
);
await expect(
service.merge('colony-1', { mergedColonyIds: ['colony-3'] }, 'user-1'),
).rejects.toMatchObject({ status: 409, message: reloadMessage });
expect(tx.colony.update).not.toHaveBeenCalled();
expect(tx.colonyApiaryHistory.create).not.toHaveBeenCalled();
});
it('an update without an apiary change still works when the colony was moved meanwhile', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.findUnique.mockResolvedValue({
...parentColony,
apiaryId: 'apiary-3',
});
tx.colony.update.mockResolvedValue(parentColony);
await expect(
service.update('colony-1', { notes: 'x' }, 'user-1'),
).resolves.toBeDefined();
});
it('updateIn called from an inspection takeover locks no apiary', async () => {
tx.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.update.mockResolvedValue(parentColony);
await service.updateIn(tx as never, 'colony-1', { notes: 'x' }, 'user-1');
expect(lockSql()).toEqual([
'SELECT id FROM "Colony" WHERE id = ? FOR UPDATE',
]);
expect(tx.apiaryRestriction.findMany).not.toHaveBeenCalled();
});
it('updateIn refuses to move the apiary without the locks update() takes', async () => {
tx.colony.findUnique.mockResolvedValue(parentColony);
await expect(
service.updateIn(
tx as never,
'colony-1',
{ apiaryId: 'apiary-2' },
'user-1',
),
).rejects.toThrow('moving the apiary');
expect(tx.colony.update).not.toHaveBeenCalled();
});
});
});
+213 -34
View File
@@ -1,6 +1,7 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
@@ -21,9 +22,21 @@ import {
isLivingColonyStatus,
TERMINAL_COLONY_STATUSES,
} from './colony-status';
import { lockRowsForUpdate } from '../prisma/row-lock';
import { lockRowsForShare, lockRowsForUpdate } from '../prisma/row-lock';
import { colonyTransitionEvents, lockColony } from './colony-events';
import { berlinToday, dayToDate } from '../common/dates';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import {
assertNoActiveRestriction,
RestrictionCheck,
RestrictionOverride,
} from '../compliance/restricted-zone';
import {
apiaryChangedMeanwhile,
NOT_OVERRIDDEN,
overrideNote,
overrideTrace,
} from './restriction-override';
function colonyMovedEvent(
colonyId: string,
@@ -40,6 +53,21 @@ function colonyMovedEvent(
};
}
/** The fields update() writes: the override reason is read, never stored. */
type ColonyPatch = Omit<UpdateColonyDto, 'restrictionOverrideReason'>;
/**
* What update() hands to updateIn when it moves the colony to another apiary:
* the apiary the unlocked read saw (the apiary rows were locked for it) and
* the override the user may use.
*/
export interface ApiaryMoveGuard {
seenApiaryId: string;
override: RestrictionOverride;
}
const OVERRIDE_PERMISSION = 'compliance:manage';
export interface ColonyFilter {
status?: ColonyStatus;
apiaryId?: string;
@@ -52,6 +80,7 @@ export class ColoniesService {
private readonly auditLog: AuditLogService,
private readonly webhooks: WebhooksService,
private readonly events: DomainEventsService,
private readonly permissions: PermissionResolverService,
) {}
async findAll(filter: ColonyFilter = {}): Promise<Colony[]> {
@@ -84,11 +113,14 @@ export class ColoniesService {
// this, a colony could be created or moved into an apiary that either
// doesn't exist (a raw Prisma FK-constraint error instead of a clean
// 404) or is deactivated (silently orphaning the colony the same way an
// apiary deactivated out from under an existing colony would).
private async assertApiaryActive(apiaryId: string): Promise<void> {
const apiary = await this.prisma.apiary.findUnique({
where: { id: apiaryId },
});
// apiary deactivated out from under an existing colony would). Read inside
// the transaction, after the apiary rows are locked (FOR SHARE): the
// apiary cannot be deactivated before this colony path commits.
private async assertApiaryActiveIn(
tx: Prisma.TransactionClient,
apiaryId: string,
): Promise<void> {
const apiary = await tx.apiary.findUnique({ where: { id: apiaryId } });
if (!apiary) {
throw new NotFoundException('Bienenstand nicht gefunden');
}
@@ -99,17 +131,49 @@ export class ColoniesService {
}
}
// Asked once per request, and only when a reason was sent (null counts as
// omitted): the right to override is compliance:manage.
private async overrideOf(
reason: string | null | undefined,
actingUserId: string,
): Promise<RestrictionOverride> {
if (!reason) {
return { reason: null, canOverride: false };
}
const held = await this.permissions.getPermissions(actingUserId);
// Spec 5.6: a reason without compliance:manage is a 403 - always, also
// when no restriction is active. Checked before anything is read or locked.
if (!held.has(OVERRIDE_PERMISSION)) {
throw new ForbiddenException(
'Eine Begründung für eine Ausnahme vom Sperrbezirk darf nur mit dem Recht Prüfbereitschaft verwalten (compliance:manage) angegeben werden.',
);
}
return { reason, canOverride: true };
}
async create(dto: CreateColonyDto, actingUserId: string): Promise<Colony> {
await this.assertApiaryActive(dto.apiaryId);
const { restrictionOverrideReason, ...data } = dto;
const override = await this.overrideOf(
restrictionOverrideReason,
actingUserId,
);
const colony = await this.prisma.$transaction(async (tx) => {
const created = await tx.colony.create({ data: { ...dto } });
await lockRowsForShare(tx, 'Apiary', [data.apiaryId]);
await this.assertApiaryActiveIn(tx, data.apiaryId);
const check = await assertNoActiveRestriction(
tx,
[data.apiaryId],
berlinToday(),
override,
);
const created = await tx.colony.create({ data });
await this.auditLog.record(
{
entityType: 'Colony',
entityId: created.id,
action: 'CREATE',
userId: actingUserId,
afterData: created,
afterData: { ...created, ...overrideTrace(check, override.reason) },
},
tx,
);
@@ -126,9 +190,25 @@ export class ColoniesService {
dto: UpdateColonyDto,
actingUserId: string,
): Promise<Colony> {
const { after } = await this.prisma.$transaction((tx) =>
this.updateIn(tx, id, dto, actingUserId),
const { restrictionOverrideReason, ...patch } = dto;
const override = await this.overrideOf(
restrictionOverrideReason,
actingUserId,
);
const seen = await this.findById(id);
// Only a change of the apiary involves apiaries: the old and the new one
// are locked first (FOR SHARE, ascending), then the colony (Task 1 order).
const movesApiary =
patch.apiaryId !== undefined && patch.apiaryId !== seen.apiaryId;
const { after } = await this.prisma.$transaction(async (tx) => {
if (movesApiary) {
await lockRowsForShare(tx, 'Apiary', [seen.apiaryId, patch.apiaryId!]);
}
return this.updateIn(tx, id, patch, actingUserId, {
seenApiaryId: seen.apiaryId,
override,
});
});
return after;
}
@@ -137,22 +217,44 @@ export class ColoniesService {
* inspection handing values over to the colony card). Locks the colony row
* and reads "before" only afterwards (404 when missing); writes the apiary
* history, the status events and the audit entry with the change.
*
* It locks no apiary itself. A patch that moves the colony to another
* apiary needs `guard`: update() has then locked both apiaries (FOR SHARE)
* before the colony, so the restriction check and the write cannot be
* overtaken by a restriction or a deactivation. The inspection takeover
* passes no apiary and no guard.
*/
async updateIn(
tx: Prisma.TransactionClient,
id: string,
dto: UpdateColonyDto,
dto: ColonyPatch,
actingUserId: string,
guard?: ApiaryMoveGuard,
): Promise<{ before: Colony; after: Colony }> {
const before = await lockColony(tx, id);
const today = berlinToday();
const movesApiary =
dto.apiaryId !== undefined && dto.apiaryId !== before.apiaryId;
let check: RestrictionCheck = NOT_OVERRIDDEN;
if (movesApiary) {
await this.assertApiaryActive(dto.apiaryId!);
if (!guard) {
throw new Error(
'ColoniesService.updateIn: moving the apiary needs update()',
);
}
if (before.apiaryId !== guard.seenApiaryId) {
throw apiaryChangedMeanwhile();
}
await this.assertApiaryActiveIn(tx, dto.apiaryId!);
check = await assertNoActiveRestriction(
tx,
[before.apiaryId, dto.apiaryId!],
today,
guard.override,
);
}
// An edit into an ended status ends the queen assignment like dissolve
// does, before the status is written (the pointer is read under the lock).
const today = berlinToday();
const endsColony =
dto.status !== undefined &&
TERMINAL_COLONY_STATUSES.includes(dto.status) &&
@@ -173,7 +275,11 @@ export class ColoniesService {
colonyId: id,
apiaryId: updated.apiaryId,
movedByUserId: actingUserId,
notes: 'Korrektur über Volk bearbeiten',
notes: overrideNote(
check,
guard?.override.reason,
'Korrektur über Volk bearbeiten',
),
},
});
await this.events.record(
@@ -196,7 +302,10 @@ export class ColoniesService {
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: updated,
afterData: {
...updated,
...overrideTrace(check, guard?.override.reason),
},
},
tx,
);
@@ -208,21 +317,41 @@ export class ColoniesService {
dto: MoveColonyDto,
actingUserId: string,
): Promise<Colony> {
this.assertActive(await this.findById(id));
await this.assertApiaryActive(dto.apiaryId);
const { restrictionOverrideReason, ...data } = dto;
const override = await this.overrideOf(
restrictionOverrideReason,
actingUserId,
);
const seen = await this.findById(id);
this.assertActive(seen);
const colony = await this.prisma.$transaction(async (tx) => {
await lockRowsForShare(tx, 'Apiary', [seen.apiaryId, data.apiaryId]);
const before = await lockColony(tx, id);
this.assertActive(before);
if (before.apiaryId !== seen.apiaryId) {
throw apiaryChangedMeanwhile();
}
await this.assertApiaryActiveIn(tx, data.apiaryId);
// A "move" within one apiary leaves the herd where it is.
const check =
before.apiaryId === data.apiaryId
? NOT_OVERRIDDEN
: await assertNoActiveRestriction(
tx,
[before.apiaryId, data.apiaryId],
berlinToday(),
override,
);
const updated = await tx.colony.update({
where: { id },
data: { apiaryId: dto.apiaryId },
data: { apiaryId: data.apiaryId },
});
await tx.colonyApiaryHistory.create({
data: {
colonyId: id,
apiaryId: dto.apiaryId,
apiaryId: data.apiaryId,
movedByUserId: actingUserId,
notes: dto.notes,
notes: overrideNote(check, override.reason, data.notes),
},
});
if (before.apiaryId !== updated.apiaryId) {
@@ -238,7 +367,7 @@ export class ColoniesService {
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: updated,
afterData: { ...updated, ...overrideTrace(check, override.reason) },
},
tx,
);
@@ -313,24 +442,54 @@ export class ColoniesService {
dto: MergeColonyDto,
actingUserId: string,
): Promise<Colony> {
const { restrictionOverrideReason, ...data } = dto;
const survivor = await this.findById(id);
this.assertActive(survivor);
if (dto.mergedColonyIds.includes(survivor.id)) {
if (data.mergedColonyIds.includes(survivor.id)) {
throw new BadRequestException(
'Ein Volk kann nicht mit sich selbst vereinigt werden',
);
}
if (new Set(dto.mergedColonyIds).size !== dto.mergedColonyIds.length) {
if (new Set(data.mergedColonyIds).size !== data.mergedColonyIds.length) {
throw new BadRequestException(
'Jedes Volk darf nur einmal zur Vereinigung ausgewählt werden',
);
}
const override = await this.overrideOf(
restrictionOverrideReason,
actingUserId,
);
const today = berlinToday();
// The apiaries of the colonies as the unlocked read saw them: they are
// locked first, and compared with the locked rows afterwards.
const seenApiaries = new Map(
(
await this.prisma.colony.findMany({
where: { id: { in: data.mergedColonyIds } },
select: { id: true, apiaryId: true },
})
).map((colony) => [colony.id, colony.apiaryId]),
);
await this.prisma.$transaction(async (tx) => {
await lockRowsForShare(tx, 'Apiary', [
...new Set([survivor.apiaryId, ...seenApiaries.values()]),
]);
// One statement in id order, the same order bulk lockRowsForShare
// uses: no deadlock with a concurrent bulk write.
await lockRowsForUpdate(tx, 'Colony', dto.mergedColonyIds);
for (const mergedColonyId of dto.mergedColonyIds) {
await lockRowsForUpdate(tx, 'Colony', [
survivor.id,
...data.mergedColonyIds,
]);
const lockedSurvivor = await tx.colony.findUnique({ where: { id } });
if (!lockedSurvivor) {
throw new NotFoundException('Colony not found');
}
this.assertActive(lockedSurvivor);
if (lockedSurvivor.apiaryId !== survivor.apiaryId) {
throw apiaryChangedMeanwhile();
}
const mergedRows: Colony[] = [];
for (const mergedColonyId of data.mergedColonyIds) {
const merged = await tx.colony.findUnique({
where: { id: mergedColonyId },
});
@@ -340,6 +499,22 @@ export class ColoniesService {
);
}
this.assertActive(merged);
if (seenApiaries.get(merged.id) !== merged.apiaryId) {
throw apiaryChangedMeanwhile();
}
mergedRows.push(merged);
}
// Uniting colonies of different apiaries brings bees from one stand to
// the other; within one apiary nothing leaves it.
const apiaryIds = new Set([
lockedSurvivor.apiaryId,
...mergedRows.map((merged) => merged.apiaryId),
]);
const check =
apiaryIds.size > 1
? await assertNoActiveRestriction(tx, [...apiaryIds], today, override)
: NOT_OVERRIDDEN;
for (const merged of mergedRows) {
// currentQueenId is unique across colonies - if it stayed set on
// this now-dead colony, any later attempt to assign her queen to a
// different colony would fail with a unique-constraint conflict.
@@ -348,11 +523,11 @@ export class ColoniesService {
// merge is a beekeeper decision, not one to guess here).
const releasedQueenId = await releaseQueenIn(
tx,
mergedColonyId,
merged.id,
dayToDate(today),
);
const mergedAway = await tx.colony.update({
where: { id: mergedColonyId },
where: { id: merged.id },
data: { status: 'MERGED', deactivatedAt: new Date() },
});
await this.events.recordAll(
@@ -366,21 +541,25 @@ export class ColoniesService {
await tx.colonyLineage.create({
data: {
type: 'MERGE',
fromColonyId: mergedColonyId,
toColonyId: survivor.id,
fromColonyId: merged.id,
toColonyId: lockedSurvivor.id,
date: new Date(),
notes: dto.notes,
notes: data.notes,
},
});
}
await this.auditLog.record(
{
entityType: 'Colony',
entityId: survivor.id,
entityId: lockedSurvivor.id,
action: 'UPDATE',
userId: actingUserId,
beforeData: survivor,
afterData: { ...survivor, mergedColonyIds: dto.mergedColonyIds },
beforeData: lockedSurvivor,
afterData: {
...lockedSurvivor,
mergedColonyIds: data.mergedColonyIds,
...overrideTrace(check, override.reason),
},
},
tx,
);
@@ -1,6 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { ColonyCondition, ColonyType } from '../../../generated/prisma';
import { RestrictionOverrideReason } from './restriction-override-reason';
export class CreateColonyDto {
@ApiProperty()
@@ -67,4 +68,7 @@ export class CreateColonyDto {
@IsOptional()
@IsString()
notes?: string;
@RestrictionOverrideReason()
restrictionOverrideReason?: string | null;
}
@@ -1,5 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ArrayMinSize, IsArray, IsOptional, IsString } from 'class-validator';
import { RestrictionOverrideReason } from './restriction-override-reason';
export class MergeColonyDto {
@ApiProperty({ type: [String] })
@@ -12,4 +13,7 @@ export class MergeColonyDto {
@IsOptional()
@IsString()
notes?: string;
@RestrictionOverrideReason()
restrictionOverrideReason?: string | null;
}
@@ -1,5 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';
import { RestrictionOverrideReason } from './restriction-override-reason';
export class MoveColonyDto {
@ApiProperty()
@@ -10,4 +11,7 @@ export class MoveColonyDto {
@IsOptional()
@IsString()
notes?: string;
@RestrictionOverrideReason()
restrictionOverrideReason?: string | null;
}
@@ -0,0 +1,32 @@
import { applyDecorators } from '@nestjs/common';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export const OVERRIDE_REASON_MIN = 10;
export const OVERRIDE_REASON_MAX = 500;
/**
* `restrictionOverrideReason`: why a colony may enter or leave an apiary in a
* restricted zone. Only read by ColoniesService (needs compliance:manage) and
* written to audit and apiary history - never to the colony. Trimmed before
* the length check, so blanks do not count; null counts as omitted.
*/
export function RestrictionOverrideReason(): PropertyDecorator {
return applyDecorators(
ApiPropertyOptional({
nullable: true,
minLength: OVERRIDE_REASON_MIN,
maxLength: OVERRIDE_REASON_MAX,
description:
'Begründung für eine Ausnahme vom Sperrbezirk (nur mit compliance:manage)',
}),
IsOptional(),
Transform(({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value,
),
IsString(),
MinLength(OVERRIDE_REASON_MIN),
MaxLength(OVERRIDE_REASON_MAX),
);
}
@@ -3,6 +3,7 @@ import { IsEnum } from 'class-validator';
import { ColonyStatus } from '../../../generated/prisma';
import { CreateColonyDto } from './create-colony.dto';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
import { RestrictionOverrideReason } from './restriction-override-reason';
/**
* `apiaryId` is editable here too, not only through POST /colonies/:id/move:
@@ -32,4 +33,7 @@ export class UpdateColonyDto extends UpdatableColonyFieldsDto {
@IsOptionalNotNull()
@IsEnum(ColonyStatus)
status?: ColonyStatus;
@RestrictionOverrideReason()
restrictionOverrideReason?: string | null;
}
@@ -0,0 +1,41 @@
import { ConflictException } from '@nestjs/common';
import type { RestrictionCheck } from '../compliance/restricted-zone';
/** 409 when the colony or an apiary changed between the unlocked read and the lock. */
export function apiaryChangedMeanwhile(): ConflictException {
return new ConflictException(
'Der Stand wurde gerade geändert. Bitte neu laden.',
);
}
export const NOT_OVERRIDDEN: RestrictionCheck = {
overridden: false,
restrictionIds: [],
};
/**
* The audit trace of an override: only the reason and the restriction ids,
* and only when an active restriction was really overridden. Spread into the
* audit entry's afterData - never into data written to the colony.
*/
export function overrideTrace(
check: RestrictionCheck,
reason: string | null | undefined,
): { restrictionOverride?: { reason: string; restrictionIds: string[] } } {
return check.overridden && reason
? { restrictionOverride: { reason, restrictionIds: check.restrictionIds } }
: {};
}
/** The apiary-history note: the override in front of an existing note. */
export function overrideNote(
check: RestrictionCheck,
reason: string | null | undefined,
note: string | undefined,
): string | undefined {
if (!check.overridden || !reason) {
return note;
}
const prefix = `Ausnahme Sperrbezirk: ${reason}`;
return note ? `${prefix}\n${note}` : prefix;
}
@@ -0,0 +1,86 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { Idempotent } from '../common/idempotency/idempotent.decorator';
import { AuthorityNoticesService } from './authority-notices.service';
import { CreateAuthorityNoticeDto } from './dto/create-authority-notice.dto';
import { MarkSentDto } from './dto/mark-sent.dto';
import { NoticeListQuery } from './dto/notice-list-query.dto';
import { UpdateAuthorityNoticeDto } from './dto/update-authority-notice.dto';
@ApiTags('authority-notices')
@ApiBearerAuth()
@Controller('authority-notices')
export class AuthorityNoticesController {
constructor(private readonly notices: AuthorityNoticesService) {}
@RequirePermission('compliance:read')
@Get()
findAll(@Query() query: NoticeListQuery) {
return this.notices.findAll(query);
}
@RequirePermission('compliance:read')
@Get(':id')
findOne(@Param('id') id: string) {
return this.notices.findOne(id);
}
@RequirePermission('compliance:manage')
@Idempotent()
@Post()
create(
@Body() dto: CreateAuthorityNoticeDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.notices.create(dto, actor.id);
}
@RequirePermission('compliance:manage')
@Patch(':id')
update(
@Param('id') id: string,
@Body() dto: UpdateAuthorityNoticeDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.notices.update(id, dto, actor.id);
}
@RequirePermission('compliance:manage')
@Post(':id/recount')
recount(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.notices.recount(id, actor.id);
}
@RequirePermission('compliance:manage')
@Post(':id/mark-sent')
markSent(
@Param('id') id: string,
@Body() dto: MarkSentDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.notices.markSent(id, dto, actor.id);
}
@RequirePermission('compliance:manage')
@Post(':id/reopen')
reopen(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.notices.reopen(id, actor.id);
}
@RequirePermission('compliance:manage')
@Post(':id/cancel')
cancel(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.notices.cancel(id, actor.id);
}
}
@@ -0,0 +1,579 @@
import {
BadRequestException,
ConflictException,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '../../generated/prisma';
import { AuditLogService } from '../audit/audit-log.service';
import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate } from '../prisma/row-lock';
import { AuthorityNoticesService } from './authority-notices.service';
jest.mock('../prisma/row-lock', () => ({
lockRowForUpdate: jest.fn().mockResolvedValue(true),
}));
const lockMock = lockRowForUpdate as jest.Mock;
const SETTINGS = {
id: 'singleton',
tskExcludedColonyTypes: [] as string[],
};
function notice(patch: Record<string, unknown> = {}) {
return {
id: 'n1',
kind: 'HERD_CHANGE',
status: 'DRAFT',
year: null,
referenceDate: new Date('2027-01-01T00:00:00.000Z'),
countedAt: new Date('2027-01-01T10:00:00.000Z'),
snapshot: { version: 1, apiaries: [], totalLiving: 2 },
totalColonies: 2,
sentOn: null,
sentVia: null,
reference: null,
notes: null,
openKey: 'HERD_CHANGE',
createdByUserId: 'u1',
createdAt: new Date(0),
updatedAt: new Date(0),
...patch,
};
}
function p2002(): Prisma.PrismaClientKnownRequestError {
return new Prisma.PrismaClientKnownRequestError('unique', {
code: 'P2002',
clientVersion: 'test',
});
}
describe('AuthorityNoticesService', () => {
let service: AuthorityNoticesService;
let tx: {
complianceSettings: { upsert: jest.Mock; findUniqueOrThrow: jest.Mock };
authorityNotice: {
findFirst: jest.Mock;
findUnique: jest.Mock;
findMany: jest.Mock;
create: jest.Mock;
update: jest.Mock;
};
apiary: { findMany: jest.Mock };
};
let prisma: {
$transaction: jest.Mock;
authorityNotice: { findFirst: jest.Mock; findMany: jest.Mock };
};
let auditLog: { record: jest.Mock };
// The baseline lookup and the open-draft lookup both use findFirst: the
// helper answers by the where clause.
function stubLookups(opts: {
baseline?: unknown;
openDraft?: unknown;
}): void {
tx.authorityNotice.findFirst.mockImplementation(
({ where }: { where: { openKey?: string; status?: string } }) =>
Promise.resolve(
where.openKey !== undefined
? (opts.openDraft ?? null)
: (opts.baseline ?? null),
),
);
}
beforeEach(() => {
lockMock.mockReset().mockResolvedValue(true);
tx = {
complianceSettings: {
upsert: jest.fn().mockResolvedValue(SETTINGS),
findUniqueOrThrow: jest.fn().mockResolvedValue(SETTINGS),
},
authorityNotice: {
findFirst: jest.fn().mockResolvedValue(null),
findUnique: jest.fn().mockResolvedValue(notice()),
findMany: jest.fn().mockResolvedValue([]),
create: jest
.fn()
.mockImplementation(({ data }: { data: object }) =>
Promise.resolve(notice(data as Record<string, unknown>)),
),
update: jest
.fn()
.mockImplementation(({ data }: { data: object }) =>
Promise.resolve(notice(data as Record<string, unknown>)),
),
},
apiary: {
findMany: jest.fn().mockResolvedValue([
{
id: 'a1',
name: 'Stand 1',
address: null,
location: null,
latitude: null,
longitude: null,
colonies: [
{ colonyType: 'PRODUCTION_COLONY' },
{ colonyType: 'NUCLEUS_COLONY' },
],
},
]),
},
};
prisma = {
$transaction: jest
.fn()
.mockImplementation((fn: (client: unknown) => unknown) => fn(tx)),
authorityNotice: {
findFirst: jest.fn().mockResolvedValue(null),
findMany: jest.fn().mockResolvedValue([]),
},
};
auditLog = { record: jest.fn() };
service = new AuthorityNoticesService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
);
});
describe('create', () => {
it('one open draft per kind and year', async () => {
stubLookups({
baseline: notice({ status: 'SENT' }),
openDraft: { id: 'existing' },
});
const attempt = service.create({ kind: 'HERD_CHANGE' }, 'u1');
await expect(attempt).rejects.toBeInstanceOf(ConflictException);
await expect(attempt).rejects.toMatchObject({
response: { code: 'DRAFT_EXISTS', existingId: 'existing' },
});
});
it('answers DRAFT_EXISTS when the unique key of the open draft is hit', async () => {
stubLookups({ baseline: notice({ status: 'SENT' }) });
tx.authorityNotice.create.mockRejectedValueOnce(p2002());
prisma.authorityNotice.findFirst.mockResolvedValue({ id: 'winner' });
await expect(
service.create({ kind: 'HERD_CHANGE' }, 'u1'),
).rejects.toMatchObject({
response: { code: 'DRAFT_EXISTS', existingId: 'winner' },
});
});
it('create with sentOn is SENT at once and has no openKey', async () => {
await service.create(
{ kind: 'REGISTRATION', sentOn: '2026-05-01' },
'u1',
);
expect(tx.authorityNotice.create).toHaveBeenCalledWith({
data: expect.objectContaining({
kind: 'REGISTRATION',
status: 'SENT',
sentOn: new Date('2026-05-01T00:00:00.000Z'),
openKey: null,
totalColonies: 2,
}) as unknown,
});
});
it('a notice created as sent does not collide with an open draft', async () => {
stubLookups({ openDraft: { id: 'existing' } });
await service.create(
{ kind: 'REGISTRATION', sentOn: '2026-05-01' },
'u1',
);
expect(tx.authorityNotice.create).toHaveBeenCalled();
});
it('rethrows a unique violation that no open draft explains', async () => {
stubLookups({ baseline: notice({ status: 'SENT' }) });
const error = p2002();
tx.authorityNotice.create.mockRejectedValueOnce(error);
prisma.authorityNotice.findFirst.mockResolvedValue(null);
await expect(service.create({ kind: 'HERD_CHANGE' }, 'u1')).rejects.toBe(
error,
);
});
it('a draft carries the open key and the counted snapshot', async () => {
await service.create({ kind: 'REGISTRATION' }, 'u1');
expect(tx.authorityNotice.create).toHaveBeenCalledWith({
data: expect.objectContaining({
status: 'DRAFT',
openKey: 'REGISTRATION',
sentOn: null,
totalColonies: 2,
}) as unknown,
});
});
it('herd change needs a baseline and registration needs none', async () => {
stubLookups({});
await expect(
service.create({ kind: 'HERD_CHANGE' }, 'u1'),
).rejects.toMatchObject({
response: expect.objectContaining({
message: expect.stringContaining('Erstanzeige') as unknown,
}) as unknown,
});
stubLookups({ baseline: notice({ status: 'SENT' }) });
await expect(
service.create({ kind: 'REGISTRATION' }, 'u1'),
).rejects.toBeInstanceOf(ConflictException);
});
it('TSK_REPORT needs a year and uses it in the open key', async () => {
await expect(
service.create({ kind: 'TSK_REPORT' }, 'u1'),
).rejects.toBeInstanceOf(BadRequestException);
await service.create({ kind: 'TSK_REPORT', year: 2027 }, 'u1');
expect(tx.authorityNotice.create).toHaveBeenCalledWith({
data: expect.objectContaining({
year: 2027,
openKey: 'TSK_REPORT:2027',
}) as unknown,
});
});
it('a year is only valid for the TSK report', async () => {
await expect(
service.create({ kind: 'REGISTRATION', year: 2027 }, 'u1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('the TSK count leaves out the excluded colony types', async () => {
tx.complianceSettings.findUniqueOrThrow.mockResolvedValue({
...SETTINGS,
tskExcludedColonyTypes: ['NUCLEUS_COLONY'],
});
await service.create({ kind: 'TSK_REPORT', year: 2027 }, 'u1');
expect(tx.authorityNotice.create).toHaveBeenCalledWith({
data: expect.objectContaining({ totalColonies: 1 }) as unknown,
});
});
it('locks the settings row as mutex before reading and audits the create', async () => {
await service.create({ kind: 'REGISTRATION' }, 'u1');
expect(lockMock).toHaveBeenCalledWith(
tx,
'ComplianceSettings',
'singleton',
);
expect(lockMock.mock.invocationCallOrder[0]).toBeLessThan(
tx.authorityNotice.findFirst.mock.invocationCallOrder[0],
);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'AuthorityNotice',
action: 'CREATE',
userId: 'u1',
}),
tx,
);
});
});
describe('createDraftIn', () => {
it('is the shared core: no actor needed and the caller owns the transaction', async () => {
await service.createDraftIn(
tx as never,
{
kind: 'TSK_REPORT',
year: 2027,
referenceDate: new Date('2027-01-01T00:00:00.000Z'),
},
null,
);
expect(prisma.$transaction).not.toHaveBeenCalled();
expect(tx.authorityNotice.create).toHaveBeenCalledWith({
data: expect.objectContaining({
createdByUserId: null,
openKey: 'TSK_REPORT:2027',
}) as unknown,
});
});
});
describe('transitions', () => {
it('recount only in DRAFT', async () => {
tx.authorityNotice.findUnique.mockResolvedValue(
notice({ status: 'SENT', openKey: null }),
);
await expect(service.recount('n1', 'u1')).rejects.toBeInstanceOf(
ConflictException,
);
tx.authorityNotice.findUnique.mockResolvedValue(notice());
await service.recount('n1', 'u1');
expect(tx.authorityNotice.update).toHaveBeenCalledWith({
where: { id: 'n1' },
data: expect.objectContaining({ totalColonies: 2 }) as unknown,
});
});
it('mark-sent on CANCELLED and recount on SENT answer 409', async () => {
tx.authorityNotice.findUnique.mockResolvedValue(
notice({ status: 'CANCELLED', openKey: null }),
);
await expect(service.markSent('n1', {}, 'u1')).rejects.toBeInstanceOf(
ConflictException,
);
tx.authorityNotice.findUnique.mockResolvedValue(
notice({ status: 'SENT', openKey: null }),
);
await expect(service.recount('n1', 'u1')).rejects.toBeInstanceOf(
ConflictException,
);
});
it('mark-sent defaults the sent day to the Berlin day, not the UTC day', async () => {
jest.useFakeTimers({ now: new Date('2027-03-01T23:30:00.000Z') });
try {
await service.markSent('n1', {}, 'u1');
} finally {
jest.useRealTimers();
}
expect(tx.authorityNotice.update).toHaveBeenCalledWith({
where: { id: 'n1' },
data: expect.objectContaining({
sentOn: new Date('2027-03-02T00:00:00.000Z'),
}) as unknown,
});
});
it('mark-sent sets SENT, the sent day and drops the open key', async () => {
await service.markSent(
'n1',
{ sentOn: '2027-02-02', sentVia: 'E-Mail' },
'u1',
);
expect(tx.authorityNotice.update).toHaveBeenCalledWith({
where: { id: 'n1' },
data: expect.objectContaining({
status: 'SENT',
sentOn: new Date('2027-02-02T00:00:00.000Z'),
sentVia: 'E-Mail',
openKey: null,
}) as unknown,
});
});
it('a reopened registration can be marked sent again', async () => {
tx.authorityNotice.findUnique.mockResolvedValue(
notice({ kind: 'REGISTRATION', openKey: 'REGISTRATION' }),
);
stubLookups({ baseline: notice({ id: 'other', status: 'SENT' }) });
await service.markSent('n1', {}, 'u1');
expect(tx.authorityNotice.update).toHaveBeenCalledWith({
where: { id: 'n1' },
data: expect.objectContaining({ status: 'SENT' }) as unknown,
});
});
it('reopen answers 409 while another draft of the kind exists', async () => {
tx.authorityNotice.findUnique.mockResolvedValue(
notice({ status: 'SENT', openKey: null }),
);
stubLookups({ openDraft: { id: 'other' } });
await expect(service.reopen('n1', 'u1')).rejects.toMatchObject({
response: { code: 'DRAFT_EXISTS', existingId: 'other' },
});
});
it('reopen turns a SENT notice back into a draft with its open key', async () => {
tx.authorityNotice.findUnique.mockResolvedValue(
notice({ status: 'SENT', openKey: null, sentOn: new Date(0) }),
);
await service.reopen('n1', 'u1');
expect(tx.authorityNotice.update).toHaveBeenCalledWith({
where: { id: 'n1' },
data: expect.objectContaining({
status: 'DRAFT',
openKey: 'HERD_CHANGE',
}) as unknown,
});
});
it('cancel only works on a draft and drops the open key', async () => {
await service.cancel('n1', 'u1');
expect(tx.authorityNotice.update).toHaveBeenCalledWith({
where: { id: 'n1' },
data: expect.objectContaining({
status: 'CANCELLED',
openKey: null,
}) as unknown,
});
tx.authorityNotice.findUnique.mockResolvedValue(
notice({ status: 'SENT', openKey: null }),
);
await expect(service.cancel('n1', 'u1')).rejects.toBeInstanceOf(
ConflictException,
);
});
it.each([
['recount', (s: AuthorityNoticesService) => s.recount('n1', 'u1')],
['markSent', (s: AuthorityNoticesService) => s.markSent('n1', {}, 'u1')],
['cancel', (s: AuthorityNoticesService) => s.cancel('n1', 'u1')],
[
'update',
(s: AuthorityNoticesService) => s.update('n1', { notes: 'x' }, 'u1'),
],
])(
'every transition locks the notice row and writes an audit entry (%s)',
async (_name, run) => {
await run(service);
expect(lockMock).toHaveBeenCalledWith(tx, 'AuthorityNotice', 'n1');
const lockOrder = lockMock.mock.invocationCallOrder.at(-1) ?? 0;
expect(lockOrder).toBeLessThan(
tx.authorityNotice.findUnique.mock.invocationCallOrder[0],
);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'AuthorityNotice',
entityId: 'n1',
action: 'UPDATE',
userId: 'u1',
beforeData: expect.anything() as unknown,
afterData: expect.anything() as unknown,
}),
tx,
);
},
);
it('reopen locks the settings mutex before the notice row', async () => {
tx.authorityNotice.findUnique.mockResolvedValue(
notice({ status: 'SENT', openKey: null }),
);
await service.reopen('n1', 'u1');
expect(lockMock.mock.calls.map((call: unknown[]) => call[1])).toEqual([
'ComplianceSettings',
'AuthorityNotice',
]);
});
it('answers 404 for a missing notice', async () => {
lockMock.mockResolvedValue(false);
await expect(service.recount('missing', 'u1')).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
describe('update', () => {
it('changes the corrected fields but answers 409 on CANCELLED', async () => {
await service.update(
'n1',
{ totalColonies: 7, notes: 'korrigiert' },
'u1',
);
expect(tx.authorityNotice.update).toHaveBeenCalledWith({
where: { id: 'n1' },
data: expect.objectContaining({
totalColonies: 7,
notes: 'korrigiert',
}) as unknown,
});
tx.authorityNotice.findUnique.mockResolvedValue(
notice({ status: 'CANCELLED', openKey: null }),
);
await expect(
service.update('n1', { notes: 'x' }, 'u1'),
).rejects.toBeInstanceOf(ConflictException);
});
it('a draft has no sent day', async () => {
await expect(
service.update('n1', { sentOn: '2027-01-01' }, 'u1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('changing the sent day takes the settings mutex first', async () => {
tx.authorityNotice.findUnique.mockResolvedValue(
notice({ kind: 'REGISTRATION', status: 'SENT', openKey: null }),
);
await service.update('n1', { sentOn: '2027-01-02' }, 'u1');
expect(lockMock.mock.calls.map((call: unknown[]) => call[1])).toEqual([
'ComplianceSettings',
'AuthorityNotice',
]);
});
it('other edits do not take the settings mutex', async () => {
await service.update('n1', { notes: 'x' }, 'u1');
expect(lockMock.mock.calls.map((call: unknown[]) => call[1])).toEqual([
'AuthorityNotice',
]);
});
it('a sent notice keeps its sent day', async () => {
tx.authorityNotice.findUnique.mockResolvedValue(
notice({ status: 'SENT', openKey: null, sentOn: new Date(0) }),
);
await expect(
service.update('n1', { sentOn: null }, 'u1'),
).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('findAll / findOne', () => {
it('filters by kind and year', async () => {
await service.findAll({ kind: 'TSK_REPORT', year: 2027 });
expect(prisma.authorityNotice.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { kind: 'TSK_REPORT', year: 2027 },
}),
);
});
it('answers 404 for an unknown id', async () => {
(
prisma.authorityNotice as unknown as { findUnique: jest.Mock }
).findUnique = jest.fn().mockResolvedValue(null);
await expect(service.findOne('x')).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
});
@@ -0,0 +1,385 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
AuthorityNotice,
AuthorityNoticeKind,
Prisma,
} from '../../generated/prisma';
import { AuditLogService } from '../audit/audit-log.service';
import { berlinToday, dayToDate } from '../common/dates';
import { optionalDate } from '../common/patch-values';
import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate } from '../prisma/row-lock';
import { CreateAuthorityNoticeDto } from './dto/create-authority-notice.dto';
import { MarkSentDto } from './dto/mark-sent.dto';
import { UpdateAuthorityNoticeDto } from './dto/update-authority-notice.dto';
import { buildHerdSnapshot, loadHerdApiaryRows } from './herd-snapshot';
import { COMPLIANCE_SETTINGS_ID, findBaselineIn } from './herd-status';
const AUDIT_ENTITY = 'AuthorityNotice';
const LIST_LIMIT = 200;
const UNIQUE_VIOLATION = 'P2002';
type NoticeUpdate = Prisma.AuthorityNoticeUncheckedUpdateInput;
interface DraftInput {
kind: AuthorityNoticeKind;
year?: number;
referenceDate: Date;
}
interface TransitionOptions {
/** Takes the ComplianceSettings mutex first (the open key is involved). */
mutex?: boolean;
}
/** "REGISTRATION" | "HERD_CHANGE" | "TSK_REPORT:<year>": at most one draft each. */
function openKeyOf(kind: AuthorityNoticeKind, year: number | null): string {
return kind === 'TSK_REPORT' ? `TSK_REPORT:${year}` : kind;
}
function trimmedOrNull(
value: string | null | undefined,
): string | null | undefined {
if (value === undefined) {
return undefined;
}
return value?.trim() || null;
}
function notAllowed(message: string): ConflictException {
return new ConflictException({ statusCode: 409, message });
}
function draftExists(existingId: string): ConflictException {
return new ConflictException({
statusCode: 409,
code: 'DRAFT_EXISTS',
message: 'Es gibt schon einen offenen Entwurf dieser Anzeige.',
existingId,
});
}
function isUniqueViolation(error: unknown): boolean {
return (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === UNIQUE_VIOLATION
);
}
function assertYearFits(kind: AuthorityNoticeKind, year?: number): void {
if (kind === 'TSK_REPORT' && year === undefined) {
throw new BadRequestException('Die TSK-Zählung braucht ein Jahr');
}
if (kind !== 'TSK_REPORT' && year !== undefined) {
throw new BadRequestException('Ein Jahr gibt es nur bei der TSK-Zählung');
}
}
@Injectable()
export class AuthorityNoticesService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
) {}
findAll(q: {
kind?: AuthorityNoticeKind;
year?: number;
}): Promise<AuthorityNotice[]> {
return this.prisma.authorityNotice.findMany({
where: { kind: q.kind, year: q.year },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: LIST_LIMIT,
});
}
async findOne(id: string): Promise<AuthorityNotice> {
const notice = await this.prisma.authorityNotice.findUnique({
where: { id },
});
if (!notice) {
throw new NotFoundException('Anzeige nicht gefunden');
}
return notice;
}
/**
* Creates a draft, or a SENT notice at once when `sentOn` is given (the
* way to record what was already reported). A second open draft of the
* same kind and year answers 409 DRAFT_EXISTS with the id of the first.
*/
async create(
dto: CreateAuthorityNoticeDto,
actingUserId: string,
): Promise<AuthorityNotice> {
assertYearFits(dto.kind, dto.year);
const input = {
kind: dto.kind,
year: dto.year,
referenceDate: dayToDate(dto.referenceDate ?? berlinToday()),
sentOn: dto.sentOn ? dayToDate(dto.sentOn) : undefined,
};
try {
return await this.prisma.$transaction((tx) =>
this.insertIn(tx, input, actingUserId),
);
} catch (error) {
if (!isUniqueViolation(error)) {
throw error;
}
const winner = await this.prisma.authorityNotice.findFirst({
where: { openKey: openKeyOf(dto.kind, dto.year ?? null) },
select: { id: true },
});
if (winner) {
throw draftExists(winner.id);
}
throw error;
}
}
/** The shared core of create: the daily TSK job calls it in its own transaction. */
createDraftIn(
tx: Prisma.TransactionClient,
input: DraftInput,
userId: string | null,
): Promise<AuthorityNotice> {
assertYearFits(input.kind, input.year);
return this.insertIn(tx, input, userId);
}
/** Replaces the herd picture and the total; only a draft can be recounted. */
recount(id: string, actingUserId: string): Promise<AuthorityNotice> {
return this.transition(id, actingUserId, {}, async (tx, before) => {
if (before.status !== 'DRAFT') {
throw notAllowed('Nur ein Entwurf kann neu gezählt werden.');
}
const countedAt = new Date();
const snapshot = await this.countIn(tx, before.kind, countedAt);
return {
snapshot: snapshot as unknown as Prisma.InputJsonValue,
totalColonies: snapshot.totalLiving,
countedAt,
};
});
}
markSent(
id: string,
dto: MarkSentDto,
actingUserId: string,
): Promise<AuthorityNotice> {
return this.transition(id, actingUserId, { mutex: true }, (_tx, before) => {
if (before.status !== 'DRAFT') {
return Promise.reject(
notAllowed('Nur ein Entwurf kann als gesendet markiert werden.'),
);
}
return Promise.resolve({
status: 'SENT',
sentOn: dayToDate(dto.sentOn ?? berlinToday()),
sentVia: trimmedOrNull(dto.sentVia),
reference: trimmedOrNull(dto.reference),
openKey: null,
});
});
}
reopen(id: string, actingUserId: string): Promise<AuthorityNotice> {
return this.transition(
id,
actingUserId,
{ mutex: true },
async (tx, before) => {
if (before.status !== 'SENT') {
throw notAllowed('Nur eine gesendete Anzeige kann geöffnet werden.');
}
const openKey = openKeyOf(before.kind, before.year);
const other = await tx.authorityNotice.findFirst({
where: { openKey },
select: { id: true },
});
if (other) {
throw draftExists(other.id);
}
return { status: 'DRAFT', openKey, sentOn: null };
},
);
}
cancel(id: string, actingUserId: string): Promise<AuthorityNotice> {
return this.transition(id, actingUserId, {}, (_tx, before) => {
if (before.status !== 'DRAFT') {
return Promise.reject(
notAllowed('Nur ein Entwurf kann verworfen werden.'),
);
}
return Promise.resolve({ status: 'CANCELLED', openKey: null });
});
}
update(
id: string,
dto: UpdateAuthorityNoticeDto,
actingUserId: string,
): Promise<AuthorityNotice> {
// A new sent day can change which notice is the baseline: the mutex
// comes first, in lock order, whenever the sent day is touched.
const mutex = dto.sentOn !== undefined;
return this.transition(id, actingUserId, { mutex }, (_tx, before) => {
if (before.status === 'CANCELLED') {
return Promise.reject(
notAllowed('Eine verworfene Anzeige kann nicht geändert werden.'),
);
}
if (before.status === 'DRAFT' && dto.sentOn != null) {
return Promise.reject(
new BadRequestException('Ein Entwurf hat kein Sendedatum.'),
);
}
if (before.status === 'SENT' && dto.sentOn === null) {
return Promise.reject(
new BadRequestException(
'Eine gesendete Anzeige braucht ihr Sendedatum',
),
);
}
return Promise.resolve({
totalColonies: dto.totalColonies,
referenceDate: dto.referenceDate
? dayToDate(dto.referenceDate)
: undefined,
sentOn: optionalDate(dto.sentOn),
sentVia: trimmedOrNull(dto.sentVia),
reference: trimmedOrNull(dto.reference),
notes: trimmedOrNull(dto.notes),
});
});
}
private async countIn(
tx: Prisma.TransactionClient,
kind: AuthorityNoticeKind,
countedAt: Date,
) {
const settings = await tx.complianceSettings.findUniqueOrThrow({
where: { id: COMPLIANCE_SETTINGS_ID },
});
return buildHerdSnapshot(
await loadHerdApiaryRows(tx),
countedAt,
kind === 'TSK_REPORT' ? settings.tskExcludedColonyTypes : [],
);
}
private async insertIn(
tx: Prisma.TransactionClient,
input: DraftInput & { sentOn?: Date },
userId: string | null,
): Promise<AuthorityNotice> {
await this.lockSettingsIn(tx);
const baseline = await findBaselineIn(tx);
if (input.kind === 'REGISTRATION' && baseline) {
throw notAllowed('Die Erstanzeige ist schon erfasst.');
}
if (input.kind === 'HERD_CHANGE' && !baseline) {
throw notAllowed('Erst die Erstanzeige erfassen.');
}
const openKey = openKeyOf(input.kind, input.year ?? null);
// A notice recorded as already sent has no open key and cannot collide.
const draft = input.sentOn
? null
: await tx.authorityNotice.findFirst({
where: { openKey },
select: { id: true },
});
if (draft) {
throw draftExists(draft.id);
}
const countedAt = new Date();
const snapshot = await this.countIn(tx, input.kind, countedAt);
const created = await tx.authorityNotice.create({
data: {
kind: input.kind,
status: input.sentOn ? 'SENT' : 'DRAFT',
year: input.year ?? null,
referenceDate: input.referenceDate,
countedAt,
snapshot: snapshot as unknown as Prisma.InputJsonValue,
totalColonies: snapshot.totalLiving,
sentOn: input.sentOn ?? null,
openKey: input.sentOn ? null : openKey,
createdByUserId: userId,
},
});
await this.auditLog.record(
{
entityType: AUDIT_ENTITY,
entityId: created.id,
action: 'CREATE',
userId: userId ?? undefined,
afterData: created,
},
tx,
);
return created;
}
/** The ComplianceSettings row is the mutex of everything around the open key. */
private async lockSettingsIn(tx: Prisma.TransactionClient): Promise<void> {
await tx.complianceSettings.upsert({
where: { id: COMPLIANCE_SETTINGS_ID },
update: {},
create: { id: COMPLIANCE_SETTINGS_ID },
});
await lockRowForUpdate(tx, 'ComplianceSettings', COMPLIANCE_SETTINGS_ID);
}
/**
* One state change: lock (settings mutex first when asked, then the notice
* row), read "before" under the lock, check the transition, write, audit.
*/
private async transition(
id: string,
actingUserId: string,
options: TransitionOptions,
change: (
tx: Prisma.TransactionClient,
before: AuthorityNotice,
) => Promise<NoticeUpdate>,
): Promise<AuthorityNotice> {
return this.prisma.$transaction(async (tx) => {
if (options.mutex) {
await this.lockSettingsIn(tx);
}
if (!(await lockRowForUpdate(tx, 'AuthorityNotice', id))) {
throw new NotFoundException('Anzeige nicht gefunden');
}
const before = await tx.authorityNotice.findUnique({ where: { id } });
if (!before) {
throw new NotFoundException('Anzeige nicht gefunden');
}
const after = await tx.authorityNotice.update({
where: { id },
data: await change(tx, before),
});
await this.auditLog.record(
{
entityType: AUDIT_ENTITY,
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: after,
},
tx,
);
return after;
});
}
}
@@ -0,0 +1,33 @@
import {
describeNullContracts,
NullContract,
} from '../common/validation/null-contract.spec-helper';
import { UpdateAuthorityNoticeDto } from './dto/update-authority-notice.dto';
import { UpdateComplianceSettingsDto } from './dto/update-compliance-settings.dto';
const contracts: Record<string, NullContract> = {
UpdateComplianceSettingsDto: {
dto: UpdateComplianceSettingsDto,
notNull: [
'noticeOnColonyCountChange',
'noticeOnApiaryChange',
'tskReferenceDay',
'tskExcludedColonyTypes',
'netQuantityDigitRules',
],
clearable: [
'veterinaryOfficeName',
'veterinaryOfficeAddress',
'registrationNumber',
'tskReportDueDay',
'tskMemberNumber',
],
},
UpdateAuthorityNoticeDto: {
dto: UpdateAuthorityNoticeDto,
notNull: ['totalColonies', 'referenceDate'],
clearable: ['sentOn', 'sentVia', 'reference', 'notes'],
},
};
describeNullContracts('Compliance DTO null contracts', contracts);
@@ -0,0 +1,29 @@
import { Body, Controller, Get, Patch } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { ComplianceSettingsService } from './compliance-settings.service';
import { UpdateComplianceSettingsDto } from './dto/update-compliance-settings.dto';
@ApiTags('compliance')
@ApiBearerAuth()
@Controller('compliance/settings')
export class ComplianceSettingsController {
constructor(private readonly settings: ComplianceSettingsService) {}
@RequirePermission('compliance:read')
@Get()
get() {
return this.settings.get();
}
@RequirePermission('compliance:manage')
@Patch()
update(
@Body() dto: UpdateComplianceSettingsDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.settings.update(dto, actor.id);
}
}
@@ -0,0 +1,236 @@
import { BadRequestException } from '@nestjs/common';
import { AuditLogService } from '../audit/audit-log.service';
import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate } from '../prisma/row-lock';
import { ComplianceSettingsService } from './compliance-settings.service';
jest.mock('../prisma/row-lock', () => ({
lockRowForUpdate: jest.fn().mockResolvedValue(true),
}));
const STORED = {
id: 'singleton',
veterinaryOfficeName: null,
veterinaryOfficeAddress: null,
registrationNumber: null,
noticeOnColonyCountChange: true,
noticeOnApiaryChange: true,
tskReferenceDay: '01-01',
tskReportDueDay: null,
tskMemberNumber: null,
tskExcludedColonyTypes: [],
netQuantityDigitRules: [
{ maxGrams: 50, minMm: 2 },
{ maxGrams: null, minMm: 6 },
],
updatedAt: new Date(0),
};
describe('ComplianceSettingsService', () => {
let service: ComplianceSettingsService;
let tx: {
complianceSettings: {
upsert: jest.Mock;
findUniqueOrThrow: jest.Mock;
update: jest.Mock;
};
};
let prisma: {
$transaction: jest.Mock;
complianceSettings: { findUnique: jest.Mock; upsert: jest.Mock };
};
let auditLog: { record: jest.Mock };
beforeEach(() => {
(lockRowForUpdate as jest.Mock).mockResolvedValue(true);
tx = {
complianceSettings: {
upsert: jest.fn().mockResolvedValue(STORED),
findUniqueOrThrow: jest.fn().mockResolvedValue(STORED),
update: jest
.fn()
.mockImplementation(({ data }: { data: object }) =>
Promise.resolve({ ...STORED, ...data }),
),
},
};
prisma = {
$transaction: jest
.fn()
.mockImplementation((fn: (client: unknown) => unknown) => fn(tx)),
complianceSettings: {
findUnique: jest.fn().mockResolvedValue(null),
upsert: jest.fn().mockResolvedValue(STORED),
},
};
auditLog = { record: jest.fn() };
service = new ComplianceSettingsService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
);
});
it('creates defaults on first read', async () => {
const settings = await service.get();
expect(prisma.complianceSettings.upsert).toHaveBeenCalledWith({
where: { id: 'singleton' },
update: {},
create: { id: 'singleton' },
});
expect(settings).toBe(STORED);
});
it('returns the stored row without creating one', async () => {
prisma.complianceSettings.findUnique.mockResolvedValue(STORED);
await service.get();
expect(prisma.complianceSettings.upsert).not.toHaveBeenCalled();
});
it('rejects 02-29 as reference day with 400', async () => {
await expect(
service.update({ tskReferenceDay: '02-29' }, 'u1'),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
service.update({ tskReportDueDay: '13-01' }, 'u1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects digit rules that are not ascending or whose last rule has a limit', async () => {
const attempts = [
[
{ maxGrams: 200, minMm: 3 },
{ maxGrams: 50, minMm: 2 },
{ maxGrams: null, minMm: 6 },
],
[
{ maxGrams: 50, minMm: 2 },
{ maxGrams: 50, minMm: 3 },
{ maxGrams: null, minMm: 6 },
],
[{ maxGrams: 50, minMm: 2 }],
[
{ maxGrams: null, minMm: 2 },
{ maxGrams: null, minMm: 6 },
],
[],
];
for (const rules of attempts) {
await expect(
service.update({ netQuantityDigitRules: rules }, 'u1'),
).rejects.toBeInstanceOf(BadRequestException);
}
});
it('rejects a digit height outside 0.5 to 20 mm and more than 10 rules', async () => {
await expect(
service.update(
{ netQuantityDigitRules: [{ maxGrams: null, minMm: 0.4 }] },
'u1',
),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
service.update(
{ netQuantityDigitRules: [{ maxGrams: null, minMm: 20.5 }] },
'u1',
),
).rejects.toBeInstanceOf(BadRequestException);
const eleven = Array.from({ length: 10 }, (_, index) => ({
maxGrams: (index + 1) * 10,
minMm: 2,
})).concat([{ maxGrams: null as never, minMm: 2 }]);
await expect(
service.update({ netQuantityDigitRules: eleven }, 'u1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('treats a missing maxGrams key like null', async () => {
await service.update(
{
netQuantityDigitRules: [
{ maxGrams: 50, minMm: 2 },
{ minMm: 6 } as never,
],
},
'u1',
);
const call = (
tx.complianceSettings.update.mock.calls as unknown[][]
)[0][0] as {
data: { netQuantityDigitRules: unknown };
};
expect(call.data.netQuantityDigitRules).toEqual([
{ maxGrams: 50, minMm: 2 },
{ maxGrams: null, minMm: 6 },
]);
await expect(
service.update(
{
netQuantityDigitRules: [{ minMm: 2 } as never, { minMm: 6 } as never],
},
'u1',
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects a colony type listed twice', async () => {
await expect(
service.update(
{ tskExcludedColonyTypes: ['NUCLEUS_COLONY', 'NUCLEUS_COLONY'] },
'u1',
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('stores valid values under the row lock with an audit entry', async () => {
await service.update(
{
veterinaryOfficeName: ' Veterinäramt ',
tskReferenceDay: '04-15',
tskReportDueDay: null,
netQuantityDigitRules: [{ maxGrams: null, minMm: 3 }],
},
'u1',
);
expect(lockRowForUpdate).toHaveBeenCalledWith(
tx,
'ComplianceSettings',
'singleton',
);
expect(tx.complianceSettings.update).toHaveBeenCalledWith({
where: { id: 'singleton' },
data: expect.objectContaining({
veterinaryOfficeName: 'Veterinäramt',
tskReferenceDay: '04-15',
tskReportDueDay: null,
netQuantityDigitRules: [{ maxGrams: null, minMm: 3 }],
}) as unknown,
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'ComplianceSettings',
entityId: 'singleton',
action: 'UPDATE',
userId: 'u1',
}),
tx,
);
});
it('an omitted field keeps the stored value', async () => {
await service.update({ noticeOnApiaryChange: false }, 'u1');
const call = (
tx.complianceSettings.update.mock.calls as unknown[][]
)[0][0] as {
data: Record<string, unknown>;
};
expect(call.data.noticeOnApiaryChange).toBe(false);
expect(call.data.tskReferenceDay).toBeUndefined();
expect(call.data.veterinaryOfficeName).toBeUndefined();
});
});
@@ -0,0 +1,150 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { ComplianceSettings } from '../../generated/prisma';
import { AuditLogService } from '../audit/audit-log.service';
import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate } from '../prisma/row-lock';
import { UpdateComplianceSettingsDto } from './dto/update-compliance-settings.dto';
import { complianceSettingsIn, COMPLIANCE_SETTINGS_ID } from './herd-status';
import { isValidMonthDay } from './tsk-day';
const AUDIT_ENTITY = 'ComplianceSettings';
const MIN_DIGIT_RULES = 1;
const MAX_DIGIT_RULES = 10;
const MIN_DIGIT_MM = 0.5;
const MAX_DIGIT_MM = 20;
interface DigitRuleInput {
maxGrams: number | null;
minMm: number;
}
// undefined keeps the stored value, null or blank clears it, anything else is
// stored trimmed.
function trimmedOrNull(
value: string | null | undefined,
): string | null | undefined {
if (value === undefined) {
return undefined;
}
return value?.trim() || null;
}
function assertMonthDay(field: string, value: string | null | undefined): void {
if (value != null && !isValidMonthDay(value)) {
throw new BadRequestException(
`${field} muss ein gültiger Tag im Format MM-TT sein (ohne 29. Februar)`,
);
}
}
/**
* 1 to 10 rules, the limits strictly ascending, only the last without a
* limit, every height between 0.5 and 20 mm. The values themselves are the
* owner's assumption - nothing here knows what the law asks for.
*/
function assertDigitRules(rules: readonly DigitRuleInput[]): void {
if (rules.length < MIN_DIGIT_RULES || rules.length > MAX_DIGIT_RULES) {
throw new BadRequestException(
`Es braucht ${MIN_DIGIT_RULES} bis ${MAX_DIGIT_RULES} Regeln für die Ziffernhöhe`,
);
}
let previous = 0;
rules.forEach((rule, index) => {
const isLast = index === rules.length - 1;
if (rule.minMm < MIN_DIGIT_MM || rule.minMm > MAX_DIGIT_MM) {
throw new BadRequestException(
`Die Ziffernhöhe muss zwischen ${MIN_DIGIT_MM} und ${MAX_DIGIT_MM} mm liegen`,
);
}
if (isLast) {
if (rule.maxGrams !== null) {
throw new BadRequestException(
'Die letzte Regel gilt für alles darüber und hat keine Obergrenze',
);
}
return;
}
if (rule.maxGrams === null || rule.maxGrams <= previous) {
throw new BadRequestException(
'Die Obergrenzen der Regeln müssen aufsteigen, nur die letzte hat keine',
);
}
previous = rule.maxGrams;
});
}
function assertUnique(values: readonly string[]): void {
if (new Set(values).size !== values.length) {
throw new BadRequestException('Eine Völkerart steht doppelt in der Liste');
}
}
@Injectable()
export class ComplianceSettingsService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
) {}
/** Find-or-create, like SiteSettings. */
get(): Promise<ComplianceSettings> {
return complianceSettingsIn(this.prisma);
}
async update(
dto: UpdateComplianceSettingsDto,
actingUserId: string,
): Promise<ComplianceSettings> {
assertMonthDay('Der Stichtag', dto.tskReferenceDay);
assertMonthDay('Die Meldefrist', dto.tskReportDueDay);
if (dto.tskExcludedColonyTypes) {
assertUnique(dto.tskExcludedColonyTypes);
}
const digitRules = dto.netQuantityDigitRules?.map((rule) => ({
maxGrams: rule.maxGrams ?? null,
minMm: rule.minMm,
}));
if (digitRules) {
assertDigitRules(digitRules);
}
return this.prisma.$transaction(async (tx) => {
// The row must exist to be locked; "before" is read after the lock.
await tx.complianceSettings.upsert({
where: { id: COMPLIANCE_SETTINGS_ID },
update: {},
create: { id: COMPLIANCE_SETTINGS_ID },
});
await lockRowForUpdate(tx, 'ComplianceSettings', COMPLIANCE_SETTINGS_ID);
const before = await tx.complianceSettings.findUniqueOrThrow({
where: { id: COMPLIANCE_SETTINGS_ID },
});
const settings = await tx.complianceSettings.update({
where: { id: COMPLIANCE_SETTINGS_ID },
data: {
veterinaryOfficeName: trimmedOrNull(dto.veterinaryOfficeName),
veterinaryOfficeAddress: trimmedOrNull(dto.veterinaryOfficeAddress),
registrationNumber: trimmedOrNull(dto.registrationNumber),
noticeOnColonyCountChange: dto.noticeOnColonyCountChange,
noticeOnApiaryChange: dto.noticeOnApiaryChange,
tskReferenceDay: dto.tskReferenceDay,
tskReportDueDay: dto.tskReportDueDay,
tskMemberNumber: trimmedOrNull(dto.tskMemberNumber),
tskExcludedColonyTypes: dto.tskExcludedColonyTypes,
netQuantityDigitRules: digitRules,
},
});
await this.auditLog.record(
{
entityType: AUDIT_ENTITY,
entityId: COMPLIANCE_SETTINGS_ID,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: settings,
},
tx,
);
return settings;
});
}
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { AuthorityNoticesController } from './authority-notices.controller';
import { AuthorityNoticesService } from './authority-notices.service';
import { ComplianceSettingsController } from './compliance-settings.controller';
import { ComplianceSettingsService } from './compliance-settings.service';
// No provider here may inject PermissionResolverService: the worker imports
// this module too (handlers arrive with the notification task).
@Module({
controllers: [ComplianceSettingsController, AuthorityNoticesController],
providers: [ComplianceSettingsService, AuthorityNoticesService],
exports: [ComplianceSettingsService, AuthorityNoticesService],
})
export class ComplianceModule {}
@@ -0,0 +1,50 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsDateString,
IsEnum,
IsInt,
IsOptional,
Matches,
Max,
Min,
} from 'class-validator';
import { AuthorityNoticeKind } from '../../../generated/prisma';
export const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
export const DAY_MESSAGE = 'muss ein Datum im Format JJJJ-MM-TT sein';
export const MIN_NOTICE_YEAR = 2000;
export const MAX_NOTICE_YEAR = 2100;
export class CreateAuthorityNoticeDto {
@ApiProperty({ enum: AuthorityNoticeKind })
@IsEnum(AuthorityNoticeKind)
kind!: AuthorityNoticeKind;
@ApiPropertyOptional({
description: 'Nur für die TSK-Zählung (dort Pflicht)',
})
@IsOptional()
@IsInt()
@Min(MIN_NOTICE_YEAR)
@Max(MAX_NOTICE_YEAR)
year?: number;
@ApiPropertyOptional({
example: '2027-01-01',
description: 'Vorgabe: heute (Berlin)',
})
@IsOptional()
@Matches(DAY_PATTERN, { message: `referenceDate ${DAY_MESSAGE}` })
@IsDateString({ strict: true })
referenceDate?: string;
@ApiPropertyOptional({
example: '2026-05-01',
description:
'Gesetzt: die Anzeige entsteht gleich als gesendet (Altbestand)',
})
@IsOptional()
@Matches(DAY_PATTERN, { message: `sentOn ${DAY_MESSAGE}` })
@IsDateString({ strict: true })
sentOn?: string;
}
@@ -0,0 +1,33 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
IsDateString,
IsOptional,
IsString,
Matches,
MaxLength,
} from 'class-validator';
import { DAY_MESSAGE, DAY_PATTERN } from './create-authority-notice.dto';
import { MAX_NOTICE_TEXT_LENGTH } from './update-authority-notice.dto';
export class MarkSentDto {
@ApiPropertyOptional({
example: '2027-01-15',
description: 'Vorgabe: heute (Berlin)',
})
@IsOptional()
@Matches(DAY_PATTERN, { message: `sentOn ${DAY_MESSAGE}` })
@IsDateString({ strict: true })
sentOn?: string;
@ApiPropertyOptional({ description: 'z. B. E-Mail, Post' })
@IsOptional()
@IsString()
@MaxLength(MAX_NOTICE_TEXT_LENGTH)
sentVia?: string;
@ApiPropertyOptional({ description: 'Aktenzeichen' })
@IsOptional()
@IsString()
@MaxLength(MAX_NOTICE_TEXT_LENGTH)
reference?: string;
}
@@ -0,0 +1,23 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator';
import { AuthorityNoticeKind } from '../../../generated/prisma';
import {
MAX_NOTICE_YEAR,
MIN_NOTICE_YEAR,
} from './create-authority-notice.dto';
export class NoticeListQuery {
@ApiPropertyOptional({ enum: AuthorityNoticeKind })
@IsOptional()
@IsEnum(AuthorityNoticeKind)
kind?: AuthorityNoticeKind;
@ApiPropertyOptional()
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(MIN_NOTICE_YEAR)
@Max(MAX_NOTICE_YEAR)
year?: number;
}
@@ -0,0 +1,55 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
IsDateString,
IsInt,
IsOptional,
IsString,
Matches,
MaxLength,
Min,
} from 'class-validator';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
import { DAY_MESSAGE, DAY_PATTERN } from './create-authority-notice.dto';
export const MAX_NOTICE_TEXT_LENGTH = 300;
export const MAX_NOTICE_NOTES_LENGTH = 2000;
export class UpdateAuthorityNoticeDto {
@ApiPropertyOptional({
description: 'Die angezeigte Zahl; bleibt korrigierbar',
})
@IsOptionalNotNull()
@IsInt()
@Min(0)
totalColonies?: number;
@ApiPropertyOptional({ example: '2027-01-01' })
@IsOptionalNotNull()
@Matches(DAY_PATTERN, { message: `referenceDate ${DAY_MESSAGE}` })
@IsDateString({ strict: true })
referenceDate?: string;
@ApiPropertyOptional({ nullable: true, example: '2027-01-15' })
@IsOptional()
@Matches(DAY_PATTERN, { message: `sentOn ${DAY_MESSAGE}` })
@IsDateString({ strict: true })
sentOn?: string | null;
@ApiPropertyOptional({ nullable: true, description: 'z. B. E-Mail, Post' })
@IsOptional()
@IsString()
@MaxLength(MAX_NOTICE_TEXT_LENGTH)
sentVia?: string | null;
@ApiPropertyOptional({ nullable: true, description: 'Aktenzeichen' })
@IsOptional()
@IsString()
@MaxLength(MAX_NOTICE_TEXT_LENGTH)
reference?: string | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(MAX_NOTICE_NOTES_LENGTH)
notes?: string | null;
}
@@ -0,0 +1,104 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsEnum,
IsNumber,
IsOptional,
IsString,
Matches,
MaxLength,
ValidateNested,
} from 'class-validator';
import { ColonyType } from '../../../generated/prisma';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
const MONTH_DAY_PATTERN = /^\d{2}-\d{2}$/;
const MONTH_DAY_MESSAGE = 'muss ein Tag im Format MM-TT sein';
const MAX_NAME_LENGTH = 200;
const MAX_ADDRESS_LENGTH = 300;
/** One row of the label digit height table. Annahme - vom Inhaber zu prüfen. */
export class NetQuantityDigitRuleDto {
@ApiProperty({
nullable: true,
description: 'Obergrenze in g, null = alles darüber',
})
@IsOptional()
@IsNumber({ allowNaN: false, allowInfinity: false })
maxGrams!: number | null;
@ApiProperty({ description: 'Mindesthöhe der Ziffern in mm' })
@IsNumber({ allowNaN: false, allowInfinity: false })
minMm!: number;
}
/**
* Legal values (deadlines, digit heights) are settings, not code. Every value
* here is an assumption the owner has to check.
*/
export class UpdateComplianceSettingsDto {
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(MAX_NAME_LENGTH)
veterinaryOfficeName?: string | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(MAX_ADDRESS_LENGTH)
veterinaryOfficeAddress?: string | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(MAX_NAME_LENGTH)
registrationNumber?: string | null;
@ApiPropertyOptional()
@IsOptionalNotNull()
@IsBoolean()
noticeOnColonyCountChange?: boolean;
@ApiPropertyOptional()
@IsOptionalNotNull()
@IsBoolean()
noticeOnApiaryChange?: boolean;
@ApiPropertyOptional({ example: '01-01', description: 'MM-TT, ohne 02-29' })
@IsOptionalNotNull()
@IsString()
@Matches(MONTH_DAY_PATTERN, {
message: `tskReferenceDay ${MONTH_DAY_MESSAGE}`,
})
tskReferenceDay?: string;
@ApiPropertyOptional({ nullable: true, example: '03-31' })
@IsOptional()
@IsString()
@Matches(MONTH_DAY_PATTERN, {
message: `tskReportDueDay ${MONTH_DAY_MESSAGE}`,
})
tskReportDueDay?: string | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(MAX_NAME_LENGTH)
tskMemberNumber?: string | null;
@ApiPropertyOptional({ enum: ColonyType, isArray: true })
@IsOptionalNotNull()
@IsArray()
@IsEnum(ColonyType, { each: true })
tskExcludedColonyTypes?: ColonyType[];
@ApiPropertyOptional({ type: [NetQuantityDigitRuleDto] })
@IsOptionalNotNull()
@IsArray()
@ValidateNested({ each: true })
@Type(() => NetQuantityDigitRuleDto)
netQuantityDigitRules?: NetQuantityDigitRuleDto[];
}
@@ -0,0 +1,182 @@
import { ColonyType } from '../../generated/prisma';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import {
buildHerdSnapshot,
herdDeviation,
HerdApiaryRow,
HerdSnapshot,
loadHerdApiaryRows,
} from './herd-snapshot';
const TAKEN_AT = new Date('2027-01-01T10:00:00.000Z');
const ENABLED = { noticeOnColonyCountChange: true, noticeOnApiaryChange: true };
const P = ColonyType.PRODUCTION_COLONY;
const N = ColonyType.NUCLEUS_COLONY;
function row(
apiaryId: string,
colonyTypes: ColonyType[],
patch: Partial<HerdApiaryRow> = {},
): HerdApiaryRow {
return {
apiaryId,
name: `Stand ${apiaryId}`,
address: `Weg ${apiaryId}`,
location: null,
latitude: 48.1,
longitude: 11.5,
colonyTypes,
...patch,
};
}
describe('buildHerdSnapshot', () => {
it('counts per apiary and per type', () => {
const snapshot = buildHerdSnapshot(
[row('a', [P, P, N]), row('b', [P])],
TAKEN_AT,
);
expect(snapshot.version).toBe(1);
expect(snapshot.takenAt).toBe(TAKEN_AT.toISOString());
expect(snapshot.totalLiving).toBe(4);
expect(snapshot.apiaries[0]).toMatchObject({
apiaryId: 'a',
livingColonies: 3,
byType: { PRODUCTION_COLONY: 2, NUCLEUS_COLONY: 1 },
});
});
it('excluded colony types do not count', () => {
const snapshot = buildHerdSnapshot([row('a', [P, N, N])], TAKEN_AT, [N]);
expect(snapshot.totalLiving).toBe(1);
expect(snapshot.apiaries[0].byType).toEqual({ PRODUCTION_COLONY: 1 });
});
it('keeps an apiary without colonies', () => {
const snapshot = buildHerdSnapshot([row('a', [])], TAKEN_AT);
expect(snapshot.apiaries).toHaveLength(1);
expect(snapshot.totalLiving).toBe(0);
});
});
describe('loadHerdApiaryRows', () => {
it('counts only living colonies at active apiaries', async () => {
const findMany = jest.fn().mockResolvedValue([
{
id: 'a',
name: 'A',
address: null,
location: null,
latitude: null,
longitude: null,
colonies: [{ colonyType: P }, { colonyType: N }],
},
]);
const db = { apiary: { findMany } };
const rows = await loadHerdApiaryRows(db as never);
const args = (findMany.mock.calls as unknown[][])[0][0] as {
where: unknown;
select: { colonies: { where: unknown } };
};
expect(args.where).toEqual({ status: 'ACTIVE' });
expect(args.select.colonies.where).toEqual(LIVING_COLONY_WHERE);
expect(rows).toEqual([
expect.objectContaining({ apiaryId: 'a', colonyTypes: [P, N] }),
]);
});
});
describe('herdDeviation', () => {
const base = [row('a', [P, P]), row('b', [P])];
function baselineOf(rows: HerdApiaryRow[], totalColonies?: number) {
const snapshot = buildHerdSnapshot(rows, TAKEN_AT);
return { snapshot, totalColonies: totalColonies ?? snapshot.totalLiving };
}
it('no deviation without changes', () => {
const deviation = herdDeviation(
baselineOf(base),
buildHerdSnapshot(base, TAKEN_AT),
ENABLED,
);
expect(deviation).toEqual({
triggered: false,
colonyCountChanged: false,
apiariesAdded: [],
apiariesRemoved: [],
apiariesRelocated: [],
});
});
it('count change triggers only when enabled', () => {
const current = buildHerdSnapshot([row('a', [P, P, P]), base[1]], TAKEN_AT);
expect(herdDeviation(baselineOf(base), current, ENABLED).triggered).toBe(
true,
);
const off = herdDeviation(baselineOf(base), current, {
...ENABLED,
noticeOnColonyCountChange: false,
});
expect(off.triggered).toBe(false);
expect(off.colonyCountChanged).toBe(true);
});
it('moving between apiaries keeps the total and triggers nothing', () => {
const current = buildHerdSnapshot(
[row('a', [P]), row('b', [P, P])],
TAKEN_AT,
);
const deviation = herdDeviation(baselineOf(base), current, ENABLED);
expect(deviation.colonyCountChanged).toBe(false);
expect(deviation.triggered).toBe(false);
});
it('new, removed or relocated apiary triggers when enabled', () => {
const added = buildHerdSnapshot([...base, row('c', [])], TAKEN_AT);
const removed = buildHerdSnapshot([base[0]], TAKEN_AT);
const relocated = buildHerdSnapshot(
[base[0], row('b', [P], { address: 'Neuer Weg 1' })],
TAKEN_AT,
);
const sameCount = (current: HerdSnapshot) =>
herdDeviation(baselineOf(base, current.totalLiving), current, ENABLED);
expect(sameCount(added)).toMatchObject({
triggered: true,
apiariesAdded: ['c'],
});
expect(sameCount(removed)).toMatchObject({
triggered: true,
apiariesRemoved: ['b'],
});
expect(sameCount(relocated)).toMatchObject({
triggered: true,
apiariesRelocated: ['b'],
});
expect(
herdDeviation(baselineOf(base, 3), relocated, {
...ENABLED,
noticeOnApiaryChange: false,
}).triggered,
).toBe(false);
});
it('compares the count with the corrected totalColonies of the baseline', () => {
const current = buildHerdSnapshot(base, TAKEN_AT);
const deviation = herdDeviation(baselineOf(base, 5), current, ENABLED);
expect(deviation.colonyCountChanged).toBe(true);
expect(deviation.triggered).toBe(true);
});
});
@@ -0,0 +1,161 @@
import { ColonyType, ComplianceSettings } from '../../generated/prisma';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import { DbClient } from '../prisma/db-client';
export const HERD_SNAPSHOT_VERSION = 1;
export interface HerdApiarySnapshot {
apiaryId: string;
name: string;
address: string | null;
location: string | null;
latitude: number | null;
longitude: number | null;
livingColonies: number;
byType: Partial<Record<ColonyType, number>>;
}
export interface HerdSnapshot {
version: 1;
takenAt: string;
apiaries: HerdApiarySnapshot[];
totalLiving: number;
}
/** An active apiary with the colony type of each living colony standing there. */
export interface HerdApiaryRow {
apiaryId: string;
name: string;
address: string | null;
location: string | null;
latitude: number | null;
longitude: number | null;
colonyTypes: ColonyType[];
}
export interface HerdDeviation {
triggered: boolean;
colonyCountChanged: boolean;
apiariesAdded: string[];
apiariesRemoved: string[];
apiariesRelocated: string[];
}
type DeviationSettings = Pick<
ComplianceSettings,
'noticeOnColonyCountChange' | 'noticeOnApiaryChange'
>;
/** Pure: counts the living colonies per apiary and per type. */
export function buildHerdSnapshot(
rows: readonly HerdApiaryRow[],
takenAt: Date,
excludedTypes: readonly ColonyType[] = [],
): HerdSnapshot {
const apiaries = rows.map((row): HerdApiarySnapshot => {
const byType: Partial<Record<ColonyType, number>> = {};
let livingColonies = 0;
for (const type of row.colonyTypes) {
if (excludedTypes.includes(type)) {
continue;
}
byType[type] = (byType[type] ?? 0) + 1;
livingColonies += 1;
}
return {
apiaryId: row.apiaryId,
name: row.name,
address: row.address,
location: row.location,
latitude: row.latitude,
longitude: row.longitude,
livingColonies,
byType,
};
});
return {
version: HERD_SNAPSHOT_VERSION,
takenAt: takenAt.toISOString(),
apiaries,
totalLiving: apiaries.reduce(
(sum, apiary) => sum + apiary.livingColonies,
0,
),
};
}
/** Active apiaries with their living colonies (LIVING_COLONY_WHERE). */
export async function loadHerdApiaryRows(
db: DbClient,
): Promise<HerdApiaryRow[]> {
const apiaries = await db.apiary.findMany({
where: { status: 'ACTIVE' },
select: {
id: true,
name: true,
address: true,
location: true,
latitude: true,
longitude: true,
colonies: { where: LIVING_COLONY_WHERE, select: { colonyType: true } },
},
orderBy: [{ name: 'asc' }, { id: 'asc' }],
});
return apiaries.map((apiary) => ({
apiaryId: apiary.id,
name: apiary.name,
address: apiary.address,
location: apiary.location,
latitude: apiary.latitude,
longitude: apiary.longitude,
colonyTypes: apiary.colonies.map((colony) => colony.colonyType),
}));
}
function sameSite(a: HerdApiarySnapshot, b: HerdApiarySnapshot): boolean {
return (
a.address === b.address &&
a.location === b.location &&
a.latitude === b.latitude &&
a.longitude === b.longitude
);
}
/**
* How the current herd differs from the baseline. The count is compared with
* the corrected `totalColonies` of the notice, not with its snapshot. A move
* between apiaries keeps the total and triggers nothing.
*/
export function herdDeviation(
baseline: { snapshot: HerdSnapshot; totalColonies: number },
current: HerdSnapshot,
settings: DeviationSettings,
): HerdDeviation {
const before = new Map(
baseline.snapshot.apiaries.map((apiary) => [apiary.apiaryId, apiary]),
);
const now = new Map(
current.apiaries.map((apiary) => [apiary.apiaryId, apiary]),
);
const apiariesAdded = [...now.keys()].filter((id) => !before.has(id));
const apiariesRemoved = [...before.keys()].filter((id) => !now.has(id));
const apiariesRelocated = [...now.entries()]
.filter(([id, apiary]) => {
const old = before.get(id);
return old !== undefined && !sameSite(old, apiary);
})
.map(([id]) => id);
const colonyCountChanged = baseline.totalColonies !== current.totalLiving;
const apiaryChanged =
apiariesAdded.length + apiariesRemoved.length + apiariesRelocated.length >
0;
return {
triggered:
(settings.noticeOnColonyCountChange && colonyCountChanged) ||
(settings.noticeOnApiaryChange && apiaryChanged),
colonyCountChanged,
apiariesAdded,
apiariesRemoved,
apiariesRelocated,
};
}
@@ -0,0 +1,82 @@
import { herdStatusIn } from './herd-status';
const NOW = new Date('2027-03-01T10:00:00.000Z');
function settings() {
return {
id: 'singleton',
noticeOnColonyCountChange: true,
noticeOnApiaryChange: true,
tskExcludedColonyTypes: [],
};
}
function dbWith(baseline: unknown) {
return {
complianceSettings: {
findUnique: jest.fn().mockResolvedValue(settings()),
upsert: jest.fn().mockResolvedValue(settings()),
},
authorityNotice: { findFirst: jest.fn().mockResolvedValue(baseline) },
apiary: {
findMany: jest.fn().mockResolvedValue([
{
id: 'a',
name: 'A',
address: null,
location: null,
latitude: null,
longitude: null,
colonies: [{ colonyType: 'PRODUCTION_COLONY' }],
},
]),
},
};
}
describe('herdStatusIn', () => {
it('no baseline means deviation null, not a herd change', async () => {
const status = await herdStatusIn(dbWith(null) as never, NOW);
expect(status.baseline).toBeNull();
expect(status.deviation).toBeNull();
expect(status.current.totalLiving).toBe(1);
});
it('the baseline is the newest SENT registration or herd change by sentOn and ignores TSK reports', async () => {
const db = dbWith(null);
await herdStatusIn(db as never, NOW);
expect(db.authorityNotice.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: {
status: 'SENT',
kind: { in: ['REGISTRATION', 'HERD_CHANGE'] },
},
orderBy: [{ sentOn: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
}),
);
});
it('reports the deviation against the baseline', async () => {
const baseline = {
id: 'n1',
totalColonies: 3,
snapshot: {
version: 1,
takenAt: NOW.toISOString(),
apiaries: [],
totalLiving: 3,
},
};
const status = await herdStatusIn(dbWith(baseline) as never, NOW);
expect(status.deviation).toMatchObject({
triggered: true,
colonyCountChanged: true,
apiariesAdded: ['a'],
});
});
});
@@ -0,0 +1,71 @@
import { AuthorityNotice, ComplianceSettings } from '../../generated/prisma';
import { DbClient } from '../prisma/db-client';
import {
buildHerdSnapshot,
herdDeviation,
HerdDeviation,
HerdSnapshot,
loadHerdApiaryRows,
} from './herd-snapshot';
export const COMPLIANCE_SETTINGS_ID = 'singleton';
export interface HerdStatus {
settings: ComplianceSettings;
baseline: AuthorityNotice | null;
current: HerdSnapshot;
/** null without a baseline: there is nothing to deviate from. */
deviation: HerdDeviation | null;
}
/** The settings row, created with its defaults when it does not exist yet. */
export async function complianceSettingsIn(
db: DbClient,
): Promise<ComplianceSettings> {
const existing = await db.complianceSettings.findUnique({
where: { id: COMPLIANCE_SETTINGS_ID },
});
if (existing) {
return existing;
}
return db.complianceSettings.upsert({
where: { id: COMPLIANCE_SETTINGS_ID },
update: {},
create: { id: COMPLIANCE_SETTINGS_ID },
});
}
/**
* The baseline is the newest SENT registration or herd change (by sentOn);
* TSK reports go to another recipient and do not count.
*/
export function findBaselineIn(db: DbClient): Promise<AuthorityNotice | null> {
return db.authorityNotice.findFirst({
where: { status: 'SENT', kind: { in: ['REGISTRATION', 'HERD_CHANGE'] } },
orderBy: [{ sentOn: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
});
}
export async function herdStatusIn(
db: DbClient,
now: Date,
): Promise<HerdStatus> {
const settings = await complianceSettingsIn(db);
const baseline = await findBaselineIn(db);
const current = buildHerdSnapshot(await loadHerdApiaryRows(db), now);
return {
settings,
baseline,
current,
deviation: baseline
? herdDeviation(
{
snapshot: baseline.snapshot as unknown as HerdSnapshot,
totalColonies: baseline.totalColonies,
},
current,
settings,
)
: null,
};
}
@@ -0,0 +1,178 @@
import { ForbiddenException } from '@nestjs/common';
import {
assertNoActiveRestriction,
isRestrictionActive,
} from './restricted-zone';
const day = (value: string): Date => new Date(`${value}T00:00:00.000Z`);
function row(overrides: Record<string, unknown> = {}) {
return {
id: 'r1',
apiaryId: 'a1',
apiary: { name: 'Stand Nord' },
disease: 'Faulbrut',
startsOn: day('2026-10-01'),
endsOn: null,
...overrides,
};
}
function fakeTx(rows: unknown[]) {
return { apiaryRestriction: { findMany: jest.fn().mockResolvedValue(rows) } };
}
interface HttpBody {
code: string;
message: string;
restrictionIds: string[];
restrictions: Record<string, string>[];
}
async function refusal(promise: Promise<unknown>): Promise<HttpBody> {
try {
await promise;
} catch (error) {
return (error as { getResponse: () => HttpBody }).getResponse();
}
throw new Error('expected a refusal');
}
describe('isRestrictionActive', () => {
it('active on the start and end day, inactive the day after', () => {
const restriction = {
startsOn: day('2026-10-01'),
endsOn: day('2026-10-10'),
};
expect(isRestrictionActive(restriction, '2026-09-30')).toBe(false);
expect(isRestrictionActive(restriction, '2026-10-01')).toBe(true);
expect(isRestrictionActive(restriction, '2026-10-10')).toBe(true);
expect(isRestrictionActive(restriction, '2026-10-11')).toBe(false);
});
it('an open end date means until further notice', () => {
const restriction = { startsOn: day('2026-10-01'), endsOn: null };
expect(isRestrictionActive(restriction, '2026-10-01')).toBe(true);
expect(isRestrictionActive(restriction, '2040-01-01')).toBe(true);
});
});
describe('assertNoActiveRestriction', () => {
it('passes without a query when no apiary is named', async () => {
const tx = fakeTx([]);
await expect(
assertNoActiveRestriction(tx as never, [], '2026-10-15'),
).resolves.toEqual({ overridden: false, restrictionIds: [] });
expect(tx.apiaryRestriction.findMany).not.toHaveBeenCalled();
});
it('passes when the only restriction ended yesterday', async () => {
const tx = fakeTx([row({ endsOn: day('2026-10-14') })]);
await expect(
assertNoActiveRestriction(tx as never, ['a1'], '2026-10-15'),
).resolves.toEqual({ overridden: false, restrictionIds: [] });
});
it('asks for each apiary once, with the unique ids', async () => {
const tx = fakeTx([]);
await assertNoActiveRestriction(
tx as never,
['a2', 'a1', 'a2'],
'2026-10-15',
);
const [[args]] = tx.apiaryRestriction.findMany.mock.calls as [
[{ where: { apiaryId: { in: string[] } } }],
];
expect([...args.where.apiaryId.in].sort()).toEqual(['a1', 'a2']);
});
it('refuses moving into and out of a restricted apiary', async () => {
const tx = fakeTx([row()]);
await expect(
assertNoActiveRestriction(tx as never, ['a0', 'a1'], '2026-10-15'),
).rejects.toMatchObject({ status: 409 });
});
it('the 409 body names apiary, disease and start', async () => {
const tx = fakeTx([row({ id: 'r9' })]);
const body = await refusal(
assertNoActiveRestriction(tx as never, ['a1'], '2026-10-15'),
);
expect(body.code).toBe('RESTRICTED_ZONE');
expect(body.restrictionIds).toEqual(['r9']);
expect(body.restrictions).toEqual([
{
id: 'r9',
apiaryId: 'a1',
apiaryName: 'Stand Nord',
disease: 'Faulbrut',
startsOn: '2026-10-01',
},
]);
expect(body.message).toContain('Stand Nord');
expect(body.message).toContain('Faulbrut');
expect(body.message).toContain('01.10.2026');
});
it('override needs compliance:manage and a reason', async () => {
const tx = fakeTx([row()]);
await expect(
assertNoActiveRestriction(tx as never, ['a1'], '2026-10-15', {
reason: 'Amtstierarzt hat es genehmigt',
canOverride: false,
}),
).rejects.toBeInstanceOf(ForbiddenException);
await expect(
assertNoActiveRestriction(tx as never, ['a1'], '2026-10-15', {
reason: 'Amtstierarzt hat es genehmigt',
canOverride: true,
}),
).resolves.toEqual({ overridden: true, restrictionIds: ['r1'] });
});
it.each([null, undefined])(
'a missing reason (%s) is no override even with the right',
async (reason) => {
const tx = fakeTx([row()]);
await expect(
assertNoActiveRestriction(tx as never, ['a1'], '2026-10-15', {
reason,
canOverride: true,
}),
).rejects.toMatchObject({ status: 409 });
},
);
it('a reason without an active restriction is ignored, even without the right', async () => {
const tx = fakeTx([]);
await expect(
assertNoActiveRestriction(tx as never, ['a1'], '2026-10-15', {
reason: 'Amtstierarzt hat es genehmigt',
canOverride: false,
}),
).resolves.toEqual({ overridden: false, restrictionIds: [] });
});
it('refuses the root client', async () => {
const root = {
$connect: jest.fn(),
apiaryRestriction: { findMany: jest.fn() },
};
await expect(
assertNoActiveRestriction(root as never, ['a1'], '2026-10-15'),
).rejects.toThrow('needs the transaction client');
});
});
@@ -0,0 +1,143 @@
import { ConflictException, ForbiddenException } from '@nestjs/common';
import { Prisma } from '../../generated/prisma';
import { dayToDate } from '../common/dates';
import { formatDayDe } from '../common/format-day';
import { assertTransactionClient, DbClient } from '../prisma/db-client';
/**
* What a caller may do about an active restriction: `reason` is the typed
* justification (null and undefined count as omitted), `canOverride` says
* whether the user holds compliance:manage.
*/
export interface RestrictionOverride {
reason: string | null | undefined;
canOverride: boolean;
}
export interface ActiveRestrictionInfo {
id: string;
apiaryId: string;
apiaryName: string;
disease: string;
startsOn: string;
}
export interface RestrictionCheck {
overridden: boolean;
restrictionIds: string[];
}
export const RESTRICTED_ZONE_CODE = 'RESTRICTED_ZONE';
const NO_RESTRICTION: RestrictionCheck = {
overridden: false,
restrictionIds: [],
};
function dayOf(value: Date): string {
return value.toISOString().slice(0, 10);
}
/**
* Active from the start day to the end day, both included; an open end date
* means until further notice. `day` is a Berlin calendar day (YYYY-MM-DD),
* the dates are stored at midnight UTC of their day.
*/
export function isRestrictionActive(
restriction: { startsOn: Date; endsOn: Date | null },
day: string,
): boolean {
return (
dayOf(restriction.startsOn) <= day &&
(restriction.endsOn === null || day <= dayOf(restriction.endsOn))
);
}
type RestrictionRow = Prisma.ApiaryRestrictionGetPayload<{
include: { apiary: { select: { name: true } } };
}>;
export function toActiveRestrictionInfo(
row: RestrictionRow,
): ActiveRestrictionInfo {
return {
id: row.id,
apiaryId: row.apiaryId,
apiaryName: row.apiary.name,
disease: row.disease,
startsOn: dayOf(row.startsOn),
};
}
/** The restrictions that started on or before `day` (the caller filters the end). */
export function startedRestrictions(
db: DbClient,
apiaryIds: readonly string[] | undefined,
day: string,
): Promise<RestrictionRow[]> {
return db.apiaryRestriction.findMany({
where: {
apiaryId: apiaryIds ? { in: [...apiaryIds] } : undefined,
startsOn: { lte: dayToDate(day) },
},
include: { apiary: { select: { name: true } } },
orderBy: [{ startsOn: 'asc' }, { id: 'asc' }],
});
}
function refusalMessage(infos: readonly ActiveRestrictionInfo[]): string {
const places = infos.map(
(info) =>
`„${info.apiaryName}“ (${info.disease}, seit ${formatDayDe(info.startsOn, true)})`,
);
return infos.length === 1
? `Der Bienenstand ${places[0]} liegt im Sperrbezirk. Völker dürfen dort nicht angelegt, umgesetzt oder vereinigt werden.`
: `Die Bienenstände ${places.join(', ')} liegen im Sperrbezirk. Völker dürfen dort nicht angelegt, umgesetzt oder vereinigt werden.`;
}
/**
* Refuses a colony being brought into, out of or between apiaries while one of
* them lies in an active restricted zone (409 RESTRICTED_ZONE). Runs inside the
* caller's transaction AFTER the apiary rows are locked (FOR SHARE), so a
* restriction cannot start between the check and the write.
*
* A reason with the right to use it (`canOverride`) lets the action through;
* a reason without the right is a 403. A reason without any active
* restriction is ignored. The caller writes the reason into audit and
* history, never onto the colony.
*/
export async function assertNoActiveRestriction(
tx: Prisma.TransactionClient,
apiaryIds: readonly string[],
day: string,
override?: RestrictionOverride,
): Promise<RestrictionCheck> {
assertTransactionClient(tx, 'assertNoActiveRestriction');
const ids = [...new Set(apiaryIds)];
if (ids.length === 0) {
return NO_RESTRICTION;
}
const active = (await startedRestrictions(tx, ids, day)).filter((row) =>
isRestrictionActive(row, day),
);
if (active.length === 0) {
return NO_RESTRICTION;
}
const restrictionIds = active.map((row) => row.id);
if (override?.reason) {
if (!override.canOverride) {
throw new ForbiddenException(
'Für eine Ausnahme vom Sperrbezirk fehlt das Recht Prüfbereitschaft verwalten (compliance:manage).',
);
}
return { overridden: true, restrictionIds };
}
const restrictions = active.map(toActiveRestrictionInfo);
throw new ConflictException({
statusCode: 409,
code: RESTRICTED_ZONE_CODE,
message: refusalMessage(restrictions),
restrictionIds,
restrictions,
});
}
@@ -0,0 +1,25 @@
import { isValidMonthDay, tskReferenceDayOf } from './tsk-day';
describe('isValidMonthDay', () => {
it.each(['02-30', '02-29', '1-1', '13-01', '00-10', 'ab-cd', '01-32', ''])(
'rejects %s',
(value) => {
expect(isValidMonthDay(value)).toBe(false);
},
);
it.each(['01-01', '12-31', '02-28', '04-30'])('accepts %s', (value) => {
expect(isValidMonthDay(value)).toBe(true);
});
});
describe('tskReferenceDayOf', () => {
it('joins year and month-day into a day', () => {
expect(tskReferenceDayOf(2027, '01-01')).toBe('2027-01-01');
expect(tskReferenceDayOf(2028, '12-31')).toBe('2028-12-31');
});
it('rejects an invalid month-day', () => {
expect(() => tskReferenceDayOf(2027, '02-29')).toThrow();
});
});
+25
View File
@@ -0,0 +1,25 @@
const MONTH_DAY_PATTERN = /^(\d{2})-(\d{2})$/;
// Days per month of a non-leap year: 02-29 is not a valid reference day, so
// the reference day exists in every year.
const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
/** MM-TT with a real month and a day that exists in a non-leap year. */
export function isValidMonthDay(value: string): boolean {
const match = MONTH_DAY_PATTERN.exec(value);
if (!match) {
return false;
}
const month = Number(match[1]);
const day = Number(match[2]);
return (
month >= 1 && month <= 12 && day >= 1 && day <= DAYS_IN_MONTH[month - 1]
);
}
/** The reference day (YYYY-MM-DD) of a year for a stored MM-TT. */
export function tskReferenceDayOf(year: number, monthDay: string): string {
if (!isValidMonthDay(monthDay)) {
throw new Error(`Invalid month-day: ${monthDay}`);
}
return `${String(year).padStart(4, '0')}-${monthDay}`;
}
@@ -1,14 +1,27 @@
import { Controller, Get, Param, Query, Res } from '@nestjs/common';
import {
Controller,
ForbiddenException,
Get,
Param,
Query,
Res,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { ExportService } from './export.service';
@ApiTags('export')
@ApiBearerAuth()
@Controller('export')
export class ExportController {
constructor(private readonly exportService: ExportService) {}
constructor(
private readonly exportService: ExportService,
private readonly permissions: PermissionResolverService,
) {}
@RequirePermission('export:read')
@Get('colonies.csv')
@@ -54,8 +67,17 @@ export class ExportController {
@Query('colonyId') colonyId: string | undefined,
@Query('apiaryId') apiaryId: string | undefined,
@Query('year') year: string | undefined,
@CurrentUser() actor: AuthenticatedUser,
@Res() res: Response,
): Promise<void> {
// The Bestandsbuch prints supplier, purchase proofs and the vet: those
// are visible with veterinary-medicines:read only.
if (report === 'treatment-log') {
const held = await this.permissions.getPermissions(actor.id);
if (!held.has('veterinary-medicines:read')) {
throw new ForbiddenException('Insufficient permissions');
}
}
const pdf = await this.exportService.exportReportPdf(report, {
honeyBatchId,
colonyId,
@@ -1,6 +1,11 @@
import {
apiaryOnDate,
groupTreatmentLog,
LOT_FOOTNOTE,
MISSING,
resolveSourceValues,
treatmentLogCells,
treatmentLogGaps,
TreatmentLogSource,
} from './treatment-log';
@@ -24,10 +29,51 @@ function source(overrides: Partial<TreatmentLogSource>): TreatmentLogSource {
prescribingVet: null,
withdrawalPeriodDays: 0,
administeredBy: 'Imker',
medicineId: null,
prescriptionOnly: false,
doseQuantity: null,
medicineUnit: null,
lot: null,
...overrides,
};
}
type Lot = NonNullable<TreatmentLogSource['lot']>;
const lotOf = (overrides: Partial<Lot> = {}): Lot => ({
id: 'lot-1',
batchNumber: 'LOT-B',
supplier: 'Apotheke Y, Hauptstr. 1',
purchaseProof: 'Rechnung 77',
prescribingVet: 'Dr. Vet',
...overrides,
});
// A treatment from a pack: supplier, proof, batch and vet only on the lot.
const fromLot = (
overrides: Partial<TreatmentLogSource> = {},
): TreatmentLogSource =>
source({
medicineId: 'med-1',
batchNumber: null,
supplier: null,
purchaseProof: null,
prescribingVet: null,
lot: lotOf(),
...overrides,
});
const withDose = (
dose: number,
unit: 'ML' | 'G' | 'PIECE',
): TreatmentLogSource =>
source({
amount: null,
medicineId: 'med-1',
doseQuantity: dose,
medicineUnit: unit,
});
describe('apiaryOnDate', () => {
it('uses the apiary stored with the treatment', () => {
expect(apiaryOnDate(source({ apiaryId: 'stored' }), [])).toEqual({
@@ -124,3 +170,178 @@ describe('groupTreatmentLog', () => {
});
});
});
describe('resolveSourceValues', () => {
it('takes supplier, proof, batch and vet from the lot', () => {
expect(
resolveSourceValues(fromLot({ batchNumber: 'ignored-by-design' })),
).toMatchObject({
batchNumber: 'LOT-B',
supplier: 'Apotheke Y, Hauptstr. 1',
purchaseProof: 'Rechnung 77',
prescribingVet: 'Dr. Vet',
});
});
it('never mixes treatment text into a row with a lot', () => {
const values = resolveSourceValues(
fromLot({
supplier: 'alt',
lot: lotOf({ supplier: null, purchaseProof: null }),
}),
);
expect(values.supplier).toBeNull();
expect(values.purchaseProof).toBeNull();
});
it('reads the treatment text when there is no lot', () => {
expect(resolveSourceValues(source({}))).toMatchObject({
batchNumber: 'B1',
supplier: 'Imkereibedarf X',
purchaseProof: 'Rechnung 1',
prescribingVet: null,
amount: '30 ml',
});
});
it('prints the dose with unit when the amount text is empty', () => {
expect(resolveSourceValues(withDose(30, 'ML')).amount).toBe('30 ml');
expect(resolveSourceValues(withDose(2.5, 'G')).amount).toBe('2,5 g');
expect(resolveSourceValues(withDose(1, 'PIECE')).amount).toBe('1 Stück');
});
it('prefers the amount text over the dose', () => {
expect(
resolveSourceValues({ ...withDose(30, 'ML'), amount: '5 ml je Volk' })
.amount,
).toBe('5 ml je Volk');
});
it('leaves the amount empty without text and without dose', () => {
expect(resolveSourceValues(source({ amount: null })).amount).toBeNull();
expect(resolveSourceValues(source({ amount: ' ' })).amount).toBeNull();
});
});
describe('treatmentLogGaps', () => {
const complete = {
supplier: 'S',
purchaseProof: 'P',
amount: '1 ml',
withdrawalPeriodDays: 0,
prescribingVet: null,
prescriptionOnly: false,
};
it('reports nothing for a complete row; a withdrawal of 0 days is complete', () => {
expect(treatmentLogGaps(complete)).toEqual([]);
});
it('reports each missing mandatory field', () => {
expect(
treatmentLogGaps({
...complete,
supplier: null,
purchaseProof: '',
amount: null,
withdrawalPeriodDays: null,
}),
).toEqual(['SUPPLIER', 'PROOF', 'AMOUNT', 'WITHDRAWAL']);
});
it('requires the vet only for prescription-only medicines', () => {
expect(treatmentLogGaps({ ...complete, prescriptionOnly: true })).toEqual([
'VET',
]);
expect(
treatmentLogGaps({
...complete,
prescriptionOnly: true,
prescribingVet: 'Dr. Vet',
}),
).toEqual([]);
expect(treatmentLogGaps(complete)).toEqual([]);
});
});
describe('Bestandsbuch from the lot', () => {
it('groups two colonies of the same lot and day into one row', () => {
const [sheet] = groupTreatmentLog(
[
fromLot({ id: '1', colonyCode: 'V-02', apiaryId: 'a' }),
fromLot({ id: '2', colonyId: 'c2', colonyCode: 'V-01', apiaryId: 'a' }),
],
[],
);
expect(sheet.rows).toHaveLength(1);
expect(sheet.rows[0].colonyCodes).toEqual(['V-01', 'V-02']);
expect(sheet.rows[0]).toMatchObject({
lotId: 'lot-1',
supplier: 'Apotheke Y, Hauptstr. 1',
batchNumber: 'LOT-B',
});
});
it('keeps colonies of two different lots in two rows', () => {
const [sheet] = groupTreatmentLog(
[
fromLot({ id: '1', apiaryId: 'a' }),
fromLot({
id: '2',
colonyId: 'c2',
apiaryId: 'a',
lot: lotOf({ id: 'lot-2' }),
}),
],
[],
);
expect(sheet.rows).toHaveLength(2);
});
it('prints FEHLT exactly where treatmentLogGaps reports a gap', () => {
const variants: Partial<TreatmentLogSource>[] = [
{},
{ lot: lotOf({ supplier: null }) },
{ lot: lotOf({ purchaseProof: '' }) },
{ amount: null },
{ withdrawalPeriodDays: null },
{ prescriptionOnly: true, lot: lotOf({ prescribingVet: null }) },
{ prescriptionOnly: true },
{
lot: lotOf({ supplier: null, purchaseProof: null }),
amount: null,
withdrawalPeriodDays: null,
prescriptionOnly: true,
},
];
for (const overrides of variants) {
const [sheet] = groupTreatmentLog(
[fromLot({ apiaryId: 'a', ...overrides })],
[],
);
const row = sheet.rows[0];
const gaps = treatmentLogGaps(row);
const cells = treatmentLogCells(row);
const [, , supplierCell, proofCell, , amountCell, withdrawalCell] = cells;
expect(supplierCell.startsWith(MISSING)).toBe(gaps.includes('SUPPLIER'));
expect(supplierCell.includes(`Tierarzt: ${MISSING}`)).toBe(
gaps.includes('VET'),
);
expect(proofCell === MISSING).toBe(gaps.includes('PROOF'));
expect(amountCell === MISSING).toBe(gaps.includes('AMOUNT'));
expect(withdrawalCell === MISSING).toBe(gaps.includes('WITHDRAWAL'));
// No FEHLT anywhere else.
expect(cells.join('|').split(MISSING).length - 1).toBe(gaps.length);
}
});
it('footnote says supplier, proof and batch come from the recorded lot', () => {
expect(LOT_FOOTNOTE).toBe(
'Lieferant, Beleg und Ch.-B. stammen aus der erfassten Packung.',
);
});
});
+149 -21
View File
@@ -1,4 +1,5 @@
import PDFDocument from 'pdfkit';
import type { MedicineUnit } from '../../generated/prisma';
import { PrismaService } from '../prisma/prisma.service';
import {
PDF_MARGIN,
@@ -24,6 +25,15 @@ import {
export const MISSING = 'FEHLT';
export const LOT_FOOTNOTE =
'Lieferant, Beleg und Ch.-B. stammen aus der erfassten Packung.';
const UNIT_LABELS: Record<MedicineUnit, string> = {
ML: 'ml',
G: 'g',
PIECE: 'Stück',
};
export interface TreatmentLogSource {
id: string;
colonyId: string;
@@ -42,6 +52,19 @@ export interface TreatmentLogSource {
prescribingVet: string | null;
withdrawalPeriodDays: number | null;
administeredBy: string;
medicineId: string | null;
/** Of the catalogue entry; the vet is only mandatory for these. */
prescriptionOnly: boolean;
doseQuantity: number | null;
medicineUnit: MedicineUnit | null;
/** The pack the dose was taken from: its values replace the treatment's. */
lot: {
id: string;
batchNumber: string | null;
supplier: string | null;
purchaseProof: string | null;
prescribingVet: string | null;
} | null;
}
export interface ColonyMove {
@@ -65,6 +88,76 @@ export interface TreatmentLogRow {
administeredBy: string;
/** Apiary derived from today's assignment, not known for that date. */
locationUncertain: boolean;
lotId: string | null;
prescriptionOnly: boolean;
}
export type TreatmentLogGap =
'SUPPLIER' | 'PROOF' | 'AMOUNT' | 'WITHDRAWAL' | 'VET';
function hasText(value: string | null | undefined): value is string {
return value !== null && value !== undefined && value.trim() !== '';
}
function orNull(value: string | null | undefined): string | null {
return hasText(value) ? value : null;
}
function doseText(dose: number, unit: MedicineUnit): string {
return `${String(dose).replace('.', ',')} ${UNIT_LABELS[unit]}`;
}
/**
* The columns of one treatment, each from exactly one source: the pack when
* the dose was taken from one (supplier, proof, batch number and vet are never
* mixed with text on the treatment), else the treatment itself. The amount is
* the treatment's own text, else its dose with the unit of the medicine.
*/
export function resolveSourceValues(
source: TreatmentLogSource,
): Pick<
TreatmentLogSource,
'batchNumber' | 'supplier' | 'purchaseProof' | 'prescribingVet'
> & { amount: string | null } {
const origin = source.lot ?? source;
const amount = hasText(source.amount)
? source.amount
: source.doseQuantity !== null && source.medicineUnit !== null
? doseText(source.doseQuantity, source.medicineUnit)
: null;
return {
batchNumber: orNull(origin.batchNumber),
supplier: orNull(origin.supplier),
purchaseProof: orNull(origin.purchaseProof),
prescribingVet: orNull(origin.prescribingVet),
amount,
};
}
/**
* The mandatory fields a row lacks: supplier, proof, amount and withdrawal
* period (0 days counts as given), and the vet only for prescription-only
* medicines. The PDF prints FEHLT exactly where this reports a gap, and the
* readiness check reads the same list.
*/
export function treatmentLogGaps(
row: Pick<
TreatmentLogRow,
| 'supplier'
| 'purchaseProof'
| 'amount'
| 'withdrawalPeriodDays'
| 'prescribingVet'
| 'prescriptionOnly'
>,
): TreatmentLogGap[] {
const gaps: TreatmentLogGap[] = [];
if (!hasText(row.supplier)) gaps.push('SUPPLIER');
if (!hasText(row.purchaseProof)) gaps.push('PROOF');
if (!hasText(row.amount)) gaps.push('AMOUNT');
if (row.withdrawalPeriodDays === null) gaps.push('WITHDRAWAL');
if (row.prescriptionOnly && !hasText(row.prescribingVet)) gaps.push('VET');
return gaps;
}
export interface TreatmentLogSheet {
@@ -120,17 +213,20 @@ export function groupTreatmentLog(
for (const source of sorted) {
const { apiaryId, uncertain } = apiaryOnDate(source, moves);
const start = source.startDate ?? source.date;
const values = resolveSourceValues(source);
const key = [
source.date.toISOString(),
source.endDate?.toISOString() ?? '',
source.product,
source.batchNumber ?? '',
source.amount ?? '',
source.supplier ?? '',
source.purchaseProof ?? '',
source.prescribingVet ?? '',
values.batchNumber ?? '',
values.amount ?? '',
values.supplier ?? '',
values.purchaseProof ?? '',
values.prescribingVet ?? '',
source.withdrawalPeriodDays ?? '',
source.administeredBy,
source.lot?.id ?? '',
source.prescriptionOnly,
uncertain,
].join('|');
@@ -142,17 +238,19 @@ export function groupTreatmentLog(
rows.set(key, {
colonyCodes: [source.colonyCode],
product: source.product,
batchNumber: source.batchNumber,
supplier: source.supplier,
prescribingVet: source.prescribingVet,
purchaseProof: source.purchaseProof,
batchNumber: values.batchNumber,
supplier: values.supplier,
prescribingVet: values.prescribingVet,
purchaseProof: values.purchaseProof,
date: source.date,
endDate: source.endDate,
durationDays: durationDays(start, source.endDate),
amount: source.amount,
amount: values.amount,
withdrawalPeriodDays: source.withdrawalPeriodDays,
administeredBy: source.administeredBy,
locationUncertain: uncertain,
lotId: source.lot?.id ?? null,
prescriptionOnly: source.prescriptionOnly,
});
}
sheets.set(apiaryId, rows);
@@ -194,6 +292,16 @@ export async function buildTreatmentLog(
include: {
colony: { select: { code: true, apiaryId: true } },
user: { select: { name: true } },
medicine: { select: { prescriptionOnly: true, unit: true } },
medicineLot: {
select: {
id: true,
batchNumber: true,
supplier: true,
purchaseProof: true,
prescribingVet: true,
},
},
},
});
const colonyIds = [...new Set(treatments.map((t) => t.colonyId))];
@@ -226,6 +334,11 @@ export async function buildTreatmentLog(
prescribingVet: t.prescribingVet,
withdrawalPeriodDays: t.withdrawalPeriodDays,
administeredBy: t.user.name,
medicineId: t.medicineId,
prescriptionOnly: t.medicine?.prescriptionOnly ?? false,
doseQuantity: t.doseQuantity,
medicineUnit: t.medicine?.unit ?? null,
lot: t.medicineLot,
})),
moves,
).filter((sheet) => !apiaryId || sheet.apiaryId === apiaryId);
@@ -255,6 +368,30 @@ function orMissing(value: string | number | null | undefined): string {
: String(value);
}
/**
* The printed cells of one row. FEHLT stands exactly where treatmentLogGaps
* reports a gap - the supplier column also carries the vet, with FEHLT after
* "Tierarzt:" when one is required and missing.
*/
export function treatmentLogCells(row: TreatmentLogRow): string[] {
const gaps = treatmentLogGaps(row);
const vetPart = row.prescribingVet
? `; Tierarzt: ${row.prescribingVet}`
: gaps.includes('VET')
? `; Tierarzt: ${MISSING}`
: '';
return [
`${row.colonyCodes.length} Volk/Völker: ${row.colonyCodes.join(', ')}${row.locationUncertain ? ' *' : ''}`,
`${row.product}${row.batchNumber ? ` (Ch.-B. ${row.batchNumber})` : ''}`,
`${gaps.includes('SUPPLIER') ? MISSING : row.supplier}${vetPart}`,
gaps.includes('PROOF') ? MISSING : (row.purchaseProof ?? MISSING),
`${formatDay(row.date)}${row.endDate ? ` – ${formatDay(row.endDate)}` : ''}, ${row.durationDays} Tag(e)`,
gaps.includes('AMOUNT') ? MISSING : (row.amount ?? MISSING),
gaps.includes('WITHDRAWAL') ? MISSING : orMissing(row.withdrawalPeriodDays),
orDash(row.administeredBy),
];
}
function renderSheet(
doc: PDFKit.PDFDocument,
log: TreatmentLog,
@@ -305,16 +442,7 @@ function renderSheet(
{ header: 'Wartezeit (Tage)', width: 50 },
{ header: 'Anwendende Person', width: 0 },
]),
sheet.rows.map((row) => [
`${row.colonyCodes.length} Volk/Völker: ${row.colonyCodes.join(', ')}${row.locationUncertain ? ' *' : ''}`,
`${row.product}${row.batchNumber ? ` (Ch.-B. ${row.batchNumber})` : ''}`,
`${orMissing(row.supplier)}${row.prescribingVet ? `; Tierarzt: ${row.prescribingVet}` : ''}`,
orMissing(row.purchaseProof),
`${formatDay(row.date)}${row.endDate ? ` – ${formatDay(row.endDate)}` : ''}, ${row.durationDays} Tag(e)`,
orMissing(row.amount),
orMissing(row.withdrawalPeriodDays),
orDash(row.administeredBy),
]),
sheet.rows.map(treatmentLogCells),
);
doc.font('Helvetica').fontSize(7);
if (uncertain) {
@@ -324,7 +452,7 @@ function renderSheet(
);
}
doc.text(
`"${MISSING}" = Pflichtangabe fehlt – bitte in der Behandlung nachtragen. Bestandsbuch und Erwerbsbelege 5 Jahre aufbewahren (Art. 108 Abs. 5 VO (EU) 2019/6).`,
`"${MISSING}" = Pflichtangabe fehlt – bitte in der Behandlung oder in der Packung nachtragen. ${LOT_FOOTNOTE} Bestandsbuch und Erwerbsbelege 5 Jahre aufbewahren (Art. 108 Abs. 5 VO (EU) 2019/6).`,
PDF_MARGIN,
);
}
@@ -12,7 +12,12 @@ describe('ImportService', () => {
queen: { create: jest.Mock; findUnique: jest.Mock };
$transaction: jest.Mock;
};
let tx: { colony: { create: jest.Mock } };
let tx: {
colony: { create: jest.Mock };
$queryRaw: jest.Mock;
apiary: { findUnique: jest.Mock };
apiaryRestriction: { findMany: jest.Mock };
};
let auditLog: { record: jest.Mock };
let events: { record: jest.Mock };
@@ -25,7 +30,16 @@ describe('ImportService', () => {
};
// The colony row is written through the transaction client; it shares the
// mock with prisma.colony so the row tests read the same calls.
tx = { colony: { create: prisma.colony.create } };
tx = {
colony: { create: prisma.colony.create },
$queryRaw: jest.fn().mockResolvedValue([]),
apiary: {
findUnique: jest
.fn()
.mockResolvedValue({ id: 'apiary-1', status: 'ACTIVE' }),
},
apiaryRestriction: { findMany: jest.fn().mockResolvedValue([]) },
};
prisma.$transaction.mockImplementation(
(callback: (txArg: typeof tx) => unknown) => callback(tx),
);
@@ -243,6 +257,84 @@ describe('ImportService', () => {
);
});
it('locks the apiary (share) and checks activity and the restricted zone before it writes', async () => {
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
prisma.colony.findUnique.mockResolvedValue(null);
prisma.colony.create.mockResolvedValue({
id: 'c1',
apiaryId: 'apiary-1',
});
await service.importCsv(
'colonies',
Buffer.from(
'code,apiaryName,colonyType\nV-1,Teststand,PRODUCTION_COLONY',
),
'user-1',
);
const [[query]] = tx.$queryRaw.mock.calls as [[{ sql: string }]];
expect(query.sql).toContain('"Apiary"');
expect(query.sql).toContain('FOR SHARE');
expect(tx.apiaryRestriction.findMany).toHaveBeenCalled();
expect(
tx.apiaryRestriction.findMany.mock.invocationCallOrder[0],
).toBeLessThan(prisma.colony.create.mock.invocationCallOrder[0]);
});
it('reports a row in a restricted apiary as an error and writes nothing for it', async () => {
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
prisma.colony.findUnique.mockResolvedValue(null);
tx.apiaryRestriction.findMany.mockResolvedValue([
{
id: 'r1',
apiaryId: 'apiary-1',
apiary: { name: 'Teststand' },
disease: 'Faulbrut',
startsOn: new Date('2000-01-01T00:00:00.000Z'),
endsOn: null,
},
]);
const report = await service.importCsv(
'colonies',
Buffer.from(
'code,apiaryName,colonyType\nV-1,Teststand,PRODUCTION_COLONY',
),
'user-1',
);
expect(report.errors).toBe(1);
expect(report.rows[0].status).toBe('error');
expect(report.rows[0].message).toContain('Sperrbezirk');
expect(prisma.colony.create).not.toHaveBeenCalled();
expect(events.record).not.toHaveBeenCalled();
expect(auditLog.record).not.toHaveBeenCalledWith(
expect.objectContaining({ entityType: 'Colony' }),
expect.anything(),
);
});
it('reports a row in an apiary deactivated meanwhile as an error', async () => {
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
prisma.colony.findUnique.mockResolvedValue(null);
tx.apiary.findUnique.mockResolvedValue({
id: 'apiary-1',
status: 'INACTIVE',
});
const report = await service.importCsv(
'colonies',
Buffer.from(
'code,apiaryName,colonyType\nV-1,Teststand,PRODUCTION_COLONY',
),
'user-1',
);
expect(report.rows[0].status).toBe('error');
expect(prisma.colony.create).not.toHaveBeenCalled();
});
it('records no event for a skipped or invalid row', async () => {
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
prisma.colony.findUnique.mockResolvedValue({ id: 'existing' });
+33 -4
View File
@@ -1,4 +1,8 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
Injectable,
} from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { validate, ValidationError } from 'class-validator';
import { PrismaService } from '../prisma/prisma.service';
@@ -12,6 +16,9 @@ import {
appendLegacyFeedNote,
feedConditionFromInput,
} from '../common/feed-vocabulary';
import { berlinToday } from '../common/dates';
import { assertNoActiveRestriction } from '../compliance/restricted-zone';
import { lockRowsForShare } from '../prisma/row-lock';
import { parseCsv } from './csv';
const IMPORT_ENTITIES = ['apiaries', 'colonies', 'queens'] as const;
@@ -216,8 +223,26 @@ export class ImportService {
id: existing.id,
};
}
// Like ColoniesService.create: record, audit and event in one transaction.
const colony = await this.prisma.$transaction(async (tx) => {
// Like ColoniesService.create: apiary rows locked (share), activity and
// restricted zone checked under the lock - an import has no override -,
// then record, audit and event in one transaction.
const outcome = await this.prisma.$transaction(async (tx) => {
await lockRowsForShare(tx, 'Apiary', [apiary.id]);
const locked = await tx.apiary.findUnique({ where: { id: apiary.id } });
if (locked?.status !== 'ACTIVE') {
return {
error: `Bienenstand "${apiaryName}" ist nicht aktiv und kann keine Völker aufnehmen`,
};
}
try {
await assertNoActiveRestriction(tx, [apiary.id], berlinToday());
} catch (error) {
if (error instanceof ConflictException) {
const body = error.getResponse() as { message?: string };
return { error: body.message ?? error.message };
}
throw error;
}
const created = await tx.colony.create({ data: { ...dto } });
await this.auditLog.record(
{
@@ -230,8 +255,12 @@ export class ImportService {
tx,
);
await this.events.record(tx, colonyCreatedEvent(created, actingUserId));
return created;
return { colony: created };
});
if ('error' in outcome) {
return { row: rowNumber, status: 'error', message: outcome.error };
}
const { colony } = outcome;
return {
row: rowNumber,
status: 'created',
@@ -0,0 +1,154 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayUnique,
Equals,
IsArray,
IsBoolean,
IsIn,
IsNumber,
IsOptional,
IsPositive,
IsString,
Matches,
Max,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
import { PLACEHOLDER_KEYS } from '../label-element.types';
const FONT_FAMILIES = ['sans', 'serif', 'mono'] as const;
const TEXT_ALIGNS = ['left', 'center', 'right'] as const;
const MAX_ELEMENTS = 100;
const MAX_FONT_SIZE_PT = 500;
// A label is a few centimetres; the bounds only keep absurd numbers out.
const MAX_COORDINATE_MM = 1000;
const MIN_BOX_MM = 0.1;
const MAX_SHEET_MM = 1000;
/**
* A text element of a label draft. The check only looks at text, so every
* other element type is refused here instead of being ignored: a draft that
* sends an image is a client bug worth a 400.
*/
export class LabelTextElementDto {
@ApiProperty()
@IsString()
@MaxLength(100)
id!: string;
@ApiProperty({ enum: ['text'] })
@Equals('text')
type!: 'text';
@ApiProperty()
@IsNumber()
@Min(-MAX_COORDINATE_MM)
@Max(MAX_COORDINATE_MM)
x!: number;
@ApiProperty()
@IsNumber()
@Min(-MAX_COORDINATE_MM)
@Max(MAX_COORDINATE_MM)
y!: number;
@ApiProperty()
@IsNumber()
@Min(MIN_BOX_MM)
@Max(MAX_SHEET_MM)
width!: number;
@ApiProperty()
@IsNumber()
@Min(MIN_BOX_MM)
@Max(MAX_SHEET_MM)
height!: number;
@ApiPropertyOptional({ description: 'Literaler Text' })
@IsOptional()
@IsString()
@MaxLength(500)
text?: string;
@ApiPropertyOptional({ enum: PLACEHOLDER_KEYS })
@IsOptional()
@IsIn(PLACEHOLDER_KEYS)
placeholderKey?: (typeof PLACEHOLDER_KEYS)[number] | null;
@ApiProperty({ description: 'Schriftgröße in pt' })
@IsNumber()
@IsPositive()
@Max(MAX_FONT_SIZE_PT)
fontSize!: number;
@ApiPropertyOptional({ enum: FONT_FAMILIES })
@IsOptional()
@IsIn(FONT_FAMILIES)
fontFamily?: (typeof FONT_FAMILIES)[number];
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
bold?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
italic?: boolean;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(20)
color?: string;
@ApiPropertyOptional({ enum: TEXT_ALIGNS })
@IsOptional()
@IsIn(TEXT_ALIGNS)
align?: (typeof TEXT_ALIGNS)[number];
}
/** POST /label-templates/check: a draft that is not saved yet. */
export class CheckLabelTemplateDto {
@ApiProperty({ type: [LabelTextElementDto] })
@IsArray()
@ArrayMaxSize(MAX_ELEMENTS)
@ArrayUnique((element: LabelTextElementDto) => element.id)
@ValidateNested({ each: true })
@Type(() => LabelTextElementDto)
elements!: LabelTextElementDto[];
@ApiProperty({
description: 'Wird geprüft, fließt aber nicht in die Prüfung ein.',
})
@IsNumber()
@Min(10)
@Max(MAX_SHEET_MM)
widthMm!: number;
@ApiProperty()
@IsNumber()
@Min(10)
@Max(MAX_SHEET_MM)
heightMm!: number;
@ApiProperty({
description: 'Kleinste x-Höhe (mm) des Entwurfs, 0,5 bis 5.',
})
@IsNumber()
@Min(0.5)
@Max(5)
minXHeightMm!: number;
@ApiPropertyOptional({
description: 'Abfüllung, gegen die geprüft wird. Leer = Beispielwerte.',
})
@IsOptional()
@IsString()
@MaxLength(100)
@Matches(/^[A-Za-z0-9_-]+$/)
bottlingBatchId?: string;
}
@@ -0,0 +1,126 @@
import { ConflictException, Logger } from '@nestjs/common';
import {
expectedParticulars,
loadDigitRules,
parseDigitRules,
} from './label-check.loader';
describe('expectedParticulars', () => {
it('maps a bottling batch to the expected particulars (jar grams, best-before day, lot, origin, master data)', () => {
const expected = expectedParticulars(
{
jarSize: 500,
lotNumber: '2026-0042',
// Midnight UTC: stays on its own calendar day.
bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'),
},
{
originCountry: 'Deutschland',
foodBusinessName: 'Imkerei Muster',
foodBusinessAddress: 'Bühlweg 10, 86450 Altenmünster',
},
);
expect(expected).toEqual({
jarSizeGrams: 500,
bestBefore: '2028-06-01',
lotNumber: '2026-0042',
originCountry: 'Deutschland',
foodBusinessName: 'Imkerei Muster',
foodBusinessAddress: 'Bühlweg 10, 86450 Altenmünster',
});
});
it('takes the best-before day from labelDayOf, an instant maps to its Berlin day', () => {
const expected = expectedParticulars(
{
jarSize: 250,
lotNumber: 'L1',
bestBeforeDate: new Date('2028-06-01T22:30:00.000Z'),
},
{
originCountry: 'Deutschland',
foodBusinessName: null,
foodBusinessAddress: null,
},
);
expect(expected.bestBefore).toBe('2028-06-02');
expect(expected.foodBusinessName).toBeNull();
});
});
describe('parseDigitRules', () => {
const open = { maxGrams: null, minMm: 5 };
it('reads the stored rules', () => {
const rules = [{ maxGrams: 100, minMm: 2 }, open];
expect(parseDigitRules(rules)).toEqual(rules);
});
it.each([
['null', null],
['undefined', undefined],
['a string', 'x'],
['an object', {}],
['an empty array', []],
['a non-numeric limit', [{ maxGrams: 'a', minMm: 2 }, open]],
['an empty rule', [{}]],
['minMm zero', [{ maxGrams: 100, minMm: 0 }, open]],
['minMm negative', [{ maxGrams: 100, minMm: -1 }, open]],
['minMm not finite', [{ maxGrams: 100, minMm: Infinity }, open]],
['maxGrams not finite', [{ maxGrams: Infinity, minMm: 2 }, open]],
['no rule without a limit', [{ maxGrams: 100, minMm: 2 }]],
['two rules without a limit', [open, { maxGrams: null, minMm: 6 }]],
])('rejects %s', (_name, value) => {
expect(parseDigitRules(value)).toBeNull();
});
});
describe('loadDigitRules', () => {
let errorLog: jest.SpyInstance;
const dbWith = (row: unknown) => ({
complianceSettings: { findUnique: jest.fn().mockResolvedValue(row) },
});
beforeEach(() => {
errorLog = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
});
afterEach(() => jest.restoreAllMocks());
async function refusal(row: unknown): Promise<ConflictException> {
try {
await loadDigitRules(dbWith(row) as never);
} catch (error) {
return error as ConflictException;
}
throw new Error('expected a refusal');
}
it('fails closed with 409 when the settings row is missing', async () => {
const error = await refusal(null);
expect(error).toBeInstanceOf(ConflictException);
expect(error.getResponse()).toEqual({
code: 'COMPLIANCE_SETTINGS_INVALID',
message: 'Compliance-Einstellungen fehlen oder sind ungültig.',
});
expect(errorLog).toHaveBeenCalledTimes(1);
});
it('fails closed with 409 when the stored rules are invalid', async () => {
const error = await refusal({ netQuantityDigitRules: [{ minMm: 0 }] });
expect(error.getStatus()).toBe(409);
});
it('uses the rules of the settings row', async () => {
const rules = [{ maxGrams: null, minMm: 7 }];
const db = dbWith({ netQuantityDigitRules: rules });
await expect(loadDigitRules(db as never)).resolves.toEqual(rules);
expect(db.complianceSettings.findUnique).toHaveBeenCalledWith({
where: { id: 'singleton' },
select: { netQuantityDigitRules: true },
});
});
});
@@ -0,0 +1,101 @@
import { ConflictException, Logger } from '@nestjs/common';
import { loggableError } from '../common/loggable-error';
import { DbClient } from '../prisma/db-client';
import { LabelCheckInput, NetQuantityDigitRule } from './label-particulars';
import { labelDayOf } from './label-text';
const COMPLIANCE_SETTINGS_ID = 'singleton';
export const COMPLIANCE_SETTINGS_INVALID = 'COMPLIANCE_SETTINGS_INVALID';
const logger = new Logger('LabelCheckLoader');
export interface BottlingForCheck {
jarSize: number;
lotNumber: string;
bestBeforeDate: Date;
}
export interface MasterDataForCheck {
originCountry: string;
foodBusinessName: string | null;
foodBusinessAddress: string | null;
}
/**
* What the label must show for this bottling. The best-before day comes from
* `labelDayOf`, the function the printed text uses too, so check and print
* cannot disagree about the day.
*/
export function expectedParticulars(
bottling: BottlingForCheck,
settings: MasterDataForCheck,
): LabelCheckInput['expected'] {
return {
jarSizeGrams: bottling.jarSize,
bestBefore: labelDayOf(bottling.bestBeforeDate),
lotNumber: bottling.lotNumber,
originCountry: settings.originCountry,
foodBusinessName: settings.foodBusinessName,
foodBusinessAddress: settings.foodBusinessAddress,
};
}
function isDigitRule(value: unknown): value is NetQuantityDigitRule {
if (typeof value !== 'object' || value === null) {
return false;
}
const { maxGrams, minMm } = value as Record<string, unknown>;
return (
(maxGrams === null ||
(typeof maxGrams === 'number' && Number.isFinite(maxGrams))) &&
typeof minMm === 'number' &&
Number.isFinite(minMm)
);
}
/**
* The stored JSON as rules, or null when it cannot be trusted: a rule needs
* a finite positive height, the list must not be empty, and exactly one rule
* (the catch-all for any size) has no upper limit.
*/
export function parseDigitRules(value: unknown): NetQuantityDigitRule[] | null {
if (
!Array.isArray(value) ||
value.length === 0 ||
!value.every(isDigitRule)
) {
return null;
}
const rules = value.map(({ maxGrams, minMm }) => ({ maxGrams, minMm }));
const open = rules.filter((rule) => rule.maxGrams === null).length;
return open === 1 && rules.every((rule) => rule.minMm > 0) ? rules : null;
}
/**
* The digit rules of the compliance settings. No legal value lives in the
* code: a missing row or unusable rules refuse the check (409) instead of
* checking against a guess.
*/
export async function loadDigitRules(
db: DbClient,
): Promise<NetQuantityDigitRule[]> {
const row = await db.complianceSettings.findUnique({
where: { id: COMPLIANCE_SETTINGS_ID },
select: { netQuantityDigitRules: true },
});
const rules = parseDigitRules(row?.netQuantityDigitRules);
if (!rules) {
const { message } = loggableError(
new Error(
row
? 'Invalid netQuantityDigitRules'
: 'ComplianceSettings row missing',
),
);
logger.error(message);
throw new ConflictException({
code: COMPLIANCE_SETTINGS_INVALID,
message: 'Compliance-Einstellungen fehlen oder sind ungültig.',
});
}
return rules;
}
@@ -68,7 +68,7 @@ const FORBIDDEN = new RegExp(
'toLocaleTimeString\\(',
'toLocaleDateString\\((?![^)]*timeZone)',
'toLocaleString\\((?![^)]*timeZone)',
'new Intl\\.DateTimeFormat\\((?![^)]*timeZone)',
'Intl\\.DateTimeFormat\\((?![^)]*timeZone)',
// new Date(y, m, d, ...): several arguments build a local-time date
'new Date\\((?:[^(),]|\\([^()]*\\))+,',
].join('|'),
@@ -86,6 +86,7 @@ describe('local-time guard', () => {
'date.toDateString()',
"date.toLocaleDateString('de-DE')",
"new Intl.DateTimeFormat('de-DE')",
"Intl.DateTimeFormat('de-DE')",
'new Date(2027, 2, 1)',
])('flags %s', (sample) => {
expect(sample).toMatch(FORBIDDEN);
@@ -735,23 +735,57 @@ describe('checkLabel: round 3', () => {
});
it('measures the distance against the whole group, not the last element', () => {
// a tall street box, an empty box below it, the town further down: the
// empty box alone would be 24 mm from the town, the street box is not
// A is a tall box, B a short one below its top, C lies 24 mm below B but
// inside A's box: only the running extent of the group joins C
const elements = [
...base(),
textElement('street', null, 62, {
text: 'Bühlweg 10',
height: 40,
}),
textElement('empty', null, 64, { text: '', height: 2 }),
textElement('town', null, 90, {
text: '86450 Altenmünster',
height: 5,
}),
textElement('a', null, 62, { text: 'Bühlweg', height: 40 }),
textElement('b', null, 64, { x: 30, text: '10', height: 2 }),
textElement('c', null, 90, { text: '86450 Altenmünster', height: 5 }),
];
expect(checkLabel(input({ elements })).gaps).toEqual([]);
});
it('an empty box does not bridge distances', () => {
const elements = [
...base(),
textElement('street', null, 62, { text: 'Bühlweg 10', height: 5 }),
textElement('empty', null, 66, { text: '', height: 40 }),
textElement('town', null, 100, { text: '86450 Altenmünster', height: 5 }),
];
expect(codes(checkLabel(input({ elements })).gaps)).toEqual([
'FOOD_BUSINESS',
]);
});
it('a tall text box bridges rows by its box height (documented)', () => {
const street = (height: number) => [
...base(),
textElement('street', null, 62, { text: 'Bühlweg 10', height }),
textElement('town', null, 100, { text: '86450 Altenmünster', height: 5 }),
];
expect(checkLabel(input({ elements: street(40) })).gaps).toEqual([]);
expect(codes(checkLabel(input({ elements: street(5) })).gaps)).toEqual([
'FOOD_BUSINESS',
]);
});
it('names only clipped parts that belong to the match', () => {
const measurer: TextMeasurer = {
heightOfStringPt: (_text, element) =>
element.id === 'town' || element.id === 'lot-ref' ? 999 : 0,
};
const elements = [
...base(),
textElement('street', null, 62, { text: 'Bühlweg 10', height: 5 }),
textElement('town', null, 68, { text: '86450 Altenmünster', height: 5 }),
textElement('lot-ref', null, 90, { text: 'Lot 10', height: 5 }),
];
const result = checkLabel(input({ elements, measurer }));
expect(codes(result.gaps)).toEqual(['FOOD_BUSINESS']);
expect(result.gaps[0].elementIds).toEqual(['town']);
});
describe('number and unit in two elements', () => {
function split(numberSize: number, unitSize: number): LabelTextElement[] {
return [
@@ -792,6 +826,38 @@ describe('checkLabel: round 3', () => {
expect(codes(result.warnings)).not.toContain('NET_QUANTITY_HEIGHT');
});
it('finds a big number with a small, bottom-aligned unit', () => {
const elements = [
...without('net'),
textElement('num', null, 14, {
x: 2,
width: 20,
height: 10,
fontSize: 20,
text: '500',
}),
textElement('unit', null, 20, {
x: 24,
width: 10,
height: 4,
fontSize: 10,
text: 'g',
}),
];
expect(checkLabel(input({ elements })).gaps).toEqual([]);
});
it('does not pair a far left number with a far right unit', () => {
const elements = [
...without('net'),
textElement('num', null, 14, { x: 2, width: 20, text: '500' }),
textElement('unit', null, 14, { x: 70, width: 10, text: 'g' }),
];
expect(codes(checkLabel(input({ elements })).gaps)).toEqual([
'NET_QUANTITY',
]);
});
it('does not join across rows or onto a bigger number', () => {
const otherRow = [
...without('net'),
@@ -203,7 +203,8 @@ function distanceTo(span: Span, candidate: Candidate): number {
function groupByDistance(list: Candidate[]): Candidate[][] {
const groups: Candidate[][] = [];
let span: Span = { top: 0, bottom: 0 };
for (const candidate of list) {
// An empty box (a placeholder without a value) must not bridge distances.
for (const candidate of list.filter((entry) => entry.normalized !== '')) {
const group = groups[groups.length - 1];
if (group && distanceTo(span, candidate) <= MAX_JOIN_GAP_MM) {
group.push(candidate);
@@ -295,9 +296,20 @@ function quantityPairs(views: TextViews, needles: string[]): Candidate[][] {
const pairs: Candidate[][] = [];
views.visible.forEach((first, index) => {
const second = views.visible[index + 1];
// Side by side: the boxes overlap vertically (a big number with a small,
// bottom-aligned unit), the unit lies to the right and not far away.
if (
!second ||
Math.abs(second.element.y - first.element.y) > ROW_TOLERANCE_MM
distanceTo(
{
top: first.element.y,
bottom: first.element.y + first.element.height,
},
second,
) > 0 ||
second.element.x < first.element.x ||
second.element.x - (first.element.x + first.element.width) >
MAX_JOIN_GAP_MM
) {
return;
}
@@ -528,6 +540,7 @@ export function checkLabel(input: LabelCheckInput): LabelCheckResult {
const candidates = candidatesOf(input, measurer);
const visible = candidates.filter((candidate) => !candidate.clipped);
const views = textViews(visible);
const allViews = textViews(candidates);
const combined = views.full;
const gaps: LabelFinding[] = [];
@@ -558,13 +571,20 @@ export function checkLabel(input: LabelCheckInput): LabelCheckResult {
...new Set([...singleHolders, ...splitParts, ...pairs.flat()]),
];
holders.forEach((candidate) => carriers.add(candidate));
// Clipped parts of a split phrase: those that would be part of the match
// had they been visible.
const clippedSplit = new Set(
splitPhrases.flatMap((phrase) =>
groupsHolding(allViews, phrase)
.flat()
.filter((candidate) => candidate.clipped),
),
);
const clippedHolders = candidates.filter(
(candidate) =>
candidate.clipped &&
(particular.isCarriedBy(candidate.normalized) ||
splitPhrases.some((phrase) =>
sharesWord(candidate.normalized, phrase),
)),
clippedSplit.has(candidate)),
);
clippedHolders.forEach((candidate) => clippedCarriers.add(candidate));
if (!isOnLabel) {
@@ -0,0 +1,72 @@
import { drawProofWatermark } from './label-proof-watermark';
type Call = [name: string, ...args: unknown[]];
/** A PDFKit stand-in that records the drawing calls in order. */
function fakeDoc() {
const calls: Call[] = [];
const doc: Record<string, unknown> = {};
const record =
(name: string) =>
(...args: unknown[]) => {
calls.push([name, ...args]);
return doc;
};
for (const name of [
'save',
'restore',
'rotate',
'fillColor',
'fillOpacity',
'font',
'fontSize',
'text',
]) {
doc[name] = record(name);
}
doc.widthOfString = () => 100;
return { doc: doc as unknown as PDFKit.PDFDocument, calls };
}
const callsOf = (calls: Call[], name: string) =>
calls.filter(([callName]) => callName === name);
describe('drawProofWatermark', () => {
it('draws the text MUSTER – nicht verkaufsfähig rotated, grey, half opaque, once per label', () => {
const { doc, calls } = fakeDoc();
drawProofWatermark(doc, 10, 20, 255, 99);
const texts = callsOf(calls, 'text');
expect(texts).toHaveLength(1);
expect(texts[0][1]).toBe('MUSTER – nicht verkaufsfähig');
expect(callsOf(calls, 'fillOpacity')).toEqual([['fillOpacity', 0.5]]);
const [, color] = callsOf(calls, 'fillColor')[0];
expect(color).toBe('#808080');
const [, angle] = callsOf(calls, 'rotate')[0];
expect(angle).toBeLessThan(0);
expect(angle).toBeGreaterThan(-90);
});
it('saves and restores the graphics state around the watermark', () => {
const { doc, calls } = fakeDoc();
drawProofWatermark(doc, 0, 0, 255, 99);
expect(calls[0][0]).toBe('save');
expect(calls[calls.length - 1][0]).toBe('restore');
});
it('keeps the text on the label: centred on the label box', () => {
const { doc, calls } = fakeDoc();
drawProofWatermark(doc, 10, 20, 200, 100);
const [, , options] = callsOf(calls, 'rotate')[0] as [
string,
number,
{ origin: [number, number] },
];
expect(options.origin).toEqual([110, 70]);
});
});
@@ -0,0 +1,49 @@
export const PROOF_WATERMARK_TEXT = 'MUSTER – nicht verkaufsfähig';
const WATERMARK_COLOR = '#808080';
const WATERMARK_OPACITY = 0.5;
const WATERMARK_FONT = 'Helvetica-Bold';
// The text runs along the label's diagonal; this share of it is covered.
const DIAGONAL_COVERAGE = 0.8;
// Never taller than this share of the label's height.
const MAX_HEIGHT_SHARE = 0.5;
const MEASURE_SIZE_PT = 10;
/**
* Draws the proof stamp "MUSTER – nicht verkaufsfähig" over one label: grey,
* half opaque, rotated along the label's diagonal and centred. Called once
* per label, after its content. The graphics state is restored, so nothing
* leaks into the next label.
*/
export function drawProofWatermark(
doc: PDFKit.PDFDocument,
originX: number,
originY: number,
widthPt: number,
heightPt: number,
): void {
const centerX = originX + widthPt / 2;
const centerY = originY + heightPt / 2;
const angleDeg = (Math.atan2(heightPt, widthPt) * 180) / Math.PI;
doc.save();
doc.font(WATERMARK_FONT).fontSize(MEASURE_SIZE_PT);
const widthAtMeasureSize = doc.widthOfString(PROOF_WATERMARK_TEXT);
const targetWidth = Math.hypot(widthPt, heightPt) * DIAGONAL_COVERAGE;
const fontSize = Math.min(
(MEASURE_SIZE_PT * targetWidth) / widthAtMeasureSize,
heightPt * MAX_HEIGHT_SHARE,
);
const textWidth = (widthAtMeasureSize * fontSize) / MEASURE_SIZE_PT;
doc.fillColor(WATERMARK_COLOR).fillOpacity(WATERMARK_OPACITY);
doc.rotate(-angleDeg, { origin: [centerX, centerY] });
doc.fontSize(fontSize);
doc.text(
PROOF_WATERMARK_TEXT,
centerX - textWidth / 2,
centerY - fontSize / 2,
{ lineBreak: false },
);
doc.restore();
}
@@ -1,9 +1,13 @@
import {
Body,
Controller,
ForbiddenException,
BadRequestException,
Delete,
Get,
HttpCode,
Param,
ParseBoolPipe,
ParseIntPipe,
Patch,
Post,
@@ -12,18 +16,34 @@ import {
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateLabelTemplateDto } from './dto/create-label-template.dto';
import { UpdateLabelTemplateDto } from './dto/update-label-template.dto';
import { CheckLabelTemplateDto } from './dto/check-label-template.dto';
import { LabelTemplatesService } from './label-templates.service';
// Far above any sheet grid; the service clamps to the real slot count.
const MAX_START_POSITION = 1000;
@ApiTags('label-templates')
@ApiBearerAuth()
@Controller('label-templates')
export class LabelTemplatesController {
constructor(private readonly labelTemplatesService: LabelTemplatesService) {}
constructor(
private readonly labelTemplatesService: LabelTemplatesService,
private readonly permissionResolver: PermissionResolverService,
) {}
// A check or print reads the bottling (lot, dates, jar size), so it needs
// the right to see bottlings as well as the label permission.
private async assertCanReadBottling(userId: string): Promise<void> {
if (!(await this.permissionResolver.hasAny(userId, ['bottling:read']))) {
throw new ForbiddenException('Missing permission: bottling:read');
}
}
@RequirePermission('label-templates:read')
@Get()
@@ -31,6 +51,30 @@ export class LabelTemplatesController {
return this.labelTemplatesService.findAll();
}
@RequirePermission('label-templates:read')
@Post('check')
@HttpCode(200)
async checkDraft(
@Body() dto: CheckLabelTemplateDto,
@CurrentUser() actor: AuthenticatedUser,
) {
if (dto.bottlingBatchId) {
await this.assertCanReadBottling(actor.id);
}
return this.labelTemplatesService.checkDraft(dto);
}
@RequirePermission('label-templates:read')
@Get(':id/check/:bottlingBatchId')
async checkForBottling(
@Param('id') id: string,
@Param('bottlingBatchId') bottlingBatchId: string,
@CurrentUser() actor: AuthenticatedUser,
) {
await this.assertCanReadBottling(actor.id);
return this.labelTemplatesService.checkForBottling(id, bottlingBatchId);
}
@RequirePermission('label-templates:read')
@Get(':id')
findOne(@Param('id') id: string) {
@@ -69,12 +113,25 @@ export class LabelTemplatesController {
@Param('bottlingBatchId') bottlingBatchId: string,
@Query('startPosition', new ParseIntPipe({ optional: true }))
startPosition: number | undefined,
@Query('proof', new ParseBoolPipe({ optional: true }))
proof: boolean | undefined,
@CurrentUser() actor: AuthenticatedUser,
@Res() res: Response,
): Promise<void> {
if (
startPosition !== undefined &&
(startPosition < 1 || startPosition > MAX_START_POSITION)
) {
throw new BadRequestException(
`startPosition must be between 1 and ${MAX_START_POSITION}`,
);
}
await this.assertCanReadBottling(actor.id);
const pdf = await this.labelTemplatesService.renderLabelsPdf(
id,
bottlingBatchId,
startPosition,
{ startPosition, proof },
actor.id,
);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader(
@@ -1,4 +1,4 @@
import { NotFoundException } from '@nestjs/common';
import { ConflictException, NotFoundException } from '@nestjs/common';
import {
LabelTemplatesService,
buildPlaceholderValues,
@@ -7,12 +7,16 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { SiteSettingsService } from '../site-settings/site-settings.service';
import { lockRowForUpdate } from '../prisma/row-lock';
import { LabelElement } from './label-element.types';
import { LabelElement, LabelTextElement } from './label-element.types';
import * as watermark from './label-proof-watermark';
import { CheckLabelTemplateDto } from './dto/check-label-template.dto';
jest.mock('../prisma/row-lock', () => ({
lockRowForUpdate: jest.fn().mockResolvedValue(true),
}));
const PROOF = { proof: true };
const SETTINGS = {
originCountry: 'Deutschland',
foodBusinessName: 'Imkerei Muster' as string | null,
@@ -38,6 +42,7 @@ describe('LabelTemplatesService', () => {
update: jest.Mock;
};
bottlingBatch: { findUnique: jest.Mock };
complianceSettings: { findUnique: jest.Mock };
$transaction: jest.Mock;
};
let auditLog: { record: jest.Mock };
@@ -90,6 +95,7 @@ describe('LabelTemplatesService', () => {
gapYMm: 2,
isDefault: false,
active: true,
minXHeightMm: 1.2,
};
beforeEach(() => {
@@ -117,6 +123,14 @@ describe('LabelTemplatesService', () => {
update: jest.fn(),
},
bottlingBatch: { findUnique: jest.fn() },
complianceSettings: {
findUnique: jest.fn().mockResolvedValue({
netQuantityDigitRules: [
{ maxGrams: 1000, minMm: 4 },
{ maxGrams: null, minMm: 6 },
],
}),
},
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
callback(tx),
),
@@ -248,7 +262,12 @@ describe('LabelTemplatesService', () => {
product: { name: 'Glas 500g' },
});
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
const pdf = await service.renderLabelsPdf(
'template-1',
'batch-1',
PROOF,
'user-1',
);
expect(pdf).toBeInstanceOf(Buffer);
expect(pdf.length).toBeGreaterThan(0);
@@ -261,7 +280,7 @@ describe('LabelTemplatesService', () => {
prisma.bottlingBatch.findUnique.mockResolvedValue(null);
await expect(
service.renderLabelsPdf('template-1', 'missing'),
service.renderLabelsPdf('template-1', 'missing', PROOF, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
});
@@ -288,7 +307,12 @@ describe('LabelTemplatesService', () => {
product: { name: 'Glas 500g' },
});
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
const pdf = await service.renderLabelsPdf(
'template-1',
'batch-1',
PROOF,
'user-1',
);
expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
// A PDF with an embedded raster image contains an XObject /Image entry.
@@ -323,7 +347,12 @@ describe('LabelTemplatesService', () => {
product: { name: 'Glas 500g' },
});
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
const pdf = await service.renderLabelsPdf(
'template-1',
'batch-1',
PROOF,
'user-1',
);
// pdfkit always lists /ImageB /ImageC /ImageI in a page's ProcSet
// regardless of whether anything is actually embedded, so a plain
@@ -358,7 +387,12 @@ describe('LabelTemplatesService', () => {
product: { name: 'Glas 500g' },
});
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
const pdf = await service.renderLabelsPdf(
'template-1',
'batch-1',
PROOF,
'user-1',
);
expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
expect(pdf.toString('latin1')).not.toMatch(/\/Subtype\s*\/Image\b/);
@@ -386,7 +420,12 @@ describe('LabelTemplatesService', () => {
product: { name: 'Glas 500g' },
});
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
const pdf = await service.renderLabelsPdf(
'template-1',
'batch-1',
PROOF,
'user-1',
);
expect(countPages(pdf)).toBe(3);
});
@@ -411,7 +450,12 @@ describe('LabelTemplatesService', () => {
product: { name: 'Glas 500g' },
});
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
const pdf = await service.renderLabelsPdf(
'template-1',
'batch-1',
PROOF,
'user-1',
);
// Clamped to the real max of 2 columns x 1 row = 2 per page, so 3
// copies still need 2 pages - same as the explicit columns:2 test.
@@ -434,7 +478,12 @@ describe('LabelTemplatesService', () => {
product: { name: 'Glas 500g' },
});
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
const pdf = await service.renderLabelsPdf(
'template-1',
'batch-1',
PROOF,
'user-1',
);
// Only fits on a single page if the rotated 15-per-page layout was
// actually used - upright would need a second page for the 15th copy.
@@ -458,7 +507,12 @@ describe('LabelTemplatesService', () => {
product: { name: 'Glas 500g' },
});
const pdf = await service.renderLabelsPdf('template-1', 'batch-1');
const pdf = await service.renderLabelsPdf(
'template-1',
'batch-1',
PROOF,
'user-1',
);
// An explicit grid describes a real pre-printed sheet - it must be
// honoured as-is (14 per page), needing 2 pages for 15 copies, even
@@ -483,7 +537,12 @@ describe('LabelTemplatesService', () => {
product: { name: 'Glas 500g' },
});
const pdf = await service.renderLabelsPdf('template-1', 'batch-1', 2);
const pdf = await service.renderLabelsPdf(
'template-1',
'batch-1',
{ startPosition: 2, proof: true },
'user-1',
);
// perPage=2, skip=1: label 1 fills the remaining slot on page 1,
// labels 2-3 fill both slots of page 2.
@@ -491,6 +550,296 @@ describe('LabelTemplatesService', () => {
});
});
describe('print guard, proof and audit', () => {
function textElement(
id: string,
y: number,
placeholderKey: LabelTextElement['placeholderKey'],
fontSize = 9,
): LabelTextElement {
return {
id,
type: 'text',
x: 2,
y,
width: 80,
height: 8,
text: '',
placeholderKey,
fontSize,
fontFamily: 'sans',
bold: false,
italic: false,
color: '#000000',
align: 'left',
};
}
// Every mandatory particular, type big enough: no gap and no warning.
const completeElements: LabelTextElement[] = [
textElement('legal', 1, 'legalName'),
textElement('net', 10, 'jarSize', 16),
textElement('mhd', 20, 'bestBeforeLine'),
textElement('lot', 29, 'lotLine'),
textElement('origin', 38, 'originCountry'),
textElement('firm', 47, 'foodBusinessName'),
textElement('address', 56, 'foodBusinessAddress'),
];
const completeTemplate = {
...template,
id: 'template-1',
heightMm: 70,
elements: completeElements,
};
const bottling = {
id: 'batch-1',
jarSize: 500,
jarCount: 3,
lotNumber: 'L-001',
date: new Date('2026-06-01T00:00:00.000Z'),
bestBeforeDate: new Date('2028-06-01T00:00:00.000Z'),
honeyBatch: { batchNumber: 'HB-001', honeyType: 'Bluetenhonig' },
product: { name: 'Glas 500g', legalName: 'Blütenhonig' },
};
const NO_FIRM = { ...SETTINGS, foodBusinessName: null };
beforeEach(() => {
prisma.labelTemplate.findUnique.mockResolvedValue(completeTemplate);
prisma.bottlingBatch.findUnique.mockResolvedValue(bottling);
});
afterEach(() => jest.restoreAllMocks());
async function rejectionOf(promise: Promise<unknown>): Promise<unknown> {
try {
await promise;
} catch (error) {
return error;
}
throw new Error('expected a rejection');
}
it('prints a complete label and audits it', async () => {
const pdf = await service.renderLabelsPdf(
'template-1',
'batch-1',
{},
'user-1',
);
expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
expect(auditLog.record).toHaveBeenCalledTimes(1);
});
it('print refuses with 409 and the gap list', async () => {
siteSettings.get.mockResolvedValue(NO_FIRM);
const error = (await rejectionOf(
service.renderLabelsPdf('template-1', 'batch-1', {}, 'user-1'),
)) as ConflictException;
expect(error).toBeInstanceOf(ConflictException);
expect(error.getStatus()).toBe(409);
const body = error.getResponse() as {
code: string;
message: string;
gaps: { code: string }[];
};
expect(body.code).toBe('LABEL_PARTICULARS_MISSING');
expect(body.message).toEqual(expect.any(String));
expect(body.gaps.map((gap) => gap.code)).toEqual(['FOOD_BUSINESS']);
expect(auditLog.record).not.toHaveBeenCalled();
});
it('proof print renders despite a gap, adds the watermark and writes a PRINT audit entry', async () => {
siteSettings.get.mockResolvedValue(NO_FIRM);
const mark = jest.spyOn(watermark, 'drawProofWatermark');
const pdf = await service.renderLabelsPdf(
'template-1',
'batch-1',
{ proof: true },
'user-1',
);
expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
// One watermark per label, one label per jar.
expect(mark).toHaveBeenCalledTimes(bottling.jarCount);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'PRINT', userId: 'user-1' }),
);
});
it('a real print draws no watermark', async () => {
const mark = jest.spyOn(watermark, 'drawProofWatermark');
await service.renderLabelsPdf('template-1', 'batch-1', {}, 'user-1');
expect(mark).not.toHaveBeenCalled();
});
it('the audit entry carries templateId, copies, proof, gapCodes and warningCodes', async () => {
siteSettings.get.mockResolvedValue(NO_FIRM);
await service.renderLabelsPdf(
'template-1',
'batch-1',
{ proof: true },
'user-1',
);
expect(auditLog.record).toHaveBeenCalledWith({
entityType: 'BottlingBatch',
entityId: 'batch-1',
action: 'PRINT',
userId: 'user-1',
afterData: {
templateId: 'template-1',
copies: 3,
proof: true,
gapCodes: ['FOOD_BUSINESS'],
warningCodes: [],
},
});
});
it('a print with warnings only is rendered and audited with the warning codes', async () => {
prisma.bottlingBatch.findUnique.mockResolvedValue({
...bottling,
lotNumber: 'AB-001',
});
prisma.labelTemplate.findUnique.mockResolvedValue({
...completeTemplate,
elements: completeElements.map((element) =>
element.id === 'lot'
? { ...element, placeholderKey: 'lotNumber' }
: element,
),
});
const pdf = await service.renderLabelsPdf(
'template-1',
'batch-1',
{},
'user-1',
);
expect(pdf.subarray(0, 4).toString()).toBe('%PDF');
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
afterData: expect.objectContaining({
proof: false,
gapCodes: [],
warningCodes: ['LOT_PREFIX'],
}) as unknown,
}),
);
});
it('the audit is written before the PDF is returned and a failing audit fails the print', async () => {
const order: string[] = [];
auditLog.record.mockImplementation(() => {
order.push('audit');
return Promise.resolve();
});
await service.renderLabelsPdf('template-1', 'batch-1', {}, 'user-1');
order.push('returned');
expect(order).toEqual(['audit', 'returned']);
auditLog.record.mockRejectedValue(new Error('audit down'));
await expect(
service.renderLabelsPdf('template-1', 'batch-1', {}, 'user-1'),
).rejects.toThrow('audit down');
});
it('checkDraft without a bottling uses the sample values and sets sample true', async () => {
const dto = {
widthMm: 90,
heightMm: 70,
minXHeightMm: 1.2,
elements: [textElement('legal', 1, 'legalName')],
} as unknown as CheckLabelTemplateDto;
const result = await service.checkDraft(dto);
expect(result.sample).toBe(true);
expect(prisma.bottlingBatch.findUnique).not.toHaveBeenCalled();
const codes = result.gaps.map((gap) => gap.code);
// The sample's legal name fills the placeholder; the rest is missing.
expect(codes).not.toContain('LEGAL_NAME');
expect(codes).toContain('NET_QUANTITY');
});
it('checkDraft with a bottling uses that bottling and sample false', async () => {
const dto = {
widthMm: 90,
heightMm: 70,
minXHeightMm: 1.2,
bottlingBatchId: 'batch-1',
elements: completeElements,
} as unknown as CheckLabelTemplateDto;
const result = await service.checkDraft(dto);
expect(result.sample).toBe(false);
expect(result.printable).toBe(true);
});
it('checkDraft answers 404 for an unknown bottling', async () => {
prisma.bottlingBatch.findUnique.mockResolvedValue(null);
const dto = {
widthMm: 90,
heightMm: 70,
minXHeightMm: 1.2,
bottlingBatchId: 'missing',
elements: [],
} as unknown as CheckLabelTemplateDto;
await expect(service.checkDraft(dto)).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('checkForBottling uses the placeholder values of that bottling and the master data', async () => {
siteSettings.get.mockResolvedValue(NO_FIRM);
prisma.complianceSettings.findUnique.mockResolvedValue({
netQuantityDigitRules: [{ maxGrams: null, minMm: 1 }],
});
const result = await service.checkForBottling('template-1', 'batch-1');
expect(result.sample).toBe(false);
expect(result.printable).toBe(false);
expect(result.gaps.map((gap) => gap.code)).toEqual(['FOOD_BUSINESS']);
expect(result.requirements.netQuantityMinCapHeightMm).toBe(1);
});
it('refuses the print and the check when the compliance settings are missing', async () => {
prisma.complianceSettings.findUnique.mockResolvedValue(null);
await expect(
service.renderLabelsPdf('template-1', 'batch-1', {}, 'user-1'),
).rejects.toBeInstanceOf(ConflictException);
await expect(
service.checkForBottling('template-1', 'batch-1'),
).rejects.toBeInstanceOf(ConflictException);
expect(auditLog.record).not.toHaveBeenCalled();
});
it('checkForBottling ignores non-text elements', async () => {
prisma.labelTemplate.findUnique.mockResolvedValue({
...completeTemplate,
elements: [
...completeElements,
{ id: 'qr', type: 'qrCode', x: 0, y: 0, size: 10 },
],
});
const result = await service.checkForBottling('template-1', 'batch-1');
expect(result.printable).toBe(true);
});
});
describe('buildPlaceholderValues', () => {
const bottlingBatch = {
jarSize: 500,
@@ -1,4 +1,8 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import PDFDocument from 'pdfkit';
import * as QRCode from 'qrcode';
import { PrismaService } from '../prisma/prisma.service';
@@ -10,16 +14,49 @@ import { LabelTemplate } from '../../generated/prisma';
import { computeCropMarks } from './crop-marks';
import { CreateLabelTemplateDto } from './dto/create-label-template.dto';
import { UpdateLabelTemplateDto } from './dto/update-label-template.dto';
import { berlinDayOf } from '../common/dates';
import { CheckLabelTemplateDto } from './dto/check-label-template.dto';
import { formatDayDe } from '../common/format-day';
import { MM_TO_PT, pdfFontName } from './font-metrics';
import { lotStartsWithL } from './label-text';
import { LabelElement, PlaceholderKey } from './label-element.types';
import {
MM_TO_PT,
pdfFontName,
pdfTextMeasurer,
TextMeasurer,
} from './font-metrics';
import { labelDayOf, lotStartsWithL } from './label-text';
import {
LabelElement,
LabelTextElement,
PlaceholderKey,
} from './label-element.types';
import { expectedParticulars, loadDigitRules } from './label-check.loader';
import {
checkLabel,
LabelCheckResult,
NetQuantityDigitRule,
SAMPLE_BOTTLING,
} from './label-particulars';
import { drawProofWatermark } from './label-proof-watermark';
const A4_WIDTH_MM = 210;
const A4_HEIGHT_MM = 297;
const QR_SOURCE_SIZE_PX = 400;
export interface PrintOptions {
/** First free slot on the first sheet (1 = top left). */
startPosition?: number;
/** A sample print: allowed with gaps, stamped "MUSTER". */
proof?: boolean;
}
type PlaceholderBottling = Parameters<typeof buildPlaceholderValues>[0];
interface CheckContext {
settings: Parameters<typeof buildPlaceholderValues>[1];
digitRules: NetQuantityDigitRule[];
}
const LABEL_PARTICULARS_MISSING = 'LABEL_PARTICULARS_MISSING';
interface SheetLayout {
widthMm: number;
heightMm: number;
@@ -36,15 +73,7 @@ interface SheetLayout {
// not to show if the blade lands a fraction beside it.
const CROP_MARK_LINE_WIDTH_PT = 0.3;
/**
* The calendar day a stored date prints and is checked as (YYYY-MM-DD). Dates
* are midnight-UTC columns or instants; both map to their Europe/Berlin day,
* independent of the process time zone. The label text and the label check
* (`expected.bestBefore`) must both come from this function.
*/
export function labelDayOf(date: Date): string {
return berlinDayOf(date);
}
export { labelDayOf };
function formatDate(date: Date): string {
return formatDayDe(labelDayOf(date), true);
@@ -140,6 +169,33 @@ export function buildPlaceholderValues(
};
}
function textElementsOf(template: LabelTemplate): LabelTextElement[] {
return elementsOf(template).filter(
(element): element is LabelTextElement => element.type === 'text',
);
}
function textElementFromDraft(
element: CheckLabelTemplateDto['elements'][number],
): LabelTextElement {
return {
id: element.id,
type: 'text',
x: element.x,
y: element.y,
width: element.width,
height: element.height,
text: element.text ?? '',
placeholderKey: element.placeholderKey ?? null,
fontSize: element.fontSize,
fontFamily: element.fontFamily ?? 'sans',
bold: element.bold ?? false,
italic: element.italic ?? false,
color: element.color ?? '#000000',
align: element.align ?? 'left',
};
}
@Injectable()
export class LabelTemplatesService {
constructor(
@@ -244,24 +300,40 @@ export class LabelTemplatesService {
return template;
}
/**
* Prints the labels of one bottling. A mandatory particular that is missing
* blocks the print (409) unless it is a proof print. The audit entry is
* written before the PDF is handed out: no unaudited print.
*/
async renderLabelsPdf(
templateId: string,
bottlingBatchId: string,
startPosition = 1,
options: PrintOptions,
actingUserId: string,
): Promise<Buffer> {
const proof = options.proof === true;
const template = await this.findById(templateId);
const bottlingBatch = await this.prisma.bottlingBatch.findUnique({
where: { id: bottlingBatchId },
include: { honeyBatch: true, product: true },
});
if (!bottlingBatch) {
throw new NotFoundException('Bottling batch not found');
const bottlingBatch = await this.findBottling(bottlingBatchId);
const context = await this.loadCheckContext();
const result = this.check(
context,
textElementsOf(template),
template.minXHeightMm,
bottlingBatch,
false,
pdfTextMeasurer(),
);
if (result.gaps.length > 0 && !proof) {
throw new ConflictException({
code: LABEL_PARTICULARS_MISSING,
message:
'Pflichtangaben fehlen auf dem Etikett. Ein Probedruck ist möglich.',
gaps: result.gaps,
});
}
const values = buildPlaceholderValues(
bottlingBatch,
await this.siteSettings.get(),
);
const values = buildPlaceholderValues(bottlingBatch, context.settings);
const elements = elementsOf(template).map((element) =>
element.type === 'text' && element.placeholderKey
@@ -283,7 +355,7 @@ export class LabelTemplatesService {
});
}
return this.renderPdf(
const pdf = await this.renderPdf(
elements,
{
widthMm: template.widthMm,
@@ -298,8 +370,94 @@ export class LabelTemplatesService {
},
bottlingBatch.jarCount,
qrCodeBuffer,
startPosition,
options.startPosition ?? 1,
proof,
);
await this.auditLog.record({
entityType: 'BottlingBatch',
entityId: bottlingBatchId,
action: 'PRINT',
userId: actingUserId,
afterData: {
templateId,
copies: bottlingBatch.jarCount,
proof,
gapCodes: result.gaps.map((gap) => gap.code),
warningCodes: result.warnings.map((warning) => warning.code),
},
});
return pdf;
}
/** Checks an unsaved draft; without a bottling the sample values fill it. */
async checkDraft(dto: CheckLabelTemplateDto): Promise<LabelCheckResult> {
const bottling = dto.bottlingBatchId
? await this.findBottling(dto.bottlingBatchId)
: SAMPLE_BOTTLING;
return this.check(
await this.loadCheckContext(),
dto.elements.map(textElementFromDraft),
dto.minXHeightMm,
bottling,
!dto.bottlingBatchId,
);
}
/** The same check the print runs, for one saved template and bottling. */
async checkForBottling(
templateId: string,
bottlingBatchId: string,
): Promise<LabelCheckResult> {
const template = await this.findById(templateId);
const bottling = await this.findBottling(bottlingBatchId);
return this.check(
await this.loadCheckContext(),
textElementsOf(template),
template.minXHeightMm,
bottling,
false,
);
}
private async findBottling(id: string) {
const bottlingBatch = await this.prisma.bottlingBatch.findUnique({
where: { id },
include: { honeyBatch: true, product: true },
});
if (!bottlingBatch) {
throw new NotFoundException('Bottling batch not found');
}
return bottlingBatch;
}
private async loadCheckContext(): Promise<CheckContext> {
const [settings, digitRules] = await Promise.all([
this.siteSettings.get(),
loadDigitRules(this.prisma),
]);
return { settings, digitRules };
}
// The one place that builds a label check: the print and both check
// endpoints go through it.
private check(
context: CheckContext,
elements: LabelTextElement[],
minXHeightMm: number,
bottling: PlaceholderBottling,
sample: boolean,
measurer?: TextMeasurer,
): LabelCheckResult {
return checkLabel({
elements,
minXHeightMm,
digitRules: context.digitRules,
values: buildPlaceholderValues(bottling, context.settings),
expected: expectedParticulars(bottling, context.settings),
sample,
measurer,
});
}
private renderPdf(
@@ -308,6 +466,7 @@ export class LabelTemplatesService {
copies: number,
qrCodeBuffer: Buffer | null,
startPosition: number,
proof: boolean,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
const doc = new PDFDocument({ size: 'A4', margin: 0 });
@@ -436,10 +595,18 @@ export class LabelTemplatesService {
centerX - w / 2,
centerY - h / 2,
qrCodeBuffer,
proof ? { widthPt: w, heightPt: h } : null,
);
doc.restore();
} else {
this.drawLabel(doc, elements, originX, originY, qrCodeBuffer);
this.drawLabel(
doc,
elements,
originX,
originY,
qrCodeBuffer,
proof ? { widthPt: slotW, heightPt: slotH } : null,
);
}
}
@@ -453,10 +620,20 @@ export class LabelTemplatesService {
originX: number,
originY: number,
qrCodeBuffer: Buffer | null,
proofStamp: { widthPt: number; heightPt: number } | null,
): void {
for (const element of elements) {
this.drawElement(doc, element, originX, originY, qrCodeBuffer);
}
if (proofStamp) {
drawProofWatermark(
doc,
originX,
originY,
proofStamp.widthPt,
proofStamp.heightPt,
);
}
}
private drawElement(
@@ -1,5 +1,16 @@
import { berlinDayOf } from '../common/dates';
import { LabelTextElement, PlaceholderKey } from './label-element.types';
/**
* The calendar day a stored date prints and is checked as (YYYY-MM-DD). Dates
* are midnight-UTC columns or instants; both map to their Europe/Berlin day,
* independent of the process time zone. The label text and the label check
* (`expected.bestBefore`) must both come from this function.
*/
export function labelDayOf(date: Date): string {
return berlinDayOf(date);
}
/**
* Text as the label check compares it: NFKC, lower case, every character that
* is not a letter or digit becomes a space, runs of spaces collapse. So
@@ -2,12 +2,19 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsDateString,
IsInt,
IsNumber,
IsOptional,
IsPositive,
IsString,
Matches,
Min,
MaxLength,
ValidateIf,
} from 'class-validator';
const ID_PATTERN = /^[A-Za-z0-9_-]+$/;
const MAX_ID_LENGTH = 64;
export class CreateTreatmentDto {
@ApiProperty()
@IsString()
@@ -27,9 +34,45 @@ export class CreateTreatmentDto {
@IsDateString()
endDate?: string;
@ApiProperty()
@ApiPropertyOptional({
description:
'Handelsname. Fehlt er, aber ein Mittel oder eine Packung ist gewählt, wird es der Name aus dem Katalog.',
})
@ValidateIf((o: CreateTreatmentDto) => !o.medicineId && !o.medicineLotId)
@IsString()
product!: string;
@MaxLength(200)
product?: string;
@ApiPropertyOptional({
nullable: true,
description: 'Katalogeintrag (Tierarzneimittel)',
})
@IsOptional()
@IsString()
@Matches(ID_PATTERN)
@MaxLength(MAX_ID_LENGTH)
medicineId?: string | null;
@ApiPropertyOptional({
nullable: true,
description:
'Packung, aus der die Dosis gebucht wird. Lieferant, Beleg, Ch.-B. und Tierarzt kommen dann aus der Packung.',
})
@IsOptional()
@IsString()
@Matches(ID_PATTERN)
@MaxLength(MAX_ID_LENGTH)
medicineLotId?: string | null;
@ApiPropertyOptional({
nullable: true,
description:
'Dosis je Volk in der Einheit des Mittels (> 0). Mit Packung wird sie gebucht, ohne Packung nur gespeichert.',
})
@IsOptional()
@IsNumber({ allowNaN: false, allowInfinity: false })
@IsPositive()
doseQuantity?: number | null;
@ApiPropertyOptional()
@IsOptional()
@@ -1,11 +1,24 @@
import { PartialType } from '@nestjs/swagger';
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { IsString, Matches, MaxLength } from 'class-validator';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
import { CreateTreatmentDto } from './create-treatment.dto';
/**
* Every field stays correctable, `colonyId` included: a record filed against
* the wrong colony is a data-entry error, and re-filing it is the only way to
* repair the colony's history without deleting the record.
* repair the colony's history without deleting the record. `apiaryId` corrects
* the apiary kept for the Bestandsbuch (where the colony stood on the day);
* `medicineId`, `medicineLotId` and `doseQuantity` can be cleared with null.
*/
export class UpdateTreatmentDto extends PartialType(CreateTreatmentDto, {
skipNullProperties: false,
}) {}
}) {
@ApiPropertyOptional({
description: 'Stand am Behandlungstag (Bestandsbuch) korrigieren',
})
@IsOptionalNotNull()
@IsString()
@Matches(/^[A-Za-z0-9_-]+$/)
@MaxLength(64)
apiaryId?: string;
}
@@ -0,0 +1,228 @@
import {
BadRequestException,
ConflictException,
NotFoundException,
} from '@nestjs/common';
import { Prisma, Treatment } from '../../generated/prisma';
import { roundToThousandths } from '../common/quantity';
import { lockRowsForShare, lockRowsForUpdate } from '../prisma/row-lock';
import {
BookedPerLot,
treatmentBookingDelta,
} from '../veterinary-medicines/medicine-ledger';
/**
* What a treatment takes from the medicine catalogue and the pack book
* (Spec 5.2). Both helpers run inside the caller's transaction, for create,
* createBulk and update alike, so the three paths cannot drift apart.
*/
/** The smallest dose on the thousandths grid; below it nothing could be booked. */
const MIN_DOSE = 0.001;
export const LOT_SOURCE_MESSAGE = 'Diese Angabe kommt aus der Packung';
export interface MedicineInput {
/** The trade name as entered; undefined when the form sent none. */
product?: string | null;
/** Update only: the stored name, kept when no medicine is chosen. */
currentProduct?: string;
/** undefined: not given; null: explicitly empty. */
medicineId?: string | null;
medicineLotId?: string | null;
doseQuantity?: number | null;
supplier?: string | null;
purchaseProof?: string | null;
batchNumber?: string | null;
prescribingVet?: string | null;
}
export interface ResolvedMedicine {
medicineId: string | null;
medicineLotId: string | null;
/** The catalogue name with a medicine, else the entered trade name. */
product: string;
/** On the thousandths grid, or null. */
doseQuantity: number | null;
}
function hasText(value: string | null | undefined): boolean {
return value !== null && value !== undefined && value.trim() !== '';
}
function sameName(a: string, b: string): boolean {
return a.trim().toLowerCase() === b.trim().toLowerCase();
}
function checkedDose(dose: number | null | undefined): number | null {
if (dose === null || dose === undefined) {
return null;
}
const rounded = roundToThousandths(dose);
if (!(rounded >= MIN_DOSE)) {
throw new BadRequestException('Die Dosis ist zu klein');
}
return rounded;
}
function assertNoLotTextWithLot(input: MedicineInput): void {
const given = [
input.supplier,
input.purchaseProof,
input.batchNumber,
input.prescribingVet,
].some(hasText);
if (given) {
throw new BadRequestException(LOT_SOURCE_MESSAGE);
}
}
/**
* Checks the (merged) state of a treatment against catalogue and pack and
* returns what to store. Never fills activeIngredient, withdrawal period or
* method from the catalogue - only the trade name follows it.
*
* `previousMedicineId` (update): the medicine the treatment already had; an
* inactive entry only blocks a medicine that is new to the treatment.
*/
export async function resolveMedicineIn(
tx: Prisma.TransactionClient,
input: MedicineInput,
mode: 'create' | 'update',
previousMedicineId: string | null = null,
): Promise<ResolvedMedicine> {
const doseQuantity = checkedDose(input.doseQuantity);
const lotId = input.medicineLotId ?? null;
if (lotId !== null && input.medicineId === null) {
throw new BadRequestException(
'Bei gewählter Packung muss das Mittel gesetzt bleiben',
);
}
if (lotId !== null) {
assertNoLotTextWithLot(input);
}
const lot =
lotId === null
? null
: await tx.veterinaryMedicineLot.findUnique({
where: { id: lotId },
select: { id: true, medicineId: true },
});
if (lotId !== null && !lot) {
throw new NotFoundException('Packung nicht gefunden');
}
if (lot && input.medicineId && input.medicineId !== lot.medicineId) {
throw new BadRequestException('Die Packung gehört zu einem anderen Mittel');
}
const medicineId = lot?.medicineId ?? input.medicineId ?? null;
const entered = hasText(input.product) ? input.product!.trim() : undefined;
if (medicineId === null) {
const product = entered ?? input.currentProduct;
if (product === undefined) {
throw new BadRequestException('Bitte Mittel oder Handelsname angeben');
}
return { medicineId: null, medicineLotId: null, product, doseQuantity };
}
// Shared: the entry cannot be deactivated before this transaction ends.
await lockRowsForShare(tx, 'VeterinaryMedicine', [medicineId]);
const medicine = await tx.veterinaryMedicine.findUnique({
where: { id: medicineId },
select: { id: true, name: true, active: true },
});
if (!medicine) {
throw new NotFoundException('Mittel nicht gefunden');
}
if (
!medicine.active &&
(mode === 'create' || medicineId !== previousMedicineId)
) {
throw new BadRequestException('Das Mittel ist deaktiviert.');
}
if (entered !== undefined && !sameName(entered, medicine.name)) {
throw new BadRequestException(
'Mittel und Katalogeintrag passen nicht zusammen',
);
}
return {
medicineId,
medicineLotId: lotId,
product: medicine.name,
doseQuantity,
};
}
/** Sums the TREATMENT movements one treatment has so far, per lot. */
export async function bookingsOf(
tx: Prisma.TransactionClient,
treatmentId: string,
): Promise<BookedPerLot[]> {
const sums = await tx.veterinaryMedicineMovement.groupBy({
by: ['lotId'],
where: { treatmentId, type: 'TREATMENT' },
_sum: { quantity: true },
});
return sums.map((sum) => ({
lotId: sum.lotId,
quantity: sum._sum.quantity ?? 0,
}));
}
/**
* Makes the movements of the treatment add up to its state: -dose on its lot,
* nothing on any other. Existing movements are never touched, a changed lot or
* dose is a counter-booking plus a new booking (treatmentBookingDelta). The
* lots are locked in ascending id order, after the treatment row, so two
* edits cannot wait for each other and an inventory count (which reads the
* rest under the same lot lock) sees either all of this booking or none.
*
* `booked`: the movements so far; omitted, they are read (a new treatment has
* none and passes []).
*/
export async function bookTreatmentIn(
tx: Prisma.TransactionClient,
treatment: Treatment,
userId: string,
booked?: readonly BookedPerLot[],
): Promise<void> {
const current = booked ?? (await bookingsOf(tx, treatment.id));
const { medicineLotId, doseQuantity } = treatment;
const desired =
typeof medicineLotId === 'string' && typeof doseQuantity === 'number'
? { lotId: medicineLotId, dose: doseQuantity }
: null;
const deltas = treatmentBookingDelta(current, desired);
if (deltas.length === 0) {
return;
}
const lotIds = deltas.map((delta) => delta.lotId);
await lockRowsForUpdate(tx, 'VeterinaryMedicineLot', lotIds);
if (desired) {
// The lot was read before it was locked: it may have changed medicine.
const lots = await tx.veterinaryMedicineLot.findMany({
where: { id: { in: lotIds } },
select: { id: true, medicineId: true },
});
const wanted = lots.find((lot) => lot.id === desired.lotId);
if (!wanted || wanted.medicineId !== treatment.medicineId) {
throw new ConflictException(
'Die Packung wurde gerade geändert. Bitte neu laden.',
);
}
}
for (const delta of [...deltas].sort((a, b) =>
a.lotId < b.lotId ? -1 : a.lotId > b.lotId ? 1 : 0,
)) {
await tx.veterinaryMedicineMovement.create({
data: {
type: 'TREATMENT',
quantity: delta.quantity,
treatmentId: treatment.id,
lotId: delta.lotId,
userId,
},
});
}
}
@@ -0,0 +1,31 @@
import {
describeNullContracts,
NullContract,
} from '../common/validation/null-contract.spec-helper';
import { UpdateTreatmentDto } from './dto/update-treatment.dto';
const contracts: Record<string, NullContract> = {
UpdateTreatmentDto: {
dto: UpdateTreatmentDto,
notNull: ['colonyId', 'date', 'product', 'apiaryId'],
clearable: [
'startDate',
'endDate',
'activeIngredient',
'amount',
'method',
'reason',
'batchNumber',
'supplier',
'purchaseProof',
'prescribingVet',
'notes',
'withdrawalPeriodDays',
'medicineId',
'medicineLotId',
'doseQuantity',
],
},
};
describeNullContracts('Treatment update DTO', contracts);
@@ -1,4 +1,8 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
NotFoundException,
} from '@nestjs/common';
import { TreatmentsService } from './treatments.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
@@ -26,6 +30,10 @@ describe('TreatmentsService', () => {
update: jest.Mock;
};
colony: { findUnique: jest.Mock; findMany: jest.Mock };
apiary: { findUnique: jest.Mock };
veterinaryMedicine: { findUnique: jest.Mock };
veterinaryMedicineLot: { findUnique: jest.Mock; findMany: jest.Mock };
veterinaryMedicineMovement: { create: jest.Mock; groupBy: jest.Mock };
$queryRaw: jest.Mock;
};
let auditLog: { record: jest.Mock };
@@ -34,6 +42,7 @@ describe('TreatmentsService', () => {
const treatment = {
id: 'treatment-1',
colonyId: 'colony-1',
product: 'Ameisensäure',
date: new Date(),
};
@@ -48,6 +57,16 @@ describe('TreatmentsService', () => {
findUnique: jest.fn().mockResolvedValue({ apiaryId: 'apiary-1' }),
findMany: jest.fn().mockResolvedValue([]),
},
apiary: { findUnique: jest.fn().mockResolvedValue({ id: 'apiary-1' }) },
veterinaryMedicine: { findUnique: jest.fn() },
veterinaryMedicineLot: {
findUnique: jest.fn(),
findMany: jest.fn().mockResolvedValue([]),
},
veterinaryMedicineMovement: {
create: jest.fn(),
groupBy: jest.fn().mockResolvedValue([]),
},
$queryRaw: jest.fn().mockResolvedValue([]),
};
prisma = {
@@ -615,4 +634,487 @@ describe('TreatmentsService', () => {
});
});
});
describe('medicine ledger', () => {
const oxuvar = {
id: 'med-1',
name: 'Oxuvar',
active: true,
unit: 'ML',
};
const lot = { id: 'lot-1', medicineId: 'med-1' };
const base = { colonyId: 'colony-1', date: '2026-08-01' };
type CreateData = Record<string, unknown>;
type Movement = {
data: {
type: string;
quantity: number;
treatmentId: string;
lotId: string;
userId: string;
};
};
const createdData = (): CreateData =>
(tx.treatment.create.mock.calls as [{ data: CreateData }][])[0][0].data;
const movements = () =>
(tx.veterinaryMedicineMovement.create.mock.calls as [Movement][]).map(
([arg]) => arg.data,
);
beforeEach(() => {
tx.veterinaryMedicine.findUnique.mockResolvedValue(oxuvar);
tx.veterinaryMedicineLot.findUnique.mockResolvedValue(lot);
tx.veterinaryMedicineLot.findMany.mockResolvedValue([lot]);
tx.treatment.create.mockImplementation(({ data }: { data: CreateData }) =>
Promise.resolve({ ...data, id: `t-${String(data.colonyId)}` }),
);
});
it('create books the dose from the lot in the same transaction', async () => {
await service.create(
{ ...base, medicineLotId: 'lot-1', doseQuantity: 30 },
'user-1',
);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(movements()).toEqual([
{
type: 'TREATMENT',
quantity: -30,
treatmentId: 't-colony-1',
lotId: 'lot-1',
userId: 'user-1',
},
]);
// After the treatment row, before the audit entry.
const createOrder = tx.treatment.create.mock.invocationCallOrder[0];
const bookOrder =
tx.veterinaryMedicineMovement.create.mock.invocationCallOrder[0];
const auditOrder = auditLog.record.mock.invocationCallOrder[0];
expect(createOrder).toBeLessThan(bookOrder);
expect(bookOrder).toBeLessThan(auditOrder);
});
it('rejects a lot of another medicine', async () => {
tx.veterinaryMedicineLot.findUnique.mockResolvedValue({
id: 'lot-1',
medicineId: 'med-2',
});
await expect(
service.create(
{ ...base, medicineId: 'med-1', medicineLotId: 'lot-1' },
'user-1',
),
).rejects.toBeInstanceOf(BadRequestException);
expect(tx.treatment.create).not.toHaveBeenCalled();
});
it('rejects supplier text together with a lot', async () => {
for (const field of [
'supplier',
'purchaseProof',
'batchNumber',
'prescribingVet',
]) {
await expect(
service.create(
{ ...base, medicineLotId: 'lot-1', [field]: 'Text' },
'user-1',
),
).rejects.toThrow('Diese Angabe kommt aus der Packung');
}
expect(tx.treatment.create).not.toHaveBeenCalled();
});
it('takes the product name from the catalogue', async () => {
await service.create({ ...base, medicineId: 'med-1' }, 'user-1');
expect(createdData()).toMatchObject({
product: 'Oxuvar',
medicineId: 'med-1',
medicineLotId: null,
});
});
it('rejects medicine and product that differ ignoring case', async () => {
await expect(
service.create(
{ ...base, medicineId: 'med-1', product: 'Api Bioxal' },
'user-1',
),
).rejects.toThrow('Mittel und Katalogeintrag passen nicht zusammen');
await service.create(
{ ...base, medicineId: 'med-1', product: ' oxuvar ' },
'user-1',
);
expect(createdData().product).toBe('Oxuvar');
});
it('rejects an inactive catalogue entry on create but allows editing an existing treatment of it', async () => {
tx.veterinaryMedicine.findUnique.mockResolvedValue({
...oxuvar,
active: false,
});
await expect(
service.create({ ...base, medicineId: 'med-1' }, 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
const existing = {
...treatment,
product: 'Oxuvar',
medicineId: 'med-1',
medicineLotId: null,
doseQuantity: null,
};
tx.treatment.findUnique.mockResolvedValue(existing);
tx.treatment.update.mockResolvedValue(existing);
await expect(
service.update('treatment-1', { notes: 'neu' }, 'user-1'),
).resolves.toBe(existing);
});
it('sets medicineId from the lot when only the lot is given', async () => {
await service.create({ ...base, medicineLotId: 'lot-1' }, 'user-1');
expect(createdData()).toMatchObject({
medicineId: 'med-1',
medicineLotId: 'lot-1',
product: 'Oxuvar',
});
});
it('never fills withdrawal or ingredient from the catalogue', async () => {
tx.veterinaryMedicine.findUnique.mockResolvedValue({
...oxuvar,
activeIngredient: 'Oxalsäure',
withdrawalPeriodDays: 0,
applicationMethod: 'Träufeln',
dosePerColony: 5,
});
await service.create({ ...base, medicineId: 'med-1' }, 'user-1');
const data = createdData();
expect(data.activeIngredient).toBeUndefined();
expect(data.withdrawalPeriodDays).toBeUndefined();
expect(data.method).toBeUndefined();
expect(data.doseQuantity).toBeNull();
});
it('a dose without a lot is stored and books nothing', async () => {
await service.create(
{ ...base, product: 'Ameisensäure', doseQuantity: 12.3456 },
'user-1',
);
expect(createdData().doseQuantity).toBe(12.346);
expect(tx.veterinaryMedicineMovement.create).not.toHaveBeenCalled();
});
it('rejects a dose below one thousandth', async () => {
await expect(
service.create(
{ ...base, product: 'X', doseQuantity: 0.0004 },
'user-1',
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('a lot without a dose books nothing', async () => {
await service.create({ ...base, medicineLotId: 'lot-1' }, 'user-1');
expect(tx.veterinaryMedicineMovement.create).not.toHaveBeenCalled();
});
it('rejects an unknown lot and an unknown medicine with 404', async () => {
tx.veterinaryMedicineLot.findUnique.mockResolvedValue(null);
await expect(
service.create({ ...base, medicineLotId: 'nope' }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
tx.veterinaryMedicine.findUnique.mockResolvedValue(null);
await expect(
service.create({ ...base, medicineId: 'nope' }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
});
it('rejects a missing product when no medicine is chosen', async () => {
await expect(
service.create({ ...base }, 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('bulk books one movement per colony', async () => {
tx.colony.findMany.mockResolvedValue([
{ id: 'colony-1', status: 'ACTIVE', apiaryId: 'apiary-1' },
{ id: 'colony-2', status: 'ACTIVE', apiaryId: 'apiary-1' },
]);
await service.createBulk(
{
date: '2026-08-01',
medicineLotId: 'lot-1',
doseQuantity: 5,
colonyIds: ['colony-1', 'colony-2'],
},
'user-1',
);
expect(movements()).toEqual([
expect.objectContaining({ treatmentId: 't-colony-1', quantity: -5 }),
expect.objectContaining({ treatmentId: 't-colony-2', quantity: -5 }),
]);
expect(tx.veterinaryMedicineLot.findUnique).toHaveBeenCalledTimes(1);
});
it('bulk rolls back all bookings when one colony fails', async () => {
tx.colony.findMany.mockResolvedValue([
{ id: 'colony-1', status: 'ACTIVE', apiaryId: 'apiary-1' },
{ id: 'colony-2', status: 'DISSOLVED', apiaryId: 'apiary-1' },
]);
await expect(
service.createBulk(
{
date: '2026-08-01',
medicineLotId: 'lot-1',
doseQuantity: 5,
colonyIds: ['colony-1', 'colony-2'],
},
'user-1',
),
).rejects.toBeInstanceOf(BadRequestException);
expect(tx.veterinaryMedicineMovement.create).not.toHaveBeenCalled();
expect(tx.treatment.create).not.toHaveBeenCalled();
});
describe('update', () => {
const booked = {
...treatment,
product: 'Oxuvar',
medicineId: 'med-1',
medicineLotId: 'lot-1',
doseQuantity: 30,
};
beforeEach(() => {
tx.treatment.findUnique.mockResolvedValue(booked);
tx.treatment.update.mockImplementation(
({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({
...booked,
...Object.fromEntries(
Object.entries(data).filter(([, v]) => v !== undefined),
),
}),
);
tx.veterinaryMedicineMovement.groupBy.mockResolvedValue([
{ lotId: 'lot-1', _sum: { quantity: -30 } },
]);
});
it('books only the delta when the dose changes and moves the booking when the lot changes', async () => {
await service.update('treatment-1', { doseQuantity: 20 }, 'user-1');
expect(movements()).toEqual([
expect.objectContaining({ lotId: 'lot-1', quantity: 10 }),
]);
tx.veterinaryMedicineMovement.create.mockClear();
tx.veterinaryMedicineLot.findUnique.mockResolvedValue({
id: 'lot-2',
medicineId: 'med-1',
});
tx.veterinaryMedicineLot.findMany.mockResolvedValue([
{ id: 'lot-1', medicineId: 'med-1' },
{ id: 'lot-2', medicineId: 'med-1' },
]);
await service.update(
'treatment-1',
{ medicineLotId: 'lot-2' },
'user-1',
);
expect(movements()).toEqual([
expect.objectContaining({ lotId: 'lot-1', quantity: 30 }),
expect.objectContaining({ lotId: 'lot-2', quantity: -30 }),
]);
});
it('books nothing when neither lot nor dose change', async () => {
await service.update('treatment-1', { notes: 'neu' }, 'user-1');
expect(tx.veterinaryMedicineMovement.create).not.toHaveBeenCalled();
});
it('update with dose null or lot null reverses the booking', async () => {
await service.update('treatment-1', { doseQuantity: null }, 'user-1');
expect(movements()).toEqual([
expect.objectContaining({ lotId: 'lot-1', quantity: 30 }),
]);
tx.veterinaryMedicineMovement.create.mockClear();
await service.update('treatment-1', { medicineLotId: null }, 'user-1');
expect(movements()).toEqual([
expect.objectContaining({ lotId: 'lot-1', quantity: 30 }),
]);
expect(tx.treatment.update).toHaveBeenLastCalledWith({
where: { id: 'treatment-1' },
data: expect.objectContaining({ medicineLotId: null }) as unknown,
});
});
it('update rejects medicineId null while a lot is set', async () => {
await expect(
service.update('treatment-1', { medicineId: null }, 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
expect(tx.treatment.update).not.toHaveBeenCalled();
});
it('update checks the merged state: supplier text for a treatment with a lot is rejected', async () => {
await expect(
service.update('treatment-1', { supplier: 'Apotheke' }, 'user-1'),
).rejects.toThrow('Diese Angabe kommt aus der Packung');
});
it('update reads before after locking Treatment and then the lots in ascending id order', async () => {
tx.veterinaryMedicineLot.findUnique.mockResolvedValue({
id: 'lot-0',
medicineId: 'med-1',
});
tx.veterinaryMedicineLot.findMany.mockResolvedValue([
{ id: 'lot-0', medicineId: 'med-1' },
{ id: 'lot-1', medicineId: 'med-1' },
]);
await service.update(
'treatment-1',
{ medicineLotId: 'lot-0' },
'user-1',
);
const locks = (
tx.$queryRaw.mock.calls as [
{ strings: string[]; values: unknown[] },
][]
).map(([sql]) => ({
table: sql.strings.join('?'),
values: sql.values,
}));
const treatmentLock = locks.findIndex((l) =>
l.table.includes('"Treatment"'),
);
const lotLock = locks.findIndex((l) =>
l.table.includes('"VeterinaryMedicineLot"'),
);
expect(treatmentLock).toBe(0);
expect(lotLock).toBeGreaterThan(treatmentLock);
expect(locks[lotLock].values).toEqual([['lot-0', 'lot-1']]);
const orders = tx.$queryRaw.mock.invocationCallOrder;
expect(orders[treatmentLock]).toBeLessThan(
tx.treatment.findUnique.mock.invocationCallOrder[0],
);
expect(
tx.treatment.findUnique.mock.invocationCallOrder[0],
).toBeLessThan(orders[lotLock]);
});
it('re-filing to another colony leaves the stock untouched', async () => {
tx.colony.findUnique.mockResolvedValue({ apiaryId: 'apiary-2' });
await service.update('treatment-1', { colonyId: 'colony-2' }, 'user-1');
expect(tx.veterinaryMedicineMovement.create).not.toHaveBeenCalled();
});
it('locks the colony before the treatment, then the medicine, then the lots', async () => {
tx.colony.findUnique.mockResolvedValue({ apiaryId: 'apiary-2' });
tx.veterinaryMedicineLot.findMany.mockResolvedValue([
{ id: 'lot-1', medicineId: 'med-1' },
]);
await service.update(
'treatment-1',
{ colonyId: 'colony-2', doseQuantity: 20 },
'user-1',
);
const tables = (
tx.$queryRaw.mock.calls as [{ strings: string[] }][]
).map(([sql]) => sql.strings.join('?'));
const order = [
'"Colony"',
'"Treatment"',
'"VeterinaryMedicine"',
'"VeterinaryMedicineLot"',
].map((table) => tables.findIndex((sql) => sql.includes(table)));
expect(order.every((index) => index >= 0)).toBe(true);
expect([...order].sort((a, b) => a - b)).toEqual(order);
});
it('a notes-only edit succeeds although the lot moved to another medicine', async () => {
tx.veterinaryMedicineLot.findUnique.mockResolvedValue({
id: 'lot-1',
medicineId: 'med-9',
});
await service.update('treatment-1', { notes: 'nur Notiz' }, 'user-1');
expect(tx.veterinaryMedicineLot.findUnique).not.toHaveBeenCalled();
expect(tx.veterinaryMedicineMovement.create).not.toHaveBeenCalled();
const [[updateArg]] = tx.treatment.update.mock.calls as [
[{ data: Record<string, unknown> }],
];
expect(updateArg.data).not.toHaveProperty('medicineLotId');
expect(updateArg.data).not.toHaveProperty('product');
});
it('update accepts apiaryId and rejects an unknown apiary with 404', async () => {
tx.apiary.findUnique.mockResolvedValue({ id: 'apiary-9' });
await service.update('treatment-1', { apiaryId: 'apiary-9' }, 'user-1');
expect(tx.treatment.update).toHaveBeenLastCalledWith({
where: { id: 'treatment-1' },
data: expect.objectContaining({ apiaryId: 'apiary-9' }) as unknown,
});
tx.apiary.findUnique.mockResolvedValue(null);
await expect(
service.update('treatment-1', { apiaryId: 'nope' }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
});
it('conflicts when the lot changed its medicine meanwhile', async () => {
tx.veterinaryMedicineLot.findMany.mockResolvedValue([
{ id: 'lot-1', medicineId: 'med-9' },
]);
await expect(
service.update('treatment-1', { doseQuantity: 20 }, 'user-1'),
).rejects.toBeInstanceOf(ConflictException);
});
it('re-sets the catalogue name when the medicine changes without a product', async () => {
tx.veterinaryMedicine.findUnique.mockResolvedValue({
id: 'med-2',
name: 'Api Bioxal',
active: true,
unit: 'ML',
});
await service.update(
'treatment-1',
{ medicineId: 'med-2', medicineLotId: null, doseQuantity: null },
'user-1',
);
expect(tx.treatment.update).toHaveBeenLastCalledWith({
where: { id: 'treatment-1' },
data: expect.objectContaining({
product: 'Api Bioxal',
medicineId: 'med-2',
}) as unknown,
});
});
});
});
});
@@ -14,6 +14,12 @@ import {
} from '../common/bulk/bulk-create';
import { runBulk } from '../common/bulk/run-bulk';
import { lockRowForUpdate } from '../prisma/row-lock';
import {
bookTreatmentIn,
MedicineInput,
resolveMedicineIn,
ResolvedMedicine,
} from './treatment-medicine';
import { berlinDayOf, dayToDate } from '../common/dates';
import {
lastBlockedDay,
@@ -41,14 +47,19 @@ export interface WithdrawalStatus {
// The row a new treatment is stored as - for create and createBulk alike, so
// a bulk entry stores exactly what single entries would. Where the colony
// stands today is where it was treated: the apiary is kept on the treatment
// for the Bestandsbuch, the colony may be moved later.
// for the Bestandsbuch, the colony may be moved later. Catalogue, pack and
// dose come from resolveMedicineIn; with a pack, supplier, proof, batch
// number and vet stay empty (they are read from the pack, never copied).
function treatmentRow(
dto: CreateTreatmentDto,
apiaryId: string,
actingUserId: string,
medicine: ResolvedMedicine,
): Prisma.TreatmentUncheckedCreateInput {
return {
...dto,
...medicineColumns(medicine),
...(medicine.medicineLotId === null ? {} : LOT_TEXT_CLEARED),
apiaryId,
date: new Date(dto.date),
startDate: optionalDate(dto.startDate),
@@ -57,6 +68,81 @@ function treatmentRow(
};
}
// The patch fields that make medicine, lot and dose consistency matter.
const MEDICINE_FIELDS = [
'product',
'medicineId',
'medicineLotId',
'doseQuantity',
'supplier',
'purchaseProof',
'batchNumber',
'prescribingVet',
] as const;
const LOT_TEXT_CLEARED = {
supplier: null,
purchaseProof: null,
batchNumber: null,
prescribingVet: null,
} as const;
function medicineColumns(medicine: ResolvedMedicine) {
return {
product: medicine.product,
medicineId: medicine.medicineId,
medicineLotId: medicine.medicineLotId,
doseQuantity: medicine.doseQuantity,
};
}
function medicineInputOf(dto: CreateTreatmentDto): MedicineInput {
return {
product: dto.product,
medicineId: dto.medicineId,
medicineLotId: dto.medicineLotId,
doseQuantity: dto.doseQuantity,
supplier: dto.supplier,
purchaseProof: dto.purchaseProof,
batchNumber: dto.batchNumber,
prescribingVet: dto.prescribingVet,
};
}
// The state an edit leaves behind: what the patch does not mention is what
// the treatment had. A missing medicineId stays missing so that a lot alone
// can set it; an explicit null stays null so that it is refused with a lot.
function mergedMedicineInput(
before: Treatment,
dto: UpdateTreatmentDto,
): MedicineInput {
return {
product: dto.product,
currentProduct: before.product,
medicineId:
dto.medicineId !== undefined
? dto.medicineId
: (before.medicineId ?? undefined),
medicineLotId:
dto.medicineLotId !== undefined
? dto.medicineLotId
: before.medicineLotId,
doseQuantity:
dto.doseQuantity !== undefined ? dto.doseQuantity : before.doseQuantity,
supplier: dto.supplier !== undefined ? dto.supplier : before.supplier,
purchaseProof:
dto.purchaseProof !== undefined
? dto.purchaseProof
: before.purchaseProof,
batchNumber:
dto.batchNumber !== undefined ? dto.batchNumber : before.batchNumber,
prescribingVet:
dto.prescribingVet !== undefined
? dto.prescribingVet
: before.prescribingVet,
};
}
@Injectable()
export class TreatmentsService {
constructor(
@@ -145,9 +231,14 @@ export class TreatmentsService {
if (!colony) {
throw new NotFoundException('Colony not found');
}
const medicine = await resolveMedicineIn(
tx,
medicineInputOf(dto),
'create',
);
return this.recordIn(
tx,
treatmentRow(dto, colony.apiaryId, actingUserId),
treatmentRow(dto, colony.apiaryId, actingUserId, medicine),
actingUserId,
);
});
@@ -164,21 +255,39 @@ export class TreatmentsService {
actingUserId: string,
): Promise<BulkCreateResult<Treatment>> {
const { colonyIds, ...shared } = dto;
// The shared fields are checked once, in the bulk transaction after the
// colonies are locked; every colony then books its own dose. Valid
// because runBulk is ONE transaction without retries: the closure lives
// for exactly one attempt, so the memoised check can never outlive the
// transaction (and the locks) it was made under.
let medicine: ResolvedMedicine | undefined;
return runBulk(
this.prisma,
colonyIds.map((colonyId) => ({ colonyId })),
(tx, { colonyId }, colony, bulkId) =>
this.recordIn(
async (tx, { colonyId }, colony, bulkId) => {
medicine ??= await resolveMedicineIn(
tx,
treatmentRow({ ...shared, colonyId }, colony.apiaryId, actingUserId),
medicineInputOf({ ...shared, colonyId }),
'create',
);
return this.recordIn(
tx,
treatmentRow(
{ ...shared, colonyId },
colony.apiaryId,
actingUserId,
medicine,
),
actingUserId,
bulkId,
),
);
},
);
}
// Treatment, audit entry and TreatmentRecorded in the caller's
// transaction; calendar entry and follow-up check follow from the event.
// Treatment, its booking from the pack, audit entry and TreatmentRecorded
// in the caller's transaction; calendar entry and follow-up check follow
// from the event.
private async recordIn(
tx: Prisma.TransactionClient,
data: Prisma.TreatmentUncheckedCreateInput,
@@ -186,6 +295,7 @@ export class TreatmentsService {
bulkId?: string,
): Promise<Treatment> {
const treatment = await tx.treatment.create({ data });
await bookTreatmentIn(tx, treatment, actingUserId, []);
await this.auditLog.record(
{
entityType: 'Treatment',
@@ -214,6 +324,12 @@ export class TreatmentsService {
// TreatmentCorrected moves the calendar event (changed date, colony or
// product) but starts no follow-up check.
return this.prisma.$transaction(async (tx) => {
// Lock order follows LOCKABLE_TABLES: the colony the treatment may be
// re-filed to first, then the treatment, then (below) the medicine and
// the lots in ascending id order.
if (dto.colonyId !== undefined) {
await lockRowForUpdate(tx, 'Colony', dto.colonyId);
}
// The audit "before" is read under the row lock, not before the
// transaction: a concurrent edit cannot slip in between.
await lockRowForUpdate(tx, 'Treatment', id);
@@ -224,22 +340,55 @@ export class TreatmentsService {
// Re-filed to another colony: take that colony's apiary as the location.
let movedColony: { apiaryId: string | null } | null = null;
if (dto.colonyId !== undefined && dto.colonyId !== before.colonyId) {
await lockRowForUpdate(tx, 'Colony', dto.colonyId);
movedColony = await tx.colony.findUnique({
where: { id: dto.colonyId },
select: { apiaryId: true },
});
}
// An explicit apiary corrects the stand on the day; it wins over the
// new colony apiary.
if (dto.apiaryId !== undefined) {
const apiary = await tx.apiary.findUnique({
where: { id: dto.apiaryId },
select: { id: true },
});
if (!apiary) {
throw new NotFoundException('Apiary not found');
}
}
// Medicine, lot and dose are checked and booked only when the edit
// touches them: a notes-only edit of an old treatment must succeed
// even if its lot has since moved to another medicine. The name only
// follows the catalogue in that case, too.
const touchesMedicine = MEDICINE_FIELDS.some(
(field) => dto[field] !== undefined,
);
const medicine = touchesMedicine
? await resolveMedicineIn(
tx,
mergedMedicineInput(before, dto),
'update',
before.medicineId,
)
: null;
const treatment = await tx.treatment.update({
where: { id },
data: {
...dto,
apiaryId: movedColony ? movedColony.apiaryId : undefined,
...(medicine ? medicineColumns(medicine) : {}),
...(medicine && medicine.medicineLotId !== null
? LOT_TEXT_CLEARED
: {}),
apiaryId:
dto.apiaryId ?? (movedColony ? movedColony.apiaryId : undefined),
date: dto.date ? new Date(dto.date) : undefined,
startDate: optionalDate(dto.startDate),
endDate: optionalDate(dto.endDate),
},
});
if (medicine) {
await bookTreatmentIn(tx, treatment, actingUserId);
}
await this.auditLog.record(
{
entityType: 'Treatment',