generated from fahricansecer/boilerplate-be
322 lines
9.1 KiB
TypeScript
322 lines
9.1 KiB
TypeScript
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<string>(
|
||
'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';
|
||
}
|
||
}
|
||
}
|