main
Some checks failed
Backend Deploy 🚀 / build-and-deploy (push) Has been cancelled

This commit is contained in:
Harun CAN
2026-03-30 00:21:32 +03:00
parent 85c35c73e8
commit acb103657b
29 changed files with 11473 additions and 13081 deletions

View File

@@ -6,18 +6,21 @@ WORKDIR /app
# Raspberry Pi ve Prisma uyumluluğu için gerekli kütüphaneler
RUN apk add --no-cache openssl libc6-compat
# pnpm kurulumu (workspace kuralı gereği)
RUN corepack enable && corepack prepare pnpm@latest --activate
# Paket dosyalarını kopyala
COPY package*.json ./
RUN npm ci
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# Kaynak kodları kopyala
COPY . .
# Prisma client üret (Database şeman için şart)
# Prisma client üret (Database şeması için şart)
RUN npx prisma generate
# Build al (NestJS/Backend için)
RUN npm run build
RUN pnpm build
# --- Production Stage (Canlı Sistem) ---
FROM node:20-alpine AS production
@@ -25,18 +28,21 @@ FROM node:20-alpine AS production
# Prisma için gerekli kütüphaneleri buraya da ekliyoruz
RUN apk add --no-cache openssl libc6-compat
# pnpm kurulumu
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package*.json ./
COPY package.json pnpm-lock.yaml ./
# Sadece production (canlıda lazım olan) paketleri kur
RUN npm ci --only=production
RUN pnpm install --frozen-lockfile --prod
# Prisma şemasını taşı ve client üret
COPY prisma ./prisma
RUN npx prisma generate
# Build edilen dosyaları taşı (Senin Dockerfile'ındaki yapıya sadık kaldım)
# Build edilen dosyaları taşı
# Güvenlik için dosyaları 'node' kullanıcısına zimmetliyoruz
COPY --chown=node:node --from=builder /app/dist ./dist

12961
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,8 +16,12 @@
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
"test:e2e": "jest --config ./test/jest-e2e.json",
"db:seed": "npx prisma db seed",
"db:migrate": "npx prisma migrate deploy",
"db:reset": "npx prisma migrate reset --force"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.964.0",
"@google/genai": "^1.35.0",
@@ -33,6 +37,7 @@
"@nestjs/swagger": "^11.2.4",
"@nestjs/terminus": "^11.0.0",
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.17",
"@prisma/client": "^5.22.0",
"bcrypt": "^6.0.0",
"bullmq": "^5.66.4",
@@ -52,6 +57,7 @@
"prisma": "^5.22.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"socket.io": "^4.8.3",
"stripe": "^21.0.1",
"zod": "^4.3.5"
},
@@ -100,5 +106,8 @@
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
},
"prisma": {
"seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
}
}

9165
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

290
prisma/seed.ts Normal file
View File

