feat: T6 wave 1 – compliance schema, permissions, lock order, backup
- 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:
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', () => {
|
||||
|
||||
@@ -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] }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user