generated from fahricansecer/boilerplate-be
Initial commit
This commit is contained in:
22
src/app.controller.spec.ts
Normal file
22
src/app.controller.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
describe('AppController', () => {
|
||||
let appController: AppController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
appController = app.get<AppController>(AppController);
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(appController.getHello()).toBe('Hello World!');
|
||||
});
|
||||
});
|
||||
});
|
||||
12
src/app.controller.ts
Normal file
12
src/app.controller.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
}
|
||||
204
src/app.module.ts
Normal file
204
src/app.module.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import { redisStore } from 'cache-manager-redis-yet';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
import {
|
||||
I18nModule,
|
||||
AcceptLanguageResolver,
|
||||
HeaderResolver,
|
||||
QueryResolver,
|
||||
} from 'nestjs-i18n';
|
||||
import * as path from 'path';
|
||||
|
||||
// Config
|
||||
import {
|
||||
appConfig,
|
||||
databaseConfig,
|
||||
jwtConfig,
|
||||
redisConfig,
|
||||
i18nConfig,
|
||||
featuresConfig,
|
||||
throttleConfig,
|
||||
} from './config/configuration';
|
||||
import { geminiConfig } from './modules/gemini/gemini.config';
|
||||
import { validateEnv } from './config/env.validation';
|
||||
|
||||
// Common
|
||||
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
|
||||
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
||||
|
||||
// Database
|
||||
import { DatabaseModule } from './database/database.module';
|
||||
|
||||
// Modules
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { UsersModule } from './modules/users/users.module';
|
||||
import { AdminModule } from './modules/admin/admin.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { GeminiModule } from './modules/gemini/gemini.module';
|
||||
import { SkriptaiModule } from './modules/skriptai/skriptai.module';
|
||||
|
||||
// Guards
|
||||
import {
|
||||
JwtAuthGuard,
|
||||
RolesGuard,
|
||||
PermissionsGuard,
|
||||
} from './modules/auth/guards';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// Configuration
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
validate: validateEnv,
|
||||
load: [
|
||||
appConfig,
|
||||
databaseConfig,
|
||||
jwtConfig,
|
||||
redisConfig,
|
||||
i18nConfig,
|
||||
featuresConfig,
|
||||
throttleConfig,
|
||||
geminiConfig,
|
||||
],
|
||||
}),
|
||||
|
||||
// Logger (Structured Logging with Pino)
|
||||
LoggerModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService) => {
|
||||
return {
|
||||
pinoHttp: {
|
||||
level: configService.get('app.isDevelopment') ? 'debug' : 'info',
|
||||
transport: configService.get('app.isDevelopment')
|
||||
? {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
singleLine: true,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
// i18n
|
||||
I18nModule.forRootAsync({
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
fallbackLanguage: configService.get('i18n.fallbackLanguage', 'en'),
|
||||
loaderOptions: {
|
||||
path: path.join(__dirname, '/i18n/'),
|
||||
watch: configService.get('app.isDevelopment', true),
|
||||
},
|
||||
}),
|
||||
resolvers: [
|
||||
new HeaderResolver(['x-lang', 'accept-language']),
|
||||
new QueryResolver(['lang']),
|
||||
AcceptLanguageResolver,
|
||||
],
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
|
||||
// Throttling
|
||||
ThrottlerModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => [
|
||||
{
|
||||
ttl: configService.get('throttle.ttl', 60000),
|
||||
limit: configService.get('throttle.limit', 100),
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
// Caching (Redis with in-memory fallback)
|
||||
CacheModule.registerAsync({
|
||||
isGlobal: true,
|
||||
imports: [ConfigModule],
|
||||
useFactory: async (configService: ConfigService) => {
|
||||
const useRedis = configService.get('REDIS_ENABLED', 'false') === 'true';
|
||||
|
||||
if (useRedis) {
|
||||
try {
|
||||
const store = await redisStore({
|
||||
socket: {
|
||||
host: configService.get('redis.host', 'localhost'),
|
||||
port: configService.get('redis.port', 6379),
|
||||
},
|
||||
ttl: 60 * 1000, // 1 minute default
|
||||
});
|
||||
console.log('✅ Redis cache connected');
|
||||
return {
|
||||
store: store as unknown as any,
|
||||
ttl: 60 * 1000,
|
||||
};
|
||||
} catch {
|
||||
console.warn('⚠️ Redis connection failed, using in-memory cache');
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to in-memory cache
|
||||
console.log('📦 Using in-memory cache');
|
||||
return {
|
||||
ttl: 60 * 1000,
|
||||
};
|
||||
},
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
|
||||
// Database
|
||||
DatabaseModule,
|
||||
|
||||
// Core Modules
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
AdminModule,
|
||||
|
||||
// Optional Modules (controlled by env variables)
|
||||
GeminiModule,
|
||||
SkriptaiModule,
|
||||
HealthModule,
|
||||
],
|
||||
providers: [
|
||||
// Global Exception Filter
|
||||
{
|
||||
provide: APP_FILTER,
|
||||
useClass: GlobalExceptionFilter,
|
||||
},
|
||||
|
||||
// Global Response Interceptor
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: ResponseInterceptor,
|
||||
},
|
||||
|
||||
// Global Rate Limiting
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: ThrottlerGuard,
|
||||
},
|
||||
|
||||
// Global JWT Auth Guard
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: JwtAuthGuard,
|
||||
},
|
||||
|
||||
// Global Roles Guard
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: RolesGuard,
|
||||
},
|
||||
|
||||
// Global Permissions Guard
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: PermissionsGuard,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
8
src/app.service.ts
Normal file
8
src/app.service.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
128
src/common/base/base.controller.ts
Normal file
128
src/common/base/base.controller.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Param,
|
||||
Query,
|
||||
Body,
|
||||
HttpCode,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiOperation,
|
||||
ApiOkResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiBadRequestResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { BaseService } from './base.service';
|
||||
import { PaginationDto } from '../dto/pagination.dto';
|
||||
import {
|
||||
ApiResponse,
|
||||
createSuccessResponse,
|
||||
createPaginatedResponse,
|
||||
} from '../types/api-response.type';
|
||||
|
||||
/**
|
||||
* Generic base controller with common CRUD endpoints
|
||||
* Extend this class for entity-specific controllers
|
||||
*
|
||||
* Note: Use decorators like @Controller() on the child class
|
||||
*/
|
||||
export abstract class BaseController<T, CreateDto, UpdateDto> {
|
||||
constructor(
|
||||
protected readonly service: BaseService<T, CreateDto, UpdateDto>,
|
||||
protected readonly entityName: string,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Get all records with pagination' })
|
||||
@ApiOkResponse({ description: 'Records retrieved successfully' })
|
||||
async findAll(
|
||||
@Query() pagination: PaginationDto,
|
||||
): Promise<ApiResponse<{ items: T[]; meta: any }>> {
|
||||
const result = await this.service.findAll(pagination);
|
||||
return createPaginatedResponse(
|
||||
result.items,
|
||||
result.meta.total,
|
||||
result.meta.page,
|
||||
result.meta.limit,
|
||||
`${this.entityName} list retrieved successfully`,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Get a record by ID' })
|
||||
@ApiOkResponse({ description: 'Record retrieved successfully' })
|
||||
@ApiNotFoundResponse({ description: 'Record not found' })
|
||||
async findOne(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<ApiResponse<T>> {
|
||||
const result = await this.service.findOne(id);
|
||||
return createSuccessResponse(
|
||||
result,
|
||||
`${this.entityName} retrieved successfully`,
|
||||
);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Create a new record' })
|
||||
@ApiOkResponse({ description: 'Record created successfully' })
|
||||
@ApiBadRequestResponse({ description: 'Validation failed' })
|
||||
async create(@Body() createDto: CreateDto): Promise<ApiResponse<T>> {
|
||||
const result = await this.service.create(createDto);
|
||||
return createSuccessResponse(
|
||||
result,
|
||||
`${this.entityName} created successfully`,
|
||||
201,
|
||||
);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Update an existing record' })
|
||||
@ApiOkResponse({ description: 'Record updated successfully' })
|
||||
@ApiNotFoundResponse({ description: 'Record not found' })
|
||||
async update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() updateDto: UpdateDto,
|
||||
): Promise<ApiResponse<T>> {
|
||||
const result = await this.service.update(id, updateDto);
|
||||
return createSuccessResponse(
|
||||
result,
|
||||
`${this.entityName} updated successfully`,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Delete a record (soft delete)' })
|
||||
@ApiOkResponse({ description: 'Record deleted successfully' })
|
||||
@ApiNotFoundResponse({ description: 'Record not found' })
|
||||
async delete(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<ApiResponse<T>> {
|
||||
const result = await this.service.delete(id);
|
||||
return createSuccessResponse(
|
||||
result,
|
||||
`${this.entityName} deleted successfully`,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/restore')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Restore a soft-deleted record' })
|
||||
@ApiOkResponse({ description: 'Record restored successfully' })
|
||||
async restore(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<ApiResponse<T>> {
|
||||
const result = await this.service.restore(id);
|
||||
return createSuccessResponse(
|
||||
result,
|
||||
`${this.entityName} restored successfully`,
|
||||
);
|
||||
}
|
||||
}
|
||||
165
src/common/base/base.service.ts
Normal file
165
src/common/base/base.service.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { NotFoundException, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { PaginationDto } from '../dto/pagination.dto';
|
||||
import { PaginationMeta } from '../types/api-response.type';
|
||||
|
||||
/**
|
||||
* Generic base service with common CRUD operations
|
||||
* Extend this class for entity-specific services
|
||||
*/
|
||||
export abstract class BaseService<T, CreateDto, UpdateDto> {
|
||||
protected readonly logger: Logger;
|
||||
|
||||
constructor(
|
||||
protected readonly prisma: PrismaService,
|
||||
protected readonly modelName: string,
|
||||
) {
|
||||
this.logger = new Logger(`${modelName}Service`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Prisma model delegate
|
||||
*/
|
||||
protected get model() {
|
||||
return (this.prisma as any)[this.modelName.toLowerCase()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all records with pagination
|
||||
*/
|
||||
async findAll(
|
||||
pagination: PaginationDto,
|
||||
where?: any,
|
||||
): Promise<{ items: T[]; meta: PaginationMeta }> {
|
||||
const { skip, take, orderBy } = pagination;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.model.findMany({
|
||||
where,
|
||||
skip,
|
||||
take,
|
||||
orderBy,
|
||||
}),
|
||||
this.model.count({ where }),
|
||||
]);
|
||||
|
||||
const totalPages = Math.ceil(total / take);
|
||||
|
||||
return {
|
||||
items,
|
||||
meta: {
|
||||
total,
|
||||
page: pagination.page || 1,
|
||||
limit: pagination.limit || 10,
|
||||
totalPages,
|
||||
hasNextPage: (pagination.page || 1) < totalPages,
|
||||
hasPreviousPage: (pagination.page || 1) > 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single record by ID
|
||||
*/
|
||||
async findOne(id: string, include?: any): Promise<T> {
|
||||
const record = await this.model.findUnique({
|
||||
where: { id },
|
||||
include,
|
||||
});
|
||||
|
||||
if (!record) {
|
||||
throw new NotFoundException(`${this.modelName} not found`);
|
||||
}
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single record by custom criteria
|
||||
*/
|
||||
findOneBy(where: any, include?: any): Promise<T | null> {
|
||||
return this.model.findFirst({
|
||||
where,
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new record
|
||||
*/
|
||||
create(data: CreateDto, include?: any): Promise<T> {
|
||||
return this.model.create({
|
||||
data,
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing record
|
||||
*/
|
||||
async update(id: string, data: UpdateDto, include?: any): Promise<T> {
|
||||
// Check if record exists
|
||||
await this.findOne(id);
|
||||
|
||||
return this.model.update({
|
||||
where: { id },
|
||||
data,
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete a record (sets deletedAt)
|
||||
*/
|
||||
async delete(id: string): Promise<T> {
|
||||
// Check if record exists
|
||||
await this.findOne(id);
|
||||
|
||||
return this.model.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard delete a record (permanently removes)
|
||||
*/
|
||||
async hardDelete(id: string): Promise<T> {
|
||||
// Check if record exists
|
||||
await this.findOne(id);
|
||||
|
||||
return this.prisma.hardDelete(this.modelName, { id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a soft-deleted record
|
||||
*/
|
||||
async restore(id: string): Promise<T> {
|
||||
return this.prisma.restore(this.modelName, { id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a record exists
|
||||
*/
|
||||
async exists(id: string): Promise<boolean> {
|
||||
const count = await this.model.count({
|
||||
where: { id },
|
||||
});
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count records matching criteria
|
||||
*/
|
||||
count(where?: any): Promise<number> {
|
||||
return this.model.count({ where });
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a transaction
|
||||
*/
|
||||
transaction<R>(fn: (prisma: PrismaService) => Promise<R>): Promise<R> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
return fn(tx as unknown as PrismaService);
|
||||
});
|
||||
}
|
||||
}
|
||||
2
src/common/base/index.ts
Normal file
2
src/common/base/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './base.service';
|
||||
export * from './base.controller';
|
||||
60
src/common/decorators/index.ts
Normal file
60
src/common/decorators/index.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
createParamDecorator,
|
||||
ExecutionContext,
|
||||
SetMetadata,
|
||||
} from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* Get the current authenticated user from request
|
||||
*/
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: string | undefined, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
if (data) {
|
||||
return user?.[data];
|
||||
}
|
||||
|
||||
return user;
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Mark a route as public (no authentication required)
|
||||
*/
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
|
||||
/**
|
||||
* Require specific roles to access a route
|
||||
*/
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||
|
||||
/**
|
||||
* Require specific permissions to access a route
|
||||
*/
|
||||
export const PERMISSIONS_KEY = 'permissions';
|
||||
export const RequirePermissions = (...permissions: string[]) =>
|
||||
SetMetadata(PERMISSIONS_KEY, permissions);
|
||||
|
||||
/**
|
||||
* Get tenant ID from request (for multi-tenancy)
|
||||
*/
|
||||
export const CurrentTenant = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.tenantId;
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Get the current language from request headers
|
||||
*/
|
||||
export const CurrentLang = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.headers['accept-language'] || 'en';
|
||||
},
|
||||
);
|
||||
65
src/common/dto/pagination.dto.ts
Normal file
65
src/common/dto/pagination.dto.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { IsOptional, IsInt, Min, Max, IsString, IsIn } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class PaginationDto {
|
||||
@ApiPropertyOptional({ default: 1, minimum: 1, description: 'Page number' })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => parseInt(value, 10))
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
default: 10,
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
description: 'Items per page',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => parseInt(value, 10))
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number = 10;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Field to sort by' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sortBy?: string = 'createdAt';
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: ['asc', 'desc'],
|
||||
default: 'desc',
|
||||
description: 'Sort order',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(['asc', 'desc'])
|
||||
sortOrder?: 'asc' | 'desc' = 'desc';
|
||||
|
||||
@ApiPropertyOptional({ description: 'Search query' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
/**
|
||||
* Get skip value for Prisma
|
||||
*/
|
||||
get skip(): number {
|
||||
return ((this.page || 1) - 1) * (this.limit || 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get take value for Prisma
|
||||
*/
|
||||
get take(): number {
|
||||
return this.limit || 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get orderBy object for Prisma
|
||||
*/
|
||||
get orderBy(): Record<string, 'asc' | 'desc'> {
|
||||
return { [this.sortBy || 'createdAt']: this.sortOrder || 'desc' };
|
||||
}
|
||||
}
|
||||
109
src/common/filters/global-exception.filter.ts
Normal file
109
src/common/filters/global-exception.filter.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import { I18nService, I18nContext } from 'nestjs-i18n';
|
||||
import { ApiResponse, createErrorResponse } from '../types/api-response.type';
|
||||
|
||||
/**
|
||||
* Global exception filter that catches all exceptions
|
||||
* and returns a standardized ApiResponse with HTTP 200
|
||||
*/
|
||||
@Catch()
|
||||
export class GlobalExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(GlobalExceptionFilter.name);
|
||||
|
||||
constructor(private readonly i18n?: I18nService) {}
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
// Determine status and message
|
||||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let message = 'Internal server error';
|
||||
let errors: string[] = [];
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
|
||||
if (typeof exceptionResponse === 'string') {
|
||||
message = exceptionResponse;
|
||||
} else if (typeof exceptionResponse === 'object') {
|
||||
const responseObj = exceptionResponse as Record<string, unknown>;
|
||||
message = (responseObj.message as string) || exception.message;
|
||||
|
||||
// Handle validation errors (class-validator)
|
||||
if (Array.isArray(responseObj.message)) {
|
||||
errors = responseObj.message as string[];
|
||||
message = 'VALIDATION_FAILED';
|
||||
}
|
||||
}
|
||||
} else if (exception instanceof Error) {
|
||||
message = exception.message;
|
||||
}
|
||||
|
||||
// Try to translate the message
|
||||
if (this.i18n) {
|
||||
try {
|
||||
const i18nContext = I18nContext.current();
|
||||
let lang = i18nContext?.lang;
|
||||
|
||||
if (!lang) {
|
||||
const acceptLanguage = request.headers['accept-language'];
|
||||
const xLang = request.headers['x-lang'];
|
||||
|
||||
if (xLang) {
|
||||
lang = Array.isArray(xLang) ? xLang[0] : xLang;
|
||||
} else if (acceptLanguage) {
|
||||
// Take first preferred language: "tr-TR,en;q=0.9" -> "tr"
|
||||
lang = acceptLanguage.split(',')[0].split(';')[0].split('-')[0];
|
||||
}
|
||||
}
|
||||
|
||||
lang = lang || 'en';
|
||||
|
||||
// Translate validation error specially
|
||||
if (message === 'VALIDATION_FAILED') {
|
||||
message = this.i18n.translate('errors.VALIDATION_FAILED', { lang });
|
||||
} else {
|
||||
// Try dynamic translation
|
||||
const translatedMessage = this.i18n.translate(`errors.${message}`, {
|
||||
lang,
|
||||
});
|
||||
// Only update if translation exists (key is different from result)
|
||||
if (translatedMessage !== `errors.${message}`) {
|
||||
message = translatedMessage;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Keep original message if translation fails
|
||||
}
|
||||
}
|
||||
|
||||
// Log the error
|
||||
this.logger.error(
|
||||
`${request.method} ${request.url} - ${status} - ${message}`,
|
||||
exception instanceof Error ? exception.stack : undefined,
|
||||
);
|
||||
|
||||
// Build response
|
||||
const isDevelopment = process.env.NODE_ENV === 'development';
|
||||
const errorResponse: ApiResponse<null> = createErrorResponse(
|
||||
message,
|
||||
status,
|
||||
errors,
|
||||
isDevelopment && exception instanceof Error ? exception.stack : undefined,
|
||||
);
|
||||
|
||||
// Always return HTTP 200, actual status in body
|
||||
response.status(200).json(errorResponse);
|
||||
}
|
||||
}
|
||||
74
src/common/interceptors/response.interceptor.ts
Normal file
74
src/common/interceptors/response.interceptor.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { ApiResponse, createSuccessResponse } from '../types/api-response.type';
|
||||
|
||||
/**
|
||||
* Response interceptor that wraps all successful responses
|
||||
* in the standard ApiResponse format
|
||||
*/
|
||||
import { I18nService, I18nContext } from 'nestjs-i18n';
|
||||
|
||||
@Injectable()
|
||||
export class ResponseInterceptor<T> implements NestInterceptor<
|
||||
T,
|
||||
ApiResponse<T>
|
||||
> {
|
||||
constructor(private readonly i18n: I18nService) {}
|
||||
|
||||
intercept(
|
||||
context: ExecutionContext,
|
||||
next: CallHandler,
|
||||
): Observable<ApiResponse<T>> {
|
||||
return next.handle().pipe(
|
||||
map((data: unknown) => {
|
||||
// If data is already an ApiResponse, return as-is
|
||||
if (this.isApiResponse(data)) {
|
||||
return data as ApiResponse<T>;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
// Determine language
|
||||
const i18nContext = I18nContext.current();
|
||||
let lang = i18nContext?.lang;
|
||||
|
||||
if (!lang) {
|
||||
const acceptLanguage = request.headers['accept-language'];
|
||||
const xLang = request.headers['x-lang'];
|
||||
|
||||
if (xLang) {
|
||||
lang = Array.isArray(xLang) ? xLang[0] : xLang;
|
||||
} else if (acceptLanguage) {
|
||||
lang = acceptLanguage.split(',')[0].split(';')[0].split('-')[0];
|
||||
}
|
||||
}
|
||||
|
||||
lang = lang || 'en';
|
||||
|
||||
const message = this.i18n.translate('common.success', {
|
||||
lang,
|
||||
});
|
||||
|
||||
// Wrap in success response
|
||||
return createSuccessResponse(data as T, message);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private isApiResponse(data: unknown): boolean {
|
||||
return (
|
||||
data !== null &&
|
||||
typeof data === 'object' &&
|
||||
'success' in data &&
|
||||
'status' in data &&
|
||||
'message' in data &&
|
||||
'data' in data
|
||||
);
|
||||
}
|
||||
}
|
||||
96
src/common/types/api-response.type.ts
Normal file
96
src/common/types/api-response.type.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Standard API Response Type
|
||||
* All responses return HTTP 200 with this structure
|
||||
*/
|
||||
export type ApiResponse<T = any> = {
|
||||
errors: any[];
|
||||
stack?: string;
|
||||
message: string;
|
||||
success: boolean;
|
||||
status: number;
|
||||
data: T;
|
||||
};
|
||||
|
||||
/**
|
||||
* Paginated response wrapper
|
||||
*/
|
||||
export interface PaginatedData<T> {
|
||||
items: T[];
|
||||
meta: PaginationMeta;
|
||||
}
|
||||
|
||||
export interface PaginationMeta {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a successful API response
|
||||
*/
|
||||
export function createSuccessResponse<T>(
|
||||
data: T,
|
||||
message = 'Success',
|
||||
status = 200,
|
||||
): ApiResponse<T> {
|
||||
return {
|
||||
success: true,
|
||||
status,
|
||||
message,
|
||||
data,
|
||||
errors: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an error API response
|
||||
*/
|
||||
export function createErrorResponse(
|
||||
message: string,
|
||||
status = 400,
|
||||
errors: any[] = [],
|
||||
stack?: string,
|
||||
): ApiResponse<null> {
|
||||
return {
|
||||
success: false,
|
||||
status,
|
||||
message,
|
||||
data: null,
|
||||
errors,
|
||||
stack,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a paginated API response
|
||||
*/
|
||||
export function createPaginatedResponse<T>(
|
||||
items: T[],
|
||||
total: number,
|
||||
page: number,
|
||||
limit: number,
|
||||
message = 'Success',
|
||||
): ApiResponse<PaginatedData<T>> {
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status: 200,
|
||||
message,
|
||||
data: {
|
||||
items,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPreviousPage: page > 1,
|
||||
},
|
||||
},
|
||||
errors: [],
|
||||
};
|
||||
}
|
||||
57
src/config/configuration.ts
Normal file
57
src/config/configuration.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export const appConfig = registerAs('app', () => ({
|
||||
env: process.env.NODE_ENV || 'development',
|
||||
port: parseInt(process.env.PORT || '3000', 10),
|
||||
isDevelopment: process.env.NODE_ENV === 'development',
|
||||
isProduction: process.env.NODE_ENV === 'production',
|
||||
}));
|
||||
|
||||
export const databaseConfig = registerAs('database', () => ({
|
||||
url: process.env.DATABASE_URL,
|
||||
}));
|
||||
|
||||
export const jwtConfig = registerAs('jwt', () => ({
|
||||
secret: process.env.JWT_SECRET,
|
||||
accessExpiration: process.env.JWT_ACCESS_EXPIRATION || '15m',
|
||||
refreshExpiration: process.env.JWT_REFRESH_EXPIRATION || '7d',
|
||||
}));
|
||||
|
||||
export const redisConfig = registerAs('redis', () => ({
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||
password: process.env.REDIS_PASSWORD || undefined,
|
||||
}));
|
||||
|
||||
export const i18nConfig = registerAs('i18n', () => ({
|
||||
defaultLanguage: process.env.DEFAULT_LANGUAGE || 'en',
|
||||
fallbackLanguage: process.env.FALLBACK_LANGUAGE || 'en',
|
||||
}));
|
||||
|
||||
export const featuresConfig = registerAs('features', () => ({
|
||||
mail: process.env.ENABLE_MAIL === 'true',
|
||||
s3: process.env.ENABLE_S3 === 'true',
|
||||
websocket: process.env.ENABLE_WEBSOCKET === 'true',
|
||||
multiTenancy: process.env.ENABLE_MULTI_TENANCY === 'true',
|
||||
}));
|
||||
|
||||
export const mailConfig = registerAs('mail', () => ({
|
||||
host: process.env.MAIL_HOST,
|
||||
port: parseInt(process.env.MAIL_PORT || '587', 10),
|
||||
user: process.env.MAIL_USER,
|
||||
password: process.env.MAIL_PASSWORD,
|
||||
from: process.env.MAIL_FROM,
|
||||
}));
|
||||
|
||||
export const s3Config = registerAs('s3', () => ({
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
accessKey: process.env.S3_ACCESS_KEY,
|
||||
secretKey: process.env.S3_SECRET_KEY,
|
||||
bucket: process.env.S3_BUCKET,
|
||||
region: process.env.S3_REGION || 'us-east-1',
|
||||
}));
|
||||
|
||||
export const throttleConfig = registerAs('throttle', () => ({
|
||||
ttl: parseInt(process.env.THROTTLE_TTL || '60000', 10),
|
||||
limit: parseInt(process.env.THROTTLE_LIMIT || '100', 10),
|
||||
}));
|
||||
80
src/config/env.validation.ts
Normal file
80
src/config/env.validation.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Helper to parse boolean from string
|
||||
*/
|
||||
const booleanString = z
|
||||
.string()
|
||||
.optional()
|
||||
.default('false')
|
||||
.transform((val) => val === 'true');
|
||||
|
||||
/**
|
||||
* Environment variables schema validation using Zod
|
||||
*/
|
||||
export const envSchema = z.object({
|
||||
// Environment
|
||||
NODE_ENV: z
|
||||
.enum(['development', 'production', 'test'])
|
||||
.default('development'),
|
||||
PORT: z.coerce.number().default(3000),
|
||||
|
||||
// Database
|
||||
DATABASE_URL: z.string().url(),
|
||||
|
||||
// JWT
|
||||
JWT_SECRET: z.string().min(32),
|
||||
JWT_ACCESS_EXPIRATION: z.string().default('24h'),
|
||||
JWT_REFRESH_EXPIRATION: z.string().default('7d'),
|
||||
|
||||
// Redis
|
||||
REDIS_HOST: z.string().default('localhost'),
|
||||
REDIS_PORT: z.coerce.number().default(6379),
|
||||
REDIS_PASSWORD: z.string().optional(),
|
||||
|
||||
// i18n
|
||||
DEFAULT_LANGUAGE: z.string().default('en'),
|
||||
FALLBACK_LANGUAGE: z.string().default('en'),
|
||||
|
||||
// Optional Features
|
||||
ENABLE_MAIL: booleanString,
|
||||
ENABLE_S3: booleanString,
|
||||
ENABLE_WEBSOCKET: booleanString,
|
||||
ENABLE_MULTI_TENANCY: booleanString,
|
||||
|
||||
// Mail (Optional)
|
||||
MAIL_HOST: z.string().optional(),
|
||||
MAIL_PORT: z.coerce.number().optional(),
|
||||
MAIL_USER: z.string().optional(),
|
||||
MAIL_PASSWORD: z.string().optional(),
|
||||
MAIL_FROM: z.string().optional(),
|
||||
|
||||
// S3 (Optional)
|
||||
S3_ENDPOINT: z.string().optional(),
|
||||
S3_ACCESS_KEY: z.string().optional(),
|
||||
S3_SECRET_KEY: z.string().optional(),
|
||||
S3_BUCKET: z.string().optional(),
|
||||
S3_REGION: z.string().optional(),
|
||||
|
||||
// Throttle
|
||||
THROTTLE_TTL: z.coerce.number().default(60000),
|
||||
THROTTLE_LIMIT: z.coerce.number().default(100),
|
||||
});
|
||||
|
||||
export type EnvConfig = z.infer<typeof envSchema>;
|
||||
|
||||
/**
|
||||
* Validate environment variables
|
||||
*/
|
||||
export function validateEnv(config: Record<string, unknown>): EnvConfig {
|
||||
const result = envSchema.safeParse(config);
|
||||
|
||||
if (!result.success) {
|
||||
const errors = result.error.issues.map(
|
||||
(err) => `${err.path.join('.')}: ${err.message}`,
|
||||
);
|
||||
throw new Error(`Environment validation failed:\n${errors.join('\n')}`);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
9
src/database/database.module.ts
Normal file
9
src/database/database.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
134
src/database/prisma.service.ts
Normal file
134
src/database/prisma.service.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
Injectable,
|
||||
OnModuleInit,
|
||||
OnModuleDestroy,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
// Models that support soft delete
|
||||
const SOFT_DELETE_MODELS = ['user', 'role', 'tenant'];
|
||||
|
||||
// Type for Prisma model delegate with common operations
|
||||
interface PrismaDelegate {
|
||||
delete: (args: { where: Record<string, unknown> }) => Promise<unknown>;
|
||||
findMany: (args?: Record<string, unknown>) => Promise<unknown[]>;
|
||||
update: (args: {
|
||||
where: Record<string, unknown>;
|
||||
data: Record<string, unknown>;
|
||||
}) => Promise<unknown>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
private readonly logger = new Logger(PrismaService.name);
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
log: [
|
||||
{ emit: 'event', level: 'query' },
|
||||
{ emit: 'event', level: 'error' },
|
||||
{ emit: 'event', level: 'warn' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
this.logger.log(
|
||||
`Connecting to database... URL: ${process.env.DATABASE_URL?.split('@')[1]}`,
|
||||
); // Mask password
|
||||
try {
|
||||
await this.$connect();
|
||||
this.logger.log('✅ Database connected successfully');
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`❌ Database connection failed: ${error.message}`,
|
||||
error.stack,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
this.logger.log('🔌 Database disconnected');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if model has soft delete (deletedAt field)
|
||||
*/
|
||||
hasSoftDelete(model: string | undefined): boolean {
|
||||
return model ? SOFT_DELETE_MODELS.includes(model.toLowerCase()) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard delete - actually remove from database
|
||||
*/
|
||||
hardDelete<T>(model: string, where: Record<string, unknown>): Promise<T> {
|
||||
const delegate = this.getModelDelegate(model);
|
||||
return delegate.delete({ where }) as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find including soft deleted records
|
||||
*/
|
||||
findWithDeleted<T>(
|
||||
model: string,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<T[]> {
|
||||
const delegate = this.getModelDelegate(model);
|
||||
return delegate.findMany(args) as Promise<T[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a soft deleted record
|
||||
*/
|
||||
restore<T>(model: string, where: Record<string, unknown>): Promise<T> {
|
||||
const delegate = this.getModelDelegate(model);
|
||||
return delegate.update({
|
||||
where,
|
||||
data: { deletedAt: null },
|
||||
}) as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete - set deletedAt to current date
|
||||
*/
|
||||
softDelete<T>(model: string, where: Record<string, unknown>): Promise<T> {
|
||||
const delegate = this.getModelDelegate(model);
|
||||
return delegate.update({
|
||||
where,
|
||||
data: { deletedAt: new Date() },
|
||||
}) as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find many excluding soft deleted records
|
||||
*/
|
||||
findManyActive<T>(
|
||||
model: string,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<T[]> {
|
||||
const delegate = this.getModelDelegate(model);
|
||||
const whereWithDeleted = {
|
||||
...args,
|
||||
where: {
|
||||
...(args?.where as Record<string, unknown> | undefined),
|
||||
deletedAt: null,
|
||||
},
|
||||
};
|
||||
return delegate.findMany(whereWithDeleted) as Promise<T[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Prisma model delegate by name
|
||||
*/
|
||||
private getModelDelegate(model: string): PrismaDelegate {
|
||||
const modelKey = model.charAt(0).toLowerCase() + model.slice(1);
|
||||
|
||||
return (this as any)[modelKey] as PrismaDelegate;
|
||||
}
|
||||
}
|
||||
6
src/i18n/en/auth.json
Normal file
6
src/i18n/en/auth.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"registered": "User registered successfully",
|
||||
"login_success": "Login successful",
|
||||
"refresh_success": "Token refreshed successfully",
|
||||
"logout_success": "Logout successful"
|
||||
}
|
||||
13
src/i18n/en/common.json
Normal file
13
src/i18n/en/common.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"welcome": "Welcome",
|
||||
"success": "Operation completed successfully",
|
||||
"created": "Resource created successfully",
|
||||
"updated": "Resource updated successfully",
|
||||
"deleted": "Resource deleted successfully",
|
||||
"restored": "Resource restored successfully",
|
||||
"notFound": "Resource not found",
|
||||
"serverError": "An unexpected error occurred",
|
||||
"unauthorized": "You are not authorized to perform this action",
|
||||
"forbidden": "Access denied",
|
||||
"badRequest": "Invalid request"
|
||||
}
|
||||
14
src/i18n/en/errors.json
Normal file
14
src/i18n/en/errors.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"USER_NOT_FOUND": "User not found",
|
||||
"INVALID_CREDENTIALS": "Invalid email or password",
|
||||
"EMAIL_ALREADY_EXISTS": "This email is already registered",
|
||||
"INVALID_REFRESH_TOKEN": "Invalid or expired refresh token",
|
||||
"ACCOUNT_DISABLED": "Your account has been disabled",
|
||||
"TOKEN_EXPIRED": "Your session has expired, please login again",
|
||||
"PERMISSION_DENIED": "You do not have permission to perform this action",
|
||||
"ROLE_NOT_FOUND": "Role not found",
|
||||
"TENANT_NOT_FOUND": "Tenant not found",
|
||||
"VALIDATION_FAILED": "Validation failed",
|
||||
"INTERNAL_ERROR": "An internal error occurred, please try again later",
|
||||
"AUTH_REQUIRED": "Authentication required, please provide a valid token"
|
||||
}
|
||||
18
src/i18n/en/skriptai.json
Normal file
18
src/i18n/en/skriptai.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"PROJECT_CREATED": "Project created successfully",
|
||||
"PROJECT_UPDATED": "Project updated",
|
||||
"PROJECT_DELETED": "Project deleted",
|
||||
"PROJECT_DUPLICATED": "Project duplicated",
|
||||
"SCRIPT_GENERATED": "Script generated successfully",
|
||||
"SCRIPT_REWRITTEN": "Segment rewritten",
|
||||
"RESEARCH_COMPLETE": "Research completed",
|
||||
"SOURCES_ADDED": "Sources added",
|
||||
"BRIEF_UPDATED": "Brief updated",
|
||||
"CHARACTERS_GENERATED": "Characters generated",
|
||||
"LOGLINE_GENERATED": "Logline and high concept generated",
|
||||
"NEURO_ANALYSIS_COMPLETE": "Neuro marketing analysis completed",
|
||||
"YOUTUBE_AUDIT_COMPLETE": "YouTube audit completed",
|
||||
"COMMERCIAL_BRIEF_READY": "Commercial brief ready",
|
||||
"EXPORT_READY": "Export ready",
|
||||
"VISUAL_ASSETS_GENERATED": "Visual assets generated"
|
||||
}
|
||||
23
src/i18n/en/validation.json
Normal file
23
src/i18n/en/validation.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"email": {
|
||||
"required": "Email is required",
|
||||
"invalid": "Please enter a valid email address"
|
||||
},
|
||||
"password": {
|
||||
"required": "Password is required",
|
||||
"minLength": "Password must be at least 8 characters long",
|
||||
"weak": "Password is too weak"
|
||||
},
|
||||
"firstName": {
|
||||
"required": "First name is required"
|
||||
},
|
||||
"lastName": {
|
||||
"required": "Last name is required"
|
||||
},
|
||||
"generic": {
|
||||
"required": "This field is required",
|
||||
"invalid": "Invalid value",
|
||||
"minLength": "Must be at least {min} characters",
|
||||
"maxLength": "Must be at most {max} characters"
|
||||
}
|
||||
}
|
||||
6
src/i18n/tr/auth.json
Normal file
6
src/i18n/tr/auth.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"registered": "Kullanıcı başarıyla kaydedildi",
|
||||
"login_success": "Giriş başarılı",
|
||||
"refresh_success": "Token başarıyla yenilendi",
|
||||
"logout_success": "Çıkış başarılı"
|
||||
}
|
||||
13
src/i18n/tr/common.json
Normal file
13
src/i18n/tr/common.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"welcome": "Hoş geldiniz",
|
||||
"success": "İşlem başarıyla tamamlandı",
|
||||
"created": "Kayıt başarıyla oluşturuldu",
|
||||
"updated": "Kayıt başarıyla güncellendi",
|
||||
"deleted": "Kayıt başarıyla silindi",
|
||||
"restored": "Kayıt başarıyla geri yüklendi",
|
||||
"notFound": "Kayıt bulunamadı",
|
||||
"serverError": "Beklenmeyen bir hata oluştu",
|
||||
"unauthorized": "Bu işlemi yapmaya yetkiniz yok",
|
||||
"forbidden": "Erişim reddedildi",
|
||||
"badRequest": "Geçersiz istek"
|
||||
}
|
||||
14
src/i18n/tr/errors.json
Normal file
14
src/i18n/tr/errors.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"USER_NOT_FOUND": "Kullanıcı bulunamadı",
|
||||
"INVALID_CREDENTIALS": "Geçersiz e-posta veya şifre",
|
||||
"EMAIL_ALREADY_EXISTS": "Bu e-posta adresi zaten kayıtlı",
|
||||
"INVALID_REFRESH_TOKEN": "Geçersiz veya süresi dolmuş yenileme token'ı",
|
||||
"ACCOUNT_DISABLED": "Hesabınız devre dışı bırakılmış",
|
||||
"TOKEN_EXPIRED": "Oturumunuz sona erdi, lütfen tekrar giriş yapın",
|
||||
"PERMISSION_DENIED": "Bu işlemi gerçekleştirme izniniz yok",
|
||||
"ROLE_NOT_FOUND": "Rol bulunamadı",
|
||||
"TENANT_NOT_FOUND": "Kiracı bulunamadı",
|
||||
"VALIDATION_FAILED": "Doğrulama başarısız",
|
||||
"INTERNAL_ERROR": "Bir iç hata oluştu, lütfen daha sonra tekrar deneyin",
|
||||
"AUTH_REQUIRED": "Kimlik doğrulama gerekli, lütfen geçerli bir token sağlayın"
|
||||
}
|
||||
18
src/i18n/tr/skriptai.json
Normal file
18
src/i18n/tr/skriptai.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"PROJECT_CREATED": "Proje başarıyla oluşturuldu",
|
||||
"PROJECT_UPDATED": "Proje güncellendi",
|
||||
"PROJECT_DELETED": "Proje silindi",
|
||||
"PROJECT_DUPLICATED": "Proje kopyalandı",
|
||||
"SCRIPT_GENERATED": "Script başarıyla oluşturuldu",
|
||||
"SCRIPT_REWRITTEN": "Segment yeniden yazıldı",
|
||||
"RESEARCH_COMPLETE": "Araştırma tamamlandı",
|
||||
"SOURCES_ADDED": "Kaynaklar eklendi",
|
||||
"BRIEF_UPDATED": "Brief güncellendi",
|
||||
"CHARACTERS_GENERATED": "Karakterler oluşturuldu",
|
||||
"LOGLINE_GENERATED": "Logline ve high concept oluşturuldu",
|
||||
"NEURO_ANALYSIS_COMPLETE": "Nöro pazarlama analizi tamamlandı",
|
||||
"YOUTUBE_AUDIT_COMPLETE": "YouTube denetimi tamamlandı",
|
||||
"COMMERCIAL_BRIEF_READY": "Ticari brief hazır",
|
||||
"EXPORT_READY": "Dışa aktarım hazır",
|
||||
"VISUAL_ASSETS_GENERATED": "Görsel varlıklar oluşturuldu"
|
||||
}
|
||||
23
src/i18n/tr/validation.json
Normal file
23
src/i18n/tr/validation.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"email": {
|
||||
"required": "E-posta adresi gereklidir",
|
||||
"invalid": "Lütfen geçerli bir e-posta adresi girin"
|
||||
},
|
||||
"password": {
|
||||
"required": "Şifre gereklidir",
|
||||
"minLength": "Şifre en az 8 karakter olmalıdır",
|
||||
"weak": "Şifre çok zayıf"
|
||||
},
|
||||
"firstName": {
|
||||
"required": "Ad gereklidir"
|
||||
},
|
||||
"lastName": {
|
||||
"required": "Soyad gereklidir"
|
||||
},
|
||||
"generic": {
|
||||
"required": "Bu alan gereklidir",
|
||||
"invalid": "Geçersiz değer",
|
||||
"minLength": "En az {min} karakter olmalıdır",
|
||||
"maxLength": "En fazla {max} karakter olmalıdır"
|
||||
}
|
||||
}
|
||||
90
src/main.ts
Normal file
90
src/main.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe, Logger as NestLogger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module';
|
||||
import helmet from 'helmet';
|
||||
import { Logger, LoggerErrorInterceptor } from 'nestjs-pino';
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new NestLogger('Bootstrap');
|
||||
|
||||
logger.log('🔄 Starting application...');
|
||||
|
||||
const app = await NestFactory.create(AppModule, { bufferLogs: true });
|
||||
|
||||
// Use Pino Logger
|
||||
app.useLogger(app.get(Logger));
|
||||
app.useGlobalInterceptors(new LoggerErrorInterceptor());
|
||||
|
||||
// Security Headers
|
||||
app.use(helmet());
|
||||
|
||||
// Graceful Shutdown (Prisma & Docker)
|
||||
app.enableShutdownHooks();
|
||||
|
||||
// Get config service
|
||||
const configService = app.get(ConfigService);
|
||||
const port = configService.get<number>('PORT', 3000);
|
||||
const nodeEnv = configService.get('NODE_ENV', 'development');
|
||||
|
||||
// Enable CORS
|
||||
app.enableCors({
|
||||
origin: true,
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// Global prefix
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
// Validation pipe (Strict)
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transformOptions: {
|
||||
enableImplicitConversion: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Swagger setup
|
||||
const swaggerConfig = new DocumentBuilder()
|
||||
.setTitle('TypeScript Boilerplate API')
|
||||
.setDescription(
|
||||
'Senior-level NestJS backend boilerplate with generic CRUD, authentication, i18n, and Redis caching',
|
||||
)
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.addTag('Auth', 'Authentication endpoints')
|
||||
.addTag('Users', 'User management endpoints')
|
||||
.addTag('Admin', 'Admin management endpoints')
|
||||
.addTag('Health', 'Health check endpoints')
|
||||
.build();
|
||||
|
||||
logger.log('Initializing Swagger...');
|
||||
const document = SwaggerModule.createDocument(app, swaggerConfig);
|
||||
SwaggerModule.setup('api/docs', app, document, {
|
||||
swaggerOptions: {
|
||||
persistAuthorization: true,
|
||||
},
|
||||
});
|
||||
logger.log('Swagger initialized');
|
||||
|
||||
logger.log(`Attempting to listen on port ${port}...`);
|
||||
await app.listen(port, '0.0.0.0');
|
||||
|
||||
logger.log('═══════════════════════════════════════════════════════════');
|
||||
logger.log(`🚀 Server is running on: http://localhost:${port}/api`);
|
||||
logger.log(`📚 Swagger documentation: http://localhost:${port}/api/docs`);
|
||||
logger.log(`💚 Health check: http://localhost:${port}/api/health`);
|
||||
logger.log(`🌍 Environment: ${nodeEnv.toUpperCase()}`);
|
||||
logger.log('═══════════════════════════════════════════════════════════');
|
||||
|
||||
if (nodeEnv === 'development') {
|
||||
logger.warn('⚠️ Running in development mode');
|
||||
}
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
270
src/modules/admin/admin.controller.ts
Normal file
270
src/modules/admin/admin.controller.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
Query,
|
||||
UseInterceptors,
|
||||
Inject,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
CacheInterceptor,
|
||||
CacheKey,
|
||||
CacheTTL,
|
||||
CACHE_MANAGER,
|
||||
} from '@nestjs/cache-manager';
|
||||
import * as cacheManager from 'cache-manager';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import { Roles } from '../../common/decorators';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { PaginationDto } from '../../common/dto/pagination.dto';
|
||||
import {
|
||||
ApiResponse,
|
||||
createSuccessResponse,
|
||||
createPaginatedResponse,
|
||||
PaginatedData,
|
||||
} from '../../common/types/api-response.type';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { UserResponseDto } from '../users/dto/user.dto';
|
||||
import {
|
||||
PermissionResponseDto,
|
||||
RolePermissionResponseDto,
|
||||
RoleResponseDto,
|
||||
UserRoleResponseDto,
|
||||
} from './dto/admin.dto';
|
||||
|
||||
@ApiTags('Admin')
|
||||
@ApiBearerAuth()
|
||||
@Controller('admin')
|
||||
@Roles('admin')
|
||||
export class AdminController {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(CACHE_MANAGER) private cacheManager: cacheManager.Cache,
|
||||
) {}
|
||||
|
||||
// ================== Users Management ==================
|
||||
|
||||
@Get('users')
|
||||
@ApiOperation({ summary: 'Get all users (admin)' })
|
||||
async getAllUsers(
|
||||
@Query() pagination: PaginationDto,
|
||||
): Promise<ApiResponse<PaginatedData<UserResponseDto>>> {
|
||||
const { skip, take, orderBy } = pagination;
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
this.prisma.user.findMany({
|
||||
skip,
|
||||
take,
|
||||
orderBy,
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.user.count(),
|
||||
]);
|
||||
|
||||
const dtos = plainToInstance(
|
||||
UserResponseDto,
|
||||
users,
|
||||
) as unknown as UserResponseDto[];
|
||||
|
||||
return createPaginatedResponse(
|
||||
dtos,
|
||||
total,
|
||||
pagination.page || 1,
|
||||
pagination.limit || 10,
|
||||
);
|
||||
}
|
||||
|
||||
@Put('users/:id/toggle-active')
|
||||
@ApiOperation({ summary: 'Toggle user active status' })
|
||||
async toggleUserActive(
|
||||
@Param('id') id: string,
|
||||
): Promise<ApiResponse<UserResponseDto>> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||
|
||||
const updated = await this.prisma.user.update({
|
||||
where: { id },
|
||||
data: { isActive: !user?.isActive },
|
||||
});
|
||||
|
||||
return createSuccessResponse(
|
||||
plainToInstance(UserResponseDto, updated),
|
||||
'User status updated',
|
||||
);
|
||||
}
|
||||
|
||||
@Post('users/:userId/roles/:roleId')
|
||||
@ApiOperation({ summary: 'Assign role to user' })
|
||||
async assignRole(
|
||||
@Param('userId') userId: string,
|
||||
@Param('roleId') roleId: string,
|
||||
): Promise<ApiResponse<UserRoleResponseDto>> {
|
||||
const userRole = await this.prisma.userRole.create({
|
||||
data: { userId, roleId },
|
||||
});
|
||||
return createSuccessResponse(
|
||||
plainToInstance(UserRoleResponseDto, userRole),
|
||||
'Role assigned to user',
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('users/:userId/roles/:roleId')
|
||||
@ApiOperation({ summary: 'Remove role from user' })
|
||||
async removeRole(
|
||||
@Param('userId') userId: string,
|
||||
@Param('roleId') roleId: string,
|
||||
): Promise<ApiResponse<null>> {
|
||||
await this.prisma.userRole.deleteMany({
|
||||
where: { userId, roleId },
|
||||
});
|
||||
return createSuccessResponse(null, 'Role removed from user');
|
||||
}
|
||||
|
||||
// ================== Roles Management ==================
|
||||
|
||||
@Get('roles')
|
||||
@UseInterceptors(CacheInterceptor)
|
||||
@CacheKey('roles_list')
|
||||
@CacheTTL(60 * 1000)
|
||||
@ApiOperation({ summary: 'Get all roles' })
|
||||
async getAllRoles(): Promise<ApiResponse<RoleResponseDto[]>> {
|
||||
const roles = await this.prisma.role.findMany({
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: { users: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Transform Prisma structure to DTO structure
|
||||
const transformedRoles = roles.map((role) => ({
|
||||
...role,
|
||||
permissions: role.permissions.map((rp) => rp.permission),
|
||||
}));
|
||||
|
||||
return createSuccessResponse(
|
||||
plainToInstance(
|
||||
RoleResponseDto,
|
||||
transformedRoles,
|
||||
) as unknown as RoleResponseDto[],
|
||||
);
|
||||
}
|
||||
|
||||
@Post('roles')
|
||||
@ApiOperation({ summary: 'Create a new role' })
|
||||
async createRole(
|
||||
@Body() data: { name: string; description?: string },
|
||||
): Promise<ApiResponse<RoleResponseDto>> {
|
||||
const role = await this.prisma.role.create({ data });
|
||||
await this.cacheManager.del('roles_list');
|
||||
return createSuccessResponse(
|
||||
plainToInstance(RoleResponseDto, role),
|
||||
'Role created',
|
||||
201,
|
||||
);
|
||||
}
|
||||
|
||||
@Put('roles/:id')
|
||||
@ApiOperation({ summary: 'Update a role' })
|
||||
async updateRole(
|
||||
@Param('id') id: string,
|
||||
@Body() data: { name?: string; description?: string },
|
||||
): Promise<ApiResponse<RoleResponseDto>> {
|
||||
const role = await this.prisma.role.update({ where: { id }, data });
|
||||
await this.cacheManager.del('roles_list');
|
||||
return createSuccessResponse(
|
||||
plainToInstance(RoleResponseDto, role),
|
||||
'Role updated',
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('roles/:id')
|
||||
@ApiOperation({ summary: 'Delete a role' })
|
||||
async deleteRole(@Param('id') id: string): Promise<ApiResponse<null>> {
|
||||
await this.prisma.role.delete({ where: { id } });
|
||||
await this.cacheManager.del('roles_list');
|
||||
return createSuccessResponse(null, 'Role deleted');
|
||||
}
|
||||
|
||||
// ================== Permissions Management ==================
|
||||
|
||||
@Get('permissions')
|
||||
@UseInterceptors(CacheInterceptor)
|
||||
@CacheKey('permissions_list')
|
||||
@CacheTTL(60 * 1000)
|
||||
@ApiOperation({ summary: 'Get all permissions' })
|
||||
async getAllPermissions(): Promise<ApiResponse<PermissionResponseDto[]>> {
|
||||
const permissions = await this.prisma.permission.findMany();
|
||||
return createSuccessResponse(
|
||||
plainToInstance(
|
||||
PermissionResponseDto,
|
||||
permissions,
|
||||
) as unknown as PermissionResponseDto[],
|
||||
);
|
||||
}
|
||||
|
||||
@Post('permissions')
|
||||
@ApiOperation({ summary: 'Create a new permission' })
|
||||
async createPermission(
|
||||
@Body()
|
||||
data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
resource: string;
|
||||
action: string;
|
||||
},
|
||||
): Promise<ApiResponse<PermissionResponseDto>> {
|
||||
const permission = await this.prisma.permission.create({ data });
|
||||
await this.cacheManager.del('permissions_list');
|
||||
return createSuccessResponse(
|
||||
plainToInstance(PermissionResponseDto, permission),
|
||||
'Permission created',
|
||||
201,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('roles/:roleId/permissions/:permissionId')
|
||||
@ApiOperation({ summary: 'Assign permission to role' })
|
||||
async assignPermission(
|
||||
@Param('roleId') roleId: string,
|
||||
@Param('permissionId') permissionId: string,
|
||||
): Promise<ApiResponse<RolePermissionResponseDto>> {
|
||||
const rolePermission = await this.prisma.rolePermission.create({
|
||||
data: { roleId, permissionId },
|
||||
});
|
||||
// Invalidate roles_list because permissions are nested in roles
|
||||
await this.cacheManager.del('roles_list');
|
||||
return createSuccessResponse(
|
||||
plainToInstance(RolePermissionResponseDto, rolePermission),
|
||||
'Permission assigned to role',
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('roles/:roleId/permissions/:permissionId')
|
||||
@ApiOperation({ summary: 'Remove permission from role' })
|
||||
async removePermission(
|
||||
@Param('roleId') roleId: string,
|
||||
@Param('permissionId') permissionId: string,
|
||||
): Promise<ApiResponse<null>> {
|
||||
await this.prisma.rolePermission.deleteMany({
|
||||
where: { roleId, permissionId },
|
||||
});
|
||||
// Invalidate roles_list because permissions are nested in roles
|
||||
await this.cacheManager.del('roles_list');
|
||||
return createSuccessResponse(null, 'Permission removed from role');
|
||||
}
|
||||
}
|
||||
7
src/modules/admin/admin.module.ts
Normal file
7
src/modules/admin/admin.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminController } from './admin.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [AdminController],
|
||||
})
|
||||
export class AdminModule {}
|
||||
71
src/modules/admin/dto/admin.dto.ts
Normal file
71
src/modules/admin/dto/admin.dto.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Exclude, Expose, Type } from 'class-transformer';
|
||||
|
||||
@Exclude()
|
||||
export class PermissionResponseDto {
|
||||
@Expose()
|
||||
id: string;
|
||||
|
||||
@Expose()
|
||||
name: string;
|
||||
|
||||
@Expose()
|
||||
description: string | null;
|
||||
|
||||
@Expose()
|
||||
resource: string;
|
||||
|
||||
@Expose()
|
||||
action: string;
|
||||
|
||||
@Expose()
|
||||
createdAt: Date;
|
||||
|
||||
@Expose()
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
@Exclude()
|
||||
export class RoleResponseDto {
|
||||
@Expose()
|
||||
id: string;
|
||||
|
||||
@Expose()
|
||||
name: string;
|
||||
|
||||
@Expose()
|
||||
description: string | null;
|
||||
|
||||
@Expose()
|
||||
@Type(() => PermissionResponseDto)
|
||||
permissions?: PermissionResponseDto[];
|
||||
|
||||
@Expose()
|
||||
createdAt: Date;
|
||||
|
||||
@Expose()
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
@Exclude()
|
||||
export class UserRoleResponseDto {
|
||||
@Expose()
|
||||
userId: string;
|
||||
|
||||
@Expose()
|
||||
roleId: string;
|
||||
|
||||
@Expose()
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@Exclude()
|
||||
export class RolePermissionResponseDto {
|
||||
@Expose()
|
||||
roleId: string;
|
||||
|
||||
@Expose()
|
||||
permissionId: string;
|
||||
|
||||
@Expose()
|
||||
createdAt: Date;
|
||||
}
|
||||
78
src/modules/auth/auth.controller.ts
Normal file
78
src/modules/auth/auth.controller.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Controller, Post, Body, HttpCode } from '@nestjs/common';
|
||||
import { I18n, I18nContext } from 'nestjs-i18n';
|
||||
import { ApiTags, ApiOperation, ApiOkResponse } from '@nestjs/swagger';
|
||||
import { AuthService } from './auth.service';
|
||||
import {
|
||||
RegisterDto,
|
||||
LoginDto,
|
||||
RefreshTokenDto,
|
||||
TokenResponseDto,
|
||||
} from './dto/auth.dto';
|
||||
import { Public } from '../../common/decorators';
|
||||
import {
|
||||
ApiResponse,
|
||||
createSuccessResponse,
|
||||
} from '../../common/types/api-response.type';
|
||||
|
||||
@ApiTags('Auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
@Public()
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Register a new user' })
|
||||
@ApiOkResponse({
|
||||
description: 'User registered successfully',
|
||||
type: TokenResponseDto,
|
||||
})
|
||||
async register(
|
||||
@Body() dto: RegisterDto,
|
||||
@I18n() i18n: I18nContext,
|
||||
): Promise<ApiResponse<TokenResponseDto>> {
|
||||
const result = await this.authService.register(dto);
|
||||
return createSuccessResponse(result, i18n.t('auth.registered'), 201);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@Public()
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Login with email and password' })
|
||||
@ApiOkResponse({ description: 'Login successful', type: TokenResponseDto })
|
||||
async login(
|
||||
@Body() dto: LoginDto,
|
||||
@I18n() i18n: I18nContext,
|
||||
): Promise<ApiResponse<TokenResponseDto>> {
|
||||
const result = await this.authService.login(dto);
|
||||
return createSuccessResponse(result, i18n.t('auth.login_success'));
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@Public()
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Refresh access token' })
|
||||
@ApiOkResponse({
|
||||
description: 'Token refreshed successfully',
|
||||
type: TokenResponseDto,
|
||||
})
|
||||
async refreshToken(
|
||||
@Body() dto: RefreshTokenDto,
|
||||
@I18n() i18n: I18nContext,
|
||||
): Promise<ApiResponse<TokenResponseDto>> {
|
||||
const result = await this.authService.refreshToken(dto.refreshToken);
|
||||
return createSuccessResponse(result, i18n.t('auth.refresh_success'));
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Logout and invalidate refresh token' })
|
||||
@ApiOkResponse({ description: 'Logout successful' })
|
||||
async logout(
|
||||
@Body() dto: RefreshTokenDto,
|
||||
@I18n() i18n: I18nContext,
|
||||
): Promise<ApiResponse<null>> {
|
||||
await this.authService.logout(dto.refreshToken);
|
||||
return createSuccessResponse(null, i18n.t('auth.logout_success'));
|
||||
}
|
||||
}
|
||||
37
src/modules/auth/auth.module.ts
Normal file
37
src/modules/auth/auth.module.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule, JwtModuleOptions } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { JwtAuthGuard, RolesGuard, PermissionsGuard } from './guards';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService): JwtModuleOptions => {
|
||||
const expiresIn =
|
||||
configService.get<string>('JWT_ACCESS_EXPIRATION') || '15m';
|
||||
return {
|
||||
secret: configService.get<string>('JWT_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: expiresIn as any,
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
JwtStrategy,
|
||||
JwtAuthGuard,
|
||||
RolesGuard,
|
||||
PermissionsGuard,
|
||||
],
|
||||
exports: [AuthService, JwtAuthGuard, RolesGuard, PermissionsGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
336
src/modules/auth/auth.service.ts
Normal file
336
src/modules/auth/auth.service.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import {
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import * as crypto from 'crypto';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { RegisterDto, LoginDto, TokenResponseDto } from './dto/auth.dto';
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
interface UserWithRoles {
|
||||
id: string;
|
||||
email: string;
|
||||
password: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
isActive: boolean;
|
||||
tenantId: string | null;
|
||||
roles: Array<{
|
||||
role: {
|
||||
name: string;
|
||||
permissions: Array<{
|
||||
permission: {
|
||||
name: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
*/
|
||||
async register(dto: RegisterDto): Promise<TokenResponseDto> {
|
||||
// Check if email already exists
|
||||
const existingUser = await this.prisma.user.findUnique({
|
||||
where: { email: dto.email },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
throw new ConflictException('EMAIL_ALREADY_EXISTS');
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await this.hashPassword(dto.password);
|
||||
|
||||
// Create user with default role
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
email: dto.email,
|
||||
password: hashedPassword,
|
||||
firstName: dto.firstName,
|
||||
lastName: dto.lastName,
|
||||
roles: {
|
||||
create: {
|
||||
role: {
|
||||
connectOrCreate: {
|
||||
where: { name: 'user' },
|
||||
create: { name: 'user', description: 'Default user role' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return this.generateTokens(user as unknown as UserWithRoles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with email and password
|
||||
*/
|
||||
async login(dto: LoginDto): Promise<TokenResponseDto> {
|
||||
// Find user by email
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { email: dto.email },
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('INVALID_CREDENTIALS');
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isPasswordValid = await this.comparePassword(
|
||||
dto.password,
|
||||
user.password,
|
||||
);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
throw new UnauthorizedException('INVALID_CREDENTIALS');
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
throw new UnauthorizedException('ACCOUNT_DISABLED');
|
||||
}
|
||||
|
||||
return this.generateTokens(user as unknown as UserWithRoles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh token
|
||||
*/
|
||||
async refreshToken(refreshToken: string): Promise<TokenResponseDto> {
|
||||
// Find refresh token
|
||||
const storedToken = await this.prisma.refreshToken.findUnique({
|
||||
where: { token: refreshToken },
|
||||
include: {
|
||||
user: {
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!storedToken) {
|
||||
throw new UnauthorizedException('INVALID_REFRESH_TOKEN');
|
||||
}
|
||||
|
||||
if (storedToken.expiresAt < new Date()) {
|
||||
// Delete expired token
|
||||
await this.prisma.refreshToken.delete({
|
||||
where: { id: storedToken.id },
|
||||
});
|
||||
throw new UnauthorizedException('INVALID_REFRESH_TOKEN');
|
||||
}
|
||||
|
||||
// Delete old refresh token
|
||||
await this.prisma.refreshToken.delete({
|
||||
where: { id: storedToken.id },
|
||||
});
|
||||
|
||||
return this.generateTokens(storedToken.user as unknown as UserWithRoles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout - invalidate refresh token
|
||||
*/
|
||||
async logout(refreshToken: string): Promise<void> {
|
||||
await this.prisma.refreshToken.deleteMany({
|
||||
where: { token: refreshToken },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate user by ID (used by JWT strategy)
|
||||
*/
|
||||
async validateUser(userId: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove password from user object
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { password: _, ...result } = user;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate access and refresh tokens
|
||||
*/
|
||||
private async generateTokens(user: UserWithRoles): Promise<TokenResponseDto> {
|
||||
// Extract roles and permissions
|
||||
const roles = user.roles.map((ur) => ur.role.name);
|
||||
const permissions = user.roles.flatMap((ur) =>
|
||||
ur.role.permissions.map((rp) => rp.permission.name),
|
||||
);
|
||||
|
||||
const payload: JwtPayload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
roles,
|
||||
permissions,
|
||||
tenantId: user.tenantId || undefined,
|
||||
};
|
||||
|
||||
// Generate access token
|
||||
const accessToken = this.jwtService.sign(payload, {
|
||||
expiresIn: this.configService.get('JWT_ACCESS_EXPIRATION', '15m'),
|
||||
});
|
||||
|
||||
// Generate refresh token
|
||||
const refreshTokenValue = crypto.randomUUID();
|
||||
const refreshExpiration = this.parseExpiration(
|
||||
this.configService.get('JWT_REFRESH_EXPIRATION', '7d'),
|
||||
);
|
||||
|
||||
// Store refresh token
|
||||
await this.prisma.refreshToken.create({
|
||||
data: {
|
||||
token: refreshTokenValue,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + refreshExpiration),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: refreshTokenValue,
|
||||
expiresIn:
|
||||
this.parseExpiration(
|
||||
this.configService.get('JWT_ACCESS_EXPIRATION', '15m'),
|
||||
) / 1000, // Convert to seconds
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName || undefined,
|
||||
lastName: user.lastName || undefined,
|
||||
roles,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash password using bcrypt
|
||||
*/
|
||||
private async hashPassword(password: string): Promise<string> {
|
||||
const saltRounds = 12;
|
||||
return bcrypt.hash(password, saltRounds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare password with hash
|
||||
*/
|
||||
private async comparePassword(
|
||||
password: string,
|
||||
hashedPassword: string,
|
||||
): Promise<boolean> {
|
||||
return bcrypt.compare(password, hashedPassword);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse expiration string to milliseconds
|
||||
*/
|
||||
private parseExpiration(expiration: string): number {
|
||||
const match = expiration.match(/^(\d+)([smhd])$/);
|
||||
if (!match) {
|
||||
return 15 * 60 * 1000; // Default 15 minutes
|
||||
}
|
||||
|
||||
const value = parseInt(match[1], 10);
|
||||
const unit = match[2];
|
||||
|
||||
switch (unit) {
|
||||
case 's':
|
||||
return value * 1000;
|
||||
case 'm':
|
||||
return value * 60 * 1000;
|
||||
case 'h':
|
||||
return value * 60 * 60 * 1000;
|
||||
case 'd':
|
||||
return value * 24 * 60 * 60 * 1000;
|
||||
default:
|
||||
return 15 * 60 * 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
70
src/modules/auth/dto/auth.dto.ts
Normal file
70
src/modules/auth/dto/auth.dto.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class RegisterDto {
|
||||
@ApiProperty({ example: 'user@example.com' })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: 'password123', minLength: 8 })
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'John' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
firstName?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Doe' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
lastName?: string;
|
||||
}
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty({ example: 'user@example.com' })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: 'password123' })
|
||||
@IsString()
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export class UserInfoDto {
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
firstName?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
lastName?: string;
|
||||
|
||||
@ApiProperty()
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
export class TokenResponseDto {
|
||||
@ApiProperty()
|
||||
accessToken: string;
|
||||
|
||||
@ApiProperty()
|
||||
refreshToken: string;
|
||||
|
||||
@ApiProperty()
|
||||
expiresIn: number;
|
||||
|
||||
@ApiProperty({ type: UserInfoDto })
|
||||
user: UserInfoDto;
|
||||
}
|
||||
129
src/modules/auth/guards/auth.guards.ts
Normal file
129
src/modules/auth/guards/auth.guards.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Request } from 'express';
|
||||
import {
|
||||
IS_PUBLIC_KEY,
|
||||
ROLES_KEY,
|
||||
PERMISSIONS_KEY,
|
||||
} from '../../../common/decorators';
|
||||
|
||||
interface AuthenticatedUser {
|
||||
id: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT Auth Guard - Validates JWT token
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
constructor(private reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext) {
|
||||
// Check if route is public
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.canActivate(context);
|
||||
}
|
||||
|
||||
handleRequest<TUser = AuthenticatedUser>(
|
||||
err: Error | null,
|
||||
user: TUser | false,
|
||||
info: any,
|
||||
): TUser {
|
||||
if (err || !user) {
|
||||
if (info?.name === 'TokenExpiredError') {
|
||||
throw new UnauthorizedException('TOKEN_EXPIRED');
|
||||
}
|
||||
throw err || new UnauthorizedException('AUTH_REQUIRED');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Roles Guard - Check if user has required roles
|
||||
*/
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<string[]>(
|
||||
ROLES_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
if (!requiredRoles || requiredRoles.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const user = request.user as AuthenticatedUser | undefined;
|
||||
|
||||
if (!user || !user.roles) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasRole = requiredRoles.some((role) => user.roles.includes(role));
|
||||
if (!hasRole) {
|
||||
throw new ForbiddenException('PERMISSION_DENIED');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Permissions Guard - Check if user has required permissions
|
||||
*/
|
||||
@Injectable()
|
||||
export class PermissionsGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredPermissions = this.reflector.getAllAndOverride<string[]>(
|
||||
PERMISSIONS_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
if (!requiredPermissions || requiredPermissions.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const user = request.user as AuthenticatedUser | undefined;
|
||||
|
||||
if (!user || !user.permissions) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasPermission = requiredPermissions.every((permission) =>
|
||||
user.permissions.includes(permission),
|
||||
);
|
||||
|
||||
if (!hasPermission) {
|
||||
throw new ForbiddenException('PERMISSION_DENIED');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
1
src/modules/auth/guards/index.ts
Normal file
1
src/modules/auth/guards/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './auth.guards';
|
||||
38
src/modules/auth/strategies/jwt.strategy.ts
Normal file
38
src/modules/auth/strategies/jwt.strategy.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AuthService, JwtPayload } from '../auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly authService: AuthService,
|
||||
) {
|
||||
const secret = configService.get<string>('JWT_SECRET');
|
||||
if (!secret) {
|
||||
throw new Error('JWT_SECRET is not defined');
|
||||
}
|
||||
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: secret,
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload) {
|
||||
const user = await this.authService.validateUser(payload.sub);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...user,
|
||||
roles: payload.roles,
|
||||
permissions: payload.permissions,
|
||||
};
|
||||
}
|
||||
}
|
||||
7
src/modules/gemini/gemini.config.ts
Normal file
7
src/modules/gemini/gemini.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export const geminiConfig = registerAs('gemini', () => ({
|
||||
enabled: process.env.ENABLE_GEMINI === 'true',
|
||||
apiKey: process.env.GOOGLE_API_KEY,
|
||||
defaultModel: process.env.GEMINI_MODEL || 'gemini-2.5-flash',
|
||||
}));
|
||||
18
src/modules/gemini/gemini.module.ts
Normal file
18
src/modules/gemini/gemini.module.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { GeminiService } from './gemini.service';
|
||||
import { geminiConfig } from './gemini.config';
|
||||
|
||||
/**
|
||||
* Gemini AI Module
|
||||
*
|
||||
* Optional module for AI-powered features using Google Gemini API.
|
||||
* Enable by setting ENABLE_GEMINI=true in your .env file.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [ConfigModule.forFeature(geminiConfig)],
|
||||
providers: [GeminiService],
|
||||
exports: [GeminiService],
|
||||
})
|
||||
export class GeminiModule {}
|
||||
282
src/modules/gemini/gemini.service.ts
Normal file
282
src/modules/gemini/gemini.service.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { GoogleGenAI } from '@google/genai';
|
||||
|
||||
export interface GeminiGenerateOptions {
|
||||
model?: string;
|
||||
systemPrompt?: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
tools?: any[];
|
||||
}
|
||||
|
||||
export interface GeminiChatMessage {
|
||||
role: 'user' | 'model';
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini AI Service
|
||||
*
|
||||
* Provides AI-powered text generation using Google Gemini API.
|
||||
* This service is globally available when ENABLE_GEMINI=true.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Simple text generation
|
||||
* const response = await geminiService.generateText('Write a poem about coding');
|
||||
*
|
||||
* // With options
|
||||
* const response = await geminiService.generateText('Translate to Turkish', {
|
||||
* temperature: 0.7,
|
||||
* systemPrompt: 'You are a professional translator',
|
||||
* });
|
||||
*
|
||||
* // Chat conversation
|
||||
* const messages = [
|
||||
* { role: 'user', content: 'Hello!' },
|
||||
* { role: 'model', content: 'Hi there!' },
|
||||
* { role: 'user', content: 'What is 2+2?' },
|
||||
* ];
|
||||
* const response = await geminiService.chat(messages);
|
||||
* ```
|
||||
*/
|
||||
@Injectable()
|
||||
export class GeminiService implements OnModuleInit {
|
||||
private readonly logger = new Logger(GeminiService.name);
|
||||
private client: GoogleGenAI | null = null;
|
||||
private isEnabled = false;
|
||||
private defaultModel: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.isEnabled = this.configService.get<boolean>('gemini.enabled', false);
|
||||
this.defaultModel = this.configService.get<string>(
|
||||
'gemini.defaultModel',
|
||||
'gemini-2.5-flash',
|
||||
);
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
if (!this.isEnabled) {
|
||||
this.logger.log(
|
||||
'Gemini AI is disabled. Set ENABLE_GEMINI=true to enable.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKey = this.configService.get<string>('gemini.apiKey');
|
||||
if (!apiKey) {
|
||||
this.logger.warn(
|
||||
'GOOGLE_API_KEY is not set. Gemini features will not work.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.client = new GoogleGenAI({ apiKey });
|
||||
this.logger.log('✅ Gemini AI initialized successfully');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to initialize Gemini AI', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Gemini is available and properly configured
|
||||
*/
|
||||
isAvailable(): boolean {
|
||||
return this.isEnabled && this.client !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate text content from a prompt
|
||||
*
|
||||
* @param prompt - The text prompt to send to the AI
|
||||
* @param options - Optional configuration for the generation
|
||||
* @returns Generated text response
|
||||
*/
|
||||
async generateText(
|
||||
prompt: string,
|
||||
options: GeminiGenerateOptions = {},
|
||||
): Promise<{ text: string; usage?: any }> {
|
||||
if (!this.isAvailable()) {
|
||||
throw new Error('Gemini AI is not available. Check your configuration.');
|
||||
}
|
||||
|
||||
const model = options.model || this.defaultModel;
|
||||
|
||||
try {
|
||||
const contents: any[] = [];
|
||||
|
||||
// Add system prompt if provided
|
||||
if (options.systemPrompt) {
|
||||
contents.push({
|
||||
role: 'user',
|
||||
parts: [{ text: options.systemPrompt }],
|
||||
});
|
||||
contents.push({
|
||||
role: 'model',
|
||||
parts: [{ text: 'Understood. I will follow these instructions.' }],
|
||||
});
|
||||
}
|
||||
|
||||
contents.push({
|
||||
role: 'user',
|
||||
parts: [{ text: prompt }],
|
||||
});
|
||||
|
||||
const response = await this.client!.models.generateContent({
|
||||
model,
|
||||
contents,
|
||||
config: {
|
||||
temperature: options.temperature,
|
||||
maxOutputTokens: options.maxTokens,
|
||||
tools: options.tools,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
text: (response.text || '').trim(),
|
||||
usage: response.usageMetadata,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Gemini generation failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Have a multi-turn chat conversation
|
||||
*
|
||||
* @param messages - Array of chat messages
|
||||
* @param options - Optional configuration for the generation
|
||||
* @returns Generated text response
|
||||
*/
|
||||
async chat(
|
||||
messages: GeminiChatMessage[],
|
||||
options: GeminiGenerateOptions = {},
|
||||
): Promise<{ text: string; usage?: any }> {
|
||||
if (!this.isAvailable()) {
|
||||
throw new Error('Gemini AI is not available. Check your configuration.');
|
||||
}
|
||||
|
||||
const model = options.model || this.defaultModel;
|
||||
|
||||
try {
|
||||
const contents = messages.map((msg) => ({
|
||||
role: msg.role,
|
||||
parts: [{ text: msg.content }],
|
||||
}));
|
||||
|
||||
// Prepend system prompt if provided
|
||||
if (options.systemPrompt) {
|
||||
contents.unshift(
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: options.systemPrompt }],
|
||||
},
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: 'Understood. I will follow these instructions.' }],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const response = await this.client!.models.generateContent({
|
||||
model,
|
||||
contents,
|
||||
config: {
|
||||
temperature: options.temperature,
|
||||
maxOutputTokens: options.maxTokens,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
text: (response.text || '').trim(),
|
||||
usage: response.usageMetadata,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Gemini chat failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate structured JSON output
|
||||
*
|
||||
* @param prompt - The prompt describing what JSON to generate
|
||||
* @param schema - JSON schema description for the expected output
|
||||
* @param options - Optional configuration for the generation
|
||||
* @returns Parsed JSON object
|
||||
*/
|
||||
async generateJSON<T = any>(
|
||||
prompt: string,
|
||||
schema: string,
|
||||
options: GeminiGenerateOptions = {},
|
||||
): Promise<{ data: T; usage?: any }> {
|
||||
const fullPrompt = `${prompt}
|
||||
|
||||
Output the result as valid JSON that matches this schema:
|
||||
${schema}
|
||||
|
||||
IMPORTANT: Only output valid JSON, no markdown code blocks or other text.`;
|
||||
|
||||
const response = await this.generateText(fullPrompt, options);
|
||||
|
||||
try {
|
||||
// Try to extract JSON from the response
|
||||
let jsonStr = response.text;
|
||||
|
||||
// Remove potential markdown code blocks
|
||||
const jsonMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||
if (jsonMatch) {
|
||||
jsonStr = jsonMatch[1].trim();
|
||||
}
|
||||
|
||||
const data = JSON.parse(jsonStr) as T;
|
||||
return { data, usage: response.usage };
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to parse JSON response', error);
|
||||
throw new Error('Failed to parse AI response as JSON');
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Generate an image using Google Imagen (Nano Banana)
|
||||
*
|
||||
* @param prompt - Image description
|
||||
* @returns Base64 encoded image data URI
|
||||
*/
|
||||
async generateImage(prompt: string): Promise<string> {
|
||||
if (!this.isAvailable()) {
|
||||
throw new Error('Gemini AI is not available. Check your configuration.');
|
||||
}
|
||||
|
||||
try {
|
||||
// Use Imagen 3.0 (Nano Banana Pro)
|
||||
const model = 'imagen-3.0-generate-001';
|
||||
|
||||
const response = (await this.client!.models.generateImages({
|
||||
model,
|
||||
prompt,
|
||||
config: {
|
||||
numberOfImages: 1,
|
||||
aspectRatio: '16:9',
|
||||
},
|
||||
})) as any;
|
||||
|
||||
if (
|
||||
response.images &&
|
||||
response.images.length > 0 &&
|
||||
response.images[0].image
|
||||
) {
|
||||
// Return as Data URI
|
||||
return `data:image/png;base64,${response.images[0].image}`;
|
||||
}
|
||||
|
||||
throw new Error('No image returned from Gemini');
|
||||
} catch (error) {
|
||||
this.logger.error('Gemini image generation failed', error);
|
||||
// Fallback or rethrow
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
3
src/modules/gemini/index.ts
Normal file
3
src/modules/gemini/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './gemini.module';
|
||||
export * from './gemini.service';
|
||||
export * from './gemini.config';
|
||||
44
src/modules/health/health.controller.ts
Normal file
44
src/modules/health/health.controller.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import {
|
||||
HealthCheck,
|
||||
HealthCheckService,
|
||||
PrismaHealthIndicator,
|
||||
} from '@nestjs/terminus';
|
||||
import { Public } from '../../common/decorators';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
|
||||
@ApiTags('Health')
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(
|
||||
private health: HealthCheckService,
|
||||
private prismaHealth: PrismaHealthIndicator,
|
||||
private prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@Public()
|
||||
@HealthCheck()
|
||||
@ApiOperation({ summary: 'Basic health check' })
|
||||
check() {
|
||||
return this.health.check([]);
|
||||
}
|
||||
|
||||
@Get('ready')
|
||||
@Public()
|
||||
@HealthCheck()
|
||||
@ApiOperation({ summary: 'Readiness check (includes database)' })
|
||||
readiness() {
|
||||
return this.health.check([
|
||||
() => this.prismaHealth.pingCheck('database', this.prisma),
|
||||
]);
|
||||
}
|
||||
|
||||
@Get('live')
|
||||
@Public()
|
||||
@ApiOperation({ summary: 'Liveness check' })
|
||||
liveness() {
|
||||
return { status: 'ok', timestamp: new Date().toISOString() };
|
||||
}
|
||||
}
|
||||
11
src/modules/health/health.module.ts
Normal file
11
src/modules/health/health.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TerminusModule } from '@nestjs/terminus';
|
||||
import { PrismaHealthIndicator } from '@nestjs/terminus';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TerminusModule],
|
||||
controllers: [HealthController],
|
||||
providers: [PrismaHealthIndicator],
|
||||
})
|
||||
export class HealthModule {}
|
||||
75
src/modules/skriptai/controllers/analysis.controller.ts
Normal file
75
src/modules/skriptai/controllers/analysis.controller.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
} from '@nestjs/swagger';
|
||||
import { AnalysisService } from '../services';
|
||||
import { JwtAuthGuard } from '../../auth/guards';
|
||||
|
||||
/**
|
||||
* AnalysisController
|
||||
*
|
||||
* REST API controller for content analysis endpoints.
|
||||
*
|
||||
* TR: İçerik analizi endpoint'leri için REST API controller.
|
||||
* EN: REST API controller for content analysis endpoints.
|
||||
*/
|
||||
@ApiTags('SkriptAI - Analysis')
|
||||
@Controller('skriptai/analysis')
|
||||
export class AnalysisController {
|
||||
constructor(private readonly analysisService: AnalysisService) {}
|
||||
|
||||
@Post(':projectId/neuro')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Perform Neuro Marketing Analysis' })
|
||||
@ApiResponse({ status: 201, description: 'Analysis completed' })
|
||||
async analyzeNeuroMarketing(@Param('projectId') projectId: string) {
|
||||
return this.analysisService.analyzeNeuroMarketing(projectId);
|
||||
}
|
||||
|
||||
@Post(':projectId/youtube-audit')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Perform YouTube Algorithm Audit' })
|
||||
@ApiResponse({ status: 201, description: 'Audit completed' })
|
||||
async performYoutubeAudit(@Param('projectId') projectId: string) {
|
||||
return this.analysisService.performYoutubeAudit(projectId);
|
||||
}
|
||||
|
||||
@Post(':projectId/commercial-brief')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Generate Commercial Brief (Sponsorship Analysis)' })
|
||||
@ApiResponse({ status: 201, description: 'Brief generated' })
|
||||
async generateCommercialBrief(@Param('projectId') projectId: string) {
|
||||
return this.analysisService.generateCommercialBrief(projectId);
|
||||
}
|
||||
|
||||
@Post(':projectId/visual-assets')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Generate visual assets for project' })
|
||||
async generateVisualAssets(
|
||||
@Param('projectId') projectId: string,
|
||||
@Query('count') count: number = 5,
|
||||
) {
|
||||
return this.analysisService.generateVisualAssets(projectId, count);
|
||||
}
|
||||
|
||||
@Post('thumbnail')
|
||||
@ApiOperation({ summary: 'Generate a thumbnail image from prompt' })
|
||||
async generateThumbnail(@Body() body: { prompt: string }) {
|
||||
const url = await this.analysisService.generateThumbnailImage(body.prompt);
|
||||
return { url };
|
||||
}
|
||||
}
|
||||
4
src/modules/skriptai/controllers/index.ts
Normal file
4
src/modules/skriptai/controllers/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './projects.controller';
|
||||
export * from './scripts.controller';
|
||||
export * from './research.controller';
|
||||
export * from './analysis.controller';
|
||||
87
src/modules/skriptai/controllers/projects.controller.ts
Normal file
87
src/modules/skriptai/controllers/projects.controller.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
} from '@nestjs/swagger';
|
||||
import { ProjectsService } from '../services';
|
||||
import { CreateProjectDto, UpdateProjectDto } from '../dto';
|
||||
import { JwtAuthGuard } from '../../auth/guards';
|
||||
import { CurrentUser } from '../../../common/decorators';
|
||||
|
||||
/**
|
||||
* ProjectsController
|
||||
*
|
||||
* REST API controller for script project management.
|
||||
*
|
||||
* TR: Script projesi yönetimi için REST API controller.
|
||||
* EN: REST API controller for script project management.
|
||||
*/
|
||||
@ApiTags('SkriptAI - Projects')
|
||||
@Controller('skriptai/projects')
|
||||
export class ProjectsController {
|
||||
constructor(private readonly projectsService: ProjectsService) {}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Create a new script project' })
|
||||
@ApiResponse({ status: 201, description: 'Project created successfully' })
|
||||
async create(@Body() createDto: CreateProjectDto, @CurrentUser() user: any) {
|
||||
return this.projectsService.create(createDto, user?.id);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Get all projects for current user' })
|
||||
async findAll(@CurrentUser() user: any) {
|
||||
return this.projectsService.findAll(user?.id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get a project by ID' })
|
||||
async findOne(@Param('id') id: string) {
|
||||
return this.projectsService.findOne(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Update a project' })
|
||||
async update(@Param('id') id: string, @Body() updateDto: UpdateProjectDto) {
|
||||
return this.projectsService.update(id, updateDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Soft delete a project' })
|
||||
async remove(@Param('id') id: string) {
|
||||
return this.projectsService.remove(id);
|
||||
}
|
||||
|
||||
@Post(':id/duplicate')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Duplicate a project with all content' })
|
||||
async duplicate(@Param('id') id: string, @CurrentUser() user: any) {
|
||||
return this.projectsService.duplicate(id, user?.id);
|
||||
}
|
||||
|
||||
@Get(':id/export')
|
||||
@ApiOperation({ summary: 'Export project to JSON format' })
|
||||
async exportToJson(@Param('id') id: string) {
|
||||
return this.projectsService.exportToJson(id);
|
||||
}
|
||||
}
|
||||
166
src/modules/skriptai/controllers/research.controller.ts
Normal file
166
src/modules/skriptai/controllers/research.controller.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
} from '@nestjs/swagger';
|
||||
import { ResearchService } from '../services';
|
||||
import {
|
||||
CreateSourceDto,
|
||||
CreateBriefItemDto,
|
||||
CreateCharacterDto,
|
||||
PerformResearchDto,
|
||||
GenerateDiscoveryQuestionsDto,
|
||||
GenerateLoglineDto,
|
||||
GenerateCharactersDto,
|
||||
} from '../dto';
|
||||
import { JwtAuthGuard } from '../../auth/guards';
|
||||
|
||||
/**
|
||||
* ResearchController
|
||||
*
|
||||
* REST API controller for research sources, creative brief, and characters.
|
||||
*
|
||||
* TR: Araştırma kaynakları, yaratıcı brief ve karakterler için REST API controller.
|
||||
* EN: REST API controller for research sources, creative brief, and characters.
|
||||
*/
|
||||
@ApiTags('SkriptAI - Research')
|
||||
@Controller('skriptai/research')
|
||||
export class ResearchController {
|
||||
constructor(private readonly researchService: ResearchService) {}
|
||||
|
||||
// ========== SOURCES ==========
|
||||
|
||||
@Post('sources')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Add a research source' })
|
||||
async addSource(@Body() createDto: CreateSourceDto) {
|
||||
return this.researchService.addSource(createDto);
|
||||
}
|
||||
|
||||
@Put('sources/:id/toggle')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Toggle source selection' })
|
||||
async toggleSource(@Param('id') id: string) {
|
||||
return this.researchService.toggleSourceSelection(id);
|
||||
}
|
||||
|
||||
@Delete('sources/:id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Delete a research source' })
|
||||
async deleteSource(@Param('id') id: string) {
|
||||
return this.researchService.deleteSource(id);
|
||||
}
|
||||
|
||||
@Post('deep-research')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Perform deep research using AI' })
|
||||
@ApiResponse({ status: 201, description: 'Research completed' })
|
||||
async performDeepResearch(@Body() researchDto: PerformResearchDto) {
|
||||
return this.researchService.performDeepResearch(
|
||||
researchDto.projectId,
|
||||
researchDto.additionalQuery,
|
||||
);
|
||||
}
|
||||
|
||||
// ========== BRIEF ITEMS ==========
|
||||
|
||||
@Post('brief-items')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Add a brief item (Q&A)' })
|
||||
async addBriefItem(@Body() createDto: CreateBriefItemDto) {
|
||||
return this.researchService.addBriefItem(createDto);
|
||||
}
|
||||
|
||||
@Put('brief-items/:id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Update a brief item answer' })
|
||||
async updateBriefItem(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { answer: string },
|
||||
) {
|
||||
return this.researchService.updateBriefItem(id, body.answer);
|
||||
}
|
||||
|
||||
@Delete('brief-items/:id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Delete a brief item' })
|
||||
async deleteBriefItem(@Param('id') id: string) {
|
||||
return this.researchService.deleteBriefItem(id);
|
||||
}
|
||||
|
||||
@Post('discovery-questions')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Generate discovery questions using AI' })
|
||||
async generateDiscoveryQuestions(@Body() dto: GenerateDiscoveryQuestionsDto) {
|
||||
return this.researchService.generateDiscoveryQuestions(
|
||||
dto.topic,
|
||||
dto.language,
|
||||
dto.existingQuestions,
|
||||
);
|
||||
}
|
||||
|
||||
// ========== CHARACTERS ==========
|
||||
|
||||
@Post('characters')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Add a character profile' })
|
||||
async addCharacter(@Body() createDto: CreateCharacterDto) {
|
||||
return this.researchService.addCharacter(createDto);
|
||||
}
|
||||
|
||||
@Put('characters/:id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Update a character profile' })
|
||||
async updateCharacter(
|
||||
@Param('id') id: string,
|
||||
@Body() updateDto: Partial<CreateCharacterDto>,
|
||||
) {
|
||||
return this.researchService.updateCharacter(id, updateDto);
|
||||
}
|
||||
|
||||
@Delete('characters/:id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Delete a character profile' })
|
||||
async deleteCharacter(@Param('id') id: string) {
|
||||
return this.researchService.deleteCharacter(id);
|
||||
}
|
||||
|
||||
@Post('characters/generate')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Auto-generate character profiles using AI' })
|
||||
async generateCharacters(@Body() dto: GenerateCharactersDto) {
|
||||
return this.researchService.generateCharacters(dto.projectId);
|
||||
}
|
||||
|
||||
// ========== LOGLINE ==========
|
||||
|
||||
@Post('logline')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Generate logline and high concept' })
|
||||
async generateLogline(@Body() dto: GenerateLoglineDto) {
|
||||
return this.researchService.generateLogline(dto.projectId);
|
||||
}
|
||||
}
|
||||
102
src/modules/skriptai/controllers/scripts.controller.ts
Normal file
102
src/modules/skriptai/controllers/scripts.controller.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
} from '@nestjs/swagger';
|
||||
import { ScriptsService } from '../services';
|
||||
import {
|
||||
CreateSegmentDto,
|
||||
UpdateSegmentDto,
|
||||
RewriteSegmentDto,
|
||||
GenerateScriptDto,
|
||||
} from '../dto';
|
||||
import { JwtAuthGuard } from '../../auth/guards';
|
||||
|
||||
/**
|
||||
* ScriptsController
|
||||
*
|
||||
* REST API controller for script segments and AI generation.
|
||||
*
|
||||
* TR: Script segmentleri ve AI üretimi için REST API controller.
|
||||
* EN: REST API controller for script segments and AI generation.
|
||||
*/
|
||||
@ApiTags('SkriptAI - Scripts')
|
||||
@Controller('skriptai/scripts')
|
||||
export class ScriptsController {
|
||||
constructor(private readonly scriptsService: ScriptsService) {}
|
||||
|
||||
@Post('segments')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Create a new script segment' })
|
||||
async createSegment(@Body() createDto: CreateSegmentDto) {
|
||||
return this.scriptsService.createSegment(createDto);
|
||||
}
|
||||
|
||||
@Put('segments/:id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Update a script segment' })
|
||||
async updateSegment(
|
||||
@Param('id') id: string,
|
||||
@Body() updateDto: UpdateSegmentDto,
|
||||
) {
|
||||
return this.scriptsService.updateSegment(id, updateDto);
|
||||
}
|
||||
|
||||
@Delete('segments/:id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Delete a script segment' })
|
||||
async deleteSegment(@Param('id') id: string) {
|
||||
return this.scriptsService.deleteSegment(id);
|
||||
}
|
||||
|
||||
@Post('segments/reorder')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Reorder segments in a project' })
|
||||
async reorderSegments(
|
||||
@Body() body: { projectId: string; segmentIds: string[] },
|
||||
) {
|
||||
return this.scriptsService.reorderSegments(body.projectId, body.segmentIds);
|
||||
}
|
||||
|
||||
@Post('generate')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Generate full script using AI' })
|
||||
@ApiResponse({ status: 201, description: 'Script generated successfully' })
|
||||
async generateScript(@Body() generateDto: GenerateScriptDto) {
|
||||
return this.scriptsService.generateScript(generateDto.projectId);
|
||||
}
|
||||
|
||||
@Post('rewrite')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Rewrite a segment with new style' })
|
||||
async rewriteSegment(@Body() rewriteDto: RewriteSegmentDto) {
|
||||
return this.scriptsService.rewriteSegment(
|
||||
rewriteDto.segmentId,
|
||||
rewriteDto.newStyle,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('segments/:id/image')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Generate image for a segment' })
|
||||
async generateSegmentImage(@Param('id') id: string) {
|
||||
return this.scriptsService.generateSegmentImage(id);
|
||||
}
|
||||
}
|
||||
3
src/modules/skriptai/dto/index.ts
Normal file
3
src/modules/skriptai/dto/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './project.dto';
|
||||
export * from './segment.dto';
|
||||
export * from './research.dto';
|
||||
143
src/modules/skriptai/dto/project.dto.ts
Normal file
143
src/modules/skriptai/dto/project.dto.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
ArrayMinSize,
|
||||
} from 'class-validator';
|
||||
|
||||
// Types are defined as string unions in skriptai.types.ts
|
||||
// Using string here to avoid emitDecoratorMetadata issues
|
||||
|
||||
/**
|
||||
* CreateProjectDto
|
||||
*
|
||||
* DTO for creating a new script project.
|
||||
*
|
||||
* TR: Yeni bir script projesi oluşturmak için DTO.
|
||||
* EN: DTO for creating a new script project.
|
||||
*/
|
||||
export class CreateProjectDto {
|
||||
@ApiProperty({ description: 'Main topic of the project' })
|
||||
@IsString()
|
||||
topic: string;
|
||||
|
||||
@ApiProperty({ description: 'Content format type' })
|
||||
@IsString()
|
||||
contentType: string;
|
||||
|
||||
@ApiProperty({ description: 'Target audience list', isArray: true })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
targetAudience: string[];
|
||||
|
||||
@ApiProperty({ description: 'Speech style list', isArray: true })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
speechStyle: string[];
|
||||
|
||||
@ApiProperty({ description: 'Target video duration' })
|
||||
@IsString()
|
||||
targetDuration: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Additional user notes' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userNotes?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Content tone' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
tone?: string;
|
||||
|
||||
@ApiProperty({ description: 'Content language', default: 'tr' })
|
||||
@IsString()
|
||||
language: string = 'tr';
|
||||
|
||||
@ApiPropertyOptional({ description: 'Include interview segments' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
includeInterviews?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* UpdateProjectDto
|
||||
*
|
||||
* DTO for updating an existing project.
|
||||
* All fields are optional.
|
||||
*
|
||||
* TR: Mevcut bir projeyi güncellemek için DTO.
|
||||
* EN: DTO for updating an existing project.
|
||||
*/
|
||||
export class UpdateProjectDto {
|
||||
@ApiPropertyOptional({ description: 'Main topic of the project' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
topic?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Content format type' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
contentType?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Target audience list', isArray: true })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
targetAudience?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Speech style list', isArray: true })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
speechStyle?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Target video duration' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
targetDuration?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Additional user notes' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userNotes?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Content tone' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
tone?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Content language' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
language?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Project logline' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
logline?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'High concept description' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
highConcept?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Include interview segments' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
includeInterviews?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'SEO title' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
seoTitle?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'SEO description' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
seoDescription?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'SEO tags', isArray: true })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
seoTags?: string[];
|
||||
}
|
||||
175
src/modules/skriptai/dto/research.dto.ts
Normal file
175
src/modules/skriptai/dto/research.dto.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsString, IsOptional, IsBoolean, IsArray } from 'class-validator';
|
||||
|
||||
// SourceType and CharacterRole are string unions - using string here for decorator compatibility
|
||||
|
||||
/**
|
||||
* CreateSourceDto
|
||||
*
|
||||
* DTO for adding a research source.
|
||||
*
|
||||
* TR: Araştırma kaynağı eklemek için DTO.
|
||||
* EN: DTO for adding a research source.
|
||||
*/
|
||||
export class CreateSourceDto {
|
||||
@ApiProperty({ description: 'Project ID' })
|
||||
@IsString()
|
||||
projectId: string;
|
||||
|
||||
@ApiProperty({ description: 'Source title' })
|
||||
@IsString()
|
||||
title: string;
|
||||
|
||||
@ApiProperty({ description: 'Source URL' })
|
||||
@IsString()
|
||||
url: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Source snippet/summary' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
snippet?: string;
|
||||
|
||||
@ApiProperty({ description: 'Source type' })
|
||||
@IsString()
|
||||
type: string; // article, video, interview, etc.
|
||||
|
||||
@ApiPropertyOptional({ description: 'Whether source is selected' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* CreateBriefItemDto
|
||||
*
|
||||
* DTO for adding a brief question/answer.
|
||||
*
|
||||
* TR: Brief sorusu/cevabı eklemek için DTO.
|
||||
* EN: DTO for adding a brief question/answer.
|
||||
*/
|
||||
export class CreateBriefItemDto {
|
||||
@ApiProperty({ description: 'Project ID' })
|
||||
@IsString()
|
||||
projectId: string;
|
||||
|
||||
@ApiProperty({ description: 'Question text' })
|
||||
@IsString()
|
||||
question: string;
|
||||
|
||||
@ApiProperty({ description: 'Answer text' })
|
||||
@IsString()
|
||||
answer: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Sort order' })
|
||||
@IsOptional()
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* CreateCharacterDto
|
||||
*
|
||||
* DTO for creating a character profile.
|
||||
*
|
||||
* TR: Karakter profili oluşturmak için DTO.
|
||||
* EN: DTO for creating a character profile.
|
||||
*/
|
||||
export class CreateCharacterDto {
|
||||
@ApiProperty({ description: 'Project ID' })
|
||||
@IsString()
|
||||
projectId: string;
|
||||
|
||||
@ApiProperty({ description: 'Character name' })
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ description: 'Character role' })
|
||||
@IsString()
|
||||
role: string; // Protagonist, Antagonist, etc.
|
||||
|
||||
@ApiPropertyOptional({ description: 'Character values (inner beliefs)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
values?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Character traits (personality)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
traits?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Character mannerisms (external behavior)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mannerisms?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* PerformResearchDto
|
||||
*
|
||||
* DTO for performing deep research.
|
||||
*
|
||||
* TR: Derin araştırma yapmak için DTO.
|
||||
* EN: DTO for performing deep research.
|
||||
*/
|
||||
export class PerformResearchDto {
|
||||
@ApiProperty({ description: 'Project ID' })
|
||||
@IsString()
|
||||
projectId: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Additional research query' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
additionalQuery?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GenerateDiscoveryQuestionsDto
|
||||
*
|
||||
* DTO for generating creative brief discovery questions.
|
||||
*
|
||||
* TR: Yaratıcı brief keşif soruları oluşturmak için DTO.
|
||||
* EN: DTO for generating creative brief discovery questions.
|
||||
*/
|
||||
export class GenerateDiscoveryQuestionsDto {
|
||||
@ApiProperty({ description: 'Topic to generate questions for' })
|
||||
@IsString()
|
||||
topic: string;
|
||||
|
||||
@ApiProperty({ description: 'Language for questions' })
|
||||
@IsString()
|
||||
language: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Existing questions to avoid' })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
existingQuestions?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* GenerateLoglineDto
|
||||
*
|
||||
* DTO for generating logline and high concept.
|
||||
*
|
||||
* TR: Logline ve high concept oluşturmak için DTO.
|
||||
* EN: DTO for generating logline and high concept.
|
||||
*/
|
||||
export class GenerateLoglineDto {
|
||||
@ApiProperty({ description: 'Project ID' })
|
||||
@IsString()
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GenerateCharactersDto
|
||||
*
|
||||
* DTO for auto-generating character profiles.
|
||||
*
|
||||
* TR: Otomatik karakter profilleri oluşturmak için DTO.
|
||||
* EN: DTO for auto-generating character profiles.
|
||||
*/
|
||||
export class GenerateCharactersDto {
|
||||
@ApiProperty({ description: 'Project ID' })
|
||||
@IsString()
|
||||
projectId: string;
|
||||
}
|
||||
191
src/modules/skriptai/dto/segment.dto.ts
Normal file
191
src/modules/skriptai/dto/segment.dto.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsString, IsOptional, IsInt, Min } from 'class-validator';
|
||||
|
||||
// SegmentType and SpeechStyle are string unions - using string here for decorator compatibility
|
||||
|
||||
/**
|
||||
* CreateSegmentDto
|
||||
*
|
||||
* DTO for creating a new script segment.
|
||||
*
|
||||
* TR: Yeni bir script segmenti oluşturmak için DTO.
|
||||
* EN: DTO for creating a new script segment.
|
||||
*/
|
||||
export class CreateSegmentDto {
|
||||
@ApiProperty({ description: 'Project ID this segment belongs to' })
|
||||
@IsString()
|
||||
projectId: string;
|
||||
|
||||
@ApiProperty({ description: 'Segment type (Hook, Intro, Body, etc.)' })
|
||||
@IsString()
|
||||
segmentType: string; // Hook, Intro, Body, etc.
|
||||
|
||||
@ApiProperty({ description: 'Start time in format MM:SS' })
|
||||
@IsString()
|
||||
timeStart: string;
|
||||
|
||||
@ApiProperty({ description: 'Duration in seconds (e.g., "30s")' })
|
||||
@IsString()
|
||||
duration: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Visual description for the segment' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
visualDescription?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Narrator script text' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
narratorScript?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Editor notes' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
editorNotes?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Audio cues' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
audioCues?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'On-screen text overlay' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
onScreenText?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Stock footage search query' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
stockQuery?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Video generation prompt (VEO/Runway)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
videoPrompt?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Image generation prompt (Midjourney/Flux)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
imagePrompt?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Sort order in the script' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* UpdateSegmentDto
|
||||
*
|
||||
* DTO for updating an existing segment.
|
||||
*
|
||||
* TR: Mevcut bir segmenti güncellemek için DTO.
|
||||
* EN: DTO for updating an existing segment.
|
||||
*/
|
||||
export class UpdateSegmentDto {
|
||||
@ApiPropertyOptional({ description: 'Segment type' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
segmentType?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Start time' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
timeStart?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Duration' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
duration?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Visual description' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
visualDescription?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Narrator script' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
narratorScript?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Editor notes' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
editorNotes?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'General notes' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
generalNotes?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Audio cues' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
audioCues?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'On-screen text' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
onScreenText?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Stock query' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
stockQuery?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Video prompt' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
videoPrompt?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Image prompt' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
imagePrompt?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Generated image URL' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
generatedImageUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Sort order' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* RewriteSegmentDto
|
||||
*
|
||||
* DTO for rewriting a segment with a new style.
|
||||
*
|
||||
* TR: Bir segmenti yeni bir stille yeniden yazmak için DTO.
|
||||
* EN: DTO for rewriting a segment with a new style.
|
||||
*/
|
||||
export class RewriteSegmentDto {
|
||||
@ApiProperty({ description: 'Segment ID to rewrite' })
|
||||
@IsString()
|
||||
segmentId: string;
|
||||
|
||||
@ApiProperty({ description: 'New style to apply' })
|
||||
@IsString()
|
||||
newStyle: string; // SpeechStyle or 'Make it Longer' | 'Make it Shorter'
|
||||
}
|
||||
|
||||
/**
|
||||
* GenerateScriptDto
|
||||
*
|
||||
* DTO for generating a full script from project data.
|
||||
*
|
||||
* TR: Proje verisinden tam bir script oluşturmak için DTO.
|
||||
* EN: DTO for generating a full script from project data.
|
||||
*/
|
||||
export class GenerateScriptDto {
|
||||
@ApiProperty({ description: 'Project ID to generate script for' })
|
||||
@IsString()
|
||||
projectId: string;
|
||||
}
|
||||
5
src/modules/skriptai/index.ts
Normal file
5
src/modules/skriptai/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './skriptai.module';
|
||||
export * from './services';
|
||||
export * from './controllers';
|
||||
export * from './dto';
|
||||
export * from './types/skriptai.types';
|
||||
331
src/modules/skriptai/services/analysis.service.ts
Normal file
331
src/modules/skriptai/services/analysis.service.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../database/prisma.service';
|
||||
import { GeminiService } from '../../gemini/gemini.service';
|
||||
import { NeuroAnalysisResult, YoutubeAudit } from '../types/skriptai.types';
|
||||
|
||||
/**
|
||||
* AnalysisService
|
||||
*
|
||||
* Service for AI-powered content analysis including:
|
||||
* - Neuro Marketing Analysis
|
||||
* - YouTube Audit
|
||||
* - Commercial Brief Generation
|
||||
*
|
||||
* TR: AI destekli içerik analizi servisi (Nöro Pazarlama, YouTube Denetimi, Ticari Brief).
|
||||
* EN: Service for AI-powered content analysis.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AnalysisService {
|
||||
private readonly logger = new Logger(AnalysisService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly gemini: GeminiService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Perform Neuro Marketing Analysis on a script
|
||||
*
|
||||
* @param projectId - Project ID
|
||||
* @returns Neuro analysis result
|
||||
*/
|
||||
async analyzeNeuroMarketing(projectId: string): Promise<NeuroAnalysisResult> {
|
||||
const project = await this.prisma.scriptProject.findUnique({
|
||||
where: { id: projectId },
|
||||
include: { segments: { orderBy: { sortOrder: 'asc' } } },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new NotFoundException(`Project with ID ${projectId} not found`);
|
||||
}
|
||||
|
||||
const fullScript = project.segments
|
||||
.map((s) => s.narratorScript)
|
||||
.join('\n\n');
|
||||
|
||||
const prompt = `Analyze this script using Consumer Neuroscience and Cialdini's 6 Principles of Persuasion.
|
||||
|
||||
Script:
|
||||
${fullScript.substring(0, 10000)}
|
||||
|
||||
Provide:
|
||||
1. Engagement Score (0-100): How well does it capture attention?
|
||||
2. Dopamine Score (0-100): Does it create anticipation & reward loops?
|
||||
3. Clarity Score (0-100): Is the message clear and memorable?
|
||||
|
||||
4. Cialdini's Persuasion Metrics (0-100 each):
|
||||
- Reciprocity: Does it give value first?
|
||||
- Scarcity: Does it create urgency?
|
||||
- Authority: Does it establish credibility?
|
||||
- Consistency: Does it align with viewer beliefs?
|
||||
- Liking: Is the tone likeable/relatable?
|
||||
- Social Proof: Does it reference others' actions?
|
||||
|
||||
5. Neuro Metrics:
|
||||
- Attention Hooks: Moments that grab attention
|
||||
- Emotional Triggers: Points that evoke emotion
|
||||
- Memory Anchors: Unique/memorable elements
|
||||
- Action Drivers: CTAs or challenges
|
||||
|
||||
6. Suggestions: 3-5 specific improvements
|
||||
|
||||
Return JSON: {
|
||||
"engagementScore": number,
|
||||
"dopamineScore": number,
|
||||
"clarityScore": number,
|
||||
"persuasionMetrics": {
|
||||
"reciprocity": number,
|
||||
"scarcity": number,
|
||||
"authority": number,
|
||||
"consistency": number,
|
||||
"liking": number,
|
||||
"socialProof": number
|
||||
},
|
||||
"neuroMetrics": {
|
||||
"attentionHooks": ["..."],
|
||||
"emotionalTriggers": ["..."],
|
||||
"memoryAnchors": ["..."],
|
||||
"actionDrivers": ["..."]
|
||||
},
|
||||
"suggestions": ["..."]
|
||||
}`;
|
||||
|
||||
const resp = await this.gemini.generateJSON<NeuroAnalysisResult>(
|
||||
prompt,
|
||||
'{ engagementScore, dopamineScore, clarityScore, persuasionMetrics, neuroMetrics, suggestions }',
|
||||
);
|
||||
|
||||
// Save to project
|
||||
await this.prisma.scriptProject.update({
|
||||
where: { id: projectId },
|
||||
data: { neuroAnalysis: resp.data as any },
|
||||
});
|
||||
|
||||
return resp.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform YouTube Audit
|
||||
*
|
||||
* @param projectId - Project ID
|
||||
* @returns YouTube audit result
|
||||
*/
|
||||
async performYoutubeAudit(projectId: string): Promise<YoutubeAudit> {
|
||||
const project = await this.prisma.scriptProject.findUnique({
|
||||
where: { id: projectId },
|
||||
include: { segments: { orderBy: { sortOrder: 'asc' } } },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new NotFoundException(`Project with ID ${projectId} not found`);
|
||||
}
|
||||
|
||||
const fullScript = project.segments
|
||||
.map((s) => s.narratorScript)
|
||||
.join('\n\n');
|
||||
|
||||
const prompt = `Perform a YouTube Algorithm Audit on this script for topic "${project.topic}".
|
||||
|
||||
Script:
|
||||
${fullScript.substring(0, 10000)}
|
||||
|
||||
Analyze and provide:
|
||||
1. Hook Score (0-100): First 10 seconds effectiveness
|
||||
2. Pacing Score (0-100): Does it maintain momentum?
|
||||
3. Viral Potential (0-100): Shareability factor
|
||||
|
||||
4. Retention Analysis: 3-5 potential drop-off points with time, issue, suggestion, severity (High/Medium/Low)
|
||||
|
||||
5. Thumbnail Concepts: 3 high-CTR thumbnail ideas with:
|
||||
- Concept name
|
||||
- Visual description
|
||||
- Text overlay
|
||||
- Color psychology
|
||||
- Emotion target
|
||||
- AI generation prompt
|
||||
|
||||
6. Title Options: 5 clickable titles (curiosity gap, numbers, power words)
|
||||
|
||||
7. Community Post: Engaging post to tease the video
|
||||
|
||||
8. Pinned Comment: Engagement-driving first comment
|
||||
|
||||
9. SEO Description: Optimized video description with keywords
|
||||
|
||||
10. Keywords: 10 relevant search keywords
|
||||
|
||||
Return JSON: {
|
||||
"hookScore": number,
|
||||
"pacingScore": number,
|
||||
"viralPotential": number,
|
||||
"retentionAnalysis": [{ "time": "0:30", "issue": "...", "suggestion": "...", "severity": "High" }],
|
||||
"thumbnails": [{ "conceptName": "...", "visualDescription": "...", "textOverlay": "...", "colorPsychology": "...", "emotionTarget": "...", "aiPrompt": "..." }],
|
||||
"titles": ["..."],
|
||||
"communityPost": "...",
|
||||
"pinnedComment": "...",
|
||||
"description": "...",
|
||||
"keywords": ["..."]
|
||||
}`;
|
||||
|
||||
const resp = await this.gemini.generateJSON<YoutubeAudit>(
|
||||
prompt,
|
||||
'{ hookScore, pacingScore, viralPotential, retentionAnalysis, thumbnails, titles, communityPost, pinnedComment, description, keywords }',
|
||||
);
|
||||
|
||||
// Save to project
|
||||
await this.prisma.scriptProject.update({
|
||||
where: { id: projectId },
|
||||
data: { youtubeAudit: resp.data as any },
|
||||
});
|
||||
|
||||
return resp.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Commercial Brief (Sponsorship Analysis)
|
||||
*
|
||||
* @param projectId - Project ID
|
||||
* @returns Commercial brief with sponsor suggestions
|
||||
*/
|
||||
async generateCommercialBrief(projectId: string) {
|
||||
const project = await this.prisma.scriptProject.findUnique({
|
||||
where: { id: projectId },
|
||||
include: { segments: { orderBy: { sortOrder: 'asc' } } },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new NotFoundException(`Project with ID ${projectId} not found`);
|
||||
}
|
||||
|
||||
const fullScript = project.segments
|
||||
.map((s) => s.narratorScript)
|
||||
.join('\n\n');
|
||||
|
||||
const prompt = `Analyze this content for commercial viability and sponsorship opportunities.
|
||||
|
||||
Topic: "${project.topic}"
|
||||
Audience: ${project.targetAudience.join(', ')}
|
||||
Content Type: ${project.contentType}
|
||||
|
||||
Script excerpt:
|
||||
${fullScript.substring(0, 5000)}
|
||||
|
||||
Provide:
|
||||
1. Viability Score (1-10 scale as string): "8/10"
|
||||
2. Viability Reason: Why this content is commercially viable
|
||||
|
||||
3. Sponsor Suggestions (3-5 potential sponsors):
|
||||
- Company name
|
||||
- Industry
|
||||
- Match reason (why this sponsor fits)
|
||||
- Email draft (outreach template)
|
||||
|
||||
Return JSON: {
|
||||
"viabilityScore": "8/10",
|
||||
"viabilityReason": "...",
|
||||
"sponsors": [
|
||||
{
|
||||
"name": "Company Name",
|
||||
"industry": "Tech/Finance/etc",
|
||||
"matchReason": "...",
|
||||
"emailDraft": "..."
|
||||
}
|
||||
]
|
||||
}`;
|
||||
|
||||
const resp = await this.gemini.generateJSON<{
|
||||
viabilityScore: string;
|
||||
viabilityReason: string;
|
||||
sponsors: {
|
||||
name: string;
|
||||
industry: string;
|
||||
matchReason: string;
|
||||
emailDraft: string;
|
||||
}[];
|
||||
}>(prompt, '{ viabilityScore, viabilityReason, sponsors }');
|
||||
|
||||
// Save to project
|
||||
await this.prisma.scriptProject.update({
|
||||
where: { id: projectId },
|
||||
data: { commercialBrief: resp.data as any },
|
||||
});
|
||||
|
||||
return resp.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate thumbnails using external image service
|
||||
*
|
||||
* @param prompt - Image generation prompt
|
||||
* @returns Generated image URL
|
||||
*/
|
||||
/**
|
||||
* Generate thumbnails using external image service
|
||||
* Applies "Nano Banana" prompt enrichment for high-quality results.
|
||||
*
|
||||
* @param prompt - Image generation prompt
|
||||
* @returns Generated image URL
|
||||
*/
|
||||
async generateThumbnailImage(prompt: string): Promise<string> {
|
||||
// Quality boosters (Nano Banana style)
|
||||
const QUALITY_BOOSTERS = [
|
||||
'highly detailed',
|
||||
'8k resolution',
|
||||
'professional photography',
|
||||
'studio lighting',
|
||||
'sharp focus',
|
||||
'cinematic composition',
|
||||
'vibrant colors',
|
||||
'masterpiece',
|
||||
];
|
||||
|
||||
// Enrich prompt with Nano Banana logic
|
||||
const enrichedPrompt = `${prompt}, ${QUALITY_BOOSTERS.join(', ')}. CRITICAL OBJECTIVE: The result MUST achieve a perfect 10/10 score. Clarity: 10/10. Professionalism: 10/10.`;
|
||||
|
||||
// Use Real Nano Banana (Gemini Imagen)
|
||||
return await this.gemini.generateImage(enrichedPrompt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate visual assets for a project
|
||||
*
|
||||
* @param projectId - Project ID
|
||||
* @param count - Number of assets to generate
|
||||
* @returns Generated visual assets
|
||||
*/
|
||||
async generateVisualAssets(projectId: string, count: number = 5) {
|
||||
const project = await this.prisma.scriptProject.findUnique({
|
||||
where: { id: projectId },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new NotFoundException(`Project with ID ${projectId} not found`);
|
||||
}
|
||||
|
||||
const prompt = `Generate ${count} specific, simple visual keywords for an image generator about "${project.topic}".
|
||||
Format: "subject action context style". Keep it English, concise, no special chars.
|
||||
Return JSON array of strings.`;
|
||||
|
||||
const resp = await this.gemini.generateJSON<string[]>(
|
||||
prompt,
|
||||
'["keyword1", "keyword2", ...]',
|
||||
);
|
||||
|
||||
// Generate image URLs and save to database
|
||||
const assets = await Promise.all(
|
||||
resp.data.map(async (keyword) => {
|
||||
const url = await this.generateThumbnailImage(keyword);
|
||||
return this.prisma.visualAsset.create({
|
||||
data: {
|
||||
projectId,
|
||||
url,
|
||||
desc: keyword,
|
||||
selected: true,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return assets;
|
||||
}
|
||||
}
|
||||
4
src/modules/skriptai/services/index.ts
Normal file
4
src/modules/skriptai/services/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './projects.service';
|
||||
export * from './scripts.service';
|
||||
export * from './research.service';
|
||||
export * from './analysis.service';
|
||||
309
src/modules/skriptai/services/projects.service.ts
Normal file
309
src/modules/skriptai/services/projects.service.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../database/prisma.service';
|
||||
import { CreateProjectDto, UpdateProjectDto } from '../dto';
|
||||
|
||||
/**
|
||||
* ProjectsService
|
||||
*
|
||||
* Service for managing script projects (CRUD operations).
|
||||
*
|
||||
* TR: Script projelerini yönetmek için servis (CRUD operasyonları).
|
||||
* EN: Service for managing script projects (CRUD operations).
|
||||
*/
|
||||
@Injectable()
|
||||
export class ProjectsService {
|
||||
private readonly logger = new Logger(ProjectsService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Create a new script project
|
||||
*
|
||||
* @param data - Project creation data
|
||||
* @param userId - Optional user ID for ownership
|
||||
* @returns Created project
|
||||
*/
|
||||
async create(data: CreateProjectDto, userId?: string) {
|
||||
this.logger.log(`Creating project: ${data.topic}`);
|
||||
|
||||
return this.prisma.scriptProject.create({
|
||||
data: {
|
||||
...data,
|
||||
userId,
|
||||
seoTags: [],
|
||||
thumbnailIdeas: [],
|
||||
},
|
||||
include: {
|
||||
segments: true,
|
||||
sources: true,
|
||||
characters: true,
|
||||
briefItems: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all projects for a user
|
||||
*
|
||||
* @param userId - User ID (optional, returns all if not provided)
|
||||
* @returns List of projects
|
||||
*/
|
||||
async findAll(userId?: string) {
|
||||
const where = userId ? { userId, deletedAt: null } : { deletedAt: null };
|
||||
|
||||
return this.prisma.scriptProject.findMany({
|
||||
where,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
segments: true,
|
||||
sources: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a project by ID
|
||||
*
|
||||
* @param id - Project ID
|
||||
* @returns Project with all relations
|
||||
*/
|
||||
async findOne(id: string) {
|
||||
const project = await this.prisma.scriptProject.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
segments: { orderBy: { sortOrder: 'asc' } },
|
||||
sources: true,
|
||||
characters: true,
|
||||
briefItems: { orderBy: { sortOrder: 'asc' } },
|
||||
visualAssets: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new NotFoundException(`Project with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return project;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a project
|
||||
*
|
||||
* @param id - Project ID
|
||||
* @param data - Update data
|
||||
* @returns Updated project
|
||||
*/
|
||||
async update(id: string, data: UpdateProjectDto) {
|
||||
// Verify project exists
|
||||
await this.findOne(id);
|
||||
|
||||
return this.prisma.scriptProject.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
segments: { orderBy: { sortOrder: 'asc' } },
|
||||
sources: true,
|
||||
characters: true,
|
||||
briefItems: { orderBy: { sortOrder: 'asc' } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete a project
|
||||
*
|
||||
* @param id - Project ID
|
||||
*/
|
||||
async remove(id: string) {
|
||||
await this.findOne(id);
|
||||
|
||||
return this.prisma.scriptProject.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard delete a project and all related data
|
||||
*
|
||||
* @param id - Project ID
|
||||
*/
|
||||
async hardDelete(id: string) {
|
||||
await this.findOne(id);
|
||||
|
||||
return this.prisma.scriptProject.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a project with all its content
|
||||
*
|
||||
* @param id - Project ID to duplicate
|
||||
* @param userId - New owner user ID
|
||||
* @returns New duplicated project
|
||||
*/
|
||||
async duplicate(id: string, userId?: string) {
|
||||
const original = await this.findOne(id);
|
||||
|
||||
// Create new project with copied data
|
||||
const newProject = await this.prisma.scriptProject.create({
|
||||
data: {
|
||||
userId,
|
||||
topic: `${original.topic} (Copy)`,
|
||||
contentType: original.contentType,
|
||||
targetAudience: original.targetAudience,
|
||||
speechStyle: original.speechStyle,
|
||||
targetDuration: original.targetDuration,
|
||||
userNotes: original.userNotes,
|
||||
tone: original.tone,
|
||||
language: original.language,
|
||||
logline: original.logline,
|
||||
highConcept: original.highConcept,
|
||||
includeInterviews: original.includeInterviews,
|
||||
seoTitle: original.seoTitle,
|
||||
seoDescription: original.seoDescription,
|
||||
seoTags: original.seoTags,
|
||||
thumbnailIdeas: original.thumbnailIdeas,
|
||||
},
|
||||
});
|
||||
|
||||
// Copy segments
|
||||
if (original.segments.length > 0) {
|
||||
await this.prisma.scriptSegment.createMany({
|
||||
data: original.segments.map((seg) => ({
|
||||
projectId: newProject.id,
|
||||
segmentType: seg.segmentType,
|
||||
timeStart: seg.timeStart,
|
||||
duration: seg.duration,
|
||||
visualDescription: seg.visualDescription,
|
||||
narratorScript: seg.narratorScript,
|
||||
editorNotes: seg.editorNotes,
|
||||
generalNotes: seg.generalNotes,
|
||||
audioCues: seg.audioCues,
|
||||
onScreenText: seg.onScreenText,
|
||||
stockQuery: seg.stockQuery,
|
||||
videoPrompt: seg.videoPrompt,
|
||||
imagePrompt: seg.imagePrompt,
|
||||
sortOrder: seg.sortOrder,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Copy sources
|
||||
if (original.sources.length > 0) {
|
||||
await this.prisma.researchSource.createMany({
|
||||
data: original.sources.map((src) => ({
|
||||
projectId: newProject.id,
|
||||
title: src.title,
|
||||
url: src.url,
|
||||
snippet: src.snippet,
|
||||
type: src.type,
|
||||
selected: src.selected,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Copy characters
|
||||
if (original.characters.length > 0) {
|
||||
await this.prisma.characterProfile.createMany({
|
||||
data: original.characters.map((char) => ({
|
||||
projectId: newProject.id,
|
||||
name: char.name,
|
||||
role: char.role,
|
||||
values: char.values,
|
||||
traits: char.traits,
|
||||
mannerisms: char.mannerisms,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Copy brief items
|
||||
if (original.briefItems.length > 0) {
|
||||
await this.prisma.briefItem.createMany({
|
||||
data: original.briefItems.map((item) => ({
|
||||
projectId: newProject.id,
|
||||
question: item.question,
|
||||
answer: item.answer,
|
||||
sortOrder: item.sortOrder,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return this.findOne(newProject.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export project to JSON format (for download)
|
||||
*
|
||||
* @param id - Project ID
|
||||
* @returns Project data as JSON-serializable object
|
||||
*/
|
||||
async exportToJson(id: string) {
|
||||
const project = await this.findOne(id);
|
||||
|
||||
return {
|
||||
exportedAt: new Date().toISOString(),
|
||||
version: '1.0',
|
||||
project: {
|
||||
topic: project.topic,
|
||||
contentType: project.contentType,
|
||||
targetAudience: project.targetAudience,
|
||||
speechStyle: project.speechStyle,
|
||||
targetDuration: project.targetDuration,
|
||||
userNotes: project.userNotes,
|
||||
tone: project.tone,
|
||||
language: project.language,
|
||||
logline: project.logline,
|
||||
highConcept: project.highConcept,
|
||||
includeInterviews: project.includeInterviews,
|
||||
seo: {
|
||||
title: project.seoTitle,
|
||||
description: project.seoDescription,
|
||||
tags: project.seoTags,
|
||||
thumbnailIdeas: project.thumbnailIdeas,
|
||||
},
|
||||
neuroAnalysis: project.neuroAnalysis,
|
||||
youtubeAudit: project.youtubeAudit,
|
||||
postProduction: project.postProduction,
|
||||
commercialBrief: project.commercialBrief,
|
||||
creativeBrief: project.briefItems.map((item) => ({
|
||||
question: item.question,
|
||||
answer: item.answer,
|
||||
})),
|
||||
sources: project.sources.map((src) => ({
|
||||
title: src.title,
|
||||
url: src.url,
|
||||
snippet: src.snippet,
|
||||
type: src.type,
|
||||
selected: src.selected,
|
||||
})),
|
||||
characters: project.characters.map((char) => ({
|
||||
name: char.name,
|
||||
role: char.role,
|
||||
values: char.values,
|
||||
traits: char.traits,
|
||||
mannerisms: char.mannerisms,
|
||||
})),
|
||||
script: project.segments.map((seg) => ({
|
||||
segmentType: seg.segmentType,
|
||||
timeStart: seg.timeStart,
|
||||
duration: seg.duration,
|
||||
visualDescription: seg.visualDescription,
|
||||
narratorScript: seg.narratorScript,
|
||||
editorNotes: seg.editorNotes,
|
||||
generalNotes: seg.generalNotes,
|
||||
audioCues: seg.audioCues,
|
||||
onScreenText: seg.onScreenText,
|
||||
stockQuery: seg.stockQuery,
|
||||
videoPrompt: seg.videoPrompt,
|
||||
imagePrompt: seg.imagePrompt,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
395
src/modules/skriptai/services/research.service.ts
Normal file
395
src/modules/skriptai/services/research.service.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../database/prisma.service';
|
||||
import { GeminiService } from '../../gemini/gemini.service';
|
||||
import {
|
||||
CreateSourceDto,
|
||||
CreateBriefItemDto,
|
||||
CreateCharacterDto,
|
||||
} from '../dto';
|
||||
import { CharacterRole } from '../types/skriptai.types';
|
||||
|
||||
/**
|
||||
* ResearchService
|
||||
*
|
||||
* Service for managing research sources, creative brief, and character profiles.
|
||||
* Also provides AI-powered research and discovery question generation.
|
||||
*
|
||||
* TR: Araştırma kaynakları, yaratıcı brief ve karakter profilleri yönetimi.
|
||||
* EN: Service for managing research sources, creative brief, and character profiles.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ResearchService {
|
||||
private readonly logger = new Logger(ResearchService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly gemini: GeminiService,
|
||||
) {}
|
||||
|
||||
// ========== SOURCES ==========
|
||||
|
||||
/**
|
||||
* Add a research source
|
||||
*/
|
||||
async addSource(data: CreateSourceDto) {
|
||||
return this.prisma.researchSource.create({ data });
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle source selection
|
||||
*/
|
||||
async toggleSourceSelection(id: string) {
|
||||
const source = await this.prisma.researchSource.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!source) {
|
||||
throw new NotFoundException(`Source with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return this.prisma.researchSource.update({
|
||||
where: { id },
|
||||
data: { selected: !source.selected },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a source
|
||||
*/
|
||||
async deleteSource(id: string) {
|
||||
return this.prisma.researchSource.delete({ where: { id } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform deep research using AI
|
||||
*
|
||||
* @param projectId - Project ID
|
||||
* @param additionalQuery - Optional additional query to append
|
||||
* @returns Generated research sources
|
||||
*/
|
||||
async performDeepResearch(projectId: string, additionalQuery?: string) {
|
||||
const project = await this.prisma.scriptProject.findUnique({
|
||||
where: { id: projectId },
|
||||
include: { briefItems: true },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new NotFoundException(`Project with ID ${projectId} not found`);
|
||||
}
|
||||
|
||||
const briefContext = project.briefItems
|
||||
.map((b) => `Q: ${b.question} A: ${b.answer}`)
|
||||
.join('; ');
|
||||
|
||||
const topic = additionalQuery
|
||||
? `${project.topic} ${additionalQuery}`
|
||||
: project.topic;
|
||||
|
||||
// Generate search queries
|
||||
const queryPrompt = `Generate 5 specific Google Search queries for "${topic}".
|
||||
Context: ${briefContext}. Language: ${project.language}.
|
||||
Return strictly a JSON array of strings.`;
|
||||
|
||||
let searchQueries: string[] = [];
|
||||
// Check if Gemini is available for queries
|
||||
if (!this.gemini.isAvailable()) {
|
||||
this.logger.warn('Gemini is disabled. Using mock search queries.');
|
||||
searchQueries = [
|
||||
`${topic} foundation`,
|
||||
`${topic} controversy`,
|
||||
`${topic} future`,
|
||||
];
|
||||
} else {
|
||||
try {
|
||||
const queryResp = await this.gemini.generateJSON<string[]>(
|
||||
queryPrompt,
|
||||
'["query1", "query2", ...]',
|
||||
{ tools: [{ googleSearch: {} }] },
|
||||
);
|
||||
searchQueries = queryResp.data;
|
||||
} catch {
|
||||
searchQueries = [`${topic} details`, `${topic} news`];
|
||||
}
|
||||
}
|
||||
|
||||
// Generate sources for each query
|
||||
const allSources: any[] = [];
|
||||
const processedUrls = new Set<string>();
|
||||
|
||||
for (const query of searchQueries.slice(0, 5)) {
|
||||
try {
|
||||
if (!this.gemini.isAvailable()) {
|
||||
allSources.push({
|
||||
projectId,
|
||||
title: `Mock Source: ${query}`,
|
||||
url: `https://example.com/mock/${query.replace(/\s+/g, '-')}`,
|
||||
snippet: `This is a simulated research result for "${query}" because AI is disabled. Enable Gemini in .env for real results.`,
|
||||
type: 'article',
|
||||
selected: true,
|
||||
isNew: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourcePrompt = `Find 3 high-quality web sources for: ${query}. Language: ${project.language}.
|
||||
Return JSON array: [{ "title": string, "url": string, "snippet": string, "type": "article" }]`;
|
||||
|
||||
const sourceResp = await this.gemini.generateJSON<
|
||||
{ title: string; url: string; snippet: string; type: string }[]
|
||||
>(sourcePrompt, '[{ title, url, snippet, type }]', {
|
||||
tools: [{ googleSearch: {} }],
|
||||
});
|
||||
|
||||
for (const item of sourceResp.data) {
|
||||
if (item.url && !processedUrls.has(item.url)) {
|
||||
processedUrls.add(item.url);
|
||||
allSources.push({
|
||||
projectId,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
snippet: item.snippet,
|
||||
type: item.type || 'article',
|
||||
selected: true,
|
||||
isNew: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
this.logger.warn(`Failed to get sources for query: ${query}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Save sources to database
|
||||
if (allSources.length > 0) {
|
||||
await this.prisma.researchSource.createMany({
|
||||
data: allSources,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
return this.prisma.researchSource.findMany({
|
||||
where: { projectId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
// ========== BRIEF ITEMS ==========
|
||||
|
||||
/**
|
||||
* Add a brief item (Q&A)
|
||||
*/
|
||||
async addBriefItem(data: CreateBriefItemDto) {
|
||||
const lastItem = await this.prisma.briefItem.findFirst({
|
||||
where: { projectId: data.projectId },
|
||||
orderBy: { sortOrder: 'desc' },
|
||||
});
|
||||
|
||||
return this.prisma.briefItem.create({
|
||||
data: {
|
||||
...data,
|
||||
sortOrder: data.sortOrder ?? (lastItem?.sortOrder ?? 0) + 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a brief item
|
||||
*/
|
||||
async updateBriefItem(id: string, answer: string) {
|
||||
return this.prisma.briefItem.update({
|
||||
where: { id },
|
||||
data: { answer },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a brief item
|
||||
*/
|
||||
async deleteBriefItem(id: string) {
|
||||
return this.prisma.briefItem.delete({ where: { id } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate discovery questions using AI
|
||||
*
|
||||
* @param topic - Topic to generate questions for
|
||||
* @param language - Language for questions
|
||||
* @param existingQuestions - Questions to avoid
|
||||
* @returns Generated questions
|
||||
*/
|
||||
async generateDiscoveryQuestions(
|
||||
topic: string,
|
||||
language: string,
|
||||
existingQuestions: string[] = [],
|
||||
) {
|
||||
const existingContext =
|
||||
existingQuestions.length > 0
|
||||
? `Avoid these questions: ${existingQuestions.join(', ')}`
|
||||
: '';
|
||||
|
||||
// Check if Gemini is available
|
||||
if (!this.gemini.isAvailable()) {
|
||||
this.logger.warn(
|
||||
'Gemini is disabled. Returning mock discovery questions.',
|
||||
);
|
||||
return [
|
||||
`What is the unique angle or "Simpsons Moment" that makes ${topic} surprising?`,
|
||||
`Who is the "Unseen Character" in this story about ${topic}?`,
|
||||
`If ${topic} was a crime scene, what is the smoking gun?`,
|
||||
`What is the one thing everyone gets wrong about ${topic}?`,
|
||||
];
|
||||
}
|
||||
|
||||
const prompt = `You are an expert Screenwriter and Creative Director. Topic: "${topic}".
|
||||
|
||||
PHASE 1: DEEP DIVE
|
||||
Think like a filmmaker. We are not just making a video; we are telling a story.
|
||||
Analyze the topic "${topic}" to find the drama, the conflict, and the human element.
|
||||
|
||||
PHASE 2: INTERROGATION
|
||||
Ask 3-4 provocative, "Screenwriter's Room" style questions to help shape the narrative arc.
|
||||
|
||||
DO NOT ASK: "What is the goal?" or "Who is the audience?".
|
||||
|
||||
INSTEAD ASK (Examples):
|
||||
- "What is the 'Inciting Incident' that makes this topic urgent right now?"
|
||||
- "If this topic was a character, what would be its fatal flaw?"
|
||||
- "What is the 'Villain' (opposing force or misconception) we are fighting against?"
|
||||
- "What is the emotional climax you want the viewer to feel at the end?"
|
||||
|
||||
${existingContext}
|
||||
Output Language: ${language}.
|
||||
Return JSON object: { "questions": ["Question 1", "Question 2", "Question 3", "Question 4"] }`;
|
||||
|
||||
const resp = await this.gemini.generateJSON<{ questions: string[] }>(
|
||||
prompt,
|
||||
'{ questions: string[] }',
|
||||
);
|
||||
|
||||
return resp.data.questions;
|
||||
}
|
||||
|
||||
// ========== CHARACTERS ==========
|
||||
|
||||
/**
|
||||
* Add a character profile
|
||||
*/
|
||||
async addCharacter(data: CreateCharacterDto) {
|
||||
return this.prisma.characterProfile.create({ data });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a character
|
||||
*/
|
||||
async updateCharacter(
|
||||
id: string,
|
||||
data: Partial<Omit<CreateCharacterDto, 'projectId'>>,
|
||||
) {
|
||||
return this.prisma.characterProfile.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a character
|
||||
*/
|
||||
async deleteCharacter(id: string) {
|
||||
return this.prisma.characterProfile.delete({ where: { id } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-generate character profiles using AI
|
||||
*
|
||||
* @param projectId - Project ID
|
||||
* @returns Generated characters
|
||||
*/
|
||||
async generateCharacters(projectId: string) {
|
||||
const project = await this.prisma.scriptProject.findUnique({
|
||||
where: { id: projectId },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new NotFoundException(`Project with ID ${projectId} not found`);
|
||||
}
|
||||
|
||||
const prompt = `Create Character Profiles for a ${project.contentType} about "${project.topic}".
|
||||
Use Alan C. Hueth's "Triunity of Character" model:
|
||||
1. Values (Inner belief)
|
||||
2. Traits (Personality)
|
||||
3. Mannerisms (External behavior)
|
||||
|
||||
If format is non-fiction (Youtube Doc), create a 'Host/Narrator' persona and potentially an 'Antagonist' (e.g., The Problem, Time, A Rival).
|
||||
Language: ${project.language}.
|
||||
Return JSON Array: [{ "name": "Name", "role": "Protagonist", "values": "...", "traits": "...", "mannerisms": "..." }]`;
|
||||
|
||||
const resp = await this.gemini.generateJSON<
|
||||
{
|
||||
name: string;
|
||||
role: CharacterRole;
|
||||
values: string;
|
||||
traits: string;
|
||||
mannerisms: string;
|
||||
}[]
|
||||
>(prompt, '[{ name, role, values, traits, mannerisms }]');
|
||||
|
||||
// Save characters to database
|
||||
const characters = await Promise.all(
|
||||
resp.data.map((char) =>
|
||||
this.prisma.characterProfile.create({
|
||||
data: {
|
||||
projectId,
|
||||
name: char.name,
|
||||
role: char.role,
|
||||
values: char.values,
|
||||
traits: char.traits,
|
||||
mannerisms: char.mannerisms,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return characters;
|
||||
}
|
||||
|
||||
// ========== LOGLINE ==========
|
||||
|
||||
/**
|
||||
* Generate logline and high concept
|
||||
*
|
||||
* @param projectId - Project ID
|
||||
* @returns Logline and high concept
|
||||
*/
|
||||
async generateLogline(projectId: string) {
|
||||
const project = await this.prisma.scriptProject.findUnique({
|
||||
where: { id: projectId },
|
||||
include: { sources: { where: { selected: true }, take: 5 } },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new NotFoundException(`Project with ID ${projectId} not found`);
|
||||
}
|
||||
|
||||
const sourceContext = project.sources.map((s) => s.snippet).join('\n');
|
||||
|
||||
const prompt = `Act as a Hollywood Producer. Topic: ${project.topic}. Material: ${sourceContext}.
|
||||
Create a "High Concept" premise and a "Logline" (Max 25 words, Dallas Jones formula).
|
||||
Language: ${project.language}.
|
||||
Return JSON: { "logline": "...", "highConcept": "..." }`;
|
||||
|
||||
const resp = await this.gemini.generateJSON<{
|
||||
logline: string;
|
||||
highConcept: string;
|
||||
}>(prompt, '{ logline, highConcept }');
|
||||
|
||||
// Update project
|
||||
await this.prisma.scriptProject.update({
|
||||
where: { id: projectId },
|
||||
data: {
|
||||
logline: resp.data.logline,
|
||||
highConcept: resp.data.highConcept,
|
||||
},
|
||||
});
|
||||
|
||||
return resp.data;
|
||||
}
|
||||
}
|
||||
397
src/modules/skriptai/services/scripts.service.ts
Normal file
397
src/modules/skriptai/services/scripts.service.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../database/prisma.service';
|
||||
import { GeminiService } from '../../gemini/gemini.service';
|
||||
import { CreateSegmentDto, UpdateSegmentDto } from '../dto';
|
||||
|
||||
import { AnalysisService } from './analysis.service';
|
||||
|
||||
// AI_CONFIG is only used for model selection reference
|
||||
|
||||
/**
|
||||
* ScriptsService
|
||||
*
|
||||
* Service for managing script segments and AI-powered script generation.
|
||||
*
|
||||
* TR: Script segmentlerini yönetmek ve AI destekli script oluşturmak için servis.
|
||||
* EN: Service for managing script segments and AI-powered script generation.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ScriptsService {
|
||||
private readonly logger = new Logger(ScriptsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly gemini: GeminiService,
|
||||
private readonly analysisService: AnalysisService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a new segment
|
||||
*/
|
||||
async createSegment(data: CreateSegmentDto) {
|
||||
// Get highest sortOrder for this project
|
||||
const lastSegment = await this.prisma.scriptSegment.findFirst({
|
||||
where: { projectId: data.projectId },
|
||||
orderBy: { sortOrder: 'desc' },
|
||||
});
|
||||
|
||||
const sortOrder = data.sortOrder ?? (lastSegment?.sortOrder ?? 0) + 1;
|
||||
|
||||
return this.prisma.scriptSegment.create({
|
||||
data: {
|
||||
...data,
|
||||
sortOrder,
|
||||
citationIndexes: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a segment
|
||||
*/
|
||||
async updateSegment(id: string, data: UpdateSegmentDto) {
|
||||
const segment = await this.prisma.scriptSegment.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!segment) {
|
||||
throw new NotFoundException(`Segment with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return this.prisma.scriptSegment.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a segment
|
||||
*/
|
||||
async deleteSegment(id: string) {
|
||||
const segment = await this.prisma.scriptSegment.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!segment) {
|
||||
throw new NotFoundException(`Segment with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return this.prisma.scriptSegment.delete({ where: { id } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder segments
|
||||
*
|
||||
* @param projectId - Project ID
|
||||
* @param segmentIds - Array of segment IDs in new order
|
||||
*/
|
||||
async reorderSegments(projectId: string, segmentIds: string[]) {
|
||||
const updates = segmentIds.map((id, index) =>
|
||||
this.prisma.scriptSegment.update({
|
||||
where: { id },
|
||||
data: { sortOrder: index },
|
||||
}),
|
||||
);
|
||||
|
||||
await this.prisma.$transaction(updates);
|
||||
|
||||
return this.prisma.scriptSegment.findMany({
|
||||
where: { projectId },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a full script for a project
|
||||
*
|
||||
* @param projectId - Project ID
|
||||
* @returns Generated segments
|
||||
*/
|
||||
async generateScript(projectId: string) {
|
||||
this.logger.log(`Generating script for project: ${projectId}`);
|
||||
|
||||
const project = await this.prisma.scriptProject.findUnique({
|
||||
where: { id: projectId },
|
||||
include: {
|
||||
sources: { where: { selected: true } },
|
||||
briefItems: { orderBy: { sortOrder: 'asc' } },
|
||||
characters: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new NotFoundException(`Project with ID ${projectId} not found`);
|
||||
}
|
||||
|
||||
// Build context from project data
|
||||
const sourceContext = project.sources
|
||||
.slice(0, 5)
|
||||
.map((s, i) => `[Source ${i + 1}] (${s.type}): ${s.title} - ${s.snippet}`)
|
||||
.join('\n');
|
||||
|
||||
const briefContext = project.briefItems
|
||||
.map((b) => `Q: ${b.question}\nA: ${b.answer}`)
|
||||
.join('\n');
|
||||
|
||||
const characterContext = project.characters
|
||||
.map(
|
||||
(c) =>
|
||||
`${c.name} (${c.role}): Values[${c.values}] Traits[${c.traits}] Mannerisms[${c.mannerisms}]`,
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
// Calculate target word count based on duration
|
||||
let targetWordCount = 840;
|
||||
if (project.targetDuration.includes('Short')) targetWordCount = 140;
|
||||
else if (project.targetDuration.includes('Standard')) targetWordCount = 840;
|
||||
else if (project.targetDuration.includes('Long')) targetWordCount = 1680;
|
||||
else if (project.targetDuration.includes('Deep Dive'))
|
||||
targetWordCount = 2800;
|
||||
|
||||
const estimatedChapters = Math.ceil(targetWordCount / 200);
|
||||
|
||||
// PHASE 1: Generate Outline
|
||||
const outlinePrompt = `
|
||||
Create a CONTENT OUTLINE.
|
||||
Topic: "${project.topic}"
|
||||
Logline: "${project.logline || ''}"
|
||||
Characters: ${characterContext}
|
||||
Styles: ${project.speechStyle.join(', ')}. Audience: ${project.targetAudience.join(', ')}.
|
||||
Format: ${project.contentType}. Target Duration: ${project.targetDuration}. Target Total Word Count: ${targetWordCount}.
|
||||
Generate exactly ${estimatedChapters} chapters.
|
||||
Material: ${sourceContext.substring(0, 15000)}
|
||||
Brief: ${briefContext}
|
||||
|
||||
Return JSON: {
|
||||
"title": "Title", "seoDescription": "Desc", "tags": ["tag1"],
|
||||
"thumbnailIdeas": ["Idea 1"],
|
||||
"chapters": [{ "title": "Chap 1", "focus": "Summary", "type": "Intro" }]
|
||||
}
|
||||
`;
|
||||
|
||||
const outlineResp = await this.gemini.generateJSON<{
|
||||
title: string;
|
||||
seoDescription: string;
|
||||
tags: string[];
|
||||
thumbnailIdeas: string[];
|
||||
chapters: { title: string; focus: string; type: string }[];
|
||||
}>(
|
||||
outlinePrompt,
|
||||
'{ title, seoDescription, tags, thumbnailIdeas, chapters }',
|
||||
);
|
||||
|
||||
const outlineData = outlineResp.data;
|
||||
|
||||
// Update project with SEO data
|
||||
await this.prisma.scriptProject.update({
|
||||
where: { id: projectId },
|
||||
data: {
|
||||
seoTitle: outlineData.title,
|
||||
seoDescription: outlineData.seoDescription,
|
||||
seoTags: outlineData.tags,
|
||||
thumbnailIdeas: outlineData.thumbnailIdeas,
|
||||
},
|
||||
});
|
||||
|
||||
// PHASE 2: Generate each chapter
|
||||
const generatedSegments: any[] = [];
|
||||
let timeOffset = 0;
|
||||
|
||||
for (let i = 0; i < outlineData.chapters.length; i++) {
|
||||
const chapter = outlineData.chapters[i];
|
||||
|
||||
const chapterPrompt = `
|
||||
Write Script Segment ${i + 1}/${outlineData.chapters.length}.
|
||||
Chapter: "${chapter.title}". Focus: ${chapter.focus}.
|
||||
Style: ${project.speechStyle.join(', ')}.
|
||||
Audience: ${project.targetAudience.join(', ')}.
|
||||
Characters: ${characterContext}.
|
||||
Target Length: ~200 words.
|
||||
Language: ${project.language}.
|
||||
|
||||
Return JSON Array: [{
|
||||
"segmentType": "${chapter.type || 'Body'}",
|
||||
"narratorScript": "Full text...",
|
||||
"visualDescription": "Detailed visual explanation...",
|
||||
"videoPrompt": "Cinematic shot of [subject], 4k...",
|
||||
"imagePrompt": "Hyper-realistic photo of [subject]...",
|
||||
"onScreenText": "Overlay text...",
|
||||
"stockQuery": "Pexels keyword",
|
||||
"audioCues": "SFX..."
|
||||
}]
|
||||
`;
|
||||
|
||||
try {
|
||||
const segmentResp = await this.gemini.generateJSON<any[]>(
|
||||
chapterPrompt,
|
||||
'[{ segmentType, narratorScript, visualDescription, videoPrompt, imagePrompt, onScreenText, stockQuery, audioCues }]',
|
||||
);
|
||||
|
||||
for (const seg of segmentResp.data) {
|
||||
const words = seg.narratorScript
|
||||
? seg.narratorScript.split(' ').length
|
||||
: 0;
|
||||
const dur = Math.max(5, Math.ceil(words / (140 / 60)));
|
||||
const start = this.formatTime(timeOffset);
|
||||
timeOffset += dur;
|
||||
|
||||
generatedSegments.push({
|
||||
projectId,
|
||||
segmentType: seg.segmentType || 'Body',
|
||||
timeStart: start,
|
||||
duration: `${dur}s`,
|
||||
narratorScript: seg.narratorScript || '',
|
||||
visualDescription: seg.visualDescription || 'Background',
|
||||
videoPrompt:
|
||||
seg.videoPrompt || `Cinematic shot of ${seg.stockQuery}`,
|
||||
imagePrompt:
|
||||
seg.imagePrompt || `High quality image of ${seg.stockQuery}`,
|
||||
onScreenText: seg.onScreenText || '',
|
||||
editorNotes: '',
|
||||
generalNotes: '',
|
||||
audioCues: seg.audioCues || '',
|
||||
stockQuery: seg.stockQuery || 'background',
|
||||
sortOrder: generatedSegments.length,
|
||||
citationIndexes: [],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to generate chapter ${i + 1}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear existing segments and insert new ones
|
||||
await this.prisma.scriptSegment.deleteMany({ where: { projectId } });
|
||||
|
||||
if (generatedSegments.length > 0) {
|
||||
await this.prisma.scriptSegment.createMany({
|
||||
data: generatedSegments,
|
||||
});
|
||||
}
|
||||
|
||||
return this.prisma.scriptSegment.findMany({
|
||||
where: { projectId },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a segment with a new style
|
||||
*
|
||||
* @param segmentId - Segment ID
|
||||
* @param newStyle - New speech style
|
||||
* @returns Updated segment
|
||||
*/
|
||||
async rewriteSegment(
|
||||
segmentId: string,
|
||||
newStyle: string, // SpeechStyle or 'Make it Longer' | 'Make it Shorter'
|
||||
) {
|
||||
const segment = await this.prisma.scriptSegment.findUnique({
|
||||
where: { id: segmentId },
|
||||
include: { project: true },
|
||||
});
|
||||
|
||||
if (!segment) {
|
||||
throw new NotFoundException(`Segment with ID ${segmentId} not found`);
|
||||
}
|
||||
|
||||
const prompt = `
|
||||
Rewrite this script segment.
|
||||
Current Text: "${segment.narratorScript}"
|
||||
Goal: Change style to "${newStyle}".
|
||||
Context: Topic is "${segment.project.topic}". Language: ${segment.project.language}.
|
||||
Principles: Show Don't Tell, Subtext.
|
||||
|
||||
Return JSON: {
|
||||
"narratorScript": "New text...",
|
||||
"visualDescription": "Updated visual...",
|
||||
"onScreenText": "Updated overlay...",
|
||||
"audioCues": "Updated audio..."
|
||||
}
|
||||
`;
|
||||
|
||||
const rewriteResp = await this.gemini.generateJSON<{
|
||||
narratorScript: string;
|
||||
visualDescription: string;
|
||||
onScreenText: string;
|
||||
audioCues: string;
|
||||
}>(
|
||||
prompt,
|
||||
'{ narratorScript, visualDescription, onScreenText, audioCues }',
|
||||
);
|
||||
|
||||
const data = rewriteResp.data;
|
||||
const words = data.narratorScript
|
||||
? data.narratorScript.split(' ').length
|
||||
: 0;
|
||||
const dur = Math.max(5, Math.ceil(words / (140 / 60)));
|
||||
|
||||
return this.prisma.scriptSegment.update({
|
||||
where: { id: segmentId },
|
||||
data: {
|
||||
narratorScript: data.narratorScript,
|
||||
visualDescription: data.visualDescription,
|
||||
onScreenText: data.onScreenText,
|
||||
audioCues: data.audioCues,
|
||||
duration: `${dur}s`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format seconds to MM:SS
|
||||
*/
|
||||
private formatTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an image for a specific segment
|
||||
*/
|
||||
async generateSegmentImage(segmentId: string) {
|
||||
const segment = await this.prisma.scriptSegment.findUnique({
|
||||
where: { id: segmentId },
|
||||
include: { project: true },
|
||||
});
|
||||
|
||||
if (!segment) {
|
||||
throw new NotFoundException(`Segment with ID ${segmentId} not found`);
|
||||
}
|
||||
|
||||
// 1. Generate/Refine Image Prompt using LLM
|
||||
const promptGenPrompt = `
|
||||
Create a detailed AI Image Generation Prompt and a Video Generation Prompt for this script segment.
|
||||
Topic: "${segment.project.topic}"
|
||||
Segment Content: "${segment.narratorScript}"
|
||||
Visual Context: "${segment.visualDescription}"
|
||||
|
||||
Goal: Create a highly detailed, cinematic, and artistic prompt optimized for tools like Midjourney, Flux, or Runway.
|
||||
Style: Cinematic, highly detailed, 8k, professional lighting.
|
||||
|
||||
Return JSON: {
|
||||
"imagePrompt": "Full detailed image prompt...",
|
||||
"videoPrompt": "Full detailed video prompt..."
|
||||
}
|
||||
`;
|
||||
|
||||
const prompts = await this.gemini.generateJSON<{
|
||||
imagePrompt: string;
|
||||
videoPrompt: string;
|
||||
}>(promptGenPrompt, '{ imagePrompt, videoPrompt }');
|
||||
|
||||
// 2. Use the new image prompt for generation
|
||||
const imageUrl = await this.analysisService.generateThumbnailImage(
|
||||
prompts.data.imagePrompt,
|
||||
);
|
||||
|
||||
// 3. Update segment with new prompts AND generated image URL
|
||||
return this.prisma.scriptSegment.update({
|
||||
where: { id: segmentId },
|
||||
data: {
|
||||
imagePrompt: prompts.data.imagePrompt,
|
||||
videoPrompt: prompts.data.videoPrompt,
|
||||
generatedImageUrl: imageUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
53
src/modules/skriptai/skriptai.module.ts
Normal file
53
src/modules/skriptai/skriptai.module.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DatabaseModule } from '../../database/database.module';
|
||||
import { GeminiModule } from '../gemini/gemini.module';
|
||||
|
||||
// Controllers
|
||||
import {
|
||||
ProjectsController,
|
||||
ScriptsController,
|
||||
ResearchController,
|
||||
AnalysisController,
|
||||
} from './controllers';
|
||||
|
||||
// Services
|
||||
import {
|
||||
ProjectsService,
|
||||
ScriptsService,
|
||||
ResearchService,
|
||||
AnalysisService,
|
||||
} from './services';
|
||||
|
||||
/**
|
||||
* SkriptAI Module
|
||||
*
|
||||
* Main module for the SkriptAI feature - AI-powered video script generation.
|
||||
*
|
||||
* Features:
|
||||
* - Project management (CRUD, duplicate, export)
|
||||
* - Script generation and editing
|
||||
* - Deep research with AI
|
||||
* - Neuro Marketing analysis
|
||||
* - YouTube audit
|
||||
* - Commercial brief generation
|
||||
*
|
||||
* TR: SkriptAI ana modülü - AI destekli video script üretimi.
|
||||
* EN: Main module for the SkriptAI feature - AI-powered video script generation.
|
||||
*/
|
||||
@Module({
|
||||
imports: [DatabaseModule, GeminiModule],
|
||||
controllers: [
|
||||
ProjectsController,
|
||||
ScriptsController,
|
||||
ResearchController,
|
||||
AnalysisController,
|
||||
],
|
||||
providers: [
|
||||
ProjectsService,
|
||||
ScriptsService,
|
||||
ResearchService,
|
||||
AnalysisService,
|
||||
],
|
||||
exports: [ProjectsService, ScriptsService, ResearchService, AnalysisService],
|
||||
})
|
||||
export class SkriptaiModule {}
|
||||
157
src/modules/skriptai/types/skriptai.types.ts
Normal file
157
src/modules/skriptai/types/skriptai.types.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* SkriptAI Types
|
||||
*
|
||||
* Type definitions for the SkriptAI module.
|
||||
* These mirror the original TypeScript types from the legacy app.
|
||||
*
|
||||
* TR: SkriptAI modülü için tip tanımlamaları.
|
||||
* EN: Type definitions for the SkriptAI module.
|
||||
*/
|
||||
|
||||
// Content type definitions
|
||||
export type ContentFormat =
|
||||
| 'YouTube Documentary'
|
||||
| 'YouTube Long Form (Edu/Video Essay)'
|
||||
| 'YouTube Short / TikTok'
|
||||
| 'Kids Cartoon (Script & Dialogue)'
|
||||
| 'Preschool Learning (Slow Paced)'
|
||||
| 'True Crime Story'
|
||||
| 'Product Showcase / Ad'
|
||||
| 'Corporate Presentation'
|
||||
| 'Newsletter / Blog Post'
|
||||
| 'News Bulletin / Journalism';
|
||||
|
||||
export type TargetAudience =
|
||||
| 'Preschool (0-5 Years)'
|
||||
| 'Kids (6-12 Years)'
|
||||
| 'Teenagers (13-17 Years)'
|
||||
| 'Young Adults (18-24 Years)'
|
||||
| 'Adults (25-45 Years)'
|
||||
| 'Seniors (60+ Years)'
|
||||
| 'Professionals / B2B'
|
||||
| 'Mature (18+) / Uncensored'
|
||||
| 'General Audience';
|
||||
|
||||
export type SpeechStyle =
|
||||
| 'Standard / Balanced'
|
||||
| 'Casual / Conversational'
|
||||
| 'Street / Slang (Argo)'
|
||||
| 'Formal / Corporate'
|
||||
| 'Poetic / Artistic'
|
||||
| 'Humorous / Witty'
|
||||
| 'Dramatic / Intense'
|
||||
| 'Tech-Savvy / Jargon'
|
||||
| 'Storyteller / Narrator'
|
||||
| 'Fairy Tale / Masal'
|
||||
| 'Didactic / Educational'
|
||||
| 'Dark / Noir / Mystery'
|
||||
| 'Satirical / Sarcastic'
|
||||
| 'Motivational / High Energy';
|
||||
|
||||
export type SegmentType =
|
||||
| 'Hook'
|
||||
| 'Intro'
|
||||
| 'Body'
|
||||
| 'Ad/Sponsor'
|
||||
| 'CTA'
|
||||
| 'Outro'
|
||||
| 'Scene'
|
||||
| 'Dialogue'
|
||||
| 'Section'
|
||||
| 'Headline';
|
||||
|
||||
export type SourceType =
|
||||
| 'article'
|
||||
| 'video'
|
||||
| 'interview'
|
||||
| 'academic'
|
||||
| 'book'
|
||||
| 'document';
|
||||
|
||||
export type CharacterRole =
|
||||
| 'Protagonist'
|
||||
| 'Antagonist'
|
||||
| 'Guide/Mentor'
|
||||
| 'Sidekick'
|
||||
| 'Narrator';
|
||||
|
||||
// Neuro Marketing Analysis Result
|
||||
export interface NeuroAnalysisResult {
|
||||
engagementScore: number;
|
||||
dopamineScore: number;
|
||||
clarityScore: number;
|
||||
persuasionMetrics: {
|
||||
reciprocity: number;
|
||||
scarcity: number;
|
||||
authority: number;
|
||||
consistency: number;
|
||||
liking: number;
|
||||
socialProof: number;
|
||||
};
|
||||
neuroMetrics: {
|
||||
attentionHooks: string[];
|
||||
emotionalTriggers: string[];
|
||||
memoryAnchors: string[];
|
||||
actionDrivers: string[];
|
||||
};
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
// YouTube Audit Result
|
||||
export interface RetentionPoint {
|
||||
time: string;
|
||||
issue: string;
|
||||
suggestion: string;
|
||||
severity: 'High' | 'Medium' | 'Low';
|
||||
}
|
||||
|
||||
export interface ThumbnailConcept {
|
||||
conceptName: string;
|
||||
visualDescription: string;
|
||||
textOverlay: string;
|
||||
colorPsychology: string;
|
||||
emotionTarget: string;
|
||||
aiPrompt?: string;
|
||||
}
|
||||
|
||||
export interface YoutubeAudit {
|
||||
hookScore: number;
|
||||
pacingScore: number;
|
||||
viralPotential: number;
|
||||
retentionAnalysis: RetentionPoint[];
|
||||
thumbnails: ThumbnailConcept[];
|
||||
titles: string[];
|
||||
communityPost: string;
|
||||
pinnedComment: string;
|
||||
description?: string;
|
||||
keywords?: string[];
|
||||
}
|
||||
|
||||
// Post Production Brief
|
||||
export interface PostProductionBrief {
|
||||
overview: string;
|
||||
pacing: string;
|
||||
colorGrade: string;
|
||||
musicStyle: string;
|
||||
timeline: { time: string; action: string; asset: string }[];
|
||||
}
|
||||
|
||||
// Commercial Brief
|
||||
export interface Sponsor {
|
||||
name: string;
|
||||
industry: string;
|
||||
matchReason: string;
|
||||
emailDraft: string;
|
||||
}
|
||||
|
||||
export interface CommercialBrief {
|
||||
viabilityScore: string;
|
||||
viabilityReason: string;
|
||||
sponsors: Sponsor[];
|
||||
}
|
||||
|
||||
// AI Config
|
||||
export const AI_CONFIG = {
|
||||
PRIMARY: 'gemini-2.5-flash',
|
||||
FAST: 'gemini-2.0-flash',
|
||||
} as const;
|
||||
77
src/modules/users/dto/user.dto.ts
Normal file
77
src/modules/users/dto/user.dto.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
IsEmail,
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsBoolean,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
|
||||
export class CreateUserDto {
|
||||
@ApiPropertyOptional({ example: 'user@example.com' })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'password123', minLength: 8 })
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'John' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
firstName?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Doe' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
lastName?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateUserDto extends PartialType(CreateUserDto) {
|
||||
@ApiPropertyOptional({ example: 'John' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
firstName?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Doe' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
lastName?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
import { Exclude, Expose } from 'class-transformer';
|
||||
|
||||
@Exclude()
|
||||
export class UserResponseDto {
|
||||
@Expose()
|
||||
id: string;
|
||||
|
||||
@Expose()
|
||||
email: string;
|
||||
|
||||
@Expose()
|
||||
firstName: string | null;
|
||||
|
||||
@Expose()
|
||||
lastName: string | null;
|
||||
|
||||
@Expose()
|
||||
isActive: boolean;
|
||||
|
||||
@Expose()
|
||||
createdAt: Date;
|
||||
|
||||
@Expose()
|
||||
updatedAt: Date;
|
||||
}
|
||||
65
src/modules/users/users.controller.ts
Normal file
65
src/modules/users/users.controller.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { BaseController } from '../../common/base';
|
||||
import { UsersService } from './users.service';
|
||||
import { CreateUserDto, UpdateUserDto } from './dto/user.dto';
|
||||
import { CurrentUser, Roles } from '../../common/decorators';
|
||||
import {
|
||||
ApiResponse,
|
||||
createSuccessResponse,
|
||||
} from '../../common/types/api-response.type';
|
||||
import { User } from '@prisma/client/wasm';
|
||||
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { UserResponseDto } from './dto/user.dto';
|
||||
|
||||
interface AuthenticatedUser {
|
||||
id: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
@ApiTags('Users')
|
||||
@ApiBearerAuth()
|
||||
@Controller('users')
|
||||
export class UsersController extends BaseController<
|
||||
User,
|
||||
CreateUserDto,
|
||||
UpdateUserDto
|
||||
> {
|
||||
constructor(private readonly usersService: UsersService) {
|
||||
super(usersService, 'User');
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
async getMe(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
): Promise<ApiResponse<UserResponseDto>> {
|
||||
const fullUser = await this.usersService.findOneWithRoles(user.id);
|
||||
return createSuccessResponse(
|
||||
plainToInstance(UserResponseDto, fullUser),
|
||||
'User profile retrieved successfully',
|
||||
);
|
||||
}
|
||||
|
||||
// Override create to require admin role
|
||||
@Roles('admin')
|
||||
async create(
|
||||
...args: Parameters<
|
||||
BaseController<User, CreateUserDto, UpdateUserDto>['create']
|
||||
>
|
||||
) {
|
||||
return super.create(...args);
|
||||
}
|
||||
|
||||
// Override delete to require admin role
|
||||
@Roles('admin')
|
||||
async delete(
|
||||
...args: Parameters<
|
||||
BaseController<User, CreateUserDto, UpdateUserDto>['delete']
|
||||
>
|
||||
) {
|
||||
return super.delete(...args);
|
||||
}
|
||||
}
|
||||
10
src/modules/users/users.module.ts
Normal file
10
src/modules/users/users.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersController } from './users.controller';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Module({
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
110
src/modules/users/users.service.ts
Normal file
110
src/modules/users/users.service.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Injectable, ConflictException } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { BaseService } from '../../common/base';
|
||||
import { CreateUserDto, UpdateUserDto } from './dto/user.dto';
|
||||
import { User } from '@prisma/client/wasm';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService extends BaseService<
|
||||
User,
|
||||
CreateUserDto,
|
||||
UpdateUserDto
|
||||
> {
|
||||
constructor(prisma: PrismaService) {
|
||||
super(prisma, 'User');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user with hashed password
|
||||
*/
|
||||
async create(dto: CreateUserDto): Promise<User> {
|
||||
// Check if email already exists
|
||||
const existingUser = await this.findOneBy({ email: dto.email });
|
||||
if (existingUser) {
|
||||
throw new ConflictException('EMAIL_ALREADY_EXISTS');
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await this.hashPassword(dto.password);
|
||||
|
||||
return super.create({
|
||||
...dto,
|
||||
password: hashedPassword,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user, hash password if provided
|
||||
*/
|
||||
async update(id: string, dto: UpdateUserDto): Promise<User> {
|
||||
if (dto.password) {
|
||||
dto.password = await this.hashPassword(dto.password);
|
||||
}
|
||||
|
||||
return super.update(id, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user by email
|
||||
*/
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
return this.findOneBy({ email });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user with roles and permissions
|
||||
*/
|
||||
findOneWithRoles(id: string) {
|
||||
return this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
roles: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: {
|
||||
include: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign role to user
|
||||
*/
|
||||
assignRole(userId: string, roleId: string) {
|
||||
return this.prisma.userRole.create({
|
||||
data: {
|
||||
userId,
|
||||
roleId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove role from user
|
||||
*/
|
||||
removeRole(userId: string, roleId: string) {
|
||||
return this.prisma.userRole.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
roleId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash password using bcrypt
|
||||
*/
|
||||
private async hashPassword(password: string): Promise<string> {
|
||||
const saltRounds = 12;
|
||||
return bcrypt.hash(password, saltRounds);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user