generated from fahricansecer/boilerplate-be
This commit is contained in:
424
src/modules/social-integration/services/auto-publish.service.ts
Normal file
424
src/modules/social-integration/services/auto-publish.service.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user