main
Some checks failed
Backend Deploy 🚀 / build-and-deploy (push) Has been cancelled

This commit is contained in:
Harun CAN
2026-03-28 17:16:56 +03:00
parent c1e081478c
commit a229fc1e64
9 changed files with 747 additions and 214 deletions

View File

@@ -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);

View File

@@ -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],

View File

@@ -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
}
}

View File

@@ -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<XToMarkdownResult> {
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<XToMarkdownResult> {
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<XToMarkdownResult> {
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();
}
}

View File

@@ -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<string | null> {
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<Map<string, string>> {
const result = new Map<string, string>();
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 + '...';
}
}

View File

@@ -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')

View File

@@ -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 };
}
/**

View File

@@ -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<string> {
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 ==========

View File

@@ -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<string, SourceAccount> = 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<SourceAccount> {
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<any> {
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<Pick<SourceAccount, 'category' | 'tags' | 'notes' | 'isActive'>>,
): 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<boolean> {
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<RewriteResult> {
async rewriteContent(content: string, options?: RewriteOptions): Promise<RewriteResult> {
return this.rewriter.rewrite(content, options);
}
/**
* Generate multiple variations
*/
async generateVariations(
content: string,
count: number = 3,
): Promise<RewriteResult[]> {
async generateVariations(content: string, count: number = 3): Promise<RewriteResult[]> {
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, string>,
): string | null {
generateFromTemplate(templateId: string, inputs: Record<string, string>): 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<SourceAccountStats> {
const accounts = await this.prisma.sourceAccount.findMany({ where: { userId } });
const platformCount = new Map<string, number>();
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: [],
};
}
}