Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
/node_modules
/build

package-lock.json

# Logs
logs
*.log
Expand Down
944 changes: 731 additions & 213 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
},
"dependencies": {
"@aws-sdk/client-cloudfront": "^3.975.0",
"@aws-sdk/client-kms": "^3.978.0",
"@aws-sdk/client-s3": "^3.975.0",
"@elastic/elasticsearch": "^8.19.1",
"@langchain/community": "^0.3.55",
Expand Down Expand Up @@ -73,7 +74,7 @@
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pdf-parse": "^1.1.1",
"pg": "^8.16.0",
"pg": "^8.17.2",
"prom-client": "^15.1.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
Expand Down
6 changes: 5 additions & 1 deletion src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { MonitoringInterceptor } from './common/interceptors/monitoring.intercep
import { TypeOrmMonitoringLogger } from './monitoring/logging/typeorm-logger';
import { MetricsCollectionService } from './monitoring/metrics/metrics-collection.service';
import { SyncModule } from './sync/sync.module';
import { MediaModule } from './media/media.module';
import { BackupModule } from './backup/backup.module';
import { BullModule } from '@nestjs/bull';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { CacheModule } from '@nestjs/cache-manager';
Expand All @@ -32,7 +34,7 @@ import * as redisStore from 'cache-manager-redis-store';
username: process.env.DB_USERNAME || 'postgres',
password: process.env.DB_PASSWORD || 'postgres',
database: process.env.DB_DATABASE || 'teachlink',
entities: [],
autoLoadEntities: true,
synchronize: process.env.NODE_ENV !== 'production',
logging: true,
logger: new TypeOrmMonitoringLogger(metricsService),
Expand All @@ -54,6 +56,8 @@ import * as redisStore from 'cache-manager-redis-store';
port: parseInt(process.env.REDIS_PORT || '6379'),
}),
SyncModule,
MediaModule,
BackupModule,
],
controllers: [AppController],
providers: [
Expand Down
61 changes: 61 additions & 0 deletions src/backup/backup.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {
Controller,
Post,
Get,
Param,
Body,
UseGuards,
ParseUUIDPipe,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { RecoveryTestingService } from './testing/recovery-testing.service';
import { DisasterRecoveryService } from './disaster-recovery/disaster-recovery.service';
import { BackupMonitoringService } from './monitoring/backup-monitoring.service';
import { RestoreBackupDto } from './dto/restore-backup.dto';
import { TriggerRecoveryTestDto } from './dto/trigger-recovery-test.dto';
import { RecoveryTestResponseDto } from './dto/recovery-test-response.dto';

@ApiTags('backup')
@ApiBearerAuth()
@Controller('backup')
export class BackupController {
constructor(
private readonly recoveryTestingService: RecoveryTestingService,
private readonly disasterRecoveryService: DisasterRecoveryService,
private readonly backupMonitoringService: BackupMonitoringService,
) {}

@Post('restore')
@ApiOperation({ summary: 'Restore from backup' })
@HttpCode(HttpStatus.ACCEPTED)
async restoreBackup(
@Body() dto: RestoreBackupDto,
): Promise<{ message: string }> {
await this.disasterRecoveryService.executeRestore(dto.backupRecordId);
return { message: 'Restore initiated' };
}

@Post('test')
@ApiOperation({ summary: 'Trigger recovery test' })
async triggerRecoveryTest(
@Body() dto: TriggerRecoveryTestDto,
): Promise<RecoveryTestResponseDto> {
return this.recoveryTestingService.createRecoveryTest(dto.backupRecordId);
}

@Get('test/:testId')
@ApiOperation({ summary: 'Get recovery test results' })
async getRecoveryTest(
@Param('testId', ParseUUIDPipe) testId: string,
): Promise<RecoveryTestResponseDto> {
return this.recoveryTestingService.getTestResults(testId);
}

@Get('health')
@ApiOperation({ summary: 'Get backup system health' })
async getBackupHealth(): Promise<{ healthy: boolean; issues: string[] }> {
return this.backupMonitoringService.checkBackupHealth();
}
}
50 changes: 50 additions & 0 deletions src/backup/backup.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BullModule } from '@nestjs/bull';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';

// Entities
import { BackupRecord } from './entities/backup-record.entity';
import { RecoveryTest } from './entities/recovery-test.entity';

// Services
import { BackupService } from './backup.service';
import { DisasterRecoveryService } from './disaster-recovery/disaster-recovery.service';
import { DataIntegrityService } from './integrity/data-integrity.service';
import { RecoveryTestingService } from './testing/recovery-testing.service';
import { BackupMonitoringService } from './monitoring/backup-monitoring.service';

// Controller
import { BackupController } from './backup.controller';

// Processor
import { BackupQueueProcessor } from './processing/backup-queue.processor';

// External modules
import { MediaModule } from '../media/media.module';
import { MonitoringModule } from '../monitoring/monitoring.module';

@Module({
imports: [
ConfigModule,
ScheduleModule.forRoot(),
TypeOrmModule.forFeature([BackupRecord, RecoveryTest]),
BullModule.registerQueue({
name: 'backup-processing',
}),
MediaModule, // For FileStorageService
MonitoringModule, // For AlertingService and MetricsCollectionService
],
controllers: [BackupController],
providers: [
BackupService,
DisasterRecoveryService,
DataIntegrityService,
RecoveryTestingService,
BackupMonitoringService,
BackupQueueProcessor,
],
exports: [BackupService, DisasterRecoveryService],
})
export class BackupModule {}
196 changes: 196 additions & 0 deletions src/backup/backup.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, LessThan } from 'typeorm';
import { InjectQueue } from '@nestjs/bull';
import { Queue } from 'bull';
import { ConfigService } from '@nestjs/config';
import { Cron, CronExpression } from '@nestjs/schedule';
import { BackupRecord } from './entities/backup-record.entity';
import { BackupStatus } from './enums/backup-status.enum';
import { BackupType } from './enums/backup-type.enum';
import { Region } from './enums/region.enum';
import { BackupResponseDto } from './dto/backup-response.dto';
import { BackupJobData } from './interfaces/backup.interfaces';
import { AlertingService } from '../monitoring/alerting/alerting.service';
import { MetricsCollectionService } from '../monitoring/metrics/metrics-collection.service';

