feat: Backup & Restore - Manual backup tested complete.

This commit is contained in:
Eric Gullickson
2025-12-25 10:50:09 -06:00
parent 8ef6b3d853
commit 0357ce391f
38 changed files with 5734 additions and 1415447 deletions

View File

@@ -0,0 +1,319 @@
/**
* @ai-summary Controller for backup API endpoints
* @ai-context Handles HTTP requests for backup operations
*/
import { FastifyRequest, FastifyReply } from 'fastify';
import { Pool } from 'pg';
import { BackupService } from '../domain/backup.service';
import { BackupRestoreService } from '../domain/backup-restore.service';
import {
ListBackupsQuery,
CreateBackupBody,
BackupIdParam,
RestoreBody,
CreateScheduleBody,
UpdateScheduleBody,
ScheduleIdParam,
UpdateSettingsBody,
} from './backup.validation';
export class BackupController {
private backupService: BackupService;
private restoreService: BackupRestoreService;
constructor(pool: Pool) {
this.backupService = new BackupService(pool);
this.restoreService = new BackupRestoreService(pool);
}
// ============================================
// Backup Operations
// ============================================
async listBackups(
request: FastifyRequest<{ Querystring: ListBackupsQuery }>,
reply: FastifyReply
): Promise<void> {
const result = await this.backupService.listBackups(request.query);
reply.send(result);
}
async createBackup(
request: FastifyRequest<{ Body: CreateBackupBody }>,
reply: FastifyReply
): Promise<void> {
const adminSub = (request as any).userContext?.auth0Sub;
const result = await this.backupService.createBackup({
name: request.body.name,
backupType: 'manual',
createdBy: adminSub,
includeDocuments: request.body.includeDocuments,
});
if (result.success) {
reply.status(201).send({
backupId: result.backupId,
status: 'completed',
message: 'Backup created successfully',
});
} else {
reply.status(500).send({
backupId: result.backupId,
status: 'failed',
message: result.error,
});
}
}
async getBackup(
request: FastifyRequest<{ Params: BackupIdParam }>,
reply: FastifyReply
): Promise<void> {
const backup = await this.backupService.getBackup(request.params.id);
if (!backup) {
reply.status(404).send({ error: 'Backup not found' });
return;
}
reply.send(backup);
}
async downloadBackup(
request: FastifyRequest<{ Params: BackupIdParam }>,
reply: FastifyReply
): Promise<void> {
const download = await this.backupService.createDownloadStream(request.params.id);
if (!download) {
reply.status(404).send({ error: 'Backup not found or file not available' });
return;
}
return reply
.header('Content-Type', 'application/gzip')
.header('Content-Disposition', `attachment; filename="${download.filename}"`)
.header('Content-Length', download.size)
.send(download.stream);
}
async deleteBackup(
request: FastifyRequest<{ Params: BackupIdParam }>,
reply: FastifyReply
): Promise<void> {
const deleted = await this.backupService.deleteBackup(request.params.id);
if (!deleted) {
reply.status(404).send({ error: 'Backup not found' });
return;
}
reply.status(204).send();
}
async uploadBackup(
request: FastifyRequest,
reply: FastifyReply
): Promise<void> {
const adminSub = (request as any).userContext?.auth0Sub;
// Handle multipart file upload
const data = await request.file();
if (!data) {
reply.status(400).send({ error: 'No file uploaded' });
return;
}
// Validate file type
const filename = data.filename;
if (!filename.endsWith('.tar.gz') && !filename.endsWith('.tgz')) {
reply.status(400).send({ error: 'Invalid file type. Expected .tar.gz archive' });
return;
}
// Save file temporarily
const fs = await import('fs');
const path = await import('path');
const os = await import('os');
const tempPath = path.join(os.tmpdir(), `upload-${Date.now()}-${filename}`);
const writeStream = fs.createWriteStream(tempPath);
await new Promise<void>((resolve, reject) => {
data.file.pipe(writeStream);
data.file.on('end', resolve);
data.file.on('error', reject);
});
try {
const backup = await this.backupService.importUploadedBackup(
tempPath,
filename,
adminSub
);
reply.status(201).send({
backupId: backup.id,
filename: backup.filename,
fileSizeBytes: backup.fileSizeBytes,
message: 'Backup uploaded successfully',
});
} catch (error) {
// Cleanup temp file on error
await fs.promises.unlink(tempPath).catch(() => {});
throw error;
}
}
// ============================================
// Restore Operations
// ============================================
async previewRestore(
request: FastifyRequest<{ Params: BackupIdParam }>,
reply: FastifyReply
): Promise<void> {
try {
const preview = await this.restoreService.previewRestore(request.params.id);
reply.send(preview);
} catch (error) {
reply.status(400).send({
error: error instanceof Error ? error.message : 'Failed to preview restore',
});
}
}
async executeRestore(
request: FastifyRequest<{ Params: BackupIdParam; Body: RestoreBody }>,
reply: FastifyReply
): Promise<void> {
try {
const result = await this.restoreService.executeRestore({
backupId: request.params.id,
createSafetyBackup: request.body.createSafetyBackup,
});
if (result.success) {
reply.send({
success: true,
safetyBackupId: result.safetyBackupId,
restoredAt: new Date().toISOString(),
message: 'Restore completed successfully',
});
} else {
reply.status(500).send({
success: false,
safetyBackupId: result.safetyBackupId,
error: result.error,
message: 'Restore failed',
});
}
} catch (error) {
reply.status(400).send({
success: false,
error: error instanceof Error ? error.message : 'Failed to execute restore',
});
}
}
async getRestoreStatus(
_request: FastifyRequest,
reply: FastifyReply
): Promise<void> {
const status = this.restoreService.getRestoreStatus();
reply.send(status);
}
// ============================================
// Schedule Operations
// ============================================
async listSchedules(
_request: FastifyRequest,
reply: FastifyReply
): Promise<void> {
const schedules = await this.backupService.listSchedules();
reply.send(schedules);
}
async createSchedule(
request: FastifyRequest<{ Body: CreateScheduleBody }>,
reply: FastifyReply
): Promise<void> {
const schedule = await this.backupService.createSchedule(
request.body.name,
request.body.frequency,
request.body.retentionCount,
request.body.isEnabled
);
reply.status(201).send(schedule);
}
async updateSchedule(
request: FastifyRequest<{ Params: ScheduleIdParam; Body: UpdateScheduleBody }>,
reply: FastifyReply
): Promise<void> {
const schedule = await this.backupService.updateSchedule(
request.params.id,
request.body
);
if (!schedule) {
reply.status(404).send({ error: 'Schedule not found' });
return;
}
reply.send(schedule);
}
async deleteSchedule(
request: FastifyRequest<{ Params: ScheduleIdParam }>,
reply: FastifyReply
): Promise<void> {
const deleted = await this.backupService.deleteSchedule(request.params.id);
if (!deleted) {
reply.status(404).send({ error: 'Schedule not found' });
return;
}
reply.status(204).send();
}
async toggleSchedule(
request: FastifyRequest<{ Params: ScheduleIdParam }>,
reply: FastifyReply
): Promise<void> {
const schedule = await this.backupService.toggleSchedule(request.params.id);
if (!schedule) {
reply.status(404).send({ error: 'Schedule not found' });
return;
}
reply.send(schedule);
}
// ============================================
// Settings Operations
// ============================================
async getSettings(
_request: FastifyRequest,
reply: FastifyReply
): Promise<void> {
const settings = await this.backupService.getSettings();
reply.send(settings);
}
async updateSettings(
request: FastifyRequest<{ Body: UpdateSettingsBody }>,
reply: FastifyReply
): Promise<void> {
const settings = await this.backupService.updateSettings(request.body);
reply.send(settings);
}
}