generated from fahricansecer/boilerplate-be
273 lines
8.9 KiB
TypeScript
273 lines
8.9 KiB
TypeScript
// 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;
|
|
sortBy?: string;
|
|
sortOrder?: 'asc' | 'desc';
|
|
},
|
|
) {
|
|
// 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 };
|
|
}
|
|
|
|
/**
|
|
* 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; imageUrl?: string }) {
|
|
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 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) {
|
|
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 };
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
};
|
|
}
|
|
}
|