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:
1 parent
390ec32f50
commit
2f223b6941
6 files changed
+896
No files matched your search
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user