fix: Paket 0 – data integrity, security hardening and field usability
Backend Image / build-and-push (push) Successful in 3m15s
Frontend Image / build-and-push (push) Successful in 6m2s

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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-12 16:38:55 +02:00
1 parent f5b1964223
commit 10cbe9bf2d
310 files changed
+12177 -3746

No files matched your search

+361 -70
View File
@@ -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)}`,
);
}
}
}
}