generated from fahricansecer/boilerplate-be
This commit is contained in:
20
Dockerfile
20
Dockerfile
@@ -6,18 +6,21 @@ WORKDIR /app
|
|||||||
# Raspberry Pi ve Prisma uyumluluğu için gerekli kütüphaneler
|
# Raspberry Pi ve Prisma uyumluluğu için gerekli kütüphaneler
|
||||||
RUN apk add --no-cache openssl libc6-compat
|
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
|
# Paket dosyalarını kopyala
|
||||||
COPY package*.json ./
|
COPY package.json pnpm-lock.yaml ./
|
||||||
RUN npm ci
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
# Kaynak kodları kopyala
|
# Kaynak kodları kopyala
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Prisma client üret (Database şeman için şart)
|
# Prisma client üret (Database şeması için şart)
|
||||||
RUN npx prisma generate
|
RUN npx prisma generate
|
||||||
|
|
||||||
# Build al (NestJS/Backend için)
|
# Build al (NestJS/Backend için)
|
||||||
RUN npm run build
|
RUN pnpm build
|
||||||
|
|
||||||
# --- Production Stage (Canlı Sistem) ---
|
# --- Production Stage (Canlı Sistem) ---
|
||||||
FROM node:20-alpine AS production
|
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
|
# Prisma için gerekli kütüphaneleri buraya da ekliyoruz
|
||||||
RUN apk add --no-cache openssl libc6-compat
|
RUN apk add --no-cache openssl libc6-compat
|
||||||
|
|
||||||
|
# pnpm kurulumu
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
|
||||||
# Sadece production (canlıda lazım olan) paketleri kur
|
# 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
|
# Prisma şemasını taşı ve client üret
|
||||||
COPY prisma ./prisma
|
COPY prisma ./prisma
|
||||||
RUN npx prisma generate
|
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
|
# Güvenlik için dosyaları 'node' kullanıcısına zimmetliyoruz
|
||||||
COPY --chown=node:node --from=builder /app/dist ./dist
|
COPY --chown=node:node --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
|||||||
12961
package-lock.json
generated
12961
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -16,8 +16,12 @@
|
|||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"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": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.964.0",
|
"@aws-sdk/client-s3": "^3.964.0",
|
||||||
"@google/genai": "^1.35.0",
|
"@google/genai": "^1.35.0",
|
||||||
@@ -33,6 +37,7 @@
|
|||||||
"@nestjs/swagger": "^11.2.4",
|
"@nestjs/swagger": "^11.2.4",
|
||||||
"@nestjs/terminus": "^11.0.0",
|
"@nestjs/terminus": "^11.0.0",
|
||||||
"@nestjs/throttler": "^6.5.0",
|
"@nestjs/throttler": "^6.5.0",
|
||||||
|
"@nestjs/websockets": "^11.1.17",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bullmq": "^5.66.4",
|
"bullmq": "^5.66.4",
|
||||||
@@ -52,6 +57,7 @@
|
|||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
|
"socket.io": "^4.8.3",
|
||||||
"stripe": "^21.0.1",
|
"stripe": "^21.0.1",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
},
|
},
|
||||||
@@ -100,5 +106,8 @@
|
|||||||
],
|
],
|
||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
"testEnvironment": "node"
|
"testEnvironment": "node"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
9165
pnpm-lock.yaml
generated
Normal file
9165
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
290
prisma/seed.ts
Normal file
290
prisma/seed.ts
Normal 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();
|
||||||
|
});
|
||||||
@@ -48,6 +48,10 @@ import { VideoAiModule } from './modules/video-ai/video-ai.module';
|
|||||||
import { StorageModule } from './modules/storage/storage.module';
|
import { StorageModule } from './modules/storage/storage.module';
|
||||||
import { BillingModule } from './modules/billing/billing.module';
|
import { BillingModule } from './modules/billing/billing.module';
|
||||||
import { XTwitterModule } from './modules/x-twitter/x-twitter.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
|
// Guards
|
||||||
import {
|
import {
|
||||||
@@ -190,6 +194,12 @@ import {
|
|||||||
StorageModule,
|
StorageModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
XTwitterModule,
|
XTwitterModule,
|
||||||
|
|
||||||
|
// Real-time & Callback Modules
|
||||||
|
EventsModule,
|
||||||
|
RenderCallbackModule,
|
||||||
|
DashboardModule,
|
||||||
|
NotificationsModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Global Exception Filter
|
// Global Exception Filter
|
||||||
|
|||||||
57
src/main.ts
57
src/main.ts
@@ -5,13 +5,18 @@ import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
|||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import { Logger, LoggerErrorInterceptor } from 'nestjs-pino';
|
import { Logger, LoggerErrorInterceptor } from 'nestjs-pino';
|
||||||
|
import * as express from 'express';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const logger = new NestLogger('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
|
// Use Pino Logger
|
||||||
app.useLogger(app.get(Logger));
|
app.useLogger(app.get(Logger));
|
||||||
@@ -31,6 +36,18 @@ async function bootstrap() {
|
|||||||
const port = configService.get<number>('PORT', 3000);
|
const port = configService.get<number>('PORT', 3000);
|
||||||
const nodeEnv = configService.get('NODE_ENV', 'development');
|
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
|
// Enable CORS
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: true,
|
origin: true,
|
||||||
@@ -54,39 +71,45 @@ async function bootstrap() {
|
|||||||
|
|
||||||
// Swagger setup
|
// Swagger setup
|
||||||
const swaggerConfig = new DocumentBuilder()
|
const swaggerConfig = new DocumentBuilder()
|
||||||
.setTitle('TypeScript Boilerplate API')
|
.setTitle('ContentGen AI — Video Generation SaaS API')
|
||||||
.setDescription(
|
.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()
|
.addBearerAuth()
|
||||||
.addTag('Auth', 'Authentication endpoints')
|
.addTag('Auth', 'Kimlik doğrulama')
|
||||||
.addTag('Users', 'User management endpoints')
|
.addTag('Users', 'Kullanıcı yönetimi')
|
||||||
.addTag('Admin', 'Admin management endpoints')
|
.addTag('Projects', 'Proje ve senaryo yönetimi')
|
||||||
.addTag('Health', 'Health check endpoints')
|
.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();
|
.build();
|
||||||
|
|
||||||
logger.log('Initializing Swagger...');
|
logger.log('Swagger başlatılıyor...');
|
||||||
const document = SwaggerModule.createDocument(app, swaggerConfig);
|
const document = SwaggerModule.createDocument(app, swaggerConfig);
|
||||||
SwaggerModule.setup('api/docs', app, document, {
|
SwaggerModule.setup('api/docs', app, document, {
|
||||||
swaggerOptions: {
|
swaggerOptions: {
|
||||||
persistAuthorization: true,
|
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');
|
await app.listen(port, '0.0.0.0');
|
||||||
|
|
||||||
logger.log('═══════════════════════════════════════════════════════════');
|
logger.log('═══════════════════════════════════════════════════════════');
|
||||||
logger.log(`🚀 Server is running on: http://localhost:${port}/api`);
|
logger.log(`🚀 ContentGen AI API: http://localhost:${port}/api`);
|
||||||
logger.log(`📚 Swagger documentation: http://localhost:${port}/api/docs`);
|
logger.log(`📚 Swagger Docs: http://localhost:${port}/api/docs`);
|
||||||
logger.log(`💚 Health check: http://localhost:${port}/api/health`);
|
logger.log(`💚 Health Check: http://localhost:${port}/api/health`);
|
||||||
logger.log(`🌍 Environment: ${nodeEnv.toUpperCase()}`);
|
logger.log(`📂 Medya Dosyaları: http://localhost:${port}/media/`);
|
||||||
|
logger.log(`🌍 Ortam: ${nodeEnv.toUpperCase()}`);
|
||||||
logger.log('═══════════════════════════════════════════════════════════');
|
logger.log('═══════════════════════════════════════════════════════════');
|
||||||
|
|
||||||
if (nodeEnv === 'development') {
|
if (nodeEnv === 'development') {
|
||||||
logger.warn('⚠️ Running in development mode');
|
logger.warn('⚠️ Geliştirme modunda çalışıyor');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
RoleResponseDto,
|
RoleResponseDto,
|
||||||
UserRoleResponseDto,
|
UserRoleResponseDto,
|
||||||
} from './dto/admin.dto';
|
} from './dto/admin.dto';
|
||||||
|
import { AdminService } from './admin.service';
|
||||||
|
|
||||||
@ApiTags('Admin')
|
@ApiTags('Admin')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -43,9 +44,61 @@ import {
|
|||||||
export class AdminController {
|
export class AdminController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly adminService: AdminService,
|
||||||
@Inject(CACHE_MANAGER) private cacheManager: cacheManager.Cache,
|
@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 ==================
|
// ================== Users Management ==================
|
||||||
|
|
||||||
@Get('users')
|
@Get('users')
|
||||||
@@ -268,3 +321,4 @@ export class AdminController {
|
|||||||
return createSuccessResponse(null, 'Permission removed from role');
|
return createSuccessResponse(null, 'Permission removed from role');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AdminController } from './admin.controller';
|
import { AdminController } from './admin.controller';
|
||||||
|
import { AdminService } from './admin.service';
|
||||||
|
import { StorageModule } from '../storage/storage.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [StorageModule],
|
||||||
controllers: [AdminController],
|
controllers: [AdminController],
|
||||||
|
providers: [AdminService],
|
||||||
|
exports: [AdminService],
|
||||||
})
|
})
|
||||||
export class AdminModule {}
|
export class AdminModule {}
|
||||||
|
|
||||||
|
|||||||
166
src/modules/admin/admin.service.ts
Normal file
166
src/modules/admin/admin.service.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')
|
@Post('checkout')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: 'Stripe Checkout session oluştur' })
|
@ApiOperation({ summary: 'Stripe Checkout session oluştur' })
|
||||||
@@ -62,8 +70,15 @@ export class BillingController {
|
|||||||
@ApiOperation({ summary: 'Kredi işlem geçmişi' })
|
@ApiOperation({ summary: 'Kredi işlem geçmişi' })
|
||||||
async getCreditHistory(@Req() req: any) {
|
async getCreditHistory(@Req() req: any) {
|
||||||
const userId = req.user?.id || req.user?.sub;
|
const userId = req.user?.id || req.user?.sub;
|
||||||
// Default pagination
|
return this.billingService.getCreditHistory(userId);
|
||||||
return this.billingService.getCreditBalance(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')
|
@Post('webhook')
|
||||||
|
|||||||
@@ -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
|
* Kullanıcı kredi bakiyesi
|
||||||
*/
|
*/
|
||||||
@@ -290,4 +316,53 @@ export class BillingService {
|
|||||||
|
|
||||||
this.logger.log(`❌ Abonelik iptal edildi: ${stripeSubscription.id}`);
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
50
src/modules/dashboard/dashboard.controller.ts
Normal file
50
src/modules/dashboard/dashboard.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/modules/dashboard/dashboard.module.ts
Normal file
13
src/modules/dashboard/dashboard.module.ts
Normal 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 {}
|
||||||
190
src/modules/dashboard/dashboard.service.ts
Normal file
190
src/modules/dashboard/dashboard.service.ts
Normal 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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
205
src/modules/events/events.gateway.ts
Normal file
205
src/modules/events/events.gateway.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/modules/events/events.module.ts
Normal file
8
src/modules/events/events.module.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { EventsGateway } from './events.gateway';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [EventsGateway],
|
||||||
|
exports: [EventsGateway],
|
||||||
|
})
|
||||||
|
export class EventsModule {}
|
||||||
@@ -4,4 +4,6 @@ export const geminiConfig = registerAs('gemini', () => ({
|
|||||||
enabled: process.env.ENABLE_GEMINI === 'true',
|
enabled: process.env.ENABLE_GEMINI === 'true',
|
||||||
apiKey: process.env.GOOGLE_API_KEY,
|
apiKey: process.env.GOOGLE_API_KEY,
|
||||||
defaultModel: process.env.GEMINI_MODEL || 'gemini-2.5-flash',
|
defaultModel: process.env.GEMINI_MODEL || 'gemini-2.5-flash',
|
||||||
|
imageModel: process.env.GEMINI_IMAGE_MODEL || 'gemini-2.0-flash-preview-image-generation',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
83
src/modules/notifications/notifications.controller.ts
Normal file
83
src/modules/notifications/notifications.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/modules/notifications/notifications.module.ts
Normal file
12
src/modules/notifications/notifications.module.ts
Normal 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 {}
|
||||||
211
src/modules/notifications/notifications.service.ts
Normal file
211
src/modules/notifications/notifications.service.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -150,4 +150,38 @@ export class ProjectsController {
|
|||||||
this.logger.log(`Tweet'ten proje oluşturuluyor: ${dto.tweetUrl}`);
|
this.logger.log(`Tweet'ten proje oluşturuluyor: ${dto.tweetUrl}`);
|
||||||
return this.projectsService.createFromTweet(userId, dto);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -473,4 +473,98 @@ export class ProjectsService {
|
|||||||
throw error;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
318
src/modules/render-callback/render-callback.controller.ts
Normal file
318
src/modules/render-callback/render-callback.controller.ts
Normal 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;
|
||||||
|
}
|
||||||
10
src/modules/render-callback/render-callback.module.ts
Normal file
10
src/modules/render-callback/render-callback.module.ts
Normal 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 {}
|
||||||
@@ -5,14 +5,11 @@ import * as fs from 'fs/promises';
|
|||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Storage Service — Medya dosyalarının yönetimi
|
* Storage Service — Raspberry Pi 5 Üretim Ortamı İçin Optimize Edildi
|
||||||
*
|
*
|
||||||
* Strateji: file-organizer skill'inden elde edilen bilgilerle tasarlandı
|
* Tüm medya dosyaları Raspberry Pi'nin lokal diskinde saklanır.
|
||||||
* - Geliştirme ortamı: Lokal dosya sistemi (/data/media/)
|
|
||||||
* - Üretim ortamı: Cloudflare R2 / AWS S3
|
|
||||||
*
|
|
||||||
* Dosya yapısı:
|
* Dosya yapısı:
|
||||||
* /data/media/
|
* {basePath}/
|
||||||
* ├── {projectId}/
|
* ├── {projectId}/
|
||||||
* │ ├── scenes/
|
* │ ├── scenes/
|
||||||
* │ │ ├── scene-001-video.mp4
|
* │ │ ├── scene-001-video.mp4
|
||||||
@@ -21,12 +18,15 @@ import * as crypto from 'crypto';
|
|||||||
* │ ├── audio/
|
* │ ├── audio/
|
||||||
* │ │ ├── narration.mp3
|
* │ │ ├── narration.mp3
|
||||||
* │ │ └── music.mp3
|
* │ │ └── music.mp3
|
||||||
|
* │ ├── images/
|
||||||
|
* │ │ ├── scene-001.png
|
||||||
|
* │ │ └── scene-002.png
|
||||||
* │ ├── output/
|
* │ ├── output/
|
||||||
* │ │ ├── final-video.mp4
|
* │ │ ├── final-xxxx.mp4
|
||||||
* │ │ └── thumbnail.jpg
|
* │ │ └── thumbnail.jpg
|
||||||
* │ └── subtitles/
|
* │ └── subtitles/
|
||||||
* │ └── captions.srt
|
* │ └── captions.srt
|
||||||
* └── temp/ (otomatik temizlenir)
|
* └── temp/ (otomatik temizlenir)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface UploadResult {
|
export interface UploadResult {
|
||||||
@@ -38,10 +38,9 @@ export interface UploadResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface StorageConfig {
|
export interface StorageConfig {
|
||||||
provider: 'local' | 's3' | 'r2';
|
provider: 'local';
|
||||||
basePath: string;
|
basePath: string;
|
||||||
bucket: string;
|
publicBaseUrl: string;
|
||||||
cdnUrl?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -50,101 +49,186 @@ export class StorageService {
|
|||||||
private readonly config: StorageConfig;
|
private readonly config: StorageConfig;
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
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 = {
|
this.config = {
|
||||||
provider: provider as StorageConfig['provider'],
|
provider: 'local',
|
||||||
basePath: this.configService.get<string>('STORAGE_LOCAL_PATH', './data/media'),
|
basePath: path.resolve(basePath),
|
||||||
bucket: this.configService.get<string>('STORAGE_BUCKET', 'contentgen-media'),
|
publicBaseUrl: cdnUrl || `http://localhost:${port}/media`,
|
||||||
cdnUrl: this.configService.get<string>('STORAGE_CDN_URL'),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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 {
|
getSceneVideoKey(projectId: string, sceneOrder: number): string {
|
||||||
return `${projectId}/scenes/scene-${String(sceneOrder).padStart(3, '0')}-video.mp4`;
|
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 {
|
getSceneAudioKey(projectId: string, sceneOrder: number): string {
|
||||||
return `${projectId}/audio/scene-${String(sceneOrder).padStart(3, '0')}-narration.mp3`;
|
return `${projectId}/audio/scene-${String(sceneOrder).padStart(3, '0')}-narration.mp3`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
getSceneImageKey(projectId: string, sceneOrder: number, ext = 'png'): string {
|
||||||
* Final video için anahtar oluştur
|
return `${projectId}/images/scene-${String(sceneOrder).padStart(3, '0')}.${ext}`;
|
||||||
*/
|
}
|
||||||
|
|
||||||
getFinalVideoKey(projectId: string): string {
|
getFinalVideoKey(projectId: string): string {
|
||||||
const hash = crypto.randomBytes(4).toString('hex');
|
const hash = crypto.randomBytes(4).toString('hex');
|
||||||
return `${projectId}/output/final-${hash}.mp4`;
|
return `${projectId}/output/final-${hash}.mp4`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Thumbnail için anahtar oluştur
|
|
||||||
*/
|
|
||||||
getThumbnailKey(projectId: string): string {
|
getThumbnailKey(projectId: string): string {
|
||||||
return `${projectId}/output/thumbnail.jpg`;
|
return `${projectId}/output/thumbnail.jpg`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Altyazı dosyası için anahtar oluştur
|
|
||||||
*/
|
|
||||||
getSubtitleKey(projectId: string): string {
|
getSubtitleKey(projectId: string): string {
|
||||||
return `${projectId}/subtitles/captions.srt`;
|
return `${projectId}/subtitles/captions.srt`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Müzik dosyası için anahtar oluştur
|
|
||||||
*/
|
|
||||||
getMusicKey(projectId: string): string {
|
getMusicKey(projectId: string): string {
|
||||||
return `${projectId}/audio/background-music.mp3`;
|
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> {
|
async upload(key: string, data: Buffer, mimeType: string): Promise<UploadResult> {
|
||||||
if (this.config.provider === 'local') {
|
const filePath = path.join(this.config.basePath, key);
|
||||||
return this.uploadLocal(key, data, mimeType);
|
const dir = path.dirname(filePath);
|
||||||
}
|
|
||||||
|
|
||||||
// S3/R2 desteği sonra eklenecek
|
await fs.mkdir(dir, { recursive: true });
|
||||||
return this.uploadLocal(key, data, mimeType);
|
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> {
|
async download(key: string): Promise<Buffer> {
|
||||||
if (this.config.provider === 'local') {
|
const filePath = path.join(this.config.basePath, key);
|
||||||
return this.downloadLocal(key);
|
return fs.readFile(filePath);
|
||||||
}
|
|
||||||
|
|
||||||
return this.downloadLocal(key);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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> {
|
async delete(key: string): Promise<void> {
|
||||||
if (this.config.provider === 'local') {
|
const filePath = path.join(this.config.basePath, key);
|
||||||
return this.deleteLocal(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> {
|
async cleanupProject(projectId: string): Promise<void> {
|
||||||
const projectDir = path.join(this.config.basePath, projectId);
|
const projectDir = path.join(this.config.basePath, projectId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.rm(projectDir, { recursive: true, force: true });
|
await fs.rm(projectDir, { recursive: true, force: true });
|
||||||
this.logger.log(`🗑️ Proje dosyaları silindi: ${projectId}`);
|
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 {
|
getPublicUrl(key: string): string {
|
||||||
if (this.config.cdnUrl) {
|
return `${this.config.publicBaseUrl}/${key}`;
|
||||||
return `${this.config.cdnUrl}/${key}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.config.provider === 'local') {
|
|
||||||
return `/media/${key}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `https://${this.config.bucket}.r2.dev/${key}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Private: Lokal dosya sistemi ──────────────────────────────────
|
// ── Private Helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
private async uploadLocal(key: string, data: Buffer, mimeType: string): Promise<UploadResult> {
|
private async listFilesRecursive(dir: string, prefix: string): Promise<string[]> {
|
||||||
const filePath = path.join(this.config.basePath, key);
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||||
const dir = path.dirname(filePath);
|
const files: string[] = [];
|
||||||
|
|
||||||
await fs.mkdir(dir, { recursive: true });
|
for (const entry of entries) {
|
||||||
await fs.writeFile(filePath, data);
|
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||||
|
if (entry.isDirectory()) {
|
||||||
this.logger.debug(`📥 Dosya yüklendi: ${key} (${data.length} bytes)`);
|
files.push(...(await this.listFilesRecursive(path.join(dir, entry.name), relative)));
|
||||||
|
} else {
|
||||||
return {
|
files.push(relative);
|
||||||
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ç
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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]}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { BaseController } from '../../common/base';
|
import { BaseController } from '../../common/base';
|
||||||
import { UsersService } from './users.service';
|
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
|
// Override create to require admin role
|
||||||
@Roles('admin')
|
@Roles('admin')
|
||||||
async create(
|
async create(
|
||||||
|
|||||||
@@ -546,4 +546,44 @@ export class VideoAiService {
|
|||||||
|
|
||||||
return parsed;
|
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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user