@Injectable()
export class BackupService {
private readonly logger = new Logger(BackupService.name);
private readonly retentionDays: number;

constructor(
@InjectRepository(BackupRecord)
private readonly backupRepository: Repository<BackupRecord>,
@InjectQueue('backup-processing')
private readonly backupQueue: Queue,
private readonly configService: ConfigService,
private readonly alertingService: AlertingService,
private readonly metricsService: MetricsCollectionService,
) {
this.retentionDays = this.configService.get<number>(
'BACKUP_RETENTION_DAYS',
30,
);
}

/**
* Scheduled weekly backup (every Sunday at 2 AM UTC)
*/
@Cron('0 2 * * 0', {
name: 'weekly-database-backup',
timeZone: 'UTC',
})
async handleScheduledBackup(): Promise<void> {
this.logger.log('Starting scheduled weekly backup');

try {
const region =
(this.configService.get<string>(
'BACKUP_PRIMARY_REGION',
) as Region) || Region.US_EAST_1;
const databaseName = this.configService.get<string>(
'DB_DATABASE',
'teachlink',
);

const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + this.retentionDays);

const backupRecord = this.backupRepository.create({
backupType: BackupType.FULL,
status: BackupStatus.PENDING,
region,
databaseName,
storageKey: '',
expiresAt,
metadata: {
startTime: new Date(),
},
});

await this.backupRepository.save(backupRecord);

// Queue backup job
await this.backupQueue.add(
'create-backup',
{
backupRecordId: backupRecord.id,
backupType: BackupType.FULL,
region,
databaseName,
} as BackupJobData,
{
attempts: 3,
backoff: {
type: 'exponential',
delay: 10000,
},
timeout: 3600000, // 1 hour timeout
},
);

this.logger.log(`Scheduled backup ${backupRecord.id} queued`);
} catch (error) {
this.logger.error('Failed to initiate scheduled backup:', error);
this.alertingService.sendAlert(
'BACKUP_SCHEDULED_FAILED',
`Scheduled backup failed: ${error.message}`,
'CRITICAL',
);
}
}

/**
* Cleanup expired backups (daily at 3 AM UTC)
*/
@Cron('0 3 * * *', {
name: 'cleanup-expired-backups',
timeZone: 'UTC',
})
async handleBackupCleanup(): Promise<void> {
this.logger.log('Starting backup cleanup job');

const expirationDate = new Date();
expirationDate.setDate(expirationDate.getDate() - this.retentionDays);

const expiredBackups = await this.backupRepository.find({
where: {
createdAt: LessThan(expirationDate),
status: BackupStatus.COMPLETED,
},
});

this.logger.log(
`Found ${expiredBackups.length} expired backups to cleanup`,
);

for (const backup of expiredBackups) {
await this.backupQueue.add(
'delete-backup',
{ backupRecordId: backup.id },
{
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
},
);
}
}

async getLatestBackup(region?: Region): Promise<BackupRecord | null> {
const where: any = {
status: BackupStatus.COMPLETED,
integrityVerified: true,
};
if (region) {
where.region = region;
}

return this.backupRepository.findOne({
where,
order: { completedAt: 'DESC' },
});
}

async updateBackupStatus(
backupId: string,
status: BackupStatus,
updates: Partial<BackupRecord> = {},
): Promise<void> {
await this.backupRepository.update(backupId, {
status,
...updates,
updatedAt: new Date(),
});

if (status === BackupStatus.COMPLETED) {
this.alertingService.sendAlert(
'BACKUP_COMPLETED',
`Backup ${backupId} completed successfully`,
'INFO',
);
} else if (status === BackupStatus.FAILED) {
this.alertingService.sendAlert(
'BACKUP_FAILED',
`Backup ${backupId} failed: ${updates.errorMessage}`,
'CRITICAL',
);
}
}

toResponseDto(backup: BackupRecord): BackupResponseDto {
return {
id: backup.id,
backupType: backup.backupType,
status: backup.status,
region: backup.region,
databaseName: backup.databaseName,
backupSizeBytes: backup.backupSizeBytes,
integrityVerified: backup.integrityVerified,
completedAt: backup.completedAt,
expiresAt: backup.expiresAt,
createdAt: backup.createdAt,
metadata: backup.metadata,
};
}
}
Loading