1187 lines
37 KiB
TypeScript
1187 lines
37 KiB
TypeScript
/**
|
|
* Datenbank Backup Service
|
|
*
|
|
* Ermöglicht Backup und Restore der Datenbank und Uploads über die Web-Oberfläche.
|
|
*/
|
|
|
|
import { PrismaClient } from '@prisma/client';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import archiver from 'archiver';
|
|
import AdmZip from 'adm-zip';
|
|
import bcrypt from 'bcryptjs';
|
|
|
|
const prisma = new PrismaClient();
|
|
|
|
// Verzeichnisse
|
|
const BACKUPS_DIR = path.join(__dirname, '../../prisma/backups');
|
|
const UPLOADS_DIR = path.join(process.cwd(), 'uploads');
|
|
|
|
// Stelle sicher, dass das Backup-Verzeichnis existiert
|
|
if (!fs.existsSync(BACKUPS_DIR)) {
|
|
fs.mkdirSync(BACKUPS_DIR, { recursive: true });
|
|
}
|
|
|
|
export interface BackupInfo {
|
|
name: string;
|
|
timestamp: string;
|
|
totalRecords: number;
|
|
tables: { table: string; count: number }[];
|
|
sizeBytes: number;
|
|
hasUploads: boolean;
|
|
uploadSizeBytes: number;
|
|
}
|
|
|
|
export interface BackupResult {
|
|
success: boolean;
|
|
backupName?: string;
|
|
error?: string;
|
|
}
|
|
|
|
export interface RestoreResult {
|
|
success: boolean;
|
|
restoredRecords?: number;
|
|
restoredFiles?: number;
|
|
error?: string;
|
|
}
|
|
|
|
// Hilfsfunktion: Datum-Strings zu Date-Objekten konvertieren
|
|
function convertDates(obj: any): any {
|
|
if (obj === null || obj === undefined) return obj;
|
|
if (typeof obj === 'string') {
|
|
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(obj)) {
|
|
return new Date(obj);
|
|
}
|
|
return obj;
|
|
}
|
|
if (Array.isArray(obj)) {
|
|
return obj.map(convertDates);
|
|
}
|
|
if (typeof obj === 'object') {
|
|
const result: any = {};
|
|
for (const [key, value] of Object.entries(obj)) {
|
|
result[key] = convertDates(value);
|
|
}
|
|
return result;
|
|
}
|
|
return obj;
|
|
}
|
|
|
|
// Hilfsfunktion: JSON-Datei lesen
|
|
function readJsonFile<T>(filePath: string): T[] {
|
|
if (!fs.existsSync(filePath)) {
|
|
return [];
|
|
}
|
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
return JSON.parse(content);
|
|
}
|
|
|
|
// Hilfsfunktion: Ordnergröße berechnen (rekursiv)
|
|
function getDirectorySize(dirPath: string): number {
|
|
if (!fs.existsSync(dirPath)) return 0;
|
|
|
|
let size = 0;
|
|
const items = fs.readdirSync(dirPath);
|
|
|
|
for (const item of items) {
|
|
const itemPath = path.join(dirPath, item);
|
|
const stats = fs.statSync(itemPath);
|
|
|
|
if (stats.isFile()) {
|
|
size += stats.size;
|
|
} else if (stats.isDirectory()) {
|
|
size += getDirectorySize(itemPath);
|
|
}
|
|
}
|
|
|
|
return size;
|
|
}
|
|
|
|
// Hilfsfunktion: Ordner rekursiv kopieren
|
|
function copyDirectory(src: string, dest: string): number {
|
|
if (!fs.existsSync(src)) return 0;
|
|
|
|
let fileCount = 0;
|
|
fs.mkdirSync(dest, { recursive: true });
|
|
|
|
const items = fs.readdirSync(src);
|
|
for (const item of items) {
|
|
const srcPath = path.join(src, item);
|
|
const destPath = path.join(dest, item);
|
|
const stats = fs.statSync(srcPath);
|
|
|
|
if (stats.isDirectory()) {
|
|
fileCount += copyDirectory(srcPath, destPath);
|
|
} else {
|
|
fs.copyFileSync(srcPath, destPath);
|
|
fileCount++;
|
|
}
|
|
}
|
|
|
|
return fileCount;
|
|
}
|
|
|
|
// Hilfsfunktion: Ordner rekursiv löschen
|
|
function deleteDirectory(dirPath: string): void {
|
|
if (!fs.existsSync(dirPath)) return;
|
|
|
|
const items = fs.readdirSync(dirPath);
|
|
for (const item of items) {
|
|
const itemPath = path.join(dirPath, item);
|
|
const stats = fs.statSync(itemPath);
|
|
|
|
if (stats.isDirectory()) {
|
|
deleteDirectory(itemPath);
|
|
} else {
|
|
fs.unlinkSync(itemPath);
|
|
}
|
|
}
|
|
|
|
fs.rmdirSync(dirPath);
|
|
}
|
|
|
|
/**
|
|
* Liste aller verfügbaren Backups
|
|
*/
|
|
export async function listBackups(): Promise<BackupInfo[]> {
|
|
const backups: BackupInfo[] = [];
|
|
|
|
if (!fs.existsSync(BACKUPS_DIR)) {
|
|
return backups;
|
|
}
|
|
|
|
const dirs = fs.readdirSync(BACKUPS_DIR)
|
|
.filter(f => {
|
|
const fullPath = path.join(BACKUPS_DIR, f);
|
|
return fs.statSync(fullPath).isDirectory() && f !== '.gitkeep' && !f.startsWith('.');
|
|
})
|
|
.sort()
|
|
.reverse();
|
|
|
|
for (const dir of dirs) {
|
|
const backupDir = path.join(BACKUPS_DIR, dir);
|
|
const infoPath = path.join(backupDir, '_backup-info.json');
|
|
const uploadsPath = path.join(backupDir, 'uploads');
|
|
|
|
if (fs.existsSync(infoPath)) {
|
|
try {
|
|
const info = JSON.parse(fs.readFileSync(infoPath, 'utf-8'));
|
|
const hasUploads = fs.existsSync(uploadsPath);
|
|
const uploadSize = hasUploads ? getDirectorySize(uploadsPath) : 0;
|
|
|
|
backups.push({
|
|
name: dir,
|
|
timestamp: info.timestamp,
|
|
totalRecords: info.totalRecords,
|
|
tables: info.tables,
|
|
sizeBytes: getDirectorySize(backupDir),
|
|
hasUploads,
|
|
uploadSizeBytes: uploadSize,
|
|
});
|
|
} catch {
|
|
backups.push({
|
|
name: dir,
|
|
timestamp: dir,
|
|
totalRecords: 0,
|
|
tables: [],
|
|
sizeBytes: getDirectorySize(backupDir),
|
|
hasUploads: fs.existsSync(uploadsPath),
|
|
uploadSizeBytes: 0,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return backups;
|
|
}
|
|
|
|
/**
|
|
* Neues Backup erstellen (inkl. Uploads)
|
|
*/
|
|
export async function createBackup(): Promise<BackupResult> {
|
|
try {
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
const backupDir = path.join(BACKUPS_DIR, timestamp);
|
|
|
|
fs.mkdirSync(backupDir, { recursive: true });
|
|
|
|
// Tabellen in Abhängigkeitsreihenfolge
|
|
const tables = [
|
|
{ name: 'Permission', query: () => prisma.permission.findMany() },
|
|
{ name: 'Role', query: () => prisma.role.findMany() },
|
|
{ name: 'SalesPlatform', query: () => prisma.salesPlatform.findMany() },
|
|
{ name: 'ContractCategory', query: () => prisma.contractCategory.findMany() },
|
|
{ name: 'CancellationPeriod', query: () => prisma.cancellationPeriod.findMany() },
|
|
{ name: 'ContractDuration', query: () => prisma.contractDuration.findMany() },
|
|
{ name: 'AppSetting', query: () => prisma.appSetting.findMany() },
|
|
{ name: 'EmailProviderConfig', query: () => prisma.emailProviderConfig.findMany() },
|
|
{ name: 'Provider', query: () => prisma.provider.findMany() },
|
|
{ name: 'RolePermission', query: () => prisma.rolePermission.findMany() },
|
|
{ name: 'User', query: () => prisma.user.findMany() },
|
|
{ name: 'Customer', query: () => prisma.customer.findMany() },
|
|
{ name: 'Tariff', query: () => prisma.tariff.findMany() },
|
|
{ name: 'UserRole', query: () => prisma.userRole.findMany() },
|
|
{ name: 'CustomerRepresentative', query: () => prisma.customerRepresentative.findMany() },
|
|
{ name: 'StressfreiEmail', query: () => prisma.stressfreiEmail.findMany() },
|
|
{ name: 'Contract', query: () => prisma.contract.findMany() },
|
|
{ name: 'Meter', query: () => prisma.meter.findMany() },
|
|
{ name: 'CachedEmail', query: () => prisma.cachedEmail.findMany() },
|
|
{ name: 'ContractTask', query: () => prisma.contractTask.findMany() },
|
|
{ name: 'MeterReading', query: () => prisma.meterReading.findMany() },
|
|
{ name: 'ContractTaskSubtask', query: () => prisma.contractTaskSubtask.findMany() },
|
|
{ name: 'EnergyContractDetails', query: () => prisma.energyContractDetails.findMany() },
|
|
{ name: 'InternetContractDetails', query: () => prisma.internetContractDetails.findMany() },
|
|
{ name: 'MobileContractDetails', query: () => prisma.mobileContractDetails.findMany() },
|
|
{ name: 'TvContractDetails', query: () => prisma.tvContractDetails.findMany() },
|
|
{ name: 'CarInsuranceDetails', query: () => prisma.carInsuranceDetails.findMany() },
|
|
{ name: 'PhoneNumber', query: () => prisma.phoneNumber.findMany() },
|
|
{ name: 'SimCard', query: () => prisma.simCard.findMany() },
|
|
{ name: 'Address', query: () => prisma.address.findMany() },
|
|
{ name: 'BankCard', query: () => prisma.bankCard.findMany() },
|
|
{ name: 'IdentityDocument', query: () => prisma.identityDocument.findMany() },
|
|
];
|
|
|
|
let totalRecords = 0;
|
|
const stats: { table: string; count: number }[] = [];
|
|
|
|
for (const table of tables) {
|
|
try {
|
|
const data = await table.query();
|
|
const count = data.length;
|
|
totalRecords += count;
|
|
stats.push({ table: table.name, count });
|
|
|
|
const filePath = path.join(backupDir, `${table.name}.json`);
|
|
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
|
} catch {
|
|
stats.push({ table: table.name, count: 0 });
|
|
}
|
|
}
|
|
|
|
// Uploads-Ordner kopieren (falls vorhanden)
|
|
let uploadFileCount = 0;
|
|
if (fs.existsSync(UPLOADS_DIR)) {
|
|
const uploadsBackupDir = path.join(backupDir, 'uploads');
|
|
uploadFileCount = copyDirectory(UPLOADS_DIR, uploadsBackupDir);
|
|
}
|
|
|
|
// Backup-Info speichern
|
|
const backupInfo = {
|
|
timestamp: new Date().toISOString(),
|
|
totalRecords,
|
|
tables: stats,
|
|
uploadFiles: uploadFileCount,
|
|
};
|
|
fs.writeFileSync(path.join(backupDir, '_backup-info.json'), JSON.stringify(backupInfo, null, 2));
|
|
|
|
return { success: true, backupName: timestamp };
|
|
} catch (error: any) {
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Backup wiederherstellen (inkl. Uploads)
|
|
*/
|
|
export async function restoreBackup(backupName: string): Promise<RestoreResult> {
|
|
const backupDir = path.join(BACKUPS_DIR, backupName);
|
|
|
|
if (!fs.existsSync(backupDir)) {
|
|
return { success: false, error: 'Backup nicht gefunden' };
|
|
}
|
|
|
|
try {
|
|
// Foreign Key Checks deaktivieren
|
|
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 0');
|
|
|
|
// WICHTIG: Alle Tabellen vor dem Restore leeren, damit keine alten Daten übrig bleiben
|
|
console.log('[Restore] Lösche alle bestehenden Daten...');
|
|
|
|
// Detail-Tabellen
|
|
await prisma.carInsuranceDetails.deleteMany({});
|
|
await prisma.tvContractDetails.deleteMany({});
|
|
await prisma.simCard.deleteMany({});
|
|
await prisma.mobileContractDetails.deleteMany({});
|
|
await prisma.phoneNumber.deleteMany({});
|
|
await prisma.internetContractDetails.deleteMany({});
|
|
await prisma.energyContractDetails.deleteMany({});
|
|
await prisma.meterReading.deleteMany({});
|
|
|
|
// E-Mail & Verträge
|
|
await prisma.cachedEmail.deleteMany({});
|
|
await prisma.contractTaskSubtask.deleteMany({});
|
|
await prisma.contractTask.deleteMany({});
|
|
await prisma.contract.deleteMany({});
|
|
|
|
// Kunden-bezogene Daten
|
|
await prisma.stressfreiEmail.deleteMany({});
|
|
await prisma.meter.deleteMany({});
|
|
await prisma.identityDocument.deleteMany({});
|
|
await prisma.bankCard.deleteMany({});
|
|
await prisma.address.deleteMany({});
|
|
await prisma.customerRepresentative.deleteMany({});
|
|
|
|
// Benutzer & Rollen
|
|
await prisma.userRole.deleteMany({});
|
|
await prisma.user.deleteMany({});
|
|
await prisma.customer.deleteMany({});
|
|
|
|
// Stammdaten
|
|
await prisma.tariff.deleteMany({});
|
|
await prisma.provider.deleteMany({});
|
|
await prisma.rolePermission.deleteMany({});
|
|
await prisma.role.deleteMany({});
|
|
await prisma.permission.deleteMany({});
|
|
await prisma.salesPlatform.deleteMany({});
|
|
await prisma.cancellationPeriod.deleteMany({});
|
|
await prisma.contractDuration.deleteMany({});
|
|
await prisma.contractCategory.deleteMany({});
|
|
await prisma.emailProviderConfig.deleteMany({});
|
|
await prisma.appSetting.deleteMany({});
|
|
|
|
console.log('[Restore] Alle Daten gelöscht, starte Wiederherstellung...');
|
|
|
|
// Restore-Reihenfolge
|
|
const restoreOrder = [
|
|
{
|
|
name: 'Permission',
|
|
restore: async (data: any[]) => {
|
|
for (const item of data) {
|
|
await prisma.permission.upsert({
|
|
where: { id: item.id },
|
|
update: convertDates(item),
|
|
create: convertDates(item),
|
|
});
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'Role',
|
|
restore: async (data: any[]) => {
|
|
for (const item of data) {
|
|
await prisma.role.upsert({
|
|
where: { id: item.id },
|
|
update: convertDates(item),
|
|
create: convertDates(item),
|
|
});
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'SalesPlatform',
|
|
restore: async (data: any[]) => {
|
|
for (const item of data) {
|
|
await prisma.salesPlatform.upsert({
|
|
where: { id: item.id },
|
|
update: convertDates(item),
|
|
create: convertDates(item),
|
|
});
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'ContractCategory',
|
|
restore: async (data: any[]) => {
|
|
for (const item of data) {
|
|
await prisma.contractCategory.upsert({
|
|
where: { id: item.id },
|
|
update: convertDates(item),
|
|
create: convertDates(item),
|
|
});
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'CancellationPeriod',
|
|
restore: async (data: any[]) => {
|
|
for (const item of data) {
|
|
await prisma.cancellationPeriod.upsert({
|
|
where: { id: item.id },
|
|
update: convertDates(item),
|
|
create: convertDates(item),
|
|
});
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'ContractDuration',
|
|
restore: async (data: any[]) => {
|
|
for (const item of data) {
|
|
await prisma.contractDuration.upsert({
|
|
where: { id: item.id },
|
|
update: convertDates(item),
|
|
create: convertDates(item),
|
|
});
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'AppSetting',
|
|
restore: async (data: any[]) => {
|
|
for (const item of data) {
|
|
await prisma.appSetting.upsert({
|
|
where: { id: item.id },
|
|
update: convertDates(item),
|
|
create: convertDates(item),
|
|
});
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'EmailProviderConfig',
|
|
restore: async (data: any[]) => {
|
|
for (const item of data) {
|
|
await prisma.emailProviderConfig.upsert({
|
|
where: { id: item.id },
|
|
update: convertDates(item),
|
|
create: convertDates(item),
|
|
});
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'Provider',
|
|
restore: async (data: any[]) => {
|
|
for (const item of data) {
|
|
await prisma.provider.upsert({
|
|
where: { id: item.id },
|
|
update: convertDates(item),
|
|
create: convertDates(item),
|
|
});
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'RolePermission',
|
|
restore: async (data: any[]) => {
|
|
for (const item of data) {
|
|
await prisma.rolePermission.upsert({
|
|
where: { roleId_permissionId: { roleId: item.roleId, permissionId: item.permissionId } },
|
|
update: {},
|
|
create: convertDates(item),
|
|
});
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'User',
|
|
restore: async (data: any[]) => {
|
|
for (const item of data) {
|
|
await prisma.user.upsert({
|
|
where: { id: item.id },
|
|
update: convertDates(item),
|
|
create: convertDates(item),
|
|
});
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'Customer',
|
|
restore: async (data: any[]) => {
|
|
for (const item of data) {
|
|
await prisma.customer.upsert({
|
|
where: { id: item.id },
|
|
update: convertDates(item),
|
|
create: convertDates(item),
|
|
});
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'Tariff',
|
|
restore: async (data: any[]) => {
|
|
for (const item of data) {
|
|
await prisma.tariff.upsert({
|
|
where: { id: item.id },
|
|
update: convertDates(item),
|
|
create: convertDates(item),
|
|
});
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'UserRole',
|
|
restore: async (data: any[]) => {
|
|
for (const item of data) {
|
|
await prisma.userRole.upsert({
|
|
where: { userId_roleId: { userId: item.userId, roleId: item.roleId } },
|
|
update: {},
|
|
create: convertDates(item),
|
|
});
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'CustomerRepresentative',
|
|
restore: async (data: any[]) => {
|
|
for (const item of data) {
|
|
await prisma.customerRepresentative.upsert({
|
|
where: { id: item.id },
|
|
update: convertDates(item),
|
|
create: convertDates(item),
|
|
});
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'StressfreiEmail',
|
|
restore: async (data: any[]) => {
|
|
for (const item of data) {
|
|
await prisma.stressfreiEmail.upsert({
|
|
where: { id: item.id },
|
|
update: convertDates(item),
|
|
create: convertDates(item),
|
|
});
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'Contract',
|
|
restore: async (data: any[]) => {
|
|
for (const item of data) {
|
|
await prisma.contract.upsert({
|
|
where: { id: item.id },
|
|
update: convertDates(item),
|
|
create: convertDates(item),
|
|
});
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'Meter',
|
|
restore: async (data: any[]) => {
|
|
for (const item of data) {
|
|
await prisma.meter.upsert({
|
|
where: { id: item.id },
|
|
update: convertDates(item),
|
|
create: convertDates(item),
|
|
});
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'CachedEmail',
|
|
restore: async (data: any[]) => {
|
|
for (const item of data) {
|
|
await prisma.cachedEmail.upsert({
|
|
where: { id: item.id },
|
|
update: convertDates(item),
|
|
create: convertDates(item),
|
|
});
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'ContractTask',
|
|
restore: async (data: any[]) => {
|
|
for (const item of data) {
|
|
await prisma.contractTask.upsert({
|
|
where: { id: item.id },
|
|
update: convertDates(item),
|
|
create: convertDates(item),
|
|
});
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'MeterReading',
|
|
restore: async (data: any[]) => {
|
|
for (const item of data) {
|
|
await prisma.meterReading.upsert({
|
|
where: { id: item.id },
|
|
update: convertDates(item),
|
|
create: convertDates(item),
|
|
});
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'ContractTaskSubtask',
|
|
restore: async (data: any[]) => {
|
|
for (const item of data) {
|
|
await prisma.contractTaskSubtask.upsert({
|
|
where: { id: item.id },
|
|
update: convertDates(item),
|
|
create: convertDates(item),
|
|
});
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'EnergyContractDetails',
|
|
restore: async (data: any[]) => {
|
|
for (const item of data) {
|
|
await prisma.energyContractDetails.upsert({
|
|
where: { id: item.id },
|
|
update: convertDates(item),
|
|
create: convertDates(item),
|
|
});
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'InternetContractDetails',
|
|
restore: async (data: any[]) => {
|
|
for (const item of data) {
|
|
await prisma.internetContractDetails.upsert({
|
|
where: { id: item.id },
|
|
update: convertDates(item),
|
|
create: convertDates(item),
|
|
});
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'MobileContractDetails',
|
|
restore: async (data: any[]) => {
|
|
for (const item of data) {
|
|
await prisma.mobileContractDetails.upsert({
|
|
where: { id: item.id },
|
|
update: convertDates(item),
|
|
create: convertDates(item),
|
|
});
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'TvContractDetails',
|
|
restore: async (data: any[]) => {
|
|
for (const item of data) {
|
|
await prisma.tvContractDetails.upsert({
|
|
where: { id: item.id },
|
|
update: convertDates(item),
|
|
create: convertDates(item),
|
|
});
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'CarInsuranceDetails',
|
|
restore: async (data: any[]) => {
|
|
for (const item of data) {
|
|
await prisma.carInsuranceDetails.upsert({
|
|
where: { id: item.id },
|
|
update: convertDates(item),
|
|
create: convertDates(item),
|
|
});
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'PhoneNumber',
|
|
restore: async (data: any[]) => {
|
|
for (const item of data) {
|
|
await prisma.phoneNumber.upsert({
|
|
where: { id: item.id },
|
|
update: convertDates(item),
|
|
create: convertDates(item),
|
|
});
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'SimCard',
|
|
restore: async (data: any[]) => {
|
|
for (const item of data) {
|
|
await prisma.simCard.upsert({
|
|
where: { id: item.id },
|
|
update: convertDates(item),
|
|
create: convertDates(item),
|
|
});
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'Address',
|
|
restore: async (data: any[]) => {
|
|
for (const item of data) {
|
|
await prisma.address.upsert({
|
|
where: { id: item.id },
|
|
update: convertDates(item),
|
|
create: convertDates(item),
|
|
});
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'BankCard',
|
|
restore: async (data: any[]) => {
|
|
for (const item of data) {
|
|
await prisma.bankCard.upsert({
|
|
where: { id: item.id },
|
|
update: convertDates(item),
|
|
create: convertDates(item),
|
|
});
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'IdentityDocument',
|
|
restore: async (data: any[]) => {
|
|
for (const item of data) {
|
|
await prisma.identityDocument.upsert({
|
|
where: { id: item.id },
|
|
update: convertDates(item),
|
|
create: convertDates(item),
|
|
});
|
|
}
|
|
},
|
|
},
|
|
];
|
|
|
|
let totalRestored = 0;
|
|
|
|
for (const table of restoreOrder) {
|
|
const filePath = path.join(backupDir, `${table.name}.json`);
|
|
const data = readJsonFile(filePath);
|
|
|
|
if (data.length === 0) continue;
|
|
|
|
try {
|
|
await table.restore(data);
|
|
totalRestored += data.length;
|
|
} catch {
|
|
// Fehler bei einzelner Tabelle, weitermachen
|
|
}
|
|
}
|
|
|
|
// Foreign Key Checks wieder aktivieren
|
|
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 1');
|
|
|
|
// Uploads wiederherstellen
|
|
let restoredFiles = 0;
|
|
const uploadsBackupDir = path.join(backupDir, 'uploads');
|
|
if (fs.existsSync(uploadsBackupDir)) {
|
|
// Bestehenden Uploads-Ordner leeren (optional: könnte auch nur überschreiben)
|
|
if (fs.existsSync(UPLOADS_DIR)) {
|
|
deleteDirectory(UPLOADS_DIR);
|
|
}
|
|
restoredFiles = copyDirectory(uploadsBackupDir, UPLOADS_DIR);
|
|
}
|
|
|
|
return { success: true, restoredRecords: totalRestored, restoredFiles };
|
|
} catch (error: any) {
|
|
try {
|
|
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 1');
|
|
} catch {}
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Backup löschen
|
|
*/
|
|
export async function deleteBackup(backupName: string): Promise<{ success: boolean; error?: string }> {
|
|
const backupDir = path.join(BACKUPS_DIR, backupName);
|
|
|
|
if (!fs.existsSync(backupDir)) {
|
|
return { success: false, error: 'Backup nicht gefunden' };
|
|
}
|
|
|
|
try {
|
|
deleteDirectory(backupDir);
|
|
return { success: true };
|
|
} catch (error: any) {
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Backup als ZIP-Datei erstellen und Stream zurückgeben
|
|
*/
|
|
export async function createBackupZip(backupName: string): Promise<{ stream: archiver.Archiver; filename: string } | { error: string }> {
|
|
const backupDir = path.join(BACKUPS_DIR, backupName);
|
|
|
|
if (!fs.existsSync(backupDir)) {
|
|
return { error: 'Backup nicht gefunden' };
|
|
}
|
|
|
|
const archive = archiver('zip', { zlib: { level: 9 } });
|
|
|
|
// Alle Dateien im Backup-Ordner hinzufügen
|
|
archive.directory(backupDir, false);
|
|
|
|
return {
|
|
stream: archive,
|
|
filename: `opencrm-backup-${backupName}.zip`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* ZIP-Backup hochladen und extrahieren
|
|
*/
|
|
export async function uploadBackupZip(zipBuffer: Buffer): Promise<BackupResult> {
|
|
try {
|
|
const zip = new AdmZip(zipBuffer);
|
|
const entries = zip.getEntries();
|
|
|
|
// Prüfen ob gültiges Backup (muss _backup-info.json enthalten)
|
|
const hasBackupInfo = entries.some(entry => entry.entryName === '_backup-info.json');
|
|
if (!hasBackupInfo) {
|
|
return { success: false, error: 'Ungültiges Backup-Archiv: _backup-info.json fehlt' };
|
|
}
|
|
|
|
// Backup-Info lesen um Namen zu bestimmen
|
|
const infoEntry = entries.find(entry => entry.entryName === '_backup-info.json');
|
|
let backupName: string;
|
|
|
|
try {
|
|
const infoContent = infoEntry!.getData().toString('utf8');
|
|
const info = JSON.parse(infoContent);
|
|
// Backup-Name aus Timestamp generieren
|
|
backupName = new Date(info.timestamp).toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
} catch {
|
|
// Fallback: Aktueller Timestamp
|
|
backupName = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) + '-uploaded';
|
|
}
|
|
|
|
const backupDir = path.join(BACKUPS_DIR, backupName);
|
|
|
|
// Falls Backup mit diesem Namen existiert, Suffix hinzufügen
|
|
let finalBackupDir = backupDir;
|
|
let suffix = 1;
|
|
while (fs.existsSync(finalBackupDir)) {
|
|
finalBackupDir = `${backupDir}-${suffix}`;
|
|
suffix++;
|
|
}
|
|
|
|
const finalBackupName = path.basename(finalBackupDir);
|
|
|
|
// ZIP extrahieren
|
|
zip.extractAllTo(finalBackupDir, true);
|
|
|
|
return { success: true, backupName: finalBackupName };
|
|
} catch (error: any) {
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pfad zum Backup-Verzeichnis zurückgeben
|
|
*/
|
|
export function getBackupsDir(): string {
|
|
return BACKUPS_DIR;
|
|
}
|
|
|
|
/**
|
|
* Werkseinstellungen - Alle Daten löschen (außer Backups)
|
|
*
|
|
* Löscht alle Datenbankeinträge und Uploads, behält aber Backups.
|
|
* Erstellt einen Admin-Benutzer und notwendige Stammdaten.
|
|
*/
|
|
export async function factoryReset(): Promise<{ success: boolean; error?: string }> {
|
|
try {
|
|
// Foreign Key Checks deaktivieren
|
|
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 0');
|
|
|
|
// Tabellen in korrekter Reihenfolge leeren (abhängige zuerst)
|
|
// Detail-Tabellen
|
|
await prisma.carInsuranceDetails.deleteMany({});
|
|
await prisma.tvContractDetails.deleteMany({});
|
|
await prisma.simCard.deleteMany({});
|
|
await prisma.mobileContractDetails.deleteMany({});
|
|
await prisma.phoneNumber.deleteMany({});
|
|
await prisma.internetContractDetails.deleteMany({});
|
|
await prisma.energyContractDetails.deleteMany({});
|
|
await prisma.meterReading.deleteMany({});
|
|
|
|
// E-Mail & Verträge
|
|
await prisma.cachedEmail.deleteMany({});
|
|
await prisma.contractTaskSubtask.deleteMany({});
|
|
await prisma.contractTask.deleteMany({});
|
|
await prisma.contract.deleteMany({});
|
|
|
|
// Kunden-bezogene Daten
|
|
await prisma.stressfreiEmail.deleteMany({});
|
|
await prisma.meter.deleteMany({});
|
|
await prisma.identityDocument.deleteMany({});
|
|
await prisma.bankCard.deleteMany({});
|
|
await prisma.address.deleteMany({});
|
|
await prisma.customerRepresentative.deleteMany({});
|
|
|
|
// Benutzer & Rollen (außer Admin)
|
|
await prisma.userRole.deleteMany({});
|
|
await prisma.user.deleteMany({});
|
|
await prisma.customer.deleteMany({});
|
|
|
|
// Stammdaten
|
|
await prisma.tariff.deleteMany({});
|
|
await prisma.provider.deleteMany({});
|
|
await prisma.rolePermission.deleteMany({});
|
|
await prisma.role.deleteMany({});
|
|
await prisma.permission.deleteMany({});
|
|
await prisma.salesPlatform.deleteMany({});
|
|
await prisma.cancellationPeriod.deleteMany({});
|
|
await prisma.contractDuration.deleteMany({});
|
|
await prisma.contractCategory.deleteMany({});
|
|
await prisma.emailProviderConfig.deleteMany({});
|
|
await prisma.appSetting.deleteMany({});
|
|
|
|
// Foreign Key Checks wieder aktivieren
|
|
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 1');
|
|
|
|
// Uploads-Verzeichnis leeren (aber nicht das Verzeichnis selbst)
|
|
if (fs.existsSync(UPLOADS_DIR)) {
|
|
const items = fs.readdirSync(UPLOADS_DIR);
|
|
for (const item of items) {
|
|
const itemPath = path.join(UPLOADS_DIR, item);
|
|
const stats = fs.statSync(itemPath);
|
|
if (stats.isDirectory()) {
|
|
deleteDirectory(itemPath);
|
|
} else {
|
|
fs.unlinkSync(itemPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Grundlegende Stammdaten neu anlegen (aus Seed)
|
|
// Berechtigungen - muss mit seed.ts übereinstimmen!
|
|
const resourcePermissions: Record<string, string[]> = {
|
|
// Haupt-Ressourcen (CRUD)
|
|
customers: ['create', 'read', 'update', 'delete'],
|
|
contracts: ['create', 'read', 'update', 'delete'],
|
|
users: ['create', 'read', 'update', 'delete'],
|
|
platforms: ['create', 'read', 'update', 'delete'],
|
|
providers: ['create', 'read', 'update', 'delete'],
|
|
tariffs: ['create', 'read', 'update', 'delete'],
|
|
// Lookup-Tabellen (nur lesen)
|
|
'contract-categories': ['read'],
|
|
'cancellation-periods': ['read'],
|
|
'contract-durations': ['read'],
|
|
// Einstellungen (nur lesen/ändern)
|
|
settings: ['read', 'update'],
|
|
// Spezial-Permissions
|
|
developer: ['access'],
|
|
emails: ['delete'],
|
|
};
|
|
|
|
for (const [resource, actions] of Object.entries(resourcePermissions)) {
|
|
for (const action of actions) {
|
|
await prisma.permission.create({
|
|
data: { resource, action },
|
|
});
|
|
}
|
|
}
|
|
console.log('[FactoryReset] Berechtigungen erstellt');
|
|
|
|
// Admin-Rolle mit allen Berechtigungen (außer developer:access)
|
|
const allPermissions = await prisma.permission.findMany();
|
|
const adminRole = await prisma.role.create({
|
|
data: {
|
|
name: 'Admin',
|
|
description: 'Voller Zugriff auf alle Funktionen',
|
|
permissions: {
|
|
create: allPermissions
|
|
.filter(p => !(p.resource === 'developer' && p.action === 'access'))
|
|
.map(p => ({ permissionId: p.id })),
|
|
},
|
|
},
|
|
});
|
|
|
|
// Developer-Rolle - ALLE Berechtigungen inkl. developer:access
|
|
await prisma.role.create({
|
|
data: {
|
|
name: 'Developer',
|
|
description: 'Voller Zugriff inkl. Entwickler-Tools',
|
|
permissions: {
|
|
create: allPermissions.map(p => ({ permissionId: p.id })),
|
|
},
|
|
},
|
|
});
|
|
|
|
// Mitarbeiter-Rolle - customers, contracts + read-only auf Stammdaten
|
|
const employeePermIds = allPermissions
|
|
.filter(p =>
|
|
p.resource === 'customers' ||
|
|
p.resource === 'contracts' ||
|
|
(p.action === 'read' && ['platforms', 'providers', 'tariffs', 'contract-categories', 'cancellation-periods', 'contract-durations'].includes(p.resource))
|
|
)
|
|
.map(p => p.id);
|
|
await prisma.role.create({
|
|
data: {
|
|
name: 'Mitarbeiter',
|
|
description: 'Kann Kunden und Verträge verwalten',
|
|
permissions: {
|
|
create: employeePermIds.map(id => ({ permissionId: id })),
|
|
},
|
|
},
|
|
});
|
|
|
|
// Nur-Lesen Rolle
|
|
const readOnlyResources = ['customers', 'contracts', 'platforms', 'providers', 'tariffs', 'contract-categories', 'cancellation-periods', 'contract-durations'];
|
|
const readOnlyPermIds = allPermissions
|
|
.filter(p => p.action === 'read' && readOnlyResources.includes(p.resource))
|
|
.map(p => p.id);
|
|
await prisma.role.create({
|
|
data: {
|
|
name: 'Mitarbeiter (Nur-Lesen)',
|
|
description: 'Kann nur lesen, keine Änderungen',
|
|
permissions: {
|
|
create: readOnlyPermIds.map(id => ({ permissionId: id })),
|
|
},
|
|
},
|
|
});
|
|
|
|
// Kunden-Rolle
|
|
await prisma.role.create({
|
|
data: {
|
|
name: 'Kunde',
|
|
description: 'Kann nur eigene Daten lesen',
|
|
permissions: {
|
|
create: readOnlyPermIds.map(id => ({ permissionId: id })),
|
|
},
|
|
},
|
|
});
|
|
console.log('[FactoryReset] Rollen erstellt');
|
|
|
|
// Standard Admin-Benutzer erstellen
|
|
console.log('[FactoryReset] Erstelle Admin-Benutzer...');
|
|
const hashedPassword = await bcrypt.hash('admin', 10);
|
|
console.log('[FactoryReset] Passwort gehasht, Admin-Rolle ID:', adminRole.id);
|
|
|
|
const adminUser = await prisma.user.create({
|
|
data: {
|
|
email: 'admin@admin.com',
|
|
password: hashedPassword,
|
|
firstName: 'Admin',
|
|
lastName: 'User',
|
|
roles: {
|
|
create: [{ roleId: adminRole.id }],
|
|
},
|
|
},
|
|
});
|
|
console.log('[FactoryReset] Admin-Benutzer erstellt mit ID:', adminUser.id);
|
|
|
|
// Standard Kündigungsfristen (wie in seed.ts)
|
|
const cancellationPeriods = [
|
|
{ code: '14D', description: '14 Tage' },
|
|
{ code: '1M', description: '1 Monat' },
|
|
{ code: '2M', description: '2 Monate' },
|
|
{ code: '3M', description: '3 Monate' },
|
|
{ code: '6M', description: '6 Monate' },
|
|
{ code: '12M', description: '12 Monate' },
|
|
{ code: '1W', description: '1 Woche' },
|
|
{ code: '2W', description: '2 Wochen' },
|
|
{ code: '4W', description: '4 Wochen' },
|
|
{ code: '6W', description: '6 Wochen' },
|
|
];
|
|
for (const cp of cancellationPeriods) {
|
|
await prisma.cancellationPeriod.create({ data: cp });
|
|
}
|
|
console.log('[FactoryReset] Kündigungsfristen erstellt');
|
|
|
|
// Standard Vertragslaufzeiten (wie in seed.ts)
|
|
const contractDurations = [
|
|
{ code: '1M', description: '1 Monat' },
|
|
{ code: '3M', description: '3 Monate' },
|
|
{ code: '6M', description: '6 Monate' },
|
|
{ code: '12M', description: '12 Monate' },
|
|
{ code: '24M', description: '24 Monate' },
|
|
{ code: '36M', description: '36 Monate' },
|
|
{ code: '1J', description: '1 Jahr' },
|
|
{ code: '2J', description: '2 Jahre' },
|
|
{ code: '3J', description: '3 Jahre' },
|
|
{ code: '4J', description: '4 Jahre' },
|
|
{ code: '5J', description: '5 Jahre' },
|
|
{ code: 'UNBEFRISTET', description: 'Unbefristet' },
|
|
];
|
|
for (const cd of contractDurations) {
|
|
await prisma.contractDuration.create({ data: cd });
|
|
}
|
|
console.log('[FactoryReset] Vertragslaufzeiten erstellt');
|
|
|
|
// Standard Vertragstypen (wie in seed.ts - WICHTIG: Diese müssen mit den Formularen übereinstimmen!)
|
|
const contractCategories = [
|
|
{ code: 'ELECTRICITY', name: 'Strom', icon: 'Zap', color: '#FFC107', sortOrder: 1 },
|
|
{ code: 'GAS', name: 'Gas', icon: 'Flame', color: '#FF5722', sortOrder: 2 },
|
|
{ code: 'DSL', name: 'DSL', icon: 'Wifi', color: '#2196F3', sortOrder: 3 },
|
|
{ code: 'FIBER', name: 'Glasfaser', icon: 'Cable', color: '#9C27B0', sortOrder: 4 },
|
|
{ code: 'CABLE', name: 'Kabel Internet (Coax)', icon: 'Cable', color: '#00BCD4', sortOrder: 5 },
|
|
{ code: 'MOBILE', name: 'Mobilfunk', icon: 'Smartphone', color: '#4CAF50', sortOrder: 6 },
|
|
{ code: 'TV', name: 'TV', icon: 'Tv', color: '#E91E63', sortOrder: 7 },
|
|
{ code: 'CAR_INSURANCE', name: 'KFZ-Versicherung', icon: 'Car', color: '#607D8B', sortOrder: 8 },
|
|
];
|
|
for (const cat of contractCategories) {
|
|
await prisma.contractCategory.create({ data: cat });
|
|
}
|
|
console.log('[FactoryReset] Vertragstypen erstellt');
|
|
|
|
// Standard Vertriebsplattformen (wie in seed.ts)
|
|
const platforms = ['Moon Fachhandel', 'Verivox', 'Check24', 'Eigenvermittlung'];
|
|
for (const name of platforms) {
|
|
await prisma.salesPlatform.create({ data: { name, isActive: true } });
|
|
}
|
|
console.log('[FactoryReset] Vertriebsplattformen erstellt');
|
|
|
|
// Standard Anbieter (wie in seed.ts)
|
|
const providers = [
|
|
{
|
|
name: 'Vodafone',
|
|
portalUrl: 'https://www.vodafone.de/meinvodafone/account/login',
|
|
usernameFieldName: 'username',
|
|
passwordFieldName: 'password',
|
|
},
|
|
{
|
|
name: 'Klarmobil',
|
|
portalUrl: 'https://www.klarmobil.de/login',
|
|
usernameFieldName: 'username',
|
|
passwordFieldName: 'password',
|
|
},
|
|
{
|
|
name: 'Otelo',
|
|
portalUrl: 'https://www.otelo.de/mein-otelo/login',
|
|
usernameFieldName: 'username',
|
|
passwordFieldName: 'password',
|
|
},
|
|
{
|
|
name: 'Congstar',
|
|
portalUrl: 'https://www.congstar.de/login/',
|
|
usernameFieldName: 'username',
|
|
passwordFieldName: 'password',
|
|
},
|
|
{
|
|
name: 'Telekom',
|
|
portalUrl: 'https://www.telekom.de/kundencenter/startseite',
|
|
usernameFieldName: 'username',
|
|
passwordFieldName: 'password',
|
|
},
|
|
{
|
|
name: 'O2',
|
|
portalUrl: 'https://www.o2online.de/ecare/selfcare',
|
|
usernameFieldName: 'username',
|
|
passwordFieldName: 'password',
|
|
},
|
|
{
|
|
name: '1&1',
|
|
portalUrl: 'https://control-center.1und1.de/',
|
|
usernameFieldName: 'username',
|
|
passwordFieldName: 'password',
|
|
},
|
|
];
|
|
for (const provider of providers) {
|
|
await prisma.provider.create({ data: { ...provider, isActive: true } });
|
|
}
|
|
console.log('[FactoryReset] Anbieter erstellt');
|
|
|
|
// Standard App-Einstellungen (wie in seed.ts)
|
|
const appSettings = [
|
|
{ key: 'deadlineCriticalDays', value: '14' },
|
|
{ key: 'deadlineWarningDays', value: '42' },
|
|
{ key: 'deadlineOkDays', value: '90' },
|
|
{ key: 'companyName', value: 'OpenCRM' },
|
|
{ key: 'defaultEmailDomain', value: 'stressfrei-wechseln.de' },
|
|
];
|
|
for (const setting of appSettings) {
|
|
await prisma.appSetting.create({ data: setting });
|
|
}
|
|
console.log('[FactoryReset] App-Einstellungen erstellt');
|
|
|
|
return { success: true };
|
|
} catch (error: any) {
|
|
try {
|
|
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 1');
|
|
} catch {}
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|