@@ -0,0 +1,290 @@
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcrypt';
const prisma = new PrismaClient();
async function main() {
console.log('🌱 ContentGen AI — Seed Data Yükleniyor...\n');
// ── 1. Roller ──────────────────────────────────────────────────────
console.log('👤 Roller oluşturuluyor...');
const adminRole = await prisma.role.upsert({
where: { name: 'admin' },
update: {},
create: {
name: 'admin',
description: 'Tam yetkili yönetici — sınırsız erişim',
isSystem: true,
},
});
const userRole = await prisma.role.upsert({
where: { name: 'user' },
update: {},
create: {
name: 'user',
description: 'Standart kullanıcı rolü',
isSystem: true,
},
});
console.log(` ✅ admin (${adminRole.id})`);
console.log(` ✅ user (${userRole.id})`);
// ── 2. İzinler ─────────────────────────────────────────────────────
console.log('\n🔑 İzinler oluşturuluyor...');
const permissionDefs = [
// Projects
{ name: 'projects:create', resource: 'projects', action: 'create', description: 'Proje oluşturabilir' },
{ name: 'projects:read', resource: 'projects', action: 'read', description: 'Projeleri görüntüleyebilir' },
{ name: 'projects:update', resource: 'projects', action: 'update', description: 'Proje düzenleyebilir' },
{ name: 'projects:delete', resource: 'projects', action: 'delete', description: 'Proje silebilir' },
// Templates
{ name: 'templates:read', resource: 'templates', action: 'read', description: 'Şablonları görüntüleyebilir' },
{ name: 'templates:create', resource: 'templates', action: 'create', description: 'Şablon oluşturabilir' },
{ name: 'templates:manage', resource: 'templates', action: 'manage', description: 'Şablonları yönetebilir' },
// Billing
{ name: 'billing:read', resource: 'billing', action: 'read', description: 'Fatura bilgilerini görüntüleyebilir' },
{ name: 'billing:manage', resource: 'billing', action: 'manage', description: 'Abonelik ve ödeme yönetimi' },
// Admin
{ name: 'admin:access', resource: 'admin', action: 'access', description: 'Admin paneline erişebilir' },
{ name: 'admin:users', resource: 'admin', action: 'users', description: 'Kullanıcıları yönetebilir' },
{ name: 'admin:system', resource: 'admin', action: 'system', description: 'Sistem ayarlarını yönetebilir' },
// Notifications
{ name: 'notifications:read', resource: 'notifications', action: 'read', description: 'Bildirimleri görüntüleyebilir' },
{ name: 'notifications:manage', resource: 'notifications', action: 'manage', description: 'Bildirimleri yönetebilir' },
];
const permissions: Record<string, any> = {};
for (const perm of permissionDefs) {
const p = await prisma.permission.upsert({
where: { resource_action: { resource: perm.resource, action: perm.action } },
update: { description: perm.description },
create: perm,
});
permissions[perm.name] = p;
console.log(`${perm.name}`);
}
// ── 3. Rol-İzin Eşlemeleri ─────────────────────────────────────────
console.log('\n🔗 Rol-İzin eşlemeleri oluşturuluyor...');
// Admin: Tüm izinler
for (const perm of Object.values(permissions)) {
await prisma.rolePermission.upsert({
where: { roleId_permissionId: { roleId: adminRole.id, permissionId: perm.id } },
update: {},
create: { roleId: adminRole.id, permissionId: perm.id },
});
}
console.log(` ✅ admin → ${Object.keys(permissions).length} izin`);
// User: Temel izinler
const userPermNames = [
'projects:create', 'projects:read', 'projects:update', 'projects:delete',
'templates:read', 'billing:read', 'billing:manage', 'notifications:read',
];
for (const permName of userPermNames) {
const perm = permissions[permName];
if (perm) {
await prisma.rolePermission.upsert({
where: { roleId_permissionId: { roleId: userRole.id, permissionId: perm.id } },
update: {},
create: { roleId: userRole.id, permissionId: perm.id },
});
}
}
console.log(` ✅ user → ${userPermNames.length} izin`);
// ── 4. Planlar ─────────────────────────────────────────────────────
console.log('\n💳 Planlar oluşturuluyor...');
const plans = [
{
name: 'free',
displayName: 'Free',
description: 'Başlangıç planı — temel video üretimi',
monthlyPrice: 0,
yearlyPrice: 0,
currency: 'usd',
monthlyCredits: 3,
maxDuration: 30,
maxResolution: '720p',
maxProjects: 5,
sortOrder: 0,
features: {
templates: true,
priorityQueue: false,
customBranding: false,
apiAccess: false,
analytics: false,
support: 'community',
},
},
{
name: 'pro',
displayName: 'Pro',
description: 'Profesyonel içerik üreticileri için — gelişmiş özellikler',
monthlyPrice: 1900, // $19.00
yearlyPrice: 19000, // $190.00 (yıllık ~%17 indirim)
currency: 'usd',
monthlyCredits: 25,
maxDuration: 120,
maxResolution: '1080p',
maxProjects: 50,
sortOrder: 1,
features: {
templates: true,
priorityQueue: true,
customBranding: true,
apiAccess: false,
analytics: true,
support: 'email',
},
},
{
name: 'business',
displayName: 'Business',
description: 'Ajanslar ve ekipler için — sınırsız üretim kapasitesi',
monthlyPrice: 4900, // $49.00
yearlyPrice: 49000, // $490.00 (yıllık ~%17 indirim)
currency: 'usd',
monthlyCredits: 100,
maxDuration: 300,
maxResolution: '4k',
maxProjects: 500,
sortOrder: 2,
features: {
templates: true,
priorityQueue: true,
customBranding: true,
apiAccess: true,
analytics: true,
support: 'priority',
},
},
];
for (const plan of plans) {
await prisma.plan.upsert({
where: { name: plan.name },
update: {
displayName: plan.displayName,
description: plan.description,
monthlyPrice: plan.monthlyPrice,
yearlyPrice: plan.yearlyPrice,
monthlyCredits: plan.monthlyCredits,
maxDuration: plan.maxDuration,
maxResolution: plan.maxResolution,
maxProjects: plan.maxProjects,
features: plan.features,
sortOrder: plan.sortOrder,
},
create: plan,
});
console.log(`${plan.displayName}${plan.monthlyCredits} kredi/ay, $${(plan.monthlyPrice / 100).toFixed(0)}/ay`);
}
// ── 5. Admin Kullanıcı — haruncan@gmail.com ────────────────────────
console.log('\n👑 Admin kullanıcı oluşturuluyor...');
const adminPassword = await bcrypt.hash('Admin123!', 12);
const adminEmail = 'haruncan@gmail.com';
const adminUser = await prisma.user.upsert({
where: { email: adminEmail },
update: {},
create: {
email: adminEmail,
password: adminPassword,
firstName: 'Harun',
lastName: 'Can',
isActive: true,
roles: {
create: {
roleId: adminRole.id,
},
},
},
});
console.log(`${adminEmail} (${adminUser.id})`);
// Admin kullanıcıya sınırsız kredi ver (999999 başlangıç kredisi)
const existingGrant = await prisma.creditTransaction.findFirst({
where: { userId: adminUser.id, type: 'grant', description: 'Admin başlangıç kredisi' },
});
if (!existingGrant) {
await prisma.creditTransaction.create({
data: {
userId: adminUser.id,
amount: 999999,
type: 'grant',
description: 'Admin başlangıç kredisi — sınırsız',
balanceAfter: 999999,
},
});
console.log(' ✅ 999.999 kredi yüklendi (sınırsız)');
} else {
console.log(' ⏭️ Kredi zaten mevcut');
}
// Admin kullanıcıya Business planına aktif abonelik oluştur
const businessPlan = await prisma.plan.findUnique({ where: { name: 'business' } });
if (businessPlan) {
const existingSub = await prisma.subscription.findFirst({
where: { userId: adminUser.id, status: 'active' },
});
if (!existingSub) {
await prisma.subscription.create({
data: {
userId: adminUser.id,
planId: businessPlan.id,
status: 'active',
currentPeriodStart: new Date(),
currentPeriodEnd: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 yıl
},
});
console.log(' ✅ Business plan aboneliği oluşturuldu (sınırsız)');
} else {
console.log(' ⏭️ Aktif abonelik zaten mevcut');
}
}
// Admin tercihlerini oluştur
await prisma.userPreference.upsert({
where: { userId: adminUser.id },
update: {},
create: {
userId: adminUser.id,
defaultLanguage: 'tr',
defaultVideoStyle: 'CINEMATIC',
defaultDuration: 60,
theme: 'dark',
emailNotifications: true,
pushNotifications: true,
},
});
console.log(' ✅ Kullanıcı tercihleri ayarlandı');
// ── Özet ────────────────────────────────────────────────────────────
console.log('\n═══════════════════════════════════════════════════════════');
console.log('🎉 Seed data başarıyla yüklendi!');
console.log(` 📁 Roller: ${2}`);
console.log(` 🔑 İzinler: ${Object.keys(permissions).length}`);
console.log(` 💳 Planlar: ${plans.length}`);
console.log(` 👤 Admin: ${adminEmail}`);
console.log('═══════════════════════════════════════════════════════════\n');
}
main()
.catch((e) => {
console.error('❌ Seed hatası:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -48,6 +48,10 @@ import { VideoAiModule } from './modules/video-ai/video-ai.module';
import { StorageModule } from './modules/storage/storage.module';
import { BillingModule } from './modules/billing/billing.module';
import { XTwitterModule } from './modules/x-twitter/x-twitter.module';
import { EventsModule } from './modules/events/events.module';
import { RenderCallbackModule } from './modules/render-callback/render-callback.module';
import { DashboardModule } from './modules/dashboard/dashboard.module';
import { NotificationsModule } from './modules/notifications/notifications.module';
// Guards
import {
@@ -190,6 +194,12 @@ import {
StorageModule,
BillingModule,
XTwitterModule,
// Real-time & Callback Modules
EventsModule,
RenderCallbackModule,
DashboardModule,
NotificationsModule,
],
providers: [
// Global Exception Filter

View File

@@ -5,13 +5,18 @@ import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';
import helmet from 'helmet';
import { Logger, LoggerErrorInterceptor } from 'nestjs-pino';
import * as express from 'express';
import * as path from 'path';
async function bootstrap() {
const logger = new NestLogger('Bootstrap');
logger.log('🔄 Starting application...');
logger.log('🔄 ContentGen AI başlatılıyor...');
const app = await NestFactory.create(AppModule, { bufferLogs: true });
const app = await NestFactory.create(AppModule, {
bufferLogs: true,
rawBody: true, // Stripe webhook imza doğrulaması için gerekli
});
// Use Pino Logger
app.useLogger(app.get(Logger));
@@ -31,6 +36,18 @@ async function bootstrap() {
const port = configService.get<number>('PORT', 3000);
const nodeEnv = configService.get('NODE_ENV', 'development');
// ── Static File Serving — Medya dosyalarına HTTP erişim ──
const mediaPath = configService.get<string>('STORAGE_LOCAL_PATH', './data/media');
const absoluteMediaPath = path.resolve(mediaPath);
app.use('/media', express.static(absoluteMediaPath, {
maxAge: '1d',
etag: true,
lastModified: true,
index: false,
dotfiles: 'deny',
}));
logger.log(`📂 Medya dizini: ${absoluteMediaPath} → /media/*`);
// Enable CORS
app.enableCors({
origin: true,
@@ -54,39 +71,45 @@ async function bootstrap() {
// Swagger setup
const swaggerConfig = new DocumentBuilder()
.setTitle('TypeScript Boilerplate API')
.setTitle('ContentGen AI — Video Generation SaaS API')
.setDescription(
'Senior-level NestJS backend boilerplate with generic CRUD, authentication, i18n, and Redis caching',
'AI destekli video üretim platformu. Senaryo oluşturma, medya üretimi, render pipeline ve billing yönetimi.',
)
.setVersion('1.0')
.setVersion('1.0.0')
.addBearerAuth()
.addTag('Auth', 'Authentication endpoints')
.addTag('Users', 'User management endpoints')
.addTag('Admin', 'Admin management endpoints')
.addTag('Health', 'Health check endpoints')
.addTag('Auth', 'Kimlik doğrulama')
.addTag('Users', 'Kullanıcı yönetimi')
.addTag('Projects', 'Proje ve senaryo yönetimi')
.addTag('Dashboard', 'İstatistikler ve grafikler')
.addTag('Billing', 'Abonelik ve kredi yönetimi')
.addTag('Templates', 'Şablon pazaryeri')
.addTag('Notifications', 'Bildirim yönetimi')
.addTag('Admin', 'Yönetici paneli')
.addTag('Health', 'Sistem sağlık kontrolü')
.build();
logger.log('Initializing Swagger...');
logger.log('Swagger başlatılıyor...');
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('api/docs', app, document, {
swaggerOptions: {
persistAuthorization: true,
},
});
logger.log('Swagger initialized');
logger.log('Swagger hazır');
logger.log(`Attempting to listen on port ${port}...`);
logger.log(`Port ${port} üzerinde dinleniyor...`);
await app.listen(port, '0.0.0.0');
logger.log('═══════════════════════════════════════════════════════════');
logger.log(`🚀 Server is running on: http://localhost:${port}/api`);
logger.log(`📚 Swagger documentation: http://localhost:${port}/api/docs`);
logger.log(`💚 Health check: http://localhost:${port}/api/health`);
logger.log(`🌍 Environment: ${nodeEnv.toUpperCase()}`);
logger.log(`🚀 ContentGen AI API: http://localhost:${port}/api`);
logger.log(`📚 Swagger Docs: http://localhost:${port}/api/docs`);
logger.log(`💚 Health Check: http://localhost:${port}/api/health`);
logger.log(`📂 Medya Dosyaları: http://localhost:${port}/media/`);
logger.log(`🌍 Ortam: ${nodeEnv.toUpperCase()}`);
logger.log('═══════════════════════════════════════════════════════════');
if (nodeEnv === 'development') {
logger.warn('⚠️ Running in development mode');
logger.warn('⚠️ Geliştirme modunda çalışıyor');
}
}

View File

@@ -35,6 +35,7 @@ import {
RoleResponseDto,
UserRoleResponseDto,
} from './dto/admin.dto';
import { AdminService } from './admin.service';
@ApiTags('Admin')
@ApiBearerAuth()
@@ -43,9 +44,61 @@ import {
export class AdminController {
constructor(
private readonly prisma: PrismaService,
private readonly adminService: AdminService,
@Inject(CACHE_MANAGER) private cacheManager: cacheManager.Cache,
) {}
// ================== System Stats ==================
@Get('stats')
@ApiOperation({ summary: 'Sistem istatistiklerini getir' })
async getSystemStats(): Promise<ApiResponse<any>> {
const stats = await this.adminService.getSystemStats();
return createSuccessResponse(stats);
}
// ================== Plan Management ==================
@Get('plans')
@ApiOperation({ summary: 'Tüm planları getir (admin detay)' })
async getAllPlans(): Promise<ApiResponse<any>> {
const plans = await this.adminService.getAllPlans();
return createSuccessResponse(plans);
}
@Put('plans/:id')
@ApiOperation({ summary: 'Plan güncelle' })
async updatePlan(
@Param('id') id: string,
@Body() data: any,
): Promise<ApiResponse<any>> {
const plan = await this.adminService.updatePlan(id, data);
return createSuccessResponse(plan, 'Plan güncellendi');
}
// ================== Credit Management ==================
@Post('users/:userId/credits')
@ApiOperation({ summary: 'Kullanıcıya kredi ver' })
async grantCredits(
@Param('userId') userId: string,
@Body() data: { amount: number; description: string },
): Promise<ApiResponse<any>> {
const tx = await this.adminService.grantCredits(userId, data.amount, data.description);
return createSuccessResponse(tx, 'Kredi yüklendi');
}
// ================== User Detail ==================
@Get('users/:id/detail')
@ApiOperation({ summary: 'Kullanıcı detay — abonelik, projeler, krediler' })
async getUserDetail(
@Param('id') id: string,
): Promise<ApiResponse<any>> {
const user = await this.adminService.getUserDetail(id);
return createSuccessResponse(user);
}
// ================== Users Management ==================
@Get('users')
@@ -268,3 +321,4 @@ export class AdminController {
return createSuccessResponse(null, 'Permission removed from role');
}
}

View File

@@ -1,7 +1,13 @@
import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
import { StorageModule } from '../storage/storage.module';
@Module({
imports: [StorageModule],
controllers: [AdminController],
providers: [AdminService],
exports: [AdminService],
})
export class AdminModule {}

View File

@@ -0,0 +1,166 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
import { StorageService } from '../storage/storage.service';
@Injectable()
export class AdminService {
private readonly logger = new Logger(AdminService.name);
constructor(
private readonly prisma: PrismaService,
private readonly storageService: StorageService,
) {}
// ── Sistem İstatistikleri ──────────────────────────────────────────
async getSystemStats() {
const [
totalUsers,
activeUsers,
totalProjects,
totalPlans,
storageStats,
recentUsers,
projectsByStatus,
] = await Promise.all([
this.prisma.user.count(),
this.prisma.user.count({ where: { isActive: true } }),
this.prisma.project.count(),
this.prisma.plan.count(),
this.storageService.getStorageStats().catch(() => null),
this.prisma.user.findMany({
take: 5,
orderBy: { createdAt: 'desc' },
select: { id: true, email: true, firstName: true, lastName: true, createdAt: true },
}),
this.prisma.project.groupBy({
by: ['status'],
_count: { id: true },
}),
]);
// Kredi istatistikleri
const creditStats = await this.prisma.creditTransaction.aggregate({
_sum: { amount: true },
where: { type: 'grant' },
});
const creditUsed = await this.prisma.creditTransaction.aggregate({
_sum: { amount: true },
where: { type: 'usage' },
});
return {
users: {
total: totalUsers,
active: activeUsers,
inactive: totalUsers - activeUsers,
},
projects: {
total: totalProjects,
byStatus: projectsByStatus.reduce((acc, item) => {
acc[item.status] = item._count.id;
return acc;
}, {} as Record<string, number>),
},
credits: {
totalGranted: creditStats._sum.amount || 0,
totalUsed: Math.abs(creditUsed._sum.amount || 0),
},
plans: {
total: totalPlans,
},
storage: storageStats,
recentUsers,
};
}
// ── Plan Yönetimi ─────────────────────────────────────────────────
async getAllPlans() {
return this.prisma.plan.findMany({
orderBy: { sortOrder: 'asc' },
include: {
_count: {
select: { subscriptions: true },
},
},
});
}
async updatePlan(planId: string, data: {
displayName?: string;
description?: string;
monthlyPrice?: number;
yearlyPrice?: number;
monthlyCredits?: number;
maxDuration?: number;
maxResolution?: string;
maxProjects?: number;
isActive?: boolean;
features?: any;
}) {
return this.prisma.plan.update({
where: { id: planId },
data,
});
}
// ── Kullanıcı Kredi Yönetimi ──────────────────────────────────────
async grantCredits(userId: string, amount: number, description: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
creditTransactions: {
orderBy: { createdAt: 'desc' },
take: 1,
},
},
});
if (!user) {
throw new Error('Kullanıcı bulunamadı');
}
const currentBalance = user.creditTransactions[0]?.balanceAfter || 0;
const newBalance = currentBalance + amount;
return this.prisma.creditTransaction.create({
data: {
userId,
amount,
type: 'grant',
description,
balanceAfter: newBalance,
},
});
}
// ── Kullanıcı Detay ───────────────────────────────────────────────
async getUserDetail(userId: string) {
return this.prisma.user.findUnique({
where: { id: userId },
include: {
roles: {
include: { role: true },
},
projects: {
take: 10,
orderBy: { createdAt: 'desc' },
},
subscriptions: {
include: { plan: true },
take: 1,
orderBy: { createdAt: 'desc' },
},
creditTransactions: {
take: 10,
orderBy: { createdAt: 'desc' },
},
preferences: true,
},
});
}
}

View File

@@ -38,6 +38,14 @@ export class BillingController {
}
}
@Get('plans')
@Public()
@ApiOperation({ summary: 'Mevcut planları listele (public)' })
async getPlans() {
const plans = await this.billingService.getPlans();
return plans;
}
@Post('checkout')
@ApiBearerAuth()
@ApiOperation({ summary: 'Stripe Checkout session oluştur' })
@@ -62,8 +70,15 @@ export class BillingController {
@ApiOperation({ summary: 'Kredi işlem geçmişi' })
async getCreditHistory(@Req() req: any) {
const userId = req.user?.id || req.user?.sub;
// Default pagination
return this.billingService.getCreditBalance(userId);
return this.billingService.getCreditHistory(userId);
}
@Get('subscription')
@ApiBearerAuth()
@ApiOperation({ summary: 'Aktif abonelik bilgisi' })
async getSubscription(@Req() req: any) {
const userId = req.user?.id || req.user?.sub;
return this.billingService.getActiveSubscription(userId);
}
@Post('webhook')

View File

@@ -123,6 +123,32 @@ export class BillingService {
}
}
/**
* Aktif planları listele — pricing sayfası için
*/
async getPlans() {
const plans = await this.db.plan.findMany({
where: { isActive: true },
orderBy: { sortOrder: 'asc' },
select: {
id: true,
name: true,
displayName: true,
description: true,
monthlyPrice: true,
yearlyPrice: true,
currency: true,
monthlyCredits: true,
maxDuration: true,
maxResolution: true,
maxProjects: true,
features: true,
},
});
return plans;
}
/**
* Kullanıcı kredi bakiyesi
*/
@@ -290,4 +316,53 @@ export class BillingService {
this.logger.log(`❌ Abonelik iptal edildi: ${stripeSubscription.id}`);
}
/**
* Kredi işlem geçmişi (son 50)
*/
async getCreditHistory(userId: string) {
const transactions = await this.db.creditTransaction.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
take: 50,
select: {
id: true,
amount: true,
type: true,
description: true,
balanceAfter: true,
createdAt: true,
},
});
return { transactions };
}
/**
* Aktif abonelik bilgisi
*/
async getActiveSubscription(userId: string) {
const subscription = await this.db.subscription.findFirst({
where: { userId, status: 'active' },
include: { plan: true },
});
if (!subscription) {
return {
plan: 'Free',
status: 'free',
monthlyCredits: 3,
currentPeriodEnd: null,
};
}
return {
plan: subscription.plan.displayName,
status: subscription.status,
monthlyCredits: subscription.plan.monthlyCredits,
currentPeriodEnd: subscription.currentPeriodEnd,
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
stripeSubscriptionId: subscription.stripeSubscriptionId,
};
}
}

View File

@@ -0,0 +1,50 @@
import {
Controller,
Get,
Logger,
Req,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { DashboardService } from './dashboard.service';
@ApiTags('dashboard')
@ApiBearerAuth()
@Controller('dashboard')
export class DashboardController {
private readonly logger = new Logger(DashboardController.name);
constructor(private readonly dashboardService: DashboardService) {}
/**
* Dashboard ana istatistikleri — kullanıcı bazlı.
*/
@Get('stats')
@ApiOperation({ summary: 'Dashboard istatistiklerini getir' })
@ApiResponse({ status: 200, description: 'Dashboard istatistikleri' })
async getStats(@Req() req: any) {
const userId = req.user?.id || req.user?.sub;
this.logger.debug(`Dashboard stats istendi: ${userId}`);
return this.dashboardService.getStats(userId);
}
/**
* Video üretim kuyruğu durumu.
*/
@Get('queue-status')
@ApiOperation({ summary: 'Kuyruk durum bilgisi' })
@ApiResponse({ status: 200, description: 'Kuyruk ve WebSocket durumu' })
async getQueueStatus() {
return this.dashboardService.getQueueStatus();
}
/**
* Bu ayki video üretim chart verisi (gün bazlı).
*/
@Get('chart')
@ApiOperation({ summary: 'Aylık video üretim chart verisi' })
@ApiResponse({ status: 200, description: 'Gün bazlı üretim verileri' })
async getMonthlyChart(@Req() req: any) {
const userId = req.user?.id || req.user?.sub;
return this.dashboardService.getMonthlyChart(userId);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { DashboardController } from './dashboard.controller';
import { DashboardService } from './dashboard.service';
import { VideoQueueModule } from '../video-queue/video-queue.module';
import { EventsModule } from '../events/events.module';
@Module({
imports: [VideoQueueModule, EventsModule],
controllers: [DashboardController],
providers: [DashboardService],
exports: [DashboardService],
})
export class DashboardModule {}

View File

@@ -0,0 +1,190 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
import { VideoGenerationProducer } from '../video-queue/video-generation.producer';
import { EventsGateway } from '../events/events.gateway';
@Injectable()
export class DashboardService {
private readonly logger = new Logger(DashboardService.name);
constructor(
private readonly db: PrismaService,
private readonly videoGenerationProducer: VideoGenerationProducer,
private readonly eventsGateway: EventsGateway,
) {}
/**
* Dashboard istatistiklerini hesaplar.
* UserID bazlı filtreleme — her kullanıcı kendi verilerini görür.
* Gerçek plan bilgisini Subscription → Plan tablosundan çeker.
*/
async getStats(userId: string) {
const [
totalProjects,
completedVideos,
activeRenderJobs,
failedProjects,
draftProjects,
totalCreditsUsed,
recentProjects,
activeSubscription,
creditBalance,
] = await Promise.all([
// Toplam proje sayısı
this.db.project.count({
where: { userId, deletedAt: null },
}),
// Tamamlanan video sayısı
this.db.project.count({
where: { userId, status: 'COMPLETED', deletedAt: null },
}),
// Aktif render job sayısı
this.db.project.count({
where: {
userId,
deletedAt: null,
status: { in: ['PENDING', 'GENERATING_MEDIA', 'RENDERING', 'GENERATING_SCRIPT'] },
},
}),
// Başarısız projeler
this.db.project.count({
where: { userId, status: 'FAILED', deletedAt: null },
}),
// Draft projeler
this.db.project.count({
where: { userId, status: 'DRAFT', deletedAt: null },
}),
// Toplam harcanan kredi
this.db.project.aggregate({
where: { userId, deletedAt: null },
_sum: { creditsUsed: true },
}),
// Son 5 proje
this.db.project.findMany({
where: { userId, deletedAt: null },
orderBy: { createdAt: 'desc' },
take: 5,
select: {
id: true,
title: true,
status: true,
progress: true,
thumbnailUrl: true,
finalVideoUrl: true,
videoStyle: true,
aspectRatio: true,
language: true,
sourceType: true,
createdAt: true,
updatedAt: true,
completedAt: true,
},
}),
// Kullanıcının aktif aboneliği (plan dahil)
this.db.subscription.findFirst({
where: {
userId,
status: { in: ['active', 'trialing'] },
},
include: { plan: true },
orderBy: { createdAt: 'desc' },
}),
// Bu ayki net kredi bakiyesi (CreditTransaction tablosundan)
this.db.creditTransaction.aggregate({
where: {
userId,
createdAt: {
gte: new Date(new Date().getFullYear(), new Date().getMonth(), 1),
},
},
_sum: { amount: true },
}),
]);
// Plan bilgisini aktif abonelikten çek, yoksa free plan varsayılanları
const plan = activeSubscription?.plan;
const currentPlan = plan?.name || 'free';
const monthlyLimit = plan?.monthlyCredits ?? 3;
const maxDuration = plan?.maxDuration ?? 30;
const maxResolution = plan?.maxResolution ?? '720p';
// Kredi hesaplama: CreditTransaction tablosundan kalan bakiye
const creditsUsed = totalCreditsUsed._sum.creditsUsed || 0;
const netCreditBalance = creditBalance._sum.amount || 0;
const creditsRemaining = Math.max(0, netCreditBalance);
return {
totalProjects,
completedVideos,
activeRenderJobs,
failedProjects,
draftProjects,
totalCreditsUsed: creditsUsed,
creditsRemaining,
monthlyLimit,
currentPlan,
maxDuration,
maxResolution,
recentProjects,
};
}
/**
* Kuyruk durumunu BullMQ ve Worker kuyruğundan alır.
*/
async getQueueStatus() {
const queueStats = await this.videoGenerationProducer.getQueueStats();
const wsClients = this.eventsGateway.getConnectedClientsCount();
return {
queue: queueStats,
websocket: {
connectedClients: wsClients,
},
};
}
/**
* Bu ay üretilen videoların gün bazlı dağılımı (chart verisi).
*/
async getMonthlyChart(userId: string) {
const startOfMonth = new Date();
startOfMonth.setDate(1);
startOfMonth.setHours(0, 0, 0, 0);
const projects = await this.db.project.findMany({
where: {
userId,
deletedAt: null,
createdAt: { gte: startOfMonth },
},
select: {
createdAt: true,
status: true,
},
orderBy: { createdAt: 'asc' },
});
// Gün bazlı gruplama
const dailyMap: Record<string, { created: number; completed: number }> = {};
projects.forEach((p) => {
const day = p.createdAt.toISOString().split('T')[0];
if (!dailyMap[day]) dailyMap[day] = { created: 0, completed: 0 };
dailyMap[day].created++;
if (p.status === 'COMPLETED') dailyMap[day].completed++;
});
return Object.entries(dailyMap).map(([date, counts]) => ({
date,
...counts,
}));
}
}

View File

@@ -0,0 +1,205 @@
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
OnGatewayDisconnect,
ConnectedSocket,
MessageBody,
} from '@nestjs/websockets';
import { Logger } from '@nestjs/common';
import { Server, Socket } from 'socket.io';
/**
* ContentGen AI — Gerçek Zamanlı WebSocket Gateway
*
* Frontend'e render ilerleme bildirimlerini iletir.
* C# Worker → RenderCallbackController → EventsGateway → Frontend
*
* Room yapısı: "project:{projectId}" — her proje kendi room'una abone olur
*/
@WebSocketGateway({
cors: {
origin: ['http://localhost:3001', 'http://localhost:3000', process.env.FRONTEND_URL || '*'],
credentials: true,
},
namespace: '/ws',
transports: ['websocket', 'polling'],
})
export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;
private readonly logger = new Logger(EventsGateway.name);
private connectedClients = 0;
handleConnection(client: Socket) {
this.connectedClients++;
this.logger.log(`Client bağlandı: ${client.id} (toplam: ${this.connectedClients})`);
}
handleDisconnect(client: Socket) {
this.connectedClients--;
this.logger.log(`Client ayrıldı: ${client.id} (toplam: ${this.connectedClients})`);
}
/**
* Frontend, proje detay sayfasına girdiğinde bu event'i gönderir.
* Client ilgili proje room'una katılır.
*/
@SubscribeMessage('join:project')
handleJoinProject(
@ConnectedSocket() client: Socket,
@MessageBody() data: { projectId: string },
) {
const room = `project:${data.projectId}`;
client.join(room);
this.logger.debug(`Client ${client.id} → room: ${room}`);
return { event: 'joined', data: { room, projectId: data.projectId } };
}
/**
* Frontend, proje sayfasından ayrıldığında bu event'i gönderir.
*/
@SubscribeMessage('leave:project')
handleLeaveProject(
@ConnectedSocket() client: Socket,
@MessageBody() data: { projectId: string },
) {
const room = `project:${data.projectId}`;
client.leave(room);
this.logger.debug(`Client ${client.id} ← room: ${room}`);
return { event: 'left', data: { room } };
}
/**
* Kullanıcı kendi bildirim room'una katılır.
* Frontend login sonrası bu event'i göndererek anlık bildirimleri alır.
*/
@SubscribeMessage('join:user')
handleJoinUser(
@ConnectedSocket() client: Socket,
@MessageBody() data: { userId: string },
) {
const room = `user:${data.userId}`;
client.join(room);
this.logger.debug(`Client ${client.id} → user room: ${room}`);
return { event: 'joined', data: { room, userId: data.userId } };
}
/**
* Kullanıcı bildirim room'undan ayrılır.
*/
@SubscribeMessage('leave:user')
handleLeaveUser(
@ConnectedSocket() client: Socket,
@MessageBody() data: { userId: string },
) {
const room = `user:${data.userId}`;
client.leave(room);
this.logger.debug(`Client ${client.id} ← user room: ${room}`);
return { event: 'left', data: { room } };
}
// ═══════════════════════════════════════════════════════
// Server → Client Events (RenderCallbackController tarafından tetiklenir)
// ═══════════════════════════════════════════════════════
/**
* Render ilerleme bildirimi gönder.
* Stage: 'tts' | 'image_generation' | 'music_generation' | 'compositing' | 'encoding'
*/
emitRenderProgress(
projectId: string,
payload: {
progress: number;
stage: string;
stageLabel: string;
currentScene?: number;
totalScenes?: number;
eta?: number; // Tahmini kalan saniye
},
) {
this.server.to(`project:${projectId}`).emit('render:progress', {
projectId,
...payload,
timestamp: new Date().toISOString(),
});
}
/**
* Render tamamlandı bildirimi.
*/
emitRenderCompleted(
projectId: string,
payload: {
finalVideoUrl: string;
thumbnailUrl?: string;
processingTimeMs: number;
fileSize: number;
},
) {
this.server.to(`project:${projectId}`).emit('render:completed', {
projectId,
...payload,
timestamp: new Date().toISOString(),
});
}
/**
* Render hatası bildirimi.
*/
emitRenderFailed(
projectId: string,
payload: {
error: string;
stage: string;
attemptNumber: number;
canRetry: boolean;
},
) {
this.server.to(`project:${projectId}`).emit('render:failed', {
projectId,
...payload,
timestamp: new Date().toISOString(),
});
}
/**
* Proje durum değişikliği bildirimi (status change).
*/
emitProjectStatusChanged(projectId: string, status: string) {
this.server.to(`project:${projectId}`).emit('project:status', {
projectId,
status,
timestamp: new Date().toISOString(),
});
}
/**
* Kullanıcıya anlık bildirim gönder.
* NotificationsService.createNotification() tarafından çağrılır.
*/
emitNotification(
userId: string,
payload: {
id: string;
type: string;
title: string;
message?: string | null;
metadata?: unknown;
isRead: boolean;
createdAt: string;
},
) {
this.server.to(`user:${userId}`).emit('notification:new', {
...payload,
timestamp: new Date().toISOString(),
});
}
/** Bağlı client sayısını döndür (health check için) */
getConnectedClientsCount(): number {
return this.connectedClients;
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { EventsGateway } from './events.gateway';
@Module({
providers: [EventsGateway],
exports: [EventsGateway],
})
export class EventsModule {}

View File

@@ -4,4 +4,6 @@ export const geminiConfig = registerAs('gemini', () => ({
enabled: process.env.ENABLE_GEMINI === 'true',
apiKey: process.env.GOOGLE_API_KEY,
defaultModel: process.env.GEMINI_MODEL || 'gemini-2.5-flash',
imageModel: process.env.GEMINI_IMAGE_MODEL || 'gemini-2.0-flash-preview-image-generation',
}));

View File

@@ -237,4 +237,100 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
throw new Error('Failed to parse AI response as JSON');
}
}
// ── Görsel Üretim (Gemini Image Generation) ─────────────────────────
/**
* Gemini Image Generation API ile görsel üret.
* Raspberry Pi 5 bellek koruması için buffer olarak döner.
*
* @param prompt - İngilizce görsel açıklaması
* @param aspectRatio - Görsel en-boy oranı (16:9, 9:16, 1:1)
* @returns Base64 decoded image buffer ve mime type
*/
async generateImage(
prompt: string,
aspectRatio: '16:9' | '9:16' | '1:1' = '16:9',
): Promise<{ buffer: Buffer; mimeType: string } | null> {
if (!this.isAvailable()) {
throw new Error('Gemini AI is not available. Check your configuration.');
}
const imageModel = this.configService.get<string>(
'gemini.imageModel',
'gemini-2.0-flash-preview-image-generation',
);
try {
this.logger.debug(`🎨 Görsel üretiliyor: "${prompt.substring(0, 80)}..." [${aspectRatio}]`);
const response = await this.client!.models.generateContent({
model: imageModel,
contents: [
{
role: 'user',
parts: [
{
text: `Generate a high-quality image for this description: ${prompt}. Aspect ratio: ${aspectRatio}. Style: photorealistic, cinematic lighting, detailed.`,
},
],
},
],
config: {
responseModalities: ['TEXT', 'IMAGE'] as any,
},
});
// Gemini image response'dan image part'ı çıkar
const parts = (response as any).candidates?.[0]?.content?.parts || [];
for (const part of parts) {
if (part.inlineData?.data) {
const buffer = Buffer.from(part.inlineData.data, 'base64');
const mimeType = part.inlineData.mimeType || 'image/png';
this.logger.log(`✅ Görsel üretildi: ${(buffer.length / 1024).toFixed(1)} KB`);
return { buffer, mimeType };
}
}
this.logger.warn('Gemini görsel üretemedi — boş yanıt');
return null;
} catch (error) {
this.logger.error(`Gemini görsel üretim hatası: ${error}`);
throw error;
}
}
/**
* Sahne bazlı görsel üret — visualPrompt ve video stili kullanarak.
*
* @param visualPrompt - Sahnenin İngilizce görsel açıklaması
* @param style - Video stili (cinematic, documentary, educational vb.)
* @param aspectRatio - En-boy oranı
* @returns Buffer ve mimeType
*/
async generateImageForScene(
visualPrompt: string,
style: string = 'cinematic',
aspectRatio: '16:9' | '9:16' | '1:1' = '16:9',
): Promise<{ buffer: Buffer; mimeType: string } | null> {
const enhancedPrompt = `${visualPrompt}. Style: ${style}, professional production quality, volumetric lighting, sharp details, 8K resolution.`;
return this.generateImage(enhancedPrompt, aspectRatio);
}
/**
* Video için thumbnail görsel üret — proje başlığı ve açıklamasından.
*
* @param title - Video başlığı
* @param description - Video açıklaması
* @returns Buffer ve mimeType
*/
async generateThumbnail(
title: string,
description: string,
): Promise<{ buffer: Buffer; mimeType: string } | null> {
const prompt = `Create a compelling YouTube video thumbnail for a video titled "${title}". ${description}. Make it eye-catching with bold, dynamic composition. No text overlay needed.`;
return this.generateImage(prompt, '16:9');
}
}

View File

@@ -0,0 +1,83 @@
import {
Controller,
Get,
Patch,
Delete,
Param,
Query,
Req,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { NotificationsService } from './notifications.service';
/**
* Notifications Controller — Kullanıcı Bildirim Endpoint'leri
*
* GET /notifications → Bildirim listesi (paginated)
* GET /notifications/unread-count → Okunmamış sayısı
* PATCH /notifications/:id/read → Tekil okundu işaretle
* PATCH /notifications/read-all → Tümünü okundu yap
* DELETE /notifications/:id → Silme
*/
@ApiTags('Notifications')
@ApiBearerAuth()
@Controller('notifications')
export class NotificationsController {
constructor(private readonly notificationsService: NotificationsService) {}
@Get()
@ApiOperation({ summary: 'Kullanıcının bildirimlerini getir' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
async getNotifications(
@Req() req: any,
@Query('page') page?: string,
@Query('limit') limit?: string,
) {
const userId = req.user?.id || req.user?.sub;
return this.notificationsService.getUserNotifications(
userId,
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 20,
);
}
@Get('unread-count')
@ApiOperation({ summary: 'Okunmamış bildirim sayısı' })
async getUnreadCount(@Req() req: any) {
const userId = req.user?.id || req.user?.sub;
return this.notificationsService.getUnreadCount(userId);
}
@Patch('read-all')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Tüm bildirimleri okundu olarak işaretle' })
async markAllAsRead(@Req() req: any) {
const userId = req.user?.id || req.user?.sub;
return this.notificationsService.markAllAsRead(userId);
}
@Patch(':id/read')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Bildirimi okundu olarak işaretle' })
async markAsRead(
@Req() req: any,
@Param('id') id: string,
) {
const userId = req.user?.id || req.user?.sub;
return this.notificationsService.markAsRead(id, userId);
}
@Delete(':id')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Bildirimi sil' })
async deleteNotification(
@Req() req: any,
@Param('id') id: string,
) {
const userId = req.user?.id || req.user?.sub;
return this.notificationsService.deleteNotification(id, userId);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { NotificationsService } from './notifications.service';
import { NotificationsController } from './notifications.controller';
import { EventsModule } from '../events/events.module';
@Module({
imports: [EventsModule],
controllers: [NotificationsController],
providers: [NotificationsService],
exports: [NotificationsService],
})
export class NotificationsModule {}

View File

@@ -0,0 +1,211 @@
import { Injectable, Logger, NotFoundException, ForbiddenException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../database/prisma.service';
import { EventsGateway } from '../events/events.gateway';
/**
* Notifications Service — In-App Bildirim Yönetimi
*
* Render sonuçları, düşük kredi uyarıları ve sistem bildirimleri
* için veritabanı + WebSocket push altyapısı.
*
* Flow:
* 1. createNotification() → DB'ye yaz + WebSocket push (anlık)
* 2. Frontend: WebSocket dinle + API ile liste çek
* 3. Kullanıcı: okundu işaretle / sil
*/
export type NotificationType =
| 'render_complete'
| 'render_failed'
| 'credit_low'
| 'credit_added'
| 'system';
export interface CreateNotificationPayload {
userId: string;
type: NotificationType;
title: string;
message?: string;
metadata?: Record<string, unknown>;
}
@Injectable()
export class NotificationsService {
private readonly logger = new Logger(NotificationsService.name);
constructor(
private readonly db: PrismaService,
private readonly eventsGateway: EventsGateway,
) {}
/**
* Bildirim oluştur — DB'ye yaz + WebSocket ile anlık push.
*/
async createNotification(payload: CreateNotificationPayload) {
const notification = await this.db.notification.create({
data: {
userId: payload.userId,
type: payload.type,
title: payload.title,
message: payload.message || null,
metadata: payload.metadata
? (payload.metadata as Prisma.InputJsonValue)
: Prisma.JsonNull,
isRead: false,
},
});
// WebSocket ile kullanıcıya anlık bildirim gönder
this.eventsGateway.emitNotification(payload.userId, {
id: notification.id,
type: notification.type,
title: notification.title,
message: notification.message,
metadata: notification.metadata,
isRead: false,
createdAt: notification.createdAt.toISOString(),
});
this.logger.debug(
`Bildirim oluşturuldu: [${payload.type}] "${payload.title}" → User: ${payload.userId}`,
);
return notification;
}
/**
* Kullanıcının bildirimlerini getir (pagination).
*/
async getUserNotifications(
userId: string,
page = 1,
limit = 20,
) {
const skip = (page - 1) * limit;
const [notifications, total] = await Promise.all([
this.db.notification.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
skip,
take: limit,
select: {
id: true,
type: true,
title: true,
message: true,
isRead: true,
metadata: true,
createdAt: true,
readAt: true,
},
}),
this.db.notification.count({ where: { userId } }),
]);
return {
data: notifications,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Okunmamış bildirim sayısı.
*/
async getUnreadCount(userId: string): Promise<{ count: number }> {
const count = await this.db.notification.count({
where: { userId, isRead: false },
});
return { count };
}
/**
* Tekil bildirimi okundu olarak işaretle.
*/
async markAsRead(notificationId: string, userId: string) {
const notification = await this.db.notification.findUnique({
where: { id: notificationId },
});
if (!notification) {
throw new NotFoundException('Bildirim bulunamadı');
}
if (notification.userId !== userId) {
throw new ForbiddenException('Bu bildirime erişim izniniz yok');
}
return this.db.notification.update({
where: { id: notificationId },
data: {
isRead: true,
readAt: new Date(),
},
});
}
/**
* Kullanıcının tüm bildirimlerini okundu olarak işaretle.
*/
async markAllAsRead(userId: string) {
const result = await this.db.notification.updateMany({
where: { userId, isRead: false },
data: {
isRead: true,
readAt: new Date(),
},
});
this.logger.debug(`${result.count} bildirim okundu işaretlendi — User: ${userId}`);
return { updated: result.count };
}
/**
* Bildirimi sil.
*/
async deleteNotification(notificationId: string, userId: string) {
const notification = await this.db.notification.findUnique({
where: { id: notificationId },
});
if (!notification) {
throw new NotFoundException('Bildirim bulunamadı');
}
if (notification.userId !== userId) {
throw new ForbiddenException('Bu bildirime erişim izniniz yok');
}
await this.db.notification.delete({
where: { id: notificationId },
});
return { deleted: true };
}
/**
* Eski bildirimleri temizle (30 günden eski, okunmuş olanlar).
* Cron job veya admin endpoint'i ile çağrılabilir.
*/
async cleanupOldNotifications(): Promise<{ deleted: number }> {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const result = await this.db.notification.deleteMany({
where: {
isRead: true,
createdAt: { lt: thirtyDaysAgo },
},
});
this.logger.log(`Eski bildirim temizliği: ${result.count} adet silindi`);
return { deleted: result.count };
}
}

View File

@@ -150,4 +150,38 @@ export class ProjectsController {
this.logger.log(`Tweet'ten proje oluşturuluyor: ${dto.tweetUrl}`);
return this.projectsService.createFromTweet(userId, dto);
}
/**
* Tekil sahne güncelleme (narrasyon, görsel prompt, süre).
*/
@Patch(':id/scenes/:sceneId')
@ApiOperation({ summary: 'Sahneyi güncelle' })
@ApiResponse({ status: 200, description: 'Sahne güncellendi' })
async updateScene(
@Param('id', ParseUUIDPipe) id: string,
@Param('sceneId', ParseUUIDPipe) sceneId: string,
@Body() body: { narrationText?: string; visualPrompt?: string; subtitleText?: string; duration?: number },
@Req() req: any,
) {
const userId = req.user?.id || req.user?.sub;
this.logger.log(`Sahne güncelleniyor: ${sceneId} (proje: ${id})`);
return this.projectsService.updateScene(userId, id, sceneId, body);
}
/**
* Tekil sahneyi AI ile yeniden üretir.
*/
@Post(':id/scenes/:sceneId/regenerate')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Sahneyi AI ile yeniden üret' })
@ApiResponse({ status: 200, description: 'Sahne yeniden üretildi' })
async regenerateScene(
@Param('id', ParseUUIDPipe) id: string,
@Param('sceneId', ParseUUIDPipe) sceneId: string,
@Req() req: any,
) {
const userId = req.user?.id || req.user?.sub;
this.logger.log(`Sahne yeniden üretiliyor: ${sceneId} (proje: ${id})`);
return this.projectsService.regenerateScene(userId, id, sceneId);
}
}

View File

@@ -473,4 +473,98 @@ export class ProjectsService {
throw error;
}
}
/**
* Tekil sahne güncelleme — narrasyon, görsel prompt, altyazı veya süre.
*/
async updateScene(
userId: string,
projectId: string,
sceneId: string,
data: { narrationText?: string; visualPrompt?: string; subtitleText?: string; duration?: number },
) {
// Proje sahipliğini doğrula
const project = await this.findOne(userId, projectId);
if (project.status !== 'DRAFT' && project.status !== 'FAILED') {
throw new BadRequestException(
`Sahneler yalnızca DRAFT veya FAILED durumunda düzenlenebilir. Mevcut: ${project.status}`,
);
}
// Sahnenin bu projeye ait olduğunu doğrula
const scene = project.scenes.find((s) => s.id === sceneId);
if (!scene) {
throw new NotFoundException(`Sahne bulunamadı: ${sceneId}`);
}
const updated = await this.db.scene.update({
where: { id: sceneId },
data: {
...(data.narrationText !== undefined && { narrationText: data.narrationText }),
...(data.visualPrompt !== undefined && { visualPrompt: data.visualPrompt }),
...(data.subtitleText !== undefined && { subtitleText: data.subtitleText }),
...(data.duration !== undefined && { duration: data.duration }),
},
include: { mediaAssets: true },
});
this.logger.log(`Sahne güncellendi: ${sceneId} (proje: ${projectId})`);
return updated;
}
/**
* Tekil sahneyi AI ile yeniden üretir.
* Mevcut sahnenin sıra numarası ve bağlamı korunur, sadece içerik yeniden üretilir.
*/
async regenerateScene(userId: string, projectId: string, sceneId: string) {
const project = await this.findOne(userId, projectId);
if (project.status !== 'DRAFT' && project.status !== 'FAILED') {
throw new BadRequestException(
`Sahneler yalnızca DRAFT veya FAILED durumunda yeniden üretilebilir.`,
);
}
const scene = project.scenes.find((s) => s.id === sceneId);
if (!scene) {
throw new NotFoundException(`Sahne bulunamadı: ${sceneId}`);
}
// Bağlam: Önceki ve sonraki sahne bilgisi
const prevScene = project.scenes.find((s) => s.order === scene.order - 1);
const nextScene = project.scenes.find((s) => s.order === scene.order + 1);
const contextPrompt = `
Bir video senaryosunun ${scene.order}. sahnesini yeniden üret.
Proje konusu: ${project.prompt}
Proje dili: ${project.language}
Video stili: ${project.videoStyle}
${prevScene ? `Önceki sahne: "${prevScene.narrationText}"` : ''}
${nextScene ? `Sonraki sahne: "${nextScene.narrationText}"` : ''}
Sadece bu tek sahneyi üret. JSON formatında:
{
"narrationText": "...",
"visualPrompt": "...",
"subtitleText": "...",
"durationSeconds": ${scene.duration}
}`;
const result = await this.videoAiService.generateSingleScene(contextPrompt);
const updated = await this.db.scene.update({
where: { id: sceneId },
data: {
narrationText: result.narrationText,
visualPrompt: result.visualPrompt,
subtitleText: result.subtitleText || result.narrationText,
duration: result.durationSeconds || scene.duration,
},
include: { mediaAssets: true },
});
this.logger.log(`Sahne yeniden üretildi: ${sceneId} (proje: ${projectId})`);
return updated;
}
}

View File

@@ -0,0 +1,318 @@
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';
/**
* 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';
}
}
}
// ── DTO Tanımları ──────────────────────────────────────────
class RenderProgressDto {
projectId: string;
renderJobId?: string;
progress: number;
stage: string;
stageLabel: string;
currentScene?: number;
totalScenes?: number;
eta?: number;
stepDurationMs?: number;
}
class RenderCompletedDto {
projectId: string;
renderJobId?: string;
finalVideoUrl: string;
thumbnailUrl?: string;
processingTimeMs: number;
fileSize: number;
}
class RenderFailedDto {
projectId: string;
renderJobId?: string;
error: string;
stage: string;
attemptNumber: number;
canRetry?: boolean;
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { RenderCallbackController } from './render-callback.controller';
import { EventsModule } from '../events/events.module';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [EventsModule, NotificationsModule],
controllers: [RenderCallbackController],
})
export class RenderCallbackModule {}

View File

@@ -5,14 +5,11 @@ import * as fs from 'fs/promises';
import * as crypto from 'crypto';
/**
* Storage Service — Medya dosyalarının yönetimi
*
* Strateji: file-organizer skill'inden elde edilen bilgilerle tasarlandı
* - Geliştirme ortamı: Lokal dosya sistemi (/data/media/)
* - Üretim ortamı: Cloudflare R2 / AWS S3
* Storage Service — Raspberry Pi 5 Üretim Ortamı İçin Optimize Edildi
*
* Tüm medya dosyaları Raspberry Pi'nin lokal diskinde saklanır.
* Dosya yapısı:
* /data/media/
* {basePath}/
* ├── {projectId}/
* │ ├── scenes/
* │ │ ├── scene-001-video.mp4
@@ -21,12 +18,15 @@ import * as crypto from 'crypto';
* │ ├── audio/
* │ │ ├── narration.mp3
* │ │ └── music.mp3
* │ ├── images/
* │ │ ├── scene-001.png
* │ │ └── scene-002.png
* │ ├── output/
* │ │ ├── final-video.mp4
* │ │ ├── final-xxxx.mp4
* │ │ └── thumbnail.jpg
* │ └── subtitles/
* │ └── captions.srt
* └── temp/ (otomatik temizlenir)
* └── temp/ (otomatik temizlenir)
*/
export interface UploadResult {
@@ -38,10 +38,9 @@ export interface UploadResult {
}
export interface StorageConfig {
provider: 'local' | 's3' | 'r2';
provider: 'local';
basePath: string;
bucket: string;
cdnUrl?: string;
publicBaseUrl: string;
}
@Injectable()
@@ -50,101 +49,186 @@ export class StorageService {
private readonly config: StorageConfig;
constructor(private readonly configService: ConfigService) {
const provider = this.configService.get<string>('STORAGE_PROVIDER', 'local');
const basePath = this.configService.get<string>('STORAGE_LOCAL_PATH', './data/media');
const port = this.configService.get<number>('PORT', 3000);
const cdnUrl = this.configService.get<string>('STORAGE_CDN_URL');
this.config = {
provider: provider as StorageConfig['provider'],
basePath: this.configService.get<string>('STORAGE_LOCAL_PATH', './data/media'),
bucket: this.configService.get<string>('STORAGE_BUCKET', 'contentgen-media'),
cdnUrl: this.configService.get<string>('STORAGE_CDN_URL'),
provider: 'local',
basePath: path.resolve(basePath),
publicBaseUrl: cdnUrl || `http://localhost:${port}/media`,
};
this.logger.log(`📦 Storage provider: ${this.config.provider}`);
this.logger.log(`📦 Storage: lokal depolama — ${this.config.basePath}`);
this.ensureBaseDir();
}
/**
* Sahne videosu için anahtar oluştur
* Başlangıçta temel dizini oluştur.
*/
private async ensureBaseDir() {
try {
await fs.mkdir(this.config.basePath, { recursive: true });
await fs.mkdir(path.join(this.config.basePath, 'temp'), { recursive: true });
} catch (error) {
this.logger.error(`Temel dizin oluşturulamadı: ${error}`);
}
}
// ── Key Generators ─────────────────────────────────────────────────
getSceneVideoKey(projectId: string, sceneOrder: number): string {
return `${projectId}/scenes/scene-${String(sceneOrder).padStart(3, '0')}-video.mp4`;
}
/**
* Sahne ses kaydı için anahtar oluştur
*/
getSceneAudioKey(projectId: string, sceneOrder: number): string {
return `${projectId}/audio/scene-${String(sceneOrder).padStart(3, '0')}-narration.mp3`;
}
/**
* Final video için anahtar oluştur
*/
getSceneImageKey(projectId: string, sceneOrder: number, ext = 'png'): string {
return `${projectId}/images/scene-${String(sceneOrder).padStart(3, '0')}.${ext}`;
}
getFinalVideoKey(projectId: string): string {
const hash = crypto.randomBytes(4).toString('hex');
return `${projectId}/output/final-${hash}.mp4`;
}
/**
* Thumbnail için anahtar oluştur
*/
getThumbnailKey(projectId: string): string {
return `${projectId}/output/thumbnail.jpg`;
}
/**
* Altyazı dosyası için anahtar oluştur
*/
getSubtitleKey(projectId: string): string {
return `${projectId}/subtitles/captions.srt`;
}
/**
* Müzik dosyası için anahtar oluştur
*/
getMusicKey(projectId: string): string {
return `${projectId}/audio/background-music.mp3`;
}
getAmbientKey(projectId: string, sceneOrder: number): string {
return `${projectId}/audio/ambient-${String(sceneOrder).padStart(3, '0')}.mp3`;
}
getTempKey(projectId: string, filename: string): string {
return `temp/${projectId}-${filename}`;
}
// ── Core Operations ────────────────────────────────────────────────
/**
* Dosya yükle
* Dosya yükle (Buffer → disk).
*/
async upload(key: string, data: Buffer, mimeType: string): Promise<UploadResult> {
if (this.config.provider === 'local') {
return this.uploadLocal(key, data, mimeType);
}
const filePath = path.join(this.config.basePath, key);
const dir = path.dirname(filePath);
// S3/R2 desteği sonra eklenecek
return this.uploadLocal(key, data, mimeType);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(filePath, data);
const sizeBytes = data.length;
this.logger.debug(`📥 Yüklendi: ${key} (${this.formatBytes(sizeBytes)}, ${mimeType})`);
return {
key,
url: this.getPublicUrl(key),
bucket: 'local',
sizeBytes,
mimeType,
};
}
/**
* Dosya indir
* Stream olarak dosya yükle — büyük dosyalar için (Raspberry Pi bellek koruması).
*/
async uploadFromPath(key: string, sourcePath: string, mimeType: string): Promise<UploadResult> {
const destPath = path.join(this.config.basePath, key);
const dir = path.dirname(destPath);
await fs.mkdir(dir, { recursive: true });
await fs.copyFile(sourcePath, destPath);
const stats = await fs.stat(destPath);
this.logger.debug(`📥 Dosyadan yüklendi: ${key} (${this.formatBytes(stats.size)})`);
return {
key,
url: this.getPublicUrl(key),
bucket: 'local',
sizeBytes: Number(stats.size),
mimeType,
};
}
/**
* Dosya indir (disk → Buffer).
*/
async download(key: string): Promise<Buffer> {
if (this.config.provider === 'local') {
return this.downloadLocal(key);
}
return this.downloadLocal(key);
const filePath = path.join(this.config.basePath, key);
return fs.readFile(filePath);
}
/**
* Dosya sil
* Dosyanın disk yolunu döndür (FFmpeg gibi araçlar için).
*/
getAbsolutePath(key: string): string {
return path.join(this.config.basePath, key);
}
/**
* Dosya sil.
*/
async delete(key: string): Promise<void> {
if (this.config.provider === 'local') {
return this.deleteLocal(key);
const filePath = path.join(this.config.basePath, key);
try {
await fs.unlink(filePath);
this.logger.debug(`🗑️ Silindi: ${key}`);
} catch {
// Dosya bulunamadı — sessizce geç
}
return this.deleteLocal(key);
}
/**
* Proje dosyalarını temizle
* Dosyanın mevcut olup olmadığını kontrol et.
*/
async exists(key: string): Promise<boolean> {
const filePath = path.join(this.config.basePath, key);
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
/**
* Dosya boyutunu al.
*/
async getFileSize(key: string): Promise<number> {
const filePath = path.join(this.config.basePath, key);
const stats = await fs.stat(filePath);
return Number(stats.size);
}
/**
* Proje dosyalarını listele.
*/
async listProjectFiles(projectId: string): Promise<string[]> {
const projectDir = path.join(this.config.basePath, projectId);
try {
return await this.listFilesRecursive(projectDir, projectId);
} catch {
return [];
}
}
/**
* Proje dosyalarını tamamen temizle.
*/
async cleanupProject(projectId: string): Promise<void> {
const projectDir = path.join(this.config.basePath, projectId);
try {
await fs.rm(projectDir, { recursive: true, force: true });
this.logger.log(`🗑️ Proje dosyaları silindi: ${projectId}`);
@@ -154,51 +238,96 @@ export class StorageService {
}
/**
* Dosyanın public URL'ini oluştur
* Temp dosyalarını temizle (24 saatten eski).
*/
async cleanupTemp(): Promise<number> {
const tempDir = path.join(this.config.basePath, 'temp');
let cleaned = 0;
try {
const files = await fs.readdir(tempDir);
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
for (const file of files) {
const filePath = path.join(tempDir, file);
const stats = await fs.stat(filePath);
if (stats.mtimeMs < cutoff) {
await fs.unlink(filePath);
cleaned++;
}
}
if (cleaned > 0) {
this.logger.log(`🧹 ${cleaned} temp dosyası temizlendi`);
}
} catch {
// temp dizini yoksa sorun değil
}
return cleaned;
}
/**
* Disk kullanım istatistikleri.
*/
async getStorageStats(): Promise<{
totalFiles: number;
totalSizeBytes: number;
totalSizeHuman: string;
}> {
try {
const files = await this.listFilesRecursive(this.config.basePath, '');
let totalSize = 0;
for (const file of files) {
try {
const stats = await fs.stat(path.join(this.config.basePath, file));
totalSize += Number(stats.size);
} catch {
// skip
}
}
return {
totalFiles: files.length,
totalSizeBytes: totalSize,
totalSizeHuman: this.formatBytes(totalSize),
};
} catch {
return { totalFiles: 0, totalSizeBytes: 0, totalSizeHuman: '0 B' };
}
}
/**
* Dosyanın public URL'ini oluştur.
*/
getPublicUrl(key: string): string {
if (this.config.cdnUrl) {
return `${this.config.cdnUrl}/${key}`;
}
if (this.config.provider === 'local') {
return `/media/${key}`;
}
return `https://${this.config.bucket}.r2.dev/${key}`;
return `${this.config.publicBaseUrl}/${key}`;
}
// ── Private: Lokal dosya sistemi ──────────────────────────────────
// ── Private Helpers ────────────────────────────────────────────────
private async uploadLocal(key: string, data: Buffer, mimeType: string): Promise<UploadResult> {
const filePath = path.join(this.config.basePath, key);
const dir = path.dirname(filePath);
private async listFilesRecursive(dir: string, prefix: string): Promise<string[]> {
const entries = await fs.readdir(dir, { withFileTypes: true });
const files: string[] = [];
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(filePath, data);
this.logger.debug(`📥 Dosya yüklendi: ${key} (${data.length} bytes)`);
return {
key,
url: this.getPublicUrl(key),
bucket: this.config.bucket,
sizeBytes: data.length,
mimeType,
};
}
private async downloadLocal(key: string): Promise<Buffer> {
const filePath = path.join(this.config.basePath, key);
return fs.readFile(filePath);
}
private async deleteLocal(key: string): Promise<void> {
const filePath = path.join(this.config.basePath, key);
try {
await fs.unlink(filePath);
} catch {
// Dosya bulunamadı — sessizce geç
for (const entry of entries) {
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
files.push(...(await this.listFilesRecursive(path.join(dir, entry.name), relative)));
} else {
files.push(relative);
}
}
return files;
}
private formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
}

View File

@@ -1,4 +1,4 @@
import { Controller, Get } from '@nestjs/common';
import { Controller, Get, Patch, Body, BadRequestException } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { BaseController } from '../../common/base';
import { UsersService } from './users.service';
@@ -43,6 +43,45 @@ export class UsersController extends BaseController<
);
}
@Patch('me')
async updateProfile(
@CurrentUser() user: AuthenticatedUser,
@Body() body: { firstName?: string; lastName?: string },
): Promise<ApiResponse<UserResponseDto>> {
const updated = await this.usersService.update(user.id, {
firstName: body.firstName,
lastName: body.lastName,
});
return createSuccessResponse(
plainToInstance(UserResponseDto, updated),
'Profil başarıyla güncellendi',
);
}
@Patch('me/password')
async changePassword(
@CurrentUser() user: AuthenticatedUser,
@Body() body: { currentPassword: string; newPassword: string },
): Promise<ApiResponse<{ success: boolean }>> {
if (!body.currentPassword || !body.newPassword) {
throw new BadRequestException('Mevcut ve yeni şifre gerekli');
}
if (body.newPassword.length < 8) {
throw new BadRequestException('Yeni şifre en az 8 karakter olmalı');
}
const fullUser = await this.usersService.findOne(user.id);
if (!fullUser) throw new BadRequestException('Kullanıcı bulunamadı');
const bcrypt = await import('bcrypt');
const isValid = await bcrypt.compare(body.currentPassword, fullUser.password);
if (!isValid) {
throw new BadRequestException('Mevcut şifre hatalı');
}
await this.usersService.update(user.id, { password: body.newPassword });
return createSuccessResponse({ success: true }, 'Şifre başarıyla güncellendi');
}
// Override create to require admin role
@Roles('admin')
async create(

View File

@@ -546,4 +546,44 @@ export class VideoAiService {
return parsed;
}
/**
* Tekil sahne yeniden üretimi — sınırlı bağlam ile sadece 1 sahne üretir.
*/
async generateSingleScene(contextPrompt: string): Promise<{
narrationText: string;
visualPrompt: string;
subtitleText: string;
durationSeconds: number;
}> {
if (!this.genAI) {
throw new InternalServerErrorException('AI servisi etkin değil — Google API Key gerekli.');
}
try {
const response = await this.genAI.models.generateContent({
model: this.modelName,
contents: contextPrompt,
config: {
responseMimeType: 'application/json',
temperature: 0.8,
maxOutputTokens: 1024,
},
});
const rawText = response.text || '';
const cleaned = rawText.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
const parsed = JSON.parse(cleaned);
return {
narrationText: parsed.narrationText || 'Yeniden üretilen sahne.',
visualPrompt: parsed.visualPrompt || 'Cinematic establishing shot.',
subtitleText: parsed.subtitleText || parsed.narrationText || '',
durationSeconds: parsed.durationSeconds || 5,
};
} catch (error) {
this.logger.error(`Tekil sahne üretim hatası: ${error}`);
throw new InternalServerErrorException('Sahne yeniden üretilemedi.');
}
}
}