generated from fahricansecer/boilerplate-be
This commit is contained in:
256
src/modules/content/content.controller.ts
Normal file
256
src/modules/content/content.controller.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
// Content Controller - API endpoints for content management
|
||||
// Path: src/modules/content/content.controller.ts
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { ContentService } from './content.service';
|
||||
import { MasterContentService } from './services/master-content.service';
|
||||
import type { CreateMasterContentDto } from './services/master-content.service';
|
||||
import { BuildingBlocksService } from './services/building-blocks.service';
|
||||
import { WritingStylesService } from './services/writing-styles.service';
|
||||
import type { WritingStyleConfig } from './services/writing-styles.service';
|
||||
import { ContentVariationsService } from './services/content-variations.service';
|
||||
import type { VariationConfig } from './services/content-variations.service';
|
||||
import { PlatformAdaptersService } from './services/platform-adapters.service';
|
||||
import { CurrentUser } from '../../common/decorators';
|
||||
import { SocialPlatform, ContentStatus, MasterContentType } from '@prisma/client';
|
||||
|
||||
@ApiTags('content')
|
||||
@ApiBearerAuth()
|
||||
@Controller('content')
|
||||
export class ContentController {
|
||||
constructor(
|
||||
private readonly contentService: ContentService,
|
||||
private readonly masterContentService: MasterContentService,
|
||||
private readonly buildingBlocksService: BuildingBlocksService,
|
||||
private readonly writingStylesService: WritingStylesService,
|
||||
private readonly variationsService: ContentVariationsService,
|
||||
private readonly platformsService: PlatformAdaptersService,
|
||||
) { }
|
||||
|
||||
// ==================== Master Content ====================
|
||||
|
||||
@Post('master')
|
||||
@ApiOperation({ summary: 'Create master content' })
|
||||
async createMaster(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Body() dto: CreateMasterContentDto,
|
||||
) {
|
||||
return this.masterContentService.create(userId, dto);
|
||||
}
|
||||
|
||||
@Get('master')
|
||||
@ApiOperation({ summary: 'Get user master contents' })
|
||||
@ApiQuery({ name: 'type', required: false, enum: MasterContentType })
|
||||
async getMasterContents(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Query('type') type?: MasterContentType,
|
||||
@Query('limit') limit?: number,
|
||||
@Query('offset') offset?: number,
|
||||
) {
|
||||
return this.masterContentService.getByUser(userId, {
|
||||
type,
|
||||
limit: limit ? Number(limit) : undefined,
|
||||
offset: offset ? Number(offset) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('master/:id')
|
||||
@ApiOperation({ summary: 'Get master content by ID' })
|
||||
async getMasterById(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.masterContentService.getById(id);
|
||||
}
|
||||
|
||||
@Post('master/:id/repurpose')
|
||||
@ApiOperation({ summary: 'Repurpose master content to multiple platforms' })
|
||||
async repurposeMaster(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('platforms') platforms: string[],
|
||||
) {
|
||||
return this.masterContentService.repurpose(id, platforms);
|
||||
}
|
||||
|
||||
// ==================== Platform Content ====================
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create platform-specific content' })
|
||||
async createContent(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Body() dto: { masterContentId: string; platform: SocialPlatform; body?: string; scheduledAt?: string },
|
||||
) {
|
||||
return this.contentService.createFromMaster(userId, {
|
||||
...dto,
|
||||
scheduledAt: dto.scheduledAt ? new Date(dto.scheduledAt) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get user contents' })
|
||||
@ApiQuery({ name: 'platform', required: false, enum: SocialPlatform })
|
||||
@ApiQuery({ name: 'status', required: false, enum: ContentStatus })
|
||||
async getContents(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Query('platform') platform?: SocialPlatform,
|
||||
@Query('status') status?: ContentStatus,
|
||||
@Query('limit') limit?: number,
|
||||
@Query('offset') offset?: number,
|
||||
) {
|
||||
return this.contentService.getByUser(userId, {
|
||||
platform,
|
||||
status,
|
||||
limit: limit ? Number(limit) : undefined,
|
||||
offset: offset ? Number(offset) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('calendar')
|
||||
@ApiOperation({ summary: 'Get content calendar' })
|
||||
async getCalendar(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Query('start') start: string,
|
||||
@Query('end') end: string,
|
||||
) {
|
||||
return this.contentService.getCalendar(userId, new Date(start), new Date(end));
|
||||
}
|
||||
|
||||
@Get('analytics')
|
||||
@ApiOperation({ summary: 'Get content analytics' })
|
||||
async getAnalytics(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Query('period') period: 'week' | 'month' | 'year' = 'month',
|
||||
) {
|
||||
return this.contentService.getAnalytics(userId, period);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get content by ID' })
|
||||
async getById(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.contentService.getById(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: 'Update content' })
|
||||
async updateContent(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: { body?: string; status?: ContentStatus; scheduledAt?: string },
|
||||
) {
|
||||
return this.contentService.update(id, {
|
||||
...dto,
|
||||
scheduledAt: dto.scheduledAt ? new Date(dto.scheduledAt) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Post(':id/publish')
|
||||
@ApiOperation({ summary: 'Publish content' })
|
||||
async publishContent(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('publishedUrl') publishedUrl?: string,
|
||||
) {
|
||||
return this.contentService.publish(id, publishedUrl);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete content' })
|
||||
async deleteContent(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.contentService.delete(id);
|
||||
}
|
||||
|
||||
// ==================== Variations ====================
|
||||
|
||||
@Post(':id/variations')
|
||||
@ApiOperation({ summary: 'Generate content variations' })
|
||||
async generateVariations(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() config: VariationConfig,
|
||||
) {
|
||||
return this.variationsService.generateVariations(id, config);
|
||||
}
|
||||
|
||||
@Get(':id/variations/metrics')
|
||||
@ApiOperation({ summary: 'Get variation performance metrics' })
|
||||
async getVariationMetrics(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.variationsService.getPerformanceMetrics(id);
|
||||
}
|
||||
|
||||
@Post(':id/variations/winner')
|
||||
@ApiOperation({ summary: 'Select winning variation' })
|
||||
async selectWinner(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('metric') metric: 'ctr' | 'engagementRate' | 'conversionRate',
|
||||
) {
|
||||
return this.variationsService.selectWinner(id, metric);
|
||||
}
|
||||
|
||||
// ==================== Writing Styles ====================
|
||||
|
||||
@Get('styles')
|
||||
@ApiOperation({ summary: 'Get all writing styles' })
|
||||
async getWritingStyles(@CurrentUser('id') userId: string) {
|
||||
return this.writingStylesService.getAll(userId);
|
||||
}
|
||||
|
||||
@Post('styles')
|
||||
@ApiOperation({ summary: 'Create custom writing style' })
|
||||
async createWritingStyle(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Body() config: WritingStyleConfig,
|
||||
) {
|
||||
return this.writingStylesService.create(userId, config);
|
||||
}
|
||||
|
||||
@Put('styles/:id/default')
|
||||
@ApiOperation({ summary: 'Set default writing style' })
|
||||
async setDefaultStyle(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('id') styleId: string,
|
||||
) {
|
||||
return this.writingStylesService.setDefault(userId, styleId);
|
||||
}
|
||||
|
||||
// ==================== Building Blocks ====================
|
||||
|
||||
@Get('blocks/:type')
|
||||
@ApiOperation({ summary: 'Get building blocks by type' })
|
||||
async getBuildingBlocks(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('type') type: string,
|
||||
@Query('limit') limit?: number,
|
||||
) {
|
||||
return this.buildingBlocksService.getByType(userId, type, limit ? Number(limit) : undefined);
|
||||
}
|
||||
|
||||
// ==================== Platforms ====================
|
||||
|
||||
@Get('platforms/config')
|
||||
@ApiOperation({ summary: 'Get all platform configurations' })
|
||||
async getPlatformConfigs() {
|
||||
return this.platformsService.getAllConfigs();
|
||||
}
|
||||
|
||||
@Get('platforms/:platform/config')
|
||||
@ApiOperation({ summary: 'Get specific platform config' })
|
||||
async getPlatformConfig(@Param('platform') platform: SocialPlatform) {
|
||||
return this.platformsService.getConfig(platform);
|
||||
}
|
||||
|
||||
@Post('platforms/adapt')
|
||||
@ApiOperation({ summary: 'Adapt content for platform' })
|
||||
async adaptContent(
|
||||
@Body() dto: { content: string; sourcePlatform: SocialPlatform; targetPlatform: SocialPlatform },
|
||||
) {
|
||||
return {
|
||||
adapted: this.platformsService.adapt(dto.content, dto.sourcePlatform, dto.targetPlatform),
|
||||
validation: this.platformsService.validate(dto.content, dto.targetPlatform),
|
||||
};
|
||||
}
|
||||
}
|
||||
25
src/modules/content/content.module.ts
Normal file
25
src/modules/content/content.module.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// Master Content Module - Content generation pipeline
|
||||
// Path: src/modules/content/content.module.ts
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ContentService } from './content.service';
|
||||
import { ContentController } from './content.controller';
|
||||
import { MasterContentService } from './services/master-content.service';
|
||||
import { BuildingBlocksService } from './services/building-blocks.service';
|
||||
import { WritingStylesService } from './services/writing-styles.service';
|
||||
import { ContentVariationsService } from './services/content-variations.service';
|
||||
import { PlatformAdaptersService } from './services/platform-adapters.service';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
ContentService,
|
||||
MasterContentService,
|
||||
BuildingBlocksService,
|
||||
WritingStylesService,
|
||||
ContentVariationsService,
|
||||
PlatformAdaptersService,
|
||||
],
|
||||
controllers: [ContentController],
|
||||
exports: [ContentService, MasterContentService],
|
||||
})
|
||||
export class ContentModule { }
|
||||
225
src/modules/content/content.service.ts
Normal file
225
src/modules/content/content.service.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
// Content Service - Main orchestrator for content operations
|
||||
// Path: src/modules/content/content.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { MasterContentService } from './services/master-content.service';
|
||||
import { BuildingBlocksService } from './services/building-blocks.service';
|
||||
import { WritingStylesService } from './services/writing-styles.service';
|
||||
import { ContentVariationsService } from './services/content-variations.service';
|
||||
import { PlatformAdaptersService } from './services/platform-adapters.service';
|
||||
import { SocialPlatform, ContentStatus } from '@prisma/client';
|
||||
|
||||
export interface CreateContentDto {
|
||||
masterContentId: string;
|
||||
platform: SocialPlatform;
|
||||
body?: string;
|
||||
scheduledAt?: Date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContentService {
|
||||
private readonly logger = new Logger(ContentService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly masterContent: MasterContentService,
|
||||
private readonly buildingBlocks: BuildingBlocksService,
|
||||
private readonly writingStyles: WritingStylesService,
|
||||
private readonly variations: ContentVariationsService,
|
||||
private readonly platforms: PlatformAdaptersService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Create platform-specific content from master content
|
||||
*/
|
||||
async createFromMaster(userId: string, dto: CreateContentDto) {
|
||||
const master = await this.masterContent.getById(dto.masterContentId);
|
||||
if (!master) {
|
||||
throw new Error('Master content not found');
|
||||
}
|
||||
|
||||
// Get platform config
|
||||
const platformConfig = this.platforms.getConfig(dto.platform);
|
||||
|
||||
// Adapt content for platform
|
||||
let body = dto.body || master.body || '';
|
||||
body = this.platforms.format(body, dto.platform);
|
||||
|
||||
// Validate content
|
||||
const validation = this.platforms.validate(body, dto.platform);
|
||||
if (!validation.valid) {
|
||||
this.logger.warn(`Content validation issues: ${validation.issues.join(', ')}`);
|
||||
}
|
||||
|
||||
// Create content record
|
||||
const content = await this.prisma.content.create({
|
||||
data: {
|
||||
userId,
|
||||
masterContentId: dto.masterContentId,
|
||||
type: dto.platform as any,
|
||||
body,
|
||||
status: dto.scheduledAt ? ContentStatus.SCHEDULED : ContentStatus.DRAFT,
|
||||
scheduledAt: dto.scheduledAt,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
content,
|
||||
platformConfig,
|
||||
validation,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's content with filters
|
||||
*/
|
||||
async getByUser(
|
||||
userId: string,
|
||||
options?: {
|
||||
platform?: SocialPlatform;
|
||||
status?: ContentStatus;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
},
|
||||
) {
|
||||
return this.prisma.content.findMany({
|
||||
where: {
|
||||
userId,
|
||||
...(options?.platform && { platform: options.platform }),
|
||||
...(options?.status && { status: options.status }),
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: options?.limit || 20,
|
||||
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 } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content by ID
|
||||
*/
|
||||
async getById(id: string) {
|
||||
return this.prisma.content.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
masterContent: true,
|
||||
variants: true,
|
||||
approvals: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update content
|
||||
*/
|
||||
async update(id: string, data: { body?: string; status?: ContentStatus; scheduledAt?: Date }) {
|
||||
return this.prisma.content.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish content (mark as published)
|
||||
*/
|
||||
async publish(id: string, publishedUrl?: string) {
|
||||
return this.prisma.content.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: ContentStatus.PUBLISHED,
|
||||
publishedAt: new Date(),
|
||||
publishedUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete content
|
||||
*/
|
||||
async delete(id: string) {
|
||||
return this.prisma.content.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content calendar for user
|
||||
*/
|
||||
async getCalendar(userId: string, startDate: Date, endDate: Date) {
|
||||
return this.prisma.content.findMany({
|
||||
where: {
|
||||
userId,
|
||||
OR: [
|
||||
{ scheduledAt: { gte: startDate, lte: endDate } },
|
||||
{ publishedAt: { gte: startDate, lte: endDate } },
|
||||
],
|
||||
},
|
||||
orderBy: [{ scheduledAt: 'asc' }, { publishedAt: 'asc' }],
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
status: true,
|
||||
scheduledAt: true,
|
||||
publishedAt: true,
|
||||
masterContent: { select: { title: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content analytics
|
||||
*/
|
||||
async getAnalytics(userId: string, period: 'week' | 'month' | 'year') {
|
||||
const now = new Date();
|
||||
let startDate: Date;
|
||||
|
||||
switch (period) {
|
||||
case 'week':
|
||||
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case 'month':
|
||||
startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case 'year':
|
||||
startDate = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
}
|
||||
|
||||
const contents = await this.prisma.content.findMany({
|
||||
where: {
|
||||
userId,
|
||||
publishedAt: { gte: startDate },
|
||||
},
|
||||
include: {
|
||||
variants: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Aggregate by platform
|
||||
const byPlatform = contents.reduce(
|
||||
(acc, content) => {
|
||||
if (!acc[content.type]) {
|
||||
acc[content.type] = { count: 0, totalEngagement: 0 };
|
||||
}
|
||||
acc[content.type].count++;
|
||||
acc[content.type].totalEngagement += content.variants.reduce(
|
||||
(sum, v) => sum + (v.engagements || 0),
|
||||
0,
|
||||
);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, { count: number; totalEngagement: number }>,
|
||||
);
|
||||
|
||||
return {
|
||||
totalPublished: contents.length,
|
||||
byPlatform,
|
||||
period,
|
||||
};
|
||||
}
|
||||
}
|
||||
9
src/modules/content/index.ts
Normal file
9
src/modules/content/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// Content Module Index
|
||||
export * from './content.module';
|
||||
export * from './content.service';
|
||||
export * from './content.controller';
|
||||
export * from './services/master-content.service';
|
||||
export * from './services/building-blocks.service';
|
||||
export * from './services/writing-styles.service';
|
||||
export * from './services/content-variations.service';
|
||||
export * from './services/platform-adapters.service';
|
||||
193
src/modules/content/services/building-blocks.service.ts
Normal file
193
src/modules/content/services/building-blocks.service.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
// Building Blocks Service - Extract reusable content elements
|
||||
// Path: src/modules/content/services/building-blocks.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../database/prisma.service';
|
||||
|
||||
export interface BuildingBlocks {
|
||||
hooks: string[];
|
||||
painPoints: string[];
|
||||
paradoxes: string[];
|
||||
quotes: string[];
|
||||
statistics: string[];
|
||||
callToActions: string[];
|
||||
metaphors: string[];
|
||||
stories: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BuildingBlocksService {
|
||||
private readonly logger = new Logger(BuildingBlocksService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) { }
|
||||
|
||||
/**
|
||||
* Extract building blocks from content/research
|
||||
*/
|
||||
async extract(title: string, researchNotes?: string): Promise<BuildingBlocks> {
|
||||
this.logger.log(`Extracting building blocks for: ${title}`);
|
||||
|
||||
// AI-powered extraction will be added here
|
||||
// For now, generate template blocks
|
||||
return {
|
||||
hooks: this.generateHooks(title),
|
||||
painPoints: this.generatePainPoints(title),
|
||||
paradoxes: this.generateParadoxes(title),
|
||||
quotes: [],
|
||||
statistics: [],
|
||||
callToActions: this.generateCTAs(title),
|
||||
metaphors: [],
|
||||
stories: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate platform-specific content from building blocks
|
||||
*/
|
||||
async generatePlatformContent(
|
||||
masterBody: string,
|
||||
blocks: BuildingBlocks,
|
||||
platform: string,
|
||||
): Promise<string[]> {
|
||||
const platformConfig = this.getPlatformConfig(platform);
|
||||
const variations: string[] = [];
|
||||
|
||||
// Generate variations based on platform constraints
|
||||
for (let i = 0; i < platformConfig.variationCount; i++) {
|
||||
const hook = blocks.hooks[i % blocks.hooks.length];
|
||||
const cta = blocks.callToActions[i % blocks.callToActions.length];
|
||||
|
||||
let content = '';
|
||||
if (platform === 'TWITTER') {
|
||||
content = this.generateTwitterContent(hook, masterBody, cta, platformConfig.maxLength);
|
||||
} else if (platform === 'LINKEDIN') {
|
||||
content = this.generateLinkedInContent(hook, masterBody, cta, platformConfig.maxLength);
|
||||
} else if (platform === 'INSTAGRAM') {
|
||||
content = this.generateInstagramContent(hook, masterBody, cta, platformConfig.maxLength);
|
||||
} else {
|
||||
content = this.generateGenericContent(hook, masterBody, cta, platformConfig.maxLength);
|
||||
}
|
||||
|
||||
variations.push(content);
|
||||
}
|
||||
|
||||
return variations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save building blocks for reuse
|
||||
*/
|
||||
async save(userId: string, masterContentId: string, blocks: BuildingBlocks) {
|
||||
return this.prisma.buildingBlock.createMany({
|
||||
data: [
|
||||
...blocks.hooks.map((text) => ({
|
||||
userId,
|
||||
masterContentId,
|
||||
type: 'HOOK' as const,
|
||||
content: text,
|
||||
})),
|
||||
...blocks.painPoints.map((text) => ({
|
||||
userId,
|
||||
masterContentId,
|
||||
type: 'PAIN_POINT' as const,
|
||||
content: text,
|
||||
})),
|
||||
...blocks.callToActions.map((text) => ({
|
||||
userId,
|
||||
masterContentId,
|
||||
type: 'CTA' as const,
|
||||
content: text,
|
||||
})),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get saved building blocks by type
|
||||
*/
|
||||
async getByType(userId: string, type: string, limit = 20) {
|
||||
return this.prisma.buildingBlock.findMany({
|
||||
where: { masterContent: { userId }, type: type as any },
|
||||
orderBy: { usageCount: 'desc' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
// Hook Templates
|
||||
private generateHooks(title: string): string[] {
|
||||
const templates = [
|
||||
`The biggest mistake people make with ${title} is...`,
|
||||
`What if everything you knew about ${title} was wrong?`,
|
||||
`I spent 5 years studying ${title}. Here's what I learned:`,
|
||||
`${title} doesn't work the way you think it does.`,
|
||||
`The hidden truth about ${title} that nobody talks about:`,
|
||||
`Why 95% of people fail at ${title} (and how to be in the 5%):`,
|
||||
`Stop doing this one thing with ${title}. It's costing you.`,
|
||||
`I was wrong about ${title}. Here's my updated take:`,
|
||||
];
|
||||
return templates;
|
||||
}
|
||||
|
||||
// Pain Point Templates
|
||||
private generatePainPoints(title: string): string[] {
|
||||
return [
|
||||
`Struggling to get results with ${title}?`,
|
||||
`Feeling overwhelmed by all the ${title} advice out there?`,
|
||||
`Tired of spending hours on ${title} with nothing to show for it?`,
|
||||
`Frustrated that ${title} seems to work for everyone but you?`,
|
||||
`Not sure where to start with ${title}?`,
|
||||
];
|
||||
}
|
||||
|
||||
// Paradox Templates
|
||||
private generateParadoxes(title: string): string[] {
|
||||
return [
|
||||
`The more you focus on ${title}, the less progress you make.`,
|
||||
`To master ${title}, you must first unlearn everything you know.`,
|
||||
`The best ${title} strategy is having no strategy at all.`,
|
||||
`Less effort in ${title} often leads to better results.`,
|
||||
];
|
||||
}
|
||||
|
||||
// CTA Templates
|
||||
private generateCTAs(title: string): string[] {
|
||||
return [
|
||||
`Want to learn more about ${title}? Link in bio.`,
|
||||
`Save this post for later. You'll need it.`,
|
||||
`Drop a 🔥 if this changed your perspective on ${title}.`,
|
||||
`Follow for more insights on ${title}.`,
|
||||
`Share this with someone who needs to hear it.`,
|
||||
`Comment "INFO" and I'll send you my complete ${title} guide.`,
|
||||
];
|
||||
}
|
||||
|
||||
// Platform configurations
|
||||
private getPlatformConfig(platform: string) {
|
||||
const configs: Record<string, { maxLength: number; variationCount: number }> = {
|
||||
TWITTER: { maxLength: 280, variationCount: 5 },
|
||||
LINKEDIN: { maxLength: 3000, variationCount: 3 },
|
||||
INSTAGRAM: { maxLength: 2200, variationCount: 3 },
|
||||
FACEBOOK: { maxLength: 63206, variationCount: 2 },
|
||||
TIKTOK: { maxLength: 300, variationCount: 3 },
|
||||
};
|
||||
return configs[platform] || { maxLength: 1000, variationCount: 2 };
|
||||
}
|
||||
|
||||
// Platform-specific generators
|
||||
private generateTwitterContent(hook: string, body: string, cta: string, maxLength: number): string {
|
||||
const content = `${hook}\n\n${body.substring(0, 150)}...\n\n${cta}`;
|
||||
return content.substring(0, maxLength);
|
||||
}
|
||||
|
||||
private generateLinkedInContent(hook: string, body: string, cta: string, maxLength: number): string {
|
||||
return `${hook}\n\n${body.substring(0, 2500)}\n\n---\n${cta}`;
|
||||
}
|
||||
|
||||
private generateInstagramContent(hook: string, body: string, cta: string, maxLength: number): string {
|
||||
return `${hook}\n\n${body.substring(0, 1800)}\n\n.\n.\n.\n${cta}`;
|
||||
}
|
||||
|
||||
private generateGenericContent(hook: string, body: string, cta: string, maxLength: number): string {
|
||||
return `${hook}\n\n${body}\n\n${cta}`.substring(0, maxLength);
|
||||
}
|
||||
}
|
||||
168
src/modules/content/services/content-variations.service.ts
Normal file
168
src/modules/content/services/content-variations.service.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
// Content Variations Service - Generate multiple content variations
|
||||
// Path: src/modules/content/services/content-variations.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../database/prisma.service';
|
||||
import { ContentVariant } from '@prisma/client';
|
||||
|
||||
export interface VariationConfig {
|
||||
count: number;
|
||||
strategy: 'ab_test' | 'audience_segment' | 'platform_specific' | 'time_based';
|
||||
variations: VariationSpec[];
|
||||
}
|
||||
|
||||
export interface VariationSpec {
|
||||
name: string;
|
||||
hook?: string;
|
||||
tone?: string;
|
||||
length?: 'shorter' | 'same' | 'longer';
|
||||
cta?: string;
|
||||
targetAudience?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContentVariationsService {
|
||||
private readonly logger = new Logger(ContentVariationsService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) { }
|
||||
|
||||
/**
|
||||
* Generate content variations for A/B testing
|
||||
*/
|
||||
async generateVariations(
|
||||
contentId: string,
|
||||
config: VariationConfig,
|
||||
): Promise<any[]> {
|
||||
const content = await this.prisma.content.findUnique({
|
||||
where: { id: contentId },
|
||||
});
|
||||
|
||||
if (!content) {
|
||||
throw new Error('Content not found');
|
||||
}
|
||||
|
||||
const variations: ContentVariant[] = [];
|
||||
|
||||
for (const spec of config.variations) {
|
||||
const variation = await this.createVariation(content, spec);
|
||||
variations.push(variation);
|
||||
}
|
||||
|
||||
return variations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single variation
|
||||
*/
|
||||
private async createVariation(
|
||||
originalContent: any,
|
||||
spec: VariationSpec,
|
||||
) {
|
||||
let variedBody = originalContent.body || '';
|
||||
|
||||
// Apply hook variation
|
||||
if (spec.hook) {
|
||||
variedBody = this.replaceHook(variedBody, spec.hook);
|
||||
}
|
||||
|
||||
// Apply length variation
|
||||
if (spec.length === 'shorter') {
|
||||
variedBody = this.shortenContent(variedBody, 0.7);
|
||||
} else if (spec.length === 'longer') {
|
||||
variedBody = this.expandContent(variedBody, 1.3);
|
||||
}
|
||||
|
||||
// Apply CTA variation
|
||||
if (spec.cta) {
|
||||
variedBody = this.replaceCTA(variedBody, spec.cta);
|
||||
}
|
||||
|
||||
// Save variation
|
||||
return this.prisma.contentVariant.create({
|
||||
data: {
|
||||
contentId: originalContent.id,
|
||||
name: spec.name,
|
||||
text: variedBody,
|
||||
platform: originalContent.platform,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get variation performance metrics
|
||||
*/
|
||||
async getPerformanceMetrics(contentId: string) {
|
||||
const variants = await this.prisma.contentVariant.findMany({
|
||||
where: { contentId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
impressions: true,
|
||||
clicks: true,
|
||||
engagements: true,
|
||||
shares: true,
|
||||
conversions: true,
|
||||
},
|
||||
});
|
||||
|
||||
return variants.map((v) => ({
|
||||
...v,
|
||||
ctr: v.impressions > 0 ? (v.clicks / v.impressions) * 100 : 0,
|
||||
engagementRate: v.impressions > 0 ? (v.engagements / v.impressions) * 100 : 0,
|
||||
conversionRate: v.clicks > 0 ? (v.conversions / v.clicks) * 100 : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Select best performing variation
|
||||
*/
|
||||
async selectWinner(contentId: string, metric: 'ctr' | 'engagementRate' | 'conversionRate') {
|
||||
const metrics = await this.getPerformanceMetrics(contentId);
|
||||
|
||||
if (metrics.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort by selected metric
|
||||
metrics.sort((a, b) => b[metric] - a[metric]);
|
||||
|
||||
const winner = metrics[0];
|
||||
|
||||
// Mark as winner
|
||||
await this.prisma.contentVariant.update({
|
||||
where: { id: winner.id },
|
||||
data: { isWinner: true },
|
||||
});
|
||||
|
||||
return winner;
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
private replaceHook(content: string, newHook: string): string {
|
||||
const lines = content.split('\n');
|
||||
if (lines.length > 0) {
|
||||
lines[0] = newHook;
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
private shortenContent(content: string, ratio: number): string {
|
||||
const words = content.split(/\s+/);
|
||||
const targetLength = Math.floor(words.length * ratio);
|
||||
return words.slice(0, targetLength).join(' ') + '...';
|
||||
}
|
||||
|
||||
private expandContent(content: string, ratio: number): string {
|
||||
// In real implementation, this would use AI to expand
|
||||
return content + '\n\n[Expanded content would be added here]';
|
||||
}
|
||||
|
||||
private replaceCTA(content: string, newCTA: string): string {
|
||||
const lines = content.split('\n');
|
||||
if (lines.length > 0) {
|
||||
lines[lines.length - 1] = newCTA;
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
}
|
||||
268
src/modules/content/services/master-content.service.ts
Normal file
268
src/modules/content/services/master-content.service.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
// Master Content Service - Hub for long-form content creation
|
||||
// Path: src/modules/content/services/master-content.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../database/prisma.service';
|
||||
import { BuildingBlocksService } from './building-blocks.service';
|
||||
import { WritingStylesService } from './writing-styles.service';
|
||||
import { MasterContentType, ContentLanguage } from '@prisma/client';
|
||||
|
||||
export interface CreateMasterContentDto {
|
||||
title: string;
|
||||
type: MasterContentType;
|
||||
nicheId?: string;
|
||||
trendId?: string;
|
||||
researchNotes?: string;
|
||||
targetAudience?: string;
|
||||
writingStyleId?: string;
|
||||
targetLanguage?: ContentLanguage;
|
||||
}
|
||||
|
||||
export interface GeneratedMasterContent {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
outline: string[];
|
||||
buildingBlocks: {
|
||||
hooks: string[];
|
||||
painPoints: string[];
|
||||
paradoxes: string[];
|
||||
quotes: string[];
|
||||
statistics: string[];
|
||||
};
|
||||
metadata: {
|
||||
wordCount: number;
|
||||
readingTime: number;
|
||||
seoScore?: number;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MasterContentService {
|
||||
private readonly logger = new Logger(MasterContentService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly buildingBlocks: BuildingBlocksService,
|
||||
private readonly writingStyles: WritingStylesService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Create master content (Blog, Newsletter, Podcast Script, Video Script)
|
||||
*/
|
||||
async create(
|
||||
userId: string,
|
||||
data: CreateMasterContentDto,
|
||||
): Promise<GeneratedMasterContent> {
|
||||
this.logger.log(`Creating ${data.type} master content: ${data.title}`);
|
||||
|
||||
// Get writing style
|
||||
const style = data.writingStyleId
|
||||
? await this.writingStyles.getById(data.writingStyleId)
|
||||
: await this.writingStyles.getDefault(userId);
|
||||
|
||||
// Generate outline based on content type
|
||||
const outline = this.generateOutline(data.type, data.title);
|
||||
|
||||
// Create master content record
|
||||
const masterContent = await this.prisma.masterContent.create({
|
||||
data: {
|
||||
userId,
|
||||
nicheId: data.nicheId,
|
||||
trendId: data.trendId,
|
||||
title: data.title,
|
||||
type: data.type,
|
||||
researchNotes: data.researchNotes,
|
||||
targetAudience: data.targetAudience,
|
||||
writingStyleId: style?.id,
|
||||
outline,
|
||||
status: 'DRAFT',
|
||||
body: '',
|
||||
},
|
||||
});
|
||||
|
||||
// Extract building blocks
|
||||
const blocks = await this.buildingBlocks.extract(data.title, data.researchNotes);
|
||||
|
||||
// Generate content body (placeholder - will integrate with AI)
|
||||
const body = this.generateContentBody(data.type, outline, blocks, style);
|
||||
|
||||
// Update with generated content
|
||||
await this.prisma.masterContent.update({
|
||||
where: { id: masterContent.id },
|
||||
data: {
|
||||
body,
|
||||
hooks: blocks.hooks,
|
||||
painPoints: blocks.painPoints,
|
||||
paradoxes: blocks.paradoxes,
|
||||
quotes: blocks.quotes,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: masterContent.id,
|
||||
title: data.title,
|
||||
body,
|
||||
outline,
|
||||
buildingBlocks: blocks,
|
||||
metadata: {
|
||||
wordCount: body.split(/\s+/).length,
|
||||
readingTime: Math.ceil(body.split(/\s+/).length / 200),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get master content by ID
|
||||
*/
|
||||
async getById(id: string) {
|
||||
return this.prisma.masterContent.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
niche: true,
|
||||
trend: true,
|
||||
writingStyle: true,
|
||||
contents: true,
|
||||
buildingBlocks: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's master contents
|
||||
*/
|
||||
async getByUser(
|
||||
userId: string,
|
||||
options?: {
|
||||
type?: MasterContentType;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
},
|
||||
) {
|
||||
return this.prisma.masterContent.findMany({
|
||||
where: {
|
||||
userId,
|
||||
...(options?.type && { type: options.type }),
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: options?.limit || 20,
|
||||
skip: options?.offset || 0,
|
||||
include: {
|
||||
niche: { select: { id: true, name: true } },
|
||||
_count: { select: { contents: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Repurpose master content into multiple pieces
|
||||
*/
|
||||
async repurpose(masterContentId: string, platforms: string[]) {
|
||||
const masterContent = await this.getById(masterContentId);
|
||||
if (!masterContent) {
|
||||
throw new Error('Master content not found');
|
||||
}
|
||||
|
||||
const blocks = {
|
||||
hooks: masterContent.buildingBlocks.filter(b => b.type === 'HOOK').map(b => b.content),
|
||||
painPoints: masterContent.buildingBlocks.filter(b => b.type === 'PAIN_POINT').map(b => b.content),
|
||||
paradoxes: masterContent.buildingBlocks.filter(b => b.type === 'PARADOX').map(b => b.content),
|
||||
quotes: masterContent.buildingBlocks.filter(b => b.type === 'QUOTE').map(b => b.content),
|
||||
statistics: masterContent.buildingBlocks.filter(b => b.type === 'STATISTIC').map(b => b.content),
|
||||
callToActions: masterContent.buildingBlocks.filter(b => b.type === 'CTA').map(b => b.content),
|
||||
metaphors: masterContent.buildingBlocks.filter(b => b.type === 'METAPHOR').map(b => b.content),
|
||||
stories: masterContent.buildingBlocks.filter(b => b.type === 'STORY').map(b => b.content),
|
||||
};
|
||||
|
||||
// Generate content for each platform
|
||||
const results: { platform: string; variations: string[] }[] = [];
|
||||
for (const platform of platforms) {
|
||||
const variations = await this.buildingBlocks.generatePlatformContent(
|
||||
masterContent.body || '',
|
||||
blocks,
|
||||
platform,
|
||||
);
|
||||
results.push({ platform, variations });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate outline based on content type
|
||||
*/
|
||||
private generateOutline(type: MasterContentType, title: string): string[] {
|
||||
const outlines: Record<MasterContentType, string[]> = {
|
||||
BLOG: [
|
||||
'Introduction - Hook & Problem Statement',
|
||||
'Background - Context & Why It Matters',
|
||||
'Main Point 1 - Key Insight',
|
||||
'Main Point 2 - Supporting Evidence',
|
||||
'Main Point 3 - Practical Application',
|
||||
'Objections & Counterarguments',
|
||||
'Conclusion - Call to Action',
|
||||
],
|
||||
NEWSLETTER: [
|
||||
'Attention-Grabbing Opening',
|
||||
'This Week\'s Key Insight',
|
||||
'Quick Win / Actionable Tip',
|
||||
'Deep Dive Section',
|
||||
'Resource of the Week',
|
||||
'Community Highlight',
|
||||
'What\'s Coming Next',
|
||||
],
|
||||
PODCAST_SCRIPT: [
|
||||
'Cold Open / Teaser',
|
||||
'Intro & Welcome',
|
||||
'Main Topic Introduction',
|
||||
'Segment 1 - Story/Context',
|
||||
'Segment 2 - Key Insights',
|
||||
'Segment 3 - Practical Takeaways',
|
||||
'Sponsor Break (if applicable)',
|
||||
'Listener Q&A / Community',
|
||||
'Outro & CTA',
|
||||
],
|
||||
VIDEO_SCRIPT: [
|
||||
'Hook (First 5 seconds)',
|
||||
'Promise / What They\'ll Learn',
|
||||
'Credibility Moment',
|
||||
'Main Content Section 1',
|
||||
'Main Content Section 2',
|
||||
'Main Content Section 3',
|
||||
'Recap & Summary',
|
||||
'Call to Action',
|
||||
'End Screen Prompt',
|
||||
],
|
||||
THREAD: [
|
||||
'Hook Tweet (Pattern Interrupt)',
|
||||
'Context / Problem',
|
||||
'Key Insight 1',
|
||||
'Key Insight 2',
|
||||
'Key Insight 3',
|
||||
'Proof / Example',
|
||||
'Summary',
|
||||
'CTA',
|
||||
],
|
||||
};
|
||||
|
||||
return outlines[type] || outlines.BLOG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate content body (placeholder for AI integration)
|
||||
*/
|
||||
private generateContentBody(
|
||||
type: MasterContentType,
|
||||
outline: string[],
|
||||
blocks: any,
|
||||
style: any,
|
||||
): string {
|
||||
// This will be replaced with actual AI generation
|
||||
const sections = outline.map((section, index) => {
|
||||
const hook = (blocks.hooks && blocks.hooks.length > 0) ? blocks.hooks[index % blocks.hooks.length] : '';
|
||||
return `## ${section}\n\n${hook}\n\n[Content for ${section} will be generated here]`;
|
||||
});
|
||||
|
||||
return sections.join('\n\n---\n\n');
|
||||
}
|
||||
}
|
||||
288
src/modules/content/services/platform-adapters.service.ts
Normal file
288
src/modules/content/services/platform-adapters.service.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
// Platform Adapters Service - Transform content for different platforms
|
||||
// Path: src/modules/content/services/platform-adapters.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { SocialPlatform } from '@prisma/client';
|
||||
|
||||
export interface PlatformConfig {
|
||||
name: string;
|
||||
maxLength: number;
|
||||
supportsMedia: boolean;
|
||||
supportsLinks: boolean;
|
||||
supportsHashtags: boolean;
|
||||
supportsMentions: boolean;
|
||||
supportsEmoji: boolean;
|
||||
mediaFormats?: string[];
|
||||
optimalLength?: number;
|
||||
bestPostingTimes?: string[];
|
||||
engagementTips?: string[];
|
||||
}
|
||||
|
||||
export const PLATFORM_CONFIGS: Record<SocialPlatform, PlatformConfig> = {
|
||||
TWITTER: {
|
||||
name: 'X (Twitter)',
|
||||
maxLength: 280,
|
||||
optimalLength: 240,
|
||||
supportsMedia: true,
|
||||
supportsLinks: true,
|
||||
supportsHashtags: true,
|
||||
supportsMentions: true,
|
||||
supportsEmoji: true,
|
||||
mediaFormats: ['image', 'video', 'gif'],
|
||||
bestPostingTimes: ['9:00', '12:00', '17:00', '21:00'],
|
||||
engagementTips: [
|
||||
'Use threads for longer content',
|
||||
'Ask questions to boost engagement',
|
||||
'Quote tweet with commentary',
|
||||
],
|
||||
},
|
||||
LINKEDIN: {
|
||||
name: 'LinkedIn',
|
||||
maxLength: 3000,
|
||||
optimalLength: 1300,
|
||||
supportsMedia: true,
|
||||
supportsLinks: true,
|
||||
supportsHashtags: true,
|
||||
supportsMentions: true,
|
||||
supportsEmoji: true,
|
||||
mediaFormats: ['image', 'video', 'document', 'carousel'],
|
||||
bestPostingTimes: ['7:30', '12:00', '17:00'],
|
||||
engagementTips: [
|
||||
'Hook in first line (before "see more")',
|
||||
'Use line breaks for readability',
|
||||
'End with a question',
|
||||
],
|
||||
},
|
||||
INSTAGRAM: {
|
||||
name: 'Instagram',
|
||||
maxLength: 2200,
|
||||
optimalLength: 1500,
|
||||
supportsMedia: true,
|
||||
supportsLinks: false,
|
||||
supportsHashtags: true,
|
||||
supportsMentions: true,
|
||||
supportsEmoji: true,
|
||||
mediaFormats: ['image', 'video', 'reels', 'carousel', 'stories'],
|
||||
bestPostingTimes: ['6:00', '11:00', '19:00'],
|
||||
engagementTips: [
|
||||
'Use up to 30 hashtags',
|
||||
'Put hashtags in first comment',
|
||||
'Include CTA in caption',
|
||||
],
|
||||
},
|
||||
FACEBOOK: {
|
||||
name: 'Facebook',
|
||||
maxLength: 63206,
|
||||
optimalLength: 250,
|
||||
supportsMedia: true,
|
||||
supportsLinks: true,
|
||||
supportsHashtags: true,
|
||||
supportsMentions: true,
|
||||
supportsEmoji: true,
|
||||
mediaFormats: ['image', 'video', 'reels', 'stories'],
|
||||
bestPostingTimes: ['9:00', '13:00', '19:00'],
|
||||
engagementTips: [
|
||||
'Shorter posts perform better',
|
||||
'Native video preferred',
|
||||
'Use Facebook Live for engagement',
|
||||
],
|
||||
},
|
||||
TIKTOK: {
|
||||
name: 'TikTok',
|
||||
maxLength: 300,
|
||||
optimalLength: 150,
|
||||
supportsMedia: true,
|
||||
supportsLinks: false,
|
||||
supportsHashtags: true,
|
||||
supportsMentions: true,
|
||||
supportsEmoji: true,
|
||||
mediaFormats: ['video'],
|
||||
bestPostingTimes: ['7:00', '10:00', '19:00', '23:00'],
|
||||
engagementTips: [
|
||||
'Hook in first 3 seconds',
|
||||
'Use trending sounds',
|
||||
'Keep videos under 60 seconds',
|
||||
],
|
||||
},
|
||||
YOUTUBE: {
|
||||
name: 'YouTube',
|
||||
maxLength: 5000,
|
||||
optimalLength: 500,
|
||||
supportsMedia: true,
|
||||
supportsLinks: true,
|
||||
supportsHashtags: true,
|
||||
supportsMentions: false,
|
||||
supportsEmoji: true,
|
||||
mediaFormats: ['video', 'shorts'],
|
||||
bestPostingTimes: ['12:00', '15:00', '19:00'],
|
||||
engagementTips: [
|
||||
'Use timestamps in description',
|
||||
'Include relevant keywords',
|
||||
'Add cards and end screens',
|
||||
],
|
||||
},
|
||||
PINTEREST: {
|
||||
name: 'Pinterest',
|
||||
maxLength: 500,
|
||||
optimalLength: 300,
|
||||
supportsMedia: true,
|
||||
supportsLinks: true,
|
||||
supportsHashtags: true,
|
||||
supportsMentions: false,
|
||||
supportsEmoji: true,
|
||||
mediaFormats: ['image', 'video', 'idea_pin'],
|
||||
bestPostingTimes: ['20:00', '21:00', '22:00'],
|
||||
engagementTips: [
|
||||
'Vertical images perform best',
|
||||
'Use keyword-rich descriptions',
|
||||
'Create multiple pins per post',
|
||||
],
|
||||
},
|
||||
THREADS: {
|
||||
name: 'Threads',
|
||||
maxLength: 500,
|
||||
optimalLength: 280,
|
||||
supportsMedia: true,
|
||||
supportsLinks: false,
|
||||
supportsHashtags: false,
|
||||
supportsMentions: true,
|
||||
supportsEmoji: true,
|
||||
mediaFormats: ['image', 'video'],
|
||||
bestPostingTimes: ['12:00', '17:00', '21:00'],
|
||||
engagementTips: [
|
||||
'Conversational tone works best',
|
||||
'Reply to trending topics',
|
||||
'Cross-post from Instagram',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PlatformAdaptersService {
|
||||
private readonly logger = new Logger(PlatformAdaptersService.name);
|
||||
|
||||
/**
|
||||
* Get platform configuration
|
||||
*/
|
||||
getConfig(platform: SocialPlatform): PlatformConfig {
|
||||
return PLATFORM_CONFIGS[platform];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all platform configs
|
||||
*/
|
||||
getAllConfigs(): Record<SocialPlatform, PlatformConfig> {
|
||||
return PLATFORM_CONFIGS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapt content for specific platform
|
||||
*/
|
||||
adapt(content: string, sourcePlatform: SocialPlatform, targetPlatform: SocialPlatform): string {
|
||||
const sourceConfig = PLATFORM_CONFIGS[sourcePlatform];
|
||||
const targetConfig = PLATFORM_CONFIGS[targetPlatform];
|
||||
|
||||
let adapted = content;
|
||||
|
||||
// Handle length constraints
|
||||
if (adapted.length > targetConfig.maxLength) {
|
||||
adapted = this.truncate(adapted, targetConfig.maxLength);
|
||||
}
|
||||
|
||||
// Handle link support
|
||||
if (!targetConfig.supportsLinks && sourceConfig.supportsLinks) {
|
||||
adapted = this.removeLinks(adapted);
|
||||
}
|
||||
|
||||
// Handle hashtag support
|
||||
if (!targetConfig.supportsHashtags && sourceConfig.supportsHashtags) {
|
||||
adapted = this.removeHashtags(adapted);
|
||||
}
|
||||
|
||||
return adapted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format content for platform
|
||||
*/
|
||||
format(content: string, platform: SocialPlatform): string {
|
||||
const config = PLATFORM_CONFIGS[platform];
|
||||
|
||||
switch (platform) {
|
||||
case 'TWITTER':
|
||||
return this.formatForTwitter(content, config);
|
||||
case 'LINKEDIN':
|
||||
return this.formatForLinkedIn(content, config);
|
||||
case 'INSTAGRAM':
|
||||
return this.formatForInstagram(content, config);
|
||||
default:
|
||||
return this.truncate(content, config.maxLength);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if content is valid for platform
|
||||
*/
|
||||
validate(content: string, platform: SocialPlatform): { valid: boolean; issues: string[] } {
|
||||
const config = PLATFORM_CONFIGS[platform];
|
||||
const issues: string[] = [];
|
||||
|
||||
if (content.length > config.maxLength) {
|
||||
issues.push(`Content exceeds ${config.maxLength} character limit`);
|
||||
}
|
||||
|
||||
if (!config.supportsLinks && this.containsLinks(content)) {
|
||||
issues.push('Platform does not support links in captions');
|
||||
}
|
||||
|
||||
return { valid: issues.length === 0, issues };
|
||||
}
|
||||
|
||||
// Private helpers
|
||||
private truncate(content: string, maxLength: number): string {
|
||||
if (content.length <= maxLength) return content;
|
||||
return content.substring(0, maxLength - 3) + '...';
|
||||
}
|
||||
|
||||
private removeLinks(content: string): string {
|
||||
return content.replace(/https?:\/\/[^\s]+/g, '[link in bio]');
|
||||
}
|
||||
|
||||
private removeHashtags(content: string): string {
|
||||
return content.replace(/#\w+/g, '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
private containsLinks(content: string): boolean {
|
||||
return /https?:\/\/[^\s]+/.test(content);
|
||||
}
|
||||
|
||||
private formatForTwitter(content: string, config: PlatformConfig): string {
|
||||
let formatted = content;
|
||||
|
||||
// Ensure hashtags at end
|
||||
const hashtags = formatted.match(/#\w+/g) || [];
|
||||
formatted = formatted.replace(/#\w+/g, '').trim();
|
||||
formatted = `${formatted}\n\n${hashtags.slice(0, 3).join(' ')}`;
|
||||
|
||||
return this.truncate(formatted, config.maxLength);
|
||||
}
|
||||
|
||||
private formatForLinkedIn(content: string, config: PlatformConfig): string {
|
||||
// Add line breaks for readability
|
||||
const lines = content.split(/(?<=[.!?])\s+/);
|
||||
return lines.slice(0, 20).join('\n\n');
|
||||
}
|
||||
|
||||
private formatForInstagram(content: string, config: PlatformConfig): string {
|
||||
let formatted = content;
|
||||
|
||||
// Add dots before hashtags (common Instagram style)
|
||||
if (formatted.includes('#')) {
|
||||
const mainContent = formatted.split('#')[0].trim();
|
||||
const hashtags = formatted.match(/#\w+/g) || [];
|
||||
formatted = `${mainContent}\n.\n.\n.\n${hashtags.join(' ')}`;
|
||||
}
|
||||
|
||||
return formatted;
|
||||
}
|
||||
}
|
||||
424
src/modules/content/services/writing-styles.service.ts
Normal file
424
src/modules/content/services/writing-styles.service.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
// Writing Styles Service - Extended with 15+ personality tones
|
||||
// Path: src/modules/content/services/writing-styles.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../database/prisma.service';
|
||||
import { ContentLanguage } from '@prisma/client';
|
||||
|
||||
export interface WritingStyleConfig {
|
||||
name: string;
|
||||
description?: string;
|
||||
tone: WritingTone;
|
||||
voice: 'first_person' | 'second_person' | 'third_person';
|
||||
vocabulary: 'simple' | 'intermediate' | 'advanced' | 'technical';
|
||||
sentenceLength: 'short' | 'medium' | 'long' | 'varied';
|
||||
emojiUsage: 'none' | 'minimal' | 'moderate' | 'heavy';
|
||||
hashtagStyle: 'none' | 'minimal' | 'topic_based' | 'trending';
|
||||
structurePreference: 'paragraphs' | 'bullets' | 'numbered' | 'mixed';
|
||||
engagementStyle: 'educational' | 'storytelling' | 'data_driven' | 'conversational' | 'provocative';
|
||||
signatureElements?: string[];
|
||||
avoidPhrases?: string[];
|
||||
preferredPhrases?: string[];
|
||||
language?: ContentLanguage;
|
||||
}
|
||||
|
||||
// 15+ Writing Tones
|
||||
export type WritingTone =
|
||||
| 'storyteller' // Narrative-driven, emotionally engaging
|
||||
| 'narrator' // Documentary style, observational
|
||||
| 'sarcastic' // Witty, ironic, sharp humor
|
||||
| 'inspirational' // Motivational, uplifting
|
||||
| 'professional' // Business-like, polished
|
||||
| 'casual' // Relaxed, conversational
|
||||
| 'friendly' // Warm, approachable
|
||||
| 'authoritative' // Expert, commanding
|
||||
| 'playful' // Fun, light-hearted
|
||||
| 'provocative' // Controversial, challenging
|
||||
| 'empathetic' // Understanding, supportive
|
||||
| 'analytical' // Data-focused, logical
|
||||
| 'humorous' // Comedy-driven, entertaining
|
||||
| 'minimalist' // Concise, direct
|
||||
| 'dramatic' // Intense, emotional
|
||||
| 'educational'; // Teaching, informative
|
||||
|
||||
export const WRITING_TONES: Record<WritingTone, { emoji: string; description: string; promptHint: string }> = {
|
||||
storyteller: {
|
||||
emoji: '📖',
|
||||
description: 'Narrative-driven, weaves stories to make points memorable',
|
||||
promptHint: 'Write like a master storyteller, using narrative techniques, character arcs, and emotional hooks',
|
||||
},
|
||||
narrator: {
|
||||
emoji: '🎙️',
|
||||
description: 'Documentary-style, observational and descriptive',
|
||||
promptHint: 'Write like a documentary narrator, observing and describing with clarity and depth',
|
||||
},
|
||||
sarcastic: {
|
||||
emoji: '😏',
|
||||
description: 'Witty, ironic, with sharp humor',
|
||||
promptHint: 'Write with sarcastic wit, using irony and clever observations that make readers think',
|
||||
},
|
||||
inspirational: {
|
||||
emoji: '✨',
|
||||
description: 'Motivational and uplifting content',
|
||||
promptHint: 'Write to inspire and motivate, using powerful language that uplifts the reader',
|
||||
},
|
||||
professional: {
|
||||
emoji: '💼',
|
||||
description: 'Business-like, polished and authoritative',
|
||||
promptHint: 'Write in a professional, business-appropriate tone with credibility and expertise',
|
||||
},
|
||||
casual: {
|
||||
emoji: '😊',
|
||||
description: 'Relaxed and conversational',
|
||||
promptHint: 'Write casually, like talking to a friend over coffee',
|
||||
},
|
||||
friendly: {
|
||||
emoji: '🤝',
|
||||
description: 'Warm, approachable and welcoming',
|
||||
promptHint: 'Write in a warm, friendly manner that makes readers feel comfortable and welcomed',
|
||||
},
|
||||
authoritative: {
|
||||
emoji: '👔',
|
||||
description: 'Expert voice with commanding presence',
|
||||
promptHint: 'Write with authority and expertise, establishing credibility and trust',
|
||||
},
|
||||
playful: {
|
||||
emoji: '🎉',
|
||||
description: 'Fun, light-hearted and entertaining',
|
||||
promptHint: 'Write playfully with humor, wordplay, and a light touch',
|
||||
},
|
||||
provocative: {
|
||||
emoji: '🔥',
|
||||
description: 'Controversial, thought-provoking',
|
||||
promptHint: 'Write to challenge assumptions and provoke thought, with bold statements',
|
||||
},
|
||||
empathetic: {
|
||||
emoji: '💙',
|
||||
description: 'Understanding and supportive',
|
||||
promptHint: 'Write with empathy, acknowledging struggles and offering understanding',
|
||||
},
|
||||
analytical: {
|
||||
emoji: '📊',
|
||||
description: 'Data-focused and logical',
|
||||
promptHint: 'Write analytically, using data, logic, and structured arguments',
|
||||
},
|
||||
humorous: {
|
||||
emoji: '😂',
|
||||
description: 'Comedy-driven, entertaining',
|
||||
promptHint: 'Write with humor, jokes, and entertainment value',
|
||||
},
|
||||
minimalist: {
|
||||
emoji: '🎯',
|
||||
description: 'Concise, direct, no fluff',
|
||||
promptHint: 'Write minimally, every word counts, eliminate all unnecessary words',
|
||||
},
|
||||
dramatic: {
|
||||
emoji: '🎭',
|
||||
description: 'Intense, emotional, theatrical',
|
||||
promptHint: 'Write dramatically with intensity, building tension and emotional impact',
|
||||
},
|
||||
educational: {
|
||||
emoji: '📚',
|
||||
description: 'Teaching-focused, informative',
|
||||
promptHint: 'Write to educate, explain concepts clearly with examples and structure',
|
||||
},
|
||||
};
|
||||
|
||||
// Preset Writing Styles (combining tone + other settings)
|
||||
export const PRESET_STYLES: Record<string, WritingStyleConfig> = {
|
||||
master_storyteller: {
|
||||
name: 'Master Storyteller',
|
||||
description: 'Narrative-driven, emotionally engaging content with story arcs',
|
||||
tone: 'storyteller',
|
||||
voice: 'first_person',
|
||||
vocabulary: 'intermediate',
|
||||
sentenceLength: 'varied',
|
||||
emojiUsage: 'minimal',
|
||||
hashtagStyle: 'none',
|
||||
structurePreference: 'paragraphs',
|
||||
engagementStyle: 'storytelling',
|
||||
preferredPhrases: ['Let me tell you...', 'Picture this:', 'Here\'s what happened:'],
|
||||
},
|
||||
sharp_sarcastic: {
|
||||
name: 'Sharp & Sarcastic',
|
||||
description: 'Witty observations with ironic humor',
|
||||
tone: 'sarcastic',
|
||||
voice: 'first_person',
|
||||
vocabulary: 'intermediate',
|
||||
sentenceLength: 'short',
|
||||
emojiUsage: 'minimal',
|
||||
hashtagStyle: 'none',
|
||||
structurePreference: 'paragraphs',
|
||||
engagementStyle: 'provocative',
|
||||
preferredPhrases: ['Oh sure,', 'Because obviously,', 'Shocking, I know.'],
|
||||
avoidPhrases: ['To be honest', 'Actually'],
|
||||
},
|
||||
documentary_narrator: {
|
||||
name: 'Documentary Narrator',
|
||||
description: 'Observational, descriptive, cinematic',
|
||||
tone: 'narrator',
|
||||
voice: 'third_person',
|
||||
vocabulary: 'advanced',
|
||||
sentenceLength: 'long',
|
||||
emojiUsage: 'none',
|
||||
hashtagStyle: 'none',
|
||||
structurePreference: 'paragraphs',
|
||||
engagementStyle: 'storytelling',
|
||||
},
|
||||
motivational_coach: {
|
||||
name: 'Motivational Coach',
|
||||
description: 'Inspiring, action-oriented, empowering',
|
||||
tone: 'inspirational',
|
||||
voice: 'second_person',
|
||||
vocabulary: 'simple',
|
||||
sentenceLength: 'short',
|
||||
emojiUsage: 'moderate',
|
||||
hashtagStyle: 'minimal',
|
||||
structurePreference: 'bullets',
|
||||
engagementStyle: 'conversational',
|
||||
preferredPhrases: ['You can do this!', 'Here\'s the truth:', 'Your time is now.'],
|
||||
},
|
||||
data_analyst: {
|
||||
name: 'Data Analyst',
|
||||
description: 'Facts, figures, and logical conclusions',
|
||||
tone: 'analytical',
|
||||
voice: 'first_person',
|
||||
vocabulary: 'technical',
|
||||
sentenceLength: 'medium',
|
||||
emojiUsage: 'none',
|
||||
hashtagStyle: 'topic_based',
|
||||
structurePreference: 'numbered',
|
||||
engagementStyle: 'data_driven',
|
||||
preferredPhrases: ['The data shows:', 'Research indicates:', 'Here are the numbers:'],
|
||||
},
|
||||
friendly_teacher: {
|
||||
name: 'Friendly Teacher',
|
||||
description: 'Educational, patient, encouraging',
|
||||
tone: 'educational',
|
||||
voice: 'second_person',
|
||||
vocabulary: 'simple',
|
||||
sentenceLength: 'short',
|
||||
emojiUsage: 'moderate',
|
||||
hashtagStyle: 'topic_based',
|
||||
structurePreference: 'numbered',
|
||||
engagementStyle: 'educational',
|
||||
preferredPhrases: ['Let me explain:', 'Think of it this way:', 'Here\'s a simple example:'],
|
||||
},
|
||||
corporate_executive: {
|
||||
name: 'Corporate Executive',
|
||||
description: 'Professional, strategic, leadership-focused',
|
||||
tone: 'professional',
|
||||
voice: 'first_person',
|
||||
vocabulary: 'advanced',
|
||||
sentenceLength: 'medium',
|
||||
emojiUsage: 'none',
|
||||
hashtagStyle: 'topic_based',
|
||||
structurePreference: 'mixed',
|
||||
engagementStyle: 'data_driven',
|
||||
},
|
||||
stand_up_comedian: {
|
||||
name: 'Stand-up Comedian',
|
||||
description: 'Funny, self-deprecating, observational humor',
|
||||
tone: 'humorous',
|
||||
voice: 'first_person',
|
||||
vocabulary: 'simple',
|
||||
sentenceLength: 'varied',
|
||||
emojiUsage: 'moderate',
|
||||
hashtagStyle: 'none',
|
||||
structurePreference: 'paragraphs',
|
||||
engagementStyle: 'conversational',
|
||||
},
|
||||
thought_provocateur: {
|
||||
name: 'Thought Provocateur',
|
||||
description: 'Bold statements, contrarian views, challenges assumptions',
|
||||
tone: 'provocative',
|
||||
voice: 'first_person',
|
||||
vocabulary: 'advanced',
|
||||
sentenceLength: 'varied',
|
||||
emojiUsage: 'none',
|
||||
hashtagStyle: 'none',
|
||||
structurePreference: 'paragraphs',
|
||||
engagementStyle: 'provocative',
|
||||
preferredPhrases: ['Unpopular opinion:', 'Hot take:', 'Everyone is wrong about:'],
|
||||
},
|
||||
zen_minimalist: {
|
||||
name: 'Zen Minimalist',
|
||||
description: 'Every word matters, no fluff, pure clarity',
|
||||
tone: 'minimalist',
|
||||
voice: 'second_person',
|
||||
vocabulary: 'simple',
|
||||
sentenceLength: 'short',
|
||||
emojiUsage: 'none',
|
||||
hashtagStyle: 'none',
|
||||
structurePreference: 'bullets',
|
||||
engagementStyle: 'educational',
|
||||
},
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class WritingStylesService {
|
||||
private readonly logger = new Logger(WritingStylesService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) { }
|
||||
|
||||
/**
|
||||
* Get all available tones
|
||||
*/
|
||||
getTones(): typeof WRITING_TONES {
|
||||
return WRITING_TONES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all preset styles
|
||||
*/
|
||||
getPresets(): typeof PRESET_STYLES {
|
||||
return PRESET_STYLES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a custom writing style
|
||||
*/
|
||||
async create(userId: string, config: WritingStyleConfig) {
|
||||
return this.prisma.writingStyle.create({
|
||||
data: {
|
||||
userId,
|
||||
name: config.name,
|
||||
type: 'CUSTOM',
|
||||
tone: config.tone,
|
||||
vocabulary: Array.isArray(config.vocabulary) ? config.vocabulary : [config.vocabulary],
|
||||
sentenceLength: config.sentenceLength,
|
||||
emojiUsage: config.emojiUsage,
|
||||
hashtagStyle: config.hashtagStyle,
|
||||
structurePreference: config.structurePreference,
|
||||
engagementStyle: config.engagementStyle,
|
||||
signatureElements: config.signatureElements || [],
|
||||
avoidWords: config.avoidPhrases || [],
|
||||
preferredPhrases: config.preferredPhrases || [],
|
||||
isDefault: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get writing style by ID
|
||||
*/
|
||||
async getById(id: string) {
|
||||
// Check if it's a preset
|
||||
if (id.startsWith('preset-')) {
|
||||
const presetKey = id.replace('preset-', '');
|
||||
return PRESET_STYLES[presetKey] ? { id, ...PRESET_STYLES[presetKey] } : null;
|
||||
}
|
||||
|
||||
return this.prisma.writingStyle.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's default writing style
|
||||
*/
|
||||
async getDefault(userId: string) {
|
||||
const userDefault = await this.prisma.writingStyle.findFirst({
|
||||
where: { userId, isDefault: true },
|
||||
});
|
||||
|
||||
return userDefault || { id: 'preset-master_storyteller', ...PRESET_STYLES.master_storyteller };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all user's writing styles (custom + presets)
|
||||
*/
|
||||
async getAll(userId: string) {
|
||||
const customStyles = await this.prisma.writingStyle.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const presets = Object.entries(PRESET_STYLES).map(([key, style]) => ({
|
||||
id: `preset-${key}`,
|
||||
...style,
|
||||
isPreset: true,
|
||||
}));
|
||||
|
||||
return [...customStyles, ...presets];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set default writing style
|
||||
*/
|
||||
async setDefault(userId: string, styleId: string) {
|
||||
await this.prisma.writingStyle.updateMany({
|
||||
where: { userId, isDefault: true },
|
||||
data: { isDefault: false },
|
||||
});
|
||||
|
||||
return this.prisma.writingStyle.update({
|
||||
where: { id: styleId },
|
||||
data: { isDefault: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate AI prompt for style
|
||||
*/
|
||||
generatePrompt(style: WritingStyleConfig, language?: ContentLanguage): string {
|
||||
const toneInfo = WRITING_TONES[style.tone];
|
||||
|
||||
let prompt = `
|
||||
WRITING STYLE INSTRUCTIONS:
|
||||
${toneInfo.promptHint}
|
||||
|
||||
STYLE PARAMETERS:
|
||||
- Tone: ${style.tone} (${toneInfo.description})
|
||||
- Voice: ${style.voice.replace('_', ' ')}
|
||||
- Vocabulary: ${style.vocabulary}
|
||||
- Sentence length: ${style.sentenceLength}
|
||||
- Emoji usage: ${style.emojiUsage}
|
||||
- Structure: ${style.structurePreference}
|
||||
- Engagement: ${style.engagementStyle}
|
||||
`;
|
||||
|
||||
if (style.preferredPhrases?.length) {
|
||||
prompt += `\n- Use phrases like: ${style.preferredPhrases.join(', ')}`;
|
||||
}
|
||||
|
||||
if (style.avoidPhrases?.length) {
|
||||
prompt += `\n- Avoid phrases: ${style.avoidPhrases.join(', ')}`;
|
||||
}
|
||||
|
||||
if (language) {
|
||||
prompt += `\n\nWRITE CONTENT IN: ${language}`;
|
||||
}
|
||||
|
||||
return prompt.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply writing style transformations
|
||||
*/
|
||||
applyStyle(content: string, style: WritingStyleConfig): string {
|
||||
let styledContent = content;
|
||||
|
||||
if (style.emojiUsage === 'none') {
|
||||
styledContent = styledContent.replace(/[\u{1F300}-\u{1F9FF}]/gu, '');
|
||||
}
|
||||
|
||||
if (style.structurePreference === 'bullets') {
|
||||
styledContent = this.convertToBullets(styledContent);
|
||||
} else if (style.structurePreference === 'numbered') {
|
||||
styledContent = this.convertToNumbered(styledContent);
|
||||
}
|
||||
|
||||
return styledContent;
|
||||
}
|
||||
|
||||
private convertToBullets(content: string): string {
|
||||
const sentences = content.split(/(?<=[.!?])\s+/);
|
||||
return sentences.map((s) => `• ${s.trim()}`).join('\n');
|
||||
}
|
||||
|
||||
private convertToNumbered(content: string): string {
|
||||
const sentences = content.split(/(?<=[.!?])\s+/);
|
||||
return sentences.map((s, i) => `${i + 1}. ${s.trim()}`).join('\n');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user