feat: T6 wave 1 – compliance schema, permissions, lock order, backup
Backend Image / build-and-push (push) Successful in 6m39s
Frontend Image / build-and-push (push) Successful in 11m25s

- Migrations (additive): veterinary medicines with lots and movement
  ledger, treatment medicine/lot/dose links, label particulars, authority
  notices, apiary restrictions, compliance settings and acknowledgements;
  notification category COMPLIANCE, added to non-empty Telegram lists.
- Permissions veterinary-medicines:* and compliance:* with role grants.
- LOCKABLE_TABLES: Apiary locks before Colony; T6 tables appended.
- Backup exports and restores the new tables in FK order; unsealed files
  skip them and report dropped medicine links; an open TSK draft in the
  target wins.
- describeNullContracts shared helper; Telegram backlog e2e no longer
  depends on the time of day.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-15 13:04:13 +02:00
1 parent 7a1480138d
commit 8a31c42578
23 files changed
+2028 -84

No files matched your search

@@ -391,6 +391,27 @@ export const PERMISSION_CATALOG = [
[['read', 'Inventar-Spiegel für ERPNext lesen']],
3600,
),
// T6: the veterinary medicine catalogue and its packs. Receipts, invoice
// numbers and supplier addresses live here, so the shop account gets none.
...group(
'Tierarzneimittel',
'veterinary-medicines',
[
['read', 'Ansehen'],
['create', 'Anlegen'],
['update', 'Bearbeiten'],
],
3700,
),
...group(
'Prüfbereitschaft & Behörden',
'compliance',
[
['read', 'Ansehen'],
['manage', 'Verwalten'],
],
3800,
),
] as const satisfies readonly PermissionCatalogEntry[];
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
@@ -64,6 +64,23 @@ const BUSINESS_ENTITY_KEYS = [
'backupSettings',
'calendarSyncSettings',
'calendarSyncOutboxEntry',
'veterinaryMedicine',
'veterinaryMedicineLot',
'veterinaryMedicineMovement',
'complianceSettings',
'authorityNotice',
'apiaryRestriction',
'complianceAcknowledgement',
] as const;
const T6_MODELS = [
'VeterinaryMedicine',
'VeterinaryMedicineLot',
'VeterinaryMedicineMovement',
'ComplianceSettings',
'AuthorityNotice',
'ApiaryRestriction',
'ComplianceAcknowledgement',
] as const;
const ALL_ENTITY_KEYS = [
@@ -1638,6 +1655,299 @@ describe('BackupService', () => {
});
});
describe('T6 compliance tables', () => {
const T6_KEYS = T6_MODELS.map(modelKey);
it('no T6 model is excluded from the backup or listed as legacy-unsealed', async () => {
const bundle = await service.exportAll();
for (const model of T6_MODELS) {
expect(MODELS_EXCLUDED_FROM_BACKUP).not.toHaveProperty(model);
expect(bundle.data).toHaveProperty(modelKey(model));
}
for (const key of T6_KEYS) {
expect(LEGACY_UNSEALED_TABLES as readonly string[]).not.toContain(key);
}
});
it('restores medicine, lot, treatment and movement in foreign-key order', async () => {
const one = (id: string) => [{ id }];
await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
apiary: one('apiary-1'),
apiaryRestriction: one('restriction-1'),
veterinaryMedicineMovement: one('movement-1'),
treatment: one('treatment-1'),
veterinaryMedicineLot: one('lot-1'),
veterinaryMedicine: one('medicine-1'),
},
},
'admin-1',
TRUSTED,
);
const order = (key: string) => tx[key].upsert.mock.invocationCallOrder[0];
const dependencies: [parent: string, child: string][] = [
['veterinaryMedicine', 'veterinaryMedicineLot'],
['veterinaryMedicineLot', 'treatment'],
['veterinaryMedicine', 'treatment'],
['treatment', 'veterinaryMedicineMovement'],
['veterinaryMedicineLot', 'veterinaryMedicineMovement'],
['apiary', 'apiaryRestriction'],
];
for (const [parent, child] of dependencies) {
expect({ parent, child, before: order(parent) < order(child) }).toEqual(
{ parent, child, before: true },
);
}
});
it('remaps the user columns of the T6 rows like every other table', async () => {
await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
veterinaryMedicineLot: [
{ id: 'lot-1', userId: 'gone-user' },
{ id: 'lot-2', userId: 'known-user' },
{ id: 'lot-3', userId: null },
],
veterinaryMedicineMovement: [{ id: 'movement-1', userId: 'gone' }],
authorityNotice: [{ id: 'notice-1', createdByUserId: 'gone' }],
apiaryRestriction: [{ id: 'restriction-1', createdByUserId: null }],
complianceAcknowledgement: [
{ id: 'ack-1', userId: 'gone-user', revokedByUserId: 'gone' },
],
},
},
'admin-1',
TRUSTED,
);
const created = (key: string, call = 0) =>
(tx[key].upsert.mock.calls[call] as [{ create: Row }])[0].create;
expect(created('veterinaryMedicineLot', 0).userId).toBe('admin-1');
expect(created('veterinaryMedicineLot', 1).userId).toBe('known-user');
expect(created('veterinaryMedicineLot', 2).userId).toBeNull();
expect(created('veterinaryMedicineMovement').userId).toBe('admin-1');
expect(created('authorityNotice').createdByUserId).toBe('admin-1');
expect(created('apiaryRestriction').createdByUserId).toBeNull();
expect(created('complianceAcknowledgement').userId).toBe('admin-1');
});
it('upserts the compliance settings singleton by id', async () => {
await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
complianceSettings: [
{ id: 'singleton', registrationNumber: 'X-1' },
],
},
},
'admin-1',
TRUSTED,
);
const [[call]] = tx.complianceSettings.upsert.mock.calls as [
[{ where: Row; update: Row }],
];
expect(call.where).toEqual({ id: 'singleton' });
expect(call.update).toEqual({ registrationNumber: 'X-1' });
});
it('skips a notice whose open draft key is held by another row here and counts it', async () => {
(
tx as unknown as { authorityNotice: { findUnique: jest.Mock } }
).authorityNotice.findUnique = jest
.fn()
.mockResolvedValueOnce({ id: 'other-draft' })
.mockResolvedValueOnce({ id: 'notice-2' });
const summary = await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
authorityNotice: [
{ id: 'notice-1', openKey: 'HERD_CHANGE' },
{ id: 'notice-2', openKey: 'TSK_REPORT:2027' },
{ id: 'notice-3', openKey: null },
],
},
},
'admin-1',
TRUSTED,
);
expect(tx.authorityNotice.upsert).toHaveBeenCalledTimes(2);
expect(summary.imported.authorityNotice).toBe(2);
expect(summary.imported.authorityNoticeSkipped).toBe(1);
});
it('an unsealed legacy file skips every T6 table and reports it', async () => {
const data = Object.fromEntries(
T6_KEYS.map((key) => [key, [{ id: `${key}-1` }]]),
);
const summary = await service.importAll(
{ version: 2, exportedAt: '2026-01-01T00:00:00.000Z', data },
'admin-1',
{ trusted: false },
);
for (const key of T6_KEYS) {
expect({ key, calls: tx[key].upsert.mock.calls.length }).toEqual({
key,
calls: 0,
});
}
expect([...summary.skipped].sort()).toEqual([...T6_KEYS].sort());
});
it('an unsealed file keeps the pack link off its treatments', async () => {
await service.importAll(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
treatment: [
{
id: 'treatment-1',
product: 'Mittel',
medicineId: 'medicine-1',
medicineLotId: 'lot-1',
doseQuantity: 5,
},
],
},
},
'admin-1',
{ trusted: false },
);
const [[call]] = tx.treatment.upsert.mock.calls as [[{ create: Row }]];
expect(call.create).not.toHaveProperty('medicineId');
expect(call.create).not.toHaveProperty('medicineLotId');
expect(call.create).not.toHaveProperty('doseQuantity');
expect(call.create.product).toBe('Mittel');
});
it('counts and logs the treatments whose pack link an unsealed file lost', async () => {
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation();
const summary = await service.importAll(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
treatment: [
{ id: 't1', medicineLotId: 'lot-1' },
{ id: 't2', doseQuantity: 3 },
{ id: 't3', product: 'ohne Verknüpfung', medicineId: null },
],
},
},
'admin-1',
{ trusted: false },
);
expect(summary.imported.treatmentMedicineLinksDropped).toBe(2);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('2 Behandlung'),
);
warn.mockRestore();
});
it('adds no link counter when a sealed file keeps the links', async () => {
const summary = await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: { treatment: [{ id: 't1', medicineLotId: 'lot-1' }] },
},
'admin-1',
TRUSTED,
);
expect(summary.imported).not.toHaveProperty(
'treatmentMedicineLinksDropped',
);
});
it('leaves out the documents of a skipped notice and lists its id', async () => {
(
tx as unknown as { authorityNotice: { findUnique: jest.Mock } }
).authorityNotice.findUnique = jest
.fn()
.mockResolvedValue({ id: 'other-draft' });
const pdf = Buffer.from('%PDF-1.4').toString('base64');
const doc = (id: string, entityType: string, entityId: string) => ({
id,
entityType,
entityId,
mimeType: 'application/pdf',
contentBase64: pdf,
});
const summary = await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
authorityNotice: [
{ id: 'notice-1', openKey: 'HERD_CHANGE' },
{ id: 'notice-2', openKey: null },
],
document: [
doc('d1', 'AUTHORITY_NOTICE', 'notice-1'),
doc('d2', 'AUTHORITY_NOTICE', 'notice-2'),
doc('d3', 'COLONY', 'notice-1'),
],
},
},
'admin-1',
TRUSTED,
);
expect(summary.skippedNoticeIds).toEqual(['notice-1']);
expect(summary.imported.authorityNoticeDocumentsSkipped).toBe(1);
expect(summary.imported.document).toBe(2);
const calls = tx.document.upsert.mock.calls as [
{ where: { id: string } },
][];
const ids = calls.map(([arg]) => arg.where.id);
expect(ids).toEqual(['d2', 'd3']);
});
it('a sealed file keeps the pack link of its treatments', async () => {
await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
treatment: [
{ id: 'treatment-1', medicineLotId: 'lot-1', doseQuantity: 5 },
],
},
},
'admin-1',
TRUSTED,
);
const [[call]] = tx.treatment.upsert.mock.calls as [[{ create: Row }]];
expect(call.create).toMatchObject({
medicineLotId: 'lot-1',
doseQuantity: 5,
});
});
});
// Drift guard: a model added to the schema has to be backed up or listed
// in MODELS_EXCLUDED_FROM_BACKUP with its reason - never silently missing.
describe('model coverage', () => {
+195 -10
View File
@@ -42,6 +42,12 @@ export interface BackupImportSummary {
* table outside LEGACY_UNSEALED_TABLES.
*/
skipped: string[];
/**
* Authority notices of the file left out because the target already holds
* the open draft for their kind and year (the target wins). Their documents
* are left out too.
*/
skippedNoticeIds?: string[];
/** False for an old, unsealed file: restricted and add-only. */
sealed: boolean;
}
@@ -179,6 +185,17 @@ function asString(row: Row, key: string): string | null {
return typeof value === 'string' ? value : null;
}
// An unsealed file cannot book the pack ledger, so it cannot link a treatment
// to a catalogue entry or a pack either (that would be a dose without a
// booking).
function withoutMedicineLinks(row: Row): Row {
const rest: Row = { ...row };
delete rest.medicineId;
delete rest.medicineLotId;
delete rest.doseQuantity;
return rest;
}
// Bundles written before the feed vocabulary change carry free text in
// Inspection.feedStatus: known words map to the enum, the rest goes to notes.
function withCurrentFeedVocabulary(row: Row): Row {
@@ -307,6 +324,7 @@ export class BackupService {
});
data.apiary = await this.prisma.apiary.findMany();
data.apiaryRestriction = await this.prisma.apiaryRestriction.findMany();
data.queen = await this.prisma.queen.findMany();
data.colony = await this.prisma.colony.findMany();
data.colonyApiaryHistory = await this.prisma.colonyApiaryHistory.findMany();
@@ -321,7 +339,12 @@ export class BackupService {
data.swarmControlCheck = await this.prisma.swarmControlCheck.findMany();
data.winterCheck = await this.prisma.winterCheck.findMany();
data.phenologyReport = await this.prisma.phenologyReport.findMany();
data.veterinaryMedicine = await this.prisma.veterinaryMedicine.findMany();
data.veterinaryMedicineLot =
await this.prisma.veterinaryMedicineLot.findMany();
data.treatment = await this.prisma.treatment.findMany();
data.veterinaryMedicineMovement =
await this.prisma.veterinaryMedicineMovement.findMany();
data.feeding = await this.prisma.feeding.findMany();
data.harvest = await this.prisma.harvest.findMany();
data.harvestColony = await this.prisma.harvestColony.findMany();
@@ -349,6 +372,10 @@ export class BackupService {
data.automationRule = await this.prisma.automationRule.findMany();
data.automationSequence = await this.prisma.automationSequence.findMany();
data.siteSettings = await this.prisma.siteSettings.findMany();
data.complianceSettings = await this.prisma.complianceSettings.findMany();
data.authorityNotice = await this.prisma.authorityNotice.findMany();
data.complianceAcknowledgement =
await this.prisma.complianceAcknowledgement.findMany();
data.backupSettings = await this.prisma.backupSettings.findMany();
data.calendarSyncSettings =
await this.prisma.calendarSyncSettings.findMany();
@@ -424,6 +451,8 @@ export class BackupService {
// Document files the restored rows no longer point at - deleted only
// once the transaction has committed.
const replacedDocumentFiles: string[] = [];
// Notices of the file the target's open draft replaced (see below).
const skippedNoticeIds = new Set<string>();
const imported: Record<string, number> = {};
@@ -536,6 +565,21 @@ export class BackupService {
}
imported.apiary = rowsOf('apiary').length;
for (const row of rowsOf('apiaryRestriction')) {
const restriction = {
...row,
createdByUserId: resolveOptionalUserId(
asString(row, 'createdByUserId'),
),
};
await tx.apiaryRestriction.upsert({
where: { id: row.id as string },
create: restriction as never,
update: updateWith(restriction) as never,
});
}
imported.apiaryRestriction = rowsOf('apiaryRestriction').length;
// Queens already here before this import keep their mother line when
// the file is unsealed (add-only, see updateWith).
const preexistingQueenIds = new Set<string>(
@@ -772,20 +816,72 @@ export class BackupService {
}
imported.phenologyReport = rowsOf('phenologyReport').length;
// Catalogue and packs before the treatments that point at them; the
// pack ledger after the treatments its bookings belong to.
for (const row of rowsOf('veterinaryMedicine')) {
await tx.veterinaryMedicine.upsert({
where: { id: row.id as string },
create: row as never,
update: updateWith(row) as never,
});
}
imported.veterinaryMedicine = rowsOf('veterinaryMedicine').length;
for (const row of rowsOf('veterinaryMedicineLot')) {
const lot = {
...row,
userId: resolveOptionalUserId(asString(row, 'userId')),
};
await tx.veterinaryMedicineLot.upsert({
where: { id: row.id as string },
create: lot as never,
update: updateWith(lot) as never,
});
}
imported.veterinaryMedicineLot = rowsOf('veterinaryMedicineLot').length;
let medicineLinksDropped = 0;
for (const row of rowsOf('treatment')) {
if (
!trusted &&
(row.medicineId != null ||
row.medicineLotId != null ||
row.doseQuantity != null)
) {
medicineLinksDropped += 1;
}
const treatment = {
...(trusted ? row : withoutMedicineLinks(row)),
userId: resolveUserId(asString(row, 'userId')),
};
await tx.treatment.upsert({
where: { id: row.id as string },
create: {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
update: updateWith({
...row,
userId: resolveUserId(asString(row, 'userId')),
}) as never,
create: treatment as never,
update: updateWith(treatment) as never,
});
}
imported.treatment = rowsOf('treatment').length;
if (medicineLinksDropped > 0) {
imported.treatmentMedicineLinksDropped = medicineLinksDropped;
this.logger.warn(
`${medicineLinksDropped} Behandlung(en) ohne Mittel-/Packungsverknüpfung übernommen: Eine unversiegelte Datei darf das Packungsbuch nicht schreiben.`,
);
}
for (const row of rowsOf('veterinaryMedicineMovement')) {
const movement = {
...row,
userId: resolveOptionalUserId(asString(row, 'userId')),
};
await tx.veterinaryMedicineMovement.upsert({
where: { id: row.id as string },
create: movement as never,
update: movement as never,
});
}
imported.veterinaryMedicineMovement = rowsOf(
'veterinaryMedicineMovement',
).length;
for (const row of rowsOf('feeding')) {
await tx.feeding.upsert({
@@ -1127,6 +1223,70 @@ export class BackupService {
}
imported.siteSettings = rowsOf('siteSettings').length;
for (const row of rowsOf('complianceSettings')) {
const { id, ...withoutId } = row;
await tx.complianceSettings.upsert({
where: { id: (id as string) ?? 'singleton' },
create: row as never,
update: withoutId as never,
});
}
imported.complianceSettings = rowsOf('complianceSettings').length;
// The open draft of a kind and year is unique (openKey): a draft of
// this database that holds the key under another id is kept and the
// file's row skipped, instead of aborting the whole restore.
let noticesWritten = 0;
let noticesSkipped = 0;
for (const row of rowsOf('authorityNotice')) {
const notice = {
...row,
createdByUserId: resolveOptionalUserId(
asString(row, 'createdByUserId'),
),
};
const openKey = asString(row, 'openKey');
if (openKey !== null) {
const holder = await tx.authorityNotice.findUnique({
where: { openKey },
select: { id: true },
});
if (holder && holder.id !== row.id) {
noticesSkipped += 1;
skippedNoticeIds.add(row.id as string);
continue;
}
}
await tx.authorityNotice.upsert({
where: { id: row.id as string },
create: notice as never,
update: notice as never,
});
noticesWritten += 1;
}
imported.authorityNotice = noticesWritten;
if (noticesSkipped > 0) {
imported.authorityNoticeSkipped = noticesSkipped;
this.logger.warn(
`${noticesSkipped} Behördenanzeige(n) übersprungen: Im Zielsystem gibt es schon einen offenen Entwurf.`,
);
}
for (const row of rowsOf('complianceAcknowledgement')) {
const acknowledgement = {
...row,
userId: resolveUserId(asString(row, 'userId')),
};
await tx.complianceAcknowledgement.upsert({
where: { id: row.id as string },
create: acknowledgement as never,
update: acknowledgement as never,
});
}
imported.complianceAcknowledgement = rowsOf(
'complianceAcknowledgement',
).length;
for (const row of rowsOf('backupSettings')) {
const { id, ...withoutId } = row;
await tx.backupSettings.upsert({
@@ -1175,7 +1335,17 @@ export class BackupService {
// written here is simply orphaned on disk (wasted space), not a
// correctness problem, since no DB row ends up pointing at it.
let documentCount = 0;
let noticeDocumentsSkipped = 0;
for (const row of rowsOf('document')) {
// The open draft of the target wins: no document of a skipped
// notice is restored, so none is left without its notice.
if (
asString(row, 'entityType') === 'AUTHORITY_NOTICE' &&
skippedNoticeIds.has(asString(row, 'entityId') ?? '')
) {
noticeDocumentsSkipped += 1;
continue;
}
const contentBase64 = row.contentBase64 as string | null | undefined;
if (!contentBase64) {
continue;
@@ -1234,6 +1404,9 @@ export class BackupService {
documentCount += 1;
}
imported.document = documentCount;
if (noticeDocumentsSkipped > 0) {
imported.authorityNoticeDocumentsSkipped = noticeDocumentsSkipped;
}
const activeUserIds = (
await tx.user.findMany({
@@ -1340,12 +1513,24 @@ export class BackupService {
entityId: 'import',
action: 'CREATE',
userId: actingUserId,
afterData: { imported, skipped, sealed: trusted },
afterData: {
imported,
skipped,
sealed: trusted,
skippedNoticeIds: [...skippedNoticeIds],
},
});
await this.removeReplacedDocumentFiles(replacedDocumentFiles);
return { imported, skipped, sealed: trusted };
return {
imported,
skipped,
sealed: trusted,
...(skippedNoticeIds.size > 0
? { skippedNoticeIds: [...skippedNoticeIds] }
: {}),
};
}
/**
@@ -0,0 +1,61 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
/**
* PATCH contract of an update DTO: an omitted field stays unchanged, null
* clears a field whose column is nullable, and null on a column that cannot
* hold it is a 400 - never silently ignored, never a 500 from the write.
*/
export interface NullContract {
dto: new () => object;
/** Fields whose column cannot hold null: null is a 400 naming the field. */
notNull: string[];
/** Fields whose column is nullable: null is accepted. */
clearable: string[];
}
async function errorPropertiesFor(
dto: new () => object,
body: Record<string, unknown>,
): Promise<string[]> {
const errors = await validate(plainToInstance(dto, body), {
whitelist: true,
forbidNonWhitelisted: true,
});
return errors.map((error) => error.property);
}
/**
* Registers the three checks for every contract (jest `describe`/`it`): an
* empty patch passes, null on a `notNull` field is rejected with exactly that
* field, null on a `clearable` field passes. Call it at the top level of a
* spec file; each spec file keeps its own contracts.
*/
export function describeNullContracts(
title: string,
contracts: Record<string, NullContract>,
): void {
describe(title, () => {
describe.each(Object.entries(contracts))('%s', (_name, contract) => {
it('accepts an empty patch', async () => {
await expect(errorPropertiesFor(contract.dto, {})).resolves.toEqual([]);
});
if (contract.notNull.length > 0) {
it.each(contract.notNull)('rejects null for %s', async (field) => {
await expect(
errorPropertiesFor(contract.dto, { [field]: null }),
).resolves.toEqual([field]);
});
}
if (contract.clearable.length > 0) {
it.each(contract.clearable)('accepts null for %s', async (field) => {
await expect(
errorPropertiesFor(contract.dto, { [field]: null }),
).resolves.toEqual([]);
});
}
});
});
}
@@ -1,5 +1,3 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { UpdateApiaryDto } from '../../apiaries/dto/update-apiary.dto';
import { UpdateAutomationRuleDto } from '../../automation/dto/update-automation-rule.dto';
import { UpdateBackupSettingsDto } from '../../backup/dto/update-backup-settings.dto';
@@ -35,15 +33,10 @@ import { UpdateUserDto } from '../../users/dto/update-user.dto';
import { UpdateVarroaMeasurementDto } from '../../varroa/dto/update-varroa-measurement.dto';
import { UpdateWebhookDto } from '../../webhooks/dto/update-webhook.dto';
import { UpdateWinterCheckDto } from '../../winter-checks/dto/update-winter-check.dto';
// PATCH contract of every update DTO: an omitted field stays unchanged, null
// clears a field whose column is nullable, and null on a column that cannot
// hold it is a 400 - never silently ignored, never a 500 from the write.
interface NullContract {
dto: new () => object;
notNull: string[];
clearable: string[];
}
import {
describeNullContracts,
NullContract,
} from './null-contract.spec-helper';
const contracts: Record<string, NullContract> = {
UpdateApiaryDto: {
@@ -374,37 +367,4 @@ const contracts: Record<string, NullContract> = {
},
};
async function errorPropertiesFor(
dto: new () => object,
body: Record<string, unknown>,
): Promise<string[]> {
const errors = await validate(plainToInstance(dto, body), {
whitelist: true,
forbidNonWhitelisted: true,
});
return errors.map((error) => error.property);
}
describe('update DTO null handling', () => {
describe.each(Object.entries(contracts))('%s', (_name, contract) => {
it('accepts an empty patch', async () => {
await expect(errorPropertiesFor(contract.dto, {})).resolves.toEqual([]);
});
if (contract.notNull.length > 0) {
it.each(contract.notNull)('rejects null for %s', async (field) => {
await expect(
errorPropertiesFor(contract.dto, { [field]: null }),
).resolves.toEqual([field]);
});
}
if (contract.clearable.length > 0) {
it.each(contract.clearable)('accepts null for %s', async (field) => {
await expect(
errorPropertiesFor(contract.dto, { [field]: null }),
).resolves.toEqual([]);
});
}
});
});
describeNullContracts('update DTO null handling', contracts);
@@ -113,6 +113,18 @@ describe('deliveryAvailableAt', () => {
expect(deliveryAvailableAt(now, 'SUGGESTION', NO_QUIET)).toEqual(now);
});
it('COMPLIANCE has no time of day: available now, inside quiet hours at their end', () => {
const afternoon = at('2026-10-13', '15:00');
const night = at('2026-10-13', '23:00');
expect(deliveryAvailableAt(afternoon, 'COMPLIANCE', DEFAULTS)).toEqual(
afternoon,
);
expect(deliveryAvailableAt(night, 'COMPLIANCE', DEFAULTS)).toEqual(
at('2026-10-14', '06:30'),
);
});
it('ends quiet hours at 06:30 Berlin time across the clock change', () => {
const now = at('2026-10-24', '22:00');
@@ -0,0 +1,36 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { NotificationCategory } from '../../../generated/prisma';
import { UpdateNotificationSettingsDto } from './update-notification-settings.dto';
async function errorsOf(body: Record<string, unknown>): Promise<string[]> {
const errors = await validate(
plainToInstance(UpdateNotificationSettingsDto, body),
{ whitelist: true, forbidNonWhitelisted: true },
);
return errors.map((error) => error.property);
}
describe('UpdateNotificationSettingsDto telegramCategories', () => {
it('accepts COMPLIANCE like every other category', async () => {
await expect(
errorsOf({ telegramCategories: ['COMPLIANCE'] }),
).resolves.toEqual([]);
});
it('accepts the complete list of categories, COMPLIANCE included', async () => {
await expect(
errorsOf({ telegramCategories: Object.values(NotificationCategory) }),
).resolves.toEqual([]);
expect(Object.values(NotificationCategory)).toContain('COMPLIANCE');
});
it('rejects an unknown category and a duplicate', async () => {
await expect(
errorsOf({ telegramCategories: ['AUTHORITY'] }),
).resolves.toEqual(['telegramCategories']);
await expect(
errorsOf({ telegramCategories: ['COMPLIANCE', 'COMPLIANCE'] }),
).resolves.toEqual(['telegramCategories']);
});
});
@@ -15,7 +15,7 @@ const stored = {
quietFrom: '21:00' as string | null,
quietUntil: '06:30' as string | null,
telegramEnabled: false,
telegramCategories: ['TASK_DUE', 'DIGEST'],
telegramCategories: ['TASK_DUE', 'DIGEST', 'COMPLIANCE'],
telegramDetail: true,
updatedAt: new Date(0),
};
@@ -167,7 +167,7 @@ describe('NotificationPreferencesService', () => {
digestTime: '07:00',
quietFrom: '21:00',
quietUntil: '06:30',
telegramCategories: ['TASK_DUE', 'DIGEST'],
telegramCategories: ['TASK_DUE', 'DIGEST', 'COMPLIANCE'],
telegramDetail: false,
},
});
+53 -2
View File
@@ -199,7 +199,14 @@ describe('lock keys per table', () => {
'StockItem',
'Treatment',
'VarroaMeasurement',
'Apiary',
'NotificationDelivery',
'LabelTemplate',
'VeterinaryMedicine',
'VeterinaryMedicineLot',
'AuthorityNotice',
'ApiaryRestriction',
'ComplianceAcknowledgement',
] as const)('locks %s by "id"', async (table) => {
const tx = { $queryRaw: jest.fn().mockResolvedValue([]) };
@@ -219,8 +226,9 @@ describe('lock keys per table', () => {
expect(tx.$queryRaw).not.toHaveBeenCalled();
});
it('keeps the map in lock order: Colony, Suggestion, Task, Queen, AutomationRule, then the rest', () => {
it('keeps the map in lock order: Apiary before Colony, the T6 tables last', () => {
expect(LOCK_ORDER).toEqual([
'Apiary',
'Colony',
'Suggestion',
'Task',
@@ -237,11 +245,54 @@ describe('lock keys per table', () => {
'Treatment',
'VarroaMeasurement',
'NotificationPreference',
'Apiary',
'PhenologyReport',
'Extraction',
'HoneyBatch',
'NotificationDelivery',
'LabelTemplate',
'SiteSettings',
'VeterinaryMedicine',
'VeterinaryMedicineLot',
'ComplianceSettings',
'AuthorityNotice',
'ApiaryRestriction',
'ComplianceAcknowledgement',
]);
});
it('every table of the map is lockable by id and each appears once', async () => {
expect(new Set(LOCK_ORDER).size).toBe(LOCK_ORDER.length);
const keyed = new Map([['NotificationPreference', 'userId']]);
for (const table of LOCK_ORDER) {
const tx = { $queryRaw: jest.fn().mockResolvedValue([]) };
await lockRowForUpdate(tx as never, table, 'x1');
const key = keyed.get(table) ?? 'id';
const column = key === 'id' ? 'id' : `"${key}"`;
const [[query]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]];
expect(query.sql).toBe(
`SELECT ${column} FROM "${table}" WHERE ${column} = ? FOR UPDATE`,
);
}
});
it.each(['SiteSettings', 'ComplianceSettings'] as const)(
'locks the %s singleton row by "id"',
async (table) => {
const tx = {
$queryRaw: jest.fn().mockResolvedValue([{ id: 'singleton' }]),
};
await expect(
lockRowForUpdate(tx as never, table, 'singleton'),
).resolves.toBe(true);
const [[query]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]];
expect(query.sql).toBe(
`SELECT id FROM "${table}" WHERE id = ? FOR UPDATE`,
);
expect(query.values).toEqual(['singleton']);
},
);
});
+18 -4
View File
@@ -13,8 +13,11 @@ function lockable(table: string, key = 'id') {
// Fixed identifier map: a table or key column name is never taken from input.
// The order is the lock order: a transaction that locks rows of several
// tables takes them in this order (and ascending key within a table), so two
// of them cannot wait for each other.
// of them cannot wait for each other. Apiary comes first: the paths that lock
// an apiary and a colony together (syncApiaryIn, moving, merging, creating a
// colony) take the apiary rows before the colony rows.
const LOCKABLE_TABLES = {
Apiary: lockable('Apiary'),
Colony: lockable('Colony'),
Suggestion: lockable('Suggestion'),
Task: lockable('Task'),
@@ -32,14 +35,25 @@ const LOCKABLE_TABLES = {
VarroaMeasurement: lockable('VarroaMeasurement'),
// One row per user, keyed by the user (the table arrives with Task 3).
NotificationPreference: lockable('NotificationPreference', 'userId'),
// T4b tables (Task 23): the apiary season state, phenology reports and the
// honey chain (extraction, honey batch ledger).
Apiary: lockable('Apiary'),
// T4b tables (Task 23): phenology reports and the honey chain (extraction,
// honey batch ledger).
PhenologyReport: lockable('PhenologyReport'),
Extraction: lockable('Extraction'),
HoneyBatch: lockable('HoneyBatch'),
// T4c (Task 34): the Telegram delivery outbox.
NotificationDelivery: lockable('NotificationDelivery'),
// T6 (Task 1): label templates, the settings singleton (row "singleton"),
// the veterinary medicine catalogue and its packs, the compliance settings
// singleton (row "singleton", the mutex of the authority notices), the
// notices, the apiary restrictions and the readiness acknowledgements.
LabelTemplate: lockable('LabelTemplate'),
SiteSettings: lockable('SiteSettings'),
VeterinaryMedicine: lockable('VeterinaryMedicine'),
VeterinaryMedicineLot: lockable('VeterinaryMedicineLot'),
ComplianceSettings: lockable('ComplianceSettings'),
AuthorityNotice: lockable('AuthorityNotice'),
ApiaryRestriction: lockable('ApiaryRestriction'),
ComplianceAcknowledgement: lockable('ComplianceAcknowledgement'),
} as const;
export type LockableTable = keyof typeof LOCKABLE_TABLES;