feat: Backup & Restore - Manual backup tested complete.
This commit is contained in:
319
backend/src/features/backup/api/backup.controller.ts
Normal file
319
backend/src/features/backup/api/backup.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
137
backend/src/features/backup/api/backup.routes.ts
Normal file
137
backend/src/features/backup/api/backup.routes.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* @ai-summary Route definitions for backup API endpoints
|
||||
* @ai-context All routes require admin authentication via fastify.requireAdmin
|
||||
*/
|
||||
|
||||
import { FastifyInstance, FastifyPluginOptions } from 'fastify';
|
||||
import { Pool } from 'pg';
|
||||
import { BackupController } from './backup.controller';
|
||||
import {
|
||||
ListBackupsQuery,
|
||||
CreateBackupBody,
|
||||
BackupIdParam,
|
||||
RestoreBody,
|
||||
CreateScheduleBody,
|
||||
UpdateScheduleBody,
|
||||
ScheduleIdParam,
|
||||
UpdateSettingsBody,
|
||||
} from './backup.validation';
|
||||
|
||||
export async function registerBackupRoutes(
|
||||
fastify: FastifyInstance,
|
||||
opts: FastifyPluginOptions & { pool: Pool }
|
||||
): Promise<void> {
|
||||
const controller = new BackupController(opts.pool);
|
||||
|
||||
// ============================================
|
||||
// Backup Operations
|
||||
// ============================================
|
||||
|
||||
// GET /api/admin/backups - List all backups
|
||||
fastify.get<{ Querystring: ListBackupsQuery }>('/admin/backups', {
|
||||
preHandler: [(fastify as any).requireAdmin],
|
||||
handler: controller.listBackups.bind(controller),
|
||||
});
|
||||
|
||||
// POST /api/admin/backups - Create manual backup
|
||||
fastify.post<{ Body: CreateBackupBody }>('/admin/backups', {
|
||||
preHandler: [(fastify as any).requireAdmin],
|
||||
handler: controller.createBackup.bind(controller),
|
||||
});
|
||||
|
||||
// GET /api/admin/backups/:id - Get backup details
|
||||
fastify.get<{ Params: BackupIdParam }>('/admin/backups/:id', {
|
||||
preHandler: [(fastify as any).requireAdmin],
|
||||
handler: controller.getBackup.bind(controller),
|
||||
});
|
||||
|
||||
// GET /api/admin/backups/:id/download - Download backup file
|
||||
fastify.get<{ Params: BackupIdParam }>('/admin/backups/:id/download', {
|
||||
preHandler: [(fastify as any).requireAdmin],
|
||||
handler: controller.downloadBackup.bind(controller),
|
||||
});
|
||||
|
||||
// DELETE /api/admin/backups/:id - Delete backup
|
||||
fastify.delete<{ Params: BackupIdParam }>('/admin/backups/:id', {
|
||||
preHandler: [(fastify as any).requireAdmin],
|
||||
handler: controller.deleteBackup.bind(controller),
|
||||
});
|
||||
|
||||
// POST /api/admin/backups/upload - Upload backup file
|
||||
fastify.post('/admin/backups/upload', {
|
||||
preHandler: [(fastify as any).requireAdmin],
|
||||
handler: controller.uploadBackup.bind(controller),
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Restore Operations
|
||||
// ============================================
|
||||
|
||||
// POST /api/admin/backups/:id/restore/preview - Preview restore
|
||||
fastify.post<{ Params: BackupIdParam }>('/admin/backups/:id/restore/preview', {
|
||||
preHandler: [(fastify as any).requireAdmin],
|
||||
handler: controller.previewRestore.bind(controller),
|
||||
});
|
||||
|
||||
// POST /api/admin/backups/:id/restore - Execute restore
|
||||
fastify.post<{ Params: BackupIdParam; Body: RestoreBody }>('/admin/backups/:id/restore', {
|
||||
preHandler: [(fastify as any).requireAdmin],
|
||||
handler: controller.executeRestore.bind(controller),
|
||||
});
|
||||
|
||||
// GET /api/admin/backups/restore/status - Get restore status
|
||||
fastify.get('/admin/backups/restore/status', {
|
||||
preHandler: [(fastify as any).requireAdmin],
|
||||
handler: controller.getRestoreStatus.bind(controller),
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Schedule Operations
|
||||
// ============================================
|
||||
|
||||
// GET /api/admin/backups/schedules - List schedules
|
||||
fastify.get('/admin/backups/schedules', {
|
||||
preHandler: [(fastify as any).requireAdmin],
|
||||
handler: controller.listSchedules.bind(controller),
|
||||
});
|
||||
|
||||
// POST /api/admin/backups/schedules - Create schedule
|
||||
fastify.post<{ Body: CreateScheduleBody }>('/admin/backups/schedules', {
|
||||
preHandler: [(fastify as any).requireAdmin],
|
||||
handler: controller.createSchedule.bind(controller),
|
||||
});
|
||||
|
||||
// PUT /api/admin/backups/schedules/:id - Update schedule
|
||||
fastify.put<{ Params: ScheduleIdParam; Body: UpdateScheduleBody }>('/admin/backups/schedules/:id', {
|
||||
preHandler: [(fastify as any).requireAdmin],
|
||||
handler: controller.updateSchedule.bind(controller),
|
||||
});
|
||||
|
||||
// DELETE /api/admin/backups/schedules/:id - Delete schedule
|
||||
fastify.delete<{ Params: ScheduleIdParam }>('/admin/backups/schedules/:id', {
|
||||
preHandler: [(fastify as any).requireAdmin],
|
||||
handler: controller.deleteSchedule.bind(controller),
|
||||
});
|
||||
|
||||
// PATCH /api/admin/backups/schedules/:id/toggle - Toggle schedule
|
||||
fastify.patch<{ Params: ScheduleIdParam }>('/admin/backups/schedules/:id/toggle', {
|
||||
preHandler: [(fastify as any).requireAdmin],
|
||||
handler: controller.toggleSchedule.bind(controller),
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Settings Operations
|
||||
// ============================================
|
||||
|
||||
// GET /api/admin/backups/settings - Get settings
|
||||
fastify.get('/admin/backups/settings', {
|
||||
preHandler: [(fastify as any).requireAdmin],
|
||||
handler: controller.getSettings.bind(controller),
|
||||
});
|
||||
|
||||
// PUT /api/admin/backups/settings - Update settings
|
||||
fastify.put<{ Body: UpdateSettingsBody }>('/admin/backups/settings', {
|
||||
preHandler: [(fastify as any).requireAdmin],
|
||||
handler: controller.updateSettings.bind(controller),
|
||||
});
|
||||
}
|
||||
83
backend/src/features/backup/api/backup.validation.ts
Normal file
83
backend/src/features/backup/api/backup.validation.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @ai-summary Zod validation schemas for backup API endpoints
|
||||
* @ai-context Request validation for backup operations
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ============================================
|
||||
// Backup Operations
|
||||
// ============================================
|
||||
|
||||
export const listBackupsQuerySchema = z.object({
|
||||
page: z.coerce.number().int().positive().optional().default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).optional().default(20),
|
||||
status: z.enum(['in_progress', 'completed', 'failed']).optional(),
|
||||
backupType: z.enum(['scheduled', 'manual']).optional(),
|
||||
sortBy: z.enum(['startedAt', 'fileSizeBytes', 'status']).optional().default('startedAt'),
|
||||
sortOrder: z.enum(['asc', 'desc']).optional().default('desc'),
|
||||
});
|
||||
|
||||
export const createBackupBodySchema = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
includeDocuments: z.boolean().optional().default(true),
|
||||
});
|
||||
|
||||
export const backupIdParamSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Restore Operations
|
||||
// ============================================
|
||||
|
||||
export const restoreBodySchema = z.object({
|
||||
createSafetyBackup: z.boolean().optional().default(true),
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Schedule Operations
|
||||
// ============================================
|
||||
|
||||
export const createScheduleBodySchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
frequency: z.enum(['hourly', 'daily', 'weekly', 'monthly']),
|
||||
retentionCount: z.number().int().min(1).max(365).optional().default(7),
|
||||
isEnabled: z.boolean().optional().default(true),
|
||||
});
|
||||
|
||||
export const updateScheduleBodySchema = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
frequency: z.enum(['hourly', 'daily', 'weekly', 'monthly']).optional(),
|
||||
retentionCount: z.number().int().min(1).max(365).optional(),
|
||||
isEnabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const scheduleIdParamSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Settings Operations
|
||||
// ============================================
|
||||
|
||||
export const updateSettingsBodySchema = z.object({
|
||||
emailOnSuccess: z.boolean().optional(),
|
||||
emailOnFailure: z.boolean().optional(),
|
||||
adminEmail: z.string().email().or(z.literal('')).optional(),
|
||||
maxBackupSizeMb: z.number().int().min(100).max(10240).optional(),
|
||||
compressionEnabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Type Exports
|
||||
// ============================================
|
||||
|
||||
export type ListBackupsQuery = z.infer<typeof listBackupsQuerySchema>;
|
||||
export type CreateBackupBody = z.infer<typeof createBackupBodySchema>;
|
||||
export type BackupIdParam = z.infer<typeof backupIdParamSchema>;
|
||||
export type RestoreBody = z.infer<typeof restoreBodySchema>;
|
||||
export type CreateScheduleBody = z.infer<typeof createScheduleBodySchema>;
|
||||
export type UpdateScheduleBody = z.infer<typeof updateScheduleBodySchema>;
|
||||
export type ScheduleIdParam = z.infer<typeof scheduleIdParamSchema>;
|
||||
export type UpdateSettingsBody = z.infer<typeof updateSettingsBodySchema>;
|
||||
Reference in New Issue
Block a user