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

@@ -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', () => {