fix: Paket 0 – data integrity, security hardening and field usability
Backend - Backup covers every Prisma model (bundle v3); a guard test fails for any new model that is neither backed up nor explicitly excluded. Unsealed bundles may only add rows to 27 legacy master/field tables and never overwrite; counters, ledgers, settings, roles etc. need a sealed file. Shared import limit BACKUP_MAX_IMPORT_BYTES (default 50 MB, max 256 MB) with warnings in log and UI; replaced document files are removed after commit. - Post-commit side effects (automation, calendar sync, webhooks) run via runAfterCommit and never turn a committed write into a 500, so retries with the same Idempotency-Key no longer create duplicates. - Calendar outbox is multi-replica safe: per-entry claims with stale takeover, revision-based settle, new Graph event only on 404/410, transactionId on create, 30 s Graph timeout, URL-encoded ids, GUID validation for groupId, "run now" in background and resets FAILED. - Automation tasks are deduplicated under a row lock on the rule. - Update DTOs follow one null rule: omitted = unchanged, null clears a nullable field, null on a required field is a 400. - Varroa mites/day is recomputed on correction; withdrawal status is checked against the harvest date (?at=), bounded colonyIds; inspection create is audited; updates re-enqueue calendar entries. - Dashboard, statistics and apiary/colony guards treat WEAK, CONSPICUOUS and QUEENLESS colonies as living. - Prisma errors and body-parser errors are logged without query values or request bodies. - Migration 20261012100000_calendar_sync_claims; schema index name aligned with the existing database object. Frontend - Sticky save bar sticks again (no scroll container ancestors, card uses overflow-clip), safe-area insets, scroll-padding, FAB hidden on forms, sticky desktop sidebar. - Inspections record yes / no / not checked instead of silent defaults. - All living colonies are selectable; shared RecordSelect, EnumSelect, ChoiceGroup and TriStateField replace per-page copies; selects show names instead of ids; labels point at real controls. - Emptied optional fields are sent as null (emptyToNull) so every field can be cleared; dates default to the local day and are sent via toApiDate. - Harvest withdrawal warning uses the harvest date; backup page warns when a backup outgrows the import limit and lists skipped tables. Build and docs - Docker builds fail unless lint, typecheck and unit tests pass. - ARCHITECTURE.md updated (assumptions 9-12, 19-21, section 8) and spec docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md for the backbone (api/worker/job roles, domain event outbox) and the feature roadmap T3-T7. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
f5b1964223
commit
10cbe9bf2d
310 files changed
+12177
-3746
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '../documents/document-mime-types';
|
||||
import { matchesFileSignature } from '../common/file-signature';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import { DocumentEntityType } from '../../generated/prisma';
|
||||
import { DocumentEntityType, Prisma } from '../../generated/prisma';
|
||||
import {
|
||||
SealedBackup,
|
||||
isSealedBackup,
|
||||
@@ -18,7 +18,10 @@ import {
|
||||
sealBackup,
|
||||
} from './backup-crypto';
|
||||
|
||||
const BACKUP_VERSION = 2;
|
||||
// 3: production, raw material, stock ledger, number sequence, calendar sync
|
||||
// and backup schedule tables. Older bundles simply lack those sections and
|
||||
// restore them as empty.
|
||||
const BACKUP_VERSION = 3;
|
||||
const IMPORT_TRANSACTION_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
export interface BackupBundle {
|
||||
@@ -30,34 +33,107 @@ export interface BackupBundle {
|
||||
export interface BackupImportSummary {
|
||||
imported: Record<string, number>;
|
||||
/**
|
||||
* Tables left out because the file was an old, unsealed (v2) backup: its
|
||||
* contents cannot be proven to come from ApiCore, so nothing that grants
|
||||
* access (users, roles, webhooks) or rewrites history (audit log) is
|
||||
* taken from it.
|
||||
* Tables the file carried but an unsealed backup may not write - every
|
||||
* table outside LEGACY_UNSEALED_TABLES.
|
||||
*/
|
||||
skipped: string[];
|
||||
/** False for an old, unsealed file: restricted and add-only. */
|
||||
sealed: boolean;
|
||||
}
|
||||
|
||||
// What an unsealed file may never write - see BackupImportSummary.skipped.
|
||||
const TRUSTED_ONLY_TABLES = [
|
||||
'role',
|
||||
'user',
|
||||
'rolePermission',
|
||||
'webhookSubscription',
|
||||
'auditLog',
|
||||
/**
|
||||
* All an unsealed file may write. Its contents cannot be proven to come from
|
||||
* ApiCore (it may be hand-built), so it is limited to the plain beekeeping
|
||||
* master and field records of the old unsealed (v2) format - records an
|
||||
* admin could just as well type in again - and it may only add missing
|
||||
* rows, never rewrite existing ones.
|
||||
*
|
||||
* Sealed-only, and why (any table not listed here is sealed-only too, so
|
||||
* tables added later are safe by default):
|
||||
* - role, user, rolePermission: grant access.
|
||||
* - webhookSubscription, calendarSyncSettings, calendarSyncOutboxEntry:
|
||||
* send data out, or update/delete events in an M365 calendar.
|
||||
* - backupSettings: retention 1 would prune every older backup.
|
||||
* - auditLog: history must not be rewritten.
|
||||
* - automationRule, automationSequence: hold the lot-number counters (a
|
||||
* reset hands out duplicate batch numbers) and the rule thresholds.
|
||||
* - stockItem, stockMovement, batchMovement: running stock and the stock
|
||||
* ledgers - never forged, only booked.
|
||||
* - shopStockRequest: the shop's idempotency keys - a planted one would
|
||||
* swallow a real sale.
|
||||
* - rawMaterialLot, productionBatch, productionInput,
|
||||
* honeyBatchContainerAllocation: newer than the unsealed format, so a
|
||||
* genuine old file never carries them.
|
||||
* - siteSettings: keeper name/address printed on the Bestandsbuch and the
|
||||
* public links.
|
||||
* - document: writes files to the server's disk.
|
||||
* - notification: messages shown to users as coming from ApiCore.
|
||||
*/
|
||||
export const LEGACY_UNSEALED_TABLES = [
|
||||
'apiary',
|
||||
'queen',
|
||||
'colony',
|
||||
'colonyApiaryHistory',
|
||||
'colonyLineage',
|
||||
'queenEvaluation',
|
||||
'queenColonyHistory',
|
||||
'inspection',
|
||||
'taskTemplate',
|
||||
'task',
|
||||
'varroaMeasurement',
|
||||
'swarmControlCheck',
|
||||
'winterCheck',
|
||||
'treatment',
|
||||
'feeding',
|
||||
'harvest',
|
||||
'harvestColony',
|
||||
'extraction',
|
||||
'extractionHarvest',
|
||||
'storageContainer',
|
||||
'honeyBatch',
|
||||
'honeyMeasurement',
|
||||
'honeyBatchRelocation',
|
||||
'product',
|
||||
'labelTemplate',
|
||||
'bottlingBatch',
|
||||
'equipment',
|
||||
] as const;
|
||||
|
||||
// Bundle sections that name a real table - a file's other keys are ignored
|
||||
// for every file and so never reported as skipped.
|
||||
const MODEL_SECTIONS = new Set<string>(
|
||||
Object.values(Prisma.ModelName).map(
|
||||
(model) => model.charAt(0).toLowerCase() + model.slice(1),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Models deliberately left out of the backup, each with its reason. Every
|
||||
* other Prisma model is a section of the bundle, named like its client
|
||||
* delegate (`productionBatch`). The model coverage test in
|
||||
* backup.service.spec.ts fails for a model that is neither, so a new table
|
||||
* cannot silently drop out of the backup.
|
||||
*/
|
||||
export const MODELS_EXCLUDED_FROM_BACKUP: Readonly<
|
||||
Partial<Record<Prisma.ModelName, string>>
|
||||
> = {
|
||||
Permission:
|
||||
'Code-derived catalog, re-synced from the code on every boot. RolePermission rows reference permissions by resource+action instead, resolved against the target database.',
|
||||
RefreshToken:
|
||||
'Live session tokens - a session must never be revivable from a backup file.',
|
||||
ConsumedToken:
|
||||
'Replay guard for one-time tokens that expire within minutes, so always stale by the time a backup is restored.',
|
||||
ApiKey:
|
||||
'Machine credentials stay out on purpose (ARCHITECTURE.md assumption 14): a new key is issued after a restore. BatchMovement/ShopStockRequest keep apiKeyId only as a plain historical reference.',
|
||||
IdempotencyRecord:
|
||||
'Volatile cache of first responses to retried create requests (ARCHITECTURE.md assumption 11) - a restored entry could only replay a stale response.',
|
||||
};
|
||||
|
||||
const DOCUMENT_ENTITY_TYPES = new Set<string>(
|
||||
Object.values(DocumentEntityType),
|
||||
);
|
||||
|
||||
// Excluded on purpose:
|
||||
// - Permission: code-derived, re-synced from the catalog on every boot -
|
||||
// including it would just fight that sync. RolePermission references
|
||||
// permissions by resource+action (not id) instead, resolved against
|
||||
// whatever the target database's own sync already produced.
|
||||
// - RefreshToken: live session tokens, meaningless once restored (expired,
|
||||
// or a stale session that should never be revivable from a backup file).
|
||||
// Excluded on purpose: see MODELS_EXCLUDED_FROM_BACKUP.
|
||||
//
|
||||
// Role/User/RolePermission ARE included (v2) - Role is matched by its `key`
|
||||
// (not id, since a fresh deployment's seeded system roles get a new
|
||||
@@ -84,6 +160,8 @@ function documentsStorageRoot(): string {
|
||||
|
||||
@Injectable()
|
||||
export class BackupService {
|
||||
private readonly logger = new Logger(BackupService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
@@ -98,7 +176,7 @@ export class BackupService {
|
||||
|
||||
/**
|
||||
* Restores an uploaded file. A sealed backup is restored completely; an
|
||||
* old unsealed one only with its business data (see TRUSTED_ONLY_TABLES).
|
||||
* old unsealed one only add-only and limited to LEGACY_UNSEALED_TABLES.
|
||||
*/
|
||||
async importFile(
|
||||
parsed: unknown,
|
||||
@@ -162,15 +240,28 @@ export class BackupService {
|
||||
data.honeyMeasurement = await this.prisma.honeyMeasurement.findMany();
|
||||
data.honeyBatchRelocation =
|
||||
await this.prisma.honeyBatchRelocation.findMany();
|
||||
data.honeyBatchContainerAllocation =
|
||||
await this.prisma.honeyBatchContainerAllocation.findMany();
|
||||
data.stockItem = await this.prisma.stockItem.findMany();
|
||||
data.stockMovement = await this.prisma.stockMovement.findMany();
|
||||
data.product = await this.prisma.product.findMany();
|
||||
data.labelTemplate = await this.prisma.labelTemplate.findMany();
|
||||
data.bottlingBatch = await this.prisma.bottlingBatch.findMany();
|
||||
data.rawMaterialLot = await this.prisma.rawMaterialLot.findMany();
|
||||
data.productionBatch = await this.prisma.productionBatch.findMany();
|
||||
data.productionInput = await this.prisma.productionInput.findMany();
|
||||
data.shopStockRequest = await this.prisma.shopStockRequest.findMany();
|
||||
data.batchMovement = await this.prisma.batchMovement.findMany();
|
||||
data.equipment = await this.prisma.equipment.findMany();
|
||||
data.webhookSubscription = await this.prisma.webhookSubscription.findMany();
|
||||
data.automationRule = await this.prisma.automationRule.findMany();
|
||||
data.automationSequence = await this.prisma.automationSequence.findMany();
|
||||
data.siteSettings = await this.prisma.siteSettings.findMany();
|
||||
data.backupSettings = await this.prisma.backupSettings.findMany();
|
||||
data.calendarSyncSettings =
|
||||
await this.prisma.calendarSyncSettings.findMany();
|
||||
data.calendarSyncOutboxEntry =
|
||||
await this.prisma.calendarSyncOutboxEntry.findMany();
|
||||
|
||||
const documents = await this.prisma.document.findMany();
|
||||
data.document = await Promise.all(
|
||||
@@ -213,21 +304,32 @@ export class BackupService {
|
||||
throw new BadRequestException('Invalid backup file');
|
||||
}
|
||||
|
||||
const isTrustedOnly = (key: string): boolean =>
|
||||
(TRUSTED_ONLY_TABLES as readonly string[]).includes(key);
|
||||
const isAllowedUnsealed = (key: string): boolean =>
|
||||
(LEGACY_UNSEALED_TABLES as readonly string[]).includes(key);
|
||||
const skipped = trusted
|
||||
? []
|
||||
: TRUSTED_ONLY_TABLES.filter((key) => {
|
||||
: Object.keys(bundle.data).filter((key) => {
|
||||
const value = bundle.data[key];
|
||||
return Array.isArray(value) && value.length > 0;
|
||||
return (
|
||||
MODEL_SECTIONS.has(key) &&
|
||||
!isAllowedUnsealed(key) &&
|
||||
Array.isArray(value) &&
|
||||
value.length > 0
|
||||
);
|
||||
});
|
||||
const rowsOf = (key: string): Row[] => {
|
||||
if (!trusted && isTrustedOnly(key)) {
|
||||
if (!trusted && !isAllowedUnsealed(key)) {
|
||||
return [];
|
||||
}
|
||||
const value = bundle.data[key];
|
||||
return Array.isArray(value) ? (value as Row[]) : [];
|
||||
};
|
||||
// An unsealed file may add the rows it carries, never rewrite or roll
|
||||
// back ones that already exist here.
|
||||
const updateWith = (data: Row): Row => (trusted ? data : {});
|
||||
// Document files the restored rows no longer point at - deleted only
|
||||
// once the transaction has committed.
|
||||
const replacedDocumentFiles: string[] = [];
|
||||
|
||||
const imported: Record<string, number> = {};
|
||||
|
||||
@@ -324,11 +426,26 @@ export class BackupService {
|
||||
await tx.apiary.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.apiary = rowsOf('apiary').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>(
|
||||
trusted
|
||||
? []
|
||||
: (
|
||||
await tx.queen.findMany({
|
||||
where: {
|
||||
id: { in: rowsOf('queen').map((row) => row.id as string) },
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
).map((queen) => queen.id),
|
||||
);
|
||||
|
||||
// Inserted without motherQueenId first (a daughter queen can appear
|
||||
// before her mother in export order) - patched in a second pass
|
||||
// below once every queen row exists.
|
||||
@@ -337,7 +454,7 @@ export class BackupService {
|
||||
await tx.queen.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: withoutMother as never,
|
||||
update: withoutMother as never,
|
||||
update: updateWith(withoutMother) as never,
|
||||
});
|
||||
}
|
||||
imported.queen = rowsOf('queen').length;
|
||||
@@ -346,13 +463,13 @@ export class BackupService {
|
||||
await tx.colony.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.colony = rowsOf('colony').length;
|
||||
|
||||
for (const row of rowsOf('queen')) {
|
||||
if (row.motherQueenId) {
|
||||
if (row.motherQueenId && !preexistingQueenIds.has(row.id as string)) {
|
||||
await tx.queen.update({
|
||||
where: { id: row.id as string },
|
||||
data: { motherQueenId: row.motherQueenId as string },
|
||||
@@ -369,12 +486,12 @@ export class BackupService {
|
||||
asString(row, 'movedByUserId'),
|
||||
),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
movedByUserId: resolveOptionalUserId(
|
||||
asString(row, 'movedByUserId'),
|
||||
),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.colonyApiaryHistory = rowsOf('colonyApiaryHistory').length;
|
||||
@@ -383,7 +500,7 @@ export class BackupService {
|
||||
await tx.colonyLineage.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.colonyLineage = rowsOf('colonyLineage').length;
|
||||
@@ -395,10 +512,10 @@ export class BackupService {
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.queenEvaluation = rowsOf('queenEvaluation').length;
|
||||
@@ -407,7 +524,7 @@ export class BackupService {
|
||||
await tx.queenColonyHistory.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.queenColonyHistory = rowsOf('queenColonyHistory').length;
|
||||
@@ -419,10 +536,10 @@ export class BackupService {
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.inspection = rowsOf('inspection').length;
|
||||
@@ -439,7 +556,7 @@ export class BackupService {
|
||||
asString(row, 'createdByUserId'),
|
||||
),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
assignedUserId: resolveOptionalUserId(
|
||||
asString(row, 'assignedUserId'),
|
||||
@@ -447,7 +564,7 @@ export class BackupService {
|
||||
createdByUserId: resolveOptionalUserId(
|
||||
asString(row, 'createdByUserId'),
|
||||
),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.taskTemplate = rowsOf('taskTemplate').length;
|
||||
@@ -464,7 +581,7 @@ export class BackupService {
|
||||
asString(row, 'createdByUserId'),
|
||||
),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
assignedUserId: resolveOptionalUserId(
|
||||
asString(row, 'assignedUserId'),
|
||||
@@ -472,7 +589,7 @@ export class BackupService {
|
||||
createdByUserId: resolveOptionalUserId(
|
||||
asString(row, 'createdByUserId'),
|
||||
),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.task = rowsOf('task').length;
|
||||
@@ -484,10 +601,10 @@ export class BackupService {
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.varroaMeasurement = rowsOf('varroaMeasurement').length;
|
||||
@@ -499,10 +616,10 @@ export class BackupService {
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.swarmControlCheck = rowsOf('swarmControlCheck').length;
|
||||
@@ -514,10 +631,10 @@ export class BackupService {
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.winterCheck = rowsOf('winterCheck').length;
|
||||
@@ -529,10 +646,10 @@ export class BackupService {
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.treatment = rowsOf('treatment').length;
|
||||
@@ -544,10 +661,10 @@ export class BackupService {
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.feeding = rowsOf('feeding').length;
|
||||
@@ -559,10 +676,10 @@ export class BackupService {
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.harvest = rowsOf('harvest').length;
|
||||
@@ -571,7 +688,7 @@ export class BackupService {
|
||||
await tx.harvestColony.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.harvestColony = rowsOf('harvestColony').length;
|
||||
@@ -583,10 +700,10 @@ export class BackupService {
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.extraction = rowsOf('extraction').length;
|
||||
@@ -595,7 +712,7 @@ export class BackupService {
|
||||
await tx.extractionHarvest.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.extractionHarvest = rowsOf('extractionHarvest').length;
|
||||
@@ -604,7 +721,7 @@ export class BackupService {
|
||||
await tx.storageContainer.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.storageContainer = rowsOf('storageContainer').length;
|
||||
@@ -613,7 +730,7 @@ export class BackupService {
|
||||
await tx.honeyBatch.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.honeyBatch = rowsOf('honeyBatch').length;
|
||||
@@ -625,10 +742,10 @@ export class BackupService {
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.honeyMeasurement = rowsOf('honeyMeasurement').length;
|
||||
@@ -640,14 +757,25 @@ export class BackupService {
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.honeyBatchRelocation = rowsOf('honeyBatchRelocation').length;
|
||||
|
||||
for (const row of rowsOf('honeyBatchContainerAllocation')) {
|
||||
await tx.honeyBatchContainerAllocation.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
});
|
||||
}
|
||||
imported.honeyBatchContainerAllocation = rowsOf(
|
||||
'honeyBatchContainerAllocation',
|
||||
).length;
|
||||
|
||||
for (const row of rowsOf('stockItem')) {
|
||||
await tx.stockItem.upsert({
|
||||
where: { id: row.id as string },
|
||||
@@ -676,7 +804,7 @@ export class BackupService {
|
||||
await tx.product.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.product = rowsOf('product').length;
|
||||
@@ -685,13 +813,28 @@ export class BackupService {
|
||||
await tx.labelTemplate.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.labelTemplate = rowsOf('labelTemplate').length;
|
||||
|
||||
for (const row of rowsOf('bottlingBatch')) {
|
||||
await tx.bottlingBatch.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: {
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: updateWith({
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
}) as never,
|
||||
});
|
||||
}
|
||||
imported.bottlingBatch = rowsOf('bottlingBatch').length;
|
||||
|
||||
for (const row of rowsOf('rawMaterialLot')) {
|
||||
await tx.rawMaterialLot.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: {
|
||||
...row,
|
||||
@@ -703,13 +846,62 @@ export class BackupService {
|
||||
} as never,
|
||||
});
|
||||
}
|
||||
imported.bottlingBatch = rowsOf('bottlingBatch').length;
|
||||
imported.rawMaterialLot = rowsOf('rawMaterialLot').length;
|
||||
|
||||
for (const row of rowsOf('productionBatch')) {
|
||||
await tx.productionBatch.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: {
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
});
|
||||
}
|
||||
imported.productionBatch = rowsOf('productionBatch').length;
|
||||
|
||||
for (const row of rowsOf('productionInput')) {
|
||||
await tx.productionInput.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
});
|
||||
}
|
||||
imported.productionInput = rowsOf('productionInput').length;
|
||||
|
||||
for (const row of rowsOf('shopStockRequest')) {
|
||||
await tx.shopStockRequest.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
});
|
||||
}
|
||||
imported.shopStockRequest = rowsOf('shopStockRequest').length;
|
||||
|
||||
// After every kind of batch and the shop requests it may reference.
|
||||
for (const row of rowsOf('batchMovement')) {
|
||||
await tx.batchMovement.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: {
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
update: {
|
||||
...row,
|
||||
userId: resolveOptionalUserId(asString(row, 'userId')),
|
||||
} as never,
|
||||
});
|
||||
}
|
||||
imported.batchMovement = rowsOf('batchMovement').length;
|
||||
|
||||
for (const row of rowsOf('equipment')) {
|
||||
await tx.equipment.upsert({
|
||||
where: { id: row.id as string },
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
update: updateWith(row) as never,
|
||||
});
|
||||
}
|
||||
imported.equipment = rowsOf('equipment').length;
|
||||
@@ -748,6 +940,23 @@ export class BackupService {
|
||||
}
|
||||
imported.automationRule = rowsOf('automationRule').length;
|
||||
|
||||
// Keyed by triggerType+scope, its primary key (the table has no id).
|
||||
// Restoring the counters keeps the next lot number from colliding
|
||||
// with a restored one.
|
||||
for (const row of rowsOf('automationSequence')) {
|
||||
await tx.automationSequence.upsert({
|
||||
where: {
|
||||
triggerType_scope: {
|
||||
triggerType: row.triggerType as never,
|
||||
scope: row.scope as string,
|
||||
},
|
||||
},
|
||||
create: row as never,
|
||||
update: row as never,
|
||||
});
|
||||
}
|
||||
imported.automationSequence = rowsOf('automationSequence').length;
|
||||
|
||||
for (const row of rowsOf('siteSettings')) {
|
||||
const { id, ...withoutId } = row;
|
||||
await tx.siteSettings.upsert({
|
||||
@@ -758,6 +967,48 @@ export class BackupService {
|
||||
}
|
||||
imported.siteSettings = rowsOf('siteSettings').length;
|
||||
|
||||
for (const row of rowsOf('backupSettings')) {
|
||||
const { id, ...withoutId } = row;
|
||||
await tx.backupSettings.upsert({
|
||||
where: { id: (id as string) ?? 'singleton' },
|
||||
create: row as never,
|
||||
update: withoutId as never,
|
||||
});
|
||||
}
|
||||
imported.backupSettings = rowsOf('backupSettings').length;
|
||||
|
||||
for (const row of rowsOf('calendarSyncSettings')) {
|
||||
const { id, ...withoutId } = row;
|
||||
await tx.calendarSyncSettings.upsert({
|
||||
where: { id: (id as string) ?? 'singleton' },
|
||||
create: row as never,
|
||||
update: withoutId as never,
|
||||
});
|
||||
}
|
||||
imported.calendarSyncSettings = rowsOf('calendarSyncSettings').length;
|
||||
|
||||
// Keyed by sourceType+sourceId, like CalendarSyncService.enqueue -
|
||||
// the target database may already track the same source under
|
||||
// another id. graphEventId comes along, so a restored entry keeps
|
||||
// updating its existing M365 event instead of creating a duplicate.
|
||||
for (const row of rowsOf('calendarSyncOutboxEntry')) {
|
||||
const withoutId: Row = { ...row };
|
||||
delete withoutId.id;
|
||||
await tx.calendarSyncOutboxEntry.upsert({
|
||||
where: {
|
||||
sourceType_sourceId: {
|
||||
sourceType: row.sourceType as never,
|
||||
sourceId: row.sourceId as string,
|
||||
},
|
||||
},
|
||||
create: row as never,
|
||||
update: withoutId as never,
|
||||
});
|
||||
}
|
||||
imported.calendarSyncOutboxEntry = rowsOf(
|
||||
'calendarSyncOutboxEntry',
|
||||
).length;
|
||||
|
||||
// Writes the file to disk as part of this loop, before the
|
||||
// surrounding transaction is known to commit - if a later entity in
|
||||
// this same import fails and the transaction rolls back, a file
|
||||
@@ -799,6 +1050,13 @@ export class BackupService {
|
||||
`${crypto.randomUUID()}${extension}`,
|
||||
);
|
||||
await fs.writeFile(storagePath, buffer);
|
||||
const previous = await tx.document.findUnique({
|
||||
where: { id: row.id as string },
|
||||
select: { storagePath: true },
|
||||
});
|
||||
if (previous) {
|
||||
replacedDocumentFiles.push(previous.storagePath);
|
||||
}
|
||||
const withoutContent: Row = { ...row };
|
||||
delete withoutContent.contentBase64;
|
||||
const data = {
|
||||
@@ -859,6 +1117,39 @@ export class BackupService {
|
||||
afterData: { imported, skipped, sealed: trusted },
|
||||
});
|
||||
|
||||
return { imported, skipped };
|
||||
await this.removeReplacedDocumentFiles(replacedDocumentFiles);
|
||||
|
||||
return { imported, skipped, sealed: trusted };
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the files restored document rows no longer point at - only now
|
||||
* that the import has committed, only inside the documents root and only
|
||||
* while no row still uses them. Best effort: a file left behind wastes
|
||||
* space, it never breaks a document.
|
||||
*/
|
||||
private async removeReplacedDocumentFiles(
|
||||
storagePaths: string[],
|
||||
): Promise<void> {
|
||||
const storageRoot = path.resolve(documentsStorageRoot());
|
||||
for (const storagePath of storagePaths) {
|
||||
const resolved = path.resolve(storagePath);
|
||||
if (!resolved.startsWith(storageRoot + path.sep)) {
|
||||
this.logger.warn(
|
||||
`Ersetzte Dokumentdatei liegt außerhalb von ${storageRoot} und bleibt erhalten: ${resolved}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if ((await this.prisma.document.count({ where: { storagePath } })) > 0) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await fs.unlink(resolved);
|
||||
} catch (error: unknown) {
|
||||
this.logger.warn(
|
||||
`Ersetzte Dokumentdatei ${resolved} konnte nicht gelöscht werden: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user