feat: T6 wave 2 – medicine book, document rights, label particulars, herd events
Backend Image / build-and-push (push) Successful in 6m34s
Frontend Image / build-and-push (push) Successful in 10m17s

- Veterinary medicine catalogue, lots and append-only movement ledger
  (rest always computed; corrections as counter-bookings; inactive
  medicines take no new stock; unit must match on lot moves).
- Documents of medicine lots, authority notices and restrictions need the
  domain right on top of documents:* (403 before any lookup).
- Label particulars: pure checkLabel with six mandatory gaps and four
  warnings, AFM font metrics, Berlin-day dates, geometric reading order
  for split addresses, new master-data fields with row-locked updates.
- Herd events ColonyCreated, ColonyMoved, ApiaryChanged and
  TskReferenceDayReached (ids only, never forwarded to webhooks); CSV
  import emits ColonyCreated with audit.
- ARCHITECTURE.md §12 Compliance (T6), Annahmen 59–70.

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 13:43:44 +02:00
1 parent 8a31c42578
commit c18860c75c
73 files changed
+7121 -309

No files matched your search

@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { AutopilotModule } from '../autopilot/autopilot.module';
import { DomainEventsModule } from '../domain-events/domain-events.module';
import { ApiariesController } from './apiaries.controller';
import { ApiariesService } from './apiaries.service';
import { ApiaryFieldService } from './apiary-field.service';
@Module({
imports: [AutopilotModule],
imports: [AutopilotModule, DomainEventsModule],
controllers: [ApiariesController],
providers: [ApiariesService, ApiaryFieldService],
exports: [ApiariesService],
@@ -2,44 +2,78 @@ import { ConflictException, NotFoundException } from '@nestjs/common';
import { ApiariesService } from './apiaries.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { expectOnlyIdPayloads } from '../domain-events/herd-payload.spec-helper';
describe('ApiariesService', () => {
let service: ApiariesService;
let prisma: {
apiary: {
findMany: jest.Mock;
findUnique: jest.Mock;
create: jest.Mock;
update: jest.Mock;
};
let order: string[];
let tx: {
$queryRaw: jest.Mock;
apiary: { findUnique: jest.Mock; create: jest.Mock; update: jest.Mock };
colony: { count: jest.Mock };
};
let prisma: {
apiary: { findMany: jest.Mock; findUnique: jest.Mock };
$transaction: jest.Mock;
};
let auditLog: { record: jest.Mock };
let events: { record: jest.Mock };
const existingApiary = {
id: 'apiary-1',
name: 'Heimstand',
status: 'ACTIVE',
deactivatedAt: null,
address: 'Weg 1',
location: 'Garten',
latitude: 48.1,
longitude: 11.5,
notes: null,
};
const changes = (): string[] =>
(
events.record.mock.calls as [unknown, { payload: { change: string } }][]
).map(([, event]) => event.payload.change);
beforeEach(() => {
prisma = {
order = [];
tx = {
$queryRaw: jest.fn().mockImplementation(() => {
order.push('lock');
return Promise.resolve([{ id: 'apiary-1' }]);
}),
apiary: {
findMany: jest.fn(),
findUnique: jest.fn(),
findUnique: jest.fn().mockImplementation(() => {
order.push('read');
return Promise.resolve(existingApiary);
}),
create: jest.fn(),
update: jest.fn(),
},
colony: { count: jest.fn().mockResolvedValue(0) },
};
prisma = {
apiary: { findMany: jest.fn(), findUnique: jest.fn() },
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
callback(tx),
),
};
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
events = { record: jest.fn().mockResolvedValue(undefined) };
service = new ApiariesService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
events as unknown as DomainEventsService,
);
});
// Whatever a test recorded, the herd payloads carry only ids.
afterEach(() => {
expectOnlyIdPayloads(events.record.mock.calls);
});
it('findById throws NotFoundException when missing', async () => {
prisma.apiary.findUnique.mockResolvedValue(null);
await expect(service.findById('missing')).rejects.toBeInstanceOf(
@@ -47,89 +81,152 @@ describe('ApiariesService', () => {
);
});
it('create persists the apiary and records an audit entry', async () => {
prisma.apiary.create.mockResolvedValue(existingApiary);
it('create writes record, audit and ApiaryChanged CREATED in one transaction', async () => {
tx.apiary.create.mockResolvedValue(existingApiary);
const result = await service.create({ name: 'Heimstand' }, 'user-1');
expect(result).toEqual(existingApiary);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'Apiary',
action: 'CREATE',
userId: 'user-1',
}),
tx,
);
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'ApiaryChanged',
aggregateType: 'Apiary',
aggregateId: 'apiary-1',
actorUserId: 'user-1',
payload: { change: 'CREATED' },
});
});
it('a failing audit write rolls back the apiary change', async () => {
tx.apiary.create.mockResolvedValue(existingApiary);
auditLog.record.mockRejectedValue(new Error('audit down'));
await expect(
service.create({ name: 'Heimstand' }, 'user-1'),
).rejects.toThrow('audit down');
// Record, audit and event share one transaction callback: its rejection
// is what rolls the record back.
expect(events.record).not.toHaveBeenCalled();
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
});
it('update reads before after locking the Apiary row', async () => {
tx.apiary.update.mockResolvedValue(existingApiary);
await service.update('apiary-1', { notes: 'neu' }, 'user-1');
expect(order).toEqual(['lock', 'read']);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
action: 'UPDATE',
beforeData: existingApiary,
}),
tx,
);
});
it('deactivate sets status to INACTIVE and records an audit entry', async () => {
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
prisma.apiary.update.mockResolvedValue({
it('update of a missing apiary answers 404', async () => {
tx.apiary.findUnique.mockResolvedValue(null);
await expect(
service.update('missing', { notes: 'x' }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
expect(tx.apiary.update).not.toHaveBeenCalled();
});
it.each([
[{ address: 'Neuer Weg 2' }],
[{ location: 'Wiese' }],
[{ latitude: 50 }],
[{ longitude: 12 }],
])('update records LOCATION_CHANGED for %j', async (dto) => {
tx.apiary.update.mockResolvedValue({ ...existingApiary, ...dto });
await service.update('apiary-1', dto, 'user-1');
expect(changes()).toEqual(['LOCATION_CHANGED']);
});
it('update records LOCATION_CHANGED only when address, location, latitude or longitude changed', async () => {
tx.apiary.update.mockResolvedValue({ ...existingApiary, notes: 'neu' });
await service.update(
'apiary-1',
{ name: 'Anders', notes: 'neu' },
'user-1',
);
// The same value sent again is no change either.
tx.apiary.update.mockResolvedValue(existingApiary);
await service.update('apiary-1', { address: 'Weg 1' }, 'user-1');
expect(events.record).not.toHaveBeenCalled();
});
it('deactivate counts living colonies inside the transaction and records DEACTIVATED', async () => {
tx.apiary.update.mockResolvedValue({
...existingApiary,
status: 'INACTIVE',
deactivatedAt: new Date(),
});
const result = await service.deactivate('apiary-1', 'user-1');
expect(result.status).toBe('INACTIVE');
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ entityType: 'Apiary', action: 'DEACTIVATE' }),
);
});
it('deactivate counts every living colony, weak and queenless ones included', async () => {
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
prisma.apiary.update.mockResolvedValue(existingApiary);
await service.deactivate('apiary-1', 'user-1');
expect(prisma.colony.count).toHaveBeenCalledWith({
expect(order).toEqual(['lock', 'read']);
expect(tx.colony.count).toHaveBeenCalledWith({
where: {
apiaryId: 'apiary-1',
status: { notIn: ['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'] },
},
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ entityType: 'Apiary', action: 'DEACTIVATE' }),
tx,
);
expect(changes()).toEqual(['DEACTIVATED']);
});
it('deactivate throws ConflictException when active colonies remain', async () => {
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
prisma.colony.count.mockResolvedValue(2);
tx.colony.count.mockResolvedValue(2);
await expect(
service.deactivate('apiary-1', 'user-1'),
).rejects.toBeInstanceOf(ConflictException);
expect(prisma.apiary.update).not.toHaveBeenCalled();
expect(tx.apiary.update).not.toHaveBeenCalled();
expect(events.record).not.toHaveBeenCalled();
});
it('reactivate throws ConflictException when already active', async () => {
prisma.apiary.findUnique.mockResolvedValue(existingApiary);
await expect(
service.reactivate('apiary-1', 'user-1'),
).rejects.toBeInstanceOf(ConflictException);
expect(events.record).not.toHaveBeenCalled();
});
it('reactivate sets status to ACTIVE and clears deactivatedAt', async () => {
prisma.apiary.findUnique.mockResolvedValue({
it('reactivate records REACTIVATED and clears deactivatedAt', async () => {
tx.apiary.findUnique.mockResolvedValue({
...existingApiary,
status: 'INACTIVE',
deactivatedAt: new Date(),
});
prisma.apiary.update.mockResolvedValue({
...existingApiary,
status: 'ACTIVE',
deactivatedAt: null,
});
tx.apiary.update.mockResolvedValue(existingApiary);
const result = await service.reactivate('apiary-1', 'user-1');
expect(result.status).toBe('ACTIVE');
expect(prisma.apiary.update).toHaveBeenCalledWith({
expect(tx.apiary.update).toHaveBeenCalledWith({
where: { id: 'apiary-1' },
data: { status: 'ACTIVE', deactivatedAt: null },
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ entityType: 'Apiary', action: 'UPDATE' }),
tx,
);
expect(changes()).toEqual(['REACTIVATED']);
});
});
+133 -62
View File
@@ -5,8 +5,11 @@ import {
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import type { DomainEventPayloads } from '../domain-events/domain-event-types';
import { lockRowForUpdate } from '../prisma/row-lock';
import { LIVING_COLONY_WHERE } from '../colonies/colony-status';
import { Apiary, ApiaryStatus } from '../../generated/prisma';
import { Apiary, ApiaryStatus, Prisma } from '../../generated/prisma';
import { CreateApiaryDto } from './dto/create-apiary.dto';
import { UpdateApiaryDto } from './dto/update-apiary.dto';
@@ -15,11 +18,22 @@ export interface ApiaryFilter {
location?: string;
}
type ApiaryChange = DomainEventPayloads['ApiaryChanged']['change'];
// The fields a change of which counts as a new location of the apiary.
const LOCATION_FIELDS = [
'address',
'location',
'latitude',
'longitude',
] as const;
@Injectable()
export class ApiariesService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly events: DomainEventsService,
) {}
async findAll(filter: ApiaryFilter = {}): Promise<Apiary[]> {
@@ -43,15 +57,21 @@ export class ApiariesService {
}
async create(dto: CreateApiaryDto, actingUserId: string): Promise<Apiary> {
const apiary = await this.prisma.apiary.create({ data: { ...dto } });
await this.auditLog.record({
entityType: 'Apiary',
entityId: apiary.id,
action: 'CREATE',
userId: actingUserId,
afterData: apiary,
return this.prisma.$transaction(async (tx) => {
const apiary = await tx.apiary.create({ data: { ...dto } });
await this.auditLog.record(
{
entityType: 'Apiary',
entityId: apiary.id,
action: 'CREATE',
userId: actingUserId,
afterData: apiary,
},
tx,
);
await this.recordChange(tx, apiary.id, 'CREATED', actingUserId);
return apiary;
});
return apiary;
}
async update(
@@ -59,69 +79,120 @@ export class ApiariesService {
dto: UpdateApiaryDto,
actingUserId: string,
): Promise<Apiary> {
const before = await this.findById(id);
const apiary = await this.prisma.apiary.update({
where: { id },
data: { ...dto },
return this.prisma.$transaction(async (tx) => {
const before = await this.lockApiary(tx, id);
const apiary = await tx.apiary.update({
where: { id },
data: { ...dto },
});
await this.auditLog.record(
{
entityType: 'Apiary',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: apiary,
},
tx,
);
if (LOCATION_FIELDS.some((field) => before[field] !== apiary[field])) {
await this.recordChange(tx, id, 'LOCATION_CHANGED', actingUserId);
}
return apiary;
});
await this.auditLog.record({
entityType: 'Apiary',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: apiary,
});
return apiary;
}
async deactivate(id: string, actingUserId: string): Promise<Apiary> {
const before = await this.findById(id);
// Colony.apiaryId is a plain FK, not enforced against apiary status -
// without this guard a colony can silently keep pointing at an
// inactive apiary (still shows ACTIVE, still countable, but its
// "location" has vanished from the active apiary list). Move the
// colonies elsewhere or dissolve them first instead.
const activeColonyCount = await this.prisma.colony.count({
where: { apiaryId: id, ...LIVING_COLONY_WHERE },
});
if (activeColonyCount > 0) {
throw new ConflictException(
`Der Bienenstand hat noch ${activeColonyCount} aktive(s) Volk/Völker - zuerst verlegen oder auflösen`,
return this.prisma.$transaction(async (tx) => {
const before = await this.lockApiary(tx, id);
// Colony.apiaryId is a plain FK, not enforced against apiary status -
// without this guard a colony can silently keep pointing at an
// inactive apiary (still shows ACTIVE, still countable, but its
// "location" has vanished from the active apiary list). Move the
// colonies elsewhere or dissolve them first instead. Counted under the
// apiary lock: the colony paths hold the apiary FOR SHARE, so a move
// into it that is still running finishes first.
const activeColonyCount = await tx.colony.count({
where: { apiaryId: id, ...LIVING_COLONY_WHERE },
});
if (activeColonyCount > 0) {
throw new ConflictException(
`Der Bienenstand hat noch ${activeColonyCount} aktive(s) Volk/Völker - zuerst verlegen oder auflösen`,
);
}
const apiary = await tx.apiary.update({
where: { id },
data: { status: 'INACTIVE', deactivatedAt: new Date() },
});
await this.auditLog.record(
{
entityType: 'Apiary',
entityId: id,
action: 'DEACTIVATE',
userId: actingUserId,
beforeData: before,
afterData: apiary,
},
tx,
);
}
const apiary = await this.prisma.apiary.update({
where: { id },
data: { status: 'INACTIVE', deactivatedAt: new Date() },
await this.recordChange(tx, id, 'DEACTIVATED', actingUserId);
return apiary;
});
await this.auditLog.record({
entityType: 'Apiary',
entityId: id,
action: 'DEACTIVATE',
userId: actingUserId,
beforeData: before,
afterData: apiary,
});
return apiary;
}
async reactivate(id: string, actingUserId: string): Promise<Apiary> {
const before = await this.findById(id);
if (before.status === 'ACTIVE') {
throw new ConflictException('Apiary is already active');
return this.prisma.$transaction(async (tx) => {
const before = await this.lockApiary(tx, id);
if (before.status === 'ACTIVE') {
throw new ConflictException('Apiary is already active');
}
const apiary = await tx.apiary.update({
where: { id },
data: { status: 'ACTIVE', deactivatedAt: null },
});
await this.auditLog.record(
{
entityType: 'Apiary',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: apiary,
},
tx,
);
await this.recordChange(tx, id, 'REACTIVATED', actingUserId);
return apiary;
});
}
// Locks the apiary row and reads it afterwards: the result - not an earlier
// read - is "before" for the comparison, the audit and the event.
private async lockApiary(
tx: Prisma.TransactionClient,
id: string,
): Promise<Apiary> {
await lockRowForUpdate(tx, 'Apiary', id);
const apiary = await tx.apiary.findUnique({ where: { id } });
if (!apiary) {
throw new NotFoundException('Apiary not found');
}
const apiary = await this.prisma.apiary.update({
where: { id },
data: { status: 'ACTIVE', deactivatedAt: null },
});
await this.auditLog.record({
entityType: 'Apiary',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: apiary,
});
return apiary;
}
private recordChange(
tx: Prisma.TransactionClient,
apiaryId: string,
change: ApiaryChange,
actorUserId: string,
): Promise<void> {
return this.events.record(tx, {
type: 'ApiaryChanged',
aggregateType: 'Apiary',
aggregateId: apiaryId,
actorUserId,
payload: { change },
});
}
}
+2
View File
@@ -60,6 +60,7 @@ import { SuggestionsModule } from './suggestions/suggestions.module';
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 { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { PermissionsGuard } from './auth/guards/permissions.guard';
import { PermissionsModule } from './auth/permissions/permissions.module';
@@ -132,6 +133,7 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
ErpExportModule,
ApiKeysModule,
SystemModule,
VeterinaryMedicinesModule,
],
controllers: [],
providers: [
@@ -608,6 +608,7 @@ export class BackupService {
}
imported.queen = rowsOf('queen').length;
// Restore deliberately emits no ColonyCreated (no domain events).
for (const row of rowsOf('colony')) {
await tx.colony.upsert({
where: { id: row.id as string },
@@ -5,6 +5,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { roundToThousandths } from '../common/quantity';
import { DbClient } from '../prisma/db-client';
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
import { AuditLogService } from '../audit/audit-log.service';
@@ -33,11 +34,6 @@ export interface BottlingBatchFilter {
honeyBatchId?: string;
}
/** Amounts are kept in kilograms with gram precision. */
function roundKg(amount: number): number {
return Math.round(amount * 1000) / 1000;
}
type Tx = Prisma.TransactionClient;
const REMOVED_NOTE = 'Abfüllung entfernt';
@@ -212,7 +208,9 @@ export class BottlingService {
? await this.assertBottledProduct(dto.productId)
: null;
const resolved = resolveJarAndBestBefore(dto, article);
const totalAmount = roundKg((resolved.jarSize * dto.jarCount) / 1000);
const totalAmount = roundToThousandths(
(resolved.jarSize * dto.jarCount) / 1000,
);
const lotNumber =
dto.lotNumber ??
@@ -352,7 +350,7 @@ export class BottlingService {
const movedToOtherHoneyBatch = honeyBatchId !== before.honeyBatchId;
const jarSize = dto.jarSize ?? before.jarSize;
const jarCount = dto.jarCount ?? before.jarCount;
const totalAmount = roundKg((jarSize * jarCount) / 1000);
const totalAmount = roundToThousandths((jarSize * jarCount) / 1000);
if (movedToOtherHoneyBatch) {
// Both batches in one statement in ascending id order, so two
@@ -392,7 +390,7 @@ export class BottlingService {
await this.book(
tx,
honeyBatchId,
roundKg(before.totalAmount - totalAmount),
roundToThousandths(before.totalAmount - totalAmount),
actingUserId,
{ sourceBottlingBatchId: id },
);
@@ -5,6 +5,7 @@ import { AuditLogService } from '../audit/audit-log.service';
import { WebhooksService } from '../webhooks/webhooks.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { berlinToday } from '../common/dates';
import { expectOnlyIdPayloads } from '../domain-events/herd-payload.spec-helper';
describe('ColoniesService', () => {
let service: ColoniesService;
@@ -194,9 +195,12 @@ describe('ColoniesService', () => {
'user-1',
);
const [[, event]] = events.record.mock.calls as [
[unknown, { payload: { queenSource: unknown; splitOn: string } }],
];
const event = (
events.record.mock.calls as [
unknown,
{ type: string; payload: { queenSource: unknown; splitOn: string } },
][]
).find(([, recorded]) => recorded.type === 'ColonySplit')![1];
expect(event.payload.queenSource).toBeNull();
expect(event.payload.splitOn).toBe(berlinToday());
});
@@ -514,6 +518,110 @@ describe('ColoniesService', () => {
expect(tx.colonyApiaryHistory.create).not.toHaveBeenCalled();
});
describe('herd events', () => {
// Whatever a test recorded, the herd payloads carry only ids.
afterEach(() => {
expectOnlyIdPayloads(events.record.mock.calls);
});
function recorded(type: string): unknown[] {
return (events.record.mock.calls as [unknown, { type: string }][]).filter(
([, event]) => event.type === type,
);
}
it('create records ColonyCreated with the apiary inside the transaction', async () => {
tx.colony.create.mockResolvedValue({
id: 'colony-9',
apiaryId: 'apiary-1',
});
await service.create(
{ code: 'V-9', apiaryId: 'apiary-1', colonyType: 'PRODUCTION_COLONY' },
'user-1',
);
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'ColonyCreated',
aggregateType: 'Colony',
aggregateId: 'colony-9',
actorUserId: 'user-1',
payload: { apiaryId: 'apiary-1' },
});
});
it('split records ColonyCreated for the child next to ColonySplit', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.create.mockResolvedValue({
id: 'colony-2',
apiaryId: 'apiary-1',
});
tx.colonyLineage.create.mockResolvedValue({ id: 'lineage-1' });
await service.split(
'colony-1',
{ code: 'V-001-A', colonyType: 'NUCLEUS_COLONY' },
'user-1',
);
const types = (
events.record.mock.calls as [unknown, { type: string }][]
).map(([, event]) => event.type);
expect(types).toEqual(
expect.arrayContaining(['ColonySplit', 'ColonyCreated']),
);
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'ColonyCreated',
aggregateType: 'Colony',
aggregateId: 'colony-2',
actorUserId: 'user-1',
payload: { apiaryId: 'apiary-1' },
});
});
it('move records ColonyMoved with from and to', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.update.mockResolvedValue({
...parentColony,
apiaryId: 'apiary-2',
});
await service.move('colony-1', { apiaryId: 'apiary-2' }, 'user-1');
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'ColonyMoved',
aggregateType: 'Colony',
aggregateId: 'colony-1',
actorUserId: 'user-1',
payload: { fromApiaryId: 'apiary-1', toApiaryId: 'apiary-2' },
});
});
it('update with a changed apiary records ColonyMoved and without one records none', async () => {
prisma.colony.findUnique.mockResolvedValue(parentColony);
tx.colony.update.mockResolvedValue({
...parentColony,
apiaryId: 'apiary-2',
});
await service.update('colony-1', { apiaryId: 'apiary-2' }, 'user-1');
expect(recorded('ColonyMoved')).toHaveLength(1);
expect(events.record).toHaveBeenCalledWith(
tx,
expect.objectContaining({
type: 'ColonyMoved',
payload: { fromApiaryId: 'apiary-1', toApiaryId: 'apiary-2' },
}),
);
events.record.mockClear();
tx.colony.update.mockResolvedValue(parentColony);
await service.update('colony-1', { notes: 'x' }, 'user-1');
await service.update('colony-1', { apiaryId: 'apiary-1' }, 'user-1');
expect(recorded('ColonyMoved')).toHaveLength(0);
});
});
it('update into CRITICAL health records ColonyStatusChanged with from/to snapshots', async () => {
prisma.colony.findUnique.mockResolvedValue({
...parentColony,
@@ -8,6 +8,8 @@ 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 type { AnyNewDomainEvent } from '../domain-events/domain-event-types';
import { colonyCreatedEvent } from './colony-created-event';
import { Colony, ColonyStatus, Prisma } from '../../generated/prisma';
import { CreateColonyDto } from './dto/create-colony.dto';
import { UpdateColonyDto } from './dto/update-colony.dto';
@@ -23,6 +25,21 @@ import { lockRowsForUpdate } from '../prisma/row-lock';
import { colonyTransitionEvents, lockColony } from './colony-events';
import { berlinToday, dayToDate } from '../common/dates';
function colonyMovedEvent(
colonyId: string,
fromApiaryId: string,
toApiaryId: string,
actorUserId: string,
): AnyNewDomainEvent<'ColonyMoved'> {
return {
type: 'ColonyMoved',
aggregateType: 'Colony',
aggregateId: colonyId,
actorUserId,
payload: { fromApiaryId, toApiaryId },
};
}
export interface ColonyFilter {
status?: ColonyStatus;
apiaryId?: string;
@@ -96,10 +113,10 @@ export class ColoniesService {
},
tx,
);
await this.events.record(tx, colonyCreatedEvent(created, actingUserId));
return created;
});
// No domain event for a new colony in this stage - the webhook stays on
// the direct path.
// The webhook stays on the direct path (the event is not forwarded).
await this.webhooks.dispatch('colony.created', colony);
return colony;
}
@@ -159,6 +176,10 @@ export class ColoniesService {
notes: 'Korrektur über Volk bearbeiten',
},
});
await this.events.record(
tx,
colonyMovedEvent(id, before.apiaryId, updated.apiaryId, actingUserId),
);
}
await this.events.recordAll(
tx,
@@ -204,6 +225,12 @@ export class ColoniesService {
notes: dto.notes,
},
});
if (before.apiaryId !== updated.apiaryId) {
await this.events.record(
tx,
colonyMovedEvent(id, before.apiaryId, updated.apiaryId, actingUserId),
);
}
await this.auditLog.record(
{
entityType: 'Colony',
@@ -263,6 +290,7 @@ export class ColoniesService {
},
tx,
);
await this.events.record(tx, colonyCreatedEvent(created, actingUserId));
await this.events.record(tx, {
type: 'ColonySplit',
aggregateType: 'Colony',
@@ -0,0 +1,20 @@
import type { Colony } from '../../generated/prisma';
import type { AnyNewDomainEvent } from '../domain-events/domain-event-types';
/**
* The ColonyCreated event of a colony that was just written (create, split
* child, CSV import). The caller records it in the transaction that wrote the
* colony. Backup restore deliberately records none.
*/
export function colonyCreatedEvent(
colony: Pick<Colony, 'id' | 'apiaryId'>,
actorUserId: string,
): AnyNewDomainEvent<'ColonyCreated'> {
return {
type: 'ColonyCreated',
aggregateType: 'Colony',
aggregateId: colony.id,
actorUserId,
payload: { apiaryId: colony.apiaryId },
};
}
@@ -54,6 +54,18 @@ describe('legacy after-commit paths', () => {
]);
});
it('apiaries.service.ts has no after-commit side effects', () => {
for (const call of [
'runAfterCommit(',
'webhooks.dispatch(',
'calendarSync.enqueue(',
]) {
expect(filesCalling(call)).not.toContain(
'src/apiaries/apiaries.service.ts',
);
}
});
it('no longer defines the removed post-commit automation wrappers (only SWARM_MOOD_HIGH and STOCK_ITEM_LOW keep one)', () => {
const removed = [
'evaluateInspection',
@@ -0,0 +1,27 @@
import { roundToThousandths } from './quantity';
describe('roundToThousandths', () => {
it('rounds to thousandths', () => {
expect(roundToThousandths(0.0004)).toBe(0);
});
it('keeps 1.2345 as 1.235 (half up)', () => {
expect(roundToThousandths(1.2345)).toBe(1.235);
});
it('rounds ties towards +Infinity and never returns -0', () => {
expect(roundToThousandths(-0.0005)).toBe(0);
expect(Object.is(roundToThousandths(-0.0005), 0)).toBe(true);
expect(Object.is(roundToThousandths(-0.0004), 0)).toBe(true);
expect(roundToThousandths(1.2345)).toBe(1.235);
expect(roundToThousandths(-1.2345)).toBe(-1.234);
});
it('cleans float noise of a sum', () => {
expect(roundToThousandths(0.1 + 0.2)).toBe(0.3);
});
it('keeps negative values', () => {
expect(roundToThousandths(-3.3333)).toBe(-3.333);
});
});
+8
View File
@@ -0,0 +1,8 @@
/**
* Quantities (kilograms of honey, millilitres of a medicine, ...) are kept on
* a grid of thousandths, so sums of floats compare and print cleanly.
*/
export function roundToThousandths(value: number): number {
// `+ 0` turns -0 (a tiny negative value) into 0.
return Math.round(value * 1000) / 1000 + 0;
}
@@ -43,6 +43,7 @@ export class DocumentsController {
findAll(
@Query('entityType') entityType: string,
@Query('entityId') entityId: string,
@CurrentUser() actor: AuthenticatedUser,
) {
if (!entityType || !entityId) {
throw new BadRequestException('entityType and entityId are required');
@@ -57,6 +58,7 @@ export class DocumentsController {
return this.documentsService.findAll(
entityType as DocumentEntityType,
entityId,
actor.id,
);
}
@@ -97,9 +99,15 @@ export class DocumentsController {
@RequirePermission('documents:read')
@Get(':id/download')
async download(@Param('id') id: string, @Res() res: Response): Promise<void> {
const { buffer, fileName, mimeType } =
await this.documentsService.readFile(id);
async download(
@Param('id') id: string,
@CurrentUser() actor: AuthenticatedUser,
@Res() res: Response,
): Promise<void> {
const { buffer, fileName, mimeType } = await this.documentsService.readFile(
id,
actor.id,
);
res.setHeader('Content-Type', mimeType);
res.setHeader(
'Content-Disposition',
@@ -1,8 +1,14 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
ForbiddenException,
Logger,
NotFoundException,
} from '@nestjs/common';
import * as fs from 'node:fs/promises';
import { DocumentsService } from './documents.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
jest.mock('node:fs/promises');
@@ -21,7 +27,11 @@ describe('DocumentsService', () => {
queen: { findUnique: jest.Mock };
inspection: { findUnique: jest.Mock };
honeyBatch: { findUnique: jest.Mock };
veterinaryMedicineLot: { findUnique: jest.Mock };
authorityNotice: { findUnique: jest.Mock };
apiaryRestriction: { findUnique: jest.Mock };
};
let permissions: { getPermissions: jest.Mock };
let auditLog: { record: jest.Mock };
const document = {
@@ -50,11 +60,18 @@ describe('DocumentsService', () => {
queen: { findUnique: jest.fn() },
inspection: { findUnique: jest.fn() },
honeyBatch: { findUnique: jest.fn() },
veterinaryMedicineLot: { findUnique: jest.fn() },
authorityNotice: { findUnique: jest.fn() },
apiaryRestriction: { findUnique: jest.fn() },
};
permissions = {
getPermissions: jest.fn().mockResolvedValue(new Set<string>()),
};
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
service = new DocumentsService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
permissions as unknown as PermissionResolverService,
);
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
@@ -64,7 +81,7 @@ describe('DocumentsService', () => {
it('findAll strips storagePath from results', async () => {
prisma.document.findMany.mockResolvedValueOnce([document]);
const result = await service.findAll('COLONY', 'colony-1');
const result = await service.findAll('COLONY', 'colony-1', 'user-1');
expect(result).toEqual([
{
id: 'doc-1',
@@ -143,14 +160,14 @@ describe('DocumentsService', () => {
describe('readFile', () => {
it('throws NotFoundException when the document does not exist', async () => {
prisma.document.findUnique.mockResolvedValue(null);
await expect(service.readFile('missing')).rejects.toBeInstanceOf(
NotFoundException,
);
await expect(
service.readFile('missing', 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
});
it('reads the stored file from disk', async () => {
prisma.document.findUnique.mockResolvedValue(document);
const result = await service.readFile('doc-1');
const result = await service.readFile('doc-1', 'user-1');
expect(fs.readFile).toHaveBeenCalledWith(document.storagePath);
expect(result.fileName).toBe('bild.jpg');
expect(result.mimeType).toBe('image/jpeg');
@@ -178,6 +195,21 @@ describe('DocumentsService', () => {
});
});
describe('delete file cleanup', () => {
it('still deletes the row and warns when the file cannot be removed', async () => {
prisma.document.findUnique.mockResolvedValue(document);
(fs.unlink as jest.Mock).mockRejectedValueOnce(new Error('EPERM'));
const warn = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
await expect(service.delete('doc-1', 'user-1')).resolves.toBeUndefined();
expect(prisma.document.delete).toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith(expect.stringContaining('doc-1'));
expect(auditLog.record).toHaveBeenCalled();
warn.mockRestore();
});
});
describe('update', () => {
const existing = {
id: 'doc-1',
@@ -236,4 +268,178 @@ describe('DocumentsService', () => {
expect(prisma.colony.findUnique).not.toHaveBeenCalled();
});
});
describe('per-type permissions', () => {
beforeEach(() => {
// the fs module mock is shared between tests; start each with no calls
jest.mocked(fs.writeFile).mockClear();
jest.mocked(fs.readFile).mockClear();
jest.mocked(fs.unlink).mockClear();
});
const grant = (...keys: string[]) =>
permissions.getPermissions.mockResolvedValue(new Set(keys));
const lotDocument = {
...document,
entityType: 'MEDICINE_LOT',
entityId: 'lot-1',
};
const png = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
Buffer.from('x'),
]);
const pngFile = {
buffer: png,
originalname: 'b.png',
mimetype: 'image/png',
size: png.length,
} as Express.Multer.File;
it('assertEntityExists finds a medicine lot, an authority notice and an apiary restriction and 404s a missing one', async () => {
grant(
'documents:upload',
'veterinary-medicines:update',
'compliance:manage',
);
prisma.document.create.mockResolvedValue(document);
prisma.veterinaryMedicineLot.findUnique.mockResolvedValueOnce({
id: 'l',
});
prisma.authorityNotice.findUnique.mockResolvedValueOnce({ id: 'n' });
prisma.apiaryRestriction.findUnique.mockResolvedValueOnce({ id: 'r' });
await service.create(pngFile, 'MEDICINE_LOT', 'l', 'user-1');
await service.create(pngFile, 'AUTHORITY_NOTICE', 'n', 'user-1');
await service.create(pngFile, 'APIARY_RESTRICTION', 'r', 'user-1');
expect(prisma.veterinaryMedicineLot.findUnique).toHaveBeenCalledWith({
where: { id: 'l' },
});
expect(prisma.authorityNotice.findUnique).toHaveBeenCalledWith({
where: { id: 'n' },
});
expect(prisma.apiaryRestriction.findUnique).toHaveBeenCalledWith({
where: { id: 'r' },
});
for (const type of [
'MEDICINE_LOT',
'AUTHORITY_NOTICE',
'APIARY_RESTRICTION',
] as const) {
await expect(
service.create(pngFile, type, 'missing', 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
}
});
it('assertEntityAccess is a no-op for the old entity types', async () => {
for (const type of [
'APIARY',
'COLONY',
'QUEEN',
'INSPECTION',
'HONEY_BATCH',
] as const) {
await expect(
service.assertEntityAccess('user-1', type, 'read'),
).resolves.toBeUndefined();
await expect(
service.assertEntityAccess('user-1', type, 'write'),
).resolves.toBeUndefined();
}
expect(permissions.getPermissions).not.toHaveBeenCalled();
});
it('assertEntityAccess maps each new type to its read and write right', async () => {
grant('veterinary-medicines:read', 'compliance:read');
await service.assertEntityAccess('u', 'MEDICINE_LOT', 'read');
await service.assertEntityAccess('u', 'AUTHORITY_NOTICE', 'read');
await service.assertEntityAccess('u', 'APIARY_RESTRICTION', 'read');
for (const type of [
'MEDICINE_LOT',
'AUTHORITY_NOTICE',
'APIARY_RESTRICTION',
] as const) {
await expect(
service.assertEntityAccess('u', type, 'write'),
).rejects.toBeInstanceOf(ForbiddenException);
}
grant('veterinary-medicines:update', 'compliance:manage');
await service.assertEntityAccess('u', 'MEDICINE_LOT', 'write');
await service.assertEntityAccess('u', 'AUTHORITY_NOTICE', 'write');
await service.assertEntityAccess('u', 'APIARY_RESTRICTION', 'write');
grant();
await expect(
service.assertEntityAccess('u', 'MEDICINE_LOT', 'read'),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('findAll denies a restricted type before any database query', async () => {
grant('documents:read');
await expect(
service.findAll('MEDICINE_LOT', 'lot-1', 'user-1'),
).rejects.toBeInstanceOf(ForbiddenException);
expect(prisma.document.findMany).not.toHaveBeenCalled();
});
it('create denies a restricted type before any check or write', async () => {
grant('documents:upload');
await expect(
service.create(pngFile, 'AUTHORITY_NOTICE', 'n', 'user-1'),
).rejects.toBeInstanceOf(ForbiddenException);
expect(prisma.authorityNotice.findUnique).not.toHaveBeenCalled();
expect(fs.writeFile).not.toHaveBeenCalled();
});
it('readFile denies before reading the file from disk', async () => {
grant('documents:read');
prisma.document.findUnique.mockResolvedValue(lotDocument);
await expect(service.readFile('doc-1', 'user-1')).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(fs.readFile).not.toHaveBeenCalled();
});
it('delete denies before touching the row or the file', async () => {
grant('documents:delete', 'veterinary-medicines:read');
prisma.document.findUnique.mockResolvedValue(lotDocument);
await expect(service.delete('doc-1', 'user-1')).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(prisma.document.delete).not.toHaveBeenCalled();
expect(fs.unlink).not.toHaveBeenCalled();
});
it('update checks the permission of the old and of the new entity type', async () => {
prisma.document.findUnique.mockResolvedValue(lotDocument);
prisma.apiaryRestriction.findUnique.mockResolvedValue({ id: 'r' });
prisma.document.update.mockResolvedValue(document);
const relink = {
entityType: 'APIARY_RESTRICTION' as const,
entityId: 'r',
};
grant('veterinary-medicines:update'); // old allowed, new not
await expect(
service.update('doc-1', relink, 'user-1'),
).rejects.toBeInstanceOf(ForbiddenException);
grant('compliance:manage'); // new allowed, old not
await expect(
service.update('doc-1', relink, 'user-1'),
).rejects.toBeInstanceOf(ForbiddenException);
expect(prisma.document.update).not.toHaveBeenCalled();
grant('veterinary-medicines:update', 'compliance:manage');
await service.update('doc-1', relink, 'user-1');
expect(prisma.document.update).toHaveBeenCalled();
});
it('update of a restricted document without the right is denied even for a rename', async () => {
grant('documents:update');
prisma.document.findUnique.mockResolvedValue(lotDocument);
await expect(
service.update('doc-1', { fileName: 'x.pdf' }, 'user-1'),
).rejects.toBeInstanceOf(ForbiddenException);
});
});
});
@@ -1,6 +1,8 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import * as crypto from 'node:crypto';
@@ -9,12 +11,32 @@ import * as path from 'node:path';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { matchesFileSignature } from '../common/file-signature';
import { loggableError } from '../common/loggable-error';
import { Document, DocumentEntityType } from '../../generated/prisma';
import type { PermissionKey } from '../auth/permissions/permission-catalog';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { ALLOWED_DOCUMENT_MIME_TYPES } from './document-mime-types';
import { UpdateDocumentDto } from './dto/update-document.dto';
export type DocumentSummary = Omit<Document, 'storagePath'>;
/**
* Documents of these entity types are specialist records (purchase receipts,
* authority notices, restriction orders): on top of the generic documents:*
* right the caller needs the right of the record they belong to. Types
* without an entry have no additional requirement.
*/
export const DOCUMENT_ENTITY_PERMISSIONS: Partial<
Record<DocumentEntityType, { read: PermissionKey; write: PermissionKey }>
> = {
MEDICINE_LOT: {
read: 'veterinary-medicines:read',
write: 'veterinary-medicines:update',
},
AUTHORITY_NOTICE: { read: 'compliance:read', write: 'compliance:manage' },
APIARY_RESTRICTION: { read: 'compliance:read', write: 'compliance:manage' },
};
function storageRoot(): string {
return process.env.DOCUMENTS_STORAGE_PATH ?? './uploads';
}
@@ -34,15 +56,36 @@ function toSummary(document: Document): DocumentSummary {
@Injectable()
export class DocumentsService {
private readonly logger = new Logger(DocumentsService.name);
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly permissions: PermissionResolverService,
) {}
/** 403 unless the user holds the type's specialist right (see map above). */
async assertEntityAccess(
userId: string,
entityType: DocumentEntityType,
mode: 'read' | 'write',
): Promise<void> {
const required = DOCUMENT_ENTITY_PERMISSIONS[entityType]?.[mode];
if (!required) {
return;
}
const granted = await this.permissions.getPermissions(userId);
if (!granted.has(required)) {
throw new ForbiddenException('Insufficient permissions');
}
}
async findAll(
entityType: DocumentEntityType,
entityId: string,
actingUserId: string,
): Promise<DocumentSummary[]> {
await this.assertEntityAccess(actingUserId, entityType, 'read');
const documents = await this.prisma.document.findMany({
where: { entityType, entityId },
orderBy: { createdAt: 'desc' },
@@ -70,6 +113,18 @@ export class DocumentsService {
return this.prisma.honeyBatch.findUnique({
where: { id: entityId },
});
case 'MEDICINE_LOT':
return this.prisma.veterinaryMedicineLot.findUnique({
where: { id: entityId },
});
case 'AUTHORITY_NOTICE':
return this.prisma.authorityNotice.findUnique({
where: { id: entityId },
});
case 'APIARY_RESTRICTION':
return this.prisma.apiaryRestriction.findUnique({
where: { id: entityId },
});
default:
return Promise.resolve(null);
}
@@ -85,6 +140,7 @@ export class DocumentsService {
entityId: string,
actingUserId: string,
): Promise<DocumentSummary> {
await this.assertEntityAccess(actingUserId, entityType, 'write');
if (!(file.mimetype in ALLOWED_DOCUMENT_MIME_TYPES)) {
throw new BadRequestException('Nicht unterstützter Dateityp');
}
@@ -125,6 +181,7 @@ export class DocumentsService {
async readFile(
id: string,
actingUserId: string,
): Promise<{ buffer: Buffer; fileName: string; mimeType: string }> {
const document = await this.prisma.document.findUnique({
where: { id },
@@ -132,6 +189,7 @@ export class DocumentsService {
if (!document) {
throw new NotFoundException('Document not found');
}
await this.assertEntityAccess(actingUserId, document.entityType, 'read');
const buffer = await fs.readFile(document.storagePath);
return {
buffer,
@@ -154,8 +212,12 @@ export class DocumentsService {
if (!before) {
throw new NotFoundException('Document not found');
}
await this.assertEntityAccess(actingUserId, before.entityType, 'write');
const entityType = dto.entityType ?? before.entityType;
const entityId = dto.entityId ?? before.entityId;
if (entityType !== before.entityType) {
await this.assertEntityAccess(actingUserId, entityType, 'write');
}
if (entityType !== before.entityType || entityId !== before.entityId) {
await this.assertEntityExists(entityType, entityId);
}
@@ -181,8 +243,14 @@ export class DocumentsService {
if (!document) {
throw new NotFoundException('Document not found');
}
await this.assertEntityAccess(actingUserId, document.entityType, 'write');
await this.prisma.document.delete({ where: { id } });
await fs.unlink(document.storagePath).catch(() => undefined);
await fs.unlink(document.storagePath).catch((error: unknown) => {
// The row is gone; an orphaned (possibly restricted) file must be visible.
this.logger.warn(
`Could not remove the file of document ${id}: ${loggableError(error).message}`,
);
});
await this.auditLog.record({
entityType: 'Document',
entityId: id,
@@ -215,7 +215,7 @@ describe('DomainEventRegistry', () => {
it('rejects an event type outside the catalog', () => {
class UnknownType {
@OnDomainEvent('ColonyCreated' as DomainEventType, 'colonies.welcome')
@OnDomainEvent('ColonyWelcomed' as DomainEventType, 'colonies.welcome')
handle(): Promise<void> {
return Promise.resolve();
}
@@ -129,6 +129,21 @@ export interface DomainEventPayloads {
previousSegment: SeasonSegment | null;
source: PhaseSource | null;
};
// T6, aggregate Colony: a colony came into being (create, or the child of a
// split). Ids only; not forwarded to webhooks.
ColonyCreated: { apiaryId: string };
// T6, aggregate Colony: a colony changed its apiary (move, or a correction
// through update). Not forwarded to webhooks.
ColonyMoved: { fromApiaryId: string; toApiaryId: string };
// T6, aggregate Apiary: the herd-relevant master data of an apiary changed.
// The code only, never the address or coordinates. Not forwarded to webhooks.
ApiaryChanged: {
change: 'CREATED' | 'DEACTIVATED' | 'REACTIVATED' | 'LOCATION_CHANGED';
};
// T6, aggregate Compliance; time-based (dedupeKey per day): the TSK
// reference day (referenceDay, 'YYYY-MM-DD') was reached. Not forwarded to
// webhooks.
TskReferenceDayReached: { year: number; referenceDay: string };
}
export type DomainEventType = keyof DomainEventPayloads;
@@ -169,6 +184,10 @@ const CATALOG: Record<DomainEventType, true> = {
PhenologyRecomputeRequested: true,
SeasonPhaseChanged: true,
WinterCheckRecorded: true,
ColonyCreated: true,
ColonyMoved: true,
ApiaryChanged: true,
TskReferenceDayReached: true,
};
export const DOMAIN_EVENT_TYPES: readonly DomainEventType[] = Object.freeze(
@@ -192,7 +211,8 @@ export type AggregateType =
| 'HoneyBatch'
| 'PhenologyReport'
| 'WinterCheck'
| 'Apiary';
| 'Apiary'
| 'Compliance';
export interface NewDomainEvent<T extends DomainEventType = DomainEventType> {
type: T;
@@ -183,7 +183,7 @@ describe('DomainEventsService', () => {
describe('domain event catalog', () => {
it('lists every event type exactly once', () => {
expect(DOMAIN_EVENT_TYPES).toHaveLength(34);
expect(DOMAIN_EVENT_TYPES).toHaveLength(38);
expect(new Set(DOMAIN_EVENT_TYPES).size).toBe(DOMAIN_EVENT_TYPES.length);
expect(DOMAIN_EVENT_TYPES).toEqual(
expect.arrayContaining([
@@ -202,6 +202,10 @@ describe('domain event catalog', () => {
'PhenologyRecomputeRequested',
'SeasonPhaseChanged',
'WinterCheckRecorded',
'ColonyCreated',
'ColonyMoved',
'ApiaryChanged',
'TskReferenceDayReached',
]),
);
});
@@ -0,0 +1,41 @@
// Allow-list of payload keys of the T6 herd events: ids, a change code, a year
// and a day - never a name, address, coordinate or note.
export const HERD_PAYLOAD_KEYS: Readonly<Record<string, readonly string[]>> = {
ColonyCreated: ['apiaryId'],
ColonyMoved: ['fromApiaryId', 'toApiaryId'],
ApiaryChanged: ['change'],
TskReferenceDayReached: ['year', 'referenceDay'],
};
const SAFE_VALUE = /^[A-Za-z0-9_-]+$/;
/**
* Checks the herd events among the `events.record` mock calls of a spec
* (`[tx, event]` pairs) against the allow-list: no extra key, only id-shaped
* strings or integers as values. Returns how many herd events it checked.
*/
export function expectOnlyIdPayloads(
calls: readonly (readonly unknown[])[],
): number {
let checked = 0;
for (const [, event] of calls as [unknown, unknown][]) {
const { type, payload } = event as {
type: string;
payload: Record<string, unknown>;
};
const allowed = HERD_PAYLOAD_KEYS[type];
if (allowed === undefined) {
continue;
}
checked += 1;
expect(Object.keys(payload).sort()).toEqual([...allowed].sort());
for (const value of Object.values(payload)) {
if (typeof value === 'number') {
expect(Number.isInteger(value)).toBe(true);
} else {
expect(typeof value === 'string' && SAFE_VALUE.test(value)).toBe(true);
}
}
}
return checked;
}
@@ -1,3 +1,4 @@
import { roundToThousandths } from '../common/quantity';
import { formatNumberDe } from '../suggestions/texts/format-number';
import {
WARNING_SEVERITY,
@@ -25,9 +26,7 @@ export function sharedHoneyType(
}
/** kg on a gram grid, so sums of floats compare and print cleanly. */
export function roundKg(value: number): number {
return Math.round(value * 1000) / 1000;
}
export const roundKg = roundToThousandths;
/**
* The extraction (Schleuderung) against the net weight of its harvests
@@ -2,6 +2,7 @@ import { BadRequestException } from '@nestjs/common';
import { ImportService } from './import.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
describe('ImportService', () => {
let service: ImportService;
@@ -9,19 +10,31 @@ describe('ImportService', () => {
apiary: { create: jest.Mock; findFirst: jest.Mock };
colony: { create: jest.Mock; findUnique: jest.Mock };
queen: { create: jest.Mock; findUnique: jest.Mock };
$transaction: jest.Mock;
};
let tx: { colony: { create: jest.Mock } };
let auditLog: { record: jest.Mock };
let events: { record: jest.Mock };
beforeEach(() => {
prisma = {
apiary: { create: jest.fn(), findFirst: jest.fn() },
colony: { create: jest.fn(), findUnique: jest.fn() },
queen: { create: jest.fn(), findUnique: jest.fn() },
$transaction: jest.fn(),
};
// 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 } };
prisma.$transaction.mockImplementation(
(callback: (txArg: typeof tx) => unknown) => callback(tx),
);
auditLog = { record: jest.fn() };
events = { record: jest.fn().mockResolvedValue(undefined) };
service = new ImportService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
events as unknown as DomainEventsService,
);
});
@@ -195,6 +208,56 @@ describe('ImportService', () => {
expect(createArg.data).toMatchObject({ strength: 6, occupiedFrames: 14 });
});
it('records audit and ColonyCreated in the transaction of every created colony', async () => {
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
prisma.colony.findUnique.mockResolvedValue(null);
prisma.colony.create
.mockResolvedValueOnce({ id: 'colony-1', apiaryId: 'apiary-1' })
.mockResolvedValueOnce({ id: 'colony-2', apiaryId: 'apiary-1' });
const report = await service.importCsv(
'colonies',
Buffer.from(
'code,apiaryName,colonyType\nV-1,Teststand,PRODUCTION_COLONY\nV-2,Teststand,PRODUCTION_COLONY',
),
'user-1',
);
expect(report.created).toBe(2);
expect(events.record).toHaveBeenCalledTimes(2);
expect(events.record).toHaveBeenCalledWith(tx, {
type: 'ColonyCreated',
aggregateType: 'Colony',
aggregateId: 'colony-2',
actorUserId: 'user-1',
payload: { apiaryId: 'apiary-1' },
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'Colony',
entityId: 'colony-1',
action: 'CREATE',
userId: 'user-1',
}),
tx,
);
});
it('records no event for a skipped or invalid row', async () => {
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
prisma.colony.findUnique.mockResolvedValue({ id: 'existing' });
await service.importCsv(
'colonies',
Buffer.from(
'code,apiaryName,colonyType\nV-1,Teststand,PRODUCTION_COLONY\nV-2,Teststand,NOT_A_TYPE',
),
'user-1',
);
expect(events.record).not.toHaveBeenCalled();
});
it('reports a validation error for an invalid colonyType', async () => {
prisma.apiary.findFirst.mockResolvedValue({ id: 'apiary-1' });
+25 -3
View File
@@ -3,6 +3,8 @@ import { plainToInstance } from 'class-transformer';
import { validate, ValidationError } from 'class-validator';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { DomainEventsService } from '../domain-events/domain-events.service';
import { colonyCreatedEvent } from '../colonies/colony-created-event';
import { CreateApiaryDto } from '../apiaries/dto/create-apiary.dto';
import { CreateColonyDto } from '../colonies/dto/create-colony.dto';
import { CreateQueenDto } from '../queens/dto/create-queen.dto';
@@ -56,6 +58,7 @@ export class ImportService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly events: DomainEventsService,
) {}
async importCsv(
@@ -80,7 +83,9 @@ export class ImportService {
record[column] = (dataRows[i][index] ?? '').trim();
});
const rowNumber = i + 1;
results.push(await this.importRow(entity, record, rowNumber));
results.push(
await this.importRow(entity, record, rowNumber, actingUserId),
);
}
const report: ImportReport = {
@@ -110,12 +115,13 @@ export class ImportService {
entity: ImportEntity,
record: Record<string, string>,
rowNumber: number,
actingUserId: string,
): Promise<ImportRowResult> {
switch (entity) {
case 'apiaries':
return this.importApiaryRow(record, rowNumber);
case 'colonies':
return this.importColonyRow(record, rowNumber);
return this.importColonyRow(record, rowNumber, actingUserId);
case 'queens':
return this.importQueenRow(record, rowNumber);
}
@@ -150,6 +156,7 @@ export class ImportService {
private async importColonyRow(
record: Record<string, string>,
rowNumber: number,
actingUserId: string,
): Promise<ImportRowResult> {
const apiaryName = emptyToUndefined(record.apiaryName);
if (!apiaryName) {
@@ -209,7 +216,22 @@ export class ImportService {
id: existing.id,
};
}
const colony = await this.prisma.colony.create({ data: { ...dto } });
// Like ColoniesService.create: record, audit and event in one transaction.
const colony = await this.prisma.$transaction(async (tx) => {
const created = await tx.colony.create({ data: { ...dto } });
await this.auditLog.record(
{
entityType: 'Colony',
entityId: created.id,
action: 'CREATE',
userId: actingUserId,
afterData: created,
},
tx,
);
await this.events.record(tx, colonyCreatedEvent(created, actingUserId));
return created;
});
return {
row: rowNumber,
status: 'created',
@@ -8,6 +8,7 @@ import {
IsNumber,
IsOptional,
IsString,
Max,
Min,
ValidateNested,
} from 'class-validator';
@@ -91,4 +92,14 @@ export class CreateLabelTemplateDto {
@IsOptionalNotNull()
@IsBoolean()
isDefault?: boolean;
@ApiPropertyOptional({
description:
'Kleinste x-Höhe (mm) für Pflichtangaben; darunter warnt die Etikettenprüfung. Vorgabe 1,2.',
})
@IsOptionalNotNull()
@IsNumber()
@Min(0.5)
@Max(5)
minXHeightMm?: number;
}
@@ -0,0 +1,67 @@
import { fontMetricsOf, heightMm, pdfTextMeasurer } from './font-metrics';
import { LabelTextElement } from './label-element.types';
describe('font metrics', () => {
// Guards against a pdfkit update that changes or hides the AFM values.
it('Helvetica metrics are x-height 523 and cap height 718', () => {
expect(fontMetricsOf('sans', false, false)).toEqual({
xHeight: 523,
capHeight: 718,
});
});
it('Times and Courier return their own AFM values', () => {
expect(fontMetricsOf('serif', false, false)).toEqual({
xHeight: 450,
capHeight: 662,
});
expect(fontMetricsOf('serif', true, true)).toEqual({
xHeight: 462,
capHeight: 669,
});
expect(fontMetricsOf('mono', false, false)).toEqual({
xHeight: 426,
capHeight: 562,
});
});
it('reads the style variants of a family', () => {
expect(fontMetricsOf('sans', true, false).xHeight).toBe(532);
});
it('converts a size and a metric to millimetres', () => {
expect(heightMm(6, 523)).toBeCloseTo(1.107, 3);
expect(heightMm(7, 523)).toBeCloseTo(1.2915, 3);
expect(heightMm(72, 1000)).toBeCloseTo(25.4, 9);
});
describe('pdfTextMeasurer', () => {
const element: LabelTextElement = {
id: 'e',
type: 'text',
x: 0,
y: 0,
width: 20,
height: 10,
text: '',
fontSize: 10,
fontFamily: 'sans',
bold: false,
italic: false,
color: '#000000',
align: 'left',
};
it('measures more lines for a narrower box', () => {
const measurer = pdfTextMeasurer();
const text = 'Mindestens haltbar bis 05.03.2028 und noch mehr Text';
const wide = measurer.heightOfStringPt(text, { ...element, width: 120 });
const narrow = measurer.heightOfStringPt(text, { ...element, width: 20 });
expect(narrow).toBeGreaterThan(wide);
});
it('measures nothing for an empty text', () => {
expect(pdfTextMeasurer().heightOfStringPt('', element)).toBe(0);
});
});
});
@@ -0,0 +1,92 @@
import PDFDocument from 'pdfkit';
import { LabelFontFamily, LabelTextElement } from './label-element.types';
export const MM_TO_PT = 72 / 25.4;
/** Glyph metrics in AFM units (1/1000 of the font size). */
export interface FontMetrics {
xHeight: number;
capHeight: number;
}
export interface TextMeasurer {
/** Height in pt the element's text needs at the element's width and font. */
heightOfStringPt(text: string, element: LabelTextElement): number;
}
/** The pdfkit standard font a family/style combination prints with. */
export function pdfFontName(
family: LabelFontFamily,
bold: boolean,
italic: boolean,
): string {
const base =
family === 'serif' ? 'Times' : family === 'mono' ? 'Courier' : 'Helvetica';
if (base === 'Times') {
if (bold && italic) return 'Times-BoldItalic';
if (bold) return 'Times-Bold';
if (italic) return 'Times-Italic';
return 'Times-Roman';
}
const suffix =
bold && italic ? '-BoldOblique' : bold ? '-Bold' : italic ? '-Oblique' : '';
return `${base}${suffix}`;
}
/** Height in mm of a glyph class: size in pt x metric / 1000 x 25.4 / 72. */
export function heightMm(fontSizePt: number, metricPer1000: number): number {
return ((fontSizePt * metricPer1000) / 1000) * (25.4 / 72);
}
// pdfkit keeps the loaded font in the private field `_font`; this is the one
// place that reads it. font-metrics.spec.ts pins the values, so a pdfkit
// update that moves or renames the field fails there and nowhere else.
interface PdfDocumentInternals {
_font?: { xHeight?: unknown; capHeight?: unknown };
}
const metricsCache = new Map<string, FontMetrics>();
/** AFM x-height and cap height of a built-in PDF font. */
export function fontMetricsOf(
family: LabelFontFamily,
bold: boolean,
italic: boolean,
): FontMetrics {
const name = pdfFontName(family, bold, italic);
const cached = metricsCache.get(name);
if (cached) {
return cached;
}
const doc = new PDFDocument({ size: 'A4', margin: 0 });
doc.font(name);
const font = (doc as unknown as PdfDocumentInternals)._font;
const { xHeight, capHeight } = font ?? {};
if (
typeof xHeight !== 'number' ||
typeof capHeight !== 'number' ||
!(xHeight > 0) ||
!(capHeight > 0)
) {
throw new Error(`No AFM metrics for font ${name}`);
}
const metrics = { xHeight, capHeight };
metricsCache.set(name, metrics);
return metrics;
}
/** Measures text with a throw-away PDF document; nothing is written out. */
export function pdfTextMeasurer(): TextMeasurer {
const doc = new PDFDocument({ size: 'A4', margin: 0 });
return {
heightOfStringPt(text, element) {
if (text === '') {
return 0;
}
return doc
.font(pdfFontName(element.fontFamily, element.bold, element.italic))
.fontSize(element.fontSize)
.heightOfString(text, { width: element.width * MM_TO_PT });
},
};
}
@@ -0,0 +1,29 @@
import { UpdateLabelTemplateDto } from './dto/update-label-template.dto';
import { UpdateProductDto } from '../products/dto/update-product.dto';
import { UpdateSiteSettingsDto } from '../site-settings/dto/update-site-settings.dto';
import { describeNullContracts } from '../common/validation/null-contract.spec-helper';
// Only the fields T6 Task 5 adds; the older fields stay in
// update-dto-null-handling.spec.ts.
describeNullContracts('Label particulars null contracts', {
UpdateSiteSettingsDto: {
dto: UpdateSiteSettingsDto,
notNull: [],
clearable: [
'foodBusinessName',
'foodBusinessAddress',
'holderName',
'holderAddress',
],
},
UpdateProductDto: {
dto: UpdateProductDto,
notNull: [],
clearable: ['legalName'],
},
UpdateLabelTemplateDto: {
dto: UpdateLabelTemplateDto,
notNull: ['minXHeightMm'],
clearable: [],
},
});
@@ -0,0 +1,117 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { buildPlaceholderValues, labelDayOf } from './label-templates.service';
const SETTINGS = {
originCountry: 'Deutschland',
foodBusinessName: null,
foodBusinessAddress: null,
};
function values(bestBeforeDate: Date, date = bestBeforeDate) {
return buildPlaceholderValues(
{
jarSize: 500,
lotNumber: '2026-0001',
date,
bestBeforeDate,
honeyBatch: { batchNumber: 'HB-1', honeyType: null },
product: null,
},
SETTINGS,
);
}
// The expected days are literal Berlin days (CET in winter, CEST in summer),
// so the cases hold whatever zone the test process runs in.
describe('label days', () => {
it.each([
['midnight UTC, winter', '2027-03-01T00:00:00.000Z', '2027-03-01'],
['midnight UTC, summer', '2027-07-01T00:00:00.000Z', '2027-07-01'],
['23:30 UTC, winter', '2027-03-01T23:30:00.000Z', '2027-03-02'],
['22:30 UTC, summer', '2027-06-30T22:30:00.000Z', '2027-07-01'],
['21:30 UTC, summer', '2027-06-30T21:30:00.000Z', '2027-06-30'],
['22:30 UTC, winter', '2027-02-28T22:30:00.000Z', '2027-02-28'],
])('%s', (_name, instant, day) => {
expect(labelDayOf(new Date(instant))).toBe(day);
});
it('prints the best before day and line from the same day', () => {
const result = values(new Date('2027-03-01T23:30:00.000Z'));
expect(result.bestBeforeDate).toBe('02.03.2027');
expect(result.bestBeforeLine).toBe('Mindestens haltbar bis 02.03.2027');
});
it('prints the bottling date the same way', () => {
const result = values(
new Date('2028-03-01T00:00:00.000Z'),
new Date('2026-03-01T23:30:00.000Z'),
);
expect(result.bottlingDate).toBe('02.03.2026');
});
it('prints and checks the same day string', () => {
const bestBefore = new Date('2027-03-01T00:00:00.000Z');
const [year, month, day] = labelDayOf(bestBefore).split('-');
expect(values(bestBefore).bestBeforeDate).toBe(`${day}.${month}.${year}`);
});
});
// A jest worker cannot change its own time zone, so the guarantee is by
// construction: the date code must not read or build local-time dates. UTC
// getters (getUTCDate, ...) and formatters with an explicit timeZone are fine.
const FORBIDDEN = new RegExp(
[
'\\.(getDate|getDay|getMonth|getFullYear|getYear|getHours|getMinutes|getSeconds|getMilliseconds|getTimezoneOffset)\\(',
'\\.set(Hours|Date|Month|FullYear|Minutes|Seconds)\\(',
'\\.toDateString\\(',
'toLocaleTimeString\\(',
'toLocaleDateString\\((?![^)]*timeZone)',
'toLocaleString\\((?![^)]*timeZone)',
'new Intl\\.DateTimeFormat\\((?![^)]*timeZone)',
// new Date(y, m, d, ...): several arguments build a local-time date
'new Date\\((?:[^(),]|\\([^()]*\\))+,',
].join('|'),
);
describe('local-time guard', () => {
it.each([
'date.getDate()',
'date.getHours()',
'date.getTimezoneOffset()',
'date.getYear()',
'date.getMilliseconds()',
'date.setHours(0)',
'date.setDate(1)',
'date.toDateString()',
"date.toLocaleDateString('de-DE')",
"new Intl.DateTimeFormat('de-DE')",
'new Date(2027, 2, 1)',
])('flags %s', (sample) => {
expect(sample).toMatch(FORBIDDEN);
});
it.each([
"date.toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin' })",
"new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin' })",
'date.getUTCDate()',
'date.getTime()',
'new Date(Date.UTC(2027, 2, 1))',
'new Date(`${day}T00:00:00.000Z`)',
])('allows %s', (sample) => {
expect(sample).not.toMatch(FORBIDDEN);
});
});
describe('date sources avoid local-time APIs', () => {
it.each([
['label-templates.service.ts', __dirname],
['label-particulars.ts', __dirname],
['label-text.ts', __dirname],
['font-metrics.ts', __dirname],
['dates.ts', join(__dirname, '..', 'common')],
['format-day.ts', join(__dirname, '..', 'common')],
])('%s', (file, dir) => {
expect(readFileSync(join(dir, file), 'utf8')).not.toMatch(FORBIDDEN);
});
});
@@ -10,6 +10,12 @@ export const PLACEHOLDER_KEYS = [
'bestBeforeDate',
'jarSize',
'productName',
'legalName',
'originCountry',
'foodBusinessName',
'foodBusinessAddress',
'bestBeforeLine',
'lotLine',
] as const;
export type PlaceholderKey = (typeof PLACEHOLDER_KEYS)[number];
@@ -0,0 +1,855 @@
import { heightMm, TextMeasurer } from './font-metrics';
import {
checkLabel,
LabelCheckInput,
LabelFindingCode,
NetQuantityDigitRule,
SAMPLE_BOTTLING,
} from './label-particulars';
import { LabelTextElement, PlaceholderKey } from './label-element.types';
import { buildPlaceholderValues } from './label-templates.service';
// Mirrors the default of ComplianceSettings.netQuantityDigitRules.
const RULES: NetQuantityDigitRule[] = [
{ maxGrams: 50, minMm: 2 },
{ maxGrams: 200, minMm: 3 },
{ maxGrams: 1000, minMm: 4 },
{ maxGrams: null, minMm: 6 },
];
const SETTINGS = {
originCountry: 'Deutschland',
foodBusinessName: 'Imkerei Muster',
foodBusinessAddress: 'Bühlweg 10, 86450 Altenmünster',
};
const NO_CLIPPING: TextMeasurer = { heightOfStringPt: () => 0 };
function bottling(overrides: { jarSize?: number } = {}) {
return {
jarSize: overrides.jarSize ?? 500,
lotNumber: '2026-0001',
date: new Date('2026-03-05T00:00:00.000Z'),
bestBeforeDate: new Date('2028-03-05T00:00:00.000Z'),
honeyBatch: { batchNumber: 'HB-1', honeyType: 'Sommerhonig' },
product: { name: 'Sommerhonig 500 g', legalName: 'Blütenhonig' },
};
}
function textElement(
id: string,
key: PlaceholderKey | null,
y: number,
overrides: Partial<LabelTextElement> = {},
): LabelTextElement {
return {
id,
type: 'text',
x: 2,
y,
width: 80,
height: 10,
text: '',
placeholderKey: key,
fontSize: 10,
fontFamily: 'sans',
bold: false,
italic: false,
color: '#000000',
align: 'left',
...overrides,
};
}
// 20 pt Helvetica caps are 5.07 mm, above every default rule up to 1000 g.
function fullLabel(): LabelTextElement[] {
return [
textElement('legal', 'legalName', 2),
textElement('net', 'jarSize', 14, { fontSize: 20 }),
textElement('bb', 'bestBeforeLine', 26),
textElement('lot', 'lotLine', 38),
textElement('origin', 'originCountry', 50),
textElement('biz-name', 'foodBusinessName', 62),
textElement('biz-address', 'foodBusinessAddress', 74),
];
}
function input(overrides: Partial<LabelCheckInput> = {}): LabelCheckInput {
const jarSize = 500;
return {
elements: fullLabel(),
minXHeightMm: 1.2,
digitRules: RULES,
values: buildPlaceholderValues(bottling({ jarSize }), SETTINGS),
expected: {
jarSizeGrams: jarSize,
bestBefore: '2028-03-05',
lotNumber: '2026-0001',
originCountry: 'Deutschland',
foodBusinessName: SETTINGS.foodBusinessName,
foodBusinessAddress: SETTINGS.foodBusinessAddress,
},
sample: false,
measurer: NO_CLIPPING,
...overrides,
};
}
function codes(findings: { code: LabelFindingCode }[]): LabelFindingCode[] {
return findings.map((finding) => finding.code);
}
function without(...ids: string[]): LabelTextElement[] {
return fullLabel().filter((element) => !ids.includes(element.id));
}
describe('checkLabel', () => {
it('passes a label with all particulars', () => {
const result = checkLabel(input());
expect(result.gaps).toEqual([]);
expect(result.warnings).toEqual([]);
expect(result.printable).toBe(true);
expect(result.sample).toBe(false);
});
it.each([
['LEGAL_NAME', 'legal'],
['NET_QUANTITY', 'net'],
['BEST_BEFORE', 'bb'],
['LOT', 'lot'],
['ORIGIN', 'origin'],
['FOOD_BUSINESS', 'biz-name'],
] as const)('reports a missing %s by its code', (code, elementId) => {
const elements =
code === 'FOOD_BUSINESS'
? without('biz-name', 'biz-address')
: without(elementId);
const result = checkLabel(input({ elements }));
expect(codes(result.gaps)).toEqual([code]);
expect(result.printable).toBe(false);
expect(result.gaps[0].title).not.toBe('');
expect(result.gaps[0].detail).not.toBe('');
});
it('matches the address across line breaks and separators', () => {
const elements = [
...without('biz-name', 'biz-address'),
textElement('biz', null, 62, {
text: 'Imkerei Muster\nBühlweg 10 · 86450 Altenmünster',
}),
];
expect(checkLabel(input({ elements })).gaps).toEqual([]);
});
it('matches an address that sits on two lines of one element', () => {
const elements = [
...without('biz-name', 'biz-address'),
textElement('biz', null, 62, {
text: 'Imkerei Muster, Bühlweg 10\n86450 Altenmünster',
}),
];
expect(checkLabel(input({ elements })).gaps).toEqual([]);
});
it('flags a static "500 g" on a 250 g bottling', () => {
const elements = [
...without('net'),
textElement('net', null, 14, { text: 'Inhalt 500 g', fontSize: 20 }),
];
const result = checkLabel(
input({
elements,
values: buildPlaceholderValues(bottling({ jarSize: 250 }), SETTINGS),
expected: { ...input().expected, jarSizeGrams: 250 },
}),
);
expect(codes(result.gaps)).toEqual(['NET_QUANTITY']);
});
it('accepts 250,5 g for a 250.5 g jar and "1 kg" for a 1000 g jar', () => {
const decimal = checkLabel(
input({
values: { ...input().values, jarSize: '250,5 g' },
expected: { ...input().expected, jarSizeGrams: 250.5 },
}),
);
expect(codes(decimal.gaps)).toEqual([]);
const dot = checkLabel(
input({
values: { ...input().values, jarSize: '250.5 g' },
expected: { ...input().expected, jarSizeGrams: 250.5 },
}),
);
expect(codes(dot.gaps)).toEqual([]);
const kilogram = checkLabel(
input({
values: { ...input().values, jarSize: '1 kg' },
expected: { ...input().expected, jarSizeGrams: 1000 },
}),
);
expect(codes(kilogram.gaps)).toEqual([]);
});
it('does not accept 1250 g for 250 g', () => {
const result = checkLabel(
input({
values: { ...input().values, jarSize: '1250 g' },
expected: { ...input().expected, jarSizeGrams: 250 },
}),
);
expect(codes(result.gaps)).toEqual(['NET_QUANTITY']);
});
it('does not accept "1 kg" for a jar that is not a full kilogram', () => {
const result = checkLabel(
input({
values: { ...input().values, jarSize: '1 kg' },
expected: { ...input().expected, jarSizeGrams: 1250 },
}),
);
expect(codes(result.gaps)).toEqual(['NET_QUANTITY']);
});
describe('best before', () => {
function withBestBefore(text: string): LabelTextElement[] {
return [...without('bb'), textElement('bb', null, 26, { text })];
}
it('needs the wording and the date, leading zeros do not matter', () => {
for (const text of [
'Mindestens haltbar bis 05.03.2028',
'Mindestens haltbar bis 5.3.2028',
'mindestens HALTBAR bis 5. 03. 2028',
]) {
expect(
checkLabel(input({ elements: withBestBefore(text) })).gaps,
).toEqual([]);
}
// wording without the date, date without the wording, a wrong date
for (const text of [
'Mindestens haltbar bis',
'MHD 05.03.2028',
'Mindestens haltbar bis 06.03.2028',
'Mindestens haltbar bis 05.03.2029',
]) {
expect(
codes(checkLabel(input({ elements: withBestBefore(text) })).gaps),
).toEqual(['BEST_BEFORE']);
}
});
it('accepts the wording and the date in separate elements', () => {
const elements = [
...without('bb'),
textElement('bb-label', null, 26, { text: 'Mindestens haltbar bis' }),
textElement('bb-date', 'bestBeforeDate', 32),
];
expect(checkLabel(input({ elements })).gaps).toEqual([]);
});
});
it('treats clipped text as missing and warns TEXT_CLIPPED', () => {
const measurer: TextMeasurer = {
heightOfStringPt: (_text, element) => (element.id === 'lot' ? 999 : 0),
};
const result = checkLabel(input({ measurer }));
expect(codes(result.gaps)).toEqual(['LOT']);
expect(codes(result.warnings)).toEqual(['TEXT_CLIPPED']);
expect(result.warnings[0].elementIds).toEqual(['lot']);
expect(result.printable).toBe(false);
});
it('clips with the real measurer when the box is too short for the text', () => {
const elements = [
...without('lot'),
textElement('lot', 'lotLine', 38, { height: 1 }),
];
const result = checkLabel(input({ elements, measurer: undefined }));
expect(codes(result.gaps)).toEqual(['LOT']);
expect(codes(result.warnings)).toContain('TEXT_CLIPPED');
});
it('does not warn TEXT_CLIPPED for clipped text that carries nothing', () => {
const measurer: TextMeasurer = {
heightOfStringPt: (_text, element) => (element.id === 'deco' ? 999 : 0),
};
const elements = [
...fullLabel(),
textElement('deco', null, 90, { text: 'Mit Liebe gemacht' }),
];
const result = checkLabel(input({ elements, measurer }));
expect(result.warnings).toEqual([]);
});
describe('FOOD_BUSINESS', () => {
it('is a gap when either master data field is empty', () => {
for (const expected of [
{ foodBusinessName: null },
{ foodBusinessAddress: null },
{ foodBusinessName: ' ' },
{ foodBusinessAddress: '' },
]) {
const result = checkLabel(
input({ expected: { ...input().expected, ...expected } }),
);
expect(codes(result.gaps)).toEqual(['FOOD_BUSINESS']);
}
});
it('is a gap when only the name is on the label', () => {
const result = checkLabel(input({ elements: without('biz-address') }));
expect(codes(result.gaps)).toEqual(['FOOD_BUSINESS']);
});
});
it('a label without product legalName is complete only with a fixed text containing "honig"', () => {
const noLegalName = {
...input().values,
legalName: '',
};
expect(codes(checkLabel(input({ values: noLegalName })).gaps)).toEqual([
'LEGAL_NAME',
]);
const elements = [
...fullLabel(),
textElement('fixed', null, 90, { text: 'Echter Blütenhonig' }),
];
expect(checkLabel(input({ elements, values: noLegalName })).gaps).toEqual(
[],
);
});
describe('x-height', () => {
function withOtherSize(fontSize: number): LabelTextElement[] {
return [
...without('origin'),
textElement('origin', 'originCountry', 50, { fontSize }),
];
}
it('Helvetica 6 pt is x-height 1.11 mm (warning), 7 pt is 1.29 mm', () => {
const small = checkLabel(input({ elements: withOtherSize(6) }));
expect(small.gaps).toEqual([]);
expect(codes(small.warnings)).toEqual(['X_HEIGHT']);
expect(small.warnings[0].elementIds).toEqual(['origin']);
expect(small.warnings[0].title).toBe('Text zu klein (x-Höhe 1,1 mm)');
expect(small.printable).toBe(true);
const fine = checkLabel(input({ elements: withOtherSize(7) }));
expect(fine.warnings).toEqual([]);
});
it('x-height exactly at the limit is fine', () => {
const result = checkLabel(
input({ elements: withOtherSize(6), minXHeightMm: heightMm(6, 523) }),
);
expect(result.warnings).toEqual([]);
});
it('only checks elements that carry a particular', () => {
const elements = [
...fullLabel(),
textElement('deco', null, 90, { text: 'Mit Liebe', fontSize: 4 }),
];
expect(checkLabel(input({ elements })).warnings).toEqual([]);
});
});
describe('net quantity height', () => {
function withNetSize(fontSize: number): LabelTextElement[] {
return [
...without('net'),
textElement('net', 'jarSize', 14, { fontSize }),
];
}
it('needs 4 mm digits for 500 g with the default rules (15.8 pt)', () => {
const ok = checkLabel(input({ elements: withNetSize(15.8) }));
expect(ok.warnings).toEqual([]);
const small = checkLabel(input({ elements: withNetSize(15.7) }));
expect(codes(small.warnings)).toEqual(['NET_QUANTITY_HEIGHT']);
expect(small.warnings[0].elementIds).toEqual(['net']);
expect(small.printable).toBe(true);
});
it.each([
[50, 2],
[49.5, 2],
[200, 3],
[200.5, 4],
[1000, 4],
[1001, 6],
[5000, 6],
])(
'uses the rule of the matching weight class for %s g (%s mm)',
(grams, minMm) => {
const result = checkLabel(
input({ expected: { ...input().expected, jarSizeGrams: grams } }),
);
expect(result.requirements.netQuantityMinCapHeightMm).toBe(minMm);
},
);
it('uses the open class above the largest weight class', () => {
const result = checkLabel(
input({
values: { ...input().values, jarSize: '2 kg' },
expected: { ...input().expected, jarSizeGrams: 2000 },
}),
);
// 20 pt caps are 5.07 mm, the open class wants 6 mm
expect(codes(result.warnings)).toEqual(['NET_QUANTITY_HEIGHT']);
});
it('is satisfied when one of several mentions is large enough', () => {
const elements = [
...fullLabel(),
textElement('net-small', null, 90, {
text: 'Inhalt 500 g',
fontSize: 8,
}),
];
expect(checkLabel(input({ elements })).warnings).toEqual([]);
});
it('sorts the rules itself and has no requirement without rules', () => {
const shuffled = [RULES[3], RULES[1], RULES[0], RULES[2]];
expect(
checkLabel(input({ digitRules: shuffled })).requirements
.netQuantityMinCapHeightMm,
).toBe(4);
const none = checkLabel(input({ digitRules: [] }));
expect(none.requirements.netQuantityMinCapHeightMm).toBeNull();
expect(none.warnings).toEqual([]);
});
});
describe('lot prefix', () => {
it('warns on a lot without a leading L', () => {
const elements = [
...without('lot'),
textElement('lot', null, 38, { text: 'Los 2026-0001' }),
];
const result = checkLabel(input({ elements }));
expect(result.gaps).toEqual([]);
expect(codes(result.warnings)).toEqual(['LOT_PREFIX']);
});
it('accepts "L 2026-0001" and "L2026-0001"', () => {
for (const text of ['L 2026-0001', 'L2026-0001', 'Ch.-B. L: 2026-0001']) {
const elements = [
...without('lot'),
textElement('lot', null, 38, { text }),
];
expect(codes(checkLabel(input({ elements })).warnings)).not.toContain(
'LOT_PREFIX',
);
}
});
it('does not add LOT_PREFIX when the lot is missing altogether', () => {
const result = checkLabel(input({ elements: without('lot') }));
expect(codes(result.gaps)).toEqual(['LOT']);
expect(result.warnings).toEqual([]);
});
});
it('a warning alone leaves printable true', () => {
const elements = [
...without('origin'),
textElement('origin', 'originCountry', 50, { fontSize: 6 }),
];
const result = checkLabel(input({ elements }));
expect(result.warnings.length).toBeGreaterThan(0);
expect(result.gaps).toEqual([]);
expect(result.printable).toBe(true);
});
it('sample result carries sample true', () => {
expect(checkLabel(input({ sample: true })).sample).toBe(true);
});
it('requirements carry minXHeightMm and the digit height of the jar size', () => {
const result = checkLabel(input({ minXHeightMm: 1.5 }));
expect(result.requirements).toEqual({
minXHeightMm: 1.5,
netQuantityMinCapHeightMm: 4,
});
});
it('an empty label reports every mandatory particular in a fixed order', () => {
const result = checkLabel(input({ elements: [] }));
expect(codes(result.gaps)).toEqual([
'LEGAL_NAME',
'NET_QUANTITY',
'BEST_BEFORE',
'LOT',
'ORIGIN',
'FOOD_BUSINESS',
]);
});
});
describe('checkLabel: robustness against odd layouts', () => {
it('does not take "1,250 g" for 250 g', () => {
const result = checkLabel(
input({
values: { ...input().values, jarSize: '1,250 g' },
expected: { ...input().expected, jarSizeGrams: 250 },
}),
);
expect(codes(result.gaps)).toEqual(['NET_QUANTITY']);
});
it('does not take "250,5 g" for 5 g, but "Inhalt 6 x 500 g" for 500 g', () => {
expect(
codes(
checkLabel(
input({
values: { ...input().values, jarSize: '250,5 g' },
expected: { ...input().expected, jarSizeGrams: 5 },
}),
).gaps,
),
).toEqual(['NET_QUANTITY']);
expect(
checkLabel(
input({ values: { ...input().values, jarSize: 'Inhalt 6 x 500 g' } }),
).gaps,
).toEqual([]);
});
it('does not warn LOT_PREFIX for a lot that already starts with L', () => {
const lotNumber = 'L-001';
const elements = [
...without('lot'),
textElement('lot', null, 38, { text: 'L-001' }),
];
const result = checkLabel(
input({ elements, expected: { ...input().expected, lotNumber } }),
);
expect(result.gaps).toEqual([]);
expect(codes(result.warnings)).not.toContain('LOT_PREFIX');
});
describe('an address split over elements', () => {
it('is found with another element sorted in between', () => {
const elements = [
...without('biz-name', 'biz-address'),
textElement('biz-1', null, 62, {
text: 'Imkerei Muster, Bühlweg 10',
}),
textElement('between', null, 68, { text: 'Mit Liebe gemacht' }),
textElement('biz-2', null, 74, { text: '86450 Altenmünster' }),
];
expect(checkLabel(input({ elements })).gaps).toEqual([]);
});
it('is found when the parts differ slightly in y', () => {
const elements = [
...without('biz-name', 'biz-address'),
textElement('biz-name', 'foodBusinessName', 62),
textElement('biz-right', null, 70, {
x: 40,
text: '86450 Altenmünster',
}),
textElement('biz-left', null, 70.4, { text: 'Bühlweg 10' }),
];
expect(checkLabel(input({ elements })).gaps).toEqual([]);
});
it('is still a gap when a part is really missing', () => {
const elements = [
...without('biz-name', 'biz-address'),
textElement('biz-1', null, 62, {
text: 'Imkerei Muster, Bühlweg 10',
}),
textElement('between', null, 68, { text: 'Mit Liebe gemacht' }),
];
expect(codes(checkLabel(input({ elements })).gaps)).toEqual([
'FOOD_BUSINESS',
]);
});
});
});
describe('checkLabel: round 2', () => {
describe('the digit-before guard is per element', () => {
it('ignores a lot number element right before the quantity', () => {
const elements = [
...without('lot', 'net'),
textElement('lot', null, 38, { x: 2, width: 30, text: 'L 2026-0001' }),
textElement('net', null, 38, {
x: 40,
width: 30,
fontSize: 20,
text: '500 g',
}),
];
const result = checkLabel(input({ elements }));
expect(codes(result.gaps)).toEqual([]);
});
it('ignores a date element right before the quantity', () => {
const elements = [
...without('bb', 'net'),
textElement('bb', null, 26, {
x: 2,
width: 50,
text: 'Mindestens haltbar bis 05.03.2028',
}),
textElement('net', null, 26, {
x: 60,
width: 20,
fontSize: 20,
text: '500 g',
}),
];
const result = checkLabel(input({ elements }));
expect(codes(result.gaps)).toEqual([]);
});
it('still rejects "1,250 g" inside one element', () => {
const elements = [
...without('net'),
textElement('net', null, 14, { fontSize: 20, text: 'Inhalt 1,250 g' }),
];
const result = checkLabel(
input({
elements,
expected: { ...input().expected, jarSizeGrams: 250 },
}),
);
expect(codes(result.gaps)).toEqual(['NET_QUANTITY']);
});
});
it('accepts a number glued to its unit ("250g")', () => {
const result = checkLabel(
input({
values: { ...input().values, jarSize: '250g' },
expected: { ...input().expected, jarSizeGrams: 250 },
}),
);
expect(codes(result.gaps)).toEqual([]);
});
describe('a split address is bounded', () => {
function split(
street: { y: number; fontSize?: number },
town: { y: number; fontSize?: number },
): LabelTextElement[] {
return [
...without('biz-name', 'biz-address'),
textElement('biz-name', 'foodBusinessName', 56),
textElement('street', null, street.y, {
text: 'Bühlweg 10',
fontSize: street.fontSize ?? 10,
height: 5,
}),
textElement('town', null, town.y, {
text: '86450 Altenmünster',
fontSize: town.fontSize ?? 10,
height: 5,
}),
];
}
it('is a gap when the town sits in a footer far away', () => {
const result = checkLabel(
input({ elements: split({ y: 62 }, { y: 150 }) }),
);
expect(codes(result.gaps)).toEqual(['FOOD_BUSINESS']);
});
it('is found when the town sits directly above the street', () => {
const result = checkLabel(
input({ elements: split({ y: 70 }, { y: 62 }) }),
);
expect(result.gaps).toEqual([]);
});
it('lets the x-height warning reach the parts of the match', () => {
const result = checkLabel(
input({ elements: split({ y: 62 }, { y: 68, fontSize: 6 }) }),
);
expect(result.gaps).toEqual([]);
const warning = result.warnings.find((w) => w.code === 'X_HEIGHT');
expect(warning?.elementIds).toEqual(['town']);
});
it('names a clipped part in the gap and in TEXT_CLIPPED', () => {
const measurer: TextMeasurer = {
heightOfStringPt: (_text, element) => (element.id === 'town' ? 999 : 0),
};
const result = checkLabel(
input({ elements: split({ y: 62 }, { y: 68 }), measurer }),
);
expect(codes(result.gaps)).toEqual(['FOOD_BUSINESS']);
expect(result.gaps[0].elementIds).toEqual(['town']);
const clipped = result.warnings.find((w) => w.code === 'TEXT_CLIPPED');
expect(clipped?.elementIds).toEqual(['town']);
});
});
});
describe('checkLabel: round 3', () => {
function addressParts(
street: { y: number; x?: number; text?: string },
town: { y: number; x?: number },
): LabelTextElement[] {
return [
textElement('street', null, street.y, {
x: street.x ?? 2,
text: street.text ?? 'Bühlweg 10',
height: 5,
}),
textElement('town', null, town.y, {
x: town.x ?? 2,
text: '86450 Altenmünster',
height: 5,
}),
];
}
const base = () => [
...without('biz-name', 'biz-address'),
textElement('biz-name', 'foodBusinessName', 56),
];
it('reading order comes from the geometry, not from the array order', () => {
// a far away footer town FIRST in the array is still a gap
const far = [...addressParts({ y: 62 }, { y: 150 }).reverse(), ...base()];
expect(codes(checkLabel(input({ elements: far })).gaps)).toEqual([
'FOOD_BUSINESS',
]);
// street/town in reverse array order but adjacent on the label
const adjacent = [
...addressParts({ y: 62 }, { y: 68 }).reverse(),
...base(),
];
expect(checkLabel(input({ elements: adjacent })).gaps).toEqual([]);
});
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
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,
}),
];
expect(checkLabel(input({ elements })).gaps).toEqual([]);
});
describe('number and unit in two elements', () => {
function split(numberSize: number, unitSize: number): LabelTextElement[] {
return [
...without('net'),
textElement('num', null, 14, {
x: 2,
width: 20,
fontSize: numberSize,
text: '500',
}),
textElement('unit', null, 14, {
x: 24,
width: 10,
fontSize: unitSize,
text: 'g',
}),
];
}
it('is found; the height warning looks at the parts', () => {
const big = checkLabel(input({ elements: split(20, 20) }));
expect(big.gaps).toEqual([]);
expect(codes(big.warnings)).toEqual([]);
const small = checkLabel(input({ elements: split(8, 8) }));
expect(small.gaps).toEqual([]);
const warning = small.warnings.find(
(w) => w.code === 'NET_QUANTITY_HEIGHT',
);
expect(warning?.elementIds).toEqual(['num', 'unit']);
});
it('a big number next to a small unit counts the unit for x-height', () => {
const result = checkLabel(input({ elements: split(20, 6) }));
expect(result.gaps).toEqual([]);
const xHeight = result.warnings.find((w) => w.code === 'X_HEIGHT');
expect(xHeight?.elementIds).toEqual(['unit']);
expect(codes(result.warnings)).not.toContain('NET_QUANTITY_HEIGHT');
});
it('does not join across rows or onto a bigger number', () => {
const otherRow = [
...without('net'),
textElement('num', null, 14, { text: '500' }),
textElement('unit', null, 30, { text: 'g' }),
];
expect(codes(checkLabel(input({ elements: otherRow })).gaps)).toEqual([
'NET_QUANTITY',
]);
const bigger = [
...without('net'),
textElement('num', null, 14, { width: 20, text: '1,500' }),
textElement('unit', null, 14, { x: 24, width: 10, text: 'g' }),
];
const result = checkLabel(
input({
elements: bigger,
expected: { ...input().expected, jarSizeGrams: 500 },
}),
);
expect(codes(result.gaps)).toEqual(['NET_QUANTITY']);
});
});
it('gives only the elements of the match to the address carriers', () => {
const elements = [
...base(),
textElement('lot-ref', null, 62, {
x: 2,
width: 20,
fontSize: 5,
text: 'Lot 10',
}),
textElement('street', null, 62, {
x: 30,
width: 25,
text: 'Bühlweg 10',
height: 5,
}),
textElement('town', null, 62, {
x: 60,
width: 25,
text: '86450 Altenmünster',
height: 5,
}),
];
const result = checkLabel(input({ elements }));
expect(result.gaps).toEqual([]);
expect(result.warnings.filter((w) => w.code === 'X_HEIGHT')).toEqual([]);
});
});
describe('SAMPLE_BOTTLING', () => {
it('carries example values only for the preview', () => {
expect(SAMPLE_BOTTLING.jarSize).toBe(500);
expect(SAMPLE_BOTTLING.lotNumber).toBe('2026-0001');
const values = buildPlaceholderValues(SAMPLE_BOTTLING, SETTINGS);
expect(values.jarSize).toBe('500 g');
expect(values.lotLine).toBe('L 2026-0001');
});
});
@@ -0,0 +1,638 @@
import {
fontMetricsOf,
heightMm,
MM_TO_PT,
pdfTextMeasurer,
TextMeasurer,
} from './font-metrics';
import { LabelTextElement, PlaceholderKey } from './label-element.types';
import {
lotStartsWithL,
normalizeForCheck,
resolvedTexts,
ROW_TOLERANCE_MM,
} from './label-text';
export type LabelFindingCode =
| 'LEGAL_NAME'
| 'NET_QUANTITY'
| 'BEST_BEFORE'
| 'LOT'
| 'ORIGIN'
| 'FOOD_BUSINESS'
| 'LOT_PREFIX'
| 'X_HEIGHT'
| 'NET_QUANTITY_HEIGHT'
| 'TEXT_CLIPPED';
export interface LabelFinding {
code: LabelFindingCode;
title: string;
detail: string;
elementIds: string[];
}
export interface LabelCheckResult {
printable: boolean;
gaps: LabelFinding[];
warnings: LabelFinding[];
sample: boolean;
/** The legal values the check used, so the frontend needs no own copy. */
requirements: {
minXHeightMm: number;
netQuantityMinCapHeightMm: number | null;
};
}
export interface NetQuantityDigitRule {
maxGrams: number | null;
minMm: number;
}
export interface LabelCheckInput {
elements: readonly LabelTextElement[];
minXHeightMm: number;
digitRules: readonly NetQuantityDigitRule[];
values: Record<PlaceholderKey, string>;
expected: {
jarSizeGrams: number;
/** YYYY-MM-DD */
bestBefore: string;
lotNumber: string;
originCountry: string;
foodBusinessName: string | null;
foodBusinessAddress: string | null;
};
sample: boolean;
measurer?: TextMeasurer;
}
/**
* Example values for the preview when no bottling is chosen. They only fill
* the template so it can be shown; they are not legal values.
*/
export const SAMPLE_BOTTLING = Object.freeze({
jarSize: 500 as const,
lotNumber: '2026-0001' as const,
date: new Date('2026-07-01T00:00:00.000Z'),
bestBeforeDate: new Date('2028-07-01T00:00:00.000Z'),
honeyBatch: Object.freeze({
batchNumber: 'HB-2026-001',
honeyType: 'Sommerhonig',
}),
product: Object.freeze({
name: 'Sommerhonig 500 g',
legalName: 'Sommerhonig',
}),
});
// A line that is at most half a point too tall still prints (spec 5.4).
const CLIP_TOLERANCE_PT = 0.5;
// Floating point slack for the "at least" comparisons.
const HEIGHT_EPSILON_MM = 1e-9;
const BEST_BEFORE_WORDING = 'mindestens haltbar bis';
const LEGAL_NAME_WORD = 'honig';
interface Candidate {
element: LabelTextElement;
normalized: string;
clipped: boolean;
}
interface Particular {
code: Extract<
LabelFindingCode,
| 'LEGAL_NAME'
| 'NET_QUANTITY'
| 'BEST_BEFORE'
| 'LOT'
| 'ORIGIN'
| 'FOOD_BUSINESS'
>;
/** Is the particular on the label, judged on the whole joined text? */
isOnLabel(views: TextViews): boolean;
/** Phrases that may be spread over several elements. */
phrases?: string[];
/** Matches spread over two elements, e.g. "500" | "g" (number first). */
pairs?(views: TextViews): Candidate[][];
/** Does one element's text hold (a part of) the particular? */
isCarriedBy(normalized: string): boolean;
missing: { title: string; detail: string };
}
/**
* The label text in the ways a particular may be spread over elements: every
* element alone, all in reading order, row by row, and chains of nearby
* elements that share a word with the phrase looked for.
*/
interface TextViews {
visible: Candidate[];
/** Normalized text of each visible element on its own. */
elements: string[];
full: string;
/** Reading order, cut where two neighbours are far apart. */
blocks: Candidate[][];
rows: Candidate[][];
chains(normalizedPhrase: string): Candidate[][];
}
// Parts of a split phrase must be at most this far apart (edge to edge).
const MAX_JOIN_GAP_MM = 10;
function hasPhrase(
normalizedText: string,
normalizedPhrase: string,
notAfterDigit = false,
): boolean {
if (normalizedPhrase === '') {
return false;
}
const padded = ` ${normalizedText} `;
const needle = ` ${normalizedPhrase} `;
let index = padded.indexOf(needle);
while (index !== -1) {
if (!notAfterDigit) {
return true;
}
// "1,250 g" is not "250 g": a number token right before the amount means
// the amount is only the tail of a bigger number. Only ever applied to
// one element's text: a digit from a neighbouring element (lot, date)
// says nothing about the amount.
const before = padded.slice(0, index).split(' ').pop() ?? '';
if (!/^\d+$/.test(before)) {
return true;
}
index = padded.indexOf(needle, index + 1);
}
return false;
}
function joined(candidates: readonly Candidate[]): string {
return candidates
.map((candidate) => candidate.normalized)
.filter((text) => text !== '')
.join(' ');
}
function sharesWord(normalizedText: string, normalizedPhrase: string): boolean {
const words = new Set(normalizedPhrase.split(' '));
return normalizedText.split(' ').some((word) => words.has(word));
}
interface Span {
top: number;
bottom: number;
}
// Symmetric vertical distance of an element to a span of elements: 0 when
// they overlap vertically, whichever lies above.
function distanceTo(span: Span, candidate: Candidate): number {
const { y, height } = candidate.element;
return Math.max(0, y - span.bottom, span.top - (y + height));
}
/**
* Cuts a list (already in reading order) wherever the next element is more
* than MAX_JOIN_GAP_MM away from everything gathered so far in the group
* (the running top and bottom of the group, not only the previous element).
*
* Known limits, accepted: horizontal distance is ignored, and an unrelated
* element that happens to share a word with the phrase can sit inside a chain.
*/
function groupByDistance(list: Candidate[]): Candidate[][] {
const groups: Candidate[][] = [];
let span: Span = { top: 0, bottom: 0 };
for (const candidate of list) {
const group = groups[groups.length - 1];
if (group && distanceTo(span, candidate) <= MAX_JOIN_GAP_MM) {
group.push(candidate);
span = {
top: Math.min(span.top, candidate.element.y),
bottom: Math.max(
span.bottom,
candidate.element.y + candidate.element.height,
),
};
} else {
groups.push([candidate]);
span = {
top: candidate.element.y,
bottom: candidate.element.y + candidate.element.height,
};
}
}
return groups;
}
function textViews(visible: Candidate[]): TextViews {
const rows: Candidate[][] = [];
let rowTop = Number.NEGATIVE_INFINITY;
for (const candidate of visible) {
if (candidate.element.y - rowTop > ROW_TOLERANCE_MM) {
rows.push([]);
rowTop = candidate.element.y;
}
rows[rows.length - 1].push(candidate);
}
return {
visible,
elements: visible.map((candidate) => candidate.normalized),
full: joined(visible),
blocks: groupByDistance(visible),
rows,
chains: (phrase) =>
groupByDistance(
visible.filter((candidate) => sharesWord(candidate.normalized, phrase)),
),
};
}
/**
* The elements of `group` that make up the first occurrence of the phrase in
* their joined text, or null. Only elements overlapping the occurrence
* count, not neighbours that merely sit in the same group.
*/
function matchParts(group: Candidate[], phrase: string): Candidate[] | null {
const parts = group.filter((candidate) => candidate.normalized !== '');
const text = parts.map((candidate) => candidate.normalized).join(' ');
const start = ` ${text} `.indexOf(` ${phrase} `);
if (phrase === '' || start === -1) {
return null;
}
const end = start + phrase.length;
let offset = 0;
const hit: Candidate[] = [];
for (const part of parts) {
const partEnd = offset + part.normalized.length;
if (offset < end && partEnd > start) {
hit.push(part);
}
offset = partEnd + 1;
}
return hit;
}
/**
* The parts of every group whose joined text holds the phrase: a block of
* neighbours in reading order, one row, or a chain of nearby related elements
* (a chain also in reverse order: a short address printed town-first is still
* printed together).
*/
function groupsHolding(views: TextViews, phrase: string): Candidate[][] {
const forward = [...views.blocks, ...views.rows, ...views.chains(phrase)];
const reversed = views.chains(phrase).map((chain) => [...chain].reverse());
return [...forward, ...reversed]
.map((group) => matchParts(group, phrase))
.filter((parts): parts is Candidate[] => parts !== null);
}
/**
* "500" | "g": an element ending in the amount (not glued to a bigger
* number) followed, in the same row, by an element starting with the unit.
*/
function quantityPairs(views: TextViews, needles: string[]): Candidate[][] {
const pairs: Candidate[][] = [];
views.visible.forEach((first, index) => {
const second = views.visible[index + 1];
if (
!second ||
Math.abs(second.element.y - first.element.y) > ROW_TOLERANCE_MM
) {
return;
}
const tokens = first.normalized.split(' ');
const unitStart = second.normalized.split(' ')[0];
for (const needle of needles) {
const words = needle.split(' ');
const unit = words.pop();
const amount = tokens.slice(-words.length);
const before = tokens[tokens.length - words.length - 1] ?? '';
if (
words.length > 0 &&
unit === unitStart &&
amount.join(' ') === words.join(' ') &&
!/^\d+$/.test(before)
) {
pairs.push([first, second]);
return;
}
}
});
return pairs;
}
function formatDecimal(value: number): string {
return value.toFixed(1).replace('.', ',');
}
function bestBeforeDatePattern(bestBefore: string): RegExp {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(bestBefore);
if (!match) {
throw new Error('bestBefore must be a YYYY-MM-DD date');
}
const [, year, month, day] = match;
return new RegExp(`(^| )0?${Number(day)} 0?${Number(month)} ${year}( |$)`);
}
function netQuantityNeedles(jarSizeGrams: number): string[] {
const needles = [normalizeForCheck(`${jarSizeGrams} g`)];
if (jarSizeGrams > 0 && jarSizeGrams % 1000 === 0) {
needles.push(normalizeForCheck(`${jarSizeGrams / 1000} kg`));
}
return needles;
}
function particulars(expected: LabelCheckInput['expected']): Particular[] {
const netNeedles = netQuantityNeedles(expected.jarSizeGrams);
const datePattern = bestBeforeDatePattern(expected.bestBefore);
const lot = normalizeForCheck(expected.lotNumber);
const origin = normalizeForCheck(expected.originCountry);
const businessName = normalizeForCheck(expected.foodBusinessName ?? '');
const businessAddress = normalizeForCheck(expected.foodBusinessAddress ?? '');
const hasBusinessData = businessName !== '' && businessAddress !== '';
const hasHoneyWord = (text: string) =>
text.split(' ').some((word) => word.includes(LEGAL_NAME_WORD));
return [
{
code: 'LEGAL_NAME',
isOnLabel: (views) => hasHoneyWord(views.full),
isCarriedBy: hasHoneyWord,
missing: {
title: 'Verkehrsbezeichnung fehlt',
detail:
'Die Bezeichnung des Lebensmittels fehlt. Sie muss das Wort „Honig“ enthalten, zum Beispiel „Blütenhonig“. Tragen Sie sie am Artikel ein oder setzen Sie einen festen Text.',
},
},
{
code: 'NET_QUANTITY',
pairs: (views) => quantityPairs(views, netNeedles),
isOnLabel: (views) =>
views.elements.some((text) =>
netNeedles.some((needle) => hasPhrase(text, needle, true)),
) || quantityPairs(views, netNeedles).length > 0,
isCarriedBy: (text) =>
netNeedles.some((needle) => hasPhrase(text, needle, true)),
missing: {
title: 'Füllmenge fehlt',
detail: `Die Füllmenge dieser Abfüllung (${String(expected.jarSizeGrams).replace('.', ',')} g) steht nicht auf dem Etikett. Ein fester Text mit einer anderen Menge zählt nicht.`,
},
},
{
code: 'BEST_BEFORE',
isOnLabel: ({ full }) =>
hasPhrase(full, BEST_BEFORE_WORDING) && datePattern.test(full),
isCarriedBy: (text) =>
hasPhrase(text, BEST_BEFORE_WORDING) || datePattern.test(text),
missing: {
title: 'Mindesthaltbarkeitsdatum fehlt',
detail:
'Es braucht den Text „Mindestens haltbar bis“ und das Datum dieser Abfüllung.',
},
},
{
code: 'LOT',
isOnLabel: (views) => hasPhrase(views.full, lot),
isCarriedBy: (text) => hasPhrase(text, lot),
missing: {
title: 'Losnummer fehlt',
detail: 'Die Losnummer dieser Abfüllung steht nicht auf dem Etikett.',
},
},
{
code: 'ORIGIN',
isOnLabel: (views) => hasPhrase(views.full, origin),
isCarriedBy: (text) => hasPhrase(text, origin),
missing: {
title: 'Herkunftsland fehlt',
detail:
'Das Herkunftsland aus den Einstellungen steht nicht auf dem Etikett.',
},
},
{
code: 'FOOD_BUSINESS',
phrases: [businessName, businessAddress],
isOnLabel: (views) =>
hasBusinessData &&
groupsHolding(views, businessName).length > 0 &&
groupsHolding(views, businessAddress).length > 0,
isCarriedBy: (text) =>
hasPhrase(text, businessName) || hasPhrase(text, businessAddress),
missing: {
title: 'Name und Anschrift fehlen',
detail: hasBusinessData
? 'Name und Anschrift des Lebensmittelunternehmers aus den Einstellungen stehen nicht vollständig auf dem Etikett.'
: 'Name und Anschrift des Lebensmittelunternehmers sind in den Einstellungen nicht eingetragen.',
},
},
];
}
function candidatesOf(
input: LabelCheckInput,
measurer: TextMeasurer,
): Candidate[] {
return resolvedTexts(input.elements, input.values).map(
({ element, text }) => {
const normalized = normalizeForCheck(text);
const clipped =
normalized !== '' &&
measurer.heightOfStringPt(text, element) >
element.height * MM_TO_PT + CLIP_TOLERANCE_PT;
return { element, normalized, clipped };
},
);
}
function ruleFor(
rules: readonly NetQuantityDigitRule[],
grams: number,
): NetQuantityDigitRule | null {
const ordered = [...rules].sort(
(a, b) =>
(a.maxGrams ?? Number.POSITIVE_INFINITY) -
(b.maxGrams ?? Number.POSITIVE_INFINITY),
);
return (
ordered.find((rule) => rule.maxGrams === null || grams <= rule.maxGrams) ??
null
);
}
function unique(ids: string[]): string[] {
return [...new Set(ids)];
}
function xHeightFinding(
carriers: Candidate[],
minXHeightMm: number,
): LabelFinding | null {
const measured = carriers.map(({ element }) => ({
id: element.id,
xHeightMm: heightMm(
element.fontSize,
fontMetricsOf(element.fontFamily, element.bold, element.italic).xHeight,
),
}));
const tooSmall = measured.filter(
(entry) => entry.xHeightMm < minXHeightMm - HEIGHT_EPSILON_MM,
);
if (tooSmall.length === 0) {
return null;
}
const smallest = Math.min(...tooSmall.map((entry) => entry.xHeightMm));
return {
code: 'X_HEIGHT',
title: `Text zu klein (x-Höhe ${formatDecimal(smallest)} mm)`,
detail: `Pflichtangaben brauchen eine x-Höhe von mindestens ${formatDecimal(minXHeightMm)} mm. Schrift vergrößern.`,
elementIds: unique(tooSmall.map((entry) => entry.id)),
};
}
function netQuantityHeightFinding(
carriers: Candidate[],
minMm: number | null,
): LabelFinding | null {
if (minMm === null || carriers.length === 0) {
return null;
}
const tallest = Math.max(
...carriers.map(({ element }) =>
heightMm(
element.fontSize,
fontMetricsOf(element.fontFamily, element.bold, element.italic)
.capHeight,
),
),
);
if (tallest >= minMm - HEIGHT_EPSILON_MM) {
return null;
}
return {
code: 'NET_QUANTITY_HEIGHT',
title: `Füllmenge zu klein (Ziffernhöhe ${formatDecimal(tallest)} mm)`,
detail: `Für diese Füllmenge sind Ziffern von mindestens ${formatDecimal(minMm)} mm Höhe nötig. Schrift der Füllmenge vergrößern.`,
elementIds: unique(carriers.map(({ element }) => element.id)),
};
}
/**
* Checks the label's text elements against the mandatory particulars for one
* bottling. Pure: the caller resolves the legal values (settings, digit
* rules) and the expected facts. Text that does not fit its box counts as
* missing; the check says so with TEXT_CLIPPED.
*/
export function checkLabel(input: LabelCheckInput): LabelCheckResult {
const measurer = input.measurer ?? pdfTextMeasurer();
const candidates = candidatesOf(input, measurer);
const visible = candidates.filter((candidate) => !candidate.clipped);
const views = textViews(visible);
const combined = views.full;
const gaps: LabelFinding[] = [];
const carriers = new Set<Candidate>();
const clippedCarriers = new Set<Candidate>();
let netQuantityCarriers: Candidate[] = [];
let netQuantityFound = false;
let lotFound = false;
let lotCarriers: Candidate[] = [];
for (const particular of particulars(input.expected)) {
const isOnLabel = particular.isOnLabel(views);
// Phrases no single element holds are spread over several: the parts that
// make up the match carry the particular, and so do clipped parts.
const splitPhrases = (particular.phrases ?? []).filter(
(phrase) =>
phrase !== '' &&
!visible.some((candidate) => hasPhrase(candidate.normalized, phrase)),
);
const splitParts = isOnLabel
? splitPhrases.flatMap((phrase) => groupsHolding(views, phrase).flat())
: [];
const pairs = particular.pairs?.(views) ?? [];
const singleHolders = visible.filter((candidate) =>
particular.isCarriedBy(candidate.normalized),
);
const holders = [
...new Set([...singleHolders, ...splitParts, ...pairs.flat()]),
];
holders.forEach((candidate) => carriers.add(candidate));
const clippedHolders = candidates.filter(
(candidate) =>
candidate.clipped &&
(particular.isCarriedBy(candidate.normalized) ||
splitPhrases.some((phrase) =>
sharesWord(candidate.normalized, phrase),
)),
);
clippedHolders.forEach((candidate) => clippedCarriers.add(candidate));
if (!isOnLabel) {
gaps.push({
code: particular.code,
...particular.missing,
elementIds: unique(clippedHolders.map(({ element }) => element.id)),
});
}
if (particular.code === 'NET_QUANTITY') {
netQuantityFound = isOnLabel;
netQuantityCarriers = [...new Set([...singleHolders, ...pairs.flat()])];
}
if (particular.code === 'LOT') {
lotFound = isOnLabel;
lotCarriers = holders;
}
}
const warnings: LabelFinding[] = [];
if (lotFound) {
const lot = normalizeForCheck(input.expected.lotNumber);
if (
!lotStartsWithL(input.expected.lotNumber) &&
!new RegExp(`(^| )l ?${lot}( |$)`).test(combined)
) {
warnings.push({
code: 'LOT_PREFIX',
title: 'Losnummer ohne „L“',
detail:
'Vor der Losnummer sollte der Buchstabe „L“ stehen, zum Beispiel „L 2026-0001“.',
elementIds: unique(lotCarriers.map(({ element }) => element.id)),
});
}
}
const xHeight = xHeightFinding([...carriers], input.minXHeightMm);
if (xHeight) {
warnings.push(xHeight);
}
const digitRule = ruleFor(input.digitRules, input.expected.jarSizeGrams);
const netQuantityHeight = netQuantityFound
? netQuantityHeightFinding(netQuantityCarriers, digitRule?.minMm ?? null)
: null;
if (netQuantityHeight) {
warnings.push(netQuantityHeight);
}
if (clippedCarriers.size > 0) {
warnings.push({
code: 'TEXT_CLIPPED',
title: 'Text wird abgeschnitten',
detail:
'Der Text passt nicht in sein Feld und zählt deshalb nicht. Feld vergrößern oder Schrift verkleinern.',
elementIds: unique([...clippedCarriers].map(({ element }) => element.id)),
});
}
return {
printable: gaps.length === 0,
gaps,
warnings,
sample: input.sample,
requirements: {
minXHeightMm: input.minXHeightMm,
netQuantityMinCapHeightMm: digitRule?.minMm ?? null,
},
};
}
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { SiteSettingsModule } from '../site-settings/site-settings.module';
import { LabelTemplatesController } from './label-templates.controller';
import { LabelTemplatesService } from './label-templates.service';
@Module({
imports: [SiteSettingsModule],
controllers: [LabelTemplatesController],
providers: [LabelTemplatesService],
exports: [LabelTemplatesService],
@@ -5,8 +5,20 @@ import {
} from './label-templates.service';
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';
jest.mock('../prisma/row-lock', () => ({
lockRowForUpdate: jest.fn().mockResolvedValue(true),
}));
const SETTINGS = {
originCountry: 'Deutschland',
foodBusinessName: 'Imkerei Muster' as string | null,
foodBusinessAddress: 'Bühlweg 10, 86450 Altenmünster' as string | null,
};
describe('LabelTemplatesService', () => {
let service: LabelTemplatesService;
let tx: {
@@ -14,8 +26,11 @@ describe('LabelTemplatesService', () => {
updateMany: jest.Mock;
create: jest.Mock;
update: jest.Mock;
findUnique: jest.Mock;
};
};
let siteSettings: { get: jest.Mock };
const order: string[] = [];
let prisma: {
labelTemplate: {
findMany: jest.Mock;
@@ -78,11 +93,21 @@ describe('LabelTemplatesService', () => {
};
beforeEach(() => {
order.length = 0;
(lockRowForUpdate as jest.Mock).mockImplementation(() => {
order.push('lock');
return Promise.resolve(true);
});
siteSettings = { get: jest.fn().mockResolvedValue(SETTINGS) };
tx = {
labelTemplate: {
updateMany: jest.fn().mockResolvedValue({}),
create: jest.fn(),
update: jest.fn(),
findUnique: jest.fn().mockImplementation(() => {
order.push('read');
return Promise.resolve(template);
}),
},
};
prisma = {
@@ -100,6 +125,7 @@ describe('LabelTemplatesService', () => {
service = new LabelTemplatesService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
siteSettings as unknown as SiteSettingsService,
);
});
@@ -140,6 +166,52 @@ describe('LabelTemplatesService', () => {
});
});
describe('update', () => {
it('reads before after locking the LabelTemplate row', async () => {
tx.labelTemplate.update.mockImplementation(() => {
order.push('update');
return Promise.resolve({ ...template, name: 'Neu' });
});
await service.update('template-1', { name: 'Neu' }, 'user-1');
expect(lockRowForUpdate).toHaveBeenCalledWith(
tx,
'LabelTemplate',
'template-1',
);
expect(order).toEqual(['lock', 'read', 'update']);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
action: 'UPDATE',
beforeData: template,
afterData: { ...template, name: 'Neu' },
}),
tx,
);
});
it('throws NotFoundException for a missing template', async () => {
(lockRowForUpdate as jest.Mock).mockResolvedValueOnce(false);
await expect(
service.update('missing', { name: 'Neu' }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
expect(tx.labelTemplate.update).not.toHaveBeenCalled();
});
it('clears other defaults when isDefault is set', async () => {
tx.labelTemplate.update.mockResolvedValue(template);
await service.update('template-1', { isDefault: true }, 'user-1');
expect(tx.labelTemplate.updateMany).toHaveBeenCalledWith({
where: { id: { not: 'template-1' } },
data: { isDefault: false },
});
});
});
describe('deactivate', () => {
it('sets active and isDefault to false, records DEACTIVATE', async () => {
prisma.labelTemplate.findUnique.mockResolvedValue(template);
@@ -426,28 +498,62 @@ describe('LabelTemplatesService', () => {
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' },
product: {
name: 'Glas 500g',
legalName: 'Blütenhonig' as string | null,
},
};
it('prints the jar size in grams, the unit honey is sold by', () => {
expect(buildPlaceholderValues(bottlingBatch).jarSize).toBe('500 g');
});
it('never labels the jar size as a volume', () => {
expect(buildPlaceholderValues(bottlingBatch).jarSize).not.toMatch(
/\b(ml|l|cl)\b/,
expect(buildPlaceholderValues(bottlingBatch, SETTINGS).jarSize).toBe(
'500 g',
);
});
it('never labels the jar size as a volume', () => {
expect(
buildPlaceholderValues(bottlingBatch, SETTINGS).jarSize,
).not.toMatch(/\b(ml|l|cl)\b/);
});
it('leaves an unset honey type and product as empty text, not "null"', () => {
const values = buildPlaceholderValues({
...bottlingBatch,
honeyBatch: { batchNumber: 'HB-001', honeyType: null },
product: null,
});
const values = buildPlaceholderValues(
{
...bottlingBatch,
honeyBatch: { batchNumber: 'HB-001', honeyType: null },
product: null,
},
SETTINGS,
);
expect(values.honeyType).toBe('');
expect(values.productName).toBe('');
expect(values.legalName).toBe('');
});
it('fills the particulars from the product and the settings', () => {
const values = buildPlaceholderValues(bottlingBatch, SETTINGS);
expect(values.legalName).toBe('Blütenhonig');
expect(values.originCountry).toBe('Deutschland');
expect(values.foodBusinessName).toBe('Imkerei Muster');
expect(values.foodBusinessAddress).toBe('Bühlweg 10, 86450 Altenmünster');
expect(values.bestBeforeLine).toBe('Mindestens haltbar bis 01.06.2028');
expect(values.lotLine).toBe('L-001');
});
it('leaves unset master data as empty text, not "null"', () => {
const values = buildPlaceholderValues(
{
...bottlingBatch,
product: { name: 'Glas 500g', legalName: null },
},
{ ...SETTINGS, foodBusinessName: null, foodBusinessAddress: null },
);
expect(values.legalName).toBe('');
expect(values.foodBusinessName).toBe('');
expect(values.foodBusinessAddress).toBe('');
});
});
});
@@ -2,19 +2,20 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import PDFDocument from 'pdfkit';
import * as QRCode from 'qrcode';
import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate } from '../prisma/row-lock';
import { AuditLogService } from '../audit/audit-log.service';
import { SiteSettingsService } from '../site-settings/site-settings.service';
import { matchesFileSignature } from '../common/file-signature';
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 {
LabelElement,
LabelFontFamily,
PlaceholderKey,
} from './label-element.types';
import { berlinDayOf } from '../common/dates';
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';
const MM_TO_PT = 72 / 25.4;
const A4_WIDTH_MM = 210;
const A4_HEIGHT_MM = 297;
const QR_SOURCE_SIZE_PX = 400;
@@ -35,15 +36,18 @@ interface SheetLayout {
// not to show if the blade lands a fraction beside it.
const CROP_MARK_LINE_WIDTH_PT = 0.3;
// '2-digit' forces a leading zero for day/month (e.g. "05.03.2026") - the
// default 'numeric' formatting drops it ("5.3.2026"), which is what the MHD
// on a printed label was showing.
/**
* 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);
}
function formatDate(date: Date): string {
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
return formatDayDe(labelDayOf(date), true);
}
// Prisma stores `elements` as a plain Json column; the shape is only ever
@@ -53,24 +57,6 @@ function elementsOf(template: LabelTemplate): LabelElement[] {
return template.elements as unknown as LabelElement[];
}
function pdfFontName(
family: LabelFontFamily,
bold: boolean,
italic: boolean,
): string {
const base =
family === 'serif' ? 'Times' : family === 'mono' ? 'Courier' : 'Helvetica';
if (base === 'Times') {
if (bold && italic) return 'Times-BoldItalic';
if (bold) return 'Times-Bold';
if (italic) return 'Times-Italic';
return 'Times-Roman';
}
const suffix =
bold && italic ? '-BoldOblique' : bold ? '-Bold' : italic ? '-Oblique' : '';
return `${base}${suffix}`;
}
interface GridFit {
columns: number;
rows: number;
@@ -119,22 +105,38 @@ function dataUrlToBuffer(dataUrl: string): Buffer | null {
* grams everywhere else in the app, and honey is a food sold by mass, so a
* label stating millilitres declares the wrong net quantity.
*/
export function buildPlaceholderValues(bottlingBatch: {
jarSize: number;
lotNumber: string;
date: Date;
bestBeforeDate: Date;
honeyBatch: { batchNumber: string; honeyType: string | null };
product: { name: string } | null;
}): Record<PlaceholderKey, string> {
export function buildPlaceholderValues(
bottlingBatch: {
jarSize: number;
lotNumber: string;
date: Date;
bestBeforeDate: Date;
honeyBatch: { batchNumber: string; honeyType: string | null };
product: { name: string; legalName: string | null } | null;
},
settings: {
originCountry: string;
foodBusinessName: string | null;
foodBusinessAddress: string | null;
},
): Record<PlaceholderKey, string> {
const bestBeforeDate = formatDate(bottlingBatch.bestBeforeDate);
return {
batchNumber: bottlingBatch.honeyBatch.batchNumber,
honeyType: bottlingBatch.honeyBatch.honeyType ?? '',
lotNumber: bottlingBatch.lotNumber,
bottlingDate: formatDate(bottlingBatch.date),
bestBeforeDate: formatDate(bottlingBatch.bestBeforeDate),
bestBeforeDate,
jarSize: `${bottlingBatch.jarSize} g`,
productName: bottlingBatch.product?.name ?? '',
legalName: bottlingBatch.product?.legalName ?? '',
originCountry: settings.originCountry,
foodBusinessName: settings.foodBusinessName ?? '',
foodBusinessAddress: settings.foodBusinessAddress ?? '',
bestBeforeLine: `Mindestens haltbar bis ${bestBeforeDate}`,
lotLine: lotStartsWithL(bottlingBatch.lotNumber)
? bottlingBatch.lotNumber
: `L ${bottlingBatch.lotNumber}`,
};
}
@@ -143,6 +145,7 @@ export class LabelTemplatesService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly siteSettings: SiteSettingsService,
) {}
async findAll(): Promise<LabelTemplate[]> {
@@ -189,28 +192,39 @@ export class LabelTemplatesService {
dto: UpdateLabelTemplateDto,
actingUserId: string,
): Promise<LabelTemplate> {
const before = await this.findById(id);
const template = await this.prisma.$transaction(async (tx) => {
return this.prisma.$transaction(async (tx) => {
// "before" is read after the lock: two concurrent edits queue up and the
// audit entry shows the state this edit really replaced.
if (!(await lockRowForUpdate(tx, 'LabelTemplate', id))) {
throw new NotFoundException('Label template not found');
}
const before = await tx.labelTemplate.findUnique({ where: { id } });
if (!before) {
throw new NotFoundException('Label template not found');
}
if (dto.isDefault) {
await tx.labelTemplate.updateMany({
where: { id: { not: id } },
data: { isDefault: false },
});
}
return tx.labelTemplate.update({
const template = await tx.labelTemplate.update({
where: { id },
data: { ...dto, elements: dto.elements as never },
});
await this.auditLog.record(
{
entityType: 'LabelTemplate',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: template,
},
tx,
);
return template;
});
await this.auditLog.record({
entityType: 'LabelTemplate',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: template,
});
return template;
}
async deactivate(id: string, actingUserId: string): Promise<LabelTemplate> {
@@ -244,7 +258,10 @@ export class LabelTemplatesService {
throw new NotFoundException('Bottling batch not found');
}
const values = buildPlaceholderValues(bottlingBatch);
const values = buildPlaceholderValues(
bottlingBatch,
await this.siteSettings.get(),
);
const elements = elementsOf(template).map((element) =>
element.type === 'text' && element.placeholderKey
@@ -0,0 +1,92 @@
import { LabelTextElement } from './label-element.types';
import { normalizeForCheck, resolvedTexts } from './label-text';
import { PLACEHOLDER_KEYS, PlaceholderKey } from './label-element.types';
function textElement(
id: string,
x: number,
y: number,
overrides: Partial<LabelTextElement> = {},
): LabelTextElement {
return {
id,
type: 'text',
x,
y,
width: 50,
height: 10,
text: id,
fontSize: 10,
fontFamily: 'sans',
bold: false,
italic: false,
color: '#000000',
align: 'left',
...overrides,
};
}
const values = Object.fromEntries(
PLACEHOLDER_KEYS.map((key) => [key, `value-${key}`]),
) as Record<PlaceholderKey, string>;
describe('normalizeForCheck', () => {
it('normalizes umlauts, NFKC compatibility characters and punctuation', () => {
expect(normalizeForCheck('Bühlweg 10, 86450 Altenmünster')).toBe(
'bühlweg 10 86450 altenmünster',
);
expect(normalizeForCheck('Bühlweg 10 · 86450\nAltenmünster')).toBe(
'bühlweg 10 86450 altenmünster',
);
// decomposed u + combining diaeresis equals the precomposed letter
expect(normalizeForCheck('Bu\u0308hlweg')).toBe('bühlweg');
// full-width digits, ligature, non-breaking space
expect(normalizeForCheck('500\u00a0g fix')).toBe('500 g fix');
});
it('splits a number glued to its unit', () => {
expect(normalizeForCheck('250g')).toBe('250 g');
expect(normalizeForCheck('L2026-0001')).toBe('l2026 0001');
});
it('collapses runs of separators and trims', () => {
expect(normalizeForCheck(' --L 2026-0001.. ')).toBe('l 2026 0001');
expect(normalizeForCheck('...')).toBe('');
});
});
describe('resolvedTexts', () => {
it('sorts elements top first, then left', () => {
const result = resolvedTexts(
[
textElement('c', 30, 20),
textElement('b', 30, 5),
textElement('a', 5, 5),
textElement('d', 5, 20),
],
values,
);
expect(result.map((entry) => entry.element.id)).toEqual([
'a',
'b',
'd',
'c',
]);
});
it('substitutes the placeholder value and keeps fixed text', () => {
const result = resolvedTexts(
[
textElement('p', 0, 0, { placeholderKey: 'lotLine', text: 'ignored' }),
textElement('f', 0, 10, { text: 'Honig' }),
textElement('n', 0, 20, { placeholderKey: null, text: 'Fix' }),
],
values,
);
expect(result.map((entry) => entry.text)).toEqual([
'value-lotLine',
'Honig',
'Fix',
]);
});
});
@@ -0,0 +1,65 @@
import { LabelTextElement, PlaceholderKey } from './label-element.types';
/**
* 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
* "Bühlweg 10, 86450 Altenmünster" also matches "Bühlweg 10 · 86450\nAltenmünster".
*/
export function normalizeForCheck(text: string): string {
return (
text
.normalize('NFKC')
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, ' ')
// "250g" -> "250 g": a number glued to its unit
.replace(/(\p{N})(?=\p{L})/gu, '$1 ')
.trim()
);
}
// Elements whose tops are at most this far apart count as one row.
export const ROW_TOLERANCE_MM = 1;
export interface ResolvedText {
element: LabelTextElement;
text: string;
}
/**
* The text each element prints for one bottling (placeholders filled in),
* top first, then left.
*/
export function resolvedTexts(
elements: readonly LabelTextElement[],
values: Record<PlaceholderKey, string>,
): ResolvedText[] {
const resolved = elements
.map((element) => ({
element,
text: element.placeholderKey
? values[element.placeholderKey]
: element.text,
}))
.sort((a, b) => a.element.y - b.element.y);
// Reading order comes from the geometry only, never from the order of the
// elements array: rows (tops within the tolerance of the row's first top),
// each row from left to right.
const rows: ResolvedText[][] = [];
let rowTop = Number.NEGATIVE_INFINITY;
for (const entry of resolved) {
if (entry.element.y - rowTop > ROW_TOLERANCE_MM) {
rows.push([]);
rowTop = entry.element.y;
}
rows[rows.length - 1].push(entry);
}
return rows.flatMap((row) => row.sort((a, b) => a.element.x - b.element.x));
}
/**
* Does the lot number already start with the lot marker "L" (as its own token
* or glued to digits, "L-001", "L2026")? Then no further "L" is needed.
*/
export function lotStartsWithL(lotNumber: string): boolean {
return /^l( |\d|$)/.test(normalizeForCheck(lotNumber));
}
@@ -0,0 +1,16 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { PLACEHOLDER_KEYS } from './label-element.types';
describe('PLACEHOLDER_KEYS', () => {
// The frontend compares its own list with this file.
it('the fixture file equals PLACEHOLDER_KEYS', () => {
const fixture: unknown = JSON.parse(
readFileSync(
join(__dirname, '../../test/fixtures/placeholder-keys.json'),
'utf8',
),
);
expect(fixture).toEqual([...PLACEHOLDER_KEYS]);
});
});
@@ -6,6 +6,7 @@ import {
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';
import { ProductKind, ShopCategory } from '../../../generated/prisma';
@@ -26,6 +27,16 @@ export class CreateProductDto {
@IsString()
sku?: string;
@ApiPropertyOptional({
description:
'Verkehrsbezeichnung auf dem Etikett (muss „Honig“ enthalten), zum Beispiel „Blütenhonig“',
nullable: true,
})
@IsOptional()
@IsString()
@MaxLength(200)
legalName?: string | null;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@@ -1,7 +1,12 @@
import { NotFoundException } from '@nestjs/common';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { ProductsService } from './products.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { lockRowForUpdate } from '../prisma/row-lock';
jest.mock('../prisma/row-lock', () => ({
lockRowForUpdate: jest.fn().mockResolvedValue(true),
}));
describe('ProductsService', () => {
let service: ProductsService;
@@ -12,10 +17,36 @@ describe('ProductsService', () => {
create: jest.Mock;
update: jest.Mock;
};
$transaction: jest.Mock;
};
let tx: {
product: { findUnique: jest.Mock; update: jest.Mock };
bottlingBatch: { count: jest.Mock };
productionBatch: { count: jest.Mock };
};
const order: string[] = [];
let auditLog: { record: jest.Mock };
beforeEach(() => {
order.length = 0;
(lockRowForUpdate as jest.Mock).mockImplementation(() => {
order.push('lock');
return Promise.resolve(true);
});
tx = {
product: {
findUnique: jest.fn().mockImplementation(() => {
order.push('read');
return Promise.resolve({ id: 'p1', name: 'Sommerhonig' });
}),
update: jest.fn().mockImplementation(() => {
order.push('update');
return Promise.resolve({ id: 'p1', name: 'Sommerhonig 500g' });
}),
},
bottlingBatch: { count: jest.fn().mockResolvedValue(0) },
productionBatch: { count: jest.fn().mockResolvedValue(0) },
};
prisma = {
product: {
findMany: jest.fn(),
@@ -23,6 +54,9 @@ describe('ProductsService', () => {
create: jest.fn(),
update: jest.fn(),
},
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
callback(tx),
),
};
auditLog = { record: jest.fn() };
service = new ProductsService(
@@ -53,8 +87,6 @@ describe('ProductsService', () => {
it('updates a product and records before/after audit data', async () => {
const before = { id: 'p1', name: 'Sommerhonig' };
const after = { id: 'p1', name: 'Sommerhonig 500g' };
prisma.product.findUnique.mockResolvedValueOnce(before);
prisma.product.update.mockResolvedValueOnce(after);
const result = await service.update(
'p1',
@@ -69,6 +101,76 @@ describe('ProductsService', () => {
beforeData: before,
afterData: after,
}),
tx,
);
});
it('reads before after locking the Product row', async () => {
await service.update('p1', { name: 'Sommerhonig 500g' }, 'user-1');
expect(lockRowForUpdate).toHaveBeenCalledWith(tx, 'Product', 'p1');
expect(order).toEqual(['lock', 'read', 'update']);
});
it('throws NotFoundException when updating a product that does not exist', async () => {
(lockRowForUpdate as jest.Mock).mockResolvedValueOnce(false);
await expect(
service.update('missing', { name: 'x' }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
expect(tx.product.update).not.toHaveBeenCalled();
});
it('refuses a kind change while bottlings exist', async () => {
tx.product.findUnique.mockResolvedValueOnce({
id: 'p1',
kind: 'BOTTLED_HONEY',
});
tx.bottlingBatch.count.mockResolvedValueOnce(2);
await expect(
service.update('p1', { kind: 'MANUFACTURED' }, 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
expect(tx.product.update).not.toHaveBeenCalled();
});
describe('legalName', () => {
it('trims it on create and stores an empty one as null', async () => {
prisma.product.create.mockResolvedValue({ id: 'p1' });
await service.create({ name: 'A', legalName: ' Blütenhonig ' }, 'u');
expect(prisma.product.create).toHaveBeenLastCalledWith({
data: { name: 'A', legalName: 'Blütenhonig' },
});
await service.create({ name: 'A', legalName: ' ' }, 'u');
expect(prisma.product.create).toHaveBeenLastCalledWith({
data: { name: 'A', legalName: null },
});
});
it('leaves it out of the data when the dto does not carry it', async () => {
prisma.product.create.mockResolvedValue({ id: 'p1' });
await service.create({ name: 'A' }, 'u');
expect(prisma.product.create).toHaveBeenLastCalledWith({
data: { name: 'A' },
});
});
it('clears it with null on update and trims on update', async () => {
await service.update('p1', { legalName: null }, 'u');
expect(tx.product.update).toHaveBeenLastCalledWith({
where: { id: 'p1' },
data: { legalName: null },
});
await service.update('p1', { legalName: ' Waldhonig ' }, 'u');
expect(tx.product.update).toHaveBeenLastCalledWith({
where: { id: 'p1' },
data: { legalName: 'Waldhonig' },
});
});
});
});
@@ -4,6 +4,8 @@ import {
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { DbClient } from '../prisma/db-client';
import { lockRowForUpdate } from '../prisma/row-lock';
import { AuditLogService } from '../audit/audit-log.service';
import { Product, ProductStatus } from '../../generated/prisma';
import { CreateProductDto } from './dto/create-product.dto';
@@ -13,6 +15,17 @@ export interface ProductFilter {
status?: ProductStatus;
}
// legalName is stored trimmed; blank or null clears it. Absent stays absent so
// a patch without it leaves the value alone.
function withNormalizedLegalName<T extends { legalName?: string | null }>(
dto: T,
): T {
if (dto.legalName === undefined) {
return dto;
}
return { ...dto, legalName: dto.legalName?.trim() || null };
}
@Injectable()
export class ProductsService {
constructor(
@@ -36,7 +49,9 @@ export class ProductsService {
}
async create(dto: CreateProductDto, actingUserId: string): Promise<Product> {
const product = await this.prisma.product.create({ data: { ...dto } });
const product = await this.prisma.product.create({
data: { ...withNormalizedLegalName(dto) },
});
await this.auditLog.record({
entityType: 'Product',
entityId: product.id,
@@ -52,23 +67,34 @@ export class ProductsService {
dto: UpdateProductDto,
actingUserId: string,
): Promise<Product> {
const before = await this.findById(id);
if (dto.kind !== undefined && dto.kind !== before.kind) {
await this.assertKindChangeable(id);
}
const product = await this.prisma.product.update({
where: { id },
data: { ...dto },
return this.prisma.$transaction(async (tx) => {
if (!(await lockRowForUpdate(tx, 'Product', id))) {
throw new NotFoundException('Product not found');
}
const before = await tx.product.findUnique({ where: { id } });
if (!before) {
throw new NotFoundException('Product not found');
}
if (dto.kind !== undefined && dto.kind !== before.kind) {
await this.assertKindChangeable(tx, id);
}
const product = await tx.product.update({
where: { id },
data: { ...withNormalizedLegalName(dto) },
});
await this.auditLog.record(
{
entityType: 'Product',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: product,
},
tx,
);
return product;
});
await this.auditLog.record({
entityType: 'Product',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: product,
});
return product;
}
/**
@@ -76,10 +102,10 @@ export class ProductsService {
* honey, production batches for everything else. Switching the kind while
* batches exist would leave that stock in the wrong place.
*/
private async assertKindChangeable(id: string): Promise<void> {
private async assertKindChangeable(db: DbClient, id: string): Promise<void> {
const [bottlings, productionBatches] = await Promise.all([
this.prisma.bottlingBatch.count({ where: { productId: id } }),
this.prisma.productionBatch.count({ where: { productId: id } }),
db.bottlingBatch.count({ where: { productId: id } }),
db.productionBatch.count({ where: { productId: id } }),
]);
if (bottlings > 0 || productionBatches > 0) {
throw new BadRequestException(
@@ -0,0 +1,32 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
/**
* The catalogue of veterinary medicines starts empty (ARCHITECTURE
* assumption 10): every value comes from the beekeeper's package leaflet or
* prescription, the code ships no medicine or legal data.
*/
describe('prisma/seed.ts', () => {
const seed = readFileSync(join(__dirname, '..', 'prisma', 'seed.ts'), 'utf8');
it('creates no veterinary medicine, pack or movement', () => {
expect(seed).not.toMatch(/veterinaryMedicine/i);
});
it('imports only from the known modules and has no sibling seed file', () => {
const imported = [
...seed.matchAll(/^import\s+(?:[^'"]*?from\s+)?['"]([^'"]+)['"]/gm),
].map((match) => match[1]);
expect(imported.sort()).toEqual(
[
'../generated/prisma',
'@prisma/adapter-pg',
'bcryptjs',
'dotenv/config',
'node:crypto',
].sort(),
);
const files = readdirSync(join(__dirname, '..', 'prisma'));
expect(files.filter((name) => name.endsWith('.ts'))).toEqual(['seed.ts']);
});
});
@@ -42,19 +42,43 @@ export class UpdateSiteSettingsDto {
@MaxLength(300)
websiteUrl?: string;
@ApiPropertyOptional({ description: 'Name des Bienenhalters (Bestandsbuch)' })
@ApiPropertyOptional({
description: 'Name des Bienenhalters (Bestandsbuch)',
nullable: true,
})
@IsOptional()
@IsString()
@MaxLength(200)
holderName?: string;
holderName?: string | null;
@ApiPropertyOptional({
description: 'Anschrift des Bienenhalters (Bestandsbuch)',
nullable: true,
})
@IsOptional()
@IsString()
@MaxLength(300)
holderAddress?: string;
holderAddress?: string | null;
@ApiPropertyOptional({
description:
'Name des Lebensmittelunternehmers auf dem Etikett (null oder leer löscht)',
nullable: true,
})
@IsOptional()
@IsString()
@MaxLength(200)
foodBusinessName?: string | null;
@ApiPropertyOptional({
description:
'Anschrift des Lebensmittelunternehmers auf dem Etikett (null oder leer löscht)',
nullable: true,
})
@IsOptional()
@IsString()
@MaxLength(300)
foodBusinessAddress?: string | null;
@ApiPropertyOptional({
description: 'Herkunftsland auf der öffentlichen Herkunftsseite',
@@ -1,6 +1,11 @@
import { SiteSettingsService } from './site-settings.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { lockRowForUpdate } from '../prisma/row-lock';
jest.mock('../prisma/row-lock', () => ({
lockRowForUpdate: jest.fn().mockResolvedValue(true),
}));
describe('SiteSettingsService', () => {
let service: SiteSettingsService;
@@ -10,12 +15,31 @@ describe('SiteSettingsService', () => {
create: jest.Mock;
update: jest.Mock;
};
$transaction: jest.Mock;
};
let tx: {
siteSettings: {
upsert: jest.Mock;
findUnique: jest.Mock;
update: jest.Mock;
};
};
const order: string[] = [];
function updateData(): Record<string, unknown> {
const call = tx.siteSettings.update.mock.calls[0] as [
{ where: { id: string }; data: Record<string, unknown> },
];
expect(call[0].where).toEqual({ id: 'singleton' });
return call[0].data;
}
let auditLog: { record: jest.Mock };
const existing = {
id: 'singleton',
youtubeUrl: 'https://youtube.com/@wabenhain',
foodBusinessName: null as string | null,
foodBusinessAddress: null as string | null,
instagramUrl: null as string | null,
tiktokUrl: null as string | null,
websiteUrl: null as string | null,
@@ -23,12 +47,33 @@ describe('SiteSettingsService', () => {
};
beforeEach(() => {
order.length = 0;
(lockRowForUpdate as jest.Mock).mockImplementation(() => {
order.push('lock');
return Promise.resolve(true);
});
tx = {
siteSettings: {
upsert: jest.fn().mockImplementation(() => {
order.push('upsert');
return Promise.resolve(existing);
}),
findUnique: jest.fn().mockImplementation(() => {
order.push('read');
return Promise.resolve(existing);
}),
update: jest.fn(),
},
};
prisma = {
siteSettings: {
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
callback(tx),
),
};
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
service = new SiteSettingsService(
@@ -67,9 +112,8 @@ describe('SiteSettingsService', () => {
});
describe('update', () => {
it('stores a valid link and records an audit entry', async () => {
prisma.siteSettings.findUnique.mockResolvedValue(existing);
prisma.siteSettings.update.mockResolvedValue({
it('stores a valid link and records an audit entry in the transaction', async () => {
tx.siteSettings.update.mockResolvedValue({
...existing,
instagramUrl: 'https://instagram.com/wabenhain',
});
@@ -79,14 +123,9 @@ describe('SiteSettingsService', () => {
'user-1',
);
expect(prisma.siteSettings.update).toHaveBeenCalledWith({
where: { id: 'singleton' },
data: {
youtubeUrl: undefined,
instagramUrl: 'https://instagram.com/wabenhain',
tiktokUrl: undefined,
websiteUrl: undefined,
},
expect(updateData()).toMatchObject({
instagramUrl: 'https://instagram.com/wabenhain',
youtubeUrl: undefined,
});
expect(result.instagramUrl).toBe('https://instagram.com/wabenhain');
expect(auditLog.record).toHaveBeenCalledWith(
@@ -94,44 +133,80 @@ describe('SiteSettingsService', () => {
entityType: 'SiteSettings',
action: 'UPDATE',
userId: 'user-1',
beforeData: existing,
}),
tx,
);
});
it('update reads before after locking the SiteSettings row', async () => {
tx.siteSettings.update.mockImplementation(() => {
order.push('update');
return Promise.resolve(existing);
});
await service.update({ youtubeUrl: '' }, 'user-1');
expect(lockRowForUpdate).toHaveBeenCalledWith(
tx,
'SiteSettings',
'singleton',
);
expect(order).toEqual(['upsert', 'lock', 'read', 'update']);
});
it('clears a link when given an empty string', async () => {
prisma.siteSettings.findUnique.mockResolvedValue(existing);
prisma.siteSettings.update.mockResolvedValue({
tx.siteSettings.update.mockResolvedValue({
...existing,
youtubeUrl: null,
});
await service.update({ youtubeUrl: '' }, 'user-1');
expect(prisma.siteSettings.update).toHaveBeenCalledWith({
where: { id: 'singleton' },
data: {
youtubeUrl: null,
instagramUrl: undefined,
tiktokUrl: undefined,
websiteUrl: undefined,
},
});
expect(updateData()).toMatchObject({ youtubeUrl: null });
});
it('leaves fields untouched when omitted from the dto', async () => {
prisma.siteSettings.findUnique.mockResolvedValue(existing);
prisma.siteSettings.update.mockResolvedValue(existing);
tx.siteSettings.update.mockResolvedValue(existing);
await service.update({}, 'user-1');
expect(prisma.siteSettings.update).toHaveBeenCalledWith({
where: { id: 'singleton' },
data: {
youtubeUrl: undefined,
instagramUrl: undefined,
tiktokUrl: undefined,
websiteUrl: undefined,
const call = tx.siteSettings.update.mock.calls[0] as [
{ data: Record<string, unknown> },
];
expect(Object.values(call[0].data).every((v) => v === undefined)).toBe(
true,
);
});
it('trims foodBusinessName and foodBusinessAddress and stores empty as null', async () => {
tx.siteSettings.update.mockResolvedValue(existing);
await service.update(
{
foodBusinessName: ' Imkerei Muster ',
foodBusinessAddress: ' ',
},
'user-1',
);
expect(updateData()).toMatchObject({
foodBusinessName: 'Imkerei Muster',
foodBusinessAddress: null,
});
});
it('clears the food business fields with null', async () => {
tx.siteSettings.update.mockResolvedValue(existing);
await service.update(
{ foodBusinessName: null, foodBusinessAddress: null },
'user-1',
);
expect(updateData()).toMatchObject({
foodBusinessName: null,
foodBusinessAddress: null,
});
});
});
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate } from '../prisma/row-lock';
import { AuditLogService } from '../audit/audit-log.service';
import { SiteSettings } from '../../generated/prisma';
import { UpdateSiteSettingsDto } from './dto/update-site-settings.dto';
@@ -10,6 +11,17 @@ function emptyToNull(value: string | undefined): string | null {
return value === '' ? null : (value ?? null);
}
// 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;
}
@Injectable()
export class SiteSettingsService {
constructor(
@@ -34,43 +46,56 @@ export class SiteSettingsService {
dto: UpdateSiteSettingsDto,
actingUserId: string,
): Promise<SiteSettings> {
const before = await this.get();
const settings = await this.prisma.siteSettings.update({
where: { id: SINGLETON_ID },
data: {
youtubeUrl:
dto.youtubeUrl !== undefined
? emptyToNull(dto.youtubeUrl)
: undefined,
instagramUrl:
dto.instagramUrl !== undefined
? emptyToNull(dto.instagramUrl)
: undefined,
tiktokUrl:
dto.tiktokUrl !== undefined ? emptyToNull(dto.tiktokUrl) : undefined,
websiteUrl:
dto.websiteUrl !== undefined
? emptyToNull(dto.websiteUrl)
: undefined,
holderName:
dto.holderName !== undefined
? emptyToNull(dto.holderName.trim())
: undefined,
holderAddress:
dto.holderAddress !== undefined
? emptyToNull(dto.holderAddress.trim())
: undefined,
originCountry: dto.originCountry?.trim(),
},
return this.prisma.$transaction(async (tx) => {
// The singleton row must exist to be locked; "before" is read after the
// lock so concurrent edits queue up instead of overwriting each other.
await tx.siteSettings.upsert({
where: { id: SINGLETON_ID },
update: {},
create: { id: SINGLETON_ID },
});
await lockRowForUpdate(tx, 'SiteSettings', SINGLETON_ID);
const before = await tx.siteSettings.findUnique({
where: { id: SINGLETON_ID },
});
const settings = await tx.siteSettings.update({
where: { id: SINGLETON_ID },
data: {
youtubeUrl:
dto.youtubeUrl !== undefined
? emptyToNull(dto.youtubeUrl)
: undefined,
instagramUrl:
dto.instagramUrl !== undefined
? emptyToNull(dto.instagramUrl)
: undefined,
tiktokUrl:
dto.tiktokUrl !== undefined
? emptyToNull(dto.tiktokUrl)
: undefined,
websiteUrl:
dto.websiteUrl !== undefined
? emptyToNull(dto.websiteUrl)
: undefined,
holderName: trimmedOrNull(dto.holderName),
holderAddress: trimmedOrNull(dto.holderAddress),
foodBusinessName: trimmedOrNull(dto.foodBusinessName),
foodBusinessAddress: trimmedOrNull(dto.foodBusinessAddress),
originCountry: dto.originCountry?.trim(),
},
});
await this.auditLog.record(
{
entityType: 'SiteSettings',
entityId: SINGLETON_ID,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: settings,
},
tx,
);
return settings;
});
await this.auditLog.record({
entityType: 'SiteSettings',
entityId: SINGLETON_ID,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: settings,
});
return settings;
}
}
@@ -0,0 +1,75 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsDateString,
IsNumber,
IsOptional,
IsPositive,
IsString,
Matches,
MaxLength,
} from 'class-validator';
import {
MAX_ADDRESS_LENGTH,
MAX_NAME_LENGTH,
MAX_NOTE_LENGTH,
} from './create-veterinary-medicine.dto';
export const ID_PATTERN = /^[A-Za-z0-9_-]+$/;
const MAX_ID_LENGTH = 64;
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const DAY_MESSAGE = 'muss ein Datum im Format JJJJ-MM-TT sein';
/** One bought pack. Batch number, supplier, proof and vet apply to every treatment from it. */
export class CreateLotDto {
@ApiProperty()
@IsString()
@Matches(ID_PATTERN)
@MaxLength(MAX_ID_LENGTH)
medicineId!: string;
@ApiPropertyOptional({ nullable: true, description: 'Ch.-B.' })
@IsOptional()
@IsString()
@MaxLength(MAX_NAME_LENGTH)
batchNumber?: string | null;
@ApiProperty({ example: '2026-08-01' })
@Matches(DAY_PATTERN, { message: `purchasedOn ${DAY_MESSAGE}` })
@IsDateString({ strict: true })
purchasedOn!: string;
@ApiProperty({ description: 'Anfangsmenge in der Einheit des Mittels, > 0' })
@IsNumber({ allowNaN: false, allowInfinity: false })
@IsPositive()
quantity!: number;
@ApiPropertyOptional({ nullable: true, example: '2028-01-31' })
@IsOptional()
@Matches(DAY_PATTERN, { message: `expiresOn ${DAY_MESSAGE}` })
@IsDateString({ strict: true })
expiresOn?: string | null;
@ApiPropertyOptional({ nullable: true, description: 'Name, Anschrift' })
@IsOptional()
@IsString()
@MaxLength(MAX_ADDRESS_LENGTH)
supplier?: string | null;
@ApiPropertyOptional({ nullable: true, description: 'z. B. Rechnung 4711' })
@IsOptional()
@IsString()
@MaxLength(MAX_ADDRESS_LENGTH)
purchaseProof?: string | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(MAX_ADDRESS_LENGTH)
prescribingVet?: string | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(MAX_NOTE_LENGTH)
notes?: string | null;
}
@@ -0,0 +1,50 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsIn,
IsNotEmpty,
IsNumber,
IsPositive,
IsString,
Matches,
Min,
MaxLength,
ValidateIf,
} from 'class-validator';
export const MANUAL_MOVEMENT_TYPES = ['CORRECTION', 'DISPOSAL'] as const;
export type ManualMovementType = (typeof MANUAL_MOVEMENT_TYPES)[number];
export const MAX_REASON_LENGTH = 500;
const FINITE = { allowNaN: false, allowInfinity: false } as const;
/**
* CORRECTION (inventory): the rest counted in the box. DISPOSAL: a positive
* quantity that leaves the lot, with a reason. TREATMENT bookings come only
* from a treatment.
*/
export class CreateMovementDto {
@ApiProperty({ enum: MANUAL_MOVEMENT_TYPES })
@IsIn(MANUAL_MOVEMENT_TYPES)
type!: ManualMovementType;
@ApiPropertyOptional({ description: 'CORRECTION: der tatsächliche Rest' })
@ValidateIf((dto: CreateMovementDto) => dto.type === 'CORRECTION')
@IsNumber(FINITE)
@Min(0)
actualRemaining?: number;
@ApiPropertyOptional({ description: 'DISPOSAL: Menge, positiv' })
@ValidateIf((dto: CreateMovementDto) => dto.type === 'DISPOSAL')
@IsNumber(FINITE)
@IsPositive()
quantity?: number;
@ApiPropertyOptional({ description: 'DISPOSAL: Pflicht' })
@ValidateIf(
(dto: CreateMovementDto) => dto.type === 'DISPOSAL' || dto.reason != null,
)
@IsString()
@IsNotEmpty()
@Matches(/\S/, { message: 'reason darf nicht nur aus Leerzeichen bestehen' })
@MaxLength(MAX_REASON_LENGTH)
reason?: string;
}
@@ -0,0 +1,108 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsBoolean,
IsEnum,
IsInt,
IsNotEmpty,
IsNumber,
IsOptional,
IsPositive,
IsString,
Matches,
Min,
MaxLength,
} from 'class-validator';
import { MedicineUnit } from '../../../generated/prisma';
import { IsOptionalNotNull } from '../../common/validation/is-optional-not-null';
export const MAX_NAME_LENGTH = 200;
export const MAX_ADDRESS_LENGTH = 300;
export const MAX_NOTE_LENGTH = 2000;
const MAX_DOSE_NOTE_LENGTH = 500;
const FINITE = { allowNaN: false, allowInfinity: false } as const;
/**
* Every value comes from the package leaflet or the prescription and is typed
* in by the beekeeper - the code ships no medicine data (assumption 10).
*/
export class CreateVeterinaryMedicineDto {
@ApiProperty({ description: 'Handelsname', maxLength: MAX_NAME_LENGTH })
@IsString()
@IsNotEmpty()
@Matches(/\S/, { message: 'name darf nicht nur aus Leerzeichen bestehen' })
@MaxLength(MAX_NAME_LENGTH)
name!: string;
@ApiProperty({
enum: MedicineUnit,
description: 'Nur änderbar, solange keine Packung existiert.',
})
@IsEnum(MedicineUnit)
unit!: MedicineUnit;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(MAX_NAME_LENGTH)
activeIngredient?: string | null;
@ApiPropertyOptional({ nullable: true, description: 'Zulassungsnummer' })
@IsOptional()
@IsString()
@MaxLength(MAX_NAME_LENGTH)
authorisationNumber?: string | null;
@ApiPropertyOptional()
@IsOptionalNotNull()
@IsBoolean()
prescriptionOnly?: boolean;
@ApiPropertyOptional({
nullable: true,
description: 'Wartezeit Honig in Tagen, auch 0',
})
@IsOptional()
@IsInt()
@Min(0)
withdrawalPeriodDays?: number | null;
@ApiPropertyOptional({ nullable: true, description: 'In der Einheit' })
@IsOptional()
@IsNumber(FINITE)
@IsPositive()
dosePerColony?: number | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(MAX_DOSE_NOTE_LENGTH)
doseNote?: string | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(MAX_NAME_LENGTH)
applicationMethod?: string | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsNumber(FINITE)
minTemperatureC?: number | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsNumber(FINITE)
maxTemperatureC?: number | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(MAX_ADDRESS_LENGTH)
defaultSupplier?: string | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(MAX_NOTE_LENGTH)
notes?: string | null;
}
@@ -0,0 +1,38 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsBoolean,
IsOptional,
IsString,
Matches,
MaxLength,
} from 'class-validator';
import { ID_PATTERN } from './create-lot.dto';
import { booleanQuery } from './medicine-list-query.dto';
export class LotListQuery {
@ApiPropertyOptional()
@IsOptional()
@IsString()
@Matches(ID_PATTERN)
@MaxLength(64)
medicineId?: string;
@ApiPropertyOptional({
description: 'Nur Packungen mit Rest > 0, älteste Haltbarkeit zuerst',
})
@IsOptional()
@Transform(booleanQuery)
@IsBoolean()
selectable?: boolean;
@ApiPropertyOptional({
description:
'Mit selectable: diese Packung bleibt wählbar, auch wenn sie leer oder ihr Mittel inaktiv ist (Bearbeiten einer Behandlung)',
})
@IsOptional()
@IsString()
@Matches(ID_PATTERN)
@MaxLength(64)
includeLotId?: string;
}
@@ -0,0 +1,18 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, TransformFnParams } from 'class-transformer';
import { IsBoolean, IsOptional } from 'class-validator';
/** Query strings arrive as text: only "true" and "false" are booleans. */
export function booleanQuery({ value }: TransformFnParams): unknown {
if (value === 'true') return true;
if (value === 'false') return false;
return value as unknown;
}
export class MedicineListQuery {
@ApiPropertyOptional({ description: 'Nur aktive oder nur inaktive Mittel' })
@IsOptional()
@Transform(booleanQuery)
@IsBoolean()
active?: boolean;
}
@@ -0,0 +1,7 @@
import { PartialType } from '@nestjs/swagger';
import { CreateLotDto } from './create-lot.dto';
/** `medicineId` is fixed once a movement exists (409, checked in the service). */
export class UpdateLotDto extends PartialType(CreateLotDto, {
skipNullProperties: false,
}) {}
@@ -0,0 +1,8 @@
import { PartialType } from '@nestjs/swagger';
import { CreateVeterinaryMedicineDto } from './create-veterinary-medicine.dto';
/** Omitted stays, null clears a nullable field, null on a required one is a 400. */
export class UpdateVeterinaryMedicineDto extends PartialType(
CreateVeterinaryMedicineDto,
{ skipNullProperties: false },
) {}
@@ -0,0 +1,110 @@
import { summarizeLot, treatmentBookingDelta } from './medicine-ledger';
describe('summarizeLot', () => {
it('rounds to thousandths', () => {
expect(
summarizeLot(10, [{ quantity: -3.3333 }, { quantity: -3.3333 }])
.remaining,
).toBe(3.333);
});
it('reports what was used', () => {
expect(summarizeLot(10, [{ quantity: -4 }, { quantity: 1 }])).toEqual({
remaining: 7,
used: 3,
});
});
it('lets the rest go negative', () => {
expect(summarizeLot(1, [{ quantity: -2.5 }]).remaining).toBe(-1.5);
});
it('is the initial quantity without movements', () => {
expect(summarizeLot(5, [])).toEqual({ remaining: 5, used: 0 });
});
});
describe('treatmentBookingDelta', () => {
it('books the full dose on create', () => {
expect(treatmentBookingDelta([], { lotId: 'A', dose: 5 })).toEqual([
{ lotId: 'A', quantity: -5 },
]);
});
it('moves the booking when the lot changes', () => {
expect(
treatmentBookingDelta([{ lotId: 'A', quantity: -5 }], {
lotId: 'B',
dose: 5,
}),
).toEqual([
{ lotId: 'A', quantity: 5 },
{ lotId: 'B', quantity: -5 },
]);
});
it('reverses the booking when dose or lot is cleared', () => {
expect(treatmentBookingDelta([{ lotId: 'A', quantity: -5 }], null)).toEqual(
[{ lotId: 'A', quantity: 5 }],
);
});
it('books nothing when nothing changed', () => {
expect(
treatmentBookingDelta([{ lotId: 'A', quantity: -5 }], {
lotId: 'A',
dose: 5,
}),
).toEqual([]);
expect(treatmentBookingDelta([], null)).toEqual([]);
});
it('books only the difference when the dose changes on the same lot', () => {
expect(
treatmentBookingDelta([{ lotId: 'A', quantity: -5 }], {
lotId: 'A',
dose: 7,
}),
).toEqual([{ lotId: 'A', quantity: -2 }]);
});
it('sums several earlier bookings of one lot and rounds the difference', () => {
expect(
treatmentBookingDelta(
[
{ lotId: 'A', quantity: -5 },
{ lotId: 'A', quantity: 5 },
{ lotId: 'A', quantity: -0.1 },
],
{ lotId: 'A', dose: 0.3 },
),
).toEqual([{ lotId: 'A', quantity: -0.2 }]);
});
it('counts an earlier dose-reduction booking', () => {
// dose 5 booked, later reduced to 3 (+2): the lot carries -3 in total
const current = [
{ lotId: 'A', quantity: -5 },
{ lotId: 'A', quantity: 2 },
];
expect(treatmentBookingDelta(current, { lotId: 'A', dose: 3 })).toEqual([]);
expect(treatmentBookingDelta(current, { lotId: 'A', dose: 4 })).toEqual([
{ lotId: 'A', quantity: -1 },
]);
expect(treatmentBookingDelta(current, null)).toEqual([
{ lotId: 'A', quantity: 3 },
]);
});
it('treats a fully reversed lot as nothing to do', () => {
expect(
treatmentBookingDelta(
[
{ lotId: 'A', quantity: -5 },
{ lotId: 'A', quantity: 5 },
],
null,
),
).toEqual([]);
});
});
@@ -0,0 +1,125 @@
/**
* Pure arithmetic of the veterinary medicine book - no database, so the rules
* that decide what is booked can be tested on their own.
*
* Rest of a lot = its initial quantity + the sum of its signed movements,
* rounded to thousandths. It is always derived, never stored, so it cannot
* drift from the book. A negative rest is allowed: the treatment happened,
* refusing it would only hide the gap.
*/
import type {
MedicineUnit,
VeterinaryMedicine,
VeterinaryMedicineMovementType,
} from '../../generated/prisma';
import { roundToThousandths } from '../common/quantity';
export interface LotLedgerEntry {
/** Signed: negative takes medicine out of the lot. */
quantity: number;
}
export function summarizeLot(
initial: number,
entries: readonly LotLedgerEntry[],
): { remaining: number; used: number } {
const remaining = roundToThousandths(
entries.reduce((sum, entry) => sum + entry.quantity, initial),
);
return { remaining, used: roundToThousandths(initial - remaining) };
}
/** The signed sum of the TREATMENT bookings made so far on one lot. */
export interface BookedPerLot {
lotId: string;
quantity: number;
}
export interface DesiredBooking {
lotId: string;
/** > 0: what the treatment takes out of the lot. */
dose: number;
}
/**
* What to book so that the treatment's bookings add up to the wanted state:
* a counter-booking on every lot that carries too much or too little, and
* -dose on the wanted lot. Nothing to do yields an empty list. Bookings are
* never changed or deleted, a correction is a new booking.
*/
export function treatmentBookingDelta(
current: readonly BookedPerLot[],
desired: DesiredBooking | null,
): BookedPerLot[] {
const bookedByLot = new Map<string, number>();
for (const booking of current) {
bookedByLot.set(
booking.lotId,
(bookedByLot.get(booking.lotId) ?? 0) + booking.quantity,
);
}
if (desired !== null && !bookedByLot.has(desired.lotId)) {
bookedByLot.set(desired.lotId, 0);
}
const deltas: BookedPerLot[] = [];
for (const [lotId, booked] of bookedByLot) {
const target = desired?.lotId === lotId ? -desired.dose : 0;
const quantity = roundToThousandths(target - booked);
if (quantity !== 0) {
deltas.push({ lotId, quantity });
}
}
return deltas;
}
/** Dates without a time of day travel as YYYY-MM-DD. */
export type MedicineView = VeterinaryMedicine & {
/** Σ rest of all lots of the medicine (a lot below zero counts negative). */
totalRemaining: number;
/** Earliest expiry of a lot that still has a rest, or null. */
nextExpiry: string | null;
lotCount: number;
};
export interface LotView {
id: string;
medicineId: string;
medicineName: string;
unit: MedicineUnit;
batchNumber: string | null;
purchasedOn: string;
quantity: number;
remaining: number;
expiresOn: string | null;
supplier: string | null;
purchaseProof: string | null;
prescribingVet: string | null;
notes: string | null;
movementCount: number;
}
export interface MovementView {
id: string;
type: VeterinaryMedicineMovementType;
quantity: number;
treatmentId: string | null;
reason: string | null;
userId: string | null;
createdAt: Date;
}
export interface LotDetailView extends LotView {
movements: MovementView[];
}
export interface MedicineSuggestion {
name: string;
treatmentCount: number;
latest: {
activeIngredient: string | null;
withdrawalPeriodDays: number | null;
method: string | null;
amount: string | null;
};
}
@@ -0,0 +1,107 @@
import type {
MedicineUnit,
VeterinaryMedicine,
VeterinaryMedicineLot,
} from '../../generated/prisma';
import type { DbClient } from '../prisma/db-client';
import {
summarizeLot,
type LotView,
type MedicineView,
} from './medicine-ledger';
import { roundToThousandths } from '../common/quantity';
/** Σ and number of the movements of one lot. */
export interface LotTotals {
sum: number;
count: number;
}
const NO_MOVEMENTS: LotTotals = { sum: 0, count: 0 };
/** The medicine a lot view names. */
export const LOT_MEDICINE_INCLUDE = {
medicine: { select: { name: true, unit: true } },
} as const;
export type LotRow = VeterinaryMedicineLot & {
medicine: { name: string; unit: MedicineUnit };
};
/** One grouped query for all lots, never one per lot. */
export async function lotTotals(
db: DbClient,
lotIds: readonly string[],
): Promise<Map<string, LotTotals>> {
if (lotIds.length === 0) {
return new Map();
}
const rows = await db.veterinaryMedicineMovement.groupBy({
by: ['lotId'],
where: { lotId: { in: [...lotIds] } },
_sum: { quantity: true },
_count: { _all: true },
});
return new Map(
rows.map((row) => [
row.lotId,
{ sum: row._sum.quantity ?? 0, count: row._count._all },
]),
);
}
export const dayText = (date: Date): string => date.toISOString().slice(0, 10);
export function remainingOf(
lot: { quantity: number },
totals: LotTotals | undefined,
): number {
return summarizeLot(lot.quantity, [
{ quantity: (totals ?? NO_MOVEMENTS).sum },
]).remaining;
}
export function toLotView(lot: LotRow, totals: LotTotals | undefined): LotView {
return {
id: lot.id,
medicineId: lot.medicineId,
medicineName: lot.medicine.name,
unit: lot.medicine.unit,
batchNumber: lot.batchNumber,
purchasedOn: dayText(lot.purchasedOn),
quantity: lot.quantity,
remaining: remainingOf(lot, totals),
expiresOn: lot.expiresOn === null ? null : dayText(lot.expiresOn),
supplier: lot.supplier,
purchaseProof: lot.purchaseProof,
prescribingVet: lot.prescribingVet,
notes: lot.notes,
movementCount: (totals ?? NO_MOVEMENTS).count,
};
}
export function toMedicineView(
medicine: VeterinaryMedicine,
lots: readonly Pick<VeterinaryMedicineLot, 'id' | 'quantity' | 'expiresOn'>[],
totals: ReadonlyMap<string, LotTotals>,
): MedicineView {
let total = 0;
let nextExpiry: Date | null = null;
for (const lot of lots) {
const remaining = remainingOf(lot, totals.get(lot.id));
total += remaining;
if (
remaining > 0 &&
lot.expiresOn !== null &&
(nextExpiry === null || lot.expiresOn < nextExpiry)
) {
nextExpiry = lot.expiresOn;
}
}
return {
...medicine,
totalRemaining: roundToThousandths(total),
nextExpiry: nextExpiry === null ? null : dayText(nextExpiry),
lotCount: lots.length,
};
}
@@ -0,0 +1,68 @@
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 { CreateLotDto } from './dto/create-lot.dto';
import { CreateMovementDto } from './dto/create-movement.dto';
import { LotListQuery } from './dto/lot-list-query.dto';
import { UpdateLotDto } from './dto/update-lot.dto';
import { VeterinaryMedicineLotsService } from './veterinary-medicine-lots.service';
// There is deliberately no route to change or delete a movement: the book is
// append-only, a mistake is booked back.
@ApiTags('veterinary-medicines')
@ApiBearerAuth()
@Controller('veterinary-medicine-lots')
export class VeterinaryMedicineLotsController {
constructor(private readonly lots: VeterinaryMedicineLotsService) {}
@RequirePermission('veterinary-medicines:read')
@Get()
findAll(@Query() query: LotListQuery) {
return this.lots.findAll(query);
}
@RequirePermission('veterinary-medicines:read')
@Get(':id')
findOne(@Param('id') id: string) {
return this.lots.findOne(id);
}
@RequirePermission('veterinary-medicines:create')
@Idempotent()
@Post()
create(@Body() dto: CreateLotDto, @CurrentUser() actor: AuthenticatedUser) {
return this.lots.create(dto, actor.id);
}
@RequirePermission('veterinary-medicines:update')
@Patch(':id')
update(
@Param('id') id: string,
@Body() dto: UpdateLotDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.lots.update(id, dto, actor.id);
}
@RequirePermission('veterinary-medicines:update')
@Idempotent()
@Post(':id/movements')
addMovement(
@Param('id') id: string,
@Body() dto: CreateMovementDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.lots.addMovement(id, dto, actor.id);
}
}
@@ -0,0 +1,364 @@
import {
BadRequestException,
ConflictException,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
import { VeterinaryMedicineLotsService } from './veterinary-medicine-lots.service';
jest.mock('../prisma/row-lock', () => ({
lockRowForUpdate: jest.fn().mockResolvedValue(true),
lockRowsForUpdate: jest.fn().mockResolvedValue(undefined),
}));
const lot = {
id: 'l1',
medicineId: 'm1',
batchNumber: 'CH-1',
purchasedOn: new Date('2026-08-01T00:00:00Z'),
quantity: 10,
expiresOn: null,
supplier: null,
purchaseProof: null,
prescribingVet: null,
notes: null,
userId: 'u1',
createdAt: new Date(0),
updatedAt: new Date(0),
medicine: { name: 'Mittel A', unit: 'ML' },
};
function lotAt(id: string, quantity: number, expiresOn: string | null) {
return {
...lot,
id,
quantity,
expiresOn: expiresOn === null ? null : new Date(`${expiresOn}T00:00:00Z`),
};
}
describe('VeterinaryMedicineLotsService', () => {
let service: VeterinaryMedicineLotsService;
let tx: {
veterinaryMedicine: { findUnique: jest.Mock };
veterinaryMedicineLot: {
findUnique: jest.Mock;
update: jest.Mock;
create: jest.Mock;
};
veterinaryMedicineMovement: {
aggregate: jest.Mock;
count: jest.Mock;
create: jest.Mock;
findMany: jest.Mock;
groupBy: jest.Mock;
};
};
let prisma: {
$transaction: jest.Mock;
veterinaryMedicineLot: { findMany: jest.Mock };
veterinaryMedicineMovement: { groupBy: jest.Mock };
};
let auditLog: { record: jest.Mock };
beforeEach(() => {
(lockRowForUpdate as jest.Mock).mockResolvedValue(true);
(lockRowsForUpdate as jest.Mock).mockResolvedValue(undefined);
tx = {
veterinaryMedicine: {
findUnique: jest
.fn()
.mockResolvedValue({ id: 'm1', unit: 'ML', active: true }),
},
veterinaryMedicineLot: {
findUnique: jest.fn().mockResolvedValue(lot),
update: jest
.fn()
.mockImplementation(({ data }: { data: object }) =>
Promise.resolve({ ...lot, ...data }),
),
create: jest.fn().mockResolvedValue(lot),
},
veterinaryMedicineMovement: {
aggregate: jest.fn().mockResolvedValue({ _sum: { quantity: -3 } }),
count: jest.fn().mockResolvedValue(0),
create: jest.fn().mockResolvedValue({}),
findMany: jest.fn().mockResolvedValue([]),
groupBy: jest.fn().mockResolvedValue([]),
},
};
prisma = {
$transaction: jest
.fn()
.mockImplementation((fn: (client: unknown) => unknown) => fn(tx)),
veterinaryMedicineLot: { findMany: jest.fn() },
veterinaryMedicineMovement: { groupBy: jest.fn() },
};
auditLog = { record: jest.fn() };
service = new VeterinaryMedicineLotsService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
);
});
describe('addMovement', () => {
it('CORRECTION books the delta between the rest and the actual rest', async () => {
// rest 10 - 3 = 7, counted 5
await service.addMovement(
'l1',
{ type: 'CORRECTION', actualRemaining: 5 },
'u1',
);
expect(tx.veterinaryMedicineMovement.create).toHaveBeenCalledWith({
data: {
lotId: 'l1',
type: 'CORRECTION',
quantity: -2,
reason: null,
userId: 'u1',
},
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'VeterinaryMedicineLot',
entityId: 'l1',
action: 'UPDATE',
beforeData: expect.objectContaining({ remaining: 7 }) as object,
afterData: expect.objectContaining({ remaining: 5 }) as object,
}),
tx,
);
});
it('CORRECTION books a positive delta when more is found', async () => {
await service.addMovement(
'l1',
{ type: 'CORRECTION', actualRemaining: 8.5 },
'u1',
);
expect(tx.veterinaryMedicineMovement.create).toHaveBeenCalledWith({
data: expect.objectContaining({ quantity: 1.5 }) as object,
});
});
it('CORRECTION books nothing for delta 0', async () => {
await service.addMovement(
'l1',
{ type: 'CORRECTION', actualRemaining: 7 },
'u1',
);
expect(tx.veterinaryMedicineMovement.create).not.toHaveBeenCalled();
expect(auditLog.record).not.toHaveBeenCalled();
});
it('locks the lot before it reads the rest', async () => {
const order: string[] = [];
(lockRowForUpdate as jest.Mock).mockImplementation(() => {
order.push('lock');
return Promise.resolve(true);
});
tx.veterinaryMedicineMovement.aggregate.mockImplementation(() => {
order.push('sum');
return Promise.resolve({ _sum: { quantity: 0 } });
});
await service.addMovement(
'l1',
{ type: 'CORRECTION', actualRemaining: 1 },
'u1',
);
expect(order).toEqual(['lock', 'sum']);
});
it('DISPOSAL books the negative quantity and needs a reason', async () => {
await service.addMovement(
'l1',
{ type: 'DISPOSAL', quantity: 2, reason: 'abgelaufen' },
'u1',
);
expect(tx.veterinaryMedicineMovement.create).toHaveBeenCalledWith({
data: {
lotId: 'l1',
type: 'DISPOSAL',
quantity: -2,
reason: 'abgelaufen',
userId: 'u1',
},
});
await expect(
service.addMovement('l1', { type: 'DISPOSAL', quantity: 2 }, 'u1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('answers 404 for an unknown lot', async () => {
(lockRowForUpdate as jest.Mock).mockResolvedValue(false);
await expect(
service.addMovement(
'x',
{ type: 'CORRECTION', actualRemaining: 1 },
'u1',
),
).rejects.toBeInstanceOf(NotFoundException);
});
});
describe('update', () => {
it('rejects changing medicineId once a movement exists with 409', async () => {
tx.veterinaryMedicineMovement.count.mockResolvedValue(1);
await expect(
service.update('l1', { medicineId: 'm2' }, 'u1'),
).rejects.toBeInstanceOf(ConflictException);
expect(tx.veterinaryMedicineLot.update).not.toHaveBeenCalled();
});
it('locks the medicines before the lot', async () => {
const order: string[] = [];
(lockRowsForUpdate as jest.Mock).mockImplementation(() => {
order.push('medicines');
return Promise.resolve();
});
(lockRowForUpdate as jest.Mock).mockImplementation(() => {
order.push('lot');
return Promise.resolve(true);
});
await service.update('l1', { medicineId: 'm2' }, 'u1');
expect(order).toEqual(['medicines', 'lot']);
});
it('rejects a move to a medicine with another unit with 400', async () => {
tx.veterinaryMedicine.findUnique.mockResolvedValue({
id: 'm2',
unit: 'G',
active: true,
});
await expect(
service.update('l1', { medicineId: 'm2' }, 'u1'),
).rejects.toThrow('Die Einheit des Mittels passt nicht zur Packung.');
expect(tx.veterinaryMedicineLot.update).not.toHaveBeenCalled();
});
it('rejects a move to an inactive medicine with 400', async () => {
tx.veterinaryMedicine.findUnique.mockResolvedValue({
id: 'm2',
unit: 'ML',
active: false,
});
await expect(
service.update('l1', { medicineId: 'm2' }, 'u1'),
).rejects.toThrow('Das Mittel ist deaktiviert.');
});
it('allows the other fields with movements', async () => {
tx.veterinaryMedicineMovement.count.mockResolvedValue(5);
await service.update(
'l1',
{ supplier: 'Imkerei Muster', notes: null },
'u1',
);
expect(tx.veterinaryMedicineLot.update).toHaveBeenCalledWith({
where: { id: 'l1' },
data: { supplier: 'Imkerei Muster', notes: null },
include: { medicine: { select: { name: true, unit: true } } },
});
});
});
describe('create', () => {
it('rejects a lot for an inactive medicine with 400', async () => {
tx.veterinaryMedicine.findUnique.mockResolvedValue({
id: 'm1',
unit: 'ML',
active: false,
});
await expect(
service.create(
{ medicineId: 'm1', purchasedOn: '2026-08-01', quantity: 10 },
'u1',
),
).rejects.toThrow('Das Mittel ist deaktiviert.');
expect(tx.veterinaryMedicineLot.create).not.toHaveBeenCalled();
});
it('locks the medicine and answers 404 for an unknown one', async () => {
(lockRowForUpdate as jest.Mock).mockResolvedValue(false);
await expect(
service.create(
{ medicineId: 'x', purchasedOn: '2026-08-01', quantity: 10 },
'u1',
),
).rejects.toBeInstanceOf(NotFoundException);
expect(tx.veterinaryMedicineLot.create).not.toHaveBeenCalled();
});
});
describe('findAll', () => {
beforeEach(() => {
prisma.veterinaryMedicineLot.findMany.mockResolvedValue([
lotAt('late', 10, '2027-06-01'),
lotAt('empty', 5, '2026-01-01'),
lotAt('early', 10, '2026-09-01'),
lotAt('none', 10, null),
]);
prisma.veterinaryMedicineMovement.groupBy.mockResolvedValue([
{ lotId: 'empty', _sum: { quantity: -5 }, _count: { _all: 1 } },
{ lotId: 'early', _sum: { quantity: -12 }, _count: { _all: 2 } },
]);
});
it('selectable lots are ordered oldest expiry first and exclude an empty lot', async () => {
const lots = await service.findAll({ selectable: true });
// 'empty' (rest 0) and 'early' (rest -2) have nothing left to take
expect(lots.map((entry) => entry.id)).toEqual(['late', 'none']);
});
it('asks only for lots of active medicines when selectable, plus includeLotId', async () => {
await service.findAll({ selectable: true, includeLotId: 'keep' });
expect(prisma.veterinaryMedicineLot.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
medicineId: undefined,
OR: [{ medicine: { active: true } }, { id: 'keep' }],
},
}),
);
});
it('keeps the includeLotId lot although it is empty', async () => {
const lots = await service.findAll({
selectable: true,
includeLotId: 'empty',
});
expect(lots.map((entry) => entry.id)).toEqual(['empty', 'late', 'none']);
});
it('a negative rest is allowed and reported in the view', async () => {
const lots = await service.findAll({});
expect(lots.find((entry) => entry.id === 'early')).toMatchObject({
remaining: -2,
movementCount: 2,
});
expect(lots).toHaveLength(4);
});
});
});
@@ -0,0 +1,386 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma, type MedicineUnit } from '../../generated/prisma';
import { AuditLogService } from '../audit/audit-log.service';
import { dayToDate } from '../common/dates';
import { roundToThousandths } from '../common/quantity';
import { DbClient } from '../prisma/db-client';
import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate, lockRowsForUpdate } from '../prisma/row-lock';
import { fifoOrder } from '../stock/stock-ledger';
import { CreateLotDto } from './dto/create-lot.dto';
import { CreateMovementDto } from './dto/create-movement.dto';
import { UpdateLotDto } from './dto/update-lot.dto';
import {
summarizeLot,
type LotDetailView,
type LotView,
} from './medicine-ledger';
import {
LOT_MEDICINE_INCLUDE,
lotTotals,
remainingOf,
toLotView,
type LotRow,
} from './medicine-views';
const AUDIT_ENTITY = 'VeterinaryMedicineLot';
const MEDICINE_NOT_FOUND = 'Tierarzneimittel nicht gefunden';
const LOT_NOT_FOUND = 'Packung nicht gefunden';
export interface LotListFilter {
medicineId?: string;
selectable?: boolean;
/** With `selectable`: this lot stays in the list even when empty or inactive. */
includeLotId?: string;
}
/** The lot row as audited: without the medicine the view joins in. */
function auditRow(lot: LotRow, remaining?: number): object {
const { id, medicineId, batchNumber, purchasedOn, quantity, expiresOn } = lot;
const row = {
id,
medicineId,
batchNumber,
purchasedOn,
quantity,
expiresOn,
supplier: lot.supplier,
purchaseProof: lot.purchaseProof,
prescribingVet: lot.prescribingVet,
notes: lot.notes,
userId: lot.userId,
createdAt: lot.createdAt,
updatedAt: lot.updatedAt,
};
return remaining === undefined ? row : { ...row, remaining };
}
@Injectable()
export class VeterinaryMedicineLotsService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
) {}
/**
* Newest purchase first. `selectable`: lots of active medicines with a rest
* > 0, oldest expiry first (FIFO); `includeLotId` (the lot of a treatment
* being edited) stays selectable even when empty or its medicine is inactive.
*/
async findAll(filter: LotListFilter): Promise<LotView[]> {
const lots = await this.prisma.veterinaryMedicineLot.findMany({
where: {
medicineId: filter.medicineId,
...(filter.selectable && {
OR: [
{ medicine: { active: true } },
...(filter.includeLotId ? [{ id: filter.includeLotId }] : []),
],
}),
},
include: LOT_MEDICINE_INCLUDE,
orderBy: [{ purchasedOn: 'desc' }, { id: 'asc' }],
});
const totals = await lotTotals(
this.prisma,
lots.map((lot) => lot.id),
);
const entries = lots.map((lot) => ({
id: lot.id,
remaining: remainingOf(lot, totals.get(lot.id)),
bestBeforeDate: lot.expiresOn,
date: lot.purchasedOn,
view: toLotView(lot, totals.get(lot.id)),
}));
const chosen = filter.selectable
? fifoOrder(
entries.filter(
(entry) => entry.remaining > 0 || entry.id === filter.includeLotId,
),
)
: entries;
return chosen.map((entry) => entry.view);
}
async findOne(id: string): Promise<LotDetailView> {
return this.detail(this.prisma, id);
}
async create(dto: CreateLotDto, actingUserId: string): Promise<LotView> {
return this.prisma.$transaction(async (tx) => {
// The medicine first: changing its unit counts the lots under this lock.
if (!(await lockRowForUpdate(tx, 'VeterinaryMedicine', dto.medicineId))) {
throw new NotFoundException(MEDICINE_NOT_FOUND);
}
await this.assertMedicineActive(tx, dto.medicineId);
const created = await tx.veterinaryMedicineLot.create({
data: {
...dto,
purchasedOn: dayToDate(dto.purchasedOn),
expiresOn:
dto.expiresOn == null ? dto.expiresOn : dayToDate(dto.expiresOn),
userId: actingUserId,
},
include: LOT_MEDICINE_INCLUDE,
});
await this.auditLog.record(
{
entityType: AUDIT_ENTITY,
entityId: created.id,
action: 'CREATE',
userId: actingUserId,
afterData: auditRow(created),
},
tx,
);
return toLotView(created, undefined);
});
}
/**
* The medicine of a lot is fixed once a movement exists (409). Lock order:
* medicines (ascending), then the lot - the order of the lock map.
*/
async update(
id: string,
dto: UpdateLotDto,
actingUserId: string,
): Promise<LotView> {
return this.prisma.$transaction(async (tx) => {
const readMedicineId = await this.lockMedicinesThenLot(tx, id, dto);
const before = await tx.veterinaryMedicineLot.findUnique({
where: { id },
include: LOT_MEDICINE_INCLUDE,
});
if (!before) {
throw new NotFoundException(LOT_NOT_FOUND);
}
if (
dto.medicineId !== undefined &&
dto.medicineId !== before.medicineId
) {
await this.assertMedicineChangeable(tx, before, readMedicineId, dto);
}
const { purchasedOn, expiresOn, ...rest } = dto;
const updated = await tx.veterinaryMedicineLot.update({
where: { id },
data: {
...rest,
...(purchasedOn !== undefined && {
purchasedOn: dayToDate(purchasedOn),
}),
...(expiresOn !== undefined && {
expiresOn: expiresOn === null ? null : dayToDate(expiresOn),
}),
},
include: LOT_MEDICINE_INCLUDE,
});
await this.auditLog.record(
{
entityType: AUDIT_ENTITY,
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: auditRow(before),
afterData: auditRow(updated),
},
tx,
);
const totals = await lotTotals(tx, [id]);
return toLotView(updated, totals.get(id));
});
}
/**
* CORRECTION books the difference between the rest in the book and the rest
* counted (nothing for a difference of 0); DISPOSAL books the quantity as a
* negative one. The rest is read under the lot lock, so two inventories at
* the same time are applied one after the other.
*/
async addMovement(
id: string,
dto: CreateMovementDto,
actingUserId: string,
): Promise<LotDetailView> {
const manual = this.manualBooking(dto);
return this.prisma.$transaction(async (tx) => {
if (!(await lockRowForUpdate(tx, 'VeterinaryMedicineLot', id))) {
throw new NotFoundException(LOT_NOT_FOUND);
}
const lot = await tx.veterinaryMedicineLot.findUnique({
where: { id },
include: LOT_MEDICINE_INCLUDE,
});
if (!lot) {
throw new NotFoundException(LOT_NOT_FOUND);
}
const booked = await tx.veterinaryMedicineMovement.aggregate({
where: { lotId: id },
_sum: { quantity: true },
});
const { remaining } = summarizeLot(lot.quantity, [
{ quantity: booked._sum.quantity ?? 0 },
]);
const quantity =
manual.type === 'CORRECTION'
? roundToThousandths(manual.actualRemaining - remaining)
: roundToThousandths(-manual.quantity);
if (quantity === 0) {
if (manual.type === 'DISPOSAL') {
throw new BadRequestException('Die Menge ist zu klein');
}
return this.detail(tx, id);
}
await tx.veterinaryMedicineMovement.create({
data: {
lotId: id,
type: manual.type,
quantity,
reason: manual.reason,
userId: actingUserId,
},
});
await this.auditLog.record(
{
entityType: AUDIT_ENTITY,
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: auditRow(lot, remaining),
afterData: auditRow(lot, roundToThousandths(remaining + quantity)),
},
tx,
);
return this.detail(tx, id);
});
}
private manualBooking(
dto: CreateMovementDto,
):
| { type: 'CORRECTION'; actualRemaining: number; reason: string | null }
| { type: 'DISPOSAL'; quantity: number; reason: string } {
if (dto.type === 'CORRECTION') {
if (dto.actualRemaining === undefined || !(dto.actualRemaining >= 0)) {
throw new BadRequestException('Der tatsächliche Rest fehlt');
}
return {
type: 'CORRECTION',
actualRemaining: dto.actualRemaining,
reason: dto.reason ?? null,
};
}
if (dto.quantity === undefined || !(dto.quantity > 0)) {
throw new BadRequestException('Die Menge muss größer als 0 sein');
}
if (dto.reason === undefined || dto.reason.trim() === '') {
throw new BadRequestException('Für eine Entsorgung ist ein Grund nötig');
}
return { type: 'DISPOSAL', quantity: dto.quantity, reason: dto.reason };
}
/** Returns the medicine id read before the locks, to detect a concurrent change. */
private async lockMedicinesThenLot(
tx: Prisma.TransactionClient,
id: string,
dto: UpdateLotDto,
): Promise<string | null> {
let readMedicineId: string | null = null;
if (dto.medicineId !== undefined) {
const current = await tx.veterinaryMedicineLot.findUnique({
where: { id },
select: { medicineId: true },
});
if (!current) {
throw new NotFoundException(LOT_NOT_FOUND);
}
readMedicineId = current.medicineId;
await lockRowsForUpdate(tx, 'VeterinaryMedicine', [
current.medicineId,
dto.medicineId,
]);
}
if (!(await lockRowForUpdate(tx, 'VeterinaryMedicineLot', id))) {
throw new NotFoundException(LOT_NOT_FOUND);
}
return readMedicineId;
}
/** Read under the medicine lock: an inactive medicine takes no new stock. */
private async assertMedicineActive(
tx: Prisma.TransactionClient,
medicineId: string | undefined,
): Promise<{ unit: MedicineUnit }> {
const medicine = await tx.veterinaryMedicine.findUnique({
where: { id: medicineId },
select: { unit: true, active: true },
});
if (!medicine) {
throw new NotFoundException(MEDICINE_NOT_FOUND);
}
if (!medicine.active) {
throw new BadRequestException('Das Mittel ist deaktiviert.');
}
return medicine;
}
private async assertMedicineChangeable(
tx: Prisma.TransactionClient,
before: LotRow,
readMedicineId: string | null,
dto: UpdateLotDto,
): Promise<void> {
if (readMedicineId !== before.medicineId) {
throw new ConflictException(
'Die Packung wurde gerade geändert. Bitte neu laden.',
);
}
const target = await this.assertMedicineActive(tx, dto.medicineId);
if (target.unit !== before.medicine.unit) {
throw new BadRequestException(
'Die Einheit des Mittels passt nicht zur Packung.',
);
}
if (
(await tx.veterinaryMedicineMovement.count({
where: { lotId: before.id },
})) > 0
) {
throw new ConflictException(
'Das Mittel ist festgelegt, sobald Buchungen existieren',
);
}
}
private async detail(db: DbClient, id: string): Promise<LotDetailView> {
const lot = await db.veterinaryMedicineLot.findUnique({
where: { id },
include: LOT_MEDICINE_INCLUDE,
});
if (!lot) {
throw new NotFoundException(LOT_NOT_FOUND);
}
const [totals, movements] = await Promise.all([
lotTotals(db, [id]),
db.veterinaryMedicineMovement.findMany({
where: { lotId: id },
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
select: {
id: true,
type: true,
quantity: true,
treatmentId: true,
reason: true,
userId: true,
createdAt: true,
},
}),
]);
return { ...toLotView(lot, totals.get(id)), movements };
}
}
@@ -0,0 +1,39 @@
import {
describeNullContracts,
NullContract,
} from '../common/validation/null-contract.spec-helper';
import { UpdateVeterinaryMedicineDto } from './dto/update-veterinary-medicine.dto';
import { UpdateLotDto } from './dto/update-lot.dto';
const contracts: Record<string, NullContract> = {
UpdateVeterinaryMedicineDto: {
dto: UpdateVeterinaryMedicineDto,
notNull: ['name', 'unit', 'prescriptionOnly'],
clearable: [
'activeIngredient',
'authorisationNumber',
'withdrawalPeriodDays',
'dosePerColony',
'doseNote',
'applicationMethod',
'minTemperatureC',
'maxTemperatureC',
'defaultSupplier',
'notes',
],
},
UpdateLotDto: {
dto: UpdateLotDto,
notNull: ['medicineId', 'purchasedOn', 'quantity'],
clearable: [
'batchNumber',
'expiresOn',
'supplier',
'purchaseProof',
'prescribingVet',
'notes',
],
},
};
describeNullContracts('Veterinary medicine update DTOs', contracts);
@@ -0,0 +1,76 @@
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 { CreateVeterinaryMedicineDto } from './dto/create-veterinary-medicine.dto';
import { MedicineListQuery } from './dto/medicine-list-query.dto';
import { UpdateVeterinaryMedicineDto } from './dto/update-veterinary-medicine.dto';
import { VeterinaryMedicinesService } from './veterinary-medicines.service';
@ApiTags('veterinary-medicines')
@ApiBearerAuth()
@Controller('veterinary-medicines')
export class VeterinaryMedicinesController {
constructor(private readonly medicines: VeterinaryMedicinesService) {}
@RequirePermission('veterinary-medicines:read')
@Get()
findAll(@Query() query: MedicineListQuery) {
return this.medicines.findAll(query.active);
}
// Before ':id', so 'suggestions' is not read as an id.
@RequirePermission('veterinary-medicines:read')
@Get('suggestions')
suggestions() {
return this.medicines.suggestions();
}
@RequirePermission('veterinary-medicines:read')
@Get(':id')
findOne(@Param('id') id: string) {
return this.medicines.findOne(id);
}
@RequirePermission('veterinary-medicines:create')
@Idempotent()
@Post()
create(
@Body() dto: CreateVeterinaryMedicineDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.medicines.create(dto, actor.id);
}
@RequirePermission('veterinary-medicines:update')
@Patch(':id')
update(
@Param('id') id: string,
@Body() dto: UpdateVeterinaryMedicineDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.medicines.update(id, dto, actor.id);
}
@RequirePermission('veterinary-medicines:update')
@Post(':id/deactivate')
deactivate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.medicines.setActive(id, false, actor.id);
}
@RequirePermission('veterinary-medicines:update')
@Post(':id/reactivate')
reactivate(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.medicines.setActive(id, true, actor.id);
}
}
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { VeterinaryMedicineLotsController } from './veterinary-medicine-lots.controller';
import { VeterinaryMedicineLotsService } from './veterinary-medicine-lots.service';
import { VeterinaryMedicinesController } from './veterinary-medicines.controller';
import { VeterinaryMedicinesService } from './veterinary-medicines.service';
@Module({
controllers: [
VeterinaryMedicinesController,
VeterinaryMedicineLotsController,
],
providers: [VeterinaryMedicinesService, VeterinaryMedicineLotsService],
exports: [VeterinaryMedicinesService, VeterinaryMedicineLotsService],
})
export class VeterinaryMedicinesModule {}
@@ -0,0 +1,255 @@
import { BadRequestException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { lockRowForUpdate } from '../prisma/row-lock';
import { VeterinaryMedicinesService } from './veterinary-medicines.service';
jest.mock('../prisma/row-lock', () => ({
lockRowForUpdate: jest.fn().mockResolvedValue(true),
}));
const medicine = {
id: 'm1',
name: 'Mittel A',
activeIngredient: null,
authorisationNumber: null,
prescriptionOnly: false,
withdrawalPeriodDays: null,
unit: 'ML',
dosePerColony: null,
doseNote: null,
applicationMethod: null,
minTemperatureC: null,
maxTemperatureC: null,
defaultSupplier: null,
notes: null,
active: true,
createdAt: new Date(0),
updatedAt: new Date(0),
};
describe('VeterinaryMedicinesService', () => {
let service: VeterinaryMedicinesService;
let tx: {
veterinaryMedicine: {
findUnique: jest.Mock;
update: jest.Mock;
create: jest.Mock;
};
veterinaryMedicineLot: { count: jest.Mock; findMany: jest.Mock };
veterinaryMedicineMovement: { groupBy: jest.Mock };
};
let prisma: {
$transaction: jest.Mock;
$queryRaw: jest.Mock;
veterinaryMedicine: { findMany: jest.Mock };
veterinaryMedicineLot: { findMany: jest.Mock };
veterinaryMedicineMovement: { groupBy: jest.Mock };
};
let auditLog: { record: jest.Mock };
beforeEach(() => {
(lockRowForUpdate as jest.Mock).mockResolvedValue(true);
tx = {
veterinaryMedicine: {
findUnique: jest.fn().mockResolvedValue(medicine),
create: jest.fn().mockResolvedValue(medicine),
update: jest
.fn()
.mockImplementation(({ data }: { data: object }) =>
Promise.resolve({ ...medicine, ...data }),
),
},
veterinaryMedicineLot: {
count: jest.fn().mockResolvedValue(0),
findMany: jest.fn().mockResolvedValue([]),
},
veterinaryMedicineMovement: { groupBy: jest.fn().mockResolvedValue([]) },
};
prisma = {
$transaction: jest
.fn()
.mockImplementation((fn: (client: unknown) => unknown) => fn(tx)),
$queryRaw: jest.fn(),
veterinaryMedicine: { findMany: jest.fn() },
veterinaryMedicineLot: { findMany: jest.fn() },
veterinaryMedicineMovement: { groupBy: jest.fn() },
};
auditLog = { record: jest.fn() };
service = new VeterinaryMedicinesService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
);
});
it('rejects changing the unit once a lot exists with 409', async () => {
tx.veterinaryMedicineLot.count.mockResolvedValue(1);
await expect(
service.update('m1', { unit: 'G' }, 'u1'),
).rejects.toBeInstanceOf(ConflictException);
expect(tx.veterinaryMedicine.update).not.toHaveBeenCalled();
});
it('accepts the unit it already has even with lots', async () => {
tx.veterinaryMedicineLot.count.mockResolvedValue(1);
await service.update('m1', { unit: 'ML' }, 'u1');
expect(tx.veterinaryMedicine.update).toHaveBeenCalled();
});
it('allows editing every other field of a medicine with lots', async () => {
tx.veterinaryMedicineLot.count.mockResolvedValue(3);
const view = await service.update(
'm1',
{ name: 'Neu', withdrawalPeriodDays: 0, notes: null },
'u1',
);
expect(view.name).toBe('Neu');
expect(tx.veterinaryMedicine.update).toHaveBeenCalledWith({
where: { id: 'm1' },
data: { name: 'Neu', withdrawalPeriodDays: 0, notes: null },
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'VeterinaryMedicine',
action: 'UPDATE',
beforeData: medicine,
}),
tx,
);
});
it('locks the medicine before it reads the row to change', async () => {
const order: string[] = [];
(lockRowForUpdate as jest.Mock).mockImplementation(() => {
order.push('lock');
return Promise.resolve(true);
});
tx.veterinaryMedicine.findUnique.mockImplementation(() => {
order.push('read');
return Promise.resolve(medicine);
});
await service.update('m1', { name: 'Neu' }, 'u1');
expect(order).toEqual(['lock', 'read']);
});
it('setActive without a change returns the view and writes no audit row', async () => {
const view = await service.setActive('m1', true, 'u1');
expect(view.active).toBe(true);
expect(tx.veterinaryMedicine.update).not.toHaveBeenCalled();
expect(auditLog.record).not.toHaveBeenCalled();
});
it('setActive audits a real change', async () => {
await service.setActive('m1', false, 'u1');
expect(tx.veterinaryMedicine.update).toHaveBeenCalledWith({
where: { id: 'm1' },
data: { active: false },
});
expect(auditLog.record).toHaveBeenCalledTimes(1);
});
it('rejects a minimum above the maximum temperature with 400', async () => {
tx.veterinaryMedicine.findUnique.mockResolvedValue({
...medicine,
maxTemperatureC: 20,
});
await expect(
service.update('m1', { minTemperatureC: 25 }, 'u1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('creates a medicine with its audit entry in one transaction', async () => {
const view = await service.create({ name: 'Mittel A', unit: 'ML' }, 'u1');
expect(view).toMatchObject({ id: 'm1', totalRemaining: 0, lotCount: 0 });
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'CREATE', entityId: 'm1' }),
tx,
);
});
it('suggestions skip names already in the catalogue regardless of case and give the latest values', async () => {
prisma.$queryRaw.mockResolvedValue([
{
name: 'Oxuvar',
treatmentCount: 4,
activeIngredient: 'Oxalsäure',
withdrawalPeriodDays: 0,
method: 'Träufeln',
amount: '5 ml',
},
]);
const result = await service.suggestions();
expect(result).toEqual([
{
name: 'Oxuvar',
treatmentCount: 4,
latest: {
activeIngredient: 'Oxalsäure',
withdrawalPeriodDays: 0,
method: 'Träufeln',
amount: '5 ml',
},
},
]);
const query = (prisma.$queryRaw.mock.calls[0] as [{ sql: string }])[0];
expect(query.sql).toContain('lower(btrim(');
expect(query.sql).toContain('NOT EXISTS');
expect(query.sql).toContain('LIMIT 50');
});
it('lists the catalogue with the total rest and the next expiry without a query per medicine', async () => {
prisma.veterinaryMedicine.findMany.mockResolvedValue([
medicine,
{ ...medicine, id: 'm2', name: 'Mittel B' },
]);
prisma.veterinaryMedicineLot.findMany.mockResolvedValue([
{
id: 'l1',
medicineId: 'm1',
quantity: 10,
expiresOn: new Date('2027-05-01T00:00:00Z'),
},
{
id: 'l2',
medicineId: 'm1',
quantity: 4,
expiresOn: new Date('2026-12-01T00:00:00Z'),
},
{ id: 'l3', medicineId: 'm1', quantity: 2, expiresOn: null },
]);
prisma.veterinaryMedicineMovement.groupBy.mockResolvedValue([
{ lotId: 'l1', _sum: { quantity: -10 }, _count: { _all: 2 } },
]);
const list = await service.findAll();
expect(prisma.veterinaryMedicineMovement.groupBy).toHaveBeenCalledTimes(1);
expect(list[0]).toMatchObject({
id: 'm1',
lotCount: 3,
// l1 is used up (0), l2 has 4, l3 has 2
totalRemaining: 6,
// l1 has nothing left, so the next expiry is l2's
nextExpiry: '2026-12-01',
});
expect(list[1]).toMatchObject({
id: 'm2',
lotCount: 0,
totalRemaining: 0,
nextExpiry: null,
});
});
});
@@ -0,0 +1,276 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma, VeterinaryMedicine } from '../../generated/prisma';
import { AuditLogService } from '../audit/audit-log.service';
import { DbClient } from '../prisma/db-client';
import { PrismaService } from '../prisma/prisma.service';
import { lockRowForUpdate } from '../prisma/row-lock';
import {
toLotView,
lotTotals,
toMedicineView,
LOT_MEDICINE_INCLUDE,
} from './medicine-views';
import type {
LotView,
MedicineSuggestion,
MedicineView,
} from './medicine-ledger';
import { CreateVeterinaryMedicineDto } from './dto/create-veterinary-medicine.dto';
import { UpdateVeterinaryMedicineDto } from './dto/update-veterinary-medicine.dto';
const AUDIT_ENTITY = 'VeterinaryMedicine';
const SUGGESTION_LIMIT = 50;
interface SuggestionRow {
name: string;
treatmentCount: number;
activeIngredient: string | null;
withdrawalPeriodDays: number | null;
method: string | null;
amount: string | null;
}
function assertTemperatureRange(
min: number | null | undefined,
max: number | null | undefined,
): void {
if (min != null && max != null && min > max) {
throw new BadRequestException(
'Die Mindesttemperatur darf nicht über der Höchsttemperatur liegen',
);
}
}
@Injectable()
export class VeterinaryMedicinesService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
) {}
/** The catalogue with total rest and next expiry - two grouped queries, no N+1. */
async findAll(active?: boolean): Promise<MedicineView[]> {
const medicines = await this.prisma.veterinaryMedicine.findMany({
where: { active },
orderBy: [{ name: 'asc' }, { id: 'asc' }],
});
const lots = await this.prisma.veterinaryMedicineLot.findMany({
where: { medicineId: { in: medicines.map((medicine) => medicine.id) } },
select: { id: true, medicineId: true, quantity: true, expiresOn: true },
});
const totals = await lotTotals(
this.prisma,
lots.map((lot) => lot.id),
);
return medicines.map((medicine) =>
toMedicineView(
medicine,
lots.filter((lot) => lot.medicineId === medicine.id),
totals,
),
);
}
async findOne(id: string): Promise<MedicineView & { lots: LotView[] }> {
const medicine = await this.prisma.veterinaryMedicine.findUnique({
where: { id },
});
if (!medicine) {
throw new NotFoundException('Tierarzneimittel nicht gefunden');
}
return this.viewWithLots(this.prisma, medicine);
}
/**
* Own product names from treatments that are not in the catalogue yet
* (case and surrounding blanks ignored), with the values of the latest
* treatment. Nothing is created by looking at them.
*/
async suggestions(): Promise<MedicineSuggestion[]> {
const rows = await this.prisma.$queryRaw<SuggestionRow[]>(Prisma.sql`
SELECT s.name, s."treatmentCount", s."activeIngredient",
s."withdrawalPeriodDays", s.method, s.amount
FROM (
SELECT DISTINCT ON (g.key) g.name, g."treatmentCount",
g."activeIngredient", g."withdrawalPeriodDays", g.method, g.amount
FROM (
SELECT lower(btrim(t.product)) AS key, btrim(t.product) AS name,
t.date, t."createdAt", t.id, t."activeIngredient",
t."withdrawalPeriodDays", t.method, t.amount,
count(*) OVER (PARTITION BY lower(btrim(t.product)))::int
AS "treatmentCount"
FROM "Treatment" t
WHERE btrim(t.product) <> ''
AND NOT EXISTS (
SELECT 1 FROM "VeterinaryMedicine" m
WHERE lower(btrim(m.name)) = lower(btrim(t.product))
)
) g
ORDER BY g.key, g.date DESC, g."createdAt" DESC, g.id DESC
) s
ORDER BY s."treatmentCount" DESC, lower(s.name) ASC
LIMIT ${Prisma.raw(String(SUGGESTION_LIMIT))}
`);
return rows.map((row) => ({
name: row.name,
treatmentCount: Number(row.treatmentCount),
latest: {
activeIngredient: row.activeIngredient,
withdrawalPeriodDays: row.withdrawalPeriodDays,
method: row.method,
amount: row.amount,
},
}));
}
async create(
dto: CreateVeterinaryMedicineDto,
actingUserId: string,
): Promise<MedicineView> {
assertTemperatureRange(dto.minTemperatureC, dto.maxTemperatureC);
return this.prisma.$transaction(async (tx) => {
const created = await tx.veterinaryMedicine.create({ data: { ...dto } });
await this.auditLog.record(
{
entityType: AUDIT_ENTITY,
entityId: created.id,
action: 'CREATE',
userId: actingUserId,
afterData: created,
},
tx,
);
return toMedicineView(created, [], new Map());
});
}
/**
* The unit is fixed once a lot exists (409). The medicine row is locked
* first: creating a lot takes the same lock, so the check cannot be raced.
*/
async update(
id: string,
dto: UpdateVeterinaryMedicineDto,
actingUserId: string,
): Promise<MedicineView> {
return this.prisma.$transaction(async (tx) => {
const before = await this.lockAndRead(tx, id);
if (
dto.unit !== undefined &&
dto.unit !== before.unit &&
(await tx.veterinaryMedicineLot.count({ where: { medicineId: id } })) >
0
) {
throw new ConflictException(
'Einheit ist festgelegt, sobald Packungen erfasst sind',
);
}
assertTemperatureRange(
dto.minTemperatureC === undefined
? before.minTemperatureC
: dto.minTemperatureC,
dto.maxTemperatureC === undefined
? before.maxTemperatureC
: dto.maxTemperatureC,
);
const updated = await tx.veterinaryMedicine.update({
where: { id },
data: { ...dto },
});
return this.recordUpdate(tx, before, updated, actingUserId);
});
}
/** History instead of deleting: an inactive medicine can no longer be treated with. */
async setActive(
id: string,
active: boolean,
actingUserId: string,
): Promise<MedicineView> {
return this.prisma.$transaction(async (tx) => {
const before = await this.lockAndRead(tx, id);
if (before.active === active) {
// Nothing changes: no write and no audit row.
return this.viewOf(tx, before);
}
const updated = await tx.veterinaryMedicine.update({
where: { id },
data: { active },
});
return this.recordUpdate(tx, before, updated, actingUserId);
});
}
private async lockAndRead(
tx: Prisma.TransactionClient,
id: string,
): Promise<VeterinaryMedicine> {
if (!(await lockRowForUpdate(tx, 'VeterinaryMedicine', id))) {
throw new NotFoundException('Tierarzneimittel nicht gefunden');
}
const medicine = await tx.veterinaryMedicine.findUnique({ where: { id } });
if (!medicine) {
throw new NotFoundException('Tierarzneimittel nicht gefunden');
}
return medicine;
}
private async recordUpdate(
tx: Prisma.TransactionClient,
before: VeterinaryMedicine,
updated: VeterinaryMedicine,
actingUserId: string,
): Promise<MedicineView> {
await this.auditLog.record(
{
entityType: AUDIT_ENTITY,
entityId: updated.id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: updated,
},
tx,
);
return this.viewOf(tx, updated);
}
private async viewOf(
tx: Prisma.TransactionClient,
medicine: VeterinaryMedicine,
): Promise<MedicineView> {
const lots = await tx.veterinaryMedicineLot.findMany({
where: { medicineId: medicine.id },
select: { id: true, quantity: true, expiresOn: true },
});
const totals = await lotTotals(
tx,
lots.map((lot) => lot.id),
);
return toMedicineView(medicine, lots, totals);
}
private async viewWithLots(
db: DbClient,
medicine: VeterinaryMedicine,
): Promise<MedicineView & { lots: LotView[] }> {
const lots = await db.veterinaryMedicineLot.findMany({
where: { medicineId: medicine.id },
include: LOT_MEDICINE_INCLUDE,
orderBy: [{ purchasedOn: 'desc' }, { id: 'asc' }],
});
const totals = await lotTotals(
db,
lots.map((lot) => lot.id),
);
return {
...toMedicineView(medicine, lots, totals),
lots: lots.map((lot) => toLotView(lot, totals.get(lot.id))),
};
}
}
@@ -87,6 +87,20 @@ describe('WebhookEventHandlers', () => {
});
});
it('none of the four new events is forwarded to webhooks', async () => {
const herdEvents = [
['ColonyCreated', { apiaryId: 'a1' }],
['ColonyMoved', { fromApiaryId: 'a1', toApiaryId: 'a2' }],
['ApiaryChanged', { change: 'LOCATION_CHANGED' }],
['TskReferenceDayReached', { year: 2026, referenceDay: '2026-01-01' }],
] as const;
for (const [type, payload] of herdEvents) {
expect(Object.keys(DOMAIN_EVENT_WEBHOOKS)).not.toContain(type);
await forward(envelope(type, 'x1', payload as never));
}
expect(webhooks.enqueueDeliveries).not.toHaveBeenCalled();
});
it('registers forward as "webhooks.forward" for every mapped type', () => {
// The decorator stores its metadata on the method itself.
const method = Reflect.get(WebhookEventHandlers.prototype, 'forward');
+38
View File
@@ -30,6 +30,15 @@ describe('Apiaries (e2e)', () => {
let noColonyToken: string;
let fieldApiaryId: string;
// The ApiaryChanged codes of one apiary, oldest first.
async function changesOf(id: string): Promise<string[]> {
const events = await prisma.domainEvent.findMany({
where: { type: 'ApiaryChanged', aggregateId: id },
orderBy: { occurredAt: 'asc' },
});
return events.map((event) => (event.payload as { change: string }).change);
}
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
@@ -85,6 +94,12 @@ describe('Apiaries (e2e)', () => {
});
afterAll(async () => {
await prisma.domainEvent.deleteMany({
where: {
aggregateType: 'Apiary',
aggregateId: { in: [apiaryId, fieldApiaryId].filter(Boolean) },
},
});
if (fieldApiaryId) {
await prisma.task.deleteMany({
where: { title: { startsWith: 'field-e2e-' } },
@@ -154,6 +169,7 @@ describe('Apiaries (e2e)', () => {
.send({ notes: 'aktualisiert' })
.expect(200);
expect((updated.body as ApiaryBody).notes).toBe('aktualisiert');
expect(await changesOf(apiaryId)).toEqual(['CREATED']);
const deactivated = await request(app.getHttpServer())
.post(`/api/v1/apiaries/${apiaryId}/deactivate`)
@@ -166,6 +182,11 @@ describe('Apiaries (e2e)', () => {
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(201);
expect((reactivated.body as ApiaryBody).status).toBe('ACTIVE');
expect(await changesOf(apiaryId)).toEqual([
'CREATED',
'DEACTIVATED',
'REACTIVATED',
]);
await request(app.getHttpServer())
.post(`/api/v1/apiaries/${apiaryId}/reactivate`)
@@ -173,6 +194,23 @@ describe('Apiaries (e2e)', () => {
.expect(409);
});
it('updating only the name of an apiary leaves no ApiaryChanged', async () => {
const before = await changesOf(apiaryId);
await request(app.getHttpServer())
.patch(`/api/v1/apiaries/${apiaryId}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ name: 'Heimstand umbenannt' })
.expect(200);
expect(await changesOf(apiaryId)).toEqual(before);
await request(app.getHttpServer())
.patch(`/api/v1/apiaries/${apiaryId}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ address: 'Neuer Weg 3' })
.expect(200);
expect(await changesOf(apiaryId)).toEqual([...before, 'LOCATION_CHANGED']);
});
it('lets publicLocationLabel be set, changed and cleared with null', async () => {
const patch = (body: object) =>
request(app.getHttpServer())
+22 -1
View File
@@ -163,6 +163,15 @@ describe('Colonies (e2e)', () => {
colonyId = created.id;
});
it('a new colony leaves one ColonyCreated event', async () => {
const events = await prisma.domainEvent.findMany({
where: { type: 'ColonyCreated', aggregateId: colonyId },
});
expect(events).toHaveLength(1);
expect(events[0].aggregateType).toBe('Colony');
expect(events[0].payload).toEqual({ apiaryId: apiaryAId });
});
it('moves the colony to another apiary and records history', async () => {
const response = await request(app.getHttpServer())
.post(`/api/v1/colonies/${colonyId}/move`)
@@ -176,6 +185,15 @@ describe('Colonies (e2e)', () => {
.set('Authorization', `Bearer ${beekeeperToken}`)
.expect(200);
expect((history.body as ColonyHistoryBody).apiaryHistory).toHaveLength(1);
const moved = await prisma.domainEvent.findMany({
where: { type: 'ColonyMoved', aggregateId: colonyId },
});
expect(moved).toHaveLength(1);
expect(moved[0].payload).toEqual({
fromApiaryId: apiaryAId,
toApiaryId: apiaryBId,
});
});
it('splits the colony into a new nucleus colony', async () => {
@@ -289,7 +307,10 @@ describe('Colonies (e2e)', () => {
expect((response.body as ColonyBody).status).toBe('DISSOLVED');
const events = await prisma.domainEvent.findMany({
where: { aggregateId: colonyId },
where: {
aggregateId: colonyId,
type: { in: ['ColonyStatusChanged', 'ColonyEnded'] },
},
orderBy: { occurredAt: 'asc' },
});
expect(events.map((event) => event.type)).toEqual([
+395
View File
@@ -221,3 +221,398 @@ describe('Documents (e2e)', () => {
).toBe(before + 1);
});
});
describe('Documents of specialist records (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}`;
const password = 'SuperSecret123!';
const roleIds: string[] = [];
const userIds: string[] = [];
let apiaryId: string;
let medicineId: string;
let lotId: string;
let noticeId: string;
let restrictionId: string;
let docsOnlyToken: string;
let docsAndManageToken: string;
let staffToken: string;
let beekeeperToken: string;
let lotDocumentId: string;
let noticeDocumentId: string;
let restrictionDocumentId: string;
const api = () => request(app.getHttpServer());
const auth = (token: string) => ({ Authorization: `Bearer ${token}` });
async function createUser(
label: string,
options: { permissions?: string[]; roleKey?: string },
): Promise<string> {
let roleId: string;
if (options.roleKey) {
roleId = (
await prisma.role.findUniqueOrThrow({ where: { key: options.roleKey } })
).id;
} else {
const role = await prisma.role.create({
data: {
key: `DOC_SPECIAL_${label.toUpperCase()}_${suffix}`,
name: `Documents special ${label}`,
permissions: {
create: (options.permissions ?? []).map((key) => ({
permission: { connect: { key } },
})),
},
},
});
roleIds.push(role.id);
roleId = role.id;
}
const email = `doc-special-${label}-${suffix}@example.com`;
const user = await prisma.user.create({
data: {
email,
passwordHash: await bcrypt.hash(password, 12),
name: `Documents special ${label}`,
role: { connect: { id: roleId } },
},
});
userIds.push(user.id);
return (await login(app, email, password)).accessToken;
}
const upload = (
token: string,
entityType: string,
entityId: string,
fileName = 'beleg.png',
) =>
api()
.post('/api/v1/documents')
.set(auth(token))
.field('entityType', entityType)
.field('entityId', entityId)
.attach('file', PNG_BYTES, {
filename: fileName,
contentType: 'image/png',
});
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const crud = [
'documents:read',
'documents:upload',
'documents:update',
'documents:delete',
];
docsOnlyToken = await createUser('docsonly', { permissions: crud });
docsAndManageToken = await createUser('docsmanage', {
permissions: [...crud, 'compliance:read', 'compliance:manage'],
});
staffToken = await createUser('staff', { roleKey: 'STAFF' });
beekeeperToken = await createUser('beekeeper', { roleKey: 'BEEKEEPER' });
const apiary = await prisma.apiary.create({
data: { name: `Dok-Spezial-Stand ${suffix}` },
});
apiaryId = apiary.id;
const medicine = await prisma.veterinaryMedicine.create({
data: { name: `Dok-Mittel ${suffix}`, unit: 'ML' },
});
medicineId = medicine.id;
const lot = await prisma.veterinaryMedicineLot.create({
data: { medicineId, purchasedOn: new Date('2026-05-01'), quantity: 10 },
});
lotId = lot.id;
const notice = await prisma.authorityNotice.create({
data: {
kind: 'REGISTRATION',
referenceDate: new Date('2026-05-01'),
countedAt: new Date('2026-05-01'),
snapshot: {},
totalColonies: 0,
},
});
noticeId = notice.id;
const restriction = await prisma.apiaryRestriction.create({
data: {
apiaryId,
disease: 'Test',
startsOn: new Date('2026-05-01'),
},
});
restrictionId = restriction.id;
});
afterAll(async () => {
await prisma.document.deleteMany({
where: { entityId: { in: [lotId, noticeId, restrictionId] } },
});
await prisma.apiaryRestriction.deleteMany({ where: { id: restrictionId } });
await prisma.authorityNotice.deleteMany({ where: { id: noticeId } });
await prisma.veterinaryMedicineLot.deleteMany({ where: { id: lotId } });
await prisma.veterinaryMedicine.deleteMany({ where: { id: medicineId } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.refreshToken.deleteMany({
where: { userId: { in: userIds } },
});
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } });
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
await prisma.role.deleteMany({ where: { id: { in: roleIds } } });
await fs.rm('./test-uploads', { recursive: true, force: true });
await app.close();
});
it('STAFF uploads a purchase receipt to a lot and downloads it again', async () => {
const response = await upload(staffToken, 'MEDICINE_LOT', lotId).expect(
201,
);
lotDocumentId = (response.body as DocumentBody).id;
const list = await api()
.get(`/api/v1/documents?entityType=MEDICINE_LOT&entityId=${lotId}`)
.set(auth(staffToken))
.expect(200);
expect(
(list.body as DocumentBody[]).some((entry) => entry.id === lotDocumentId),
).toBe(true);
const download = await api()
.get(`/api/v1/documents/${lotDocumentId}/download`)
.set(auth(staffToken))
.expect(200);
expect(Buffer.compare(download.body as Buffer, PNG_BYTES)).toBe(0);
expect(download.headers['cache-control']).toBe('no-store');
expect(list.headers['cache-control']).toBe('no-store');
});
it('a user with documents:read but without veterinary-medicines:read gets 403 listing and downloading a MEDICINE_LOT document', async () => {
await api()
.get(`/api/v1/documents?entityType=MEDICINE_LOT&entityId=${lotId}`)
.set(auth(docsOnlyToken))
.expect(403);
await api()
.get(`/api/v1/documents/${lotDocumentId}/download`)
.set(auth(docsOnlyToken))
.expect(403);
});
it('a user with documents:read but without compliance:read gets 403 listing AUTHORITY_NOTICE and APIARY_RESTRICTION documents', async () => {
await api()
.get(`/api/v1/documents?entityType=AUTHORITY_NOTICE&entityId=${noticeId}`)
.set(auth(docsOnlyToken))
.expect(403);
await api()
.get(
`/api/v1/documents?entityType=APIARY_RESTRICTION&entityId=${restrictionId}`,
)
.set(auth(docsOnlyToken))
.expect(403);
});
it('a user with documents:upload but without compliance:manage gets 403 uploading to an AUTHORITY_NOTICE', async () => {
const before = await prisma.document.count({
where: { entityId: noticeId },
});
await upload(docsOnlyToken, 'AUTHORITY_NOTICE', noticeId).expect(403);
await upload(docsOnlyToken, 'APIARY_RESTRICTION', restrictionId).expect(
403,
);
// STAFF has no compliance:manage either
await upload(staffToken, 'AUTHORITY_NOTICE', noticeId).expect(403);
expect(await prisma.document.count({ where: { entityId: noticeId } })).toBe(
before,
);
});
it('a user with compliance:manage uploads, lists and downloads authority documents', async () => {
const notice = await upload(
docsAndManageToken,
'AUTHORITY_NOTICE',
noticeId,
).expect(201);
noticeDocumentId = (notice.body as DocumentBody).id;
const restriction = await upload(
docsAndManageToken,
'APIARY_RESTRICTION',
restrictionId,
).expect(201);
restrictionDocumentId = (restriction.body as DocumentBody).id;
await api()
.get(`/api/v1/documents?entityType=AUTHORITY_NOTICE&entityId=${noticeId}`)
.set(auth(docsAndManageToken))
.expect(200);
await api()
.get(`/api/v1/documents/${noticeDocumentId}/download`)
.set(auth(docsAndManageToken))
.expect(200);
// documents:read alone must not reach it
await api()
.get(`/api/v1/documents/${restrictionDocumentId}/download`)
.set(auth(docsOnlyToken))
.expect(403);
});
it('re-linking a document to an APIARY_RESTRICTION needs compliance:manage', async () => {
// a document on a harmless record, owned by the docs-only user
const apiaryDoc = await upload(docsOnlyToken, 'APIARY', apiaryId).expect(
201,
);
const id = (apiaryDoc.body as DocumentBody).id;
await api()
.patch(`/api/v1/documents/${id}`)
.set(auth(docsOnlyToken))
.send({ entityType: 'APIARY_RESTRICTION', entityId: restrictionId })
.expect(403);
expect(
(await prisma.document.findUniqueOrThrow({ where: { id } })).entityType,
).toBe('APIARY');
await api()
.patch(`/api/v1/documents/${id}`)
.set(auth(docsAndManageToken))
.send({ entityType: 'APIARY_RESTRICTION', entityId: restrictionId })
.expect(200);
// moving it back out needs the right of the OLD type too
await api()
.patch(`/api/v1/documents/${id}`)
.set(auth(docsOnlyToken))
.send({ entityType: 'APIARY', entityId: apiaryId })
.expect(403);
await api()
.delete(`/api/v1/documents/${id}`)
.set(auth(docsAndManageToken))
.expect(204);
});
it('renaming a restricted document without the right is denied', () => {
return api()
.patch(`/api/v1/documents/${lotDocumentId}`)
.set(auth(docsOnlyToken))
.send({ fileName: 'neu.png' })
.expect(403);
});
it('deleting a MEDICINE_LOT document needs veterinary-medicines:update', async () => {
await api()
.delete(`/api/v1/documents/${lotDocumentId}`)
.set(auth(docsOnlyToken))
.expect(403);
expect(await prisma.document.count({ where: { id: lotDocumentId } })).toBe(
1,
);
await api()
.delete(`/api/v1/documents/${lotDocumentId}`)
.set(auth(beekeeperToken))
.expect(204);
});
it('answers 403, not 404, for an unauthorised upload to a missing MEDICINE_LOT record', async () => {
await upload(docsOnlyToken, 'MEDICINE_LOT', 'nope').expect(403);
});
describe('read/write split over HTTP', () => {
async function userWith(label: string, permissions: string[]) {
return createUser(label, { permissions });
}
it('compliance:read without compliance:manage lists (200) but cannot upload (403)', async () => {
const token = await userWith('comp-read', [
'documents:read',
'documents:upload',
'compliance:read',
]);
await api()
.get(
`/api/v1/documents?entityType=AUTHORITY_NOTICE&entityId=${noticeId}`,
)
.set(auth(token))
.expect(200);
await upload(token, 'AUTHORITY_NOTICE', noticeId).expect(403);
await upload(token, 'APIARY_RESTRICTION', restrictionId).expect(403);
});
it('veterinary-medicines:read without :update lists and downloads (200) but cannot delete or patch (403)', async () => {
const token = await userWith('med-read', [
'documents:read',
'documents:update',
'documents:delete',
'veterinary-medicines:read',
]);
const created = await upload(
beekeeperToken,
'MEDICINE_LOT',
lotId,
).expect(201);
const id = (created.body as DocumentBody).id;
await api()
.get(`/api/v1/documents?entityType=MEDICINE_LOT&entityId=${lotId}`)
.set(auth(token))
.expect(200);
await api()
.get(`/api/v1/documents/${id}/download`)
.set(auth(token))
.expect(200);
await api()
.delete(`/api/v1/documents/${id}`)
.set(auth(token))
.expect(403);
await api()
.patch(`/api/v1/documents/${id}`)
.set(auth(token))
.send({ fileName: 'x.png' })
.expect(403);
expect(await prisma.document.count({ where: { id } })).toBe(1);
});
it('compliance:manage without compliance:read uploads (201) but cannot list (403)', async () => {
const token = await userWith('comp-manage', [
'documents:read',
'documents:upload',
'compliance:manage',
]);
await upload(token, 'AUTHORITY_NOTICE', noticeId).expect(201);
await api()
.get(
`/api/v1/documents?entityType=AUTHORITY_NOTICE&entityId=${noticeId}`,
)
.set(auth(token))
.expect(403);
});
});
it('answers 404 for a missing record of the new types when the user has the right', async () => {
await upload(staffToken, 'MEDICINE_LOT', 'does-not-exist').expect(404);
await upload(docsAndManageToken, 'AUTHORITY_NOTICE', 'nope').expect(404);
await upload(docsAndManageToken, 'APIARY_RESTRICTION', 'nope').expect(404);
});
it('uploads keep the MIME allowlist and signature check for the new types', async () => {
await api()
.post('/api/v1/documents')
.set(auth(staffToken))
.field('entityType', 'MEDICINE_LOT')
.field('entityId', lotId)
.attach('file', Buffer.from('MZ not allowed'), {
filename: 'x.exe',
contentType: 'application/octet-stream',
})
.expect(400);
await api()
.post('/api/v1/documents')
.set(auth(staffToken))
.field('entityType', 'MEDICINE_LOT')
.field('entityId', lotId)
.attach('file', Buffer.from('plain text, not a png'), {
filename: 'fake.png',
contentType: 'image/png',
})
.expect(400);
});
});
+15
View File
@@ -0,0 +1,15 @@
[
"batchNumber",
"honeyType",
"lotNumber",
"bottlingDate",
"bestBeforeDate",
"jarSize",
"productName",
"legalName",
"originCountry",
"foodBusinessName",
"foodBusinessAddress",
"bestBeforeLine",
"lotLine"
]
+22
View File
@@ -70,6 +70,15 @@ describe('Import (e2e)', () => {
});
afterAll(async () => {
const colonyIds = (
await prisma.colony.findMany({
where: { apiaryId },
select: { id: true },
})
).map((colony) => colony.id);
await prisma.domainEvent.deleteMany({
where: { aggregateType: 'Colony', aggregateId: { in: colonyIds } },
});
await prisma.colony.deleteMany({ where: { apiaryId } });
await prisma.apiary.deleteMany({ where: { id: apiaryId } });
await prisma.apiary.deleteMany({
@@ -143,6 +152,13 @@ describe('Import (e2e)', () => {
const firstReport = firstResponse.body as ImportReportBody;
expect(firstReport.created).toBe(1);
const colony = await prisma.colony.findUniqueOrThrow({ where: { code } });
const events = await prisma.domainEvent.findMany({
where: { type: 'ColonyCreated', aggregateId: colony.id },
});
expect(events).toHaveLength(1);
expect(events[0].payload).toEqual({ apiaryId });
const secondResponse = await request(app.getHttpServer())
.post('/api/v1/import/colonies')
.set('Authorization', `Bearer ${adminToken}`)
@@ -154,6 +170,12 @@ describe('Import (e2e)', () => {
const secondReport = secondResponse.body as ImportReportBody;
expect(secondReport.created).toBe(0);
expect(secondReport.skipped).toBe(1);
// The skipped re-import leaves no second event.
expect(
await prisma.domainEvent.count({
where: { type: 'ColonyCreated', aggregateId: colony.id },
}),
).toBe(1);
});
it('reports an error when the referenced apiary is unknown', async () => {
+40
View File
@@ -135,6 +135,46 @@ describe('Products (e2e)', () => {
}
});
it('stores legalName and clears it with null', async () => {
const created = await request(app.getHttpServer())
.post('/api/v1/products')
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ name: `Rechtsname ${Date.now()}`, legalName: ' Blütenhonig ' })
.expect(201);
const id = (created.body as ProductBody).id;
try {
expect((created.body as { legalName: string | null }).legalName).toBe(
'Blütenhonig',
);
const changed = await request(app.getHttpServer())
.patch(`/api/v1/products/${id}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ legalName: 'Waldhonig' })
.expect(200);
expect((changed.body as { legalName: string | null }).legalName).toBe(
'Waldhonig',
);
const cleared = await request(app.getHttpServer())
.patch(`/api/v1/products/${id}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ legalName: null })
.expect(200);
expect(
(cleared.body as { legalName: string | null }).legalName,
).toBeNull();
await request(app.getHttpServer())
.patch(`/api/v1/products/${id}`)
.set('Authorization', `Bearer ${beekeeperToken}`)
.send({ legalName: 'x'.repeat(201) })
.expect(400);
} finally {
await prisma.product.deleteMany({ where: { id } });
}
});
it('rejects a shelf life outside 1 to 60 months', async () => {
for (const shelfLifeMonths of [0, 61, 1.5]) {
await request(app.getHttpServer())
+37 -1
View File
@@ -31,6 +31,8 @@ describe('Public batch origin page (e2e)', () => {
let bottlingBatchId: string;
const lotNumber = `WH-E2E-${Date.now()}`;
const apiaryName = `Stand Wald ${Date.now()}`;
const legalName = `Rechtsname-${Date.now()}-honig`;
const foodBusinessAddress = `Geheimweg ${Date.now()}, 86450 Altenmünster`;
beforeAll(async () => {
app = await createTestApp();
@@ -90,7 +92,7 @@ describe('Public batch origin page (e2e)', () => {
});
honeyBatchId = honeyBatch.id;
const product = await prisma.product.create({
data: { name: 'Sommerhonig 500g' },
data: { name: 'Sommerhonig 500g', legalName: legalName },
});
productId = product.id;
const bottlingBatch = await prisma.bottlingBatch.create({
@@ -139,6 +141,40 @@ describe('Public batch origin page (e2e)', () => {
expect(body.harvestDates).toHaveLength(1);
});
it('the public origin page exposes neither foodBusinessAddress nor legalName', async () => {
const before = await prisma.siteSettings.findUnique({
where: { id: 'singleton' },
});
await prisma.siteSettings.upsert({
where: { id: 'singleton' },
update: { foodBusinessName: 'Imkerei Geheim', foodBusinessAddress },
create: {
id: 'singleton',
foodBusinessName: 'Imkerei Geheim',
foodBusinessAddress,
},
});
try {
const response = await request(app.getHttpServer())
.get(`/api/v1/public/batches/${lotNumber}`)
.expect(200);
const text = JSON.stringify(response.body);
expect(text).not.toContain(foodBusinessAddress);
expect(text).not.toContain('Imkerei Geheim');
expect(text).not.toContain(legalName);
expect(response.body).not.toHaveProperty('legalName');
expect(response.body).not.toHaveProperty('foodBusinessAddress');
} finally {
await prisma.siteSettings.update({
where: { id: 'singleton' },
data: {
foodBusinessName: before?.foodBusinessName ?? null,
foodBusinessAddress: before?.foodBusinessAddress ?? null,
},
});
}
});
it('never leaks the apiary name, location, address or coordinates', async () => {
const response = await request(app.getHttpServer())
.get(`/api/v1/public/batches/${lotNumber}`)
+21 -1
View File
@@ -103,7 +103,11 @@ describe('Shop sync (e2e)', () => {
},
});
const product = await prisma.product.create({
data: { name: `Sommertracht 500 g ${suffix}`, sku: `SKU-${suffix}` },
data: {
name: `Sommertracht 500 g ${suffix}`,
sku: `SKU-${suffix}`,
legalName: `Rechtsname-${suffix}`,
},
});
productId = product.id;
const bottling = await prisma.bottlingBatch.create({
@@ -163,6 +167,22 @@ describe('Shop sync (e2e)', () => {
expect(raw).not.toContain('"userId"');
});
it('carries no label master data in the product payload', async () => {
const response = await request(app.getHttpServer())
.get('/api/v1/shop-sync')
.set('Authorization', `Bearer ${syncToken}`)
.expect(200);
const body = response.body as ShopSyncFeedBody;
const product = body.products.find((p) => p.id === productId);
expect(product).toBeDefined();
expect(product).not.toHaveProperty('legalName');
const raw = JSON.stringify(response.body);
expect(raw).not.toContain('legalName');
expect(raw).not.toContain('foodBusiness');
expect(raw).not.toContain(`Rechtsname-${suffix}`);
});
it('grants the sync account nothing beyond the feed', async () => {
await request(app.getHttpServer())
.get('/api/v1/harvests')
@@ -0,0 +1,793 @@
import { randomUUID } from 'node:crypto';
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
interface MedicineBody {
id: string;
name: string;
unit: string;
active: boolean;
totalRemaining: number;
nextExpiry: string | null;
lotCount: number;
lots?: LotBody[];
}
interface MovementBody {
id: string;
type: string;
quantity: number;
treatmentId: string | null;
reason: string | null;
}
interface LotBody {
id: string;
medicineId: string;
medicineName: string;
unit: string;
quantity: number;
remaining: number;
movementCount: number;
batchNumber: string | null;
expiresOn: string | null;
movements?: MovementBody[];
}
interface SuggestionBody {
name: string;
treatmentCount: number;
latest: {
activeIngredient: string | null;
withdrawalPeriodDays: number | null;
method: string | null;
amount: string | null;
};
}
describe('Veterinary medicines (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}`;
const password = 'SuperSecret123!';
const tokens: Record<string, string> = {};
const medicineIds: string[] = [];
let colonyId: string;
let apiaryId: string;
let userId: string;
const server = () => app.getHttpServer();
const auth = (role: string) => ({ Authorization: `Bearer ${tokens[role]}` });
async function createMedicine(
name: string,
extra: Record<string, unknown> = {},
): Promise<MedicineBody> {
const response = await request(server())
.post('/api/v1/veterinary-medicines')
.set(auth('STAFF'))
.send({ name: `${name}-${suffix}`, unit: 'ML', ...extra })
.expect(201);
const body = response.body as MedicineBody;
medicineIds.push(body.id);
return body;
}
async function createLot(
medicineId: string,
quantity = 10,
extra: Record<string, unknown> = {},
): Promise<LotBody> {
const response = await request(server())
.post('/api/v1/veterinary-medicine-lots')
.set(auth('STAFF'))
.send({ medicineId, purchasedOn: '2026-08-01', quantity, ...extra })
.expect(201);
return response.body as LotBody;
}
async function lotOf(id: string): Promise<LotBody> {
const response = await request(server())
.get(`/api/v1/veterinary-medicine-lots/${id}`)
.set(auth('READONLY'))
.expect(200);
return response.body as LotBody;
}
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const passwordHash = await bcrypt.hash(password, 12);
for (const role of ['STAFF', 'READONLY', 'API_CLIENT']) {
const email = `vetmed-e2e-${role.toLowerCase()}-${suffix}@example.com`;
const user = await prisma.user.create({
data: {
email,
passwordHash,
name: role,
role: { connect: { key: role } },
},
});
if (role === 'STAFF') {
userId = user.id;
}
tokens[role] = (await login(app, email, password)).accessToken;
}
const apiary = await prisma.apiary.create({
data: { name: `VetMed Stand ${suffix}` },
});
apiaryId = apiary.id;
const colony = await prisma.colony.create({
data: {
code: `VM-${suffix}`,
apiaryId,
colonyType: 'PRODUCTION_COLONY',
},
});
colonyId = colony.id;
});
afterAll(async () => {
const lots = await prisma.veterinaryMedicineLot.findMany({
where: { medicineId: { in: medicineIds } },
select: { id: true },
});
const lotIds = lots.map((lot) => lot.id);
await prisma.veterinaryMedicineMovement.deleteMany({
where: { lotId: { in: lotIds } },
});
await prisma.veterinaryMedicineLot.deleteMany({
where: { id: { in: lotIds } },
});
await prisma.veterinaryMedicine.deleteMany({
where: { id: { in: medicineIds } },
});
await prisma.treatment.deleteMany({ where: { colonyId } });
await app.close();
});
it('lot -> rest: a correction books the delta and the rest follows', async () => {
const medicine = await createMedicine('Rest');
const lot = await createLot(medicine.id, 10, { batchNumber: 'CH-REST' });
expect(lot).toMatchObject({
remaining: 10,
medicineName: `Rest-${suffix}`,
unit: 'ML',
movementCount: 0,
});
const afterCorrection = await request(server())
.post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`)
.set(auth('STAFF'))
.send({ type: 'CORRECTION', actualRemaining: 6.5 })
.expect(201);
const detail = afterCorrection.body as LotBody;
expect(detail.remaining).toBe(6.5);
expect(detail.movements).toEqual([
expect.objectContaining({
type: 'CORRECTION',
quantity: -3.5,
treatmentId: null,
}) as MovementBody,
]);
const catalogue = await request(server())
.get(`/api/v1/veterinary-medicines/${medicine.id}`)
.set(auth('READONLY'))
.expect(200);
expect(catalogue.body).toMatchObject({
lotCount: 1,
totalRemaining: 6.5,
lots: [expect.objectContaining({ id: lot.id, remaining: 6.5 })],
});
});
it('a correction to the same rest books nothing', async () => {
const medicine = await createMedicine('NoDelta');
const lot = await createLot(medicine.id, 4);
const response = await request(server())
.post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`)
.set(auth('STAFF'))
.send({ type: 'CORRECTION', actualRemaining: 4 })
.expect(201);
expect((response.body as LotBody).movements).toEqual([]);
});
it('a disposal books the negative quantity and may drive the rest negative', async () => {
const medicine = await createMedicine('Disposal');
const lot = await createLot(medicine.id, 10);
await request(server())
.post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`)
.set(auth('STAFF'))
.send({ type: 'DISPOSAL', quantity: 15, reason: 'Abgelaufen' })
.expect(201);
const detail = await lotOf(lot.id);
expect(detail.remaining).toBe(-5);
expect(detail.movements?.[0]).toMatchObject({
type: 'DISPOSAL',
quantity: -15,
reason: 'Abgelaufen',
});
const catalogue = await request(server())
.get(`/api/v1/veterinary-medicines?active=true`)
.set(auth('READONLY'))
.expect(200);
expect(
(catalogue.body as MedicineBody[]).find((m) => m.id === medicine.id),
).toMatchObject({ totalRemaining: -5 });
});
it('a disposal without a reason is a 400', async () => {
const medicine = await createMedicine('NoReason');
const lot = await createLot(medicine.id, 10);
await request(server())
.post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`)
.set(auth('STAFF'))
.send({ type: 'DISPOSAL', quantity: 1 })
.expect(400);
});
it('two parallel inventories on one lot: the second computes with the new rest', async () => {
const medicine = await createMedicine('Parallel');
const lot = await createLot(medicine.id, 10);
const responses = await Promise.all(
[4, 7].map((actualRemaining) =>
request(server())
.post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`)
.set(auth('STAFF'))
.send({ type: 'CORRECTION', actualRemaining }),
),
);
expect(responses.map((response) => response.status)).toEqual([201, 201]);
const detail = await lotOf(lot.id);
// Both ran against the rest the other left behind: the deltas are
// {-6, +3} or {-3, -3}, never {-6, -3} (both against 10).
expect([4, 7]).toContain(detail.remaining);
const deltas = (detail.movements ?? []).map((m) => m.quantity).sort();
expect(deltas).toEqual(detail.remaining === 7 ? [-6, 3] : [-3, -3]);
});
it('three parallel inventories end on the target of the last one booked', async () => {
const medicine = await createMedicine('Parallel3');
const lot = await createLot(medicine.id, 10);
const targets = [2, 5, 8];
const responses = await Promise.all(
targets.map((actualRemaining) =>
request(server())
.post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`)
.set(auth('STAFF'))
.send({ type: 'CORRECTION', actualRemaining }),
),
);
expect(responses.map((response) => response.status)).toEqual([
201, 201, 201,
]);
const detail = await lotOf(lot.id);
const rows = await prisma.veterinaryMedicineMovement.findMany({
where: { lotId: lot.id },
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
});
// Replaying the book in booking order: every delta started from the rest
// the one before left, so each step ends on one of the targets and the
// last one is the final rest.
let rest = 10;
const reached = rows.map((row) => {
rest = Math.round((rest + row.quantity) * 1000) / 1000;
return rest;
});
expect(reached.every((value) => targets.includes(value))).toBe(true);
expect(detail.remaining).toBe(reached[reached.length - 1]);
});
it('rejects changing the unit once a lot exists with 409, other fields stay editable', async () => {
const medicine = await createMedicine('Unit');
await createLot(medicine.id, 5);
await request(server())
.patch(`/api/v1/veterinary-medicines/${medicine.id}`)
.set(auth('STAFF'))
.send({ unit: 'G' })
.expect(409);
const renamed = await request(server())
.patch(`/api/v1/veterinary-medicines/${medicine.id}`)
.set(auth('STAFF'))
.send({ notes: 'Kühl lagern', withdrawalPeriodDays: 0 })
.expect(200);
expect(renamed.body).toMatchObject({
notes: 'Kühl lagern',
withdrawalPeriodDays: 0,
unit: 'ML',
});
});
it('changes the unit of a medicine without lots and clears a field with null', async () => {
const medicine = await createMedicine('Free', { notes: 'x' });
const response = await request(server())
.patch(`/api/v1/veterinary-medicines/${medicine.id}`)
.set(auth('STAFF'))
.send({ unit: 'PIECE', notes: null })
.expect(200);
expect(response.body).toMatchObject({ unit: 'PIECE', notes: null });
});
it('rejects changing the medicine of a lot once a movement exists with 409', async () => {
const first = await createMedicine('Move-A');
const second = await createMedicine('Move-B');
const lot = await createLot(first.id, 5);
const free = await createLot(first.id, 5);
await request(server())
.patch(`/api/v1/veterinary-medicine-lots/${free.id}`)
.set(auth('STAFF'))
.send({ medicineId: second.id })
.expect(200);
await request(server())
.post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`)
.set(auth('STAFF'))
.send({ type: 'CORRECTION', actualRemaining: 3 })
.expect(201);
await request(server())
.patch(`/api/v1/veterinary-medicine-lots/${lot.id}`)
.set(auth('STAFF'))
.send({ medicineId: second.id })
.expect(409);
await request(server())
.patch(`/api/v1/veterinary-medicine-lots/${lot.id}`)
.set(auth('STAFF'))
.send({ supplier: 'Imkerei Muster', expiresOn: null })
.expect(200);
});
it('selectable lots come oldest expiry first and leave out an empty lot', async () => {
const medicine = await createMedicine('Fifo');
const late = await createLot(medicine.id, 5, { expiresOn: '2028-01-01' });
const early = await createLot(medicine.id, 5, { expiresOn: '2027-01-01' });
const empty = await createLot(medicine.id, 5, { expiresOn: '2026-12-01' });
await request(server())
.post(`/api/v1/veterinary-medicine-lots/${empty.id}/movements`)
.set(auth('STAFF'))
.send({ type: 'CORRECTION', actualRemaining: 0 })
.expect(201);
const response = await request(server())
.get(
`/api/v1/veterinary-medicine-lots?medicineId=${medicine.id}&selectable=true`,
)
.set(auth('READONLY'))
.expect(200);
expect((response.body as LotBody[]).map((lot) => lot.id)).toEqual([
early.id,
late.id,
]);
const all = await request(server())
.get(`/api/v1/veterinary-medicine-lots?medicineId=${medicine.id}`)
.set(auth('READONLY'))
.expect(200);
expect(all.body as LotBody[]).toHaveLength(3);
});
it('selectable leaves out the lots of an inactive medicine, includeLotId keeps one', async () => {
const medicine = await createMedicine('Inactive');
const lot = await createLot(medicine.id, 5);
const emptyLot = await createLot(medicine.id, 5);
await request(server())
.post(`/api/v1/veterinary-medicine-lots/${emptyLot.id}/movements`)
.set(auth('STAFF'))
.send({ type: 'CORRECTION', actualRemaining: 0 })
.expect(201);
const list = async (query: string) =>
(
(
await request(server())
.get(
`/api/v1/veterinary-medicine-lots?medicineId=${medicine.id}&selectable=true${query}`,
)
.set(auth('READONLY'))
.expect(200)
).body as LotBody[]
).map((entry) => entry.id);
expect(await list('')).toEqual([lot.id]);
// The lot of an edited treatment stays selectable although it is empty.
expect(await list(`&includeLotId=${emptyLot.id}`)).toEqual(
expect.arrayContaining([lot.id, emptyLot.id]),
);
await request(server())
.post(`/api/v1/veterinary-medicines/${medicine.id}/deactivate`)
.set(auth('STAFF'))
.expect(201);
expect(await list('')).toEqual([]);
expect(await list(`&includeLotId=${emptyLot.id}`)).toEqual([emptyLot.id]);
await request(server())
.get(
`/api/v1/veterinary-medicine-lots?medicineId=${medicine.id}&selectable=true&includeLotId=bad%20id`,
)
.set(auth('READONLY'))
.expect(400);
});
it('an inactive medicine takes no new stock: no new lot, no move to it', async () => {
const off = await createMedicine('NoStock');
const other = await createMedicine('NoStockOther');
const lot = await createLot(other.id, 5);
await request(server())
.post(`/api/v1/veterinary-medicines/${off.id}/deactivate`)
.set(auth('STAFF'))
.expect(201);
const created = await request(server())
.post('/api/v1/veterinary-medicine-lots')
.set(auth('STAFF'))
.send({ medicineId: off.id, purchasedOn: '2026-08-01', quantity: 1 })
.expect(400);
expect((created.body as { message: string }).message).toBe(
'Das Mittel ist deaktiviert.',
);
await request(server())
.patch(`/api/v1/veterinary-medicine-lots/${lot.id}`)
.set(auth('STAFF'))
.send({ medicineId: off.id })
.expect(400);
});
it('moving a lot to a medicine with another unit is a 400', async () => {
const ml = await createMedicine('UnitMl');
const grams = await createMedicine('UnitG', { unit: 'G' });
const lot = await createLot(ml.id, 5);
const response = await request(server())
.patch(`/api/v1/veterinary-medicine-lots/${lot.id}`)
.set(auth('STAFF'))
.send({ medicineId: grams.id })
.expect(400);
expect((response.body as { message: string }).message).toBe(
'Die Einheit des Mittels passt nicht zur Packung.',
);
});
it('setActive without a change writes no audit row', async () => {
const medicine = await createMedicine('NoChange');
const countAudit = () =>
prisma.auditLog.count({
where: { entityType: 'VeterinaryMedicine', entityId: medicine.id },
});
const before = await countAudit();
await request(server())
.post(`/api/v1/veterinary-medicines/${medicine.id}/reactivate`)
.set(auth('STAFF'))
.expect(201);
expect(await countAudit()).toBe(before);
});
it('rejects a whitespace-only name or disposal reason with 400', async () => {
const medicine = await createMedicine('Blank');
const lot = await createLot(medicine.id, 5);
await request(server())
.post('/api/v1/veterinary-medicines')
.set(auth('STAFF'))
.send({ name: ' ', unit: 'ML' })
.expect(400);
await request(server())
.patch(`/api/v1/veterinary-medicines/${medicine.id}`)
.set(auth('STAFF'))
.send({ name: ' \t ' })
.expect(400);
await request(server())
.post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`)
.set(auth('STAFF'))
.send({ type: 'DISPOSAL', quantity: 1, reason: ' ' })
.expect(400);
});
it('suggestions list own product names that are not in the catalogue yet', async () => {
const product = `Sugg-${suffix}`;
const known = `SuggKnown-${suffix}`;
for (const [name, day, ingredient] of [
[product, '2026-05-01', 'Alt'],
[product.toUpperCase(), '2026-06-01', 'Neu'],
[known.toLowerCase(), '2026-06-02', 'Egal'],
]) {
await prisma.treatment.create({
data: {
colonyId,
userId,
date: new Date(`${day}T00:00:00Z`),
product: name,
activeIngredient: ingredient,
withdrawalPeriodDays: 0,
method: 'Träufeln',
amount: '5 ml',
},
});
}
await createMedicine('SuggKnown');
const response = await request(server())
.get('/api/v1/veterinary-medicines/suggestions')
.set(auth('READONLY'))
.expect(200);
const suggestions = response.body as SuggestionBody[];
expect(
suggestions.find(
(entry) => entry.name.toLowerCase() === known.toLowerCase(),
),
).toBeUndefined();
const mine = suggestions.find(
(entry) => entry.name.toLowerCase() === product.toLowerCase(),
);
expect(mine).toMatchObject({
treatmentCount: 2,
latest: {
activeIngredient: 'Neu',
withdrawalPeriodDays: 0,
method: 'Träufeln',
amount: '5 ml',
},
});
// nothing was created by looking at them
expect(
await prisma.veterinaryMedicine.count({
where: { name: { equals: product, mode: 'insensitive' } },
}),
).toBe(0);
});
it('STAFF may create a lot, READONLY gets 403 on every write', async () => {
const medicine = await createMedicine('Perm');
const lot = await createLot(medicine.id, 3);
for (const [method, path, body] of [
['post', '/api/v1/veterinary-medicines', { name: 'x', unit: 'ML' }],
['patch', `/api/v1/veterinary-medicines/${medicine.id}`, { notes: 'x' }],
['post', `/api/v1/veterinary-medicines/${medicine.id}/deactivate`, {}],
['post', `/api/v1/veterinary-medicines/${medicine.id}/reactivate`, {}],
[
'post',
'/api/v1/veterinary-medicine-lots',
{ medicineId: medicine.id, purchasedOn: '2026-08-01', quantity: 1 },
],
['patch', `/api/v1/veterinary-medicine-lots/${lot.id}`, { notes: 'x' }],
[
'post',
`/api/v1/veterinary-medicine-lots/${lot.id}/movements`,
{ type: 'CORRECTION', actualRemaining: 1 },
],
] as const) {
await request(server())
[method](path)
.set(auth('READONLY'))
.send(body)
.expect(403);
}
await request(server())
.get('/api/v1/veterinary-medicines')
.set(auth('READONLY'))
.expect(200);
});
it('API_CLIENT gets 403 on every route', async () => {
const medicine = await createMedicine('Client');
const lot = await createLot(medicine.id, 3);
const routes = [
['get', '/api/v1/veterinary-medicines'],
['get', '/api/v1/veterinary-medicines/suggestions'],
['get', `/api/v1/veterinary-medicines/${medicine.id}`],
['post', '/api/v1/veterinary-medicines'],
['patch', `/api/v1/veterinary-medicines/${medicine.id}`],
['post', `/api/v1/veterinary-medicines/${medicine.id}/deactivate`],
['post', `/api/v1/veterinary-medicines/${medicine.id}/reactivate`],
['get', '/api/v1/veterinary-medicine-lots'],
['get', `/api/v1/veterinary-medicine-lots/${lot.id}`],
['post', '/api/v1/veterinary-medicine-lots'],
['patch', `/api/v1/veterinary-medicine-lots/${lot.id}`],
['post', `/api/v1/veterinary-medicine-lots/${lot.id}/movements`],
] as const;
for (const [method, path] of routes) {
await request(server())
[method](path)
.set(auth('API_CLIENT'))
.send({})
.expect(403);
}
});
it('deactivates and reactivates a medicine instead of deleting it', async () => {
const medicine = await createMedicine('Active');
const off = await request(server())
.post(`/api/v1/veterinary-medicines/${medicine.id}/deactivate`)
.set(auth('STAFF'))
.expect(201);
expect(off.body).toMatchObject({ id: medicine.id, active: false });
const inactive = await request(server())
.get('/api/v1/veterinary-medicines?active=false')
.set(auth('READONLY'))
.expect(200);
expect((inactive.body as MedicineBody[]).map((m) => m.id)).toContain(
medicine.id,
);
const on = await request(server())
.post(`/api/v1/veterinary-medicines/${medicine.id}/reactivate`)
.set(auth('STAFF'))
.expect(201);
expect(on.body).toMatchObject({ active: true });
});
it('a POST with a repeated Idempotency-Key creates one lot', async () => {
const medicine = await createMedicine('Idem');
const key = `idem-${randomUUID()}`;
const body = {
medicineId: medicine.id,
purchasedOn: '2026-08-01',
quantity: 2,
batchNumber: `IDEM-${suffix}`,
};
const first = await request(server())
.post('/api/v1/veterinary-medicine-lots')
.set(auth('STAFF'))
.set('Idempotency-Key', key)
.send(body)
.expect(201);
const second = await request(server())
.post('/api/v1/veterinary-medicine-lots')
.set(auth('STAFF'))
.set('Idempotency-Key', key)
.send(body)
.expect(201);
expect((second.body as LotBody).id).toBe((first.body as LotBody).id);
expect(
await prisma.veterinaryMedicineLot.count({
where: { medicineId: medicine.id },
}),
).toBe(1);
});
it('rejects a quantity of 0 or NaN with 400', async () => {
const medicine = await createMedicine('Qty');
const lot = await createLot(medicine.id, 5);
for (const quantity of [0, -1, 'NaN', null, Infinity]) {
await request(server())
.post('/api/v1/veterinary-medicine-lots')
.set(auth('STAFF'))
.send({ medicineId: medicine.id, purchasedOn: '2026-08-01', quantity })
.expect(400);
}
await request(server())
.patch(`/api/v1/veterinary-medicine-lots/${lot.id}`)
.set(auth('STAFF'))
.send({ quantity: 0 })
.expect(400);
await request(server())
.post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`)
.set(auth('STAFF'))
.send({ type: 'DISPOSAL', quantity: 0, reason: 'x' })
.expect(400);
await request(server())
.post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`)
.set(auth('STAFF'))
.send({ type: 'CORRECTION', actualRemaining: 'NaN' })
.expect(400);
await request(server())
.post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`)
.set(auth('STAFF'))
.send({ type: 'TREATMENT', quantity: 1 })
.expect(400);
});
it('validates the medicine: temperatures, withdrawal days, name length', async () => {
await request(server())
.post('/api/v1/veterinary-medicines')
.set(auth('STAFF'))
.send({
name: `Temp-${suffix}`,
unit: 'ML',
minTemperatureC: 30,
maxTemperatureC: 10,
})
.expect(400);
await request(server())
.post('/api/v1/veterinary-medicines')
.set(auth('STAFF'))
.send({ name: `Days-${suffix}`, unit: 'ML', withdrawalPeriodDays: -1 })
.expect(400);
await request(server())
.post('/api/v1/veterinary-medicines')
.set(auth('STAFF'))
.send({ name: 'x'.repeat(201), unit: 'ML' })
.expect(400);
const zero = await createMedicine('Zero', { withdrawalPeriodDays: 0 });
expect(zero).toMatchObject({ lotCount: 0, totalRemaining: 0 });
});
it('answers 404 for an unknown medicine, lot or lot of an unknown medicine', async () => {
await request(server())
.get('/api/v1/veterinary-medicines/does-not-exist')
.set(auth('READONLY'))
.expect(404);
await request(server())
.get('/api/v1/veterinary-medicine-lots/does-not-exist')
.set(auth('READONLY'))
.expect(404);
await request(server())
.post('/api/v1/veterinary-medicine-lots')
.set(auth('STAFF'))
.send({
medicineId: 'does-not-exist',
purchasedOn: '2026-08-01',
quantity: 1,
})
.expect(404);
});
it('a movement can neither be changed nor deleted (no such routes)', async () => {
const medicine = await createMedicine('Immutable');
const lot = await createLot(medicine.id, 5);
const detail = await request(server())
.post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`)
.set(auth('STAFF'))
.send({ type: 'CORRECTION', actualRemaining: 1 })
.expect(201);
const movementId = (detail.body as LotBody).movements?.[0].id ?? '';
const base = `/api/v1/veterinary-medicine-lots/${lot.id}/movements`;
await request(server())
.patch(`${base}/${movementId}`)
.set(auth('STAFF'))
.send({ quantity: 9 })
.expect(404);
await request(server())
.delete(`${base}/${movementId}`)
.set(auth('STAFF'))
.expect(404);
await request(server())
.delete(`/api/v1/veterinary-medicine-lots/${lot.id}`)
.set(auth('STAFF'))
.expect(404);
});
it('writes an audit entry for a correction and keeps the book of the lot', async () => {
const medicine = await createMedicine('Audit');
const lot = await createLot(medicine.id, 8);
await request(server())
.post(`/api/v1/veterinary-medicine-lots/${lot.id}/movements`)
.set(auth('STAFF'))
.send({ type: 'CORRECTION', actualRemaining: 5 })
.expect(201);
const entries = await prisma.auditLog.findMany({
where: { entityType: 'VeterinaryMedicineLot', entityId: lot.id },
orderBy: { timestamp: 'asc' },
});
expect(entries.map((entry) => entry.action)).toEqual(['CREATE', 'UPDATE']);
expect(entries[1].afterData).toMatchObject({ remaining: 5 });
});
});