From 6aadaa4a9632df5aacd1f003b5eda743d4b4fe0c Mon Sep 17 00:00:00 2001 From: Aniekan Victory Date: Sat, 25 Apr 2026 09:37:38 +0100 Subject: [PATCH] feat: add database backup management --- .env.example | 5 + .../migration.sql | 53 +++ prisma/schema.prisma | 61 +++ src/admin/admin.controller.ts | 44 +- src/admin/admin.module.ts | 3 +- src/admin/admin.service.ts | 31 ++ src/app.module.ts | 4 + src/backup/backup.module.ts | 11 + src/backup/backup.service.ts | 439 ++++++++++++++++++ src/backup/dto/backup.dto.ts | 25 + test/admin/backup.service.spec.ts | 114 +++++ 11 files changed, 788 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/20260425093000_add_database_backup_management/migration.sql create mode 100644 src/backup/backup.module.ts create mode 100644 src/backup/backup.service.ts create mode 100644 src/backup/dto/backup.dto.ts create mode 100644 test/admin/backup.service.spec.ts diff --git a/.env.example b/.env.example index 2eb76c91..086b5c52 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,8 @@ JWT_REFRESH_EXPIRES_IN=7d # Security Configuration BCRYPT_ROUNDS=12 PASSWORD_HISTORY_LIMIT=5 + +# Database Backup Management +BACKUP_STORAGE_PATH=./backups +PG_DUMP_PATH=pg_dump +PSQL_PATH=psql diff --git a/prisma/migrations/20260425093000_add_database_backup_management/migration.sql b/prisma/migrations/20260425093000_add_database_backup_management/migration.sql new file mode 100644 index 00000000..002f436a --- /dev/null +++ b/prisma/migrations/20260425093000_add_database_backup_management/migration.sql @@ -0,0 +1,53 @@ +CREATE TYPE "BackupStatus" AS ENUM ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED'); +CREATE TYPE "BackupTrigger" AS ENUM ('MANUAL', 'SCHEDULED'); +CREATE TYPE "RestoreStatus" AS ENUM ('IDLE', 'RUNNING', 'COMPLETED', 'FAILED'); + +CREATE TABLE "database_backups" ( + "id" TEXT NOT NULL, + "filename" TEXT NOT NULL, + "file_path" TEXT NOT NULL, + "status" "BackupStatus" NOT NULL DEFAULT 'PENDING', + "trigger" "BackupTrigger" NOT NULL, + "size_bytes" BIGINT, + "checksum" TEXT, + "started_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "completed_at" TIMESTAMP(3), + "error_message" TEXT, + "initiated_by_id" TEXT, + "restore_status" "RestoreStatus" NOT NULL DEFAULT 'IDLE', + "restored_at" TIMESTAMP(3), + "restore_error" TEXT, + "restored_by_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "database_backups_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "backup_schedule_configs" ( + "id" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT false, + "cron_expression" TEXT NOT NULL, + "retention_count" INTEGER NOT NULL DEFAULT 10, + "last_run_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "backup_schedule_configs_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "database_backups_filename_key" ON "database_backups"("filename"); +CREATE INDEX "database_backups_status_started_at_idx" ON "database_backups"("status", "started_at"); +CREATE INDEX "database_backups_restore_status_restored_at_idx" ON "database_backups"("restore_status", "restored_at"); +CREATE INDEX "database_backups_trigger_created_at_idx" ON "database_backups"("trigger", "created_at"); + +ALTER TABLE "database_backups" +ADD CONSTRAINT "database_backups_initiated_by_id_fkey" +FOREIGN KEY ("initiated_by_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "database_backups" +ADD CONSTRAINT "database_backups_restored_by_id_fkey" +FOREIGN KEY ("restored_by_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +INSERT INTO "backup_schedule_configs" ("id", "enabled", "cron_expression", "retention_count", "created_at", "updated_at") +VALUES ('default', false, '0 2 * * *', 10, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 40e27858..002b5b31 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -91,6 +91,25 @@ enum FraudPattern { HIGH_VALUE_NEW_ACCOUNT_LISTING } +enum BackupStatus { + PENDING + RUNNING + COMPLETED + FAILED +} + +enum BackupTrigger { + MANUAL + SCHEDULED +} + +enum RestoreStatus { + IDLE + RUNNING + COMPLETED + FAILED +} + // User model model User { id String @id @default(uuid()) @@ -147,6 +166,8 @@ model User { savedFilters SavedFilter[] searchAnalytics SearchAnalytics[] searchHistory SearchHistory[] + initiatedBackups DatabaseBackup[] @relation("BackupInitiatedBy") + restoredBackups DatabaseBackup[] @relation("BackupRestoredBy") @@index([email]) @@index([role]) @@ -156,6 +177,46 @@ model User { @@map("users") } +model DatabaseBackup { + id String @id @default(uuid()) + filename String @unique + filePath String @map("file_path") + status BackupStatus @default(PENDING) + trigger BackupTrigger + sizeBytes BigInt? @map("size_bytes") + checksum String? + startedAt DateTime @default(now()) @map("started_at") + completedAt DateTime? @map("completed_at") + errorMessage String? @map("error_message") @db.Text + initiatedById String? @map("initiated_by_id") + restoreStatus RestoreStatus @default(IDLE) @map("restore_status") + restoredAt DateTime? @map("restored_at") + restoreError String? @map("restore_error") @db.Text + restoredById String? @map("restored_by_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + initiatedBy User? @relation("BackupInitiatedBy", fields: [initiatedById], references: [id], onDelete: SetNull) + restoredBy User? @relation("BackupRestoredBy", fields: [restoredById], references: [id], onDelete: SetNull) + + @@index([status, startedAt]) + @@index([restoreStatus, restoredAt]) + @@index([trigger, createdAt]) + @@map("database_backups") +} + +model BackupScheduleConfig { + id String @id + enabled Boolean @default(false) + cronExpression String @map("cron_expression") + retentionCount Int @default(10) @map("retention_count") + lastRunAt DateTime? @map("last_run_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("backup_schedule_configs") +} + model ApiKey { id String @id @default(uuid()) userId String @map("user_id") diff --git a/src/admin/admin.controller.ts b/src/admin/admin.controller.ts index e7c8eec2..ae036cec 100644 --- a/src/admin/admin.controller.ts +++ b/src/admin/admin.controller.ts @@ -1,4 +1,5 @@ -import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, Patch, Post, Put, Query, Res, UseGuards } from '@nestjs/common'; +import { Response } from 'express'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { Roles } from '../auth/decorators/roles.decorator'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @@ -18,6 +19,7 @@ import { ReviewFraudAlertDto, TransactionMonitoringQueryDto, } from './dto/admin.dto'; +import { RestoreBackupDto, UpdateBackupScheduleDto } from '../backup/dto/backup.dto'; @Controller('admin') @UseGuards(JwtAuthGuard, RolesGuard) @@ -30,6 +32,46 @@ export class AdminController { return this.adminService.getDashboard(); } + @Get('backups') + listBackups() { + return this.adminService.listBackups(); + } + + @Get('backups/status') + getBackupStatus() { + return this.adminService.getBackupStatus(); + } + + @Get('backups/schedule') + getBackupSchedule() { + return this.adminService.getBackupSchedule(); + } + + @Put('backups/schedule') + updateBackupSchedule(@Body() payload: UpdateBackupScheduleDto) { + return this.adminService.updateBackupSchedule(payload); + } + + @Post('backups/run') + runBackup(@CurrentUser() user: AuthUserPayload) { + return this.adminService.runBackup(user.sub); + } + + @Post('backups/:id/restore') + restoreBackup( + @Param('id') backupId: string, + @Body() _payload: RestoreBackupDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.adminService.restoreBackup(backupId, user.sub); + } + + @Get('backups/:id/download') + async downloadBackup(@Param('id') backupId: string, @Res() res: Response) { + const file = await this.adminService.getBackupDownload(backupId); + return res.download(file.filePath, file.filename); + } + @Get('users') listUsers(@Query() query: AdminUsersQueryDto) { return this.adminService.listUsers(query); diff --git a/src/admin/admin.module.ts b/src/admin/admin.module.ts index 3b69af73..0d21a180 100644 --- a/src/admin/admin.module.ts +++ b/src/admin/admin.module.ts @@ -3,9 +3,10 @@ import { AdminController } from './admin.controller'; import { AdminService } from './admin.service'; import { PrismaModule } from '../database/prisma.module'; import { FraudModule } from '../fraud/fraud.module'; +import { BackupModule } from '../backup/backup.module'; @Module({ - imports: [PrismaModule, FraudModule], + imports: [PrismaModule, FraudModule, BackupModule], controllers: [AdminController], providers: [AdminService], exports: [AdminService], diff --git a/src/admin/admin.service.ts b/src/admin/admin.service.ts index a786d93b..dafdd7ac 100644 --- a/src/admin/admin.service.ts +++ b/src/admin/admin.service.ts @@ -1,5 +1,7 @@ import { Injectable } from '@nestjs/common'; import { PrismaService } from '../database/prisma.service'; +import { BackupService } from '../backup/backup.service'; +import { UpdateBackupScheduleDto } from '../backup/dto/backup.dto'; import { AddFraudInvestigationNoteDto, AdminUpdateUserDto, @@ -20,8 +22,37 @@ export class AdminService { constructor( private readonly prisma: PrismaService, private readonly fraudService: FraudService, + private readonly backupService: BackupService, ) {} + async listBackups() { + return this.backupService.listBackups(); + } + + async getBackupStatus() { + return this.backupService.getBackupStatus(); + } + + async getBackupSchedule() { + return this.backupService.getSchedule(); + } + + async updateBackupSchedule(payload: UpdateBackupScheduleDto) { + return this.backupService.updateSchedule(payload); + } + + async runBackup(actorId: string) { + return this.backupService.createManualBackup(actorId); + } + + async restoreBackup(backupId: string, actorId: string) { + return this.backupService.restoreBackup(backupId, actorId); + } + + async getBackupDownload(backupId: string) { + return this.backupService.getBackupFile(backupId); + } + async getDashboard() { const [totalUsers, blockedUsers, totalProperties, pendingProperties, activeProperties] = await Promise.all([ diff --git a/src/app.module.ts b/src/app.module.ts index 3f003e4e..a1f1bbf0 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { GraphQLModule } from '@nestjs/graphql'; import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'; +import { ScheduleModule } from '@nestjs/schedule'; import { join } from 'path'; import { UsersModule } from './users/users.module'; import { AuthModule } from './auth/auth.module'; @@ -21,6 +22,7 @@ import './common/common.types'; // Load registered enums import { AdminModule } from './admin/admin.module'; import { FraudModule } from './fraud/fraud.module'; import { SearchModule } from './search/search.module'; +import { BackupModule } from './backup/backup.module'; @Module({ imports: [ ConfigModule.forRoot({ @@ -36,6 +38,7 @@ import { SearchModule } from './search/search.module'; 'graphql-ws': true, }, }), + ScheduleModule.forRoot(), CacheModuleConfig, AnalyticsModule, PrismaModule, @@ -52,6 +55,7 @@ import { SearchModule } from './search/search.module'; DocumentsModule, IntegrationsModule, SearchModule, + BackupModule, ], controllers: [AppController], }) diff --git a/src/backup/backup.module.ts b/src/backup/backup.module.ts new file mode 100644 index 00000000..4f553f6e --- /dev/null +++ b/src/backup/backup.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { PrismaModule } from '../database/prisma.module'; +import { BackupService } from './backup.service'; + +@Module({ + imports: [ConfigModule, PrismaModule], + providers: [BackupService], + exports: [BackupService], +}) +export class BackupModule {} diff --git a/src/backup/backup.service.ts b/src/backup/backup.service.ts new file mode 100644 index 00000000..05e19b33 --- /dev/null +++ b/src/backup/backup.service.ts @@ -0,0 +1,439 @@ +import { + BadRequestException, + ConflictException, + Injectable, + InternalServerErrorException, + Logger, + NotFoundException, + OnModuleInit, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + BackupStatus, + BackupTrigger, + DatabaseBackup, + RestoreStatus, +} from '@prisma/client'; +import { CronJob } from 'cron'; +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import { promisify } from 'util'; +import { execFile } from 'child_process'; +import { PrismaService } from '../database/prisma.service'; +import { UpdateBackupScheduleDto } from './dto/backup.dto'; + +const execFileAsync = promisify(execFile); +const DEFAULT_SCHEDULE_ID = 'default'; + +@Injectable() +export class BackupService implements OnModuleInit { + private readonly logger = new Logger(BackupService.name); + private scheduledJob?: CronJob; + + constructor( + private readonly prisma: PrismaService, + private readonly configService: ConfigService, + ) {} + + async onModuleInit() { + await this.ensureScheduleConfig(); + await this.refreshSchedule(); + } + + async listBackups() { + const backups = await this.prisma.databaseBackup.findMany({ + orderBy: { createdAt: 'desc' }, + include: { + initiatedBy: { + select: { id: true, email: true, firstName: true, lastName: true }, + }, + restoredBy: { + select: { id: true, email: true, firstName: true, lastName: true }, + }, + }, + }); + + return backups.map((backup) => this.serializeBackup(backup)); + } + + async getBackupStatus() { + const [latestBackup, runningBackups, totalBackups, schedule] = await Promise.all([ + this.prisma.databaseBackup.findFirst({ + orderBy: { createdAt: 'desc' }, + }), + this.prisma.databaseBackup.count({ + where: { + OR: [{ status: BackupStatus.RUNNING }, { restoreStatus: RestoreStatus.RUNNING }], + }, + }), + this.prisma.databaseBackup.count(), + this.getScheduleConfig(), + ]); + + return { + totalBackups, + runningBackups, + latestBackup: latestBackup ? this.serializeBackup(latestBackup) : null, + schedule: this.serializeSchedule(schedule), + }; + } + + async getSchedule() { + const schedule = await this.getScheduleConfig(); + return this.serializeSchedule(schedule); + } + + async updateSchedule(payload: UpdateBackupScheduleDto) { + this.assertValidCronExpression(payload.cronExpression); + + const schedule = await this.prisma.backupScheduleConfig.upsert({ + where: { id: DEFAULT_SCHEDULE_ID }, + update: { + enabled: payload.enabled, + cronExpression: payload.cronExpression, + retentionCount: payload.retentionCount, + }, + create: { + id: DEFAULT_SCHEDULE_ID, + enabled: payload.enabled, + cronExpression: payload.cronExpression, + retentionCount: payload.retentionCount ?? 10, + }, + }); + + await this.refreshSchedule(); + return this.serializeSchedule(schedule); + } + + async createManualBackup(initiatedById?: string) { + return this.createBackup(BackupTrigger.MANUAL, initiatedById); + } + + async restoreBackup(backupId: string, restoredById?: string) { + const backup = await this.prisma.databaseBackup.findUnique({ + where: { id: backupId }, + }); + + if (!backup) { + throw new NotFoundException('Backup not found'); + } + + if (!fs.existsSync(backup.filePath)) { + throw new NotFoundException('Backup file is missing from storage'); + } + + await this.ensureNoActiveJobs(backupId); + + await this.prisma.databaseBackup.update({ + where: { id: backupId }, + data: { + restoreStatus: RestoreStatus.RUNNING, + restoreError: null, + restoredById: restoredById ?? null, + }, + }); + + try { + await this.runRestoreCommand(backup.filePath); + + const restored = await this.prisma.databaseBackup.update({ + where: { id: backupId }, + data: { + restoreStatus: RestoreStatus.COMPLETED, + restoredAt: new Date(), + restoreError: null, + restoredById: restoredById ?? null, + }, + }); + + return this.serializeBackup(restored); + } catch (error) { + const message = this.toErrorMessage(error); + + await this.prisma.databaseBackup.update({ + where: { id: backupId }, + data: { + restoreStatus: RestoreStatus.FAILED, + restoreError: message, + restoredById: restoredById ?? null, + }, + }); + + throw new InternalServerErrorException(`Backup restore failed: ${message}`); + } + } + + async getBackupFile(backupId: string) { + const backup = await this.prisma.databaseBackup.findUnique({ + where: { id: backupId }, + }); + + if (!backup) { + throw new NotFoundException('Backup not found'); + } + + if (!fs.existsSync(backup.filePath)) { + throw new NotFoundException('Backup file is missing from storage'); + } + + return { + backup: this.serializeBackup(backup), + filePath: backup.filePath, + filename: backup.filename, + }; + } + + private async createBackup(trigger: BackupTrigger, initiatedById?: string) { + await this.ensureNoActiveJobs(); + this.ensureDatabaseUrl(); + + const storagePath = this.getStoragePath(); + fs.mkdirSync(storagePath, { recursive: true }); + + const filename = this.buildFilename(trigger); + const filePath = path.join(storagePath, filename); + + const backup = await this.prisma.databaseBackup.create({ + data: { + filename, + filePath, + status: BackupStatus.RUNNING, + trigger, + initiatedById: initiatedById ?? null, + }, + }); + + try { + await this.runBackupCommand(filePath); + + const stats = await fs.promises.stat(filePath); + const checksum = await this.computeChecksum(filePath); + + const completed = await this.prisma.databaseBackup.update({ + where: { id: backup.id }, + data: { + status: BackupStatus.COMPLETED, + completedAt: new Date(), + sizeBytes: BigInt(stats.size), + checksum, + errorMessage: null, + }, + }); + + if (trigger === BackupTrigger.SCHEDULED) { + await this.prisma.backupScheduleConfig.update({ + where: { id: DEFAULT_SCHEDULE_ID }, + data: { lastRunAt: new Date() }, + }); + } + + await this.enforceRetentionPolicy(); + return this.serializeBackup(completed); + } catch (error) { + const message = this.toErrorMessage(error); + + await this.prisma.databaseBackup.update({ + where: { id: backup.id }, + data: { + status: BackupStatus.FAILED, + completedAt: new Date(), + errorMessage: message, + }, + }); + + if (fs.existsSync(filePath)) { + await fs.promises.unlink(filePath).catch(() => undefined); + } + + throw new InternalServerErrorException(`Backup creation failed: ${message}`); + } + } + + private async refreshSchedule() { + if (this.scheduledJob) { + this.scheduledJob.stop(); + this.scheduledJob = undefined; + } + + const schedule = await this.getScheduleConfig(); + if (!schedule.enabled) { + return; + } + + this.assertValidCronExpression(schedule.cronExpression); + + this.scheduledJob = new CronJob(schedule.cronExpression, async () => { + try { + await this.createBackup(BackupTrigger.SCHEDULED); + } catch (error) { + this.logger.error(`Scheduled backup failed: ${this.toErrorMessage(error)}`); + } + }); + + this.scheduledJob.start(); + } + + private async ensureScheduleConfig() { + await this.prisma.backupScheduleConfig.upsert({ + where: { id: DEFAULT_SCHEDULE_ID }, + update: {}, + create: { + id: DEFAULT_SCHEDULE_ID, + enabled: false, + cronExpression: '0 2 * * *', + retentionCount: 10, + }, + }); + } + + private async getScheduleConfig() { + const schedule = await this.prisma.backupScheduleConfig.findUnique({ + where: { id: DEFAULT_SCHEDULE_ID }, + }); + + if (!schedule) { + throw new NotFoundException('Backup schedule configuration not found'); + } + + return schedule; + } + + private async enforceRetentionPolicy() { + const schedule = await this.getScheduleConfig(); + const backups = await this.prisma.databaseBackup.findMany({ + where: { status: BackupStatus.COMPLETED }, + orderBy: { createdAt: 'desc' }, + skip: schedule.retentionCount, + }); + + await Promise.all( + backups.map(async (backup) => { + if (fs.existsSync(backup.filePath)) { + await fs.promises.unlink(backup.filePath).catch(() => undefined); + } + + await this.prisma.databaseBackup.delete({ where: { id: backup.id } }); + }), + ); + } + + private async ensureNoActiveJobs(excludedBackupId?: string) { + const activeJobs = await this.prisma.databaseBackup.count({ + where: { + id: excludedBackupId ? { not: excludedBackupId } : undefined, + OR: [{ status: BackupStatus.RUNNING }, { restoreStatus: RestoreStatus.RUNNING }], + }, + }); + + if (activeJobs > 0) { + throw new ConflictException('A backup or restore job is already running'); + } + } + + private buildFilename(trigger: BackupTrigger) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return `propchain-${trigger.toLowerCase()}-${timestamp}.sql`; + } + + private getStoragePath() { + return this.configService.get('BACKUP_STORAGE_PATH') ?? path.join(process.cwd(), 'backups'); + } + + private ensureDatabaseUrl() { + if (!this.configService.get('DATABASE_URL')) { + throw new BadRequestException('DATABASE_URL is not configured'); + } + } + + private getBackupCommand() { + return this.configService.get('PG_DUMP_PATH') ?? 'pg_dump'; + } + + private getRestoreCommand() { + return this.configService.get('PSQL_PATH') ?? 'psql'; + } + + private async runBackupCommand(filePath: string) { + await execFileAsync(this.getBackupCommand(), [ + `--dbname=${this.configService.get('DATABASE_URL')}`, + '--clean', + '--if-exists', + '--no-owner', + '--no-privileges', + '--format=plain', + `--file=${filePath}`, + ]); + } + + private async runRestoreCommand(filePath: string) { + await execFileAsync(this.getRestoreCommand(), [ + `--dbname=${this.configService.get('DATABASE_URL')}`, + '--single-transaction', + '--file', + filePath, + ]); + } + + private async computeChecksum(filePath: string) { + const hash = crypto.createHash('sha256'); + const stream = fs.createReadStream(filePath); + + await new Promise((resolve, reject) => { + stream.on('data', (chunk) => hash.update(chunk)); + stream.on('end', () => resolve()); + stream.on('error', reject); + }); + + return hash.digest('hex'); + } + + private assertValidCronExpression(expression: string) { + try { + const job = new CronJob(expression, () => undefined); + job.stop(); + } catch (error) { + throw new BadRequestException(`Invalid cron expression: ${this.toErrorMessage(error)}`); + } + } + + private serializeSchedule(schedule: { + enabled: boolean; + cronExpression: string; + retentionCount: number; + lastRunAt: Date | null; + }) { + let nextRunAt: Date | null = null; + + if (schedule.enabled) { + try { + nextRunAt = new CronJob(schedule.cronExpression, () => undefined).nextDate().toJSDate(); + } catch (error) { + this.logger.warn(`Could not calculate next run: ${this.toErrorMessage(error)}`); + } + } + + return { + enabled: schedule.enabled, + cronExpression: schedule.cronExpression, + retentionCount: schedule.retentionCount, + lastRunAt: schedule.lastRunAt, + nextRunAt, + }; + } + + private serializeBackup(backup: DatabaseBackup) { + return { + ...backup, + sizeBytes: backup.sizeBytes ? Number(backup.sizeBytes) : null, + }; + } + + private toErrorMessage(error: unknown) { + if (error instanceof Error) { + return error.message; + } + + return 'Unknown error'; + } +} diff --git a/src/backup/dto/backup.dto.ts b/src/backup/dto/backup.dto.ts new file mode 100644 index 00000000..05d07b6c --- /dev/null +++ b/src/backup/dto/backup.dto.ts @@ -0,0 +1,25 @@ +import { Type } from 'class-transformer'; +import { IsBoolean, IsInt, IsNotEmpty, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class UpdateBackupScheduleDto { + @IsBoolean() + enabled!: boolean; + + @IsString() + @IsNotEmpty() + cronExpression!: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + retentionCount?: number; +} + +export class RestoreBackupDto { + @IsOptional() + @IsString() + @IsNotEmpty() + reason?: string; +} diff --git a/test/admin/backup.service.spec.ts b/test/admin/backup.service.spec.ts new file mode 100644 index 00000000..c9d6548f --- /dev/null +++ b/test/admin/backup.service.spec.ts @@ -0,0 +1,114 @@ +import { BadRequestException, ConflictException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { BackupStatus, BackupTrigger, RestoreStatus } from '@prisma/client'; +import { BackupService } from '../../src/backup/backup.service'; +import { PrismaService } from '../../src/database/prisma.service'; + +describe('BackupService', () => { + let service: BackupService; + + const mockPrismaService = { + databaseBackup: { + findMany: jest.fn(), + findFirst: jest.fn(), + findUnique: jest.fn(), + count: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + backupScheduleConfig: { + upsert: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, + } as any; + + const mockConfigService = { + get: jest.fn((key: string) => { + const values: Record = { + DATABASE_URL: 'postgresql://user:password@localhost:5432/propchain', + BACKUP_STORAGE_PATH: 'C:/tmp/backups', + PG_DUMP_PATH: 'pg_dump', + PSQL_PATH: 'psql', + }; + + return values[key]; + }), + } as any; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + BackupService, + { provide: PrismaService, useValue: mockPrismaService }, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + service = module.get(BackupService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('returns backup status with schedule details', async () => { + mockPrismaService.databaseBackup.findFirst.mockResolvedValue({ + id: 'backup-1', + filename: 'backup.sql', + filePath: 'C:/tmp/backups/backup.sql', + status: BackupStatus.COMPLETED, + trigger: BackupTrigger.MANUAL, + sizeBytes: BigInt(128), + checksum: 'abc', + startedAt: new Date('2026-04-25T08:00:00.000Z'), + completedAt: new Date('2026-04-25T08:05:00.000Z'), + errorMessage: null, + initiatedById: 'user-1', + restoreStatus: RestoreStatus.IDLE, + restoredAt: null, + restoreError: null, + restoredById: null, + createdAt: new Date('2026-04-25T08:00:00.000Z'), + updatedAt: new Date('2026-04-25T08:05:00.000Z'), + }); + mockPrismaService.databaseBackup.count + .mockResolvedValueOnce(0) + .mockResolvedValueOnce(4); + mockPrismaService.backupScheduleConfig.findUnique.mockResolvedValue({ + id: 'default', + enabled: true, + cronExpression: '0 2 * * *', + retentionCount: 10, + lastRunAt: new Date('2026-04-24T02:00:00.000Z'), + createdAt: new Date(), + updatedAt: new Date(), + }); + + const status = await service.getBackupStatus(); + + expect(status.totalBackups).toBe(4); + expect(status.runningBackups).toBe(0); + expect(status.latestBackup?.sizeBytes).toBe(128); + expect(status.schedule.enabled).toBe(true); + expect(status.schedule.cronExpression).toBe('0 2 * * *'); + }); + + it('rejects invalid cron expressions', async () => { + await expect( + service.updateSchedule({ + enabled: true, + cronExpression: 'not-a-cron', + retentionCount: 7, + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('blocks manual backup creation while another job is running', async () => { + mockPrismaService.databaseBackup.count.mockResolvedValue(1); + + await expect(service.createManualBackup('user-1')).rejects.toBeInstanceOf(ConflictException); + }); +});