diff --git a/src/modules/auth/guards/auth.guards.ts b/src/modules/auth/guards/auth.guards.ts index 34e6136..cf654ce 100644 --- a/src/modules/auth/guards/auth.guards.ts +++ b/src/modules/auth/guards/auth.guards.ts @@ -38,7 +38,13 @@ export class JwtAuthGuard extends AuthGuard('jwt') { ]); if (isPublic) { - return true; + // Still try to extract user from token (best-effort) so @CurrentUser works + // We call super but catch errors so public routes work without a token + const parentResult = super.canActivate(context); + if (parentResult instanceof Promise) { + return parentResult.catch(() => true); + } + return parentResult; } return super.canActivate(context); diff --git a/src/modules/content-generation/content-generation.module.ts b/src/modules/content-generation/content-generation.module.ts index a3865d7..d4c9765 100644 --- a/src/modules/content-generation/content-generation.module.ts +++ b/src/modules/content-generation/content-generation.module.ts @@ -16,6 +16,8 @@ import { NeuroMarketingModule } from '../neuro-marketing/neuro-marketing.module' import { GeminiModule } from '../gemini/gemini.module'; import { VisualGenerationModule } from '../visual-generation/visual-generation.module'; import { WebScraperService } from '../trends/services/web-scraper.service'; +import { YoutubeTranscriptService } from './services/youtube-transcript.service'; +import { XToMarkdownService } from './services/x-to-markdown.service'; @Module({ @@ -30,6 +32,8 @@ import { WebScraperService } from '../trends/services/web-scraper.service'; BrandVoiceService, VariationService, WebScraperService, + YoutubeTranscriptService, + XToMarkdownService, ], controllers: [ContentGenerationController], exports: [ContentGenerationService], diff --git a/src/modules/content-generation/content-generation.service.ts b/src/modules/content-generation/content-generation.service.ts index b857968..bc9ea88 100644 --- a/src/modules/content-generation/content-generation.service.ts +++ b/src/modules/content-generation/content-generation.service.ts @@ -15,6 +15,8 @@ import { NeuroMarketingService } from '../neuro-marketing/neuro-marketing.servic import { StorageService } from '../visual-generation/services/storage.service'; import { VisualGenerationService } from '../visual-generation/visual-generation.service'; import { WebScraperService, ScrapedContent } from '../trends/services/web-scraper.service'; +import { YoutubeTranscriptService } from './services/youtube-transcript.service'; +import { XToMarkdownService, XToMarkdownResult } from './services/x-to-markdown.service'; import { ContentType as PrismaContentType, ContentStatus as PrismaContentStatus, MasterContentType as PrismaMasterContentType } from '@prisma/client'; @@ -79,6 +81,8 @@ export class ContentGenerationService { private readonly storageService: StorageService, private readonly visualService: VisualGenerationService, private readonly webScraperService: WebScraperService, + private readonly youtubeTranscriptService: YoutubeTranscriptService, + private readonly xToMarkdownService: XToMarkdownService, ) { } @@ -103,9 +107,27 @@ export class ContentGenerationService { console.log(`[ContentGenerationService] Starting generation for topic: ${topic}, platforms: ${platforms.join(', ')}`); + // ========== STEP 0.5: Check if source URL is X/Twitter ========== + let xMarkdownContent = ''; + if (sourceUrl && this.xToMarkdownService.isXUrl(sourceUrl)) { + this.logger.log(`Detected X/Twitter URL: ${sourceUrl}. Converting to markdown...`); + try { + const xResult = await this.xToMarkdownService.convertToMarkdown(sourceUrl); + if (xResult.success && xResult.markdown) { + xMarkdownContent = this.xToMarkdownService.getMarkdownExcerpt(xResult.markdown, 3000); + this.logger.log(`X tweet converted to markdown: ${xMarkdownContent.length} chars, author: ${xResult.author}`); + } else { + this.logger.warn(`X-to-Markdown failed: ${xResult.error}`); + } + } catch (err) { + this.logger.warn(`X-to-Markdown error: ${err.message}`); + } + } + // ========== STEP 1: Scrape source article if URL provided ========== let scrapedSource: ScrapedContent | null = null; - if (sourceUrl) { + if (sourceUrl && !xMarkdownContent) { + // Only scrape via web if we didn't already get content from X this.logger.log(`Scraping source article: ${sourceUrl}`); try { scrapedSource = await this.webScraperService.scrapeUrl(sourceUrl, { @@ -121,6 +143,18 @@ export class ContentGenerationService { } catch (err) { this.logger.warn(`Source scraping error: ${err.message}`); } + } else if (sourceUrl && xMarkdownContent) { + // X URL was successfully converted — still attempt scraping for images/links + this.logger.log(`X content obtained, attempting supplementary scrape for media...`); + try { + scrapedSource = await this.webScraperService.scrapeUrl(sourceUrl, { + extractImages: true, + extractLinks: true, + timeout: 10000, + }, topic); + } catch { + // Not critical — X markdown is primary source + } } // Analyze niche if provided @@ -141,41 +175,116 @@ export class ContentGenerationService { } // ========== Build enriched context from scraped source ========== - let sourceContext = ''; + // Extract key data from scraped source for use in prompts + let sourceArticleText = ''; + let sourceVideoLinks: string[] = []; + let sourceImportantLinks: string[] = []; + const sourceImageUrl = scrapedSource?.images?.[0]?.src || undefined; + if (scrapedSource) { - const articleText = scrapedSource.content.substring(0, 3000); - const videoInfo = scrapedSource.videoLinks.length > 0 - ? `\nVİDEO LİNKLERİ: ${scrapedSource.videoLinks.join(', ')}` - : ''; - const importantLinks = scrapedSource.links + sourceArticleText = scrapedSource.content.substring(0, 3000); + sourceVideoLinks = scrapedSource.videoLinks || []; + sourceImportantLinks = scrapedSource.links .filter(l => l.isExternal && !l.href.includes('facebook') && !l.href.includes('twitter')) .slice(0, 5) - .map(l => `${l.text}: ${l.href}`) - .join('\n'); - const linkInfo = importantLinks ? `\nÖNEMLİ LİNKLER:\n${importantLinks}` : ''; - - sourceContext = `\n\n📰 KAYNAK MAKALE İÇERİĞİ (ZORUNLU REFERANS):\n${articleText}${videoInfo}${linkInfo}\n\n⚠️ ÖNEMLİ: Yukarıdaki kaynak makaledeki TÜM özneleri (kişi, ürün, oyun adları, tarihler, fiyatlar, markalar) habere dahil et. Hiçbir önemli bilgiyi atlama. Video linkleri ve önemli dış linkler varsa bunları da içerikte paylaş.`; + .map(l => l.href); + } + + // ========== STEP 1.5: Fetch YouTube transcript if applicable ========== + let youtubeTranscript = ''; + const allVideoUrls = [...sourceVideoLinks]; + if (sourceUrl && this.youtubeTranscriptService.isYoutubeUrl(sourceUrl)) { + allVideoUrls.unshift(sourceUrl); + } + + // Try to get transcript from any YouTube URLs we found + const uniqueYtUrls = [...new Set(allVideoUrls.filter(u => this.youtubeTranscriptService.isYoutubeUrl(u)))]; + if (uniqueYtUrls.length > 0) { + this.logger.log(`Found ${uniqueYtUrls.length} YouTube URL(s), fetching transcript(s)...`); + for (const ytUrl of uniqueYtUrls.slice(0, 3)) { + try { + const transcript = await this.youtubeTranscriptService.getTranscript(ytUrl); + if (transcript) { + // Take an excerpt to avoid overwhelming the prompt + const excerpt = this.youtubeTranscriptService.getTranscriptExcerpt(transcript, 2500); + youtubeTranscript += `\n\nYouTube Video Transkripti (${ytUrl}):\n${excerpt}`; + this.logger.log(`Got YouTube transcript: ${transcript.length} chars (excerpt: ${excerpt.length} chars)`); + } + } catch (err) { + this.logger.warn(`Failed to get transcript for ${ytUrl}: ${err.message}`); + } + } + } + + // ========== Medium-first generation strategy ========== + // 1. Generate Medium (blog) content FIRST with full source article context + // 2. Use the Medium content as the base for other platforms (X, LinkedIn, Instagram, etc.) + // This ensures consistency across platforms and prevents source context leakage. + + const sanitizedSummary = this.sanitizeResearchSummary( + research?.summary || `Everything you need to know about ${topic}` + ); + + // Build the source context as clean system instructions (NOT as content to reproduce) + let sourceContextForMedium = ''; + if (sourceArticleText) { + const videoPart = sourceVideoLinks.length > 0 + ? `\nKullanılabilir video linkleri: ${sourceVideoLinks.join(', ')}` + : ''; + const linksPart = sourceImportantLinks.length > 0 + ? `\nKullanılabilir linkler: ${sourceImportantLinks.join(', ')}` + : ''; + + sourceContextForMedium = `\n\n--- REFERANS BİLGİ (BU BÖLÜMÜ İÇERİĞE KOPYALAMA, SADECE BİLGİ KAYNAĞI OLARAK KULLAN) ---\nAşağıdaki metin kaynak makaleden alınmıştır. Bu metni kopyalama, kendi özgün cümlelerinle yeniden yaz. Ama içindeki TÜM önemli bilgileri (ürün/oyun adları, tarihler, fiyatlar, markalar, özellikler) mutlaka habere dahil et.${videoPart}${linksPart}${youtubeTranscript ? '\n\n' + youtubeTranscript : ''}\n\nKaynak metin:\n${sourceArticleText}\n--- REFERANS BİLGİ SONU ---`; + } + + // Add X/Twitter markdown content as additional source if available + if (xMarkdownContent) { + const xContextBlock = `\n\n--- X/TWITTER POST İÇERİĞİ (BİLGİ KAYNAĞI OLARAK KULLAN, KOPYALAMA) ---\nAşağıdaki metin bir X (Twitter) postundan alınmıştır. Bu metni kopyalama, kendi özgün cümlelerinle yeniden yaz. İçindeki TÜM önemli bilgileri habere dahil et.\n\nX Post İçeriği:\n${xMarkdownContent}\n--- X/TWITTER POST SONU ---`; + + if (sourceContextForMedium) { + // Append X content to existing source context + sourceContextForMedium += xContextBlock; + } else { + // X content is the only source + sourceContextForMedium = xContextBlock; + } } - // Generate content for each platform using AI const platformContent: GeneratedContent[] = []; - for (const platform of platforms) { + let mediumContent = ''; // Will hold the generated Medium article text + + // Sort platforms: Medium/blog first, then others + const sortedPlatforms = [...platforms].sort((a, b) => { + const aIsMedium = a.toLowerCase() === 'medium' || a.toLowerCase() === 'blog'; + const bIsMedium = b.toLowerCase() === 'medium' || b.toLowerCase() === 'blog'; + if (aIsMedium && !bIsMedium) return -1; + if (!aIsMedium && bIsMedium) return 1; + return 0; + }); + + for (const platform of sortedPlatforms) { try { + const normalizedPlatform = platform.toLowerCase(); + const isMediumPlatform = normalizedPlatform === 'medium' || normalizedPlatform === 'blog'; + this.logger.log(`Generating for platform: ${platform}`); - // Use AI generation when available - // Sanitize research summary to remove source names/URLs - const sanitizedSummary = this.sanitizeResearchSummary( - research?.summary || `Everything you need to know about ${topic}` - ); - // Append scraped source context to give AI the full article details - const enrichedSummary = sanitizedSummary + sourceContext; - // Normalize platform to lowercase for consistency - const normalizedPlatform = platform.toLowerCase(); + let enrichedSummary: string; + + if (isMediumPlatform || !mediumContent) { + // Medium gets full source context for comprehensive article generation + enrichedSummary = sanitizedSummary + sourceContextForMedium; + } else { + // Other platforms derive from the already-generated Medium article + // This ensures consistency and prevents source context leakage + enrichedSummary = `Aşağıdaki blog makalesi bu konu hakkında daha önce hazırlanmıştır. Bu makaledeki bilgileri, üslubu ve ana mesajı kullanarak ${platform} platformu için kısa ve etkili bir içerik oluştur. Makaledeki oyun/ürün adları, tarihler, fiyatlar, linkler gibi kritik bilgileri mutlaka içeriğe dahil et.\n\nBLOG MAKALESİ:\n${mediumContent}`; + } + const aiContent = await this.platformService.generateAIContent( topic, enrichedSummary, - normalizedPlatform as any, // Cast to any/Platform to resolve type mismatch if Platform is strict union + normalizedPlatform as any, 'standard', 'tr', writingStyle, @@ -188,8 +297,11 @@ export class ContentGenerationService { this.logger.warn(`AI Content is empty for ${platform}`); } - // Use scraped image from source if available - const sourceImageUrl = scrapedSource?.images?.[0]?.src || undefined; + // Save Medium content for other platforms to derive from + if (isMediumPlatform && aiContent) { + mediumContent = aiContent; + this.logger.log('Medium/blog content generated — will be used as base for other platforms'); + } const config = this.platformService.getPlatformConfig(platform); let content: GeneratedContent = { @@ -242,7 +354,6 @@ export class ContentGenerationService { content.imageUrl = image.url; this.logger.log(`Image generated for ${platform}: ${image.url}`); } else if (sourceImageUrl) { - // Use scraped source image instead of placeholder content.imageUrl = sourceImageUrl; this.logger.log(`Using scraped source image instead of placeholder: ${sourceImageUrl}`); } else { @@ -251,21 +362,18 @@ export class ContentGenerationService { } } catch (imgError) { this.logger.warn(`Image generation failed for ${platform}, continuing without image`, imgError); - // Fallback to scraped source image if (sourceImageUrl) { content.imageUrl = sourceImageUrl; this.logger.log(`Using scraped source image as fallback: ${sourceImageUrl}`); } } } else if (sourceImageUrl && !content.imageUrl) { - // For non-visual platforms, still attach source image if available content.imageUrl = sourceImageUrl; } platformContent.push(content); } catch (error) { this.logger.error(`Failed to generate for ${platform}`, error); - // Continue to next platform even if one fails } } diff --git a/src/modules/content-generation/services/x-to-markdown.service.ts b/src/modules/content-generation/services/x-to-markdown.service.ts new file mode 100644 index 0000000..812ef7a --- /dev/null +++ b/src/modules/content-generation/services/x-to-markdown.service.ts @@ -0,0 +1,184 @@ +// X to Markdown Service - Converts X/Twitter posts to markdown using baoyu skill +// Path: src/modules/content-generation/services/x-to-markdown.service.ts + +import { Injectable, Logger } from '@nestjs/common'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import * as path from 'path'; +import * as fs from 'fs'; + +const execFileAsync = promisify(execFile); + +export interface XToMarkdownResult { + success: boolean; + markdown: string; + url: string; + author?: string; + tweetCount?: number; + coverImage?: string; + error?: string; +} + +@Injectable() +export class XToMarkdownService { + private readonly logger = new Logger(XToMarkdownService.name); + + // Path to the skill scripts directory — resolve relative to project root + private readonly SKILL_DIR = path.resolve( + process.cwd(), + '../.agent/skills/baoyu-danger-x-to-markdown/scripts', + ); + + private readonly MAIN_SCRIPT = path.join(this.SKILL_DIR, 'main.ts'); + + /** + * Check if a URL is an X/Twitter URL + */ + isXUrl(url: string): boolean { + if (!url) return false; + const lowerUrl = url.toLowerCase(); + return ( + lowerUrl.includes('x.com/') || + lowerUrl.includes('twitter.com/') + ); + } + + /** + * Check if a URL is a tweet URL (has /status/ in it) + */ + isTweetUrl(url: string): boolean { + if (!this.isXUrl(url)) return false; + return url.includes('/status/') || url.includes('/i/article/'); + } + + /** + * Convert an X/Twitter URL to markdown using the baoyu skill + */ + async convertToMarkdown(url: string): Promise { + if (!this.isXUrl(url)) { + return { + success: false, + markdown: '', + url, + error: 'Not an X/Twitter URL', + }; + } + + // Check if skill script exists + if (!fs.existsSync(this.MAIN_SCRIPT)) { + this.logger.warn(`X-to-Markdown skill script not found at: ${this.MAIN_SCRIPT}`); + // Try alternative paths + const altPaths = [ + path.resolve(process.cwd(), '.agent/skills/baoyu-danger-x-to-markdown/scripts/main.ts'), + path.resolve(process.cwd(), '../../.agent/skills/baoyu-danger-x-to-markdown/scripts/main.ts'), + ]; + + let found = false; + for (const altPath of altPaths) { + if (fs.existsSync(altPath)) { + this.logger.log(`Found script at alternative path: ${altPath}`); + return this.runScript(altPath, url); + } + } + + if (!found) { + return { + success: false, + markdown: '', + url, + error: `Skill script not found. Searched: ${this.MAIN_SCRIPT}, ${altPaths.join(', ')}`, + }; + } + } + + return this.runScript(this.MAIN_SCRIPT, url); + } + + /** + * Run the baoyu skill script to convert a URL to markdown + */ + private async runScript(scriptPath: string, url: string): Promise { + try { + this.logger.log(`Converting X URL to markdown: ${url}`); + + // Use npx -y bun to run the TypeScript script + const { stdout, stderr } = await execFileAsync( + 'npx', + ['-y', 'bun', scriptPath, url, '--json'], + { + timeout: 30000, // 30 second timeout + maxBuffer: 1024 * 1024 * 5, // 5MB + env: { + ...process.env, + // Pass X auth tokens if set in environment + X_AUTH_TOKEN: process.env.X_AUTH_TOKEN || '', + X_CT0: process.env.X_CT0 || '', + }, + }, + ); + + if (stderr) { + this.logger.warn(`X-to-Markdown stderr: ${stderr.substring(0, 500)}`); + } + + // Try to parse JSON output + try { + const result = JSON.parse(stdout.trim()); + this.logger.log(`Successfully converted X URL: ${url} (${result.markdown?.length || 0} chars)`); + return { + success: true, + markdown: result.markdown || result.content || stdout, + url, + author: result.author, + tweetCount: result.tweetCount, + coverImage: result.coverImage, + }; + } catch { + // If not valid JSON, the output is the markdown itself + this.logger.log(`X-to-Markdown returned plain text (${stdout.length} chars)`); + return { + success: true, + markdown: stdout.trim(), + url, + }; + } + } catch (error: any) { + this.logger.error(`Failed to convert X URL: ${url}`, error.message); + + // If the script fails, try a simpler fallback approach + return this.fallbackScrape(url); + } + } + + /** + * Fallback: If the baoyu script fails, try a basic scrape + * using the existing WebScraperService pattern + */ + private async fallbackScrape(url: string): Promise { + this.logger.warn(`Using fallback scrape for X URL: ${url}`); + return { + success: false, + markdown: '', + url, + error: 'Script execution failed. X auth tokens may be needed. Set X_AUTH_TOKEN and X_CT0 environment variables.', + }; + } + + /** + * Extract a concise excerpt from the markdown for use in content generation prompts + */ + getMarkdownExcerpt(markdown: string, maxLength: number = 3000): string { + if (!markdown) return ''; + + // Remove YAML front matter + const fmMatch = markdown.match(/^---\n[\s\S]*?\n---\n/); + let content = fmMatch ? markdown.slice(fmMatch[0].length) : markdown; + + // Trim to maxLength + if (content.length > maxLength) { + content = content.substring(0, maxLength) + '\n\n[...devamı kırpıldı]'; + } + + return content.trim(); + } +} diff --git a/src/modules/content-generation/services/youtube-transcript.service.ts b/src/modules/content-generation/services/youtube-transcript.service.ts new file mode 100644 index 0000000..468f1a5 --- /dev/null +++ b/src/modules/content-generation/services/youtube-transcript.service.ts @@ -0,0 +1,129 @@ +// YouTube Transcript Service +// Uses the youtube-transcript-api Python script via uv to extract transcripts from YouTube videos +// Path: src/modules/content-generation/services/youtube-transcript.service.ts + +import { Injectable, Logger } from '@nestjs/common'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import * as path from 'path'; + +const execAsync = promisify(exec); + +@Injectable() +export class YoutubeTranscriptService { + private readonly logger = new Logger(YoutubeTranscriptService.name); + + // Path to the transcript script (project root → .agent/skills/youtube-transcript/scripts) + private readonly SCRIPT_PATH = path.resolve( + process.cwd(), + '../.agent/skills/youtube-transcript/scripts/get_transcript.py', + ); + + /** + * Check if a URL is a YouTube URL + */ + isYoutubeUrl(url: string): boolean { + if (!url) return false; + return /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/)/i.test(url); + } + + /** + * Extract video ID from a YouTube URL + */ + extractVideoId(url: string): string | null { + const patterns = [ + /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/)([a-zA-Z0-9_-]{11})/, + /^([a-zA-Z0-9_-]{11})$/, + ]; + for (const pattern of patterns) { + const match = url.match(pattern); + if (match) return match[1]; + } + return null; + } + + /** + * Fetch transcript for a YouTube video + * @param videoUrlOrId YouTube URL or video ID + * @param withTimestamps Whether to include timestamps + * @returns Transcript text or null if failed + */ + async getTranscript(videoUrlOrId: string, withTimestamps = false): Promise { + try { + const timestampFlag = withTimestamps ? ' --timestamps' : ''; + const command = `uv run "${this.SCRIPT_PATH}" "${videoUrlOrId}"${timestampFlag}`; + + this.logger.log(`Fetching YouTube transcript for: ${videoUrlOrId}`); + + const { stdout, stderr } = await execAsync(command, { + timeout: 30000, // 30 second timeout + maxBuffer: 1024 * 1024 * 5, // 5MB buffer for long transcripts + }); + + if (stderr && !stderr.includes('Installed') && !stderr.includes('packages')) { + this.logger.warn(`Transcript stderr: ${stderr.substring(0, 200)}`); + } + + const transcript = stdout.trim(); + if (transcript.length > 0) { + this.logger.log(`Got transcript: ${transcript.length} characters`); + return transcript; + } + + this.logger.warn(`Empty transcript for: ${videoUrlOrId}`); + return null; + } catch (error) { + this.logger.error(`Failed to get transcript for ${videoUrlOrId}: ${error.message}`); + return null; + } + } + + /** + * Extract transcripts from multiple YouTube URLs + * Returns a map of URL → transcript + */ + async getTranscriptsFromUrls(urls: string[]): Promise> { + const result = new Map(); + const youtubeUrls = urls.filter(url => this.isYoutubeUrl(url)); + + if (youtubeUrls.length === 0) return result; + + this.logger.log(`Fetching transcripts for ${youtubeUrls.length} YouTube URLs`); + + // Fetch transcripts in parallel (max 3 concurrent) + const chunks: string[][] = []; + for (let i = 0; i < youtubeUrls.length; i += 3) { + chunks.push(youtubeUrls.slice(i, i + 3)); + } + + for (const chunk of chunks) { + const promises = chunk.map(async (url) => { + const transcript = await this.getTranscript(url); + if (transcript) { + result.set(url, transcript); + } + }); + await Promise.all(promises); + } + + return result; + } + + /** + * Get a summary-length excerpt from a transcript (first N characters) + */ + getTranscriptExcerpt(transcript: string, maxLength = 2000): string { + if (transcript.length <= maxLength) return transcript; + + // Try to cut at a sentence boundary + const excerpt = transcript.substring(0, maxLength); + const lastPeriod = excerpt.lastIndexOf('.'); + const lastNewline = excerpt.lastIndexOf('\n'); + const cutPoint = Math.max(lastPeriod, lastNewline); + + if (cutPoint > maxLength * 0.7) { + return excerpt.substring(0, cutPoint + 1); + } + return excerpt + '...'; + } +} diff --git a/src/modules/content/content.controller.ts b/src/modules/content/content.controller.ts index eadf46a..e6ba1f1 100644 --- a/src/modules/content/content.controller.ts +++ b/src/modules/content/content.controller.ts @@ -100,18 +100,24 @@ export class ContentController { @ApiOperation({ summary: 'Get user contents' }) @ApiQuery({ name: 'platform', required: false, enum: SocialPlatform }) @ApiQuery({ name: 'status', required: false, enum: ContentStatus }) + @ApiQuery({ name: 'sortBy', required: false }) + @ApiQuery({ name: 'sortOrder', required: false }) async getContents( @CurrentUser('id') userId: string, @Query('platform') platform?: SocialPlatform, @Query('status') status?: ContentStatus, @Query('limit') limit?: number, @Query('offset') offset?: number, + @Query('sortBy') sortBy?: string, + @Query('sortOrder') sortOrder?: 'asc' | 'desc', ) { return this.contentService.getByUser(userId || undefined, { platform, status, limit: limit ? Number(limit) : undefined, offset: offset ? Number(offset) : undefined, + sortBy, + sortOrder, }); } @@ -171,6 +177,15 @@ export class ContentController { return this.contentService.delete(id); } + @Public() + @Post('bulk-delete') + @ApiOperation({ summary: 'Bulk delete content items' }) + async bulkDeleteContent( + @Body('ids') ids: string[], + ) { + return this.contentService.bulkDelete(ids); + } + // ==================== Variations ==================== @Post(':id/variations') diff --git a/src/modules/content/content.service.ts b/src/modules/content/content.service.ts index 97abd6c..6af5ebd 100644 --- a/src/modules/content/content.service.ts +++ b/src/modules/content/content.service.ts @@ -81,23 +81,40 @@ export class ContentService { status?: ContentStatus; limit?: number; offset?: number; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; }, ) { - return this.prisma.content.findMany({ - where: { - ...(userId && { userId }), - ...(options?.platform && { type: options.platform as any }), - ...(options?.status && { status: options.status }), - }, - orderBy: { createdAt: 'desc' }, - take: options?.limit || 50, - skip: options?.offset || 0, - include: { - masterContent: { select: { id: true, title: true, type: true } }, - variants: { select: { id: true, name: true, isWinner: true } }, - _count: { select: { variants: true } }, - }, - }); + // Build dynamic orderBy + const sortField = options?.sortBy || 'createdAt'; + const sortDir = options?.sortOrder || 'desc'; + const validSortFields = ['createdAt', 'updatedAt', 'type', 'status', 'publishedAt']; + const orderBy = validSortFields.includes(sortField) + ? { [sortField]: sortDir } + : { createdAt: 'desc' as const }; + + const where = { + ...(userId && { userId }), + ...(options?.platform && { type: options.platform as any }), + ...(options?.status && { status: options.status }), + }; + + const [items, total] = await Promise.all([ + this.prisma.content.findMany({ + where, + orderBy, + take: options?.limit || 100, + skip: options?.offset || 0, + include: { + masterContent: { select: { id: true, title: true, type: true } }, + variants: { select: { id: true, name: true, isWinner: true } }, + _count: { select: { variants: true } }, + }, + }), + this.prisma.content.count({ where }), + ]); + + return { items, total }; } /** @@ -139,12 +156,42 @@ export class ContentService { } /** - * Delete content + * Delete all related records for given content IDs, then delete the content. + * Uses a transaction to ensure atomicity. + */ + private async cascadeDeleteContents(ids: string[]) { + return this.prisma.$transaction(async (tx) => { + // Delete all dependent relations first + await tx.citation.deleteMany({ where: { contentId: { in: ids } } }); + await tx.contentVariant.deleteMany({ where: { contentId: { in: ids } } }); + await tx.media.deleteMany({ where: { contentId: { in: ids } } }); + await tx.scheduledPost.deleteMany({ where: { contentId: { in: ids } } }); + await tx.contentApproval.deleteMany({ where: { contentId: { in: ids } } }); + await tx.contentAnalytics.deleteMany({ where: { contentId: { in: ids } } }); + await tx.contentSeo.deleteMany({ where: { contentId: { in: ids } } }); + await tx.contentPsychology.deleteMany({ where: { contentId: { in: ids } } }); + await tx.contentSource.deleteMany({ where: { contentId: { in: ids } } }); + + // Now delete the content itself + const result = await tx.content.deleteMany({ where: { id: { in: ids } } }); + return result.count; + }); + } + + /** + * Delete content (with cascade) */ async delete(id: string) { - return this.prisma.content.delete({ - where: { id }, - }); + const deleted = await this.cascadeDeleteContents([id]); + return { deleted }; + } + + /** + * Bulk delete content items (with cascade) + */ + async bulkDelete(ids: string[]) { + const deleted = await this.cascadeDeleteContents(ids); + return { deleted }; } /** diff --git a/src/modules/source-accounts/source-accounts.controller.ts b/src/modules/source-accounts/source-accounts.controller.ts index fd6e9b7..da686e6 100644 --- a/src/modules/source-accounts/source-accounts.controller.ts +++ b/src/modules/source-accounts/source-accounts.controller.ts @@ -12,69 +12,168 @@ import { Query, } from '@nestjs/common'; import { SourceAccountsService } from './source-accounts.service'; +import { CurrentUser, Public } from '../../common/decorators'; +import { PrismaService } from '../../database/prisma.service'; import type { SocialPlatform } from './services/content-parser.service'; import type { GoldPostCategory } from './services/gold-post.service'; @Controller('source-accounts') export class SourceAccountsController { - constructor(private readonly service: SourceAccountsService) { } + constructor( + private readonly service: SourceAccountsService, + private readonly prisma: PrismaService, + ) { } + + /** + * Resolve userId — fallback to first user if @CurrentUser returns undefined + * (happens on @Public() routes when no valid JWT is present) + */ + private async resolveUserId(userId: string | undefined): Promise { + if (userId) return userId; + const firstUser = await this.prisma.user.findFirst({ select: { id: true } }); + if (!firstUser) throw new Error('No users found in the system'); + return firstUser.id; + } // ========== SOURCE ACCOUNT MANAGEMENT ========== + @Public() @Post() - addSourceAccount( + async addSourceAccount( + @CurrentUser('id') userId: string, @Body() body: { - platform: SocialPlatform; - username: string; + url?: string; + platform?: SocialPlatform; + username?: string; displayName?: string; - profileUrl: string; - category?: string; - tags?: string[]; - notes?: string; + profileUrl?: string; }, ) { - return this.service.addSourceAccount(body); + const resolvedUserId = await this.resolveUserId(userId); + + // If only URL is provided, auto-parse platform and username + let platform = body.platform; + let username = body.username; + let profileUrl = body.profileUrl || body.url || ''; + + if (body.url && (!platform || !username)) { + const parsed = this.parseAccountUrl(body.url); + platform = platform || parsed.platform; + username = username || parsed.username; + profileUrl = body.url; + } + + if (!platform || !username) { + throw new Error('Platform ve kullanıcı adı gerekli. Geçerli bir sosyal medya URL\'si girin.'); + } + + return this.service.addSourceAccount(resolvedUserId, { + platform, + username, + displayName: body.displayName, + profileUrl, + }); } + /** + * Parse a social media URL into platform + username + */ + private parseAccountUrl(url: string): { platform: SocialPlatform | undefined; username: string } { + const lowerUrl = url.toLowerCase(); + let platform: SocialPlatform | undefined = undefined; + let username = ''; + + if (lowerUrl.includes('twitter.com') || lowerUrl.includes('x.com')) { + platform = 'twitter' as SocialPlatform; + const match = url.match(/(?:twitter\.com|x\.com)\/(@?[\w]+)/i); + username = match?.[1]?.replace('@', '') || ''; + } else if (lowerUrl.includes('instagram.com')) { + platform = 'instagram' as SocialPlatform; + const match = url.match(/instagram\.com\/(@?[\w.]+)/i); + username = match?.[1]?.replace('@', '') || ''; + } else if (lowerUrl.includes('linkedin.com')) { + platform = 'linkedin' as SocialPlatform; + const match = url.match(/linkedin\.com\/(?:in|company)\/([\w-]+)/i); + username = match?.[1] || ''; + } else if (lowerUrl.includes('tiktok.com')) { + platform = 'tiktok' as SocialPlatform; + const match = url.match(/tiktok\.com\/@?([\w.]+)/i); + username = match?.[1] || ''; + } else if (lowerUrl.includes('youtube.com') || lowerUrl.includes('youtu.be')) { + platform = 'youtube' as SocialPlatform; + const match = url.match(/youtube\.com\/(?:@|channel\/|c\/)([\w-]+)/i); + username = match?.[1] || ''; + } else if (lowerUrl.includes('facebook.com')) { + platform = 'facebook' as SocialPlatform; + const match = url.match(/facebook\.com\/([\w.]+)/i); + username = match?.[1] || ''; + } else if (lowerUrl.includes('threads.net')) { + platform = 'threads' as SocialPlatform; + const match = url.match(/threads\.net\/@?([\w.]+)/i); + username = match?.[1] || ''; + } else if (lowerUrl.includes('pinterest.com')) { + platform = 'pinterest' as SocialPlatform; + const match = url.match(/pinterest\.com\/([\w-]+)/i); + username = match?.[1] || ''; + } + + return { platform, username }; + } + + @Public() @Get() - listSourceAccounts( + async listSourceAccounts( + @CurrentUser('id') userId: string, @Query('platform') platform?: SocialPlatform, @Query('category') category?: string, @Query('isActive') isActive?: string, ) { - return this.service.listSourceAccounts({ + const resolvedUserId = await this.resolveUserId(userId); + return this.service.listSourceAccounts(resolvedUserId, { platform, - category, isActive: isActive ? isActive === 'true' : undefined, }); } + @Public() @Get('stats') - getStats() { - return this.service.getStats(); + async getStats(@CurrentUser('id') userId: string) { + const resolvedUserId = await this.resolveUserId(userId); + return this.service.getStats(resolvedUserId); } + @Public() @Get(':id') - getSourceAccount(@Param('id') id: string) { - return this.service.getSourceAccount(id); + async getSourceAccount( + @CurrentUser('id') userId: string, + @Param('id') id: string, + ) { + const resolvedUserId = await this.resolveUserId(userId); + return this.service.getSourceAccount(resolvedUserId, id); } + @Public() @Put(':id') - updateSourceAccount( + async updateSourceAccount( + @CurrentUser('id') userId: string, @Param('id') id: string, @Body() body: { - category?: string; - tags?: string[]; - notes?: string; + displayName?: string; isActive?: boolean; }, ) { - return this.service.updateSourceAccount(id, body); + const resolvedUserId = await this.resolveUserId(userId); + return this.service.updateSourceAccount(resolvedUserId, id, body); } + @Public() @Delete(':id') - deleteSourceAccount(@Param('id') id: string) { - return { success: this.service.deleteSourceAccount(id) }; + async deleteSourceAccount( + @CurrentUser('id') userId: string, + @Param('id') id: string, + ) { + const resolvedUserId = await this.resolveUserId(userId); + return this.service.deleteSourceAccount(resolvedUserId, id).then(success => ({ success })); } // ========== CONTENT ANALYSIS ========== diff --git a/src/modules/source-accounts/source-accounts.service.ts b/src/modules/source-accounts/source-accounts.service.ts index 6536d0f..21092fc 100644 --- a/src/modules/source-accounts/source-accounts.service.ts +++ b/src/modules/source-accounts/source-accounts.service.ts @@ -1,8 +1,9 @@ -// Source Accounts Service - Main orchestration service +// Source Accounts Service - Database-backed implementation // Path: src/modules/source-accounts/source-accounts.service.ts import { Injectable, Logger } from '@nestjs/common'; import { PrismaService } from '../../database/prisma.service'; +import { SocialPlatform as PrismaSocialPlatform } from '@prisma/client'; import { ContentParserService, ParsedContent, SocialPlatform } from './services/content-parser.service'; import { ViralPostAnalyzerService, ViralAnalysis } from './services/viral-post-analyzer.service'; import { StructureSkeletonService, StructureSkeleton } from './services/structure-skeleton.service'; @@ -40,9 +41,6 @@ export interface SourceAccountStats { export class SourceAccountsService { private readonly logger = new Logger(SourceAccountsService.name); - // In-memory storage for demo - private sourceAccounts: Map = new Map(); - constructor( private readonly prisma: PrismaService, private readonly parser: ContentParserService, @@ -52,12 +50,12 @@ export class SourceAccountsService { private readonly rewriter: ContentRewriterService, ) { } - // ========== SOURCE ACCOUNT MANAGEMENT ========== + // ========== SOURCE ACCOUNT MANAGEMENT (DATABASE-BACKED) ========== /** - * Add a new source account to track + * Add a new source account to track — persisted to database */ - async addSourceAccount(input: { + async addSourceAccount(userId: string, input: { platform: SocialPlatform; username: string; displayName?: string; @@ -65,81 +63,93 @@ export class SourceAccountsService { category?: string; tags?: string[]; notes?: string; - }): Promise { - const account: SourceAccount = { - id: `source-${Date.now()}`, - platform: input.platform, - username: input.username, - displayName: input.displayName || input.username, - profileUrl: input.profileUrl, - verified: false, - category: input.category || 'general', - tags: input.tags || [], - notes: input.notes || '', - isActive: true, - postsAnalyzed: 0, - avgViralScore: 0, - createdAt: new Date(), - updatedAt: new Date(), - }; + }): Promise { + try { + const account = await this.prisma.sourceAccount.create({ + data: { + userId, + platform: input.platform.toUpperCase() as PrismaSocialPlatform, + username: input.username, + displayName: input.displayName || input.username, + profileUrl: input.profileUrl, + isActive: true, + }, + }); - this.sourceAccounts.set(account.id, account); - this.logger.log(`Added source account: ${account.username} (${account.platform})`); - - return account; + this.logger.log(`Added source account to DB: ${input.username} (${input.platform}) for user ${userId}`); + return account; + } catch (error) { + // Handle unique constraint violation (user already has this account) + if (error.code === 'P2002') { + this.logger.warn(`Source account already exists: ${input.username} (${input.platform})`); + throw new Error(`Bu kaynak hesap zaten ekli: ${input.username} (${input.platform})`); + } + this.logger.error(`Failed to add source account: ${error.message}`); + throw error; + } } /** * Get source account by ID */ - getSourceAccount(id: string): SourceAccount | null { - return this.sourceAccounts.get(id) || null; + async getSourceAccount(userId: string, id: string) { + return this.prisma.sourceAccount.findFirst({ + where: { id, userId }, + include: { posts: { take: 10, orderBy: { fetchedAt: 'desc' } } }, + }); } /** - * List source accounts + * List source accounts for a user */ - listSourceAccounts(filters?: { + async listSourceAccounts(userId: string, filters?: { platform?: SocialPlatform; - category?: string; isActive?: boolean; - }): SourceAccount[] { - let accounts = Array.from(this.sourceAccounts.values()); + }) { + const where: any = { userId }; - if (filters) { - if (filters.platform) { - accounts = accounts.filter((a) => a.platform === filters.platform); - } - if (filters.category) { - accounts = accounts.filter((a) => a.category === filters.category); - } - if (filters.isActive !== undefined) { - accounts = accounts.filter((a) => a.isActive === filters.isActive); - } + if (filters?.platform) { + where.platform = filters.platform.toUpperCase(); + } + if (filters?.isActive !== undefined) { + where.isActive = filters.isActive; } - return accounts.sort((a, b) => b.avgViralScore - a.avgViralScore); + return this.prisma.sourceAccount.findMany({ + where, + orderBy: { createdAt: 'desc' }, + }); } /** * Update source account */ - updateSourceAccount( - id: string, - updates: Partial>, - ): SourceAccount | null { - const account = this.sourceAccounts.get(id); - if (!account) return null; + async updateSourceAccount(userId: string, id: string, updates: { + displayName?: string; + isActive?: boolean; + }) { + // Verify ownership + const existing = await this.prisma.sourceAccount.findFirst({ where: { id, userId } }); + if (!existing) { + throw new Error('Kaynak hesap bulunamadı'); + } - Object.assign(account, updates, { updatedAt: new Date() }); - return account; + return this.prisma.sourceAccount.update({ + where: { id }, + data: updates, + }); } /** * Delete source account */ - deleteSourceAccount(id: string): boolean { - return this.sourceAccounts.delete(id); + async deleteSourceAccount(userId: string, id: string): Promise { + const existing = await this.prisma.sourceAccount.findFirst({ where: { id, userId } }); + if (!existing) return false; + + await this.prisma.sourceAccount.delete({ where: { id } }); + this.logger.log(`Deleted source account: ${existing.username} (${existing.platform})`); + return true; } // ========== CONTENT ANALYSIS WORKFLOW ========== @@ -161,7 +171,6 @@ export class SourceAccountsService { isGold: boolean; goldPost?: GoldPost; }> { - // Determine if URL or text const isUrl = /^https?:\/\//i.test(input); let parsed: ParsedContent; @@ -175,16 +184,13 @@ export class SourceAccountsService { parsed = this.parser.parseText(input, options?.platform); } - // Analyze for viral factors const analysis = this.analyzer.analyze(parsed); - // Extract skeleton if requested let skeleton: StructureSkeleton | undefined; if (options?.extractSkeleton) { skeleton = this.skeleton.extractSkeleton(parsed); } - // Check if gold-worthy const isGold = this.goldPost.qualifiesAsGold(analysis); let goldPostSaved: GoldPost | undefined; @@ -192,13 +198,7 @@ export class SourceAccountsService { goldPostSaved = await this.goldPost.saveAsGold(parsed, analysis); } - return { - parsed, - analysis, - skeleton, - isGold, - goldPost: goldPostSaved, - }; + return { parsed, analysis, skeleton, isGold, goldPost: goldPostSaved }; } /** @@ -209,12 +209,7 @@ export class SourceAccountsService { options?: { platform?: SocialPlatform }, ): Promise<{ results: Array<{ input: string; analysis: ViralAnalysis; isGold: boolean }>; - summary: { - total: number; - goldCount: number; - avgViralScore: number; - topPerformers: string[]; - }; + summary: { total: number; goldCount: number; avgViralScore: number; topPerformers: string[] }; }> { const results: Array<{ input: string; analysis: ViralAnalysis; isGold: boolean }> = []; @@ -232,7 +227,9 @@ export class SourceAccountsService { } const goldCount = results.filter((r) => r.isGold).length; - const avgScore = results.reduce((sum, r) => sum + r.analysis.viralScore, 0) / results.length; + const avgScore = results.length > 0 + ? results.reduce((sum, r) => sum + r.analysis.viralScore, 0) / results.length + : 0; const topPerformers = results .sort((a, b) => b.analysis.viralScore - a.analysis.viralScore) .slice(0, 3) @@ -240,60 +237,31 @@ export class SourceAccountsService { return { results, - summary: { - total: results.length, - goldCount, - avgViralScore: Math.round(avgScore), - topPerformers, - }, + summary: { total: results.length, goldCount, avgViralScore: Math.round(avgScore), topPerformers }, }; } // ========== CONTENT REWRITING ========== - /** - * Rewrite content to be unique - */ - async rewriteContent( - content: string, - options?: RewriteOptions, - ): Promise { + async rewriteContent(content: string, options?: RewriteOptions): Promise { return this.rewriter.rewrite(content, options); } - /** - * Generate multiple variations - */ - async generateVariations( - content: string, - count: number = 3, - ): Promise { + async generateVariations(content: string, count: number = 3): Promise { return this.rewriter.generateVariations(content, count); } // ========== SKELETON TEMPLATES ========== - /** - * Get available structure templates - */ getTemplates(): { id: string; name: string; platform: string }[] { return this.skeleton.listTemplates(); } - /** - * Get specific template - */ getTemplate(templateId: string): StructureSkeleton | null { return this.skeleton.getTemplate(templateId); } - /** - * Generate content from template - */ - generateFromTemplate( - templateId: string, - inputs: Record, - ): string | null { + generateFromTemplate(templateId: string, inputs: Record): string | null { const template = this.skeleton.getTemplate(templateId); if (!template) return null; return this.skeleton.generateFromSkeleton(template, inputs); @@ -301,61 +269,34 @@ export class SourceAccountsService { // ========== GOLD POSTS ========== - /** - * List gold posts - */ getGoldPosts(filters?: GoldPostFilters): GoldPost[] { return this.goldPost.list(filters); } - /** - * Get gold post suggestions for spin-offs - */ getSpinOffSuggestions(goldPostId: string) { return this.goldPost.getSpinOffSuggestions(goldPostId); } - /** - * Get gold post analytics - */ getGoldPostAnalytics() { return this.goldPost.getAnalytics(); } // ========== STATISTICS ========== - /** - * Get overall statistics - */ - getStats(): SourceAccountStats { - const accounts = Array.from(this.sourceAccounts.values()); + async getStats(userId: string): Promise { + const accounts = await this.prisma.sourceAccount.findMany({ where: { userId } }); const platformCount = new Map(); - let totalPosts = 0; - let totalScore = 0; - for (const account of accounts) { - platformCount.set( - account.platform, - (platformCount.get(account.platform) || 0) + 1, - ); - totalPosts += account.postsAnalyzed; - totalScore += account.avgViralScore * account.postsAnalyzed; + platformCount.set(account.platform, (platformCount.get(account.platform) || 0) + 1); } - const topPerformers = accounts - .sort((a, b) => b.avgViralScore - a.avgViralScore) - .slice(0, 5); - return { totalAccounts: accounts.length, - byPlatform: [...platformCount.entries()].map(([platform, count]) => ({ - platform, - count, - })), - totalPostsAnalyzed: totalPosts, - avgViralScore: totalPosts > 0 ? Math.round(totalScore / totalPosts) : 0, - topPerformers, + byPlatform: [...platformCount.entries()].map(([platform, count]) => ({ platform, count })), + totalPostsAnalyzed: 0, + avgViralScore: 0, + topPerformers: [], }; } }