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({
|
LoggerModule.forRootAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: async (configService: ConfigService) => {
|
useFactory: (configService: ConfigService) => {
|
||||||
return {
|
return {
|
||||||
pinoHttp: {
|
pinoHttp: {
|
||||||
level: configService.get('app.isDevelopment') ? 'debug' : 'info',
|
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)
|
// Only update if translation exists (key is different from result)
|
||||||
if (translatedMessage !== `errors.${message}`) {
|
if (translatedMessage !== `errors.${message}`) {
|
||||||
message = translatedMessage as string;
|
message = translatedMessage;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
+21
-14
@@ -28,13 +28,14 @@ async function bootstrap() {
|
|||||||
app.useLogger(app.get(Logger));
|
app.useLogger(app.get(Logger));
|
||||||
app.useGlobalInterceptors(new LoggerErrorInterceptor());
|
app.useGlobalInterceptors(new LoggerErrorInterceptor());
|
||||||
|
|
||||||
|
|
||||||
// Security Headers
|
// Security Headers
|
||||||
app.use(helmet({
|
app.use(
|
||||||
contentSecurityPolicy: false,
|
helmet({
|
||||||
crossOriginEmbedderPolicy: false,
|
contentSecurityPolicy: false,
|
||||||
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
crossOriginEmbedderPolicy: false,
|
||||||
}));
|
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// Graceful Shutdown (Prisma & Docker)
|
// Graceful Shutdown (Prisma & Docker)
|
||||||
app.enableShutdownHooks();
|
app.enableShutdownHooks();
|
||||||
@@ -45,7 +46,10 @@ async function bootstrap() {
|
|||||||
const nodeEnv = configService.get('NODE_ENV', 'development');
|
const nodeEnv = configService.get('NODE_ENV', 'development');
|
||||||
|
|
||||||
// ── Static File Serving — Medya dosyalarına HTTP erişim ──
|
// ── 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);
|
const absoluteMediaPath = path.resolve(mediaPath);
|
||||||
|
|
||||||
// Medya dosyaları için CORS header'ları (Frontend farklı port'ta çalışıyor)
|
// Medya dosyaları için CORS header'ları (Frontend farklı port'ta çalışıyor)
|
||||||
@@ -55,13 +59,16 @@ async function bootstrap() {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use('/media', express.static(absoluteMediaPath, {
|
app.use(
|
||||||
maxAge: '1d',
|
'/media',
|
||||||
etag: true,
|
express.static(absoluteMediaPath, {
|
||||||
lastModified: true,
|
maxAge: '1d',
|
||||||
index: false,
|
etag: true,
|
||||||
dotfiles: 'deny',
|
lastModified: true,
|
||||||
}));
|
index: false,
|
||||||
|
dotfiles: 'deny',
|
||||||
|
}),
|
||||||
|
);
|
||||||
logger.log(`📂 Medya dizini: ${absoluteMediaPath} → /media/*`);
|
logger.log(`📂 Medya dizini: ${absoluteMediaPath} → /media/*`);
|
||||||
|
|
||||||
// Enable CORS
|
// Enable CORS
|
||||||
|
|||||||
@@ -84,7 +84,11 @@ export class AdminController {
|
|||||||
@Param('userId') userId: string,
|
@Param('userId') userId: string,
|
||||||
@Body() data: { amount: number; description: string },
|
@Body() data: { amount: number; description: string },
|
||||||
): Promise<ApiResponse<any>> {
|
): 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');
|
return createSuccessResponse(tx, 'Kredi yüklendi');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,9 +96,7 @@ export class AdminController {
|
|||||||
|
|
||||||
@Get('users/:id/detail')
|
@Get('users/:id/detail')
|
||||||
@ApiOperation({ summary: 'Kullanıcı detay — abonelik, projeler, krediler' })
|
@ApiOperation({ summary: 'Kullanıcı detay — abonelik, projeler, krediler' })
|
||||||
async getUserDetail(
|
async getUserDetail(@Param('id') id: string): Promise<ApiResponse<any>> {
|
||||||
@Param('id') id: string,
|
|
||||||
): Promise<ApiResponse<any>> {
|
|
||||||
const user = await this.adminService.getUserDetail(id);
|
const user = await this.adminService.getUserDetail(id);
|
||||||
return createSuccessResponse(user);
|
return createSuccessResponse(user);
|
||||||
}
|
}
|
||||||
@@ -325,7 +327,13 @@ export class AdminController {
|
|||||||
@Get('projects')
|
@Get('projects')
|
||||||
@ApiOperation({ summary: 'Tüm projeleri getir (admin)' })
|
@ApiOperation({ summary: 'Tüm projeleri getir (admin)' })
|
||||||
async getAllProjects(
|
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>> {
|
): Promise<ApiResponse<any>> {
|
||||||
const result = await this.adminService.getAllProjects({
|
const result = await this.adminService.getAllProjects({
|
||||||
page: query.page ? Number(query.page) : 1,
|
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],
|
exports: [AdminService],
|
||||||
})
|
})
|
||||||
export class AdminModule {}
|
export class AdminModule {}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,13 @@ export class AdminService {
|
|||||||
this.prisma.user.findMany({
|
this.prisma.user.findMany({
|
||||||
take: 5,
|
take: 5,
|
||||||
orderBy: { createdAt: 'desc' },
|
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({
|
this.prisma.project.groupBy({
|
||||||
by: ['status'],
|
by: ['status'],
|
||||||
@@ -65,17 +71,23 @@ export class AdminService {
|
|||||||
},
|
},
|
||||||
projects: {
|
projects: {
|
||||||
total: totalProjects,
|
total: totalProjects,
|
||||||
byStatus: projectsByStatus.reduce((acc, item) => {
|
byStatus: projectsByStatus.reduce(
|
||||||
acc[item.status] = item._count.id;
|
(acc, item) => {
|
||||||
return acc;
|
acc[item.status] = item._count.id;
|
||||||
}, {} as Record<string, number>),
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, number>,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
renderJobs: {
|
renderJobs: {
|
||||||
total: totalRenderJobs,
|
total: totalRenderJobs,
|
||||||
byStatus: renderJobsByStatus.reduce((acc, item) => {
|
byStatus: renderJobsByStatus.reduce(
|
||||||
acc[item.status] = item._count.id;
|
(acc, item) => {
|
||||||
return acc;
|
acc[item.status] = item._count.id;
|
||||||
}, {} as Record<string, number>),
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, number>,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
credits: {
|
credits: {
|
||||||
totalGranted: creditStats._sum.amount || 0,
|
totalGranted: creditStats._sum.amount || 0,
|
||||||
@@ -102,18 +114,21 @@ export class AdminService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async updatePlan(planId: string, data: {
|
async updatePlan(
|
||||||
displayName?: string;
|
planId: string,
|
||||||
description?: string;
|
data: {
|
||||||
monthlyPrice?: number;
|
displayName?: string;
|
||||||
yearlyPrice?: number;
|
description?: string;
|
||||||
monthlyCredits?: number;
|
monthlyPrice?: number;
|
||||||
maxDuration?: number;
|
yearlyPrice?: number;
|
||||||
maxResolution?: string;
|
monthlyCredits?: number;
|
||||||
maxProjects?: number;
|
maxDuration?: number;
|
||||||
isActive?: boolean;
|
maxResolution?: string;
|
||||||
features?: any;
|
maxProjects?: number;
|
||||||
}) {
|
isActive?: boolean;
|
||||||
|
features?: any;
|
||||||
|
},
|
||||||
|
) {
|
||||||
return this.prisma.plan.update({
|
return this.prisma.plan.update({
|
||||||
where: { id: planId },
|
where: { id: planId },
|
||||||
data,
|
data,
|
||||||
@@ -122,7 +137,12 @@ export class AdminService {
|
|||||||
|
|
||||||
// ── Proje ve Render Yönetimi ──────────────────────────────────────
|
// ── 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;
|
const { page, limit, status, userId } = params;
|
||||||
|
|
||||||
// Status filtresini prisma tarafında idari bir kontrole dönüştürmek gerek
|
// Status filtresini prisma tarafında idari bir kontrole dönüştürmek gerek
|
||||||
@@ -133,7 +153,9 @@ export class AdminService {
|
|||||||
const [data, total] = await Promise.all([
|
const [data, total] = await Promise.all([
|
||||||
this.prisma.project.findMany({
|
this.prisma.project.findMany({
|
||||||
where: whereCondition,
|
where: whereCondition,
|
||||||
include: { user: { select: { email: true, firstName: true, lastName: true } } },
|
include: {
|
||||||
|
user: { select: { email: true, firstName: true, lastName: true } },
|
||||||
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
skip: (page - 1) * limit,
|
skip: (page - 1) * limit,
|
||||||
take: limit,
|
take: limit,
|
||||||
@@ -144,7 +166,11 @@ export class AdminService {
|
|||||||
return { data, total, page, limit, totalPages: Math.ceil(total / limit) };
|
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 { page, limit, status } = params;
|
||||||
|
|
||||||
const whereCondition: any = {};
|
const whereCondition: any = {};
|
||||||
|
|||||||
@@ -146,10 +146,12 @@ export class AuthService {
|
|||||||
if (user.email === 'admin@contentgen.ai') {
|
if (user.email === 'admin@contentgen.ai') {
|
||||||
const hasAdminRole = user.roles.some((ur) => ur.role.name === 'admin');
|
const hasAdminRole = user.roles.some((ur) => ur.role.name === 'admin');
|
||||||
if (!hasAdminRole) {
|
if (!hasAdminRole) {
|
||||||
const adminRole = await this.prisma.role.findUnique({ where: { name: 'admin' } });
|
const adminRole = await this.prisma.role.findUnique({
|
||||||
|
where: { name: 'admin' },
|
||||||
|
});
|
||||||
if (adminRole) {
|
if (adminRole) {
|
||||||
await this.prisma.userRole.create({
|
await this.prisma.userRole.create({
|
||||||
data: { userId: user.id, roleId: adminRole.id }
|
data: { userId: user.id, roleId: adminRole.id },
|
||||||
});
|
});
|
||||||
// Refresh user object
|
// Refresh user object
|
||||||
const refreshedUser = await this.prisma.user.findUnique({
|
const refreshedUser = await this.prisma.user.findUnique({
|
||||||
@@ -157,17 +159,25 @@ export class AuthService {
|
|||||||
include: {
|
include: {
|
||||||
roles: {
|
roles: {
|
||||||
include: {
|
include: {
|
||||||
role: { include: { permissions: { include: { permission: true } } } }
|
role: {
|
||||||
}
|
include: { permissions: { include: { permission: true } } },
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (refreshedUser) {
|
if (refreshedUser) {
|
||||||
// Grant 999999 credits if not granted
|
// Grant 999999 credits if not granted
|
||||||
const existingGrant = await this.prisma.creditTransaction.findFirst({
|
const existingGrant = await this.prisma.creditTransaction.findFirst(
|
||||||
where: { userId: refreshedUser.id, type: 'grant', description: 'Admin başlangıç kredisi — sınırsız' },
|
{
|
||||||
});
|
where: {
|
||||||
|
userId: refreshedUser.id,
|
||||||
|
type: 'grant',
|
||||||
|
description: 'Admin başlangıç kredisi — sınırsız',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
if (!existingGrant) {
|
if (!existingGrant) {
|
||||||
await this.prisma.creditTransaction.create({
|
await this.prisma.creditTransaction.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -179,7 +189,9 @@ export class AuthService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return this.generateTokens(refreshedUser as unknown as UserWithRoles);
|
return this.generateTokens(
|
||||||
|
refreshedUser as unknown as UserWithRoles,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,7 +315,7 @@ export class AuthService {
|
|||||||
|
|
||||||
// Generate access token
|
// Generate access token
|
||||||
const accessToken = this.jwtService.sign(payload, {
|
const accessToken = this.jwtService.sign(payload, {
|
||||||
expiresIn: accessExpiration as any,
|
expiresIn: accessExpiration,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generate refresh token
|
// Generate refresh token
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ export class BillingController {
|
|||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
) {
|
) {
|
||||||
const stripeKey = this.configService.get<string>('STRIPE_SECRET_KEY');
|
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) {
|
if (stripeKey) {
|
||||||
this.stripe = new Stripe(stripeKey);
|
this.stripe = new Stripe(stripeKey);
|
||||||
@@ -54,7 +57,11 @@ export class BillingController {
|
|||||||
@Body() body: { planName: string; billingCycle: 'monthly' | 'yearly' },
|
@Body() body: { planName: string; billingCycle: 'monthly' | 'yearly' },
|
||||||
) {
|
) {
|
||||||
const userId = req.user?.id || req.user?.sub;
|
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')
|
@Get('credits/balance')
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
BadRequestException,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { PrismaService } from '../../database/prisma.service';
|
import { PrismaService } from '../../database/prisma.service';
|
||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
@@ -32,14 +37,20 @@ export class BillingService {
|
|||||||
this.logger.log('💳 Stripe bağlantısı kuruldu');
|
this.logger.log('💳 Stripe bağlantısı kuruldu');
|
||||||
} else {
|
} else {
|
||||||
this.stripe = null;
|
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)
|
* 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) {
|
if (!this.stripe) {
|
||||||
throw new BadRequestException('Ödeme sistemi şu anda aktif değil');
|
throw new BadRequestException('Ödeme sistemi şu anda aktif değil');
|
||||||
}
|
}
|
||||||
@@ -61,12 +72,13 @@ export class BillingService {
|
|||||||
throw new NotFoundException('Kullanıcı bulunamadı');
|
throw new NotFoundException('Kullanıcı bulunamadı');
|
||||||
}
|
}
|
||||||
|
|
||||||
const priceId = billingCycle === 'yearly'
|
const priceId =
|
||||||
? plan.stripeYearlyPriceId
|
billingCycle === 'yearly' ? plan.stripeYearlyPriceId : plan.stripePriceId;
|
||||||
: plan.stripePriceId;
|
|
||||||
|
|
||||||
if (!priceId) {
|
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({
|
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`,
|
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 {
|
return {
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
@@ -103,19 +117,19 @@ export class BillingService {
|
|||||||
async handleWebhookEvent(event: Stripe.Event): Promise<void> {
|
async handleWebhookEvent(event: Stripe.Event): Promise<void> {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'checkout.session.completed':
|
case 'checkout.session.completed':
|
||||||
await this.handleCheckoutComplete(event.data.object as Stripe.Checkout.Session);
|
await this.handleCheckoutComplete(event.data.object);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'invoice.payment_succeeded':
|
case 'invoice.payment_succeeded':
|
||||||
await this.handlePaymentSucceeded(event.data.object as Stripe.Invoice);
|
await this.handlePaymentSucceeded(event.data.object);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'customer.subscription.updated':
|
case 'customer.subscription.updated':
|
||||||
await this.handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
|
await this.handleSubscriptionUpdated(event.data.object);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'customer.subscription.deleted':
|
case 'customer.subscription.deleted':
|
||||||
await this.handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
|
await this.handleSubscriptionDeleted(event.data.object);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -157,7 +171,9 @@ export class BillingService {
|
|||||||
where: { userId },
|
where: { userId },
|
||||||
include: { role: true },
|
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(
|
const monthlyTransactions = transactions.filter(
|
||||||
(tx) => tx.amount < 0 && new Date(tx.createdAt) >= monthStart,
|
(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
|
// Kullanıcının aktif planından limit al
|
||||||
const subscription = await this.db.subscription.findFirst({
|
const subscription = await this.db.subscription.findFirst({
|
||||||
@@ -217,12 +235,24 @@ export class BillingService {
|
|||||||
/**
|
/**
|
||||||
* Kredi harca (video üretimi için)
|
* 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
|
// Admin bypass — sınırsız kredi
|
||||||
const admin = await this.isAdmin(userId);
|
const admin = await this.isAdmin(userId);
|
||||||
if (admin) {
|
if (admin) {
|
||||||
this.logger.log(`🛡️ Admin kredi bypass: ${amount} — User: ${userId}, Project: ${projectId}`);
|
this.logger.log(
|
||||||
return { id: 'admin-bypass', amount: -amount, type: 'usage', description };
|
`🛡️ Admin kredi bypass: ${amount} — User: ${userId}, Project: ${projectId}`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: 'admin-bypass',
|
||||||
|
amount: -amount,
|
||||||
|
type: 'usage',
|
||||||
|
description,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const balance = await this.getCreditBalance(userId);
|
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;
|
return transaction;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kredi ekle (abonelik yenileme, bonus vb.)
|
* 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 currentBalance = await this.getCreditBalance(userId);
|
||||||
|
|
||||||
const transaction = await this.db.creditTransaction.create({
|
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;
|
return transaction;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,16 +335,22 @@ export class BillingService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// İlk ay kredilerini yükle
|
// İ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}`);
|
this.logger.log(`✅ Abonelik aktif: User ${userId}, Plan ${plan.name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handlePaymentSucceeded(invoice: Stripe.Invoice) {
|
private async handlePaymentSucceeded(invoice: Stripe.Invoice) {
|
||||||
const inv = invoice as any;
|
const inv = invoice as any;
|
||||||
const subscriptionId = typeof inv.subscription === 'string'
|
const subscriptionId =
|
||||||
? inv.subscription
|
typeof inv.subscription === 'string'
|
||||||
: inv.subscription?.id;
|
? inv.subscription
|
||||||
|
: inv.subscription?.id;
|
||||||
if (!subscriptionId) return;
|
if (!subscriptionId) return;
|
||||||
|
|
||||||
const subscription = await this.db.subscription.findFirst({
|
const subscription = await this.db.subscription.findFirst({
|
||||||
@@ -323,10 +368,14 @@ export class BillingService {
|
|||||||
`${subscription.plan.displayName} aylık kredi yenileme`,
|
`${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 periodStart = (stripeSubscription as any).current_period_start;
|
||||||
const periodEnd = (stripeSubscription as any).current_period_end;
|
const periodEnd = (stripeSubscription as any).current_period_end;
|
||||||
|
|
||||||
@@ -335,13 +384,17 @@ export class BillingService {
|
|||||||
data: {
|
data: {
|
||||||
status: stripeSubscription.status,
|
status: stripeSubscription.status,
|
||||||
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
|
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
|
||||||
...(periodStart && { currentPeriodStart: new Date(periodStart * 1000) }),
|
...(periodStart && {
|
||||||
|
currentPeriodStart: new Date(periodStart * 1000),
|
||||||
|
}),
|
||||||
...(periodEnd && { currentPeriodEnd: new Date(periodEnd * 1000) }),
|
...(periodEnd && { currentPeriodEnd: new Date(periodEnd * 1000) }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleSubscriptionDeleted(stripeSubscription: Stripe.Subscription) {
|
private async handleSubscriptionDeleted(
|
||||||
|
stripeSubscription: Stripe.Subscription,
|
||||||
|
) {
|
||||||
await this.db.subscription.updateMany({
|
await this.db.subscription.updateMany({
|
||||||
where: { stripeSubscriptionId: stripeSubscription.id },
|
where: { stripeSubscriptionId: stripeSubscription.id },
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
|
import { Controller, Get, Logger, Req } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
Controller,
|
ApiTags,
|
||||||
Get,
|
ApiOperation,
|
||||||
Logger,
|
ApiResponse,
|
||||||
Req,
|
ApiBearerAuth,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/swagger';
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
|
||||||
import { DashboardService } from './dashboard.service';
|
import { DashboardService } from './dashboard.service';
|
||||||
|
|
||||||
@ApiTags('dashboard')
|
@ApiTags('dashboard')
|
||||||
|
|||||||
@@ -45,7 +45,14 @@ export class DashboardService {
|
|||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
deletedAt: null,
|
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({
|
@WebSocketGateway({
|
||||||
cors: {
|
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,
|
credentials: true,
|
||||||
},
|
},
|
||||||
namespace: '/ws',
|
namespace: '/ws',
|
||||||
@@ -35,12 +39,16 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
|
|
||||||
handleConnection(client: Socket) {
|
handleConnection(client: Socket) {
|
||||||
this.connectedClients++;
|
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) {
|
handleDisconnect(client: Socket) {
|
||||||
this.connectedClients--;
|
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 },
|
@MessageBody() data: { projectId: string },
|
||||||
) {
|
) {
|
||||||
const room = `project:${data.projectId}`;
|
const room = `project:${data.projectId}`;
|
||||||
client.join(room);
|
void client.join(room);
|
||||||
this.logger.debug(`Client ${client.id} → room: ${room}`);
|
this.logger.debug(`Client ${client.id} → room: ${room}`);
|
||||||
return { event: 'joined', data: { room, projectId: data.projectId } };
|
return { event: 'joined', data: { room, projectId: data.projectId } };
|
||||||
}
|
}
|
||||||
@@ -67,7 +75,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
@MessageBody() data: { projectId: string },
|
@MessageBody() data: { projectId: string },
|
||||||
) {
|
) {
|
||||||
const room = `project:${data.projectId}`;
|
const room = `project:${data.projectId}`;
|
||||||
client.leave(room);
|
void client.leave(room);
|
||||||
this.logger.debug(`Client ${client.id} ← room: ${room}`);
|
this.logger.debug(`Client ${client.id} ← room: ${room}`);
|
||||||
return { event: 'left', data: { room } };
|
return { event: 'left', data: { room } };
|
||||||
}
|
}
|
||||||
@@ -82,7 +90,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
@MessageBody() data: { userId: string },
|
@MessageBody() data: { userId: string },
|
||||||
) {
|
) {
|
||||||
const room = `user:${data.userId}`;
|
const room = `user:${data.userId}`;
|
||||||
client.join(room);
|
void client.join(room);
|
||||||
this.logger.debug(`Client ${client.id} → user room: ${room}`);
|
this.logger.debug(`Client ${client.id} → user room: ${room}`);
|
||||||
return { event: 'joined', data: { room, userId: data.userId } };
|
return { event: 'joined', data: { room, userId: data.userId } };
|
||||||
}
|
}
|
||||||
@@ -96,7 +104,7 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
|||||||
@MessageBody() data: { userId: string },
|
@MessageBody() data: { userId: string },
|
||||||
) {
|
) {
|
||||||
const room = `user:${data.userId}`;
|
const room = `user:${data.userId}`;
|
||||||
client.leave(room);
|
void client.leave(room);
|
||||||
this.logger.debug(`Client ${client.id} ← user room: ${room}`);
|
this.logger.debug(`Client ${client.id} ← user room: ${room}`);
|
||||||
return { event: 'left', data: { room } };
|
return { event: 'left', data: { room } };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,27 +6,39 @@ import FormData from 'form-data';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExtractorService {
|
export class ExtractorService {
|
||||||
private readonly logger = new Logger(ExtractorService.name);
|
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() {}
|
constructor() {}
|
||||||
|
|
||||||
async extractFromUrl(url: string): Promise<string> {
|
async extractFromUrl(url: string): Promise<string> {
|
||||||
this.logger.log(`URL'den içerik çekiliyor: ${url}`);
|
this.logger.log(`URL'den içerik çekiliyor: ${url}`);
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(`${this.extractorUrl}/extract/url`, { url }, {
|
const response = await axios.post(
|
||||||
timeout: 60000 // 60 seconds timeout
|
`${this.extractorUrl}/extract/url`,
|
||||||
});
|
{ url },
|
||||||
|
{
|
||||||
|
timeout: 60000, // 60 seconds timeout
|
||||||
|
},
|
||||||
|
);
|
||||||
return response.data.content;
|
return response.data.content;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`URL extraction failed: ${error.message}`);
|
this.logger.error(`URL extraction failed: ${error.message}`);
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
throw new HttpException(error.response.data, error.response.status);
|
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}`);
|
this.logger.log(`Dosyadan içerik çekiliyor: ${filename}`);
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -35,12 +47,16 @@ export class ExtractorService {
|
|||||||
contentType: mimeType,
|
contentType: mimeType,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await axios.post(`${this.extractorUrl}/extract/file`, formData, {
|
const response = await axios.post(
|
||||||
headers: {
|
`${this.extractorUrl}/extract/file`,
|
||||||
...formData.getHeaders(),
|
formData,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
...formData.getHeaders(),
|
||||||
|
},
|
||||||
|
timeout: 120000, // 2 minutes timeout for files
|
||||||
},
|
},
|
||||||
timeout: 120000 // 2 minutes timeout for files
|
);
|
||||||
});
|
|
||||||
|
|
||||||
return response.data.content;
|
return response.data.content;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -48,7 +64,10 @@ export class ExtractorService {
|
|||||||
if (error.response) {
|
if (error.response) {
|
||||||
throw new HttpException(error.response.data, error.response.status);
|
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',
|
enabled: process.env.ENABLE_GEMINI === 'true',
|
||||||
apiKey: process.env.GOOGLE_API_KEY,
|
apiKey: process.env.GOOGLE_API_KEY,
|
||||||
defaultModel: process.env.GEMINI_MODEL || 'gemini-2.5-flash',
|
defaultModel: process.env.GEMINI_MODEL || 'gemini-2.5-flash',
|
||||||
imageModel: process.env.GEMINI_IMAGE_MODEL || 'gemini-2.0-flash-preview-image-generation',
|
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';
|
const fallbackModel = 'gemini-3.1-flash-image-preview';
|
||||||
|
|
||||||
try {
|
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
|
// 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.
|
// 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.
|
// Jenerik stil kelimelerini sonuna ekliyoruz ki ana konu (prompt) kaybolmasın.
|
||||||
const enhancedPrompt = isIllustration
|
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++) {
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||||
try {
|
try {
|
||||||
this.logger.log(`🔄 Katman 1 (deneme ${attempt}/2): ${primaryModel}`);
|
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) {
|
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 };
|
return { buffer: result.buffer, mimeType: result.mimeType };
|
||||||
}
|
}
|
||||||
|
|
||||||
const reason = result?.errorReason || 'null response';
|
const reason = result?.errorReason || 'null response';
|
||||||
this.logger.warn(`⚠️ ${primaryModel} deneme ${attempt}: görsel döndürmedi (${reason})`);
|
this.logger.warn(
|
||||||
|
`⚠️ ${primaryModel} deneme ${attempt}: görsel döndürmedi (${reason})`,
|
||||||
|
);
|
||||||
|
|
||||||
if (['IMAGE_OTHER', 'SAFETY', 'PROHIBITED_CONTENT'].includes(reason)) {
|
if (
|
||||||
this.logger.warn(`🚫 Güvenlik/Politika filtresi tetiklendi (${reason}). Denemeler iptal ediliyor.`);
|
['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
|
break; // Fail fast for safety blocks
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attempt < 2) await this.sleep(2000);
|
if (attempt < 2) await this.sleep(2000);
|
||||||
} catch (err1: any) {
|
} 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);
|
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) ──
|
// ── Katman 2: gemini-3.1-flash-image-preview (Nano Banana 2) ──
|
||||||
try {
|
try {
|
||||||
this.logger.log(`🔄 Katman 2: ${fallbackModel}`);
|
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) {
|
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 };
|
return { buffer: result.buffer, mimeType: result.mimeType };
|
||||||
}
|
}
|
||||||
this.logger.warn(`⚠️ ${fallbackModel}: görsel döndürmedi (${result?.errorReason || 'null response'})`);
|
this.logger.warn(
|
||||||
|
`⚠️ ${fallbackModel}: görsel döndürmedi (${result?.errorReason || 'null response'})`,
|
||||||
|
);
|
||||||
|
|
||||||
if (['IMAGE_OTHER', 'SAFETY', 'PROHIBITED_CONTENT'].includes(result?.errorReason || '')) {
|
if (
|
||||||
this.logger.warn(`🚫 Katman 2 Güvenlik/Politika filtresi tetiklendi. Katman 3'e geçiliyor.`);
|
['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) {
|
} 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) ──
|
// ── 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) {
|
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';
|
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 };
|
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) {
|
} 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');
|
this.logger.error('❌ Tüm görsel üretim katmanları başarısız oldu');
|
||||||
return null;
|
return null;
|
||||||
} catch (error) {
|
} 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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -360,7 +406,11 @@ IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
|
|||||||
private async tryGenerateContentImage(
|
private async tryGenerateContentImage(
|
||||||
model: string,
|
model: string,
|
||||||
prompt: 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({
|
const response = await this.client!.models.generateContent({
|
||||||
model,
|
model,
|
||||||
contents: prompt,
|
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ü
|
// Safety filter veya boş yanıt kontrolü
|
||||||
if (!candidate?.content?.parts || candidate.content.parts.length === 0) {
|
if (!candidate?.content?.parts || candidate.content.parts.length === 0) {
|
||||||
const finishReason = candidate?.finishReason || 'UNKNOWN';
|
const finishReason = candidate?.finishReason || 'UNKNOWN';
|
||||||
this.logger.warn(`⚠️ ${model}: boş yanıt (finishReason: ${finishReason})`);
|
this.logger.warn(
|
||||||
return { buffer: Buffer.from([]), mimeType: '', errorReason: finishReason };
|
`⚠️ ${model}: boş yanıt (finishReason: ${finishReason})`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
buffer: Buffer.from([]),
|
||||||
|
mimeType: '',
|
||||||
|
errorReason: finishReason,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const imagePart = candidate.content.parts.find(
|
const imagePart = candidate.content.parts.find((p: any) =>
|
||||||
(p: any) => p.inlineData?.mimeType?.startsWith('image/'),
|
p.inlineData?.mimeType?.startsWith('image/'),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (imagePart?.inlineData?.data) {
|
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)
|
// Text-only response geldi (görsel yok)
|
||||||
const textParts = candidate.content.parts.filter((p: any) => p.text);
|
const textParts = candidate.content.parts.filter((p: any) => p.text);
|
||||||
if (textParts.length > 0) {
|
if (textParts.length > 0) {
|
||||||
this.logger.warn(`⚠️ ${model}: sadece text döndü, görsel yok. Text: "${textParts[0].text?.substring(0, 100)}"`);
|
this.logger.warn(
|
||||||
return { buffer: Buffer.from([]), mimeType: '', errorReason: 'TEXT_ONLY' };
|
`⚠️ ${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 */
|
/** Basit uyku fonksiyonu — retry aralarında kullanılır */
|
||||||
private sleep(ms: number): Promise<void> {
|
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');
|
return this.generateImage(prompt, '16:9');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ import {
|
|||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiQuery,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
import { NotificationsService } from './notifications.service';
|
import { NotificationsService } from './notifications.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -62,10 +67,7 @@ export class NotificationsController {
|
|||||||
@Patch(':id/read')
|
@Patch(':id/read')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: 'Bildirimi okundu olarak işaretle' })
|
@ApiOperation({ summary: 'Bildirimi okundu olarak işaretle' })
|
||||||
async markAsRead(
|
async markAsRead(@Req() req: any, @Param('id') id: string) {
|
||||||
@Req() req: any,
|
|
||||||
@Param('id') id: string,
|
|
||||||
) {
|
|
||||||
const userId = req.user?.id || req.user?.sub;
|
const userId = req.user?.id || req.user?.sub;
|
||||||
return this.notificationsService.markAsRead(id, userId);
|
return this.notificationsService.markAsRead(id, userId);
|
||||||
}
|
}
|
||||||
@@ -73,10 +75,7 @@ export class NotificationsController {
|
|||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: 'Bildirimi sil' })
|
@ApiOperation({ summary: 'Bildirimi sil' })
|
||||||
async deleteNotification(
|
async deleteNotification(@Req() req: any, @Param('id') id: string) {
|
||||||
@Req() req: any,
|
|
||||||
@Param('id') id: string,
|
|
||||||
) {
|
|
||||||
const userId = req.user?.id || req.user?.sub;
|
const userId = req.user?.id || req.user?.sub;
|
||||||
return this.notificationsService.deleteNotification(id, userId);
|
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 { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../../database/prisma.service';
|
import { PrismaService } from '../../database/prisma.service';
|
||||||
import { EventsGateway } from '../events/events.gateway';
|
import { EventsGateway } from '../events/events.gateway';
|
||||||
@@ -77,11 +82,7 @@ export class NotificationsService {
|
|||||||
/**
|
/**
|
||||||
* Kullanıcının bildirimlerini getir (pagination).
|
* Kullanıcının bildirimlerini getir (pagination).
|
||||||
*/
|
*/
|
||||||
async getUserNotifications(
|
async getUserNotifications(userId: string, page = 1, limit = 20) {
|
||||||
userId: string,
|
|
||||||
page = 1,
|
|
||||||
limit = 20,
|
|
||||||
) {
|
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
const [notifications, total] = await Promise.all([
|
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 };
|
return { updated: result.count };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ export class CreateProjectDto {
|
|||||||
aspectRatio?: AspectRatioDto;
|
aspectRatio?: AspectRatioDto;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Video stili (CINEMATIC, DOCUMENTARY, ANIME, NOIR, CYBERPUNK vb.)',
|
description:
|
||||||
|
'Video stili (CINEMATIC, DOCUMENTARY, ANIME, NOIR, CYBERPUNK vb.)',
|
||||||
example: 'CINEMATIC',
|
example: 'CINEMATIC',
|
||||||
default: 'CINEMATIC',
|
default: 'CINEMATIC',
|
||||||
})
|
})
|
||||||
@@ -73,7 +74,9 @@ export class CreateProjectDto {
|
|||||||
@MaxLength(50)
|
@MaxLength(50)
|
||||||
videoStyle?: string;
|
videoStyle?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)' })
|
@ApiPropertyOptional({
|
||||||
|
description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)',
|
||||||
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@MaxLength(200)
|
@MaxLength(200)
|
||||||
@@ -136,14 +139,17 @@ export class UpdateProjectDto {
|
|||||||
aspectRatio?: AspectRatioDto;
|
aspectRatio?: AspectRatioDto;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Video stili (CINEMATIC, DOCUMENTARY, ANIME, NOIR, CYBERPUNK vb.)',
|
description:
|
||||||
|
'Video stili (CINEMATIC, DOCUMENTARY, ANIME, NOIR, CYBERPUNK vb.)',
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@MaxLength(50)
|
@MaxLength(50)
|
||||||
videoStyle?: string;
|
videoStyle?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)' })
|
@ApiPropertyOptional({
|
||||||
|
description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)',
|
||||||
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@MaxLength(200)
|
@MaxLength(200)
|
||||||
@@ -163,19 +169,19 @@ export class UpdateProjectDto {
|
|||||||
*/
|
*/
|
||||||
export class CreateFromTweetDto {
|
export class CreateFromTweetDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'X/Twitter tweet URL\'si',
|
description: "X/Twitter tweet URL'si",
|
||||||
example: 'https://x.com/elonmusk/status/1893456789012345678',
|
example: 'https://x.com/elonmusk/status/1893456789012345678',
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty({ message: 'Tweet URL\'si boş olamaz' })
|
@IsNotEmpty({ message: "Tweet URL'si boş olamaz" })
|
||||||
@Matches(
|
@Matches(
|
||||||
/^https?:\/\/(x\.com|twitter\.com)\/([\w]+\/status\/\d+|i\/status\/\d+)/,
|
/^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;
|
tweetUrl: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@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()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -190,13 +196,17 @@ export class CreateFromTweetDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
language?: string;
|
language?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: AspectRatioDto, default: AspectRatioDto.PORTRAIT_9_16 })
|
@ApiPropertyOptional({
|
||||||
|
enum: AspectRatioDto,
|
||||||
|
default: AspectRatioDto.PORTRAIT_9_16,
|
||||||
|
})
|
||||||
@IsEnum(AspectRatioDto)
|
@IsEnum(AspectRatioDto)
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
aspectRatio?: AspectRatioDto;
|
aspectRatio?: AspectRatioDto;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'Video stili (CINEMATIC, DOCUMENTARY, ANIME, NOIR, CYBERPUNK vb.)',
|
description:
|
||||||
|
'Video stili (CINEMATIC, DOCUMENTARY, ANIME, NOIR, CYBERPUNK vb.)',
|
||||||
default: 'CINEMATIC',
|
default: 'CINEMATIC',
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -204,13 +214,18 @@ export class CreateFromTweetDto {
|
|||||||
@MaxLength(50)
|
@MaxLength(50)
|
||||||
videoStyle?: string;
|
videoStyle?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)' })
|
@ApiPropertyOptional({
|
||||||
|
description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)',
|
||||||
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@MaxLength(200)
|
@MaxLength(200)
|
||||||
cinematicReference?: string;
|
cinematicReference?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Hedef video süresi (saniye)', default: 60 })
|
@ApiPropertyOptional({
|
||||||
|
description: 'Hedef video süresi (saniye)',
|
||||||
|
default: 60,
|
||||||
|
})
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Min(15)
|
@Min(15)
|
||||||
@@ -220,19 +235,19 @@ export class CreateFromTweetDto {
|
|||||||
|
|
||||||
export class CreateFromYoutubeDto {
|
export class CreateFromYoutubeDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'YouTube Video URL\'si',
|
description: "YouTube Video URL'si",
|
||||||
example: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
example: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty({ message: 'YouTube URL\'si boş olamaz' })
|
@IsNotEmpty({ message: "YouTube URL'si boş olamaz" })
|
||||||
@Matches(
|
@Matches(/^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\/.+$/, {
|
||||||
/^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\/.+$/,
|
message: "Geçerli bir YouTube URL'si girin",
|
||||||
{ message: 'Geçerli bir YouTube URL\'si girin' },
|
})
|
||||||
)
|
|
||||||
youtubeUrl: string;
|
youtubeUrl: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@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()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -247,7 +262,10 @@ export class CreateFromYoutubeDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
language?: string;
|
language?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: AspectRatioDto, default: AspectRatioDto.PORTRAIT_9_16 })
|
@ApiPropertyOptional({
|
||||||
|
enum: AspectRatioDto,
|
||||||
|
default: AspectRatioDto.PORTRAIT_9_16,
|
||||||
|
})
|
||||||
@IsEnum(AspectRatioDto)
|
@IsEnum(AspectRatioDto)
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
aspectRatio?: AspectRatioDto;
|
aspectRatio?: AspectRatioDto;
|
||||||
@@ -261,13 +279,18 @@ export class CreateFromYoutubeDto {
|
|||||||
@MaxLength(50)
|
@MaxLength(50)
|
||||||
videoStyle?: string;
|
videoStyle?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)' })
|
@ApiPropertyOptional({
|
||||||
|
description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)',
|
||||||
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@MaxLength(200)
|
@MaxLength(200)
|
||||||
cinematicReference?: string;
|
cinematicReference?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Hedef video süresi (saniye)', default: 60 })
|
@ApiPropertyOptional({
|
||||||
|
description: 'Hedef video süresi (saniye)',
|
||||||
|
default: 60,
|
||||||
|
})
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Min(15)
|
@Min(15)
|
||||||
@@ -292,7 +315,10 @@ export class CreateFromDocumentDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
language?: string;
|
language?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: AspectRatioDto, default: AspectRatioDto.PORTRAIT_9_16 })
|
@ApiPropertyOptional({
|
||||||
|
enum: AspectRatioDto,
|
||||||
|
default: AspectRatioDto.PORTRAIT_9_16,
|
||||||
|
})
|
||||||
@IsEnum(AspectRatioDto)
|
@IsEnum(AspectRatioDto)
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
aspectRatio?: AspectRatioDto;
|
aspectRatio?: AspectRatioDto;
|
||||||
@@ -306,13 +332,18 @@ export class CreateFromDocumentDto {
|
|||||||
@MaxLength(50)
|
@MaxLength(50)
|
||||||
videoStyle?: string;
|
videoStyle?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)' })
|
@ApiPropertyOptional({
|
||||||
|
description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)',
|
||||||
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@MaxLength(200)
|
@MaxLength(200)
|
||||||
cinematicReference?: string;
|
cinematicReference?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Hedef video süresi (saniye)', default: 60 })
|
@ApiPropertyOptional({
|
||||||
|
description: 'Hedef video süresi (saniye)',
|
||||||
|
default: 60,
|
||||||
|
})
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Min(15)
|
@Min(15)
|
||||||
@@ -360,13 +391,18 @@ export class CreateFromExtractedTextDto {
|
|||||||
@MaxLength(50)
|
@MaxLength(50)
|
||||||
videoStyle?: string;
|
videoStyle?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)' })
|
@ApiPropertyOptional({
|
||||||
|
description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)',
|
||||||
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@MaxLength(200)
|
@MaxLength(200)
|
||||||
cinematicReference?: string;
|
cinematicReference?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Hedef video süresi (saniye)', default: 60 })
|
@ApiPropertyOptional({
|
||||||
|
description: 'Hedef video süresi (saniye)',
|
||||||
|
default: 60,
|
||||||
|
})
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Min(15)
|
@Min(15)
|
||||||
@@ -387,7 +423,8 @@ export class CreateFromTextDto {
|
|||||||
text: string;
|
text: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@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()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -402,7 +439,10 @@ export class CreateFromTextDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
language?: string;
|
language?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: AspectRatioDto, default: AspectRatioDto.PORTRAIT_9_16 })
|
@ApiPropertyOptional({
|
||||||
|
enum: AspectRatioDto,
|
||||||
|
default: AspectRatioDto.PORTRAIT_9_16,
|
||||||
|
})
|
||||||
@IsEnum(AspectRatioDto)
|
@IsEnum(AspectRatioDto)
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
aspectRatio?: AspectRatioDto;
|
aspectRatio?: AspectRatioDto;
|
||||||
@@ -416,13 +456,18 @@ export class CreateFromTextDto {
|
|||||||
@MaxLength(50)
|
@MaxLength(50)
|
||||||
videoStyle?: string;
|
videoStyle?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)' })
|
@ApiPropertyOptional({
|
||||||
|
description: 'Sinematik stil referansı (örn: Wes Anderson, Matrix)',
|
||||||
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@MaxLength(200)
|
@MaxLength(200)
|
||||||
cinematicReference?: string;
|
cinematicReference?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Hedef video süresi (saniye)', default: 60 })
|
@ApiPropertyOptional({
|
||||||
|
description: 'Hedef video süresi (saniye)',
|
||||||
|
default: 60,
|
||||||
|
})
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Min(15)
|
@Min(15)
|
||||||
|
|||||||
@@ -138,7 +138,10 @@ export class ProjectsController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: 'AI ile senaryo üret (Gemini)' })
|
@ApiOperation({ summary: 'AI ile senaryo üret (Gemini)' })
|
||||||
@ApiResponse({ status: 200, description: 'Senaryo üretildi ve kaydedildi' })
|
@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;
|
const userId = req.user?.id || req.user?.sub;
|
||||||
this.logger.log(`Senaryo üretimi başlatılıyor: ${id}`);
|
this.logger.log(`Senaryo üretimi başlatılıyor: ${id}`);
|
||||||
return this.projectsService.generateScript(userId, id);
|
return this.projectsService.generateScript(userId, id);
|
||||||
@@ -167,10 +170,7 @@ export class ProjectsController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: 'Aktif render işlemini iptal et' })
|
@ApiOperation({ summary: 'Aktif render işlemini iptal et' })
|
||||||
@ApiResponse({ status: 200, description: 'Render işlemi iptal edildi' })
|
@ApiResponse({ status: 200, description: 'Render işlemi iptal edildi' })
|
||||||
async cancelRender(
|
async cancelRender(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) {
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
|
||||||
@Req() req: any,
|
|
||||||
) {
|
|
||||||
const userId = req.user?.id || req.user?.sub;
|
const userId = req.user?.id || req.user?.sub;
|
||||||
this.logger.log(`Render iptal isteği: ${id}`);
|
this.logger.log(`Render iptal isteği: ${id}`);
|
||||||
return this.projectsService.cancelRenderJob(userId, id);
|
return this.projectsService.cancelRenderJob(userId, id);
|
||||||
@@ -182,9 +182,15 @@ export class ProjectsController {
|
|||||||
*/
|
*/
|
||||||
@Post('from-tweet')
|
@Post('from-tweet')
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
@ApiOperation({ summary: 'X/Twitter tweet\'ten proje oluştur' })
|
@ApiOperation({ summary: "X/Twitter tweet'ten proje oluştur" })
|
||||||
@ApiResponse({ status: 201, description: 'Tweet\'ten proje oluşturuldu ve senaryo üretildi' })
|
@ApiResponse({
|
||||||
@ApiResponse({ status: 400, description: 'Geçersiz tweet URL\'si veya tweet bulunamadı' })
|
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) {
|
async createFromTweet(@Body() dto: CreateFromTweetDto, @Req() req: any) {
|
||||||
const userId = req.user?.id || req.user?.sub;
|
const userId = req.user?.id || req.user?.sub;
|
||||||
this.logger.log(`Tweet'ten proje oluşturuluyor: ${dto.tweetUrl}`);
|
this.logger.log(`Tweet'ten proje oluşturuluyor: ${dto.tweetUrl}`);
|
||||||
@@ -197,8 +203,14 @@ export class ProjectsController {
|
|||||||
@Post('from-youtube')
|
@Post('from-youtube')
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
@ApiOperation({ summary: 'YouTube videosundan proje oluştur' })
|
@ApiOperation({ summary: 'YouTube videosundan proje oluştur' })
|
||||||
@ApiResponse({ status: 201, description: 'YouTube videosundan proje oluşturuldu ve senaryo üretildi' })
|
@ApiResponse({
|
||||||
@ApiResponse({ status: 400, description: 'Geçersiz YouTube URL\'si veya video bulunamadı' })
|
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) {
|
async createFromYoutube(@Body() dto: CreateFromYoutubeDto, @Req() req: any) {
|
||||||
const userId = req.user?.id || req.user?.sub;
|
const userId = req.user?.id || req.user?.sub;
|
||||||
this.logger.log(`YouTube'dan proje oluşturuluyor: ${dto.youtubeUrl}`);
|
this.logger.log(`YouTube'dan proje oluşturuluyor: ${dto.youtubeUrl}`);
|
||||||
@@ -211,7 +223,10 @@ export class ProjectsController {
|
|||||||
@Post('from-text')
|
@Post('from-text')
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
@ApiOperation({ summary: 'Serbest metinden proje oluştur' })
|
@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) {
|
async createFromText(@Body() dto: CreateFromTextDto, @Req() req: any) {
|
||||||
const userId = req.user?.id || req.user?.sub;
|
const userId = req.user?.id || req.user?.sub;
|
||||||
this.logger.log(`Serbest metinden proje oluşturuluyor...`);
|
this.logger.log(`Serbest metinden proje oluşturuluyor...`);
|
||||||
@@ -226,7 +241,10 @@ export class ProjectsController {
|
|||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes('multipart/form-data')
|
||||||
@ApiOperation({ summary: 'Dosyadan/Dokümandan proje oluştur' })
|
@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(
|
async createFromDocument(
|
||||||
@UploadedFile() file: Express.Multer.File,
|
@UploadedFile() file: Express.Multer.File,
|
||||||
@Body() dto: CreateFromDocumentDto,
|
@Body() dto: CreateFromDocumentDto,
|
||||||
@@ -248,12 +266,14 @@ export class ProjectsController {
|
|||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes('multipart/form-data')
|
||||||
@ApiOperation({ summary: 'Dosyadan metin çıkar ve konu önerileri al' })
|
@ApiOperation({ summary: 'Dosyadan metin çıkar ve konu önerileri al' })
|
||||||
@ApiResponse({ status: 200, description: 'Metin ve konular başarıyla çıkarıldı' })
|
@ApiResponse({
|
||||||
async extractDocumentTopics(
|
status: 200,
|
||||||
@UploadedFile() file: Express.Multer.File,
|
description: 'Metin ve konular başarıyla çıkarıldı',
|
||||||
@Req() req: any,
|
})
|
||||||
) {
|
async extractDocumentTopics(@UploadedFile() file: Express.Multer.File) {
|
||||||
this.logger.log(`Dosyadan metin ve konular çıkarılıyor: ${file?.originalname}`);
|
this.logger.log(
|
||||||
|
`Dosyadan metin ve konular çıkarılıyor: ${file?.originalname}`,
|
||||||
|
);
|
||||||
if (!file) {
|
if (!file) {
|
||||||
throw new BadRequestException('Dosya yüklenmedi');
|
throw new BadRequestException('Dosya yüklenmedi');
|
||||||
}
|
}
|
||||||
@@ -266,10 +286,18 @@ export class ProjectsController {
|
|||||||
@Post('document-from-topic')
|
@Post('document-from-topic')
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
@ApiOperation({ summary: 'Seçilen konu ve metin ile proje oluştur' })
|
@ApiOperation({ summary: 'Seçilen konu ve metin ile proje oluştur' })
|
||||||
@ApiResponse({ status: 201, description: 'Seçilen konu baz alınarak proje oluşturuldu' })
|
@ApiResponse({
|
||||||
async createFromTopic(@Body() dto: CreateFromExtractedTextDto, @Req() req: any) {
|
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;
|
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);
|
return this.projectsService.createFromExtractedText(userId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,7 +310,13 @@ export class ProjectsController {
|
|||||||
async updateScene(
|
async updateScene(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Param('sceneId', ParseUUIDPipe) sceneId: 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,
|
@Req() req: any,
|
||||||
) {
|
) {
|
||||||
const userId = req.user?.id || req.user?.sub;
|
const userId = req.user?.id || req.user?.sub;
|
||||||
@@ -326,7 +360,10 @@ export class ProjectsController {
|
|||||||
@Post(':id/generate-seo-titles')
|
@Post(':id/generate-seo-titles')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: 'AI ile 5 yeni SEO başlığı üret' })
|
@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(
|
async generateSeoTitles(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Req() req: any,
|
@Req() req: any,
|
||||||
@@ -361,15 +398,22 @@ export class ProjectsController {
|
|||||||
*/
|
*/
|
||||||
@Post(':id/translate')
|
@Post(':id/translate')
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
@ApiOperation({ summary: 'Projeyi farklı bir dile çevir ve kopyasını oluştur' })
|
@ApiOperation({
|
||||||
@ApiResponse({ status: 201, description: 'Proje çevirisi başarıyla tamamlandı' })
|
summary: 'Projeyi farklı bir dile çevir ve kopyasını oluştur',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 201,
|
||||||
|
description: 'Proje çevirisi başarıyla tamamlandı',
|
||||||
|
})
|
||||||
async translateProject(
|
async translateProject(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body('targetLanguage') targetLanguage: string,
|
@Body('targetLanguage') targetLanguage: string,
|
||||||
@Req() req: any,
|
@Req() req: any,
|
||||||
) {
|
) {
|
||||||
if (!targetLanguage) {
|
if (!targetLanguage) {
|
||||||
throw new BadRequestException('Hedef dil (targetLanguage) belirtilmelidir.');
|
throw new BadRequestException(
|
||||||
|
'Hedef dil (targetLanguage) belirtilmelidir.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const userId = req.user?.id || req.user?.sub;
|
const userId = req.user?.id || req.user?.sub;
|
||||||
this.logger.log(`Proje çevirisi isteniyor: ${id} -> ${targetLanguage}`);
|
this.logger.log(`Proje çevirisi isteniyor: ${id} -> ${targetLanguage}`);
|
||||||
@@ -392,7 +436,12 @@ export class ProjectsController {
|
|||||||
) {
|
) {
|
||||||
const userId = req.user?.id || req.user?.sub;
|
const userId = req.user?.id || req.user?.sub;
|
||||||
this.logger.log(`Sahne görseli üretiliyor: ${sceneId} (proje: ${id})`);
|
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,
|
@Req() req: any,
|
||||||
) {
|
) {
|
||||||
const userId = req.user?.id || req.user?.sub;
|
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);
|
return this.projectsService.upscaleSceneImage(userId, id, sceneId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,15 @@ import { ExtractorModule } from '../extractor/extractor.module';
|
|||||||
import { BillingModule } from '../billing/billing.module';
|
import { BillingModule } from '../billing/billing.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [VideoAiModule, VideoQueueModule, XTwitterModule, GeminiModule, StorageModule, ExtractorModule, BillingModule],
|
imports: [
|
||||||
|
VideoAiModule,
|
||||||
|
VideoQueueModule,
|
||||||
|
XTwitterModule,
|
||||||
|
GeminiModule,
|
||||||
|
StorageModule,
|
||||||
|
ExtractorModule,
|
||||||
|
BillingModule,
|
||||||
|
],
|
||||||
controllers: [ProjectsController],
|
controllers: [ProjectsController],
|
||||||
providers: [ProjectsService],
|
providers: [ProjectsService],
|
||||||
exports: [ProjectsService],
|
exports: [ProjectsService],
|
||||||
|
|||||||
@@ -7,14 +7,22 @@ import {
|
|||||||
import { TransitionType, AspectRatio } from '@prisma/client';
|
import { TransitionType, AspectRatio } from '@prisma/client';
|
||||||
import { PrismaService } from '../../database/prisma.service';
|
import { PrismaService } from '../../database/prisma.service';
|
||||||
import { VideoAiService } from '../video-ai/video-ai.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 { VideoGenerationProducer } from '../video-queue/video-generation.producer';
|
||||||
import { XTwitterService } from '../x-twitter/x-twitter.service';
|
import { XTwitterService } from '../x-twitter/x-twitter.service';
|
||||||
import { GeminiService } from '../gemini/gemini.service';
|
import { GeminiService } from '../gemini/gemini.service';
|
||||||
import { StorageService } from '../storage/storage.service';
|
import { StorageService } from '../storage/storage.service';
|
||||||
import { ExtractorService } from '../extractor/extractor.service';
|
import { ExtractorService } from '../extractor/extractor.service';
|
||||||
import { BillingService } from '../billing/billing.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 sharp from 'sharp';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
@@ -170,7 +178,9 @@ export class ProjectsService {
|
|||||||
...(dto.language && { language: dto.language }),
|
...(dto.language && { language: dto.language }),
|
||||||
...(dto.aspectRatio && { aspectRatio: dto.aspectRatio }),
|
...(dto.aspectRatio && { aspectRatio: dto.aspectRatio }),
|
||||||
...(dto.videoStyle && { videoStyle: dto.videoStyle }),
|
...(dto.videoStyle && { videoStyle: dto.videoStyle }),
|
||||||
...(dto.cinematicReference !== undefined && { cinematicReference: dto.cinematicReference }),
|
...(dto.cinematicReference !== undefined && {
|
||||||
|
cinematicReference: dto.cinematicReference,
|
||||||
|
}),
|
||||||
...(dto.targetDuration && { targetDuration: dto.targetDuration }),
|
...(dto.targetDuration && { targetDuration: dto.targetDuration }),
|
||||||
} as any,
|
} as any,
|
||||||
});
|
});
|
||||||
@@ -180,48 +190,65 @@ export class ProjectsService {
|
|||||||
// Stil ya da görsel parametre değiştiyse ve senaryo/sahneler varsa otomatik rewrite başlat
|
// Stil ya da görsel parametre değiştiyse ve senaryo/sahneler varsa otomatik rewrite başlat
|
||||||
const styleChanged =
|
const styleChanged =
|
||||||
(dto.videoStyle && dto.videoStyle !== project.videoStyle) ||
|
(dto.videoStyle && dto.videoStyle !== project.videoStyle) ||
|
||||||
(dto.cinematicReference !== undefined && dto.cinematicReference !== (project as any).cinematicReference) ||
|
(dto.cinematicReference !== undefined &&
|
||||||
(dto.aspectRatio && dto.aspectRatio !== project.aspectRatio);
|
dto.cinematicReference !== (project as any).cinematicReference) ||
|
||||||
|
(dto.aspectRatio &&
|
||||||
|
String(dto.aspectRatio) !== String(project.aspectRatio));
|
||||||
|
|
||||||
if (styleChanged && project.scenes && project.scenes.length > 0) {
|
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.logger.log(
|
||||||
this.rewriteVisualPromptsBackground(userId, projectId, updated).catch((err) => {
|
`Stil değişikliği tespit edildi (${projectId}), visual promptlar arka planda yenileniyor...`,
|
||||||
this.logger.error(`Arka plan rewriteVisualPrompts hatası: ${err}`);
|
);
|
||||||
});
|
this.rewriteVisualPromptsBackground(userId, projectId, updated).catch(
|
||||||
|
(err) => {
|
||||||
|
this.logger.error(`Arka plan rewriteVisualPrompts hatası: ${err}`);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Arka planda tüm promptları güncelleyen metod
|
// 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);
|
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 {
|
try {
|
||||||
// Sahnelerin visual prompt'larını yenile (ID'leri ve narration'ları gönderiyoruz)
|
// 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,
|
id: s.id,
|
||||||
order: s.order,
|
order: s.order,
|
||||||
narrationText: s.narrationText,
|
narrationText: s.narrationText,
|
||||||
visualPrompt: s.visualPrompt
|
visualPrompt: s.visualPrompt,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const rewritten = await this.videoAiService.rewriteAllVisualPrompts(
|
const rewritten = await this.videoAiService.rewriteAllVisualPrompts(
|
||||||
mappedScenes,
|
mappedScenes,
|
||||||
updatedProject.videoStyle,
|
updatedProject.videoStyle,
|
||||||
(updatedProject as any).cinematicReference,
|
updatedProject.cinematicReference,
|
||||||
updatedProject.aspectRatio
|
updatedProject.aspectRatio,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Veritabanına kaydet
|
// Veritabanına kaydet
|
||||||
for (const newScene of rewritten) {
|
for (const newScene of rewritten) {
|
||||||
await this.db.scene.update({
|
await this.db.scene.update({
|
||||||
where: { id: newScene.id },
|
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) {
|
} catch (err) {
|
||||||
this.logger.error(`Visual promptları yenileme işlemi başarısız: ${err}`);
|
this.logger.error(`Visual promptları yenileme işlemi başarısız: ${err}`);
|
||||||
}
|
}
|
||||||
@@ -297,15 +324,32 @@ export class ProjectsService {
|
|||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
scriptVersion: { increment: 1 },
|
scriptVersion: { increment: 1 },
|
||||||
// AI'ın en güçlü SEO başlığını proje başlığı yap
|
// 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)
|
// SEO & Social metadata (skill-enhanced)
|
||||||
seoTitle: (scriptJson.seo?.title || scriptJson.metadata?.title || '').substring(0, 190),
|
seoTitle: (
|
||||||
seoDescription: (scriptJson.seo?.description || scriptJson.metadata?.description || '').substring(0, 490),
|
scriptJson.seo?.title ||
|
||||||
seoTitleAlts: (scriptJson.seoTitleAlternatives || []).map((t: string) => t.substring(0, 190)),
|
scriptJson.metadata?.title ||
|
||||||
seoScore: typeof scriptJson.seoScore === 'number' ? Math.min(100, Math.max(0, scriptJson.seoScore)) : null,
|
''
|
||||||
|
).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 || [],
|
seoKeywords: scriptJson.seo?.keywords || [],
|
||||||
seoSchemaJson: scriptJson.seo?.schemaMarkup as object || null,
|
seoSchemaJson: (scriptJson.seo?.schemaMarkup as object) || null,
|
||||||
socialContent: scriptJson.socialContent as object || null,
|
socialContent: (scriptJson.socialContent as object) || null,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
scenes: { orderBy: { order: 'asc' } },
|
scenes: { orderBy: { order: 'asc' } },
|
||||||
@@ -387,8 +431,11 @@ export class ProjectsService {
|
|||||||
videoStyle: project.videoStyle,
|
videoStyle: project.videoStyle,
|
||||||
targetDuration: project.targetDuration,
|
targetDuration: project.targetDuration,
|
||||||
scenes: project.scenes.map((s) => {
|
scenes: project.scenes.map((s) => {
|
||||||
const thumbnail = s.mediaAssets?.find(m => m.type === 'THUMBNAIL');
|
const thumbnail = s.mediaAssets?.find((m) => m.type === 'THUMBNAIL');
|
||||||
const imagePath = thumbnail && thumbnail.s3Key ? this.storageService.getAbsolutePath(thumbnail.s3Key) : undefined;
|
const imagePath =
|
||||||
|
thumbnail && thumbnail.s3Key
|
||||||
|
? this.storageService.getAbsolutePath(thumbnail.s3Key)
|
||||||
|
: undefined;
|
||||||
return {
|
return {
|
||||||
id: s.id,
|
id: s.id,
|
||||||
order: s.order,
|
order: s.order,
|
||||||
@@ -472,8 +519,10 @@ export class ProjectsService {
|
|||||||
const avgProcessingTime =
|
const avgProcessingTime =
|
||||||
completedWithTime.length > 0
|
completedWithTime.length > 0
|
||||||
? Math.round(
|
? Math.round(
|
||||||
completedWithTime.reduce((sum, j) => sum + (j.processingTimeMs ?? 0), 0) /
|
completedWithTime.reduce(
|
||||||
completedWithTime.length,
|
(sum, j) => sum + (j.processingTimeMs ?? 0),
|
||||||
|
0,
|
||||||
|
) / completedWithTime.length,
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -499,19 +548,21 @@ export class ProjectsService {
|
|||||||
project: j.project,
|
project: j.project,
|
||||||
logs: j.logs,
|
logs: j.logs,
|
||||||
})),
|
})),
|
||||||
recentJobs: [...completed, ...failed, ...cancelled].slice(0, 20).map((j) => ({
|
recentJobs: [...completed, ...failed, ...cancelled]
|
||||||
id: j.id,
|
.slice(0, 20)
|
||||||
status: j.status,
|
.map((j) => ({
|
||||||
currentStage: j.currentStage,
|
id: j.id,
|
||||||
attemptNumber: j.attemptNumber,
|
status: j.status,
|
||||||
processingTimeMs: j.processingTimeMs,
|
currentStage: j.currentStage,
|
||||||
errorMessage: j.errorMessage,
|
attemptNumber: j.attemptNumber,
|
||||||
finalVideoUrl: j.finalVideoUrl,
|
processingTimeMs: j.processingTimeMs,
|
||||||
createdAt: j.createdAt,
|
errorMessage: j.errorMessage,
|
||||||
startedAt: j.startedAt,
|
finalVideoUrl: j.finalVideoUrl,
|
||||||
completedAt: j.completedAt,
|
createdAt: j.createdAt,
|
||||||
project: j.project,
|
startedAt: j.startedAt,
|
||||||
})),
|
completedAt: j.completedAt,
|
||||||
|
project: j.project,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,7 +584,9 @@ export class ProjectsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (project.renderJobs.length === 0) {
|
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
|
// Aktif olan ilk render job'u al
|
||||||
@@ -542,7 +595,10 @@ export class ProjectsService {
|
|||||||
// Status'ü güncelle
|
// Status'ü güncelle
|
||||||
await this.db.renderJob.update({
|
await this.db.renderJob.update({
|
||||||
where: { id: activeJob.id },
|
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)
|
// Projeyi tekrar DRAFT durumuna döndür (senaryosu hâlâ mevcut)
|
||||||
@@ -551,7 +607,9 @@ export class ProjectsService {
|
|||||||
data: { status: 'DRAFT' },
|
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 {
|
return {
|
||||||
message: 'Render başarıyla iptal edildi',
|
message: 'Render başarıyla iptal edildi',
|
||||||
@@ -666,8 +724,16 @@ export class ProjectsService {
|
|||||||
status: 'DRAFT',
|
status: 'DRAFT',
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
scriptVersion: 1,
|
scriptVersion: 1,
|
||||||
seoTitle: (scriptJson.seo?.title || scriptJson.metadata?.title || '').substring(0, 190),
|
seoTitle: (
|
||||||
seoDescription: (scriptJson.seo?.description || scriptJson.metadata?.description || '').substring(0, 490),
|
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,
|
seoSchemaJson: (scriptJson.seo?.schemaMarkup as object) || null,
|
||||||
socialContent: (scriptJson.socialContent as object) || null,
|
socialContent: (scriptJson.socialContent as object) || null,
|
||||||
},
|
},
|
||||||
@@ -708,10 +774,14 @@ export class ProjectsService {
|
|||||||
* YouTube URL'sinden proje oluşturur. Extractor servisi kullanılarak video transkripti çekilir.
|
* YouTube URL'sinden proje oluşturur. Extractor servisi kullanılarak video transkripti çekilir.
|
||||||
*/
|
*/
|
||||||
async createFromYoutube(userId: string, dto: CreateFromYoutubeDto) {
|
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
|
// 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
|
// 2. Proje başlığı veya varsayılan prompt'u oluştur
|
||||||
const title = dto.title || 'YouTube Shorts Üretimi';
|
const title = dto.title || 'YouTube Shorts Üretimi';
|
||||||
@@ -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 {
|
try {
|
||||||
const scriptJson = await this.videoAiService.generateVideoScript({
|
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;
|
return updatedProject;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await this.db.project.update({
|
await this.db.project.update({
|
||||||
where: { id: project.id },
|
where: { id: project.id },
|
||||||
data: {
|
data: {
|
||||||
status: 'DRAFT',
|
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;
|
throw error;
|
||||||
@@ -792,7 +869,9 @@ export class ProjectsService {
|
|||||||
* PDF, Word vb. dokümandan metin çıkarır ve konu önerileri üretir.
|
* PDF, Word vb. dokümandan metin çıkarır ve konu önerileri üretir.
|
||||||
*/
|
*/
|
||||||
async extractDocumentTopics(file: Express.Multer.File) {
|
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 tempFilePath: string | null = null;
|
||||||
let extractedText = '';
|
let extractedText = '';
|
||||||
@@ -801,42 +880,60 @@ export class ProjectsService {
|
|||||||
if (file.path) {
|
if (file.path) {
|
||||||
tempFilePath = file.path;
|
tempFilePath = file.path;
|
||||||
} else if (file.buffer) {
|
} 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);
|
await fs.writeFile(tempFilePath, file.buffer);
|
||||||
} else {
|
} 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 {
|
} finally {
|
||||||
if (tempFilePath && !file.path) {
|
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) {
|
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
|
// Kısa metinse doğrudan 1 konu öner (kendi başlığı gibi), uzunsa çoklu konu
|
||||||
let topics: string[] = [];
|
let topics: string[] = [];
|
||||||
if (extractedText.length < 5000) {
|
if (extractedText.length < 5000) {
|
||||||
topics = [file.originalname.split('.')[0] || "Belge Özeti"];
|
topics = [file.originalname.split('.')[0] || 'Belge Özeti'];
|
||||||
} else {
|
} else {
|
||||||
topics = await this.videoAiService.suggestDocumentTopics(extractedText, 4);
|
topics = await this.videoAiService.suggestDocumentTopics(
|
||||||
|
extractedText,
|
||||||
|
4,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text: extractedText,
|
text: extractedText,
|
||||||
topics,
|
topics,
|
||||||
originalFilename: file.originalname
|
originalFilename: file.originalname,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PDF, Word vb. dokümandan proje oluşturur.
|
* 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}`);
|
this.logger.log(`Belgeden proje oluşturuluyor: ${file.originalname}`);
|
||||||
|
|
||||||
let tempFilePath: string | null = null;
|
let tempFilePath: string | null = null;
|
||||||
@@ -846,16 +943,27 @@ export class ProjectsService {
|
|||||||
if (file.path) {
|
if (file.path) {
|
||||||
tempFilePath = file.path;
|
tempFilePath = file.path;
|
||||||
} else if (file.buffer) {
|
} 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);
|
await fs.writeFile(tempFilePath, file.buffer);
|
||||||
} else {
|
} 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 {
|
} finally {
|
||||||
if (tempFilePath && !file.path) {
|
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 },
|
where: { id: project.id },
|
||||||
data: {
|
data: {
|
||||||
status: 'DRAFT',
|
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;
|
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.
|
* Çıkarılmış metin ve kullanıcının seçtiği bir "topic" üzerinden proje oluşturur.
|
||||||
*/
|
*/
|
||||||
async createFromExtractedText(userId: string, dto: CreateFromExtractedTextDto) {
|
async createFromExtractedText(
|
||||||
this.logger.log(`Metin ve konu üzerinden proje oluşturuluyor: ${dto.topic}`);
|
userId: string,
|
||||||
|
dto: CreateFromExtractedTextDto,
|
||||||
|
) {
|
||||||
|
this.logger.log(
|
||||||
|
`Metin ve konu üzerinden proje oluşturuluyor: ${dto.topic}`,
|
||||||
|
);
|
||||||
|
|
||||||
const title = dto.topic;
|
const title = dto.topic;
|
||||||
// Tam prompt metni (AI'a gönderilecek)
|
// Tam prompt metni (AI'a gönderilecek)
|
||||||
@@ -941,10 +1057,13 @@ export class ProjectsService {
|
|||||||
const project = await this.db.project.create({
|
const project = await this.db.project.create({
|
||||||
data: {
|
data: {
|
||||||
title,
|
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,
|
prompt: shortDbPrompt,
|
||||||
language: dto.language || 'tr',
|
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',
|
videoStyle: dto.videoStyle || 'CINEMATIC',
|
||||||
cinematicReference: dto.cinematicReference,
|
cinematicReference: dto.cinematicReference,
|
||||||
targetDuration: dto.targetDuration || 60,
|
targetDuration: dto.targetDuration || 60,
|
||||||
@@ -995,15 +1114,16 @@ export class ProjectsService {
|
|||||||
where: { id: project.id },
|
where: { id: project.id },
|
||||||
data: {
|
data: {
|
||||||
status: 'DRAFT',
|
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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kullanıcının doğrudan yazdığı serbest metinden (fikir, taslak, hikaye) proje oluşturur.
|
* Kullanıcının doğrudan yazdığı serbest metinden (fikir, taslak, hikaye) proje oluşturur.
|
||||||
*/
|
*/
|
||||||
@@ -1023,7 +1143,8 @@ export class ProjectsService {
|
|||||||
description: `Serbest metin üzerinden üretildi.`,
|
description: `Serbest metin üzerinden üretildi.`,
|
||||||
prompt: shortDbPrompt,
|
prompt: shortDbPrompt,
|
||||||
language: dto.language || 'tr',
|
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',
|
videoStyle: dto.videoStyle || 'CINEMATIC',
|
||||||
cinematicReference: dto.cinematicReference,
|
cinematicReference: dto.cinematicReference,
|
||||||
targetDuration: dto.targetDuration || 60,
|
targetDuration: dto.targetDuration || 60,
|
||||||
@@ -1074,7 +1195,10 @@ export class ProjectsService {
|
|||||||
where: { id: project.id },
|
where: { id: project.id },
|
||||||
data: {
|
data: {
|
||||||
status: 'DRAFT',
|
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;
|
throw error;
|
||||||
@@ -1088,7 +1212,12 @@ export class ProjectsService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
sceneId: 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
|
// Proje sahipliğini doğrula
|
||||||
const project = await this.findOne(userId, projectId);
|
const project = await this.findOne(userId, projectId);
|
||||||
@@ -1109,9 +1238,15 @@ export class ProjectsService {
|
|||||||
const updated = await this.db.scene.update({
|
const updated = await this.db.scene.update({
|
||||||
where: { id: sceneId },
|
where: { id: sceneId },
|
||||||
data: {
|
data: {
|
||||||
...(data.narrationText !== undefined && { narrationText: data.narrationText }),
|
...(data.narrationText !== undefined && {
|
||||||
...(data.visualPrompt !== undefined && { visualPrompt: data.visualPrompt }),
|
narrationText: data.narrationText,
|
||||||
...(data.subtitleText !== undefined && { subtitleText: data.subtitleText }),
|
}),
|
||||||
|
...(data.visualPrompt !== undefined && {
|
||||||
|
visualPrompt: data.visualPrompt,
|
||||||
|
}),
|
||||||
|
...(data.subtitleText !== undefined && {
|
||||||
|
subtitleText: data.subtitleText,
|
||||||
|
}),
|
||||||
...(data.duration !== undefined && { duration: data.duration }),
|
...(data.duration !== undefined && { duration: data.duration }),
|
||||||
},
|
},
|
||||||
include: { mediaAssets: true },
|
include: { mediaAssets: true },
|
||||||
@@ -1146,7 +1281,10 @@ export class ProjectsService {
|
|||||||
|
|
||||||
// Stil DNA bilgilerini prompt'a dahil et
|
// Stil DNA bilgilerini prompt'a dahil et
|
||||||
const cinematicRef = (project as any).cinematicReference || '';
|
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 = `
|
const contextPrompt = `
|
||||||
Bir video senaryosunun ${scene.order}. sahnesini yeniden üret.
|
Bir video senaryosunun ${scene.order}. sahnesini yeniden üret.
|
||||||
@@ -1202,11 +1340,18 @@ Sadece bu tek sahneyi üret. JSON formatında:
|
|||||||
include: { mediaAssets: true },
|
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;
|
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 project = await this.findOne(userId, projectId);
|
||||||
const scene = project.scenes.find((s) => s.id === sceneId);
|
const scene = project.scenes.find((s) => s.id === sceneId);
|
||||||
if (!scene) {
|
if (!scene) {
|
||||||
@@ -1215,16 +1360,20 @@ Sadece bu tek sahneyi üret. JSON formatında:
|
|||||||
|
|
||||||
if (customPrompt && customPrompt !== scene.visualPrompt) {
|
if (customPrompt && customPrompt !== scene.visualPrompt) {
|
||||||
// First update the prompt
|
// First update the prompt
|
||||||
await this.updateScene(userId, projectId, sceneId, { visualPrompt: customPrompt });
|
await this.updateScene(userId, projectId, sceneId, {
|
||||||
|
visualPrompt: customPrompt,
|
||||||
|
});
|
||||||
scene.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'> = {
|
const aspectRatioMap: Record<string, '16:9' | '9:16' | '1:1'> = {
|
||||||
'PORTRAIT_9_16': '9:16',
|
PORTRAIT_9_16: '9:16',
|
||||||
'LANDSCAPE_16_9': '16:9',
|
LANDSCAPE_16_9: '16:9',
|
||||||
'SQUARE_1_1': '1:1',
|
SQUARE_1_1: '1:1',
|
||||||
};
|
};
|
||||||
const mappedRatio = aspectRatioMap[project.aspectRatio] || '9:16';
|
const mappedRatio = aspectRatioMap[project.aspectRatio] || '9:16';
|
||||||
|
|
||||||
@@ -1239,7 +1388,9 @@ Sadece bu tek sahneyi üret. JSON formatında:
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!imageResult) {
|
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 {
|
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 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}`;
|
||||||
@@ -1249,23 +1400,35 @@ Sadece bu tek sahneyi üret. JSON formatında:
|
|||||||
this.logger.log(`🔄 Anonimleştirilmiş Prompt: ${rewrittenPrompt}`);
|
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}`;
|
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) {
|
} 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) {
|
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
|
// Storage'a kaydet
|
||||||
const key = this.storageService.getSceneImageKey(projectId, scene.order);
|
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);
|
const url = this.storageService.getPublicUrl(key);
|
||||||
|
|
||||||
// MediaRecord oluştur veya güncelle
|
// 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;
|
let mediaId = mediaAsset?.id;
|
||||||
if (!mediaId) {
|
if (!mediaId) {
|
||||||
const media = await this.db.mediaAsset.create({
|
const media = await this.db.mediaAsset.create({
|
||||||
@@ -1301,16 +1464,22 @@ Sadece bu tek sahneyi üret. JSON formatında:
|
|||||||
throw new NotFoundException('Sahne bulunamadı');
|
throw new NotFoundException('Sahne bulunamadı');
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaAsset = scene.mediaAssets.find(m => m.type === 'THUMBNAIL');
|
const mediaAsset = scene.mediaAssets.find((m) => m.type === 'THUMBNAIL');
|
||||||
let mediaId = mediaAsset?.id;
|
const mediaId = mediaAsset?.id;
|
||||||
if (!mediaId) {
|
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ı');
|
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 key = this.storageService.getSceneImageKey(projectId, scene.order);
|
||||||
const absPath = this.storageService.getAbsolutePath(key);
|
const absPath = this.storageService.getAbsolutePath(key);
|
||||||
@@ -1359,8 +1528,8 @@ Sadece bu tek sahneyi üret. JSON formatında:
|
|||||||
const media = await this.db.mediaAsset.findFirst({
|
const media = await this.db.mediaAsset.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: mediaId,
|
id: mediaId,
|
||||||
projectId: project.id
|
projectId: project.id,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!media) {
|
if (!media) {
|
||||||
@@ -1374,7 +1543,7 @@ Sadece bu tek sahneyi üret. JSON formatında:
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.db.mediaAsset.delete({
|
await this.db.mediaAsset.delete({
|
||||||
where: { id: mediaId }
|
where: { id: mediaId },
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`Medya silindi: ${mediaId} (Proje: ${projectId})`);
|
this.logger.log(`Medya silindi: ${mediaId} (Proje: ${projectId})`);
|
||||||
@@ -1388,12 +1557,21 @@ Sadece bu tek sahneyi üret. JSON formatında:
|
|||||||
/**
|
/**
|
||||||
* Çeviri İşlemi
|
* Ç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);
|
const project = await this.findOne(userId, projectId);
|
||||||
if (!project) throw new NotFoundException('Proje bulunamadı');
|
if (!project) throw new NotFoundException('Proje bulunamadı');
|
||||||
|
|
||||||
// 1 Kredi Kesintisi
|
// 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 = `
|
const prompt = `
|
||||||
Translate the following video project to the language: ${targetLanguage}.
|
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.
|
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:
|
Input Data:
|
||||||
${JSON.stringify({
|
${JSON.stringify(
|
||||||
title: project.title,
|
{
|
||||||
description: project.description,
|
title: project.title,
|
||||||
seoTitle: project.seoTitle,
|
description: project.description,
|
||||||
seoDescription: project.seoDescription,
|
seoTitle: project.seoTitle,
|
||||||
socialContent: project.socialContent,
|
seoDescription: project.seoDescription,
|
||||||
scenes: project.scenes.map(s => ({
|
socialContent: project.socialContent,
|
||||||
id: s.id,
|
scenes: project.scenes.map((s) => ({
|
||||||
narrationText: s.narrationText,
|
id: s.id,
|
||||||
subtitleText: s.subtitleText,
|
narrationText: s.narrationText,
|
||||||
visualPrompt: s.visualPrompt
|
subtitleText: s.subtitleText,
|
||||||
}))
|
visualPrompt: s.visualPrompt,
|
||||||
}, null, 2)}`;
|
})),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}`;
|
||||||
|
|
||||||
const schemaStr = `{
|
const schemaStr = `{
|
||||||
"title": "string",
|
"title": "string",
|
||||||
@@ -1438,15 +1620,26 @@ ${JSON.stringify({
|
|||||||
|
|
||||||
let translatedData;
|
let translatedData;
|
||||||
try {
|
try {
|
||||||
const response = await this.geminiService.generateJSON(prompt, schemaStr, {
|
const response = await this.geminiService.generateJSON(
|
||||||
temperature: 0.3,
|
prompt,
|
||||||
});
|
schemaStr,
|
||||||
|
{
|
||||||
|
temperature: 0.3,
|
||||||
|
},
|
||||||
|
);
|
||||||
translatedData = response.data;
|
translatedData = response.data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Hata olursa krediyi iade et
|
// 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}`);
|
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({
|
const newProject = await this.db.project.create({
|
||||||
@@ -1460,8 +1653,15 @@ ${JSON.stringify({
|
|||||||
cinematicReference: project.cinematicReference,
|
cinematicReference: project.cinematicReference,
|
||||||
targetDuration: project.targetDuration,
|
targetDuration: project.targetDuration,
|
||||||
seoKeywords: project.seoKeywords,
|
seoKeywords: project.seoKeywords,
|
||||||
seoTitle: (translatedData.seoTitle || project.seoTitle || '').substring(0, 190),
|
seoTitle: (translatedData.seoTitle || project.seoTitle || '').substring(
|
||||||
seoDescription: (translatedData.seoDescription || project.seoDescription || '').substring(0, 490),
|
0,
|
||||||
|
190,
|
||||||
|
),
|
||||||
|
seoDescription: (
|
||||||
|
translatedData.seoDescription ||
|
||||||
|
project.seoDescription ||
|
||||||
|
''
|
||||||
|
).substring(0, 490),
|
||||||
socialContent: translatedData.socialContent || project.socialContent,
|
socialContent: translatedData.socialContent || project.socialContent,
|
||||||
referenceUrl: project.referenceUrl,
|
referenceUrl: project.referenceUrl,
|
||||||
sourceType: project.sourceType,
|
sourceType: project.sourceType,
|
||||||
@@ -1469,27 +1669,32 @@ ${JSON.stringify({
|
|||||||
status: 'DRAFT',
|
status: 'DRAFT',
|
||||||
userId,
|
userId,
|
||||||
parentId: project.id,
|
parentId: project.id,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const originalScene of project.scenes) {
|
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({
|
await this.db.scene.create({
|
||||||
data: {
|
data: {
|
||||||
order: originalScene.order,
|
order: originalScene.order,
|
||||||
title: originalScene.title,
|
title: originalScene.title,
|
||||||
narrationText: transScene?.narrationText || originalScene.narrationText,
|
narrationText:
|
||||||
|
transScene?.narrationText || originalScene.narrationText,
|
||||||
subtitleText: transScene?.subtitleText || originalScene.subtitleText,
|
subtitleText: transScene?.subtitleText || originalScene.subtitleText,
|
||||||
visualPrompt: transScene?.visualPrompt || originalScene.visualPrompt,
|
visualPrompt: transScene?.visualPrompt || originalScene.visualPrompt,
|
||||||
duration: originalScene.duration,
|
duration: originalScene.duration,
|
||||||
transitionType: originalScene.transitionType,
|
transitionType: originalScene.transitionType,
|
||||||
projectId: newProject.id,
|
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;
|
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 {
|
return {
|
||||||
titles: updated.seoTitleAlts,
|
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.
|
* 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.
|
* 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({
|
const project = await this.db.project.findFirst({
|
||||||
where: { id: projectId, userId, deletedAt: null },
|
where: { id: projectId, userId, deletedAt: null },
|
||||||
});
|
});
|
||||||
@@ -1571,9 +1782,13 @@ ${JSON.stringify({
|
|||||||
const trimmedTitle = selectedTitle.substring(0, 190);
|
const trimmedTitle = selectedTitle.substring(0, 190);
|
||||||
|
|
||||||
// socialContent varsa youtubeTitle'ı da güncelle
|
// 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') {
|
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({
|
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;
|
return updated;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,10 @@ export class RenderCallbackController {
|
|||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly notificationsService: NotificationsService,
|
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;
|
private readonly config: StorageConfig;
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
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 port = this.configService.get<number>('PORT', 3000);
|
||||||
const cdnUrl = this.configService.get<string>('STORAGE_CDN_URL');
|
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.logger.log(`📦 Storage: lokal depolama — ${this.config.basePath}`);
|
||||||
this.ensureBaseDir();
|
void this.ensureBaseDir();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,7 +72,9 @@ export class StorageService {
|
|||||||
private async ensureBaseDir() {
|
private async ensureBaseDir() {
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(this.config.basePath, { recursive: true });
|
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) {
|
} catch (error) {
|
||||||
this.logger.error(`Temel dizin oluşturulamadı: ${error}`);
|
this.logger.error(`Temel dizin oluşturulamadı: ${error}`);
|
||||||
}
|
}
|
||||||
@@ -119,7 +124,11 @@ export class StorageService {
|
|||||||
/**
|
/**
|
||||||
* Dosya yükle (Buffer → disk).
|
* 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 filePath = path.join(this.config.basePath, key);
|
||||||
const dir = path.dirname(filePath);
|
const dir = path.dirname(filePath);
|
||||||
|
|
||||||
@@ -128,7 +137,9 @@ export class StorageService {
|
|||||||
|
|
||||||
const sizeBytes = data.length;
|
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 {
|
return {
|
||||||
key,
|
key,
|
||||||
@@ -142,7 +153,11 @@ export class StorageService {
|
|||||||
/**
|
/**
|
||||||
* Stream olarak dosya yükle — büyük dosyalar için (Raspberry Pi bellek koruması).
|
* 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 destPath = path.join(this.config.basePath, key);
|
||||||
const dir = path.dirname(destPath);
|
const dir = path.dirname(destPath);
|
||||||
|
|
||||||
@@ -151,7 +166,9 @@ export class StorageService {
|
|||||||
|
|
||||||
const stats = await fs.stat(destPath);
|
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 {
|
return {
|
||||||
key,
|
key,
|
||||||
@@ -307,14 +324,22 @@ export class StorageService {
|
|||||||
|
|
||||||
// ── Private Helpers ────────────────────────────────────────────────
|
// ── 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 entries = await fs.readdir(dir, { withFileTypes: true });
|
||||||
const files: string[] = [];
|
const files: string[] = [];
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||||
if (entry.isDirectory()) {
|
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 {
|
} else {
|
||||||
files.push(relative);
|
files.push(relative);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,10 +72,12 @@ export class UserResponseDto {
|
|||||||
@Expose()
|
@Expose()
|
||||||
@Transform(({ obj }) => {
|
@Transform(({ obj }) => {
|
||||||
if (obj.roles && Array.isArray(obj.roles)) {
|
if (obj.roles && Array.isArray(obj.roles)) {
|
||||||
return obj.roles.map((r: any) => {
|
return obj.roles
|
||||||
if (typeof r === 'string') return r;
|
.map((r: any) => {
|
||||||
return r?.role?.name || r?.name;
|
if (typeof r === 'string') return r;
|
||||||
}).filter(Boolean);
|
return r?.role?.name || r?.name;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
return [];
|
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 { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { BaseController } from '../../common/base';
|
import { BaseController } from '../../common/base';
|
||||||
import { UsersService } from './users.service';
|
import { UsersService } from './users.service';
|
||||||
@@ -73,13 +79,19 @@ export class UsersController extends BaseController<
|
|||||||
if (!fullUser) throw new BadRequestException('Kullanıcı bulunamadı');
|
if (!fullUser) throw new BadRequestException('Kullanıcı bulunamadı');
|
||||||
|
|
||||||
const bcrypt = await import('bcrypt');
|
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) {
|
if (!isValid) {
|
||||||
throw new BadRequestException('Mevcut şifre hatalı');
|
throw new BadRequestException('Mevcut şifre hatalı');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.usersService.update(user.id, { password: body.newPassword });
|
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
|
// 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
|
* BullMQ: NestJS tarafında lifecycle tracking
|
||||||
* Redis List: C# Worker BRPOP ile consume eder
|
* Redis List: C# Worker BRPOP ile consume eder
|
||||||
*/
|
*/
|
||||||
async addVideoGenerationJob(payload: VideoGenerationJobPayload): Promise<string> {
|
async addVideoGenerationJob(
|
||||||
const bullJob = await this.videoQueue.add(
|
payload: VideoGenerationJobPayload,
|
||||||
'generate-video',
|
): Promise<string> {
|
||||||
payload,
|
const bullJob = await this.videoQueue.add('generate-video', payload, {
|
||||||
{
|
attempts: 3,
|
||||||
attempts: 3,
|
backoff: { type: 'exponential', delay: 5000 },
|
||||||
backoff: { type: 'exponential', delay: 5000 },
|
removeOnComplete: { count: 100, age: 7 * 24 * 3600 },
|
||||||
removeOnComplete: { count: 100, age: 7 * 24 * 3600 },
|
removeOnFail: { count: 50 },
|
||||||
removeOnFail: { count: 50 },
|
priority: 1,
|
||||||
priority: 1,
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const workerPayload = JSON.stringify({
|
const workerPayload = JSON.stringify({
|
||||||
jobId: bullJob.id,
|
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';
|
import { IsString, IsNotEmpty, Matches } from 'class-validator';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -10,14 +10,14 @@ import { IsString, IsNotEmpty, Matches } from 'class-validator';
|
|||||||
*/
|
*/
|
||||||
export class FetchTweetDto {
|
export class FetchTweetDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'X/Twitter tweet URL\'si',
|
description: "X/Twitter tweet URL'si",
|
||||||
example: 'https://x.com/elonmusk/status/1893456789012345678',
|
example: 'https://x.com/elonmusk/status/1893456789012345678',
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty({ message: 'Tweet URL\'si boş olamaz' })
|
@IsNotEmpty({ message: "Tweet URL'si boş olamaz" })
|
||||||
@Matches(
|
@Matches(
|
||||||
/^https?:\/\/(x\.com|twitter\.com)\/([\w]+\/status\/\d+|i\/status\/\d+)/,
|
/^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;
|
tweetUrl: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export class XTwitterService {
|
|||||||
const parsed = this.parseFxTweet(response.tweet);
|
const parsed = this.parseFxTweet(response.tweet);
|
||||||
|
|
||||||
// Thread tespiti ve toplama
|
// Thread tespiti ve toplama
|
||||||
const thread = await this.collectThread(parsed, username);
|
const thread = this.collectThread(parsed, username);
|
||||||
if (thread.length > 1) {
|
if (thread.length > 1) {
|
||||||
parsed.isThread = true;
|
parsed.isThread = true;
|
||||||
parsed.threadTweets = thread;
|
parsed.threadTweets = thread;
|
||||||
@@ -103,7 +103,7 @@ export class XTwitterService {
|
|||||||
: tweet.text;
|
: tweet.text;
|
||||||
const wordCount = totalText.split(/\s+/).length;
|
const wordCount = totalText.split(/\s+/).length;
|
||||||
const estimatedDuration = Math.min(
|
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
|
90, // Max 90sn
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -182,10 +182,10 @@ export class XTwitterService {
|
|||||||
* Thread tweet'lerini toplar.
|
* Thread tweet'lerini toplar.
|
||||||
* FXTwitter'da direkt thread endpoint yok → author'un son tweet'lerinden thread'i tahmin et.
|
* FXTwitter'da direkt thread endpoint yok → author'un son tweet'lerinden thread'i tahmin et.
|
||||||
*/
|
*/
|
||||||
private async collectThread(
|
private collectThread(
|
||||||
rootTweet: ParsedTweet,
|
rootTweet: ParsedTweet,
|
||||||
username: string,
|
_username: string,
|
||||||
): Promise<ParsedTweet[]> {
|
): ParsedTweet[] {
|
||||||
const threadTweets: ParsedTweet[] = [rootTweet];
|
const threadTweets: ParsedTweet[] = [rootTweet];
|
||||||
|
|
||||||
// Tweet'in reply olup olmadığını kontrol et
|
// Tweet'in reply olup olmadığını kontrol et
|
||||||
@@ -196,7 +196,9 @@ export class XTwitterService {
|
|||||||
// İleride Xquik thread_extractor ile genişletilebilir
|
// İleride Xquik thread_extractor ile genişletilebilir
|
||||||
|
|
||||||
// Şu an: Eğer tweet uzunsa (280+ karakter) ve satır sonları varsa, thread benzeri
|
// Ş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) {
|
if (lines.length >= 3) {
|
||||||
// Uzun tek tweet — thread gibi ele alınabilir
|
// Uzun tek tweet — thread gibi ele alınabilir
|
||||||
return threadTweets;
|
return threadTweets;
|
||||||
@@ -214,7 +216,9 @@ export class XTwitterService {
|
|||||||
/**
|
/**
|
||||||
* FXTwitter API response'unu ParsedTweet'e dönüştürür.
|
* 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 views = raw.views || 1;
|
||||||
const engagement = raw.likes + raw.retweets + raw.replies;
|
const engagement = raw.likes + raw.retweets + raw.replies;
|
||||||
|
|
||||||
@@ -236,7 +240,8 @@ export class XTwitterService {
|
|||||||
retweets: raw.retweets,
|
retweets: raw.retweets,
|
||||||
likes: raw.likes,
|
likes: raw.likes,
|
||||||
views,
|
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) => ({
|
media: (raw.media?.all || []).map((m) => ({
|
||||||
type: m.type,
|
type: m.type,
|
||||||
@@ -261,7 +266,7 @@ export class XTwitterService {
|
|||||||
* Engagement rate, takipçi oranı ve toplam etkileşim bazlı.
|
* Engagement rate, takipçi oranı ve toplam etkileşim bazlı.
|
||||||
*/
|
*/
|
||||||
private calculateViralScore(tweet: ParsedTweet): number {
|
private calculateViralScore(tweet: ParsedTweet): number {
|
||||||
const { metrics, author } = tweet;
|
const { metrics } = tweet;
|
||||||
let score = 0;
|
let score = 0;
|
||||||
|
|
||||||
// Engagement rate katkısı (max 40 puan)
|
// Engagement rate katkısı (max 40 puan)
|
||||||
@@ -340,7 +345,9 @@ export class XTwitterService {
|
|||||||
if (res.status === 429) {
|
if (res.status === 429) {
|
||||||
const retryAfter = res.headers.get('Retry-After');
|
const retryAfter = res.headers.get('Retry-After');
|
||||||
const delay = retryAfter ? parseInt(retryAfter, 10) * 1000 : 5000;
|
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);
|
await this.sleep(delay);
|
||||||
continue;
|
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