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

@@ -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;
}
}