feat: Backend-Modul fuer vollstaendiges Daten-Backup (Export/Import)

Neues Modul src/backup/ fuer Backups und Datenuebertragung zwischen
Deployments: GET /backup/export liefert ein JSON-Bundle mit allen
fachlichen Daten (Bienenstaende bis Etikettenvorlagen, ~30 Tabellen),
POST /backup/import spielt so ein Bundle wieder ein.

Bewusst NICHT enthalten: User/Rollen/Rechte (werden ueber die
Benutzerverwaltung gepflegt, Permission-Zeilen werden ohnehin bei
jedem Start aus dem Code neu synchronisiert), Audit-Log (unveraenderliche
Historie), Notifications (fluechtig), Dokumente (nur die DB-Zeile waere
gesichert, nicht die tatsaechliche Datei auf der Festplatte).

Import laeuft in einer Transaktion, Tabellen in Abhaengigkeitsreihenfolge
(z.B. Bienenstand vor Volk, Volk vor Durchsicht). Selbstreferenzen
(Koenigin -> Mutterkoenigin) werden in zwei Durchgaengen aufgeloest,
damit die Reihenfolge im Export keine Rolle spielt. userId-Felder, die
im Zielsystem nicht existieren (z.B. Uebertragung auf ein anderes
Deployment), werden auf den ausfuehrenden Admin umgebogen statt die
Fremdschluessel-Pruefung scheitern zu lassen. AutomationRule wird ueber
triggerType statt id abgeglichen, da diese Zeilen code-fest sind.

Neue Berechtigungen backup:export/backup:import. 8 neue Unit-Tests
fuer die Sonderfaelle (Selbstreferenz, userId-Remapping,
AutomationRule-Abgleich, Audit-Eintrag).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-03 10:22:15 +02:00
1 parent 390ec32f50
commit 2f223b6941
6 files changed
+896

No files matched your search

