generated from fahricansecer/boilerplate-be
425 lines
14 KiB
TypeScript
425 lines
14 KiB
TypeScript
// Auto Publish Service - Unified posting interface
|
|
// Path: src/modules/social-integration/services/auto-publish.service.ts
|
|
|
|
import { Injectable, Logger } from '@nestjs/common';
|
|
import { OAuthService, SocialPlatform, ConnectedAccount } from './oauth.service';
|
|
import { TwitterApiService } from './twitter-api.service';
|
|
import { InstagramApiService } from './instagram-api.service';
|
|
import { LinkedInApiService } from './linkedin-api.service';
|
|
import { FacebookApiService } from './facebook-api.service';
|
|
import { TikTokApiService } from './tiktok-api.service';
|
|
import { YouTubeApiService } from './youtube-api.service';
|
|
|
|
export interface PublishRequest {
|
|
userId: string;
|
|
platforms: SocialPlatform[];
|
|
content: UnifiedContent;
|
|
scheduledTime?: Date;
|
|
queuePosition?: number;
|
|
}
|
|
|
|
export interface UnifiedContent {
|
|
text: string;
|
|
mediaUrls?: string[];
|
|
mediaType?: 'image' | 'video';
|
|
link?: string;
|
|
thumbnail?: string;
|
|
hashtags?: string[];
|
|
mentions?: string[];
|
|
title?: string; // For YouTube
|
|
description?: string; // For YouTube
|
|
}
|
|
|
|
export interface PublishResult {
|
|
platform: SocialPlatform;
|
|
success: boolean;
|
|
postId?: string;
|
|
postUrl?: string;
|
|
error?: string;
|
|
publishedAt?: Date;
|
|
}
|
|
|
|
export interface QueuedPost {
|
|
id: string;
|
|
userId: string;
|
|
platform: SocialPlatform;
|
|
content: UnifiedContent;
|
|
scheduledTime: Date;
|
|
status: 'pending' | 'publishing' | 'published' | 'failed';
|
|
retryCount: number;
|
|
result?: PublishResult;
|
|
createdAt: Date;
|
|
}
|
|
|
|
export interface PublishStats {
|
|
totalPosts: number;
|
|
successfulPosts: number;
|
|
failedPosts: number;
|
|
platformBreakdown: Record<SocialPlatform, {
|
|
total: number;
|
|
successful: number;
|
|
failed: number;
|
|
}>;
|
|
}
|
|
|
|
@Injectable()
|
|
export class AutoPublishService {
|
|
private readonly logger = new Logger(AutoPublishService.name);
|
|
private queue: Map<string, QueuedPost[]> = new Map();
|
|
|
|
constructor(
|
|
private readonly oauthService: OAuthService,
|
|
private readonly twitterApi: TwitterApiService,
|
|
private readonly instagramApi: InstagramApiService,
|
|
private readonly linkedinApi: LinkedInApiService,
|
|
private readonly facebookApi: FacebookApiService,
|
|
private readonly tiktokApi: TikTokApiService,
|
|
private readonly youtubeApi: YouTubeApiService,
|
|
) { }
|
|
|
|
/**
|
|
* Publish to multiple platforms at once
|
|
*/
|
|
async publishToMultiplePlatforms(request: PublishRequest): Promise<PublishResult[]> {
|
|
const results: PublishResult[] = [];
|
|
|
|
for (const platform of request.platforms) {
|
|
try {
|
|
const account = this.oauthService.getAccountByPlatform(request.userId, platform);
|
|
if (!account) {
|
|
results.push({
|
|
platform,
|
|
success: false,
|
|
error: `No connected ${platform} account found`,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const tokens = await this.oauthService.ensureValidToken(account);
|
|
const adaptedContent = this.adaptContentForPlatform(request.content, platform);
|
|
const result = await this.publishToPlatform(platform, tokens.accessToken, adaptedContent, account);
|
|
results.push(result);
|
|
} catch (error) {
|
|
results.push({
|
|
platform,
|
|
success: false,
|
|
error: error.message,
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Schedule a post for later
|
|
*/
|
|
schedulePost(request: PublishRequest): QueuedPost[] {
|
|
const scheduled: QueuedPost[] = [];
|
|
|
|
for (const platform of request.platforms) {
|
|
const post: QueuedPost = {
|
|
id: `queue-${Date.now()}-${platform}`,
|
|
userId: request.userId,
|
|
platform,
|
|
content: this.adaptContentForPlatform(request.content, platform),
|
|
scheduledTime: request.scheduledTime || new Date(),
|
|
status: 'pending',
|
|
retryCount: 0,
|
|
createdAt: new Date(),
|
|
};
|
|
|
|
const userQueue = this.queue.get(request.userId) || [];
|
|
userQueue.push(post);
|
|
this.queue.set(request.userId, userQueue);
|
|
scheduled.push(post);
|
|
}
|
|
|
|
return scheduled;
|
|
}
|
|
|
|
/**
|
|
* Get user's queue
|
|
*/
|
|
getQueue(userId: string): QueuedPost[] {
|
|
return this.queue.get(userId) || [];
|
|
}
|
|
|
|
/**
|
|
* Cancel a scheduled post
|
|
*/
|
|
cancelScheduledPost(userId: string, postId: string): boolean {
|
|
const userQueue = this.queue.get(userId) || [];
|
|
const index = userQueue.findIndex((p) => p.id === postId);
|
|
|
|
if (index !== -1 && userQueue[index].status === 'pending') {
|
|
userQueue.splice(index, 1);
|
|
this.queue.set(userId, userQueue);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Reschedule a post
|
|
*/
|
|
reschedulePost(userId: string, postId: string, newTime: Date): boolean {
|
|
const userQueue = this.queue.get(userId) || [];
|
|
const post = userQueue.find((p) => p.id === postId);
|
|
|
|
if (post && post.status === 'pending') {
|
|
post.scheduledTime = newTime;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Get publishing statistics
|
|
*/
|
|
getPublishingStats(userId: string): PublishStats {
|
|
const userQueue = this.queue.get(userId) || [];
|
|
const stats: PublishStats = {
|
|
totalPosts: userQueue.length,
|
|
successfulPosts: userQueue.filter((p) => p.status === 'published').length,
|
|
failedPosts: userQueue.filter((p) => p.status === 'failed').length,
|
|
platformBreakdown: {} as any,
|
|
};
|
|
|
|
const platforms: SocialPlatform[] = ['twitter', 'instagram', 'linkedin', 'facebook', 'tiktok', 'youtube'];
|
|
for (const platform of platforms) {
|
|
const platformPosts = userQueue.filter((p) => p.platform === platform);
|
|
stats.platformBreakdown[platform] = {
|
|
total: platformPosts.length,
|
|
successful: platformPosts.filter((p) => p.status === 'published').length,
|
|
failed: platformPosts.filter((p) => p.status === 'failed').length,
|
|
};
|
|
}
|
|
|
|
return stats;
|
|
}
|
|
|
|
/**
|
|
* Get optimal posting schedule
|
|
*/
|
|
getOptimalSchedule(platforms: SocialPlatform[]): Record<SocialPlatform, {
|
|
bestTimes: { day: string; time: string; engagement: string }[];
|
|
}> {
|
|
const schedule: any = {};
|
|
|
|
for (const platform of platforms) {
|
|
switch (platform) {
|
|
case 'twitter':
|
|
schedule[platform] = {
|
|
bestTimes: [
|
|
{ day: 'Wednesday', time: '09:00', engagement: 'Peak' },
|
|
{ day: 'Friday', time: '11:00', engagement: 'High' },
|
|
],
|
|
};
|
|
break;
|
|
case 'instagram':
|
|
schedule[platform] = {
|
|
bestTimes: [
|
|
{ day: 'Tuesday', time: '11:00', engagement: 'Peak' },
|
|
{ day: 'Wednesday', time: '14:00', engagement: 'High' },
|
|
],
|
|
};
|
|
break;
|
|
case 'linkedin':
|
|
schedule[platform] = {
|
|
bestTimes: [
|
|
{ day: 'Tuesday', time: '10:00', engagement: 'Peak' },
|
|
{ day: 'Wednesday', time: '12:00', engagement: 'High' },
|
|
],
|
|
};
|
|
break;
|
|
case 'tiktok':
|
|
schedule[platform] = {
|
|
bestTimes: [
|
|
{ day: 'Thursday', time: '19:00', engagement: 'Peak' },
|
|
{ day: 'Saturday', time: '20:00', engagement: 'Peak' },
|
|
],
|
|
};
|
|
break;
|
|
default:
|
|
schedule[platform] = {
|
|
bestTimes: [
|
|
{ day: 'Wednesday', time: '12:00', engagement: 'Medium' },
|
|
],
|
|
};
|
|
}
|
|
}
|
|
|
|
return schedule;
|
|
}
|
|
|
|
/**
|
|
* Preview how content will look on each platform
|
|
*/
|
|
previewContent(content: UnifiedContent, platforms: SocialPlatform[]): Record<SocialPlatform, {
|
|
adaptedContent: UnifiedContent;
|
|
truncated: boolean;
|
|
warnings: string[];
|
|
}> {
|
|
const previews: any = {};
|
|
|
|
for (const platform of platforms) {
|
|
const adapted = this.adaptContentForPlatform(content, platform);
|
|
const limits = this.getCharacterLimit(platform);
|
|
|
|
previews[platform] = {
|
|
adaptedContent: adapted,
|
|
truncated: content.text.length > limits,
|
|
warnings: this.getContentWarnings(content, platform),
|
|
};
|
|
}
|
|
|
|
return previews;
|
|
}
|
|
|
|
// Private methods
|
|
|
|
private async publishToPlatform(
|
|
platform: SocialPlatform,
|
|
accessToken: string,
|
|
content: UnifiedContent,
|
|
account: ConnectedAccount,
|
|
): Promise<PublishResult> {
|
|
try {
|
|
let postId: string;
|
|
let postUrl: string = '';
|
|
|
|
switch (platform) {
|
|
case 'twitter':
|
|
const tweet = await this.twitterApi.postTweet(accessToken, { text: content.text });
|
|
postId = tweet.id;
|
|
postUrl = `https://twitter.com/i/status/${postId}`;
|
|
break;
|
|
|
|
case 'instagram':
|
|
if (content.mediaUrls?.[0]) {
|
|
const igPost = await this.instagramApi.createPost(accessToken, {
|
|
mediaUrl: content.mediaUrls[0],
|
|
mediaType: content.mediaType || 'image',
|
|
caption: content.text,
|
|
});
|
|
postId = igPost.id;
|
|
} else {
|
|
throw new Error('Instagram requires media');
|
|
}
|
|
break;
|
|
|
|
case 'linkedin':
|
|
const liPost = await this.linkedinApi.createPost(accessToken, { text: content.text });
|
|
postId = liPost.id;
|
|
break;
|
|
|
|
case 'facebook':
|
|
const fbPost = await this.facebookApi.createPost(accessToken, 'page-id', { message: content.text });
|
|
postId = fbPost.id;
|
|
break;
|
|
|
|
case 'tiktok':
|
|
if (content.mediaUrls?.[0] && content.mediaType === 'video') {
|
|
const tikTok = await this.tiktokApi.uploadVideo(accessToken, {
|
|
videoUrl: content.mediaUrls[0],
|
|
caption: content.text,
|
|
});
|
|
postId = tikTok.id;
|
|
} else {
|
|
throw new Error('TikTok requires video');
|
|
}
|
|
break;
|
|
|
|
case 'youtube':
|
|
if (content.mediaUrls?.[0] && content.mediaType === 'video') {
|
|
const ytVideo = await this.youtubeApi.uploadVideo(accessToken, {
|
|
videoUrl: content.mediaUrls[0],
|
|
title: content.title || 'Untitled',
|
|
description: content.description || content.text,
|
|
});
|
|
postId = ytVideo.id;
|
|
postUrl = `https://youtube.com/watch?v=${postId}`;
|
|
} else {
|
|
throw new Error('YouTube requires video');
|
|
}
|
|
break;
|
|
|
|
default:
|
|
throw new Error(`Unsupported platform: ${platform}`);
|
|
}
|
|
|
|
return {
|
|
platform,
|
|
success: true,
|
|
postId,
|
|
postUrl: postUrl || `https://${platform}.com/post/${postId}`,
|
|
publishedAt: new Date(),
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
platform,
|
|
success: false,
|
|
error: error.message,
|
|
};
|
|
}
|
|
}
|
|
|
|
private adaptContentForPlatform(content: UnifiedContent, platform: SocialPlatform): UnifiedContent {
|
|
const limit = this.getCharacterLimit(platform);
|
|
let text = content.text;
|
|
|
|
// Add hashtags if provided
|
|
if (content.hashtags?.length) {
|
|
const hashtagString = content.hashtags.map((h) => h.startsWith('#') ? h : `#${h}`).join(' ');
|
|
|
|
if (text.length + hashtagString.length + 2 <= limit) {
|
|
text = `${text}\n\n${hashtagString}`;
|
|
}
|
|
}
|
|
|
|
// Truncate if needed
|
|
if (text.length > limit) {
|
|
text = text.substring(0, limit - 3) + '...';
|
|
}
|
|
|
|
return {
|
|
...content,
|
|
text,
|
|
};
|
|
}
|
|
|
|
private getCharacterLimit(platform: SocialPlatform): number {
|
|
const limits: Record<SocialPlatform, number> = {
|
|
twitter: 280,
|
|
instagram: 2200,
|
|
linkedin: 3000,
|
|
facebook: 63206,
|
|
tiktok: 2200,
|
|
youtube: 5000,
|
|
threads: 500,
|
|
pinterest: 500,
|
|
};
|
|
return limits[platform] || 280;
|
|
}
|
|
|
|
private getContentWarnings(content: UnifiedContent, platform: SocialPlatform): string[] {
|
|
const warnings: string[] = [];
|
|
const limit = this.getCharacterLimit(platform);
|
|
|
|
if (content.text.length > limit) {
|
|
warnings.push(`Content exceeds ${platform} character limit (${limit})`);
|
|
}
|
|
|
|
if (['instagram', 'tiktok'].includes(platform) && !content.mediaUrls?.length) {
|
|
warnings.push(`${platform} requires media (image or video)`);
|
|
}
|
|
|
|
if (platform === 'youtube' && (!content.title || !content.description)) {
|
|
warnings.push('YouTube videos require a title and description');
|
|
}
|
|
|
|
return warnings;
|
|
}
|
|
}
|