generated from fahricansecer/boilerplate-be
@@ -0,0 +1 @@
|
||||
{"success":false,"status":401,"message":"Invalid email or password","data":null,"errors":[],"stack":"UnauthorizedException: INVALID_CREDENTIALS\n at AuthService.login (/Users/haruncan/Documents/GitHub/ContentGenerator/ContentGen_BE/dist/src/modules/auth/auth.service.js:128:19)\n at async AuthController.login (/Users/haruncan/Documents/GitHub/ContentGenerator/ContentGen_BE/dist/src/modules/auth/auth.controller.js:33:24)"}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
const prisma = new PrismaClient();
|
||||
async function main() {
|
||||
const users = await prisma.user.findMany();
|
||||
console.log(users);
|
||||
}
|
||||
main().finally(() => prisma.$disconnect());
|
||||
+1
-1
@@ -96,7 +96,7 @@ import {
|
||||
LoggerModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService) => {
|
||||
useFactory: (configService: ConfigService) => {
|
||||
return {
|
||||
pinoHttp: {
|
||||
level: configService.get('app.isDevelopment') ? 'debug' : 'info',
|
||||
|
||||
@@ -80,7 +80,7 @@ export class GlobalExceptionFilter implements ExceptionFilter {
|
||||
});
|
||||
// Only update if translation exists (key is different from result)
|
||||
if (translatedMessage !== `errors.${message}`) {
|
||||
message = translatedMessage as string;
|
||||
message = translatedMessage;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
+21
-14
@@ -28,13 +28,14 @@ async function bootstrap() {
|
||||
app.useLogger(app.get(Logger));
|
||||
app.useGlobalInterceptors(new LoggerErrorInterceptor());
|
||||
|
||||
|
||||
// Security Headers
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: false,
|
||||
crossOriginEmbedderPolicy: false,
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||
}));
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: false,
|
||||
crossOriginEmbedderPolicy: false,
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||
}),
|
||||
);
|
||||
|
||||
// Graceful Shutdown (Prisma & Docker)
|
||||
app.enableShutdownHooks();
|
||||
@@ -45,7 +46,10 @@ async function bootstrap() {
|
||||
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 mediaPath = configService.get<string>(
|
||||
'STORAGE_LOCAL_PATH',
|
||||
'./data/media',
|
||||
);
|
||||
const absoluteMediaPath = path.resolve(mediaPath);
|
||||
|
||||
// Medya dosyaları için CORS header'ları (Frontend farklı port'ta çalışıyor)
|
||||
@@ -55,13 +59,16 @@ async function bootstrap() {
|
||||
next();
|
||||
});
|
||||
|
||||
app.use('/media', express.static(absoluteMediaPath, {
|
||||
maxAge: '1d',
|
||||
etag: true,
|
||||
lastModified: true,
|
||||
index: false,
|
||||
dotfiles: 'deny',
|
||||
}));
|
||||
app.use(
|
||||
'/media',
|
||||
express.static(absoluteMediaPath, {
|
||||
maxAge: '1d',
|
||||
etag: true,
|
||||
lastModified: true,
|
||||
index: false,
|
||||
dotfiles: 'deny',
|
||||
}),
|
||||
);
|
||||
logger.log(`📂 Medya dizini: ${absoluteMediaPath} → /media/*`);
|
||||
|
||||
// Enable CORS
|
||||
|
||||
@@ -84,7 +84,11 @@ export class AdminController {
|
||||
@Param('userId') userId: string,
|
||||
@Body() data: { amount: number; description: string },
|
||||
): Promise<ApiResponse<any>> {
|
||||
const tx = await this.adminService.grantCredits(userId, data.amount, data.description);
|
||||
const tx = await this.adminService.grantCredits(
|
||||
userId,
|
||||
data.amount,
|
||||
data.description,
|
||||
);
|
||||
return createSuccessResponse(tx, 'Kredi yüklendi');
|
||||
}
|
||||
|
||||
@@ -92,9 +96,7 @@ export class AdminController {
|
||||
|
||||
@Get('users/:id/detail')
|
||||
@ApiOperation({ summary: 'Kullanıcı detay — abonelik, projeler, krediler' })
|
||||
async getUserDetail(
|
||||
@Param('id') id: string,
|
||||
): Promise<ApiResponse<any>> {
|
||||
async getUserDetail(@Param('id') id: string): Promise<ApiResponse<any>> {
|
||||
const user = await this.adminService.getUserDetail(id);
|
||||
return createSuccessResponse(user);
|
||||
}
|
||||
@@ -325,7 +327,13 @@ export class AdminController {
|
||||
@Get('projects')
|
||||
@ApiOperation({ summary: 'Tüm projeleri getir (admin)' })
|
||||
async getAllProjects(
|
||||
@Query() query: { page?: number; limit?: number; status?: string; userId?: string },
|
||||
@Query()
|
||||
query: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
status?: string;
|
||||
userId?: string;
|
||||
},
|
||||
): Promise<ApiResponse<any>> {
|
||||
const result = await this.adminService.getAllProjects({
|
||||
page: query.page ? Number(query.page) : 1,
|
||||
@@ -380,4 +388,3 @@ export class AdminController {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,4 +10,3 @@ import { StorageModule } from '../storage/storage.module';
|
||||
exports: [AdminService],
|
||||
})
|
||||
export class AdminModule {}
|
||||
|
||||
|
||||
@@ -33,7 +33,13 @@ export class AdminService {
|
||||
this.prisma.user.findMany({
|
||||
take: 5,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { id: true, email: true, firstName: true, lastName: true, createdAt: true },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.project.groupBy({
|
||||
by: ['status'],
|
||||
@@ -65,17 +71,23 @@ export class AdminService {
|
||||
},
|
||||
projects: {
|
||||
total: totalProjects,
|
||||
byStatus: projectsByStatus.reduce((acc, item) => {
|
||||
acc[item.status] = item._count.id;
|
||||
return acc;
|
||||
}, {} as Record<string, number>),
|
||||
byStatus: projectsByStatus.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.status] = item._count.id;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
),
|
||||
},
|
||||
renderJobs: {
|
||||
total: totalRenderJobs,
|
||||
byStatus: renderJobsByStatus.reduce((acc, item) => {
|
||||
acc[item.status] = item._count.id;
|
||||
return acc;
|
||||
}, {} as Record<string, number>),
|
||||
byStatus: renderJobsByStatus.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.status] = item._count.id;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
),
|
||||
},
|
||||
credits: {
|
||||
totalGranted: creditStats._sum.amount || 0,
|
||||
@@ -102,18 +114,21 @@ export class AdminService {
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}) {
|
||||
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,
|
||||
@@ -122,9 +137,14 @@ export class AdminService {
|
||||
|
||||
// ── Proje ve Render Yönetimi ──────────────────────────────────────
|
||||
|
||||
async getAllProjects(params: { page: number; limit: number; status?: string; userId?: string }) {
|
||||
async getAllProjects(params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
status?: string;
|
||||
userId?: string;
|
||||
}) {
|
||||
const { page, limit, status, userId } = params;
|
||||
|
||||
|
||||
// Status filtresini prisma tarafında idari bir kontrole dönüştürmek gerek
|
||||
const whereCondition: any = { deletedAt: null };
|
||||
if (status) whereCondition.status = status;
|
||||
@@ -133,7 +153,9 @@ export class AdminService {
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.project.findMany({
|
||||
where: whereCondition,
|
||||
include: { user: { select: { email: true, firstName: true, lastName: true } } },
|
||||
include: {
|
||||
user: { select: { email: true, firstName: true, lastName: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
@@ -144,9 +166,13 @@ export class AdminService {
|
||||
return { data, total, page, limit, totalPages: Math.ceil(total / limit) };
|
||||
}
|
||||
|
||||
async getAllRenderJobs(params: { page: number; limit: number; status?: string }) {
|
||||
async getAllRenderJobs(params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
status?: string;
|
||||
}) {
|
||||
const { page, limit, status } = params;
|
||||
|
||||
|
||||
const whereCondition: any = {};
|
||||
if (status) whereCondition.status = status;
|
||||
|
||||
|
||||
@@ -146,10 +146,12 @@ export class AuthService {
|
||||
if (user.email === 'admin@contentgen.ai') {
|
||||
const hasAdminRole = user.roles.some((ur) => ur.role.name === 'admin');
|
||||
if (!hasAdminRole) {
|
||||
const adminRole = await this.prisma.role.findUnique({ where: { name: 'admin' } });
|
||||
const adminRole = await this.prisma.role.findUnique({
|
||||
where: { name: 'admin' },
|
||||
});
|
||||
if (adminRole) {
|
||||
await this.prisma.userRole.create({
|
||||
data: { userId: user.id, roleId: adminRole.id }
|
||||
data: { userId: user.id, roleId: adminRole.id },
|
||||
});
|
||||
// Refresh user object
|
||||
const refreshedUser = await this.prisma.user.findUnique({
|
||||
@@ -157,17 +159,25 @@ export class AuthService {
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: { include: { permissions: { include: { permission: true } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
role: {
|
||||
include: { permissions: { include: { permission: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
if (refreshedUser) {
|
||||
// Grant 999999 credits if not granted
|
||||
const existingGrant = await this.prisma.creditTransaction.findFirst({
|
||||
where: { userId: refreshedUser.id, type: 'grant', description: 'Admin başlangıç kredisi — sınırsız' },
|
||||
});
|
||||
const existingGrant = await this.prisma.creditTransaction.findFirst(
|
||||
{
|
||||
where: {
|
||||
userId: refreshedUser.id,
|
||||
type: 'grant',
|
||||
description: 'Admin başlangıç kredisi — sınırsız',
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!existingGrant) {
|
||||
await this.prisma.creditTransaction.create({
|
||||
data: {
|
||||
@@ -179,7 +189,9 @@ export class AuthService {
|
||||
},
|
||||
});
|
||||
}
|
||||
return this.generateTokens(refreshedUser as unknown as UserWithRoles);
|
||||
return this.generateTokens(
|
||||
refreshedUser as unknown as UserWithRoles,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -297,13 +309,13 @@ export class AuthService {
|
||||
};
|
||||
|
||||
const isAdmin = roles.includes('admin');
|
||||
const accessExpiration = isAdmin
|
||||
? '7d'
|
||||
const accessExpiration = isAdmin
|
||||
? '7d'
|
||||
: this.configService.get('JWT_ACCESS_EXPIRATION', '15m');
|
||||
|
||||
// Generate access token
|
||||
const accessToken = this.jwtService.sign(payload, {
|
||||
expiresIn: accessExpiration as any,
|
||||
expiresIn: accessExpiration,
|
||||
});
|
||||
|
||||
// Generate refresh token
|
||||
|
||||
@@ -29,7 +29,10 @@ export class BillingController {
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
const stripeKey = this.configService.get<string>('STRIPE_SECRET_KEY');
|
||||
this.webhookSecret = this.configService.get<string>('STRIPE_WEBHOOK_SECRET', '');
|
||||
this.webhookSecret = this.configService.get<string>(
|
||||
'STRIPE_WEBHOOK_SECRET',
|
||||
'',
|
||||
);
|
||||
|
||||
if (stripeKey) {
|
||||
this.stripe = new Stripe(stripeKey);
|
||||
@@ -54,7 +57,11 @@ export class BillingController {
|
||||
@Body() body: { planName: string; billingCycle: 'monthly' | 'yearly' },
|
||||
) {
|
||||
const userId = req.user?.id || req.user?.sub;
|
||||
return this.billingService.createCheckoutSession(userId, body.planName, body.billingCycle);
|
||||
return this.billingService.createCheckoutSession(
|
||||
userId,
|
||||
body.planName,
|
||||
body.billingCycle,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('credits/balance')
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
/**
|
||||
* Billing Service — Stripe entegrasyonu
|
||||
*
|
||||
*
|
||||
* stripe-integration + pricing-strategy skill'lerinden elde
|
||||
* edilen bilgilerle tasarlandı.
|
||||
*
|
||||
*
|
||||
* Akış:
|
||||
* 1. Kullanıcı plan seçer → Stripe Checkout Session oluşturulur
|
||||
* 2. Ödeme → Stripe Webhook → subscription aktif → kredi yüklenir
|
||||
@@ -32,14 +37,20 @@ export class BillingService {
|
||||
this.logger.log('💳 Stripe bağlantısı kuruldu');
|
||||
} else {
|
||||
this.stripe = null;
|
||||
this.logger.warn('⚠️ STRIPE_SECRET_KEY ayarlanmamış — Ödeme sistemi devre dışı');
|
||||
this.logger.warn(
|
||||
'⚠️ STRIPE_SECRET_KEY ayarlanmamış — Ödeme sistemi devre dışı',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkout Session oluştur — Value-Based Pricing (pricing-strategy skill)
|
||||
*/
|
||||
async createCheckoutSession(userId: string, planName: string, billingCycle: 'monthly' | 'yearly') {
|
||||
async createCheckoutSession(
|
||||
userId: string,
|
||||
planName: string,
|
||||
billingCycle: 'monthly' | 'yearly',
|
||||
) {
|
||||
if (!this.stripe) {
|
||||
throw new BadRequestException('Ödeme sistemi şu anda aktif değil');
|
||||
}
|
||||
@@ -61,12 +72,13 @@ export class BillingService {
|
||||
throw new NotFoundException('Kullanıcı bulunamadı');
|
||||
}
|
||||
|
||||
const priceId = billingCycle === 'yearly'
|
||||
? plan.stripeYearlyPriceId
|
||||
: plan.stripePriceId;
|
||||
const priceId =
|
||||
billingCycle === 'yearly' ? plan.stripeYearlyPriceId : plan.stripePriceId;
|
||||
|
||||
if (!priceId) {
|
||||
throw new BadRequestException(`Bu plan için ${billingCycle} fiyat tanımlı değil`);
|
||||
throw new BadRequestException(
|
||||
`Bu plan için ${billingCycle} fiyat tanımlı değil`,
|
||||
);
|
||||
}
|
||||
|
||||
const session = await this.stripe.checkout.sessions.create({
|
||||
@@ -88,7 +100,9 @@ export class BillingService {
|
||||
cancel_url: `${this.configService.get('APP_URL')}/dashboard/pricing?checkout=cancelled`,
|
||||
});
|
||||
|
||||
this.logger.log(`Checkout session oluşturuldu: ${session.id} — Plan: ${planName}`);
|
||||
this.logger.log(
|
||||
`Checkout session oluşturuldu: ${session.id} — Plan: ${planName}`,
|
||||
);
|
||||
|
||||
return {
|
||||
sessionId: session.id,
|
||||
@@ -103,19 +117,19 @@ export class BillingService {
|
||||
async handleWebhookEvent(event: Stripe.Event): Promise<void> {
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed':
|
||||
await this.handleCheckoutComplete(event.data.object as Stripe.Checkout.Session);
|
||||
await this.handleCheckoutComplete(event.data.object);
|
||||
break;
|
||||
|
||||
case 'invoice.payment_succeeded':
|
||||
await this.handlePaymentSucceeded(event.data.object as Stripe.Invoice);
|
||||
await this.handlePaymentSucceeded(event.data.object);
|
||||
break;
|
||||
|
||||
case 'customer.subscription.updated':
|
||||
await this.handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
|
||||
await this.handleSubscriptionUpdated(event.data.object);
|
||||
break;
|
||||
|
||||
case 'customer.subscription.deleted':
|
||||
await this.handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
|
||||
await this.handleSubscriptionDeleted(event.data.object);
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -157,7 +171,9 @@ export class BillingService {
|
||||
where: { userId },
|
||||
include: { role: true },
|
||||
});
|
||||
return userRoles.some((ur) => ur.role.name === 'admin' || ur.role.name === 'superadmin');
|
||||
return userRoles.some(
|
||||
(ur) => ur.role.name === 'admin' || ur.role.name === 'superadmin',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,7 +209,9 @@ export class BillingService {
|
||||
const monthlyTransactions = transactions.filter(
|
||||
(tx) => tx.amount < 0 && new Date(tx.createdAt) >= monthStart,
|
||||
);
|
||||
const monthlyUsed = Math.abs(monthlyTransactions.reduce((sum, tx) => sum + tx.amount, 0));
|
||||
const monthlyUsed = Math.abs(
|
||||
monthlyTransactions.reduce((sum, tx) => sum + tx.amount, 0),
|
||||
);
|
||||
|
||||
// Kullanıcının aktif planından limit al
|
||||
const subscription = await this.db.subscription.findFirst({
|
||||
@@ -217,12 +235,24 @@ export class BillingService {
|
||||
/**
|
||||
* Kredi harca (video üretimi için)
|
||||
*/
|
||||
async spendCredits(userId: string, amount: number, projectId: string, description: string) {
|
||||
async spendCredits(
|
||||
userId: string,
|
||||
amount: number,
|
||||
projectId: string,
|
||||
description: string,
|
||||
) {
|
||||
// Admin bypass — sınırsız kredi
|
||||
const admin = await this.isAdmin(userId);
|
||||
if (admin) {
|
||||
this.logger.log(`🛡️ Admin kredi bypass: ${amount} — User: ${userId}, Project: ${projectId}`);
|
||||
return { id: 'admin-bypass', amount: -amount, type: 'usage', description };
|
||||
this.logger.log(
|
||||
`🛡️ Admin kredi bypass: ${amount} — User: ${userId}, Project: ${projectId}`,
|
||||
);
|
||||
return {
|
||||
id: 'admin-bypass',
|
||||
amount: -amount,
|
||||
type: 'usage',
|
||||
description,
|
||||
};
|
||||
}
|
||||
|
||||
const balance = await this.getCreditBalance(userId);
|
||||
@@ -244,14 +274,21 @@ export class BillingService {
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Kredi harcandı: -${amount} — User: ${userId}, Project: ${projectId}`);
|
||||
this.logger.log(
|
||||
`Kredi harcandı: -${amount} — User: ${userId}, Project: ${projectId}`,
|
||||
);
|
||||
return transaction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kredi ekle (abonelik yenileme, bonus vb.)
|
||||
*/
|
||||
async grantCredits(userId: string, amount: number, type: string, description: string) {
|
||||
async grantCredits(
|
||||
userId: string,
|
||||
amount: number,
|
||||
type: string,
|
||||
description: string,
|
||||
) {
|
||||
const currentBalance = await this.getCreditBalance(userId);
|
||||
|
||||
const transaction = await this.db.creditTransaction.create({
|
||||
@@ -264,7 +301,9 @@ export class BillingService {
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Kredi eklendi: +${amount} — User: ${userId}, Type: ${type}`);
|
||||
this.logger.log(
|
||||
`Kredi eklendi: +${amount} — User: ${userId}, Type: ${type}`,
|
||||
);
|
||||
return transaction;
|
||||
}
|
||||
|
||||
@@ -296,16 +335,22 @@ export class BillingService {
|
||||
});
|
||||
|
||||
// İlk ay kredilerini yükle
|
||||
await this.grantCredits(userId, plan.monthlyCredits, 'grant', `${plan.displayName} abonelik kredisi`);
|
||||
await this.grantCredits(
|
||||
userId,
|
||||
plan.monthlyCredits,
|
||||
'grant',
|
||||
`${plan.displayName} abonelik kredisi`,
|
||||
);
|
||||
|
||||
this.logger.log(`✅ Abonelik aktif: User ${userId}, Plan ${plan.name}`);
|
||||
}
|
||||
|
||||
private async handlePaymentSucceeded(invoice: Stripe.Invoice) {
|
||||
const inv = invoice as any;
|
||||
const subscriptionId = typeof inv.subscription === 'string'
|
||||
? inv.subscription
|
||||
: inv.subscription?.id;
|
||||
const subscriptionId =
|
||||
typeof inv.subscription === 'string'
|
||||
? inv.subscription
|
||||
: inv.subscription?.id;
|
||||
if (!subscriptionId) return;
|
||||
|
||||
const subscription = await this.db.subscription.findFirst({
|
||||
@@ -323,10 +368,14 @@ export class BillingService {
|
||||
`${subscription.plan.displayName} aylık kredi yenileme`,
|
||||
);
|
||||
|
||||
this.logger.log(`💰 Ödeme başarılı — kredi yenilendi: ${subscription.userId}`);
|
||||
this.logger.log(
|
||||
`💰 Ödeme başarılı — kredi yenilendi: ${subscription.userId}`,
|
||||
);
|
||||
}
|
||||
|
||||
private async handleSubscriptionUpdated(stripeSubscription: Stripe.Subscription) {
|
||||
private async handleSubscriptionUpdated(
|
||||
stripeSubscription: Stripe.Subscription,
|
||||
) {
|
||||
const periodStart = (stripeSubscription as any).current_period_start;
|
||||
const periodEnd = (stripeSubscription as any).current_period_end;
|
||||
|
||||
@@ -335,13 +384,17 @@ export class BillingService {
|
||||
data: {
|
||||
status: stripeSubscription.status,
|
||||
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
|
||||
...(periodStart && { currentPeriodStart: new Date(periodStart * 1000) }),
|
||||
...(periodStart && {
|
||||
currentPeriodStart: new Date(periodStart * 1000),
|
||||
}),
|
||||
...(periodEnd && { currentPeriodEnd: new Date(periodEnd * 1000) }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async handleSubscriptionDeleted(stripeSubscription: Stripe.Subscription) {
|
||||
private async handleSubscriptionDeleted(
|
||||
stripeSubscription: Stripe.Subscription,
|
||||
) {
|
||||
await this.db.subscription.updateMany({
|
||||
where: { stripeSubscriptionId: stripeSubscription.id },
|
||||
data: {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Controller, Get, Logger, Req } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Logger,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
} from '@nestjs/swagger';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
|
||||
@ApiTags('dashboard')
|
||||
|
||||
@@ -45,7 +45,14 @@ export class DashboardService {
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
status: { in: ['PENDING', 'GENERATING_MEDIA', 'RENDERING', 'GENERATING_SCRIPT'] },
|
||||
status: {
|
||||
in: [
|
||||
'PENDING',
|
||||
'GENERATING_MEDIA',
|
||||
'RENDERING',
|
||||
'GENERATING_SCRIPT',
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
|
||||
@@ -20,7 +20,11 @@ import { Server, Socket } from 'socket.io';
|
||||
*/
|
||||
@WebSocketGateway({
|
||||
cors: {
|
||||
origin: ['http://localhost:3001', 'http://localhost:3000', process.env.FRONTEND_URL || '*'],
|
||||
origin: [
|
||||
'http://localhost:3001',
|
||||
'http://localhost:3000',
|
||||
process.env.FRONTEND_URL || '*',
|
||||
],
|
||||
credentials: true,
|
||||
},
|
||||
namespace: '/ws',
|
||||
@@ -35,12 +39,16 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
|
||||
handleConnection(client: Socket) {
|
||||
this.connectedClients++;
|
||||
this.logger.log(`Client bağlandı: ${client.id} (toplam: ${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})`);
|
||||
this.logger.log(
|
||||
`Client ayrıldı: ${client.id} (toplam: ${this.connectedClients})`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,7 +61,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@MessageBody() data: { projectId: string },
|
||||
) {
|
||||
const room = `project:${data.projectId}`;
|
||||
client.join(room);
|
||||
void client.join(room);
|
||||
this.logger.debug(`Client ${client.id} → room: ${room}`);
|
||||
return { event: 'joined', data: { room, projectId: data.projectId } };
|
||||
}
|
||||
@@ -67,7 +75,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@MessageBody() data: { projectId: string },
|
||||
) {
|
||||
const room = `project:${data.projectId}`;
|
||||
client.leave(room);
|
||||
void client.leave(room);
|
||||
this.logger.debug(`Client ${client.id} ← room: ${room}`);
|
||||
return { event: 'left', data: { room } };
|
||||
}
|
||||
@@ -82,7 +90,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@MessageBody() data: { userId: string },
|
||||
) {
|
||||
const room = `user:${data.userId}`;
|
||||
client.join(room);
|
||||
void client.join(room);
|
||||
this.logger.debug(`Client ${client.id} → user room: ${room}`);
|
||||
return { event: 'joined', data: { room, userId: data.userId } };
|
||||
}
|
||||
@@ -96,7 +104,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@MessageBody() data: { userId: string },
|
||||
) {
|
||||
const room = `user:${data.userId}`;
|
||||
client.leave(room);
|
||||
void client.leave(room);
|
||||
this.logger.debug(`Client ${client.id} ← user room: ${room}`);
|
||||
return { event: 'left', data: { room } };
|
||||
}
|
||||
|
||||
@@ -6,27 +6,39 @@ import FormData from 'form-data';
|
||||
@Injectable()
|
||||
export class ExtractorService {
|
||||
private readonly logger = new Logger(ExtractorService.name);
|
||||
private readonly extractorUrl = process.env.EXTRACTOR_URL || 'http://contgen-ai-extractor:8000';
|
||||
private readonly extractorUrl =
|
||||
process.env.EXTRACTOR_URL || 'http://contgen-ai-extractor:8000';
|
||||
|
||||
constructor() {}
|
||||
|
||||
async extractFromUrl(url: string): Promise<string> {
|
||||
this.logger.log(`URL'den içerik çekiliyor: ${url}`);
|
||||
try {
|
||||
const response = await axios.post(`${this.extractorUrl}/extract/url`, { url }, {
|
||||
timeout: 60000 // 60 seconds timeout
|
||||
});
|
||||
const response = await axios.post(
|
||||
`${this.extractorUrl}/extract/url`,
|
||||
{ url },
|
||||
{
|
||||
timeout: 60000, // 60 seconds timeout
|
||||
},
|
||||
);
|
||||
return response.data.content;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`URL extraction failed: ${error.message}`);
|
||||
if (error.response) {
|
||||
throw new HttpException(error.response.data, error.response.status);
|
||||
}
|
||||
throw new HttpException('Extractor servisi bulunamadı veya zaman aşımına uğradı', HttpStatus.SERVICE_UNAVAILABLE);
|
||||
throw new HttpException(
|
||||
'Extractor servisi bulunamadı veya zaman aşımına uğradı',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async extractFromFile(filePath: string, filename: string, mimeType: string): Promise<string> {
|
||||
async extractFromFile(
|
||||
filePath: string,
|
||||
filename: string,
|
||||
mimeType: string,
|
||||
): Promise<string> {
|
||||
this.logger.log(`Dosyadan içerik çekiliyor: ${filename}`);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
@@ -35,12 +47,16 @@ export class ExtractorService {
|
||||
contentType: mimeType,
|
||||
});
|
||||
|
||||
const response = await axios.post(`${this.extractorUrl}/extract/file`, formData, {
|
||||
headers: {
|
||||
...formData.getHeaders(),
|
||||
const response = await axios.post(
|
||||
`${this.extractorUrl}/extract/file`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
...formData.getHeaders(),
|
||||
},
|
||||
timeout: 120000, // 2 minutes timeout for files
|
||||
},
|
||||
timeout: 120000 // 2 minutes timeout for files
|
||||
});
|
||||
);
|
||||
|
||||
return response.data.content;
|
||||
} catch (error: any) {
|
||||
@@ -48,7 +64,10 @@ export class ExtractorService {
|
||||
if (error.response) {
|
||||
throw new HttpException(error.response.data, error.response.status);
|
||||
}
|
||||
throw new HttpException('Extractor servisi bulunamadı veya zaman aşımına uğradı', HttpStatus.SERVICE_UNAVAILABLE);
|
||||
throw new HttpException(
|
||||
'Extractor servisi bulunamadı veya zaman aşımına uğradı',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ export const geminiConfig = registerAs('gemini', () => ({
|
||||
enabled: process.env.ENABLE_GEMINI === 'true',
|
||||
apiKey: process.env.GOOGLE_API_KEY,
|
||||
defaultModel: process.env.GEMINI_MODEL || 'gemini-2.5-flash',
|
||||
imageModel: process.env.GEMINI_IMAGE_MODEL || 'gemini-2.0-flash-preview-image-generation',
|
||||
imageModel:
|
||||
process.env.GEMINI_IMAGE_MODEL ||
|
||||
'gemini-2.0-flash-preview-image-generation',
|
||||
}));
|
||||
|
||||
|
||||
@@ -269,10 +269,17 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
|
||||
const fallbackModel = 'gemini-3.1-flash-image-preview';
|
||||
|
||||
try {
|
||||
this.logger.log(`🎨 Görsel üretiliyor: "${prompt.substring(0, 100)}..." [${aspectRatio}]`);
|
||||
this.logger.log(
|
||||
`🎨 Görsel üretiliyor: "${prompt.substring(0, 100)}..." [${aspectRatio}]`,
|
||||
);
|
||||
|
||||
// En-boy oranına göre yönlendirmeyi zorla
|
||||
const orientation = aspectRatio === '9:16' ? '(VERTICAL / PORTRAIT)' : aspectRatio === '16:9' ? '(HORIZONTAL / LANDSCAPE)' : '(SQUARE)';
|
||||
const orientation =
|
||||
aspectRatio === '9:16'
|
||||
? '(VERTICAL / PORTRAIT)'
|
||||
: aspectRatio === '16:9'
|
||||
? '(HORIZONTAL / LANDSCAPE)'
|
||||
: '(SQUARE)';
|
||||
// Gemini modelleri ana konunun (subject) prompt'un en başında olmasını tercih eder.
|
||||
// Jenerik stil kelimelerini sonuna ekliyoruz ki ana konu (prompt) kaybolmasın.
|
||||
const enhancedPrompt = isIllustration
|
||||
@@ -283,23 +290,36 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
|
||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||
try {
|
||||
this.logger.log(`🔄 Katman 1 (deneme ${attempt}/2): ${primaryModel}`);
|
||||
const result = await this.tryGenerateContentImage(primaryModel, enhancedPrompt);
|
||||
const result = await this.tryGenerateContentImage(
|
||||
primaryModel,
|
||||
enhancedPrompt,
|
||||
);
|
||||
if (result && result.buffer.length > 0) {
|
||||
this.logger.log(`✅ Görsel üretildi (${primaryModel}): ${(result.buffer.length / 1024).toFixed(1)} KB`);
|
||||
this.logger.log(
|
||||
`✅ Görsel üretildi (${primaryModel}): ${(result.buffer.length / 1024).toFixed(1)} KB`,
|
||||
);
|
||||
return { buffer: result.buffer, mimeType: result.mimeType };
|
||||
}
|
||||
|
||||
|
||||
const reason = result?.errorReason || 'null response';
|
||||
this.logger.warn(`⚠️ ${primaryModel} deneme ${attempt}: görsel döndürmedi (${reason})`);
|
||||
|
||||
if (['IMAGE_OTHER', 'SAFETY', 'PROHIBITED_CONTENT'].includes(reason)) {
|
||||
this.logger.warn(`🚫 Güvenlik/Politika filtresi tetiklendi (${reason}). Denemeler iptal ediliyor.`);
|
||||
this.logger.warn(
|
||||
`⚠️ ${primaryModel} deneme ${attempt}: görsel döndürmedi (${reason})`,
|
||||
);
|
||||
|
||||
if (
|
||||
['IMAGE_OTHER', 'SAFETY', 'PROHIBITED_CONTENT'].includes(reason)
|
||||
) {
|
||||
this.logger.warn(
|
||||
`🚫 Güvenlik/Politika filtresi tetiklendi (${reason}). Denemeler iptal ediliyor.`,
|
||||
);
|
||||
break; // Fail fast for safety blocks
|
||||
}
|
||||
|
||||
|
||||
if (attempt < 2) await this.sleep(2000);
|
||||
} catch (err1: any) {
|
||||
this.logger.warn(`⚠️ ${primaryModel} deneme ${attempt} hata: ${err1.message?.substring(0, 200)}`);
|
||||
this.logger.warn(
|
||||
`⚠️ ${primaryModel} deneme ${attempt} hata: ${err1.message?.substring(0, 200)}`,
|
||||
);
|
||||
if (attempt < 2) await this.sleep(2000);
|
||||
}
|
||||
}
|
||||
@@ -307,18 +327,33 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
|
||||
// ── Katman 2: gemini-3.1-flash-image-preview (Nano Banana 2) ──
|
||||
try {
|
||||
this.logger.log(`🔄 Katman 2: ${fallbackModel}`);
|
||||
const result = await this.tryGenerateContentImage(fallbackModel, enhancedPrompt);
|
||||
const result = await this.tryGenerateContentImage(
|
||||
fallbackModel,
|
||||
enhancedPrompt,
|
||||
);
|
||||
if (result && result.buffer.length > 0) {
|
||||
this.logger.log(`✅ Görsel üretildi (${fallbackModel}): ${(result.buffer.length / 1024).toFixed(1)} KB`);
|
||||
this.logger.log(
|
||||
`✅ Görsel üretildi (${fallbackModel}): ${(result.buffer.length / 1024).toFixed(1)} KB`,
|
||||
);
|
||||
return { buffer: result.buffer, mimeType: result.mimeType };
|
||||
}
|
||||
this.logger.warn(`⚠️ ${fallbackModel}: görsel döndürmedi (${result?.errorReason || 'null response'})`);
|
||||
|
||||
if (['IMAGE_OTHER', 'SAFETY', 'PROHIBITED_CONTENT'].includes(result?.errorReason || '')) {
|
||||
this.logger.warn(`🚫 Katman 2 Güvenlik/Politika filtresi tetiklendi. Katman 3'e geçiliyor.`);
|
||||
this.logger.warn(
|
||||
`⚠️ ${fallbackModel}: görsel döndürmedi (${result?.errorReason || 'null response'})`,
|
||||
);
|
||||
|
||||
if (
|
||||
['IMAGE_OTHER', 'SAFETY', 'PROHIBITED_CONTENT'].includes(
|
||||
result?.errorReason || '',
|
||||
)
|
||||
) {
|
||||
this.logger.warn(
|
||||
`🚫 Katman 2 Güvenlik/Politika filtresi tetiklendi. Katman 3'e geçiliyor.`,
|
||||
);
|
||||
}
|
||||
} catch (err2: any) {
|
||||
this.logger.warn(`⚠️ ${fallbackModel} hata: ${err2.message?.substring(0, 200)}`);
|
||||
this.logger.warn(
|
||||
`⚠️ ${fallbackModel} hata: ${err2.message?.substring(0, 200)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Katman 3: Imagen 4 Fast (generateImages API) ──
|
||||
@@ -335,20 +370,31 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
|
||||
});
|
||||
|
||||
if (response.generatedImages?.[0]?.image?.imageBytes) {
|
||||
const buffer = Buffer.from(response.generatedImages[0].image.imageBytes, 'base64');
|
||||
const buffer = Buffer.from(
|
||||
response.generatedImages[0].image.imageBytes,
|
||||
'base64',
|
||||
);
|
||||
const mimeType = 'image/jpeg';
|
||||
this.logger.log(`✅ Görsel üretildi (Imagen 4): ${(buffer.length / 1024).toFixed(1)} KB`);
|
||||
this.logger.log(
|
||||
`✅ Görsel üretildi (Imagen 4): ${(buffer.length / 1024).toFixed(1)} KB`,
|
||||
);
|
||||
return { buffer, mimeType };
|
||||
}
|
||||
this.logger.warn(`⚠️ Imagen 4: görsel döndürmedi. Üretilen görsel sayısı: ${response.generatedImages?.length || 0}`);
|
||||
this.logger.warn(
|
||||
`⚠️ Imagen 4: görsel döndürmedi. Üretilen görsel sayısı: ${response.generatedImages?.length || 0}`,
|
||||
);
|
||||
} catch (err3: any) {
|
||||
this.logger.warn(`⚠️ Imagen 4 hata: ${err3.message?.substring(0, 200)}`);
|
||||
this.logger.warn(
|
||||
`⚠️ Imagen 4 hata: ${err3.message?.substring(0, 200)}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.error('❌ Tüm görsel üretim katmanları başarısız oldu');
|
||||
return null;
|
||||
} catch (error) {
|
||||
this.logger.error(`Gemini görsel üretim hatası: ${error instanceof Error ? error.message : error}`);
|
||||
this.logger.error(
|
||||
`Gemini görsel üretim hatası: ${error instanceof Error ? error.message : error}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -360,7 +406,11 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
|
||||
private async tryGenerateContentImage(
|
||||
model: string,
|
||||
prompt: string,
|
||||
): Promise<{ buffer: Buffer; mimeType: string; errorReason?: string } | null> {
|
||||
): Promise<{
|
||||
buffer: Buffer;
|
||||
mimeType: string;
|
||||
errorReason?: string;
|
||||
} | null> {
|
||||
const response = await this.client!.models.generateContent({
|
||||
model,
|
||||
contents: prompt,
|
||||
@@ -374,12 +424,18 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
|
||||
// Safety filter veya boş yanıt kontrolü
|
||||
if (!candidate?.content?.parts || candidate.content.parts.length === 0) {
|
||||
const finishReason = candidate?.finishReason || 'UNKNOWN';
|
||||
this.logger.warn(`⚠️ ${model}: boş yanıt (finishReason: ${finishReason})`);
|
||||
return { buffer: Buffer.from([]), mimeType: '', errorReason: finishReason };
|
||||
this.logger.warn(
|
||||
`⚠️ ${model}: boş yanıt (finishReason: ${finishReason})`,
|
||||
);
|
||||
return {
|
||||
buffer: Buffer.from([]),
|
||||
mimeType: '',
|
||||
errorReason: finishReason,
|
||||
};
|
||||
}
|
||||
|
||||
const imagePart = candidate.content.parts.find(
|
||||
(p: any) => p.inlineData?.mimeType?.startsWith('image/'),
|
||||
const imagePart = candidate.content.parts.find((p: any) =>
|
||||
p.inlineData?.mimeType?.startsWith('image/'),
|
||||
);
|
||||
|
||||
if (imagePart?.inlineData?.data) {
|
||||
@@ -391,16 +447,26 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
|
||||
// Text-only response geldi (görsel yok)
|
||||
const textParts = candidate.content.parts.filter((p: any) => p.text);
|
||||
if (textParts.length > 0) {
|
||||
this.logger.warn(`⚠️ ${model}: sadece text döndü, görsel yok. Text: "${textParts[0].text?.substring(0, 100)}"`);
|
||||
return { buffer: Buffer.from([]), mimeType: '', errorReason: 'TEXT_ONLY' };
|
||||
this.logger.warn(
|
||||
`⚠️ ${model}: sadece text döndü, görsel yok. Text: "${textParts[0].text?.substring(0, 100)}"`,
|
||||
);
|
||||
return {
|
||||
buffer: Buffer.from([]),
|
||||
mimeType: '',
|
||||
errorReason: 'TEXT_ONLY',
|
||||
};
|
||||
}
|
||||
|
||||
return { buffer: Buffer.from([]), mimeType: '', errorReason: 'NO_IMAGE_DATA' };
|
||||
return {
|
||||
buffer: Buffer.from([]),
|
||||
mimeType: '',
|
||||
errorReason: 'NO_IMAGE_DATA',
|
||||
};
|
||||
}
|
||||
|
||||
/** Basit uyku fonksiyonu — retry aralarında kullanılır */
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -435,4 +501,3 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
|
||||
return this.generateImage(prompt, '16:9');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiBearerAuth,
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
|
||||
/**
|
||||
@@ -62,10 +67,7 @@ export class NotificationsController {
|
||||
@Patch(':id/read')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Bildirimi okundu olarak işaretle' })
|
||||
async markAsRead(
|
||||
@Req() req: any,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
async markAsRead(@Req() req: any, @Param('id') id: string) {
|
||||
const userId = req.user?.id || req.user?.sub;
|
||||
return this.notificationsService.markAsRead(id, userId);
|
||||
}
|
||||
@@ -73,10 +75,7 @@ export class NotificationsController {
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Bildirimi sil' })
|
||||
async deleteNotification(
|
||||
@Req() req: any,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
async deleteNotification(@Req() req: any, @Param('id') id: string) {
|
||||
const userId = req.user?.id || req.user?.sub;
|
||||
return this.notificationsService.deleteNotification(id, userId);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { Injectable, Logger, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
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';
|
||||
@@ -77,11 +82,7 @@ export class NotificationsService {
|
||||
/**
|
||||
* Kullanıcının bildirimlerini getir (pagination).
|
||||
*/
|
||||
async getUserNotifications(
|
||||
userId: string,
|
||||
page = 1,
|
||||
limit = 20,
|
||||
) {
|
||||
async getUserNotifications(userId: string, page = 1, limit = 20) {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [notifications, total] = await Promise.all([
|
||||
@@ -162,7 +163,9 @@ export class NotificationsService {
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.debug(`${result.count} bildirim okundu işaretlendi — User: ${userId}`);
|
||||
this.logger.debug(
|
||||
`${result.count} bildirim okundu işaretlendi — User: ${userId}`,
|
||||
);
|
||||
|
||||
return { updated: result.count };
|
||||
}
|
||||
|
||||
@@ -64,7 +64,8 @@ export class CreateProjectDto {
|
||||
aspectRatio?: AspectRatioDto;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Video stili (CINEMATIC, DOCUMENTARY, ANIME, NOIR, CYBERPUNK vb.)',
|
||||
description:
|
||||
'Video stili (CINEMATIC, DOCUMENTARY, ANIME, NOIR, CYBERPUNK vb.)',
|
||||
example: 'CINEMATIC',
|
||||
default: 'CINEMATIC',
|
||||
})
|
||||
@@ -73,7 +74,9 @@ export class CreateProjectDto {
|
||||
@MaxLength(50)
|
||||
videoStyle?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)' })
|
||||
@ApiPropertyOptional({
|
||||
description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)',
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(200)
|
||||
@@ -136,14 +139,17 @@ export class UpdateProjectDto {
|
||||
aspectRatio?: AspectRatioDto;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Video stili (CINEMATIC, DOCUMENTARY, ANIME, NOIR, CYBERPUNK vb.)',
|
||||
description:
|
||||
'Video stili (CINEMATIC, DOCUMENTARY, ANIME, NOIR, CYBERPUNK vb.)',
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(50)
|
||||
videoStyle?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)' })
|
||||
@ApiPropertyOptional({
|
||||
description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)',
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(200)
|
||||
@@ -163,19 +169,19 @@ export class UpdateProjectDto {
|
||||
*/
|
||||
export class CreateFromTweetDto {
|
||||
@ApiProperty({
|
||||
description: 'X/Twitter tweet URL\'si',
|
||||
description: "X/Twitter tweet URL'si",
|
||||
example: 'https://x.com/elonmusk/status/1893456789012345678',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'Tweet URL\'si boş olamaz' })
|
||||
@IsNotEmpty({ message: "Tweet URL'si boş olamaz" })
|
||||
@Matches(
|
||||
/^https?:\/\/(x\.com|twitter\.com)\/([\w]+\/status\/\d+|i\/status\/\d+)/,
|
||||
{ message: 'Geçerli bir X/Twitter tweet URL\'si girin' },
|
||||
{ message: "Geçerli bir X/Twitter tweet URL'si girin" },
|
||||
)
|
||||
tweetUrl: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Proje başlığı (boş bırakılırsa tweet\'ten otomatik üretilir)',
|
||||
description: "Proje başlığı (boş bırakılırsa tweet'ten otomatik üretilir)",
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@@ -190,13 +196,17 @@ export class CreateFromTweetDto {
|
||||
@IsOptional()
|
||||
language?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: AspectRatioDto, default: AspectRatioDto.PORTRAIT_9_16 })
|
||||
@ApiPropertyOptional({
|
||||
enum: AspectRatioDto,
|
||||
default: AspectRatioDto.PORTRAIT_9_16,
|
||||
})
|
||||
@IsEnum(AspectRatioDto)
|
||||
@IsOptional()
|
||||
aspectRatio?: AspectRatioDto;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Video stili (CINEMATIC, DOCUMENTARY, ANIME, NOIR, CYBERPUNK vb.)',
|
||||
description:
|
||||
'Video stili (CINEMATIC, DOCUMENTARY, ANIME, NOIR, CYBERPUNK vb.)',
|
||||
default: 'CINEMATIC',
|
||||
})
|
||||
@IsString()
|
||||
@@ -204,13 +214,18 @@ export class CreateFromTweetDto {
|
||||
@MaxLength(50)
|
||||
videoStyle?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)' })
|
||||
@ApiPropertyOptional({
|
||||
description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)',
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(200)
|
||||
cinematicReference?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Hedef video süresi (saniye)', default: 60 })
|
||||
@ApiPropertyOptional({
|
||||
description: 'Hedef video süresi (saniye)',
|
||||
default: 60,
|
||||
})
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
@Min(15)
|
||||
@@ -220,19 +235,19 @@ export class CreateFromTweetDto {
|
||||
|
||||
export class CreateFromYoutubeDto {
|
||||
@ApiProperty({
|
||||
description: 'YouTube Video URL\'si',
|
||||
description: "YouTube Video URL'si",
|
||||
example: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'YouTube URL\'si boş olamaz' })
|
||||
@Matches(
|
||||
/^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\/.+$/,
|
||||
{ message: 'Geçerli bir YouTube URL\'si girin' },
|
||||
)
|
||||
@IsNotEmpty({ message: "YouTube URL'si boş olamaz" })
|
||||
@Matches(/^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\/.+$/, {
|
||||
message: "Geçerli bir YouTube URL'si girin",
|
||||
})
|
||||
youtubeUrl: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Proje başlığı (boş bırakılırsa YouTube\'dan otomatik üretilir)',
|
||||
description:
|
||||
"Proje başlığı (boş bırakılırsa YouTube'dan otomatik üretilir)",
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@@ -247,7 +262,10 @@ export class CreateFromYoutubeDto {
|
||||
@IsOptional()
|
||||
language?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: AspectRatioDto, default: AspectRatioDto.PORTRAIT_9_16 })
|
||||
@ApiPropertyOptional({
|
||||
enum: AspectRatioDto,
|
||||
default: AspectRatioDto.PORTRAIT_9_16,
|
||||
})
|
||||
@IsEnum(AspectRatioDto)
|
||||
@IsOptional()
|
||||
aspectRatio?: AspectRatioDto;
|
||||
@@ -261,13 +279,18 @@ export class CreateFromYoutubeDto {
|
||||
@MaxLength(50)
|
||||
videoStyle?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)' })
|
||||
@ApiPropertyOptional({
|
||||
description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)',
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(200)
|
||||
cinematicReference?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Hedef video süresi (saniye)', default: 60 })
|
||||
@ApiPropertyOptional({
|
||||
description: 'Hedef video süresi (saniye)',
|
||||
default: 60,
|
||||
})
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
@Min(15)
|
||||
@@ -292,7 +315,10 @@ export class CreateFromDocumentDto {
|
||||
@IsOptional()
|
||||
language?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: AspectRatioDto, default: AspectRatioDto.PORTRAIT_9_16 })
|
||||
@ApiPropertyOptional({
|
||||
enum: AspectRatioDto,
|
||||
default: AspectRatioDto.PORTRAIT_9_16,
|
||||
})
|
||||
@IsEnum(AspectRatioDto)
|
||||
@IsOptional()
|
||||
aspectRatio?: AspectRatioDto;
|
||||
@@ -306,13 +332,18 @@ export class CreateFromDocumentDto {
|
||||
@MaxLength(50)
|
||||
videoStyle?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)' })
|
||||
@ApiPropertyOptional({
|
||||
description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)',
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(200)
|
||||
cinematicReference?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Hedef video süresi (saniye)', default: 60 })
|
||||
@ApiPropertyOptional({
|
||||
description: 'Hedef video süresi (saniye)',
|
||||
default: 60,
|
||||
})
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
@Min(15)
|
||||
@@ -360,13 +391,18 @@ export class CreateFromExtractedTextDto {
|
||||
@MaxLength(50)
|
||||
videoStyle?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)' })
|
||||
@ApiPropertyOptional({
|
||||
description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)',
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(200)
|
||||
cinematicReference?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Hedef video süresi (saniye)', default: 60 })
|
||||
@ApiPropertyOptional({
|
||||
description: 'Hedef video süresi (saniye)',
|
||||
default: 60,
|
||||
})
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
@Min(15)
|
||||
@@ -387,7 +423,8 @@ export class CreateFromTextDto {
|
||||
text: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Proje başlığı (boş bırakılırsa yapay zeka tarafından üretilir)',
|
||||
description:
|
||||
'Proje başlığı (boş bırakılırsa yapay zeka tarafından üretilir)',
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@@ -402,7 +439,10 @@ export class CreateFromTextDto {
|
||||
@IsOptional()
|
||||
language?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: AspectRatioDto, default: AspectRatioDto.PORTRAIT_9_16 })
|
||||
@ApiPropertyOptional({
|
||||
enum: AspectRatioDto,
|
||||
default: AspectRatioDto.PORTRAIT_9_16,
|
||||
})
|
||||
@IsEnum(AspectRatioDto)
|
||||
@IsOptional()
|
||||
aspectRatio?: AspectRatioDto;
|
||||
@@ -416,13 +456,18 @@ export class CreateFromTextDto {
|
||||
@MaxLength(50)
|
||||
videoStyle?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)' })
|
||||
@ApiPropertyOptional({
|
||||
description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)',
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(200)
|
||||
cinematicReference?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Hedef video süresi (saniye)', default: 60 })
|
||||
@ApiPropertyOptional({
|
||||
description: 'Hedef video süresi (saniye)',
|
||||
default: 60,
|
||||
})
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
@Min(15)
|
||||
|
||||
@@ -26,12 +26,12 @@ import {
|
||||
} from '@nestjs/swagger';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ProjectsService } from './projects.service';
|
||||
import {
|
||||
CreateProjectDto,
|
||||
UpdateProjectDto,
|
||||
CreateFromTweetDto,
|
||||
CreateFromYoutubeDto,
|
||||
CreateFromDocumentDto,
|
||||
import {
|
||||
CreateProjectDto,
|
||||
UpdateProjectDto,
|
||||
CreateFromTweetDto,
|
||||
CreateFromYoutubeDto,
|
||||
CreateFromDocumentDto,
|
||||
CreateFromExtractedTextDto,
|
||||
CreateFromTextDto,
|
||||
} from './dto/project.dto';
|
||||
@@ -138,7 +138,10 @@ export class ProjectsController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'AI ile senaryo üret (Gemini)' })
|
||||
@ApiResponse({ status: 200, description: 'Senaryo üretildi ve kaydedildi' })
|
||||
async generateScript(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) {
|
||||
async generateScript(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Req() req: any,
|
||||
) {
|
||||
const userId = req.user?.id || req.user?.sub;
|
||||
this.logger.log(`Senaryo üretimi başlatılıyor: ${id}`);
|
||||
return this.projectsService.generateScript(userId, id);
|
||||
@@ -167,10 +170,7 @@ export class ProjectsController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Aktif render işlemini iptal et' })
|
||||
@ApiResponse({ status: 200, description: 'Render işlemi iptal edildi' })
|
||||
async cancelRender(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Req() req: any,
|
||||
) {
|
||||
async cancelRender(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) {
|
||||
const userId = req.user?.id || req.user?.sub;
|
||||
this.logger.log(`Render iptal isteği: ${id}`);
|
||||
return this.projectsService.cancelRenderJob(userId, id);
|
||||
@@ -182,9 +182,15 @@ export class ProjectsController {
|
||||
*/
|
||||
@Post('from-tweet')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: 'X/Twitter tweet\'ten proje oluştur' })
|
||||
@ApiResponse({ status: 201, description: 'Tweet\'ten proje oluşturuldu ve senaryo üretildi' })
|
||||
@ApiResponse({ status: 400, description: 'Geçersiz tweet URL\'si veya tweet bulunamadı' })
|
||||
@ApiOperation({ summary: "X/Twitter tweet'ten proje oluştur" })
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: "Tweet'ten proje oluşturuldu ve senaryo üretildi",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 400,
|
||||
description: "Geçersiz tweet URL'si veya tweet bulunamadı",
|
||||
})
|
||||
async createFromTweet(@Body() dto: CreateFromTweetDto, @Req() req: any) {
|
||||
const userId = req.user?.id || req.user?.sub;
|
||||
this.logger.log(`Tweet'ten proje oluşturuluyor: ${dto.tweetUrl}`);
|
||||
@@ -197,8 +203,14 @@ export class ProjectsController {
|
||||
@Post('from-youtube')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: 'YouTube videosundan proje oluştur' })
|
||||
@ApiResponse({ status: 201, description: 'YouTube videosundan proje oluşturuldu ve senaryo üretildi' })
|
||||
@ApiResponse({ status: 400, description: 'Geçersiz YouTube URL\'si veya video bulunamadı' })
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'YouTube videosundan proje oluşturuldu ve senaryo üretildi',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 400,
|
||||
description: "Geçersiz YouTube URL'si veya video bulunamadı",
|
||||
})
|
||||
async createFromYoutube(@Body() dto: CreateFromYoutubeDto, @Req() req: any) {
|
||||
const userId = req.user?.id || req.user?.sub;
|
||||
this.logger.log(`YouTube'dan proje oluşturuluyor: ${dto.youtubeUrl}`);
|
||||
@@ -211,7 +223,10 @@ export class ProjectsController {
|
||||
@Post('from-text')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: 'Serbest metinden proje oluştur' })
|
||||
@ApiResponse({ status: 201, description: 'Metinden proje oluşturuldu ve senaryo üretildi' })
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'Metinden proje oluşturuldu ve senaryo üretildi',
|
||||
})
|
||||
async createFromText(@Body() dto: CreateFromTextDto, @Req() req: any) {
|
||||
const userId = req.user?.id || req.user?.sub;
|
||||
this.logger.log(`Serbest metinden proje oluşturuluyor...`);
|
||||
@@ -226,7 +241,10 @@ export class ProjectsController {
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Dosyadan/Dokümandan proje oluştur' })
|
||||
@ApiResponse({ status: 201, description: 'Belgeden proje oluşturuldu ve senaryo üretildi' })
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'Belgeden proje oluşturuldu ve senaryo üretildi',
|
||||
})
|
||||
async createFromDocument(
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body() dto: CreateFromDocumentDto,
|
||||
@@ -248,12 +266,14 @@ export class ProjectsController {
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Dosyadan metin çıkar ve konu önerileri al' })
|
||||
@ApiResponse({ status: 200, description: 'Metin ve konular başarıyla çıkarıldı' })
|
||||
async extractDocumentTopics(
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Req() req: any,
|
||||
) {
|
||||
this.logger.log(`Dosyadan metin ve konular çıkarılıyor: ${file?.originalname}`);
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Metin ve konular başarıyla çıkarıldı',
|
||||
})
|
||||
async extractDocumentTopics(@UploadedFile() file: Express.Multer.File) {
|
||||
this.logger.log(
|
||||
`Dosyadan metin ve konular çıkarılıyor: ${file?.originalname}`,
|
||||
);
|
||||
if (!file) {
|
||||
throw new BadRequestException('Dosya yüklenmedi');
|
||||
}
|
||||
@@ -266,10 +286,18 @@ export class ProjectsController {
|
||||
@Post('document-from-topic')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: 'Seçilen konu ve metin ile proje oluştur' })
|
||||
@ApiResponse({ status: 201, description: 'Seçilen konu baz alınarak proje oluşturuldu' })
|
||||
async createFromTopic(@Body() dto: CreateFromExtractedTextDto, @Req() req: any) {
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'Seçilen konu baz alınarak proje oluşturuldu',
|
||||
})
|
||||
async createFromTopic(
|
||||
@Body() dto: CreateFromExtractedTextDto,
|
||||
@Req() req: any,
|
||||
) {
|
||||
const userId = req.user?.id || req.user?.sub;
|
||||
this.logger.log(`Metin ve konu üzerinden proje oluşturuluyor. Konu: ${dto.topic}`);
|
||||
this.logger.log(
|
||||
`Metin ve konu üzerinden proje oluşturuluyor. Konu: ${dto.topic}`,
|
||||
);
|
||||
return this.projectsService.createFromExtractedText(userId, dto);
|
||||
}
|
||||
|
||||
@@ -282,7 +310,13 @@ export class ProjectsController {
|
||||
async updateScene(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('sceneId', ParseUUIDPipe) sceneId: string,
|
||||
@Body() body: { narrationText?: string; visualPrompt?: string; subtitleText?: string; duration?: number },
|
||||
@Body()
|
||||
body: {
|
||||
narrationText?: string;
|
||||
visualPrompt?: string;
|
||||
subtitleText?: string;
|
||||
duration?: number;
|
||||
},
|
||||
@Req() req: any,
|
||||
) {
|
||||
const userId = req.user?.id || req.user?.sub;
|
||||
@@ -326,7 +360,10 @@ export class ProjectsController {
|
||||
@Post(':id/generate-seo-titles')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'AI ile 5 yeni SEO başlığı üret' })
|
||||
@ApiResponse({ status: 200, description: 'SEO başlıkları başarıyla üretildi' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'SEO başlıkları başarıyla üretildi',
|
||||
})
|
||||
async generateSeoTitles(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Req() req: any,
|
||||
@@ -361,15 +398,22 @@ export class ProjectsController {
|
||||
*/
|
||||
@Post(':id/translate')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: 'Projeyi farklı bir dile çevir ve kopyasını oluştur' })
|
||||
@ApiResponse({ status: 201, description: 'Proje çevirisi başarıyla tamamlandı' })
|
||||
@ApiOperation({
|
||||
summary: 'Projeyi farklı bir dile çevir ve kopyasını oluştur',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'Proje çevirisi başarıyla tamamlandı',
|
||||
})
|
||||
async translateProject(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('targetLanguage') targetLanguage: string,
|
||||
@Req() req: any,
|
||||
) {
|
||||
if (!targetLanguage) {
|
||||
throw new BadRequestException('Hedef dil (targetLanguage) belirtilmelidir.');
|
||||
throw new BadRequestException(
|
||||
'Hedef dil (targetLanguage) belirtilmelidir.',
|
||||
);
|
||||
}
|
||||
const userId = req.user?.id || req.user?.sub;
|
||||
this.logger.log(`Proje çevirisi isteniyor: ${id} -> ${targetLanguage}`);
|
||||
@@ -392,7 +436,12 @@ export class ProjectsController {
|
||||
) {
|
||||
const userId = req.user?.id || req.user?.sub;
|
||||
this.logger.log(`Sahne görseli üretiliyor: ${sceneId} (proje: ${id})`);
|
||||
return this.projectsService.generateSceneImage(userId, id, sceneId, body?.customPrompt);
|
||||
return this.projectsService.generateSceneImage(
|
||||
userId,
|
||||
id,
|
||||
sceneId,
|
||||
body?.customPrompt,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -408,7 +457,9 @@ export class ProjectsController {
|
||||
@Req() req: any,
|
||||
) {
|
||||
const userId = req.user?.id || req.user?.sub;
|
||||
this.logger.log(`Sahne görseli upscale ediliyor: ${sceneId} (proje: ${id})`);
|
||||
this.logger.log(
|
||||
`Sahne görseli upscale ediliyor: ${sceneId} (proje: ${id})`,
|
||||
);
|
||||
return this.projectsService.upscaleSceneImage(userId, id, sceneId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,15 @@ import { ExtractorModule } from '../extractor/extractor.module';
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
|
||||
@Module({
|
||||
imports: [VideoAiModule, VideoQueueModule, XTwitterModule, GeminiModule, StorageModule, ExtractorModule, BillingModule],
|
||||
imports: [
|
||||
VideoAiModule,
|
||||
VideoQueueModule,
|
||||
XTwitterModule,
|
||||
GeminiModule,
|
||||
StorageModule,
|
||||
ExtractorModule,
|
||||
BillingModule,
|
||||
],
|
||||
controllers: [ProjectsController],
|
||||
providers: [ProjectsService],
|
||||
exports: [ProjectsService],
|
||||
|
||||
@@ -7,14 +7,22 @@ import {
|
||||
import { TransitionType, AspectRatio } from '@prisma/client';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { VideoAiService } from '../video-ai/video-ai.service';
|
||||
import { VideoQueueModule } from '../video-queue/video-queue.module';
|
||||
|
||||
import { VideoGenerationProducer } from '../video-queue/video-generation.producer';
|
||||
import { XTwitterService } from '../x-twitter/x-twitter.service';
|
||||
import { GeminiService } from '../gemini/gemini.service';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { ExtractorService } from '../extractor/extractor.service';
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { CreateProjectDto, UpdateProjectDto, CreateFromTweetDto, CreateFromYoutubeDto, CreateFromDocumentDto, CreateFromExtractedTextDto, CreateFromTextDto } from './dto/project.dto';
|
||||
import {
|
||||
CreateProjectDto,
|
||||
UpdateProjectDto,
|
||||
CreateFromTweetDto,
|
||||
CreateFromYoutubeDto,
|
||||
CreateFromDocumentDto,
|
||||
CreateFromExtractedTextDto,
|
||||
CreateFromTextDto,
|
||||
} from './dto/project.dto';
|
||||
import sharp from 'sharp';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as os from 'os';
|
||||
@@ -170,58 +178,77 @@ export class ProjectsService {
|
||||
...(dto.language && { language: dto.language }),
|
||||
...(dto.aspectRatio && { aspectRatio: dto.aspectRatio }),
|
||||
...(dto.videoStyle && { videoStyle: dto.videoStyle }),
|
||||
...(dto.cinematicReference !== undefined && { cinematicReference: dto.cinematicReference }),
|
||||
...(dto.cinematicReference !== undefined && {
|
||||
cinematicReference: dto.cinematicReference,
|
||||
}),
|
||||
...(dto.targetDuration && { targetDuration: dto.targetDuration }),
|
||||
} as any,
|
||||
});
|
||||
|
||||
this.logger.log(`Proje güncellendi: ${projectId}`);
|
||||
|
||||
|
||||
// Stil ya da görsel parametre değiştiyse ve senaryo/sahneler varsa otomatik rewrite başlat
|
||||
const styleChanged =
|
||||
(dto.videoStyle && dto.videoStyle !== project.videoStyle) ||
|
||||
(dto.cinematicReference !== undefined && dto.cinematicReference !== (project as any).cinematicReference) ||
|
||||
(dto.aspectRatio && dto.aspectRatio !== project.aspectRatio);
|
||||
(dto.cinematicReference !== undefined &&
|
||||
dto.cinematicReference !== (project as any).cinematicReference) ||
|
||||
(dto.aspectRatio &&
|
||||
String(dto.aspectRatio) !== String(project.aspectRatio));
|
||||
|
||||
if (styleChanged && project.scenes && project.scenes.length > 0) {
|
||||
this.logger.log(`Stil değişikliği tespit edildi (${projectId}), visual promptlar arka planda yenileniyor...`);
|
||||
this.rewriteVisualPromptsBackground(userId, projectId, updated).catch((err) => {
|
||||
this.logger.error(`Arka plan rewriteVisualPrompts hatası: ${err}`);
|
||||
});
|
||||
this.logger.log(
|
||||
`Stil değişikliği tespit edildi (${projectId}), visual promptlar arka planda yenileniyor...`,
|
||||
);
|
||||
this.rewriteVisualPromptsBackground(userId, projectId, updated).catch(
|
||||
(err) => {
|
||||
this.logger.error(`Arka plan rewriteVisualPrompts hatası: ${err}`);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
// Arka planda tüm promptları güncelleyen metod
|
||||
private async rewriteVisualPromptsBackground(userId: string, projectId: string, updatedProject: any) {
|
||||
private async rewriteVisualPromptsBackground(
|
||||
userId: string,
|
||||
projectId: string,
|
||||
updatedProject: any,
|
||||
) {
|
||||
const targetProject = await this.findOne(userId, projectId);
|
||||
if (!targetProject || !targetProject.scenes || targetProject.scenes.length === 0) return;
|
||||
if (
|
||||
!targetProject ||
|
||||
!targetProject.scenes ||
|
||||
targetProject.scenes.length === 0
|
||||
)
|
||||
return;
|
||||
|
||||
try {
|
||||
// Sahnelerin visual prompt'larını yenile (ID'leri ve narration'ları gönderiyoruz)
|
||||
const mappedScenes = targetProject.scenes.map(s => ({
|
||||
const mappedScenes = targetProject.scenes.map((s) => ({
|
||||
id: s.id,
|
||||
order: s.order,
|
||||
narrationText: s.narrationText,
|
||||
visualPrompt: s.visualPrompt
|
||||
visualPrompt: s.visualPrompt,
|
||||
}));
|
||||
|
||||
const rewritten = await this.videoAiService.rewriteAllVisualPrompts(
|
||||
mappedScenes,
|
||||
updatedProject.videoStyle,
|
||||
(updatedProject as any).cinematicReference,
|
||||
updatedProject.aspectRatio
|
||||
updatedProject.cinematicReference,
|
||||
updatedProject.aspectRatio,
|
||||
);
|
||||
|
||||
// Veritabanına kaydet
|
||||
for (const newScene of rewritten) {
|
||||
await this.db.scene.update({
|
||||
where: { id: newScene.id },
|
||||
data: { visualPrompt: newScene.visualPrompt }
|
||||
data: { visualPrompt: newScene.visualPrompt },
|
||||
});
|
||||
}
|
||||
this.logger.log(`Görsel promptlar ${projectId} için başarıyla yenilendi.`);
|
||||
this.logger.log(
|
||||
`Görsel promptlar ${projectId} için başarıyla yenilendi.`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(`Visual promptları yenileme işlemi başarısız: ${err}`);
|
||||
}
|
||||
@@ -297,15 +324,32 @@ export class ProjectsService {
|
||||
errorMessage: null,
|
||||
scriptVersion: { increment: 1 },
|
||||
// AI'ın en güçlü SEO başlığını proje başlığı yap
|
||||
title: (scriptJson.seo?.title || scriptJson.metadata?.title || project.title).substring(0, 190),
|
||||
title: (
|
||||
scriptJson.seo?.title ||
|
||||
scriptJson.metadata?.title ||
|
||||
project.title
|
||||
).substring(0, 190),
|
||||
// SEO & Social metadata (skill-enhanced)
|
||||
seoTitle: (scriptJson.seo?.title || scriptJson.metadata?.title || '').substring(0, 190),
|
||||
seoDescription: (scriptJson.seo?.description || scriptJson.metadata?.description || '').substring(0, 490),
|
||||
seoTitleAlts: (scriptJson.seoTitleAlternatives || []).map((t: string) => t.substring(0, 190)),
|
||||
seoScore: typeof scriptJson.seoScore === 'number' ? Math.min(100, Math.max(0, scriptJson.seoScore)) : null,
|
||||
seoTitle: (
|
||||
scriptJson.seo?.title ||
|
||||
scriptJson.metadata?.title ||
|
||||
''
|
||||
).substring(0, 190),
|
||||
seoDescription: (
|
||||
scriptJson.seo?.description ||
|
||||
scriptJson.metadata?.description ||
|
||||
''
|
||||
).substring(0, 490),
|
||||
seoTitleAlts: (scriptJson.seoTitleAlternatives || []).map(
|
||||
(t: string) => t.substring(0, 190),
|
||||
),
|
||||
seoScore:
|
||||
typeof scriptJson.seoScore === 'number'
|
||||
? Math.min(100, Math.max(0, scriptJson.seoScore))
|
||||
: null,
|
||||
seoKeywords: scriptJson.seo?.keywords || [],
|
||||
seoSchemaJson: scriptJson.seo?.schemaMarkup as object || null,
|
||||
socialContent: scriptJson.socialContent as object || null,
|
||||
seoSchemaJson: (scriptJson.seo?.schemaMarkup as object) || null,
|
||||
socialContent: (scriptJson.socialContent as object) || null,
|
||||
},
|
||||
include: {
|
||||
scenes: { orderBy: { order: 'asc' } },
|
||||
@@ -387,8 +431,11 @@ export class ProjectsService {
|
||||
videoStyle: project.videoStyle,
|
||||
targetDuration: project.targetDuration,
|
||||
scenes: project.scenes.map((s) => {
|
||||
const thumbnail = s.mediaAssets?.find(m => m.type === 'THUMBNAIL');
|
||||
const imagePath = thumbnail && thumbnail.s3Key ? this.storageService.getAbsolutePath(thumbnail.s3Key) : undefined;
|
||||
const thumbnail = s.mediaAssets?.find((m) => m.type === 'THUMBNAIL');
|
||||
const imagePath =
|
||||
thumbnail && thumbnail.s3Key
|
||||
? this.storageService.getAbsolutePath(thumbnail.s3Key)
|
||||
: undefined;
|
||||
return {
|
||||
id: s.id,
|
||||
order: s.order,
|
||||
@@ -472,8 +519,10 @@ export class ProjectsService {
|
||||
const avgProcessingTime =
|
||||
completedWithTime.length > 0
|
||||
? Math.round(
|
||||
completedWithTime.reduce((sum, j) => sum + (j.processingTimeMs ?? 0), 0) /
|
||||
completedWithTime.length,
|
||||
completedWithTime.reduce(
|
||||
(sum, j) => sum + (j.processingTimeMs ?? 0),
|
||||
0,
|
||||
) / completedWithTime.length,
|
||||
)
|
||||
: null;
|
||||
|
||||
@@ -499,19 +548,21 @@ export class ProjectsService {
|
||||
project: j.project,
|
||||
logs: j.logs,
|
||||
})),
|
||||
recentJobs: [...completed, ...failed, ...cancelled].slice(0, 20).map((j) => ({
|
||||
id: j.id,
|
||||
status: j.status,
|
||||
currentStage: j.currentStage,
|
||||
attemptNumber: j.attemptNumber,
|
||||
processingTimeMs: j.processingTimeMs,
|
||||
errorMessage: j.errorMessage,
|
||||
finalVideoUrl: j.finalVideoUrl,
|
||||
createdAt: j.createdAt,
|
||||
startedAt: j.startedAt,
|
||||
completedAt: j.completedAt,
|
||||
project: j.project,
|
||||
})),
|
||||
recentJobs: [...completed, ...failed, ...cancelled]
|
||||
.slice(0, 20)
|
||||
.map((j) => ({
|
||||
id: j.id,
|
||||
status: j.status,
|
||||
currentStage: j.currentStage,
|
||||
attemptNumber: j.attemptNumber,
|
||||
processingTimeMs: j.processingTimeMs,
|
||||
errorMessage: j.errorMessage,
|
||||
finalVideoUrl: j.finalVideoUrl,
|
||||
createdAt: j.createdAt,
|
||||
startedAt: j.startedAt,
|
||||
completedAt: j.completedAt,
|
||||
project: j.project,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -533,7 +584,9 @@ export class ProjectsService {
|
||||
}
|
||||
|
||||
if (project.renderJobs.length === 0) {
|
||||
throw new BadRequestException('İptal edilecek aktif bir render işlemi bulunamadı');
|
||||
throw new BadRequestException(
|
||||
'İptal edilecek aktif bir render işlemi bulunamadı',
|
||||
);
|
||||
}
|
||||
|
||||
// Aktif olan ilk render job'u al
|
||||
@@ -542,7 +595,10 @@ export class ProjectsService {
|
||||
// Status'ü güncelle
|
||||
await this.db.renderJob.update({
|
||||
where: { id: activeJob.id },
|
||||
data: { status: 'CANCELLED', errorMessage: 'Kullanıcı tarafından iptal edildi' },
|
||||
data: {
|
||||
status: 'CANCELLED',
|
||||
errorMessage: 'Kullanıcı tarafından iptal edildi',
|
||||
},
|
||||
});
|
||||
|
||||
// Projeyi tekrar DRAFT durumuna döndür (senaryosu hâlâ mevcut)
|
||||
@@ -551,7 +607,9 @@ export class ProjectsService {
|
||||
data: { status: 'DRAFT' },
|
||||
});
|
||||
|
||||
this.logger.log(`Render iptal edildi: Project ${projectId}, RenderJob ${activeJob.id}`);
|
||||
this.logger.log(
|
||||
`Render iptal edildi: Project ${projectId}, RenderJob ${activeJob.id}`,
|
||||
);
|
||||
|
||||
return {
|
||||
message: 'Render başarıyla iptal edildi',
|
||||
@@ -666,8 +724,16 @@ export class ProjectsService {
|
||||
status: 'DRAFT',
|
||||
errorMessage: null,
|
||||
scriptVersion: 1,
|
||||
seoTitle: (scriptJson.seo?.title || scriptJson.metadata?.title || '').substring(0, 190),
|
||||
seoDescription: (scriptJson.seo?.description || scriptJson.metadata?.description || '').substring(0, 490),
|
||||
seoTitle: (
|
||||
scriptJson.seo?.title ||
|
||||
scriptJson.metadata?.title ||
|
||||
''
|
||||
).substring(0, 190),
|
||||
seoDescription: (
|
||||
scriptJson.seo?.description ||
|
||||
scriptJson.metadata?.description ||
|
||||
''
|
||||
).substring(0, 490),
|
||||
seoSchemaJson: (scriptJson.seo?.schemaMarkup as object) || null,
|
||||
socialContent: (scriptJson.socialContent as object) || null,
|
||||
},
|
||||
@@ -708,11 +774,15 @@ export class ProjectsService {
|
||||
* YouTube URL'sinden proje oluşturur. Extractor servisi kullanılarak video transkripti çekilir.
|
||||
*/
|
||||
async createFromYoutube(userId: string, dto: CreateFromYoutubeDto) {
|
||||
this.logger.log(`YouTube videosundan proje oluşturuluyor: ${dto.youtubeUrl}`);
|
||||
this.logger.log(
|
||||
`YouTube videosundan proje oluşturuluyor: ${dto.youtubeUrl}`,
|
||||
);
|
||||
|
||||
// 1. YouTube url'den MarkItDown yardımı ile metni çek
|
||||
const extractedText = await this.extractorService.extractFromUrl(dto.youtubeUrl);
|
||||
|
||||
const extractedText = await this.extractorService.extractFromUrl(
|
||||
dto.youtubeUrl,
|
||||
);
|
||||
|
||||
// 2. Proje başlığı veya varsayılan prompt'u oluştur
|
||||
const title = dto.title || 'YouTube Shorts Üretimi';
|
||||
const prompt = `Aşağıda dökümü (transcript) verilmiş YouTube videosundan en can alıcı 60 saniyelik bir Shorts videosu üret:\n\n${extractedText.substring(0, 15000)}`;
|
||||
@@ -734,7 +804,9 @@ export class ProjectsService {
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`YouTube projesi oluşturuldu, senaryo üretiliyor: ${project.id}`);
|
||||
this.logger.log(
|
||||
`YouTube projesi oluşturuldu, senaryo üretiliyor: ${project.id}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const scriptJson = await this.videoAiService.generateVideoScript({
|
||||
@@ -774,14 +846,19 @@ export class ProjectsService {
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`YouTube senaryo tamamlandı: ${project.id} — ${scriptJson.scenes.length} sahne`);
|
||||
this.logger.log(
|
||||
`YouTube senaryo tamamlandı: ${project.id} — ${scriptJson.scenes.length} sahne`,
|
||||
);
|
||||
return updatedProject;
|
||||
} catch (error) {
|
||||
await this.db.project.update({
|
||||
where: { id: project.id },
|
||||
data: {
|
||||
status: 'DRAFT',
|
||||
errorMessage: error instanceof Error ? error.message : 'YouTube senaryo üretimi sırasında hata',
|
||||
errorMessage:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'YouTube senaryo üretimi sırasında hata',
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
@@ -792,7 +869,9 @@ export class ProjectsService {
|
||||
* PDF, Word vb. dokümandan metin çıkarır ve konu önerileri üretir.
|
||||
*/
|
||||
async extractDocumentTopics(file: Express.Multer.File) {
|
||||
this.logger.log(`Belgeden konu önerileri çıkarılıyor: ${file.originalname}`);
|
||||
this.logger.log(
|
||||
`Belgeden konu önerileri çıkarılıyor: ${file.originalname}`,
|
||||
);
|
||||
|
||||
let tempFilePath: string | null = null;
|
||||
let extractedText = '';
|
||||
@@ -801,42 +880,60 @@ export class ProjectsService {
|
||||
if (file.path) {
|
||||
tempFilePath = file.path;
|
||||
} else if (file.buffer) {
|
||||
tempFilePath = path.join(os.tmpdir(), `${Date.now()}-${file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_')}`);
|
||||
tempFilePath = path.join(
|
||||
os.tmpdir(),
|
||||
`${Date.now()}-${file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_')}`,
|
||||
);
|
||||
await fs.writeFile(tempFilePath, file.buffer);
|
||||
} else {
|
||||
throw new Error("Dosya içeriği okunamadı (Buffer veya Path yok).");
|
||||
throw new Error('Dosya içeriği okunamadı (Buffer veya Path yok).');
|
||||
}
|
||||
|
||||
extractedText = await this.extractorService.extractFromFile(tempFilePath, file.originalname, file.mimetype);
|
||||
extractedText = await this.extractorService.extractFromFile(
|
||||
tempFilePath,
|
||||
file.originalname,
|
||||
file.mimetype,
|
||||
);
|
||||
} finally {
|
||||
if (tempFilePath && !file.path) {
|
||||
await fs.unlink(tempFilePath).catch(e => this.logger.warn(`Temp dosya silinemedi: ${e.message}`));
|
||||
await fs
|
||||
.unlink(tempFilePath)
|
||||
.catch((e) =>
|
||||
this.logger.warn(`Temp dosya silinemedi: ${e.message}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!extractedText || extractedText.trim().length === 0) {
|
||||
throw new BadRequestException("Belgeden okunabilir metin çıkarılamadı.");
|
||||
throw new BadRequestException('Belgeden okunabilir metin çıkarılamadı.');
|
||||
}
|
||||
|
||||
// Kısa metinse doğrudan 1 konu öner (kendi başlığı gibi), uzunsa çoklu konu
|
||||
let topics: string[] = [];
|
||||
if (extractedText.length < 5000) {
|
||||
topics = [file.originalname.split('.')[0] || "Belge Özeti"];
|
||||
topics = [file.originalname.split('.')[0] || 'Belge Özeti'];
|
||||
} else {
|
||||
topics = await this.videoAiService.suggestDocumentTopics(extractedText, 4);
|
||||
topics = await this.videoAiService.suggestDocumentTopics(
|
||||
extractedText,
|
||||
4,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
text: extractedText,
|
||||
topics,
|
||||
originalFilename: file.originalname
|
||||
originalFilename: file.originalname,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PDF, Word vb. dokümandan proje oluşturur.
|
||||
*/
|
||||
async createFromDocument(userId: string, file: Express.Multer.File, dto: CreateFromDocumentDto) {
|
||||
async createFromDocument(
|
||||
userId: string,
|
||||
file: Express.Multer.File,
|
||||
dto: CreateFromDocumentDto,
|
||||
) {
|
||||
this.logger.log(`Belgeden proje oluşturuluyor: ${file.originalname}`);
|
||||
|
||||
let tempFilePath: string | null = null;
|
||||
@@ -846,16 +943,27 @@ export class ProjectsService {
|
||||
if (file.path) {
|
||||
tempFilePath = file.path;
|
||||
} else if (file.buffer) {
|
||||
tempFilePath = path.join(os.tmpdir(), `${Date.now()}-${file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_')}`);
|
||||
tempFilePath = path.join(
|
||||
os.tmpdir(),
|
||||
`${Date.now()}-${file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_')}`,
|
||||
);
|
||||
await fs.writeFile(tempFilePath, file.buffer);
|
||||
} else {
|
||||
throw new Error("Dosya içeriği okunamadı (Buffer veya Path yok).");
|
||||
throw new Error('Dosya içeriği okunamadı (Buffer veya Path yok).');
|
||||
}
|
||||
|
||||
extractedText = await this.extractorService.extractFromFile(tempFilePath, file.originalname, file.mimetype);
|
||||
extractedText = await this.extractorService.extractFromFile(
|
||||
tempFilePath,
|
||||
file.originalname,
|
||||
file.mimetype,
|
||||
);
|
||||
} finally {
|
||||
if (tempFilePath && !file.path) {
|
||||
await fs.unlink(tempFilePath).catch(e => this.logger.warn(`Temp dosya silinemedi: ${e.message}`));
|
||||
await fs
|
||||
.unlink(tempFilePath)
|
||||
.catch((e) =>
|
||||
this.logger.warn(`Temp dosya silinemedi: ${e.message}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -919,7 +1027,10 @@ export class ProjectsService {
|
||||
where: { id: project.id },
|
||||
data: {
|
||||
status: 'DRAFT',
|
||||
errorMessage: error instanceof Error ? error.message : 'Belge senaryo üretimi sırasında hata',
|
||||
errorMessage:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Belge senaryo üretimi sırasında hata',
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
@@ -929,8 +1040,13 @@ export class ProjectsService {
|
||||
/**
|
||||
* Çıkarılmış metin ve kullanıcının seçtiği bir "topic" üzerinden proje oluşturur.
|
||||
*/
|
||||
async createFromExtractedText(userId: string, dto: CreateFromExtractedTextDto) {
|
||||
this.logger.log(`Metin ve konu üzerinden proje oluşturuluyor: ${dto.topic}`);
|
||||
async createFromExtractedText(
|
||||
userId: string,
|
||||
dto: CreateFromExtractedTextDto,
|
||||
) {
|
||||
this.logger.log(
|
||||
`Metin ve konu üzerinden proje oluşturuluyor: ${dto.topic}`,
|
||||
);
|
||||
|
||||
const title = dto.topic;
|
||||
// Tam prompt metni (AI'a gönderilecek)
|
||||
@@ -941,10 +1057,13 @@ export class ProjectsService {
|
||||
const project = await this.db.project.create({
|
||||
data: {
|
||||
title,
|
||||
description: dto.originalFilename ? `Belgeden üretildi: ${dto.originalFilename} (Konu: ${dto.topic})` : `Metinden üretildi (Konu: ${dto.topic})`,
|
||||
description: dto.originalFilename
|
||||
? `Belgeden üretildi: ${dto.originalFilename} (Konu: ${dto.topic})`
|
||||
: `Metinden üretildi (Konu: ${dto.topic})`,
|
||||
prompt: shortDbPrompt,
|
||||
language: dto.language || 'tr',
|
||||
aspectRatio: (dto.aspectRatio as AspectRatio) || AspectRatio.PORTRAIT_9_16,
|
||||
aspectRatio:
|
||||
(dto.aspectRatio as AspectRatio) || AspectRatio.PORTRAIT_9_16,
|
||||
videoStyle: dto.videoStyle || 'CINEMATIC',
|
||||
cinematicReference: dto.cinematicReference,
|
||||
targetDuration: dto.targetDuration || 60,
|
||||
@@ -995,15 +1114,16 @@ export class ProjectsService {
|
||||
where: { id: project.id },
|
||||
data: {
|
||||
status: 'DRAFT',
|
||||
errorMessage: error instanceof Error ? error.message : 'Konu bazlı senaryo üretimi sırasında hata',
|
||||
errorMessage:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Konu bazlı senaryo üretimi sırasında hata',
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Kullanıcının doğrudan yazdığı serbest metinden (fikir, taslak, hikaye) proje oluşturur.
|
||||
*/
|
||||
@@ -1011,10 +1131,10 @@ export class ProjectsService {
|
||||
this.logger.log(`Serbest metinden proje oluşturuluyor...`);
|
||||
|
||||
const title = dto.title || 'Yeni Proje (Metinden)';
|
||||
|
||||
|
||||
// AI'a gidecek detaylı prompt.
|
||||
const fullAiPrompt = `Aşağıda kullanıcı tarafından verilen ana fikri, metni veya hikayeyi al. Bunu geliştir, detaylandır ve yüksek kaliteli, çarpıcı bir video senaryosuna dönüştür:\n\n${dto.text.substring(0, 15000)}`;
|
||||
|
||||
|
||||
const shortDbPrompt = `Kullanıcı metninden üretildi.`;
|
||||
|
||||
const project = await this.db.project.create({
|
||||
@@ -1023,7 +1143,8 @@ export class ProjectsService {
|
||||
description: `Serbest metin üzerinden üretildi.`,
|
||||
prompt: shortDbPrompt,
|
||||
language: dto.language || 'tr',
|
||||
aspectRatio: (dto.aspectRatio as AspectRatio) || AspectRatio.PORTRAIT_9_16,
|
||||
aspectRatio:
|
||||
(dto.aspectRatio as AspectRatio) || AspectRatio.PORTRAIT_9_16,
|
||||
videoStyle: dto.videoStyle || 'CINEMATIC',
|
||||
cinematicReference: dto.cinematicReference,
|
||||
targetDuration: dto.targetDuration || 60,
|
||||
@@ -1074,7 +1195,10 @@ export class ProjectsService {
|
||||
where: { id: project.id },
|
||||
data: {
|
||||
status: 'DRAFT',
|
||||
errorMessage: error instanceof Error ? error.message : 'Serbest metin senaryo üretimi sırasında hata',
|
||||
errorMessage:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Serbest metin senaryo üretimi sırasında hata',
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
@@ -1088,7 +1212,12 @@ export class ProjectsService {
|
||||
userId: string,
|
||||
projectId: string,
|
||||
sceneId: string,
|
||||
data: { narrationText?: string; visualPrompt?: string; subtitleText?: string; duration?: number },
|
||||
data: {
|
||||
narrationText?: string;
|
||||
visualPrompt?: string;
|
||||
subtitleText?: string;
|
||||
duration?: number;
|
||||
},
|
||||
) {
|
||||
// Proje sahipliğini doğrula
|
||||
const project = await this.findOne(userId, projectId);
|
||||
@@ -1109,9 +1238,15 @@ export class ProjectsService {
|
||||
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.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 },
|
||||
@@ -1146,7 +1281,10 @@ export class ProjectsService {
|
||||
|
||||
// Stil DNA bilgilerini prompt'a dahil et
|
||||
const cinematicRef = (project as any).cinematicReference || '';
|
||||
const styleDNA = this.videoAiService.getStyleDNA(project.videoStyle, cinematicRef || undefined);
|
||||
const styleDNA = this.videoAiService.getStyleDNA(
|
||||
project.videoStyle,
|
||||
cinematicRef || undefined,
|
||||
);
|
||||
|
||||
const contextPrompt = `
|
||||
Bir video senaryosunun ${scene.order}. sahnesini yeniden üret.
|
||||
@@ -1202,11 +1340,18 @@ Sadece bu tek sahneyi üret. JSON formatında:
|
||||
include: { mediaAssets: true },
|
||||
});
|
||||
|
||||
this.logger.log(`Sahne yeniden üretildi: ${sceneId} (proje: ${projectId}) — Stil: ${project.videoStyle}${cinematicRef ? ', Ref: ' + cinematicRef : ''}`);
|
||||
this.logger.log(
|
||||
`Sahne yeniden üretildi: ${sceneId} (proje: ${projectId}) — Stil: ${project.videoStyle}${cinematicRef ? ', Ref: ' + cinematicRef : ''}`,
|
||||
);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async generateSceneImage(userId: string, projectId: string, sceneId: string, customPrompt?: string) {
|
||||
async generateSceneImage(
|
||||
userId: string,
|
||||
projectId: string,
|
||||
sceneId: string,
|
||||
customPrompt?: string,
|
||||
) {
|
||||
const project = await this.findOne(userId, projectId);
|
||||
const scene = project.scenes.find((s) => s.id === sceneId);
|
||||
if (!scene) {
|
||||
@@ -1215,16 +1360,20 @@ Sadece bu tek sahneyi üret. JSON formatında:
|
||||
|
||||
if (customPrompt && customPrompt !== scene.visualPrompt) {
|
||||
// First update the prompt
|
||||
await this.updateScene(userId, projectId, sceneId, { visualPrompt: customPrompt });
|
||||
await this.updateScene(userId, projectId, sceneId, {
|
||||
visualPrompt: customPrompt,
|
||||
});
|
||||
scene.visualPrompt = customPrompt;
|
||||
}
|
||||
|
||||
this.logger.log(`Sahne görseli üretiliyor: ${sceneId} (proje: ${projectId})`);
|
||||
this.logger.log(
|
||||
`Sahne görseli üretiliyor: ${sceneId} (proje: ${projectId})`,
|
||||
);
|
||||
|
||||
const aspectRatioMap: Record<string, '16:9' | '9:16' | '1:1'> = {
|
||||
'PORTRAIT_9_16': '9:16',
|
||||
'LANDSCAPE_16_9': '16:9',
|
||||
'SQUARE_1_1': '1:1',
|
||||
PORTRAIT_9_16: '9:16',
|
||||
LANDSCAPE_16_9: '16:9',
|
||||
SQUARE_1_1: '1:1',
|
||||
};
|
||||
const mappedRatio = aspectRatioMap[project.aspectRatio] || '9:16';
|
||||
|
||||
@@ -1237,35 +1386,49 @@ Sadece bu tek sahneyi üret. JSON formatında:
|
||||
`${scene.visualPrompt}. ${styleLabel}`,
|
||||
mappedRatio,
|
||||
);
|
||||
|
||||
|
||||
if (!imageResult) {
|
||||
this.logger.warn(`⚠️ Orijinal prompt ile görsel üretilemedi. Güvenlik filtresine takılmış olabilir. Prompt'u anonimleştirip tekrar deniyoruz...`);
|
||||
|
||||
this.logger.warn(
|
||||
`⚠️ Orijinal prompt ile görsel üretilemedi. Güvenlik filtresine takılmış olabilir. Prompt'u anonimleştirip tekrar deniyoruz...`,
|
||||
);
|
||||
|
||||
try {
|
||||
const rewritePrompt = `Rewrite the following image generation prompt. Remove the names of any real-world public figures (e.g., Elon Musk, politicians, celebrities, etc.) and replace their names with extremely detailed physical descriptions of their facial features, body type, age, hair style, and clothing, so the generated image will still look EXACTLY like them. Keep the rest of the prompt completely intact. Return ONLY the rewritten prompt without any conversational text or quotes.\n\nPrompt: ${scene.visualPrompt}`;
|
||||
|
||||
|
||||
const textResult = await this.geminiService.generateText(rewritePrompt);
|
||||
const rewrittenPrompt = textResult.text;
|
||||
this.logger.log(`🔄 Anonimleştirilmiş Prompt: ${rewrittenPrompt}`);
|
||||
|
||||
|
||||
const illustrationPrompt = `A highly detailed, premium digital illustration of the following scene. Make it an obvious illustration or artwork: ${rewrittenPrompt}. ${styleLabel}`;
|
||||
imageResult = await this.geminiService.generateImage(illustrationPrompt, mappedRatio, true);
|
||||
imageResult = await this.geminiService.generateImage(
|
||||
illustrationPrompt,
|
||||
mappedRatio,
|
||||
true,
|
||||
);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Anonimleştirilmiş illüstrasyon üretimi başarısız: ${err.message}`);
|
||||
this.logger.error(
|
||||
`Anonimleştirilmiş illüstrasyon üretimi başarısız: ${err.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!imageResult) {
|
||||
throw new BadRequestException('Görsel üretilemedi, güvenlik filtreleri veya servis hatası nedeniyle işlem başarısız oldu.');
|
||||
throw new BadRequestException(
|
||||
'Görsel üretilemedi, güvenlik filtreleri veya servis hatası nedeniyle işlem başarısız oldu.',
|
||||
);
|
||||
}
|
||||
|
||||
// Storage'a kaydet
|
||||
const key = this.storageService.getSceneImageKey(projectId, scene.order);
|
||||
await this.storageService.upload(key, imageResult.buffer, imageResult.mimeType);
|
||||
await this.storageService.upload(
|
||||
key,
|
||||
imageResult.buffer,
|
||||
imageResult.mimeType,
|
||||
);
|
||||
const url = this.storageService.getPublicUrl(key);
|
||||
|
||||
// MediaRecord oluştur veya güncelle
|
||||
let mediaAsset = scene.mediaAssets.find(m => m.type === 'THUMBNAIL');
|
||||
const mediaAsset = scene.mediaAssets.find((m) => m.type === 'THUMBNAIL');
|
||||
let mediaId = mediaAsset?.id;
|
||||
if (!mediaId) {
|
||||
const media = await this.db.mediaAsset.create({
|
||||
@@ -1301,16 +1464,22 @@ Sadece bu tek sahneyi üret. JSON formatında:
|
||||
throw new NotFoundException('Sahne bulunamadı');
|
||||
}
|
||||
|
||||
const mediaAsset = scene.mediaAssets.find(m => m.type === 'THUMBNAIL');
|
||||
let mediaId = mediaAsset?.id;
|
||||
const mediaAsset = scene.mediaAssets.find((m) => m.type === 'THUMBNAIL');
|
||||
const mediaId = mediaAsset?.id;
|
||||
if (!mediaId) {
|
||||
throw new BadRequestException('Bu sahne için upscaled edilecek görsel bulunamadı.');
|
||||
throw new BadRequestException(
|
||||
'Bu sahne için upscaled edilecek görsel bulunamadı.',
|
||||
);
|
||||
}
|
||||
|
||||
const media = await this.db.mediaAsset.findUnique({ where: { id: mediaId } });
|
||||
const media = await this.db.mediaAsset.findUnique({
|
||||
where: { id: mediaId },
|
||||
});
|
||||
if (!media) throw new NotFoundException('Medya kaydı bulunamadı');
|
||||
|
||||
this.logger.log(`Sahne görseli upscaled ediliyor (Sharp ile simülasyon): ${sceneId}`);
|
||||
this.logger.log(
|
||||
`Sahne görseli upscaled ediliyor (Sharp ile simülasyon): ${sceneId}`,
|
||||
);
|
||||
|
||||
const key = this.storageService.getSceneImageKey(projectId, scene.order);
|
||||
const absPath = this.storageService.getAbsolutePath(key);
|
||||
@@ -1355,12 +1524,12 @@ Sadece bu tek sahneyi üret. JSON formatında:
|
||||
|
||||
async deleteSceneMedia(userId: string, projectId: string, mediaId: string) {
|
||||
const project = await this.findOne(userId, projectId);
|
||||
|
||||
|
||||
const media = await this.db.mediaAsset.findFirst({
|
||||
where: {
|
||||
id: mediaId,
|
||||
projectId: project.id
|
||||
}
|
||||
projectId: project.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!media) {
|
||||
@@ -1372,11 +1541,11 @@ Sadece bu tek sahneyi üret. JSON formatında:
|
||||
// R2'den silme işlemi için key'i url'den çıkarabiliriz veya doğrudan veritabanından silebiliriz.
|
||||
// Şimdilik sadece DB'den siliyoruz, R2 temizliği storage service'te eklenebilir.
|
||||
}
|
||||
|
||||
|
||||
await this.db.mediaAsset.delete({
|
||||
where: { id: mediaId }
|
||||
where: { id: mediaId },
|
||||
});
|
||||
|
||||
|
||||
this.logger.log(`Medya silindi: ${mediaId} (Proje: ${projectId})`);
|
||||
return { success: true, message: 'Medya başarıyla silindi' };
|
||||
} catch (error) {
|
||||
@@ -1388,12 +1557,21 @@ Sadece bu tek sahneyi üret. JSON formatında:
|
||||
/**
|
||||
* Çeviri İşlemi
|
||||
*/
|
||||
async translateProject(userId: string, projectId: string, targetLanguage: string) {
|
||||
async translateProject(
|
||||
userId: string,
|
||||
projectId: string,
|
||||
targetLanguage: string,
|
||||
) {
|
||||
const project = await this.findOne(userId, projectId);
|
||||
if (!project) throw new NotFoundException('Proje bulunamadı');
|
||||
|
||||
// 1 Kredi Kesintisi
|
||||
await this.billingService.spendCredits(userId, 1, projectId, `Proje çevirisi (${targetLanguage})`);
|
||||
await this.billingService.spendCredits(
|
||||
userId,
|
||||
1,
|
||||
projectId,
|
||||
`Proje çevirisi (${targetLanguage})`,
|
||||
);
|
||||
|
||||
const prompt = `
|
||||
Translate the following video project to the language: ${targetLanguage}.
|
||||
@@ -1401,19 +1579,23 @@ Keep all structural metadata intact. For 'visualPrompt', it MUST strictly remain
|
||||
However, if the original visualPrompt contains any texts that are meant to be shown on screen (e.g., text on signs, labels, captions, or any on-screen text overlays), you MUST translate those specific on-screen text elements to ${targetLanguage} within the English visualPrompt. The rest of the visualPrompt should be kept in English.
|
||||
|
||||
Input Data:
|
||||
${JSON.stringify({
|
||||
title: project.title,
|
||||
description: project.description,
|
||||
seoTitle: project.seoTitle,
|
||||
seoDescription: project.seoDescription,
|
||||
socialContent: project.socialContent,
|
||||
scenes: project.scenes.map(s => ({
|
||||
id: s.id,
|
||||
narrationText: s.narrationText,
|
||||
subtitleText: s.subtitleText,
|
||||
visualPrompt: s.visualPrompt
|
||||
}))
|
||||
}, null, 2)}`;
|
||||
${JSON.stringify(
|
||||
{
|
||||
title: project.title,
|
||||
description: project.description,
|
||||
seoTitle: project.seoTitle,
|
||||
seoDescription: project.seoDescription,
|
||||
socialContent: project.socialContent,
|
||||
scenes: project.scenes.map((s) => ({
|
||||
id: s.id,
|
||||
narrationText: s.narrationText,
|
||||
subtitleText: s.subtitleText,
|
||||
visualPrompt: s.visualPrompt,
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}`;
|
||||
|
||||
const schemaStr = `{
|
||||
"title": "string",
|
||||
@@ -1438,15 +1620,26 @@ ${JSON.stringify({
|
||||
|
||||
let translatedData;
|
||||
try {
|
||||
const response = await this.geminiService.generateJSON(prompt, schemaStr, {
|
||||
temperature: 0.3,
|
||||
});
|
||||
const response = await this.geminiService.generateJSON(
|
||||
prompt,
|
||||
schemaStr,
|
||||
{
|
||||
temperature: 0.3,
|
||||
},
|
||||
);
|
||||
translatedData = response.data;
|
||||
} catch (err) {
|
||||
// Hata olursa krediyi iade et
|
||||
await this.billingService.grantCredits(userId, 1, 'refund', `Proje çeviri hatası iadesi`);
|
||||
await this.billingService.grantCredits(
|
||||
userId,
|
||||
1,
|
||||
'refund',
|
||||
`Proje çeviri hatası iadesi`,
|
||||
);
|
||||
this.logger.error(`Çeviri hatası: ${err.message}`);
|
||||
throw new BadRequestException('Çeviri işlemi sırasında AI servisinde bir hata oluştu.');
|
||||
throw new BadRequestException(
|
||||
'Çeviri işlemi sırasında AI servisinde bir hata oluştu.',
|
||||
);
|
||||
}
|
||||
|
||||
const newProject = await this.db.project.create({
|
||||
@@ -1460,8 +1653,15 @@ ${JSON.stringify({
|
||||
cinematicReference: project.cinematicReference,
|
||||
targetDuration: project.targetDuration,
|
||||
seoKeywords: project.seoKeywords,
|
||||
seoTitle: (translatedData.seoTitle || project.seoTitle || '').substring(0, 190),
|
||||
seoDescription: (translatedData.seoDescription || project.seoDescription || '').substring(0, 490),
|
||||
seoTitle: (translatedData.seoTitle || project.seoTitle || '').substring(
|
||||
0,
|
||||
190,
|
||||
),
|
||||
seoDescription: (
|
||||
translatedData.seoDescription ||
|
||||
project.seoDescription ||
|
||||
''
|
||||
).substring(0, 490),
|
||||
socialContent: translatedData.socialContent || project.socialContent,
|
||||
referenceUrl: project.referenceUrl,
|
||||
sourceType: project.sourceType,
|
||||
@@ -1469,27 +1669,32 @@ ${JSON.stringify({
|
||||
status: 'DRAFT',
|
||||
userId,
|
||||
parentId: project.id,
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
for (const originalScene of project.scenes) {
|
||||
const transScene = translatedData.scenes?.find((s: any) => s.id === originalScene.id);
|
||||
|
||||
const transScene = translatedData.scenes?.find(
|
||||
(s: any) => s.id === originalScene.id,
|
||||
);
|
||||
|
||||
await this.db.scene.create({
|
||||
data: {
|
||||
order: originalScene.order,
|
||||
title: originalScene.title,
|
||||
narrationText: transScene?.narrationText || originalScene.narrationText,
|
||||
narrationText:
|
||||
transScene?.narrationText || originalScene.narrationText,
|
||||
subtitleText: transScene?.subtitleText || originalScene.subtitleText,
|
||||
visualPrompt: transScene?.visualPrompt || originalScene.visualPrompt,
|
||||
duration: originalScene.duration,
|
||||
transitionType: originalScene.transitionType,
|
||||
projectId: newProject.id,
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.log(`Proje başarıyla çevrildi: ${projectId} -> ${newProject.id} (${targetLanguage})`);
|
||||
this.logger.log(
|
||||
`Proje başarıyla çevrildi: ${projectId} -> ${newProject.id} (${targetLanguage})`,
|
||||
);
|
||||
return newProject;
|
||||
}
|
||||
|
||||
@@ -1542,7 +1747,9 @@ ${JSON.stringify({
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`✅ ${result.titles.length} SEO başlığı üretildi: ${projectId}`);
|
||||
this.logger.log(
|
||||
`✅ ${result.titles.length} SEO başlığı üretildi: ${projectId}`,
|
||||
);
|
||||
|
||||
return {
|
||||
titles: updated.seoTitleAlts,
|
||||
@@ -1555,7 +1762,11 @@ ${JSON.stringify({
|
||||
* Alternatif SEO başlıklarından birini seçerek projenin ana başlığını günceller.
|
||||
* Ayrıca seoTitle ve socialContent.youtubeTitle alanlarını da senkronize eder.
|
||||
*/
|
||||
async selectSeoTitle(userId: string, projectId: string, selectedTitle: string) {
|
||||
async selectSeoTitle(
|
||||
userId: string,
|
||||
projectId: string,
|
||||
selectedTitle: string,
|
||||
) {
|
||||
const project = await this.db.project.findFirst({
|
||||
where: { id: projectId, userId, deletedAt: null },
|
||||
});
|
||||
@@ -1571,9 +1782,13 @@ ${JSON.stringify({
|
||||
const trimmedTitle = selectedTitle.substring(0, 190);
|
||||
|
||||
// socialContent varsa youtubeTitle'ı da güncelle
|
||||
let updatedSocialContent = project.socialContent as Record<string, any> || {};
|
||||
let updatedSocialContent =
|
||||
(project.socialContent as Record<string, any>) || {};
|
||||
if (updatedSocialContent && typeof updatedSocialContent === 'object') {
|
||||
updatedSocialContent = { ...updatedSocialContent, youtubeTitle: trimmedTitle.substring(0, 60) };
|
||||
updatedSocialContent = {
|
||||
...updatedSocialContent,
|
||||
youtubeTitle: trimmedTitle.substring(0, 60),
|
||||
};
|
||||
}
|
||||
|
||||
const updated = await this.db.project.update({
|
||||
@@ -1601,7 +1816,9 @@ ${JSON.stringify({
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Proje başlığı güncellendi: "${project.title}" → "${trimmedTitle}"`);
|
||||
this.logger.log(
|
||||
`Proje başlığı güncellendi: "${project.title}" → "${trimmedTitle}"`,
|
||||
);
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,10 @@ export class RenderCallbackController {
|
||||
private readonly configService: ConfigService,
|
||||
private readonly notificationsService: NotificationsService,
|
||||
) {
|
||||
this.apiKey = this.configService.get<string>('RENDER_CALLBACK_API_KEY', 'contgen-worker-secret-2026');
|
||||
this.apiKey = this.configService.get<string>(
|
||||
'RENDER_CALLBACK_API_KEY',
|
||||
'contgen-worker-secret-2026',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -316,4 +319,3 @@ export class RenderCallbackController {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,10 @@ export class StorageService {
|
||||
private readonly config: StorageConfig;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
const basePath = this.configService.get<string>('STORAGE_LOCAL_PATH', './data/media');
|
||||
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');
|
||||
|
||||
@@ -60,7 +63,7 @@ export class StorageService {
|
||||
};
|
||||
|
||||
this.logger.log(`📦 Storage: lokal depolama — ${this.config.basePath}`);
|
||||
this.ensureBaseDir();
|
||||
void this.ensureBaseDir();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,7 +72,9 @@ export class StorageService {
|
||||
private async ensureBaseDir() {
|
||||
try {
|
||||
await fs.mkdir(this.config.basePath, { recursive: true });
|
||||
await fs.mkdir(path.join(this.config.basePath, 'temp'), { recursive: true });
|
||||
await fs.mkdir(path.join(this.config.basePath, 'temp'), {
|
||||
recursive: true,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Temel dizin oluşturulamadı: ${error}`);
|
||||
}
|
||||
@@ -119,7 +124,11 @@ export class StorageService {
|
||||
/**
|
||||
* 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> {
|
||||
const filePath = path.join(this.config.basePath, key);
|
||||
const dir = path.dirname(filePath);
|
||||
|
||||
@@ -128,7 +137,9 @@ export class StorageService {
|
||||
|
||||
const sizeBytes = data.length;
|
||||
|
||||
this.logger.debug(`📥 Yüklendi: ${key} (${this.formatBytes(sizeBytes)}, ${mimeType})`);
|
||||
this.logger.debug(
|
||||
`📥 Yüklendi: ${key} (${this.formatBytes(sizeBytes)}, ${mimeType})`,
|
||||
);
|
||||
|
||||
return {
|
||||
key,
|
||||
@@ -142,7 +153,11 @@ export class StorageService {
|
||||
/**
|
||||
* 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> {
|
||||
async uploadFromPath(
|
||||
key: string,
|
||||
sourcePath: string,
|
||||
mimeType: string,
|
||||
): Promise<UploadResult> {
|
||||
const destPath = path.join(this.config.basePath, key);
|
||||
const dir = path.dirname(destPath);
|
||||
|
||||
@@ -151,7 +166,9 @@ export class StorageService {
|
||||
|
||||
const stats = await fs.stat(destPath);
|
||||
|
||||
this.logger.debug(`📥 Dosyadan yüklendi: ${key} (${this.formatBytes(stats.size)})`);
|
||||
this.logger.debug(
|
||||
`📥 Dosyadan yüklendi: ${key} (${this.formatBytes(stats.size)})`,
|
||||
);
|
||||
|
||||
return {
|
||||
key,
|
||||
@@ -307,14 +324,22 @@ export class StorageService {
|
||||
|
||||
// ── Private Helpers ────────────────────────────────────────────────
|
||||
|
||||
private async listFilesRecursive(dir: string, prefix: string): Promise<string[]> {
|
||||
private async listFilesRecursive(
|
||||
dir: string,
|
||||
prefix: string,
|
||||
): Promise<string[]> {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await this.listFilesRecursive(path.join(dir, entry.name), relative)));
|
||||
files.push(
|
||||
...(await this.listFilesRecursive(
|
||||
path.join(dir, entry.name),
|
||||
relative,
|
||||
)),
|
||||
);
|
||||
} else {
|
||||
files.push(relative);
|
||||
}
|
||||
|
||||
@@ -72,10 +72,12 @@ export class UserResponseDto {
|
||||
@Expose()
|
||||
@Transform(({ obj }) => {
|
||||
if (obj.roles && Array.isArray(obj.roles)) {
|
||||
return obj.roles.map((r: any) => {
|
||||
if (typeof r === 'string') return r;
|
||||
return r?.role?.name || r?.name;
|
||||
}).filter(Boolean);
|
||||
return obj.roles
|
||||
.map((r: any) => {
|
||||
if (typeof r === 'string') return r;
|
||||
return r?.role?.name || r?.name;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
})
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { Controller, Get, Patch, Body, BadRequestException } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Patch,
|
||||
Body,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { BaseController } from '../../common/base';
|
||||
import { UsersService } from './users.service';
|
||||
@@ -73,13 +79,19 @@ export class UsersController extends BaseController<
|
||||
if (!fullUser) throw new BadRequestException('Kullanıcı bulunamadı');
|
||||
|
||||
const bcrypt = await import('bcrypt');
|
||||
const isValid = await bcrypt.compare(body.currentPassword, fullUser.password);
|
||||
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');
|
||||
return createSuccessResponse(
|
||||
{ success: true },
|
||||
'Şifre başarıyla güncellendi',
|
||||
);
|
||||
}
|
||||
|
||||
// Override create to require admin role
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -51,18 +51,16 @@ export class VideoGenerationProducer {
|
||||
* BullMQ: NestJS tarafında lifecycle tracking
|
||||
* Redis List: C# Worker BRPOP ile consume eder
|
||||
*/
|
||||
async addVideoGenerationJob(payload: VideoGenerationJobPayload): Promise<string> {
|
||||
const bullJob = await this.videoQueue.add(
|
||||
'generate-video',
|
||||
payload,
|
||||
{
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 5000 },
|
||||
removeOnComplete: { count: 100, age: 7 * 24 * 3600 },
|
||||
removeOnFail: { count: 50 },
|
||||
priority: 1,
|
||||
},
|
||||
);
|
||||
async addVideoGenerationJob(
|
||||
payload: VideoGenerationJobPayload,
|
||||
): Promise<string> {
|
||||
const bullJob = await this.videoQueue.add('generate-video', payload, {
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 5000 },
|
||||
removeOnComplete: { count: 100, age: 7 * 24 * 3600 },
|
||||
removeOnFail: { count: 50 },
|
||||
priority: 1,
|
||||
});
|
||||
|
||||
const workerPayload = JSON.stringify({
|
||||
jobId: bullJob.id,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, IsNotEmpty, Matches } from 'class-validator';
|
||||
|
||||
/**
|
||||
@@ -10,14 +10,14 @@ import { IsString, IsNotEmpty, Matches } from 'class-validator';
|
||||
*/
|
||||
export class FetchTweetDto {
|
||||
@ApiProperty({
|
||||
description: 'X/Twitter tweet URL\'si',
|
||||
description: "X/Twitter tweet URL'si",
|
||||
example: 'https://x.com/elonmusk/status/1893456789012345678',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'Tweet URL\'si boş olamaz' })
|
||||
@IsNotEmpty({ message: "Tweet URL'si boş olamaz" })
|
||||
@Matches(
|
||||
/^https?:\/\/(x\.com|twitter\.com)\/([\w]+\/status\/\d+|i\/status\/\d+)/,
|
||||
{ message: 'Geçerli bir X/Twitter tweet URL\'si girin' },
|
||||
{ message: "Geçerli bir X/Twitter tweet URL'si girin" },
|
||||
)
|
||||
tweetUrl: string;
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export class XTwitterService {
|
||||
const parsed = this.parseFxTweet(response.tweet);
|
||||
|
||||
// Thread tespiti ve toplama
|
||||
const thread = await this.collectThread(parsed, username);
|
||||
const thread = this.collectThread(parsed, username);
|
||||
if (thread.length > 1) {
|
||||
parsed.isThread = true;
|
||||
parsed.threadTweets = thread;
|
||||
@@ -103,7 +103,7 @@ export class XTwitterService {
|
||||
: tweet.text;
|
||||
const wordCount = totalText.split(/\s+/).length;
|
||||
const estimatedDuration = Math.min(
|
||||
Math.max(Math.ceil((wordCount / 2.5) + 5), 15), // Min 15sn, ~2.5 kelime/sn okuma
|
||||
Math.max(Math.ceil(wordCount / 2.5 + 5), 15), // Min 15sn, ~2.5 kelime/sn okuma
|
||||
90, // Max 90sn
|
||||
);
|
||||
|
||||
@@ -182,10 +182,10 @@ export class XTwitterService {
|
||||
* Thread tweet'lerini toplar.
|
||||
* FXTwitter'da direkt thread endpoint yok → author'un son tweet'lerinden thread'i tahmin et.
|
||||
*/
|
||||
private async collectThread(
|
||||
private collectThread(
|
||||
rootTweet: ParsedTweet,
|
||||
username: string,
|
||||
): Promise<ParsedTweet[]> {
|
||||
_username: string,
|
||||
): ParsedTweet[] {
|
||||
const threadTweets: ParsedTweet[] = [rootTweet];
|
||||
|
||||
// Tweet'in reply olup olmadığını kontrol et
|
||||
@@ -196,7 +196,9 @@ export class XTwitterService {
|
||||
// İleride Xquik thread_extractor ile genişletilebilir
|
||||
|
||||
// Şu an: Eğer tweet uzunsa (280+ karakter) ve satır sonları varsa, thread benzeri
|
||||
const lines = rootTweet.text.split('\n').filter((l) => l.trim().length > 0);
|
||||
const lines = rootTweet.text
|
||||
.split('\n')
|
||||
.filter((l) => l.trim().length > 0);
|
||||
if (lines.length >= 3) {
|
||||
// Uzun tek tweet — thread gibi ele alınabilir
|
||||
return threadTweets;
|
||||
@@ -214,7 +216,9 @@ export class XTwitterService {
|
||||
/**
|
||||
* FXTwitter API response'unu ParsedTweet'e dönüştürür.
|
||||
*/
|
||||
private parseFxTweet(raw: NonNullable<FxTweetResponse['tweet']>): ParsedTweet {
|
||||
private parseFxTweet(
|
||||
raw: NonNullable<FxTweetResponse['tweet']>,
|
||||
): ParsedTweet {
|
||||
const views = raw.views || 1;
|
||||
const engagement = raw.likes + raw.retweets + raw.replies;
|
||||
|
||||
@@ -236,7 +240,8 @@ export class XTwitterService {
|
||||
retweets: raw.retweets,
|
||||
likes: raw.likes,
|
||||
views,
|
||||
engagementRate: views > 0 ? Number(((engagement / views) * 100).toFixed(2)) : 0,
|
||||
engagementRate:
|
||||
views > 0 ? Number(((engagement / views) * 100).toFixed(2)) : 0,
|
||||
},
|
||||
media: (raw.media?.all || []).map((m) => ({
|
||||
type: m.type,
|
||||
@@ -261,7 +266,7 @@ export class XTwitterService {
|
||||
* Engagement rate, takipçi oranı ve toplam etkileşim bazlı.
|
||||
*/
|
||||
private calculateViralScore(tweet: ParsedTweet): number {
|
||||
const { metrics, author } = tweet;
|
||||
const { metrics } = tweet;
|
||||
let score = 0;
|
||||
|
||||
// Engagement rate katkısı (max 40 puan)
|
||||
@@ -340,7 +345,9 @@ export class XTwitterService {
|
||||
if (res.status === 429) {
|
||||
const retryAfter = res.headers.get('Retry-After');
|
||||
const delay = retryAfter ? parseInt(retryAfter, 10) * 1000 : 5000;
|
||||
this.logger.warn(`Rate limited — ${delay}ms bekleniyor (deneme ${attempt}/${maxRetries})`);
|
||||
this.logger.warn(
|
||||
`Rate limited — ${delay}ms bekleniyor (deneme ${attempt}/${maxRetries})`,
|
||||
);
|
||||
await this.sleep(delay);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
const prisma = new PrismaClient();
|
||||
async function main() {
|
||||
const admin = await prisma.user.findFirst({
|
||||
where: { email: 'admin@contentgen.ai' },
|
||||
include: { roles: { include: { role: true } } }
|
||||
});
|
||||
console.log('admin roles:', JSON.stringify(admin?.roles, null, 2));
|
||||
}
|
||||
main().catch(console.error).finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,7 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
const prisma = new PrismaClient();
|
||||
async function main() {
|
||||
const admin = await prisma.user.findFirst({ where: { email: 'admin@contentgen.ai' } });
|
||||
console.log('admin record:', admin);
|
||||
}
|
||||
main().catch(console.error).finally(() => prisma.$disconnect());
|
||||
Reference in New Issue
Block a user