+2
View File
@@ -41,6 +41,7 @@ import { SwarmControlModule } from './swarm-control/swarm-control.module';
import { WeatherModule } from './weather/weather.module';
import { ProfileModule } from './profile/profile.module';
import { SiteSettingsModule } from './site-settings/site-settings.module';
import { BackupModule } from './backup/backup.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { PermissionsGuard } from './auth/guards/permissions.guard';
import { PermissionsModule } from './auth/permissions/permissions.module';
@@ -89,6 +90,7 @@ import { PermissionsModule } from './auth/permissions/permissions.module';
WeatherModule,
ProfileModule,
SiteSettingsModule,
BackupModule,
],
controllers: [],
providers: [
@@ -285,6 +285,15 @@ export const PERMISSION_CATALOG = [
],
3100,
),
...group(
'Backup',
'backup',
[
['export', 'Exportieren'],
['import', 'Importieren'],
],
3200,
),
] as const satisfies readonly PermissionCatalogEntry[];
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
@@ -0,0 +1,73 @@
import {
BadRequestException,
Controller,
Get,
Post,
Res,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiConsumes, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { BackupBundle, BackupService } from './backup.service';
// A full backup bundle can be much larger than the 5mb CSV import cap
// elsewhere (dozens of tables, potentially years of records, plus embedded
// label-template images) - 50mb comfortably covers a real dataset without
// raising the app-wide JSON body limit for every other route.
const MAX_BACKUP_FILE_SIZE_BYTES = 50 * 1024 * 1024;
@ApiTags('backup')
@ApiBearerAuth()
@Controller('backup')
export class BackupController {
constructor(private readonly backupService: BackupService) {}
@RequirePermission('backup:export')
@Get('export')
async export(@Res() res: Response): Promise<void> {
const bundle = await this.backupService.exportAll();
const filename = `apicore-backup-${new Date().toISOString().slice(0, 10)}.json`;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(JSON.stringify(bundle, null, 2));
}
@RequirePermission('backup:import')
@Post('import')
@ApiConsumes('multipart/form-data')
@UseInterceptors(
FileInterceptor('file', {
limits: { fileSize: MAX_BACKUP_FILE_SIZE_BYTES },
fileFilter: (_req, file, callback) => {
if (!file.originalname.toLowerCase().endsWith('.json')) {
callback(
new BadRequestException('Nur JSON-Dateien werden unterstützt'),
false,
);
return;
}
callback(null, true);
},
}),
)
async import(
@UploadedFile() file: Express.Multer.File | undefined,
@CurrentUser() actor: AuthenticatedUser,
) {
if (!file) {
throw new BadRequestException('No file uploaded');
}
let bundle: BackupBundle;
try {
bundle = JSON.parse(file.buffer.toString('utf-8')) as BackupBundle;
} catch {
throw new BadRequestException('Datei ist kein gültiges JSON');
}
return this.backupService.importAll(bundle, actor.id);
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { BackupController } from './backup.controller';
import { BackupService } from './backup.service';
@Module({
controllers: [BackupController],
providers: [BackupService],
})
export class BackupModule {}
@@ -0,0 +1,243 @@
import { BadRequestException } from '@nestjs/common';
import { BackupService } from './backup.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
const ENTITY_KEYS = [
'apiary',
'queen',
'colony',
'colonyApiaryHistory',
'colonyLineage',
'queenEvaluation',
'queenColonyHistory',
'inspection',
'taskTemplate',
'task',
'varroaMeasurement',
'swarmControlCheck',
'treatment',
'feeding',
'harvest',
'harvestColony',
'extraction',
'extractionHarvest',
'storageContainer',
'honeyBatch',
'honeyMeasurement',
'honeyBatchRelocation',
'stockItem',
'stockMovement',
'product',
'labelTemplate',
'bottlingBatch',
'equipment',
'webhookSubscription',
'automationRule',
'siteSettings',
] as const;
describe('BackupService', () => {
let service: BackupService;
let prisma: Record<string, { findMany: jest.Mock }> & {
user: { findMany: jest.Mock };
$transaction: jest.Mock;
};
let tx: Record<
string,
{ upsert: jest.Mock; update?: jest.Mock }
>;
let auditLog: { record: jest.Mock };
beforeEach(() => {
tx = {};
for (const key of ENTITY_KEYS) {
tx[key] = { upsert: jest.fn().mockResolvedValue({}) };
}
tx.queen.update = jest.fn().mockResolvedValue({});
prisma = {
user: { findMany: jest.fn().mockResolvedValue([{ id: 'user-1' }]) },
$transaction: jest.fn(
(callback: (txArg: typeof tx) => unknown) => callback(tx),
),
} as never;
for (const key of ENTITY_KEYS) {
prisma[key] = { findMany: jest.fn().mockResolvedValue([]) };
}
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
service = new BackupService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
);
});
describe('exportAll', () => {
it('returns a versioned bundle with every entity key', async () => {
const bundle = await service.exportAll();
expect(bundle.version).toBe(1);
expect(typeof bundle.exportedAt).toBe('string');
for (const key of ENTITY_KEYS) {
expect(bundle.data).toHaveProperty(key);
}
});
});
describe('importAll', () => {
it('rejects a bundle with no data object', async () => {
await expect(
service.importAll({ version: 1 } as never, 'admin-1'),
).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it('links a self-referencing motherQueenId only after every queen exists', async () => {
await service.importAll(
{
version: 1,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
queen: [
{ id: 'queen-daughter', motherQueenId: 'queen-mother' },
{ id: 'queen-mother', motherQueenId: null },
],
},
},
'admin-1',
);
// First pass: both queens upserted with motherQueenId stripped, so a
// daughter appearing before her mother in the array never violates
// the foreign key.
expect(tx.queen.upsert).toHaveBeenCalledTimes(2);
for (const call of tx.queen.upsert.mock.calls as {
create: { motherQueenId: string | null };
}[][]) {
expect(call[0].create.motherQueenId).toBeNull();
}
// Second pass: only the daughter's real motherQueenId is patched back.
expect(tx.queen.update).toHaveBeenCalledTimes(1);
expect(tx.queen.update).toHaveBeenCalledWith({
where: { id: 'queen-daughter' },
data: { motherQueenId: 'queen-mother' },
});
});
it('remaps a required userId that does not exist in the target database to the importing admin', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'known-user' }]);
await service.importAll(
{
version: 1,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
inspection: [
{ id: 'inspection-1', colonyId: 'colony-1', userId: 'ghost-user' },
],
},
},
'admin-1',
);
const [[call]] = tx.inspection.upsert.mock.calls as [
[{ create: { userId: string } }],
];
expect(call.create.userId).toBe('admin-1');
});
it('keeps a required userId that does exist in the target database', async () => {
prisma.user.findMany.mockResolvedValue([{ id: 'known-user' }]);
await service.importAll(
{
version: 1,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
inspection: [
{ id: 'inspection-1', colonyId: 'colony-1', userId: 'known-user' },
],
},
},
'admin-1',
);
const [[call]] = tx.inspection.upsert.mock.calls as [
[{ create: { userId: string } }],
];
expect(call.create.userId).toBe('known-user');
});
it('leaves an optional userId as null rather than substituting the admin', async () => {
await service.importAll(
{
version: 1,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
stockMovement: [
{ id: 'movement-1', stockItemId: 'stock-1', userId: null },
],
},
},
'admin-1',
);
const [[call]] = tx.stockMovement.upsert.mock.calls as [
[{ create: { userId: string | null } }],
];
expect(call.create.userId).toBeNull();
});
it('upserts automationRule by triggerType, not id, and drops the source id', async () => {
await service.importAll(
{
version: 1,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
automationRule: [
{
id: 'source-db-id',
triggerType: 'VARROA_THRESHOLD',
active: true,
},
],
},
},
'admin-1',
);
const [[call]] = tx.automationRule.upsert.mock.calls as [
[
{
where: { triggerType: string };
create: Record<string, unknown>;
},
],
];
expect(call.where).toEqual({ triggerType: 'VARROA_THRESHOLD' });
expect(call.create).not.toHaveProperty('id');
});
it('records an audit log entry with the per-entity import counts', async () => {
await service.importAll(
{
version: 1,
exportedAt: '2026-01-01T00:00:00.000Z',
data: { apiary: [{ id: 'apiary-1', name: 'Stand Wald' }] },
},
'admin-1',
);
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'Backup',
action: 'CREATE',
userId: 'admin-1',
afterData: expect.objectContaining({ apiary: 1 }),
}),
);
});
});
});
@@ -0,0 +1,560 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
const BACKUP_VERSION = 1;
const IMPORT_TRANSACTION_TIMEOUT_MS = 5 * 60 * 1000;
export interface BackupBundle {
version: number;
exportedAt: string;
data: Record<string, unknown[]>;
}
export interface BackupImportSummary {
imported: Record<string, number>;
}
// Excluded on purpose:
// - User/Role/Permission/RolePermission/RefreshToken: access control is
// managed live via the Benutzer & Rollen page, not round-tripped through a
// JSON file - Permission rows are also code-derived (re-synced from the
// catalog on every boot) and would just conflict with that.
// - AuditLog: an immutable historical record, not data to restore.
// - Notification: ephemeral, not worth backing up.
// - Document: only the DB row (filename/path/size) would be backed up, not
// the actual uploaded file bytes on disk - a restored row pointing at a
// file that was never copied is worse than no row at all. File storage
// needs its own (volume-level) backup strategy.
//
// Every model below keeps a `userId`-shaped column pointing at the (excluded)
// User table. On import, an id that doesn't exist in the target database is
// replaced with the id of the admin performing the import - see
// resolveUserId/resolveOptionalUserId.
type Row = Record<string, unknown>;
function asString(row: Row, key: string): string | null {
const value = row[key];
return typeof value === 'string' ? value : null;
}
@Injectable()
export class BackupService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
) {}
async exportAll(): Promise<BackupBundle> {
const data: Record<string, unknown[]> = {};
data.apiary = await this.prisma.apiary.findMany();
data.queen = await this.prisma.queen.findMany();
data.colony = await this.prisma.colony.findMany();
data.colonyApiaryHistory = await this.prisma.colonyApiaryHistory.findMany();
data.colonyLineage = await this.prisma.colonyLineage.findMany();
data.queenEvaluation = await this.prisma.queenEvaluation.findMany();
data.queenColonyHistory = await this.prisma.queenColonyHistory.findMany();
data.inspection = await this.prisma.inspection.findMany();
data.taskTemplate = await this.prisma.taskTemplate.findMany();
data.task = await this.prisma.task.findMany();
data.varroaMeasurement = await this.prisma.varroaMeasurement.findMany();
data.swarmControlCheck = await this.prisma.swarmControlCheck.findMany();
data.treatment = await this.prisma.treatment.findMany();
data.feeding = await this.prisma.feeding.findMany();
data.harvest = await this.prisma.harvest.findMany();
data.harvestColony = await this.prisma.harvestColony.findMany();
data.extraction = await this.prisma.extraction.findMany();
data.extractionHarvest = await this.prisma.extractionHarvest.findMany();
data.storageContainer = await this.prisma.storageContainer.findMany();
data.honeyBatch = await this.prisma.honeyBatch.findMany();
data.honeyMeasurement = await this.prisma.honeyMeasurement.findMany();
data.honeyBatchRelocation =
await this.prisma.honeyBatchRelocation.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.equipment = await this.prisma.equipment.findMany();
data.webhookSubscription = await this.prisma.webhookSubscription.findMany();
data.automationRule = await this.prisma.automationRule.findMany();
data.siteSettings = await this.prisma.siteSettings.findMany();
return {
version: BACKUP_VERSION,
exportedAt: new Date().toISOString(),
data,
};
}
async importAll(
bundle: BackupBundle,
actingUserId: string,
): Promise<BackupImportSummary> {
if (
typeof bundle !== 'object' ||
bundle === null ||
typeof bundle.data !== 'object' ||
bundle.data === null
) {
throw new BadRequestException('Invalid backup file');
}
const knownUserIds = new Set(
(await this.prisma.user.findMany({ select: { id: true } })).map(
(user) => user.id,
),
);
const resolveUserId = (rawUserId: string | null): string =>
rawUserId !== null && knownUserIds.has(rawUserId)
? rawUserId
: actingUserId;
const resolveOptionalUserId = (rawUserId: string | null): string | null =>
rawUserId === null ? null : resolveUserId(rawUserId);
const rowsOf = (key: string): Row[] => {
const value = bundle.data[key];
return Array.isArray(value) ? (value as Row[]) : [];
};
const imported: Record<string, number> = {};
await this.prisma.$transaction(
async (tx) => {
for (const row of rowsOf('apiary')) {
await tx.apiary.upsert({
where: { id: row.id as string },
create: row as never,
update: row as never,
});
}
imported.apiary = rowsOf('apiary').length;
// 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.
for (const row of rowsOf('queen')) {
const withoutMother: Row = { ...row, motherQueenId: null };
await tx.queen.upsert({
where: { id: row.id as string },
create: withoutMother as never,
update: withoutMother as never,
});
}
imported.queen = rowsOf('queen').length;
for (const row of rowsOf('colony')) {
await tx.colony.upsert({
where: { id: row.id as string },
create: row as never,
update: row as never,
});
}
imported.colony = rowsOf('colony').length;
for (const row of rowsOf('queen')) {
if (row.motherQueenId) {
await tx.queen.update({
where: { id: row.id as string },
data: { motherQueenId: row.motherQueenId as string },
});
}
}
for (const row of rowsOf('colonyApiaryHistory')) {
await tx.colonyApiaryHistory.upsert({
where: { id: row.id as string },
create: {
...row,
movedByUserId: resolveOptionalUserId(
asString(row, 'movedByUserId'),
),
} as never,
update: {
...row,
movedByUserId: resolveOptionalUserId(
asString(row, 'movedByUserId'),
),
} as never,
});
}
imported.colonyApiaryHistory = rowsOf('colonyApiaryHistory').length;
for (const row of rowsOf('colonyLineage')) {
await tx.colonyLineage.upsert({
where: { id: row.id as string },
create: row as never,
update: row as never,
});
}
imported.colonyLineage = rowsOf('colonyLineage').length;
for (const row of rowsOf('queenEvaluation')) {
await tx.queenEvaluation.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.queenEvaluation = rowsOf('queenEvaluation').length;
for (const row of rowsOf('queenColonyHistory')) {
await tx.queenColonyHistory.upsert({
where: { id: row.id as string },
create: row as never,
update: row as never,
});
}
imported.queenColonyHistory = rowsOf('queenColonyHistory').length;
for (const row of rowsOf('inspection')) {
await tx.inspection.upsert({
where: { id: row.id as string },
create: {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
update: {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
});
}
imported.inspection = rowsOf('inspection').length;
for (const row of rowsOf('taskTemplate')) {
await tx.taskTemplate.upsert({
where: { id: row.id as string },
create: {
...row,
assignedUserId: resolveOptionalUserId(
asString(row, 'assignedUserId'),
),
createdByUserId: resolveOptionalUserId(
asString(row, 'createdByUserId'),
),
} as never,
update: {
...row,
assignedUserId: resolveOptionalUserId(
asString(row, 'assignedUserId'),
),
createdByUserId: resolveOptionalUserId(
asString(row, 'createdByUserId'),
),
} as never,
});
}
imported.taskTemplate = rowsOf('taskTemplate').length;
for (const row of rowsOf('task')) {
await tx.task.upsert({
where: { id: row.id as string },
create: {
...row,
assignedUserId: resolveOptionalUserId(
asString(row, 'assignedUserId'),
),
createdByUserId: resolveOptionalUserId(
asString(row, 'createdByUserId'),
),
} as never,
update: {
...row,
assignedUserId: resolveOptionalUserId(
asString(row, 'assignedUserId'),
),
createdByUserId: resolveOptionalUserId(
asString(row, 'createdByUserId'),
),
} as never,
});
}
imported.task = rowsOf('task').length;
for (const row of rowsOf('varroaMeasurement')) {
await tx.varroaMeasurement.upsert({
where: { id: row.id as string },
create: {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
update: {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
});
}
imported.varroaMeasurement = rowsOf('varroaMeasurement').length;
for (const row of rowsOf('swarmControlCheck')) {
await tx.swarmControlCheck.upsert({
where: { id: row.id as string },
create: {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
update: {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
});
}
imported.swarmControlCheck = rowsOf('swarmControlCheck').length;
for (const row of rowsOf('treatment')) {
await tx.treatment.upsert({
where: { id: row.id as string },
create: {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
update: {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
});
}
imported.treatment = rowsOf('treatment').length;
for (const row of rowsOf('feeding')) {
await tx.feeding.upsert({
where: { id: row.id as string },
create: {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
update: {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
});
}
imported.feeding = rowsOf('feeding').length;
for (const row of rowsOf('harvest')) {
await tx.harvest.upsert({
where: { id: row.id as string },
create: {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
update: {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
});
}
imported.harvest = rowsOf('harvest').length;
for (const row of rowsOf('harvestColony')) {
await tx.harvestColony.upsert({
where: { id: row.id as string },
create: row as never,
update: row as never,
});
}
imported.harvestColony = rowsOf('harvestColony').length;
for (const row of rowsOf('extraction')) {
await tx.extraction.upsert({
where: { id: row.id as string },
create: {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
update: {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
});
}
imported.extraction = rowsOf('extraction').length;
for (const row of rowsOf('extractionHarvest')) {
await tx.extractionHarvest.upsert({
where: { id: row.id as string },
create: row as never,
update: row as never,
});
}
imported.extractionHarvest = rowsOf('extractionHarvest').length;
for (const row of rowsOf('storageContainer')) {
await tx.storageContainer.upsert({
where: { id: row.id as string },
create: row as never,
update: row as never,
});
}
imported.storageContainer = rowsOf('storageContainer').length;
for (const row of rowsOf('honeyBatch')) {
await tx.honeyBatch.upsert({
where: { id: row.id as string },
create: row as never,
update: row as never,
});
}
imported.honeyBatch = rowsOf('honeyBatch').length;
for (const row of rowsOf('honeyMeasurement')) {
await tx.honeyMeasurement.upsert({
where: { id: row.id as string },
create: {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
update: {
...row,
userId: resolveUserId(asString(row, 'userId')),
} as never,
});
}
imported.honeyMeasurement = rowsOf('honeyMeasurement').length;
for (const row of rowsOf('honeyBatchRelocation')) {
await tx.honeyBatchRelocation.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.honeyBatchRelocation = rowsOf('honeyBatchRelocation').length;
for (const row of rowsOf('stockItem')) {
await tx.stockItem.upsert({
where: { id: row.id as string },
create: row as never,
update: row as never,
});
}
imported.stockItem = rowsOf('stockItem').length;
for (const row of rowsOf('stockMovement')) {
await tx.stockMovement.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.stockMovement = rowsOf('stockMovement').length;
for (const row of rowsOf('product')) {
await tx.product.upsert({
where: { id: row.id as string },
create: row as never,
update: row as never,
});
}
imported.product = rowsOf('product').length;
for (const row of rowsOf('labelTemplate')) {
await tx.labelTemplate.upsert({
where: { id: row.id as string },
create: row as never,
update: 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: {
...row,
userId: resolveOptionalUserId(asString(row, 'userId')),
} as never,
});
}
imported.bottlingBatch = rowsOf('bottlingBatch').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,
});
}
imported.equipment = rowsOf('equipment').length;
for (const row of rowsOf('webhookSubscription')) {
await tx.webhookSubscription.upsert({
where: { id: row.id as string },
create: {
...row,
createdByUserId: resolveOptionalUserId(
asString(row, 'createdByUserId'),
),
} as never,
update: {
...row,
createdByUserId: resolveOptionalUserId(
asString(row, 'createdByUserId'),
),
} as never,
});
}
imported.webhookSubscription = rowsOf('webhookSubscription').length;
// Keyed by triggerType (its real unique key), not id: these rows are
// fixed, code-known trigger definitions rather than freely created
// data, and the target database's own row for the same triggerType
// may already exist under a different id.
for (const row of rowsOf('automationRule')) {
const withoutId: Row = { ...row };
delete withoutId.id;
await tx.automationRule.upsert({
where: { triggerType: row.triggerType as never },
create: withoutId as never,
update: withoutId as never,
});
}
imported.automationRule = rowsOf('automationRule').length;
for (const row of rowsOf('siteSettings')) {
const { id, ...withoutId } = row;
await tx.siteSettings.upsert({
where: { id: (id as string) ?? 'singleton' },
create: row as never,
update: withoutId as never,
});
}
imported.siteSettings = rowsOf('siteSettings').length;
},
{ timeout: IMPORT_TRANSACTION_TIMEOUT_MS },
);
await this.auditLog.record({
entityType: 'Backup',
entityId: 'import',
action: 'CREATE',
userId: actingUserId,
afterData: imported,
});
return { imported };
}
}