import { Controller, Post, Body, HttpCode, HttpStatus, Logger, Headers, UnauthorizedException, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiHeader } from '@nestjs/swagger'; import { ConfigService } from '@nestjs/config'; import { EventsGateway } from '../events/events.gateway'; import { PrismaService } from '../../database/prisma.service'; import { NotificationsService } from '../notifications/notifications.service'; import { Public } from '../../common/decorators'; import { ProjectStatus, RenderStage } from '@prisma/client'; // ── DTO Tanımları ────────────────────────────────────────── export class RenderProgressDto { projectId: string; renderJobId?: string; progress: number; stage: string; stageLabel: string; currentScene?: number; totalScenes?: number; eta?: number; stepDurationMs?: number; } export class RenderCompletedDto { projectId: string; renderJobId?: string; finalVideoUrl: string; thumbnailUrl?: string; processingTimeMs: number; fileSize: number; } export class RenderFailedDto { projectId: string; renderJobId?: string; error: string; stage: string; attemptNumber: number; canRetry?: boolean; } /** * Render Callback Controller * * C# Media Worker, render ilerlemesini bu endpoint'ler aracılığıyla bildirir. * Public endpoint — JWT gerekmez, bunun yerine API key ile korunur. * * Flow: C# Worker → HTTP POST → Bu controller → EventsGateway → Frontend (WebSocket) */ @ApiTags('render-callback') @Controller('render-callback') export class RenderCallbackController { private readonly logger = new Logger(RenderCallbackController.name); private readonly apiKey: string; constructor( private readonly eventsGateway: EventsGateway, private readonly db: PrismaService, private readonly configService: ConfigService, private readonly notificationsService: NotificationsService, ) { this.apiKey = this.configService.get( 'RENDER_CALLBACK_API_KEY', 'contgen-worker-secret-2026', ); } /** * API key doğrulaması — C# Worker'ın kimliğini kontrol eder. */ private validateApiKey(authHeader?: string) { const key = authHeader?.replace('Bearer ', ''); if (key !== this.apiKey) { throw new UnauthorizedException('Geçersiz worker API key'); } } /** * Render ilerleme bildirimi. * C# Worker her aşamada bu endpoint'i çağırır. */ @Public() @Post('progress') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Render ilerleme bildirimi (Worker → API)' }) @ApiHeader({ name: 'Authorization', description: 'Bearer {WORKER_API_KEY}' }) @ApiResponse({ status: 200, description: 'İlerleme kaydedildi' }) async reportProgress( @Headers('authorization') authHeader: string, @Body() body: RenderProgressDto, ) { this.validateApiKey(authHeader); this.logger.log( `Progress: ${body.projectId} — %${body.progress} [${body.stage}] sahne ${body.currentScene}/${body.totalScenes}`, ); // DB'de proje progress'ini güncelle await this.db.project.update({ where: { id: body.projectId }, data: { progress: body.progress, status: this.mapStageToStatus(body.stage) as ProjectStatus, }, }); // RenderJob log yaz if (body.renderJobId) { await this.db.renderLog.create({ data: { renderJobId: body.renderJobId, stage: body.stage as RenderStage, level: 'INFO', message: body.stageLabel || `${body.stage} — %${body.progress}`, durationMs: body.stepDurationMs, }, }); } // WebSocket ile frontend'e bildir this.eventsGateway.emitRenderProgress(body.projectId, { progress: body.progress, stage: body.stage, stageLabel: body.stageLabel, currentScene: body.currentScene, totalScenes: body.totalScenes, eta: body.eta, }); return { status: 'ok', progress: body.progress }; } /** * Render tamamlandı bildirimi. */ @Public() @Post('completed') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Render tamamlandı bildirimi (Worker → API)' }) @ApiHeader({ name: 'Authorization', description: 'Bearer {WORKER_API_KEY}' }) async reportCompleted( @Headers('authorization') authHeader: string, @Body() body: RenderCompletedDto, ) { this.validateApiKey(authHeader); this.logger.log( `COMPLETED: ${body.projectId} — video: ${body.finalVideoUrl} (${body.processingTimeMs}ms)`, ); // Proje durumunu güncelle await this.db.project.update({ where: { id: body.projectId }, data: { status: 'COMPLETED', progress: 100, finalVideoUrl: body.finalVideoUrl, thumbnailUrl: body.thumbnailUrl || null, completedAt: new Date(), }, }); // RenderJob durumunu güncelle if (body.renderJobId) { await this.db.renderJob.update({ where: { id: body.renderJobId }, data: { status: 'COMPLETED', finalVideoUrl: body.finalVideoUrl, processingTimeMs: body.processingTimeMs, completedAt: new Date(), }, }); await this.db.renderLog.create({ data: { renderJobId: body.renderJobId, stage: 'FINALIZATION' as RenderStage, level: 'INFO', message: `Video tamamlandı — ${body.processingTimeMs}ms, ${body.fileSize} bytes`, durationMs: body.processingTimeMs, }, }); } // WebSocket ile frontend'e bildir this.eventsGateway.emitRenderCompleted(body.projectId, { finalVideoUrl: body.finalVideoUrl, thumbnailUrl: body.thumbnailUrl, processingTimeMs: body.processingTimeMs, fileSize: body.fileSize, }); this.eventsGateway.emitProjectStatusChanged(body.projectId, 'COMPLETED'); // Proje sahibine in-app bildirim gönder const project = await this.db.project.findUnique({ where: { id: body.projectId }, select: { userId: true, title: true }, }); if (project) { await this.notificationsService.createNotification({ userId: project.userId, type: 'render_complete', title: 'Video hazır! 🎬', message: `"${project.title}" videonuz başarıyla oluşturuldu.`, metadata: { projectId: body.projectId, renderJobId: body.renderJobId, finalVideoUrl: body.finalVideoUrl, }, }); } return { status: 'ok', projectId: body.projectId }; } /** * Render hatası bildirimi. */ @Public() @Post('failed') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Render hatası bildirimi (Worker → API)' }) @ApiHeader({ name: 'Authorization', description: 'Bearer {WORKER_API_KEY}' }) async reportFailed( @Headers('authorization') authHeader: string, @Body() body: RenderFailedDto, ) { this.validateApiKey(authHeader); this.logger.error( `FAILED: ${body.projectId} — ${body.error} [${body.stage}] attempt: ${body.attemptNumber}`, ); // Proje durumunu güncelle await this.db.project.update({ where: { id: body.projectId }, data: { status: 'FAILED', errorMessage: body.error, }, }); // RenderJob durumunu güncelle if (body.renderJobId) { await this.db.renderJob.update({ where: { id: body.renderJobId }, data: { status: 'FAILED', errorMessage: body.error, completedAt: new Date(), }, }); await this.db.renderLog.create({ data: { renderJobId: body.renderJobId, stage: body.stage as RenderStage, level: 'ERROR', message: body.error, }, }); } // WebSocket ile frontend'e bildir this.eventsGateway.emitRenderFailed(body.projectId, { error: body.error, stage: body.stage, attemptNumber: body.attemptNumber, canRetry: body.canRetry ?? true, }); this.eventsGateway.emitProjectStatusChanged(body.projectId, 'FAILED'); // Proje sahibine in-app bildirim gönder const project = await this.db.project.findUnique({ where: { id: body.projectId }, select: { userId: true, title: true }, }); if (project) { await this.notificationsService.createNotification({ userId: project.userId, type: 'render_failed', title: 'Video oluşturulamadı ⚠️', message: `"${project.title}" işlenirken hata oluştu: ${body.error}`, metadata: { projectId: body.projectId, renderJobId: body.renderJobId, stage: body.stage, canRetry: body.canRetry ?? true, }, }); } return { status: 'ok', projectId: body.projectId }; } private mapStageToStatus(stage: string): string { switch (stage) { case 'tts': case 'image_generation': case 'music_generation': return 'GENERATING_MEDIA'; case 'compositing': case 'encoding': return 'RENDERING'; default: return 'GENERATING_MEDIA'; } } }