feat: T6 wave 2 – medicine book, document rights, label particulars, herd events
- 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:
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user