generated from fahricansecer/boilerplate-be
1542
package-lock.json
generated
1542
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,8 @@
|
|||||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.964.0",
|
"@aws-sdk/client-s3": "^3.1014.0",
|
||||||
|
"@aws-sdk/lib-storage": "^3.1014.0",
|
||||||
"@google/genai": "^1.35.0",
|
"@google/genai": "^1.35.0",
|
||||||
"@nestjs/bullmq": "^11.0.4",
|
"@nestjs/bullmq": "^11.0.4",
|
||||||
"@nestjs/cache-manager": "^3.1.0",
|
"@nestjs/cache-manager": "^3.1.0",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
i18nConfig,
|
i18nConfig,
|
||||||
featuresConfig,
|
featuresConfig,
|
||||||
throttleConfig,
|
throttleConfig,
|
||||||
|
storageConfig,
|
||||||
} from './config/configuration';
|
} from './config/configuration';
|
||||||
import { geminiConfig } from './modules/gemini/gemini.config';
|
import { geminiConfig } from './modules/gemini/gemini.config';
|
||||||
import { validateEnv } from './config/env.validation';
|
import { validateEnv } from './config/env.validation';
|
||||||
@@ -41,6 +42,7 @@ import { AdminModule } from './modules/admin/admin.module';
|
|||||||
import { HealthModule } from './modules/health/health.module';
|
import { HealthModule } from './modules/health/health.module';
|
||||||
import { GeminiModule } from './modules/gemini/gemini.module';
|
import { GeminiModule } from './modules/gemini/gemini.module';
|
||||||
import { SkriptaiModule } from './modules/skriptai/skriptai.module';
|
import { SkriptaiModule } from './modules/skriptai/skriptai.module';
|
||||||
|
import { StorageModule } from './modules/storage/storage.module';
|
||||||
|
|
||||||
// Guards
|
// Guards
|
||||||
import {
|
import {
|
||||||
@@ -64,6 +66,7 @@ import {
|
|||||||
featuresConfig,
|
featuresConfig,
|
||||||
throttleConfig,
|
throttleConfig,
|
||||||
geminiConfig,
|
geminiConfig,
|
||||||
|
storageConfig,
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -175,6 +178,7 @@ import {
|
|||||||
// Optional Modules (controlled by env variables)
|
// Optional Modules (controlled by env variables)
|
||||||
GeminiModule,
|
GeminiModule,
|
||||||
SkriptaiModule,
|
SkriptaiModule,
|
||||||
|
StorageModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
96
src/common/helpers/pagination.helper.ts
Normal file
96
src/common/helpers/pagination.helper.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../database/prisma.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pagination & Search Helpers
|
||||||
|
*
|
||||||
|
* Standardized pagination support and full-text search for projects.
|
||||||
|
*
|
||||||
|
* TR: Sayfalama ve tam metin arama yardımcıları.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface PaginationParams {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResult<T> {
|
||||||
|
data: T[];
|
||||||
|
meta: {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
hasNext: boolean;
|
||||||
|
hasPrev: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchParams extends PaginationParams {
|
||||||
|
query?: string;
|
||||||
|
status?: string;
|
||||||
|
contentType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build standard pagination options for Prisma
|
||||||
|
*/
|
||||||
|
export function buildPaginationOptions(params: PaginationParams) {
|
||||||
|
const page = Math.max(1, params.page || 1);
|
||||||
|
const limit = Math.min(100, Math.max(1, params.limit || 20));
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const orderBy: Record<string, 'asc' | 'desc'> = {};
|
||||||
|
if (params.sortBy) {
|
||||||
|
orderBy[params.sortBy] = params.sortOrder || 'desc';
|
||||||
|
} else {
|
||||||
|
orderBy['updatedAt'] = 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { skip, take: limit, orderBy, page, limit };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build paginated result from data and total count
|
||||||
|
*/
|
||||||
|
export function buildPaginatedResult<T>(
|
||||||
|
data: T[],
|
||||||
|
total: number,
|
||||||
|
page: number,
|
||||||
|
limit: number,
|
||||||
|
): PaginatedResult<T> {
|
||||||
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
meta: {
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages,
|
||||||
|
hasNext: page < totalPages,
|
||||||
|
hasPrev: page > 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build PostgreSQL full-text search condition
|
||||||
|
*
|
||||||
|
* Uses Prisma's contains with mode: 'insensitive' for compatibility.
|
||||||
|
* For production, consider PostgreSQL tsvector for true FTS.
|
||||||
|
*/
|
||||||
|
export function buildSearchCondition(query?: string) {
|
||||||
|
if (!query || query.trim().length === 0) return {};
|
||||||
|
|
||||||
|
const search = query.trim();
|
||||||
|
return {
|
||||||
|
OR: [
|
||||||
|
{ topic: { contains: search, mode: 'insensitive' as const } },
|
||||||
|
{ logline: { contains: search, mode: 'insensitive' as const } },
|
||||||
|
{ seoTitle: { contains: search, mode: 'insensitive' as const } },
|
||||||
|
{ seoDescription: { contains: search, mode: 'insensitive' as const } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
99
src/common/middleware/correlation-id.middleware.ts
Normal file
99
src/common/middleware/correlation-id.middleware.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { Injectable, NestMiddleware, Logger } from '@nestjs/common';
|
||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Correlation ID Middleware
|
||||||
|
*
|
||||||
|
* Assigns a unique correlation ID to every incoming request.
|
||||||
|
* The ID is:
|
||||||
|
* 1. Read from `x-correlation-id` header (if provided by client/gateway)
|
||||||
|
* 2. Or auto-generated as a UUID
|
||||||
|
* 3. Set on the response header
|
||||||
|
* 4. Attached to the request object for downstream logging
|
||||||
|
*
|
||||||
|
* TR: Her isteğe benzersiz korelasyon ID'si atar.
|
||||||
|
* Loglarda istekleri takip etmek için kullanılır.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CorrelationIdMiddleware implements NestMiddleware {
|
||||||
|
private readonly logger = new Logger(CorrelationIdMiddleware.name);
|
||||||
|
|
||||||
|
use(req: Request, res: Response, next: NextFunction) {
|
||||||
|
const correlationId =
|
||||||
|
(req.headers['x-correlation-id'] as string) || randomUUID();
|
||||||
|
|
||||||
|
// Attach to request for downstream use
|
||||||
|
(req as any).correlationId = correlationId;
|
||||||
|
|
||||||
|
// Set on response header
|
||||||
|
res.setHeader('x-correlation-id', correlationId);
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI Metrics Logger
|
||||||
|
*
|
||||||
|
* Structured logging helper for AI operations.
|
||||||
|
* Logs:
|
||||||
|
* - Operation type (generateJSON, generateText, etc.)
|
||||||
|
* - Model used
|
||||||
|
* - Token usage (input/output)
|
||||||
|
* - Duration
|
||||||
|
* - Success/failure
|
||||||
|
* - Correlation ID
|
||||||
|
*
|
||||||
|
* TR: AI işlemleri için yapılandırılmış log kaydı.
|
||||||
|
*/
|
||||||
|
export interface AIMetrics {
|
||||||
|
operation: string;
|
||||||
|
model: string;
|
||||||
|
inputTokens?: number;
|
||||||
|
outputTokens?: number;
|
||||||
|
durationMs: number;
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
projectId?: string;
|
||||||
|
correlationId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logAIMetrics(logger: Logger, metrics: AIMetrics): void {
|
||||||
|
const { operation, model, inputTokens, outputTokens, durationMs, success } =
|
||||||
|
metrics;
|
||||||
|
|
||||||
|
const tokenInfo =
|
||||||
|
inputTokens !== undefined
|
||||||
|
? ` | tokens: ${inputTokens}→${outputTokens || '?'}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const status = success ? '✅' : '❌';
|
||||||
|
|
||||||
|
logger.log(
|
||||||
|
`${status} AI ${operation} | model: ${model} | ${durationMs}ms${tokenInfo}${metrics.projectId ? ` | project: ${metrics.projectId}` : ''}${metrics.correlationId ? ` | cid: ${metrics.correlationId}` : ''}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!success && metrics.error) {
|
||||||
|
logger.error(`AI ${operation} error: ${metrics.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log levels used across the application
|
||||||
|
*
|
||||||
|
* - DEBUG: Development details, verbose data
|
||||||
|
* - INFO: Normal operations, startup, connections
|
||||||
|
* - WARN: Recoverable issues, fallbacks, deprecations
|
||||||
|
* - ERROR: Failures that need attention
|
||||||
|
* - FATAL: Critical failures, shutdown required
|
||||||
|
*/
|
||||||
|
export const LOG_LEVELS = {
|
||||||
|
AI_CALL: 'info',
|
||||||
|
CACHE_HIT: 'debug',
|
||||||
|
CACHE_MISS: 'debug',
|
||||||
|
QUEUE_JOB: 'info',
|
||||||
|
WEBSOCKET_EVENT: 'debug',
|
||||||
|
STORAGE_UPLOAD: 'info',
|
||||||
|
AUTH_EVENT: 'info',
|
||||||
|
} as const;
|
||||||
157
src/common/services/cache-strategy.service.ts
Normal file
157
src/common/services/cache-strategy.service.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||||
|
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||||
|
import type { Cache } from 'cache-manager';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CacheStrategyService
|
||||||
|
*
|
||||||
|
* Centralized cache management for SkriptAI with tagged invalidation.
|
||||||
|
*
|
||||||
|
* Strategies:
|
||||||
|
* - AI Response Cache: Cache expensive AI calls (keyed by prompt hash)
|
||||||
|
* - Project Data Cache: Cache project details with smart invalidation
|
||||||
|
* - Rate Limiting: Track API call counts per user
|
||||||
|
*
|
||||||
|
* TR: Merkezi cache yönetimi — AI yanıt cache, proje cache, oran sınırlama.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CacheStrategyService {
|
||||||
|
private readonly logger = new Logger(CacheStrategyService.name);
|
||||||
|
|
||||||
|
constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache) {}
|
||||||
|
|
||||||
|
// ========== AI RESPONSE CACHE ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache an AI response with a prompt-based key
|
||||||
|
*
|
||||||
|
* @param promptHash - MD5 or similar hash of the prompt
|
||||||
|
* @param data - AI response data
|
||||||
|
* @param ttlMs - Time to live in ms (default: 30 min)
|
||||||
|
*/
|
||||||
|
async cacheAIResponse(
|
||||||
|
promptHash: string,
|
||||||
|
data: any,
|
||||||
|
ttlMs: number = 30 * 60 * 1000,
|
||||||
|
): Promise<void> {
|
||||||
|
const key = `ai:${promptHash}`;
|
||||||
|
try {
|
||||||
|
await this.cache.set(key, JSON.stringify(data), ttlMs);
|
||||||
|
this.logger.debug(`AI response cached: ${key}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Cache set failed: ${key}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a cached AI response
|
||||||
|
*/
|
||||||
|
async getCachedAIResponse<T = any>(promptHash: string): Promise<T | null> {
|
||||||
|
const key = `ai:${promptHash}`;
|
||||||
|
try {
|
||||||
|
const cached = await this.cache.get<string>(key);
|
||||||
|
if (cached) {
|
||||||
|
this.logger.debug(`AI cache hit: ${key}`);
|
||||||
|
return JSON.parse(cached);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Cache get failed: ${key}`, error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== PROJECT DATA CACHE ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache project data
|
||||||
|
*/
|
||||||
|
async cacheProject(
|
||||||
|
projectId: string,
|
||||||
|
data: any,
|
||||||
|
ttlMs: number = 5 * 60 * 1000,
|
||||||
|
): Promise<void> {
|
||||||
|
const key = `project:${projectId}`;
|
||||||
|
try {
|
||||||
|
await this.cache.set(key, JSON.stringify(data), ttlMs);
|
||||||
|
} catch {
|
||||||
|
/* silent */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached project data
|
||||||
|
*/
|
||||||
|
async getCachedProject<T = any>(projectId: string): Promise<T | null> {
|
||||||
|
const key = `project:${projectId}`;
|
||||||
|
try {
|
||||||
|
const cached = await this.cache.get<string>(key);
|
||||||
|
return cached ? JSON.parse(cached) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate project cache (call after any project mutation)
|
||||||
|
*/
|
||||||
|
async invalidateProject(projectId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.cache.del(`project:${projectId}`);
|
||||||
|
this.logger.debug(`Project cache invalidated: ${projectId}`);
|
||||||
|
} catch {
|
||||||
|
/* silent */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== RATE LIMITING ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check and increment rate limit counter
|
||||||
|
*
|
||||||
|
* @param userId - User identifier
|
||||||
|
* @param action - Action name (e.g., 'ai-call')
|
||||||
|
* @param maxPerWindow - Max calls per window
|
||||||
|
* @param windowMs - Window duration in ms (default: 1 min)
|
||||||
|
* @returns { allowed, remaining, resetIn }
|
||||||
|
*/
|
||||||
|
async checkRateLimit(
|
||||||
|
userId: string,
|
||||||
|
action: string,
|
||||||
|
maxPerWindow: number = 10,
|
||||||
|
windowMs: number = 60 * 1000,
|
||||||
|
): Promise<{ allowed: boolean; remaining: number; resetIn: number }> {
|
||||||
|
const key = `rate:${userId}:${action}`;
|
||||||
|
try {
|
||||||
|
const current = await this.cache.get<string>(key);
|
||||||
|
const count = current ? parseInt(current, 10) : 0;
|
||||||
|
|
||||||
|
if (count >= maxPerWindow) {
|
||||||
|
return { allowed: false, remaining: 0, resetIn: windowMs };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.cache.set(key, String(count + 1), windowMs);
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
remaining: maxPerWindow - count - 1,
|
||||||
|
resetIn: windowMs,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { allowed: true, remaining: maxPerWindow, resetIn: windowMs };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== UTILITY ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a simple hash from prompt text (deterministic)
|
||||||
|
*/
|
||||||
|
hashPrompt(prompt: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < prompt.length; i++) {
|
||||||
|
const char = prompt.charCodeAt(i);
|
||||||
|
hash = (hash << 5) - hash + char;
|
||||||
|
hash |= 0; // Convert to 32bit integer
|
||||||
|
}
|
||||||
|
return Math.abs(hash).toString(36);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,3 +55,12 @@ export const throttleConfig = registerAs('throttle', () => ({
|
|||||||
ttl: parseInt(process.env.THROTTLE_TTL || '60000', 10),
|
ttl: parseInt(process.env.THROTTLE_TTL || '60000', 10),
|
||||||
limit: parseInt(process.env.THROTTLE_LIMIT || '100', 10),
|
limit: parseInt(process.env.THROTTLE_LIMIT || '100', 10),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const storageConfig = registerAs('storage', () => ({
|
||||||
|
enabled: process.env.STORAGE_ENABLED === 'true',
|
||||||
|
endpoint: process.env.STORAGE_ENDPOINT || 'http://192.168.1.199:9000',
|
||||||
|
accessKey: process.env.STORAGE_ACCESS_KEY || 'minioadmin',
|
||||||
|
secretKey: process.env.STORAGE_SECRET_KEY || 'minioadmin',
|
||||||
|
bucket: process.env.STORAGE_BUCKET || 'skriptai-assets',
|
||||||
|
publicUrl: process.env.STORAGE_PUBLIC_URL || 'http://192.168.1.199:9000',
|
||||||
|
}));
|
||||||
|
|||||||
67
src/config/languages.ts
Normal file
67
src/config/languages.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Supported Languages Configuration
|
||||||
|
*
|
||||||
|
* Faz 5.1 — Çoklu dil genişletme altyapısı.
|
||||||
|
* Yeni diller eklemek için bu dosyaya ekleme yapın.
|
||||||
|
*
|
||||||
|
* TR: Desteklenen diller ve RTL yapılandırması.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LanguageConfig {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
nativeName: string;
|
||||||
|
flag: string;
|
||||||
|
rtl: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SUPPORTED_LANGUAGES: LanguageConfig[] = [
|
||||||
|
{ code: 'tr', name: 'Turkish', nativeName: 'Türkçe', flag: '🇹🇷', rtl: false, enabled: true },
|
||||||
|
{ code: 'en', name: 'English', nativeName: 'English', flag: '🇬🇧', rtl: false, enabled: true },
|
||||||
|
{ code: 'ar', name: 'Arabic', nativeName: 'العربية', flag: '🇸🇦', rtl: true, enabled: false },
|
||||||
|
{ code: 'es', name: 'Spanish', nativeName: 'Español', flag: '🇪🇸', rtl: false, enabled: false },
|
||||||
|
{ code: 'de', name: 'German', nativeName: 'Deutsch', flag: '🇩🇪', rtl: false, enabled: false },
|
||||||
|
{ code: 'fr', name: 'French', nativeName: 'Français', flag: '🇫🇷', rtl: false, enabled: false },
|
||||||
|
{ code: 'ja', name: 'Japanese', nativeName: '日本語', flag: '🇯🇵', rtl: false, enabled: false },
|
||||||
|
{ code: 'ko', name: 'Korean', nativeName: '한국어', flag: '🇰🇷', rtl: false, enabled: false },
|
||||||
|
{ code: 'zh', name: 'Chinese', nativeName: '中文', flag: '🇨🇳', rtl: false, enabled: false },
|
||||||
|
{ code: 'pt', name: 'Portuguese', nativeName: 'Português', flag: '🇧🇷', rtl: false, enabled: false },
|
||||||
|
{ code: 'ru', name: 'Russian', nativeName: 'Русский', flag: '🇷🇺', rtl: false, enabled: false },
|
||||||
|
{ code: 'hi', name: 'Hindi', nativeName: 'हिन्दी', flag: '🇮🇳', rtl: false, enabled: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get only enabled languages
|
||||||
|
*/
|
||||||
|
export function getEnabledLanguages(): LanguageConfig[] {
|
||||||
|
return SUPPORTED_LANGUAGES.filter((l) => l.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if language is RTL
|
||||||
|
*/
|
||||||
|
export function isRTL(code: string): boolean {
|
||||||
|
return SUPPORTED_LANGUAGES.find((l) => l.code === code)?.rtl ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get language config by code
|
||||||
|
*/
|
||||||
|
export function getLanguageConfig(code: string): LanguageConfig | undefined {
|
||||||
|
return SUPPORTED_LANGUAGES.find((l) => l.code === code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI Prompt language instruction map
|
||||||
|
* Used to instruct the AI about output language characteristics
|
||||||
|
*/
|
||||||
|
export const LANGUAGE_INSTRUCTIONS: Record<string, string> = {
|
||||||
|
tr: 'Doğal, akıcı Türkçe kullan. Argo ve günlük dil kullanımına dikkat et.',
|
||||||
|
en: 'Use natural, fluent English. Match the requested tone and style.',
|
||||||
|
ar: 'استخدم اللغة العربية الفصحى الحديثة مع مراعاة الأسلوب المطلوب',
|
||||||
|
es: 'Utiliza español natural y fluido. Adapta el tono según lo solicitado.',
|
||||||
|
de: 'Verwende natürliches, flüssiges Deutsch. Passe den Ton an den gewünschten Stil an.',
|
||||||
|
fr: 'Utilise un français naturel et fluide. Adapte le ton au style demandé.',
|
||||||
|
ja: '自然で流暢な日本語を使用してください。要求されたトーンとスタイルに合わせてください。',
|
||||||
|
};
|
||||||
2
src/modules/storage/index.ts
Normal file
2
src/modules/storage/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './storage.service';
|
||||||
|
export * from './storage.module';
|
||||||
19
src/modules/storage/storage.module.ts
Normal file
19
src/modules/storage/storage.module.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Module, Global } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { StorageService } from './storage.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage Module — MinIO/S3 Object Storage
|
||||||
|
*
|
||||||
|
* Global module providing StorageService for file uploads.
|
||||||
|
* Configure via environment variables (see .env.example).
|
||||||
|
*
|
||||||
|
* TR: MinIO/S3 nesne depolama modülü.
|
||||||
|
*/
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
providers: [StorageService],
|
||||||
|
exports: [StorageService],
|
||||||
|
})
|
||||||
|
export class StorageModule {}
|
||||||
243
src/modules/storage/storage.service.ts
Normal file
243
src/modules/storage/storage.service.ts
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import {
|
||||||
|
S3Client,
|
||||||
|
PutObjectCommand,
|
||||||
|
GetObjectCommand,
|
||||||
|
DeleteObjectCommand,
|
||||||
|
HeadBucketCommand,
|
||||||
|
CreateBucketCommand,
|
||||||
|
} from '@aws-sdk/client-s3';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StorageService — S3-Compatible Object Storage (MinIO)
|
||||||
|
*
|
||||||
|
* Handles file uploads (images, documents) to MinIO/S3.
|
||||||
|
* Key features:
|
||||||
|
* - Base64 data URI → MinIO upload → public URL
|
||||||
|
* - Buffer/Stream upload support
|
||||||
|
* - Auto bucket creation
|
||||||
|
* - Organized folder structure: images/segments/, images/thumbnails/
|
||||||
|
*
|
||||||
|
* TR: MinIO/S3 uyumlu nesne depolama servisi.
|
||||||
|
* Base64 görselleri URL'ye dönüştürür.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class StorageService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(StorageService.name);
|
||||||
|
private client: S3Client | null = null;
|
||||||
|
private isEnabled = false;
|
||||||
|
private bucket: string;
|
||||||
|
private publicUrl: string;
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {
|
||||||
|
this.bucket = this.configService.get<string>(
|
||||||
|
'storage.bucket',
|
||||||
|
'skriptai-assets',
|
||||||
|
);
|
||||||
|
this.publicUrl = this.configService.get<string>(
|
||||||
|
'storage.publicUrl',
|
||||||
|
'http://localhost:9000',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
const endpoint = this.configService.get<string>('storage.endpoint');
|
||||||
|
const accessKey = this.configService.get<string>('storage.accessKey');
|
||||||
|
const secretKey = this.configService.get<string>('storage.secretKey');
|
||||||
|
const enabled = this.configService.get<boolean>('storage.enabled', false);
|
||||||
|
|
||||||
|
if (!enabled || !endpoint || !accessKey || !secretKey) {
|
||||||
|
this.logger.log(
|
||||||
|
'Storage is disabled. Set STORAGE_ENABLED=true with MinIO credentials.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.client = new S3Client({
|
||||||
|
endpoint,
|
||||||
|
region: 'us-east-1', // Required but unused for MinIO
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: accessKey,
|
||||||
|
secretAccessKey: secretKey,
|
||||||
|
},
|
||||||
|
forcePathStyle: true, // Required for MinIO
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure bucket exists
|
||||||
|
await this.ensureBucket();
|
||||||
|
this.isEnabled = true;
|
||||||
|
this.logger.log(`✅ Storage connected: ${endpoint}/${this.bucket}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to connect to storage', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if storage is available
|
||||||
|
*/
|
||||||
|
isAvailable(): boolean {
|
||||||
|
return this.isEnabled && this.client !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a Base64 data URI to storage
|
||||||
|
*
|
||||||
|
* @param base64DataUri - Data URI (e.g., "data:image/png;base64,iVBOR...")
|
||||||
|
* @param folder - Target folder (e.g., "images/segments")
|
||||||
|
* @param filename - Optional filename (auto-generated if omitted)
|
||||||
|
* @returns Public URL of the uploaded file
|
||||||
|
*/
|
||||||
|
async uploadBase64(
|
||||||
|
base64DataUri: string,
|
||||||
|
folder: string = 'images',
|
||||||
|
filename?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
if (!this.isAvailable()) {
|
||||||
|
this.logger.warn('Storage not available, returning original data URI');
|
||||||
|
return base64DataUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse data URI
|
||||||
|
const matches = base64DataUri.match(
|
||||||
|
/^data:([a-zA-Z0-9]+\/[a-zA-Z0-9-.+]+);base64,(.+)$/,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!matches) {
|
||||||
|
this.logger.warn('Invalid data URI format, returning as-is');
|
||||||
|
return base64DataUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimeType = matches[1];
|
||||||
|
const base64Data = matches[2];
|
||||||
|
const buffer = Buffer.from(base64Data, 'base64');
|
||||||
|
|
||||||
|
// Determine extension from MIME type
|
||||||
|
const ext = this.mimeToExtension(mimeType);
|
||||||
|
const key = `${folder}/${filename || randomUUID()}.${ext}`;
|
||||||
|
|
||||||
|
return this.uploadBuffer(buffer, key, mimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a Buffer to storage
|
||||||
|
*
|
||||||
|
* @param buffer - File content buffer
|
||||||
|
* @param key - Storage key (path/filename)
|
||||||
|
* @param contentType - MIME type
|
||||||
|
* @returns Public URL
|
||||||
|
*/
|
||||||
|
async uploadBuffer(
|
||||||
|
buffer: Buffer,
|
||||||
|
key: string,
|
||||||
|
contentType: string,
|
||||||
|
): Promise<string> {
|
||||||
|
if (!this.isAvailable()) {
|
||||||
|
throw new Error('Storage service is not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.client!.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: this.bucket,
|
||||||
|
Key: key,
|
||||||
|
Body: buffer,
|
||||||
|
ContentType: contentType,
|
||||||
|
ACL: 'public-read',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const url = `${this.publicUrl}/${this.bucket}/${key}`;
|
||||||
|
this.logger.debug(`Uploaded: ${key} (${buffer.length} bytes) → ${url}`);
|
||||||
|
return url;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Upload failed for ${key}`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a file from storage
|
||||||
|
*
|
||||||
|
* @param key - Storage key or full URL
|
||||||
|
*/
|
||||||
|
async delete(keyOrUrl: string): Promise<void> {
|
||||||
|
if (!this.isAvailable()) return;
|
||||||
|
|
||||||
|
const key = keyOrUrl.startsWith('http')
|
||||||
|
? keyOrUrl.replace(`${this.publicUrl}/${this.bucket}/`, '')
|
||||||
|
: keyOrUrl;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.client!.send(
|
||||||
|
new DeleteObjectCommand({
|
||||||
|
Bucket: this.bucket,
|
||||||
|
Key: key,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.logger.debug(`Deleted: ${key}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Failed to delete ${key}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a segment image (convenience method)
|
||||||
|
* Converts base64 to URL and stores in images/segments/
|
||||||
|
*/
|
||||||
|
async uploadSegmentImage(
|
||||||
|
base64DataUri: string,
|
||||||
|
projectId: string,
|
||||||
|
segmentId: string,
|
||||||
|
): Promise<string> {
|
||||||
|
return this.uploadBase64(
|
||||||
|
base64DataUri,
|
||||||
|
`images/segments/${projectId}`,
|
||||||
|
segmentId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a thumbnail image
|
||||||
|
*/
|
||||||
|
async uploadThumbnail(
|
||||||
|
base64DataUri: string,
|
||||||
|
projectId: string,
|
||||||
|
): Promise<string> {
|
||||||
|
return this.uploadBase64(
|
||||||
|
base64DataUri,
|
||||||
|
`images/thumbnails`,
|
||||||
|
projectId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== HELPERS ==========
|
||||||
|
|
||||||
|
private async ensureBucket(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.client!.send(
|
||||||
|
new HeadBucketCommand({ Bucket: this.bucket }),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
this.logger.log(`Creating bucket: ${this.bucket}`);
|
||||||
|
await this.client!.send(
|
||||||
|
new CreateBucketCommand({ Bucket: this.bucket }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mimeToExtension(mime: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'image/png': 'png',
|
||||||
|
'image/jpeg': 'jpg',
|
||||||
|
'image/jpg': 'jpg',
|
||||||
|
'image/webp': 'webp',
|
||||||
|
'image/gif': 'gif',
|
||||||
|
'image/svg+xml': 'svg',
|
||||||
|
'application/pdf': 'pdf',
|
||||||
|
};
|
||||||
|
return map[mime] || 'bin';
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user