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

@@ -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,