import axios, { AxiosError } from 'axios';
import FormData from 'form-data';
import fs from 'fs';
import type { PinterestContent, InstagramContent, Platform, LateAPIPostResponse } from '../../types/index.js';
import { appConfig } from '../../core/config/config.js';
import { retry } from '../../core/retry.js';
import { logger } from '../../core/logger.js';
import { checkRateLimit, getRateLimitInfo } from '../../core/rate-limit.js';
import { RateLimitError } from '../../core/errors.js';
import { API_ENDPOINTS, INSTAGRAM_LIMITS, RATE_LIMITS } from '../../core/constants/index.js';
type InstagramMediaType = 'post' | 'story' | 'reel';
const LATE_API_BASE = API_ENDPOINTS.LATE_API_BASE;
interface LateAPIMediaResponse {
id: string;
}
interface LateAPIPlatform {
platform: Platform;
accountId: string;
platformSpecificData?: Record<string, unknown>;
}
interface LateAPIPostData {
profile_id?: string; // Legacy support
accounts?: string[]; // Legacy support
media?: string[];
media_urls?: string[];
mediaItems?: Array<{ type: string; url: string }>;
scheduled_at?: string; // Legacy support
scheduledFor?: string;
platforms: LateAPIPlatform[] | Platform[]; // Support both formats
post_type?: string;
contentType?: string; // For stories
platformSpecificData?: Record<string, unknown>; // Platform-specific settings
text?: string;
title?: string;
content?: string;
link?: string;
isDraft?: boolean; // Create as draft
}
/**
* Late API Service
*/
export class LateService {
private apiKey: string;
private profileId: string;
private instagramAccountId: string;
private pinterestAccountId: string;
private instagramUsername: string;
private instagramCollaborators: string[];
constructor() {
const config = appConfig.getConfig();
this.apiKey = config.lateApi.key;
this.profileId = config.lateApi.profileId;
this.instagramAccountId = config.lateApi.instagramAccountId;
this.pinterestAccountId = config.lateApi.pinterestAccountId;
this.instagramUsername = config.content.instagramUsername;
this.instagramCollaborators = config.content.instagramCollaborators;
// Validate maximum 3 collaborators (Instagram limit)
if (this.instagramCollaborators.length > INSTAGRAM_LIMITS.MAX_COLLABORATORS) {
logger.warn('Instagram allows maximum 3 collaborators', {
found: this.instagramCollaborators.length,
using: this.instagramCollaborators.slice(0, INSTAGRAM_LIMITS.MAX_COLLABORATORS),
});
this.instagramCollaborators = this.instagramCollaborators.slice(0, INSTAGRAM_LIMITS.MAX_COLLABORATORS);
}
}
/**
* Check if dev mode is enabled (checked dynamically on each call)
*/
private get isDevMode(): boolean {
return appConfig.isDevMode();
}
/**
* Upload media file to Late API (if file path provided)
*/
private async uploadMedia(filePath: string): Promise<string> {
// Dev mode: return mock media ID
if (this.isDevMode) {
logger.debug('DEV MODE: Media upload skipped', { filePath });
return 'dev-media-mock-' + Date.now();
}
return await retry(async () => {
const formData = new FormData();
formData.append('file', fs.createReadStream(filePath));
const response = await axios.post<LateAPIMediaResponse>(
`${LATE_API_BASE}/media`,
formData,
{
headers: {
'Authorization': `Bearer ${this.apiKey}`,
...formData.getHeaders()
}
}
);
return response.data.id;
}, 3, 1000).catch((error: unknown) => {
const axiosError = error as AxiosError;
logger.error('Error uploading media to Late API', error instanceof Error ? error : new Error(String(error)), {
filePath,
status: axiosError.response?.status,
data: axiosError.response?.data,
});
throw error;
});
}
/**
* Schedule post to multiple platforms
* @param imagePathOrUrl - Local file path or public CDN URL
* @param pinterestContent - Pinterest content
* @param instagramContent - Instagram content
* @param publishDate - Publish date
* @param platforms - Platforms to publish to
* @param instagramMediaType - Instagram media type
* @param isPublicURL - Whether imagePathOrUrl is a public URL (CDN)
*/
async scheduleToMultiplePlatforms(
imagePathOrUrl: string,
pinterestContent: PinterestContent | null,
instagramContent: InstagramContent,
publishDate: Date,
platforms: Platform[] = ['instagram', 'pinterest'],
instagramMediaType: InstagramMediaType = 'post',
isPublicURL: boolean = false,
isDraft: boolean = false
): Promise<LateAPIPostResponse> {
try {
if (!this.apiKey || !this.profileId) {
throw new Error('Late API credentials not configured. Set LATE_API_KEY and LATE_PROFILE_ID');
}
// Check rate limit
const allowed = checkRateLimit('late-api', RATE_LIMITS.LATE_API.REQUESTS_PER_MINUTE, 60 * 1000);
if (!allowed) {
const info = getRateLimitInfo('late-api', RATE_LIMITS.LATE_API.REQUESTS_PER_MINUTE);
throw new RateLimitError(
`Late API rate limit exceeded. Reset in ${Math.ceil((info?.resetIn || 60000) / 1000)}s`,
info?.resetIn || 60000
);
}
// Build platforms array with account IDs and platform-specific data
// According to Late API docs, platformSpecificData should be in each platform object
const platformsArray: LateAPIPlatform[] = [];
if (platforms.includes('instagram') && this.instagramAccountId) {
const instagramPlatform: LateAPIPlatform = {
platform: 'instagram',
accountId: this.instagramAccountId
};
// Set platform-specific data for Instagram
instagramPlatform.platformSpecificData = {};
if (instagramMediaType === 'story') {
// Stories require contentType to be set to 'story'
// This tells Late API to use Story format (9:16 aspect ratio)
instagramPlatform.platformSpecificData.contentType = 'story';
// Stories don't need text content, but Late API requires content field to be present
// Set a minimal character (empty string or space may fail validation)
instagramPlatform.platformSpecificData.content = '.';
// Note: Collaborators are not supported for Stories
} else {
// Posts and reels need text content
const instagramText = `${instagramContent.caption}\n\n${instagramContent.softCta}\n\n${instagramContent.hashtags}`;
instagramPlatform.platformSpecificData.content = instagramText;
if (instagramMediaType === 'reel') {
instagramPlatform.platformSpecificData.contentType = 'reel';
}
// Add Instagram collaborators for posts (not for stories)
if (this.instagramCollaborators.length > 0) {
instagramPlatform.platformSpecificData.collaborators = this.instagramCollaborators;
}
}
platformsArray.push(instagramPlatform);
}
if (platforms.includes('pinterest') && this.pinterestAccountId) {
const pinterestPlatform: LateAPIPlatform = {
platform: 'pinterest',
accountId: this.pinterestAccountId
};
// Set platform-specific data for Pinterest
if (pinterestContent) {
pinterestPlatform.platformSpecificData = {
title: pinterestContent.title,
content: `${pinterestContent.description}\n\n${pinterestContent.cta}`,
link: `https://www.instagram.com/${this.instagramUsername}/`
};
}
platformsArray.push(pinterestPlatform);
}
if (platformsArray.length === 0) {
throw new Error('No account IDs configured for selected platforms');
}
// CRITICAL: Instagram Post + Pinterest should NEVER be sent in one request
// because they have different content (Pinterest SEO-first, Instagram emotion-first)
// This should be caught earlier in generateAndSchedule, but add safety check here
if (platforms.length > 1 &&
platforms.includes('instagram') &&
platforms.includes('pinterest') &&
instagramMediaType !== 'story') {
throw new Error('Instagram Post + Pinterest must be sent as separate requests due to different content. This should be handled by generateAndSchedule.');
}
// Prepare post data - use both new and legacy formats for compatibility
const postData: LateAPIPostData = {
profile_id: this.profileId, // Legacy support
platforms: platformsArray
};
// For Instagram stories, also set contentType at top level if only Instagram is used
// This might help Late API recognize the content type before validation
if (platforms.length === 1 && platforms.includes('instagram') && instagramMediaType === 'story') {
// Try setting contentType at top level as well (experimental)
// Late API might check this before platformSpecificData
(postData as any).contentType = 'story';
}
// Set scheduling - can be draft with scheduled date
if (isDraft) {
postData.isDraft = true;
// Drafts can have scheduledFor for Pinterest scheduling
postData.scheduledFor = publishDate.toISOString();
postData.scheduled_at = publishDate.toISOString(); // Legacy support
} else {
postData.scheduledFor = publishDate.toISOString();
postData.scheduled_at = publishDate.toISOString(); // Legacy support
}
// Add media - use mediaItems for public URLs (new API format)
if (isPublicURL) {
// For public URLs, use mediaItems with URL
// Note: Late API needs to be able to access the URL
// If this fails, the domain may need to be whitelisted in Late API settings
postData.mediaItems = [{
type: 'image',
url: imagePathOrUrl
}];
} else {
// For local files, upload to Late API
const mediaId = await this.uploadMedia(imagePathOrUrl);
postData.media = [mediaId];
}
// Handle content - platformSpecificData is already set in platforms array
// IMPORTANT: When multiple platforms are used, we should NOT set top-level content
// because each platform has different content in platformSpecificData.
// Top-level content should only be set for single platform requests.
// For multiple platforms, Late API should use platformSpecificData for each platform.
if (platforms.length > 1) {
// Multiple platforms - DO NOT set top-level content
// Each platform has its own content in platformSpecificData
// Setting top-level content here would override platform-specific content
// This is only used for Story + Pinterest combination (Story has no text)
if (platforms.includes('instagram') && instagramMediaType === 'story') {
// For stories, set empty content (Late API requires content field even for stories)
// Content is set in platformSpecificData, but top-level may also be needed
postData.content = '';
}
// For Instagram Post + Pinterest, we should NOT set top-level content
// because they have different content and should be sent as separate requests
// This code path should rarely be reached for Post + Pinterest combination
} else {
// Single platform - use top-level fields
if (platforms.includes('instagram')) {
if (instagramMediaType === 'story') {
// Stories don't need text content, but Late API requires content/text field
// Set a minimal character (empty string or space may fail validation)
// Try both content and text fields (Late API may check either)
postData.content = '.';
postData.text = '.';
// contentType is already set in platforms array
} else {
// Posts and reels need text
const instagramText = `${instagramContent.caption}\n\n${instagramContent.softCta}\n\n${instagramContent.hashtags}`;
postData.content = instagramText;
}
}
if (platforms.includes('pinterest') && pinterestContent) {
postData.title = pinterestContent.title;
postData.content = `${pinterestContent.description}\n\n${pinterestContent.cta}`;
postData.link = `https://www.instagram.com/${this.instagramUsername}/`;
}
}
// Dev mode: output JSON instead of real API call
if (this.isDevMode) {
logger.info('DEV MODE: Dry-run (no actual API call)', {
endpoint: `${LATE_API_BASE}/posts`,
method: 'POST',
requestBody: postData,
});
// Return mock response for dev mode
return {
id: 'dev-mock-' + Date.now(),
scheduled_at: publishDate.toISOString(),
scheduledFor: publishDate.toISOString(),
status: 'scheduled',
service: 'late',
platforms: platforms
};
}
const response = await retry(async () => {
return await axios.post<{ post: { _id: string; status?: string; scheduledFor?: string; platforms?: Array<{ platform: string }> } }>(
`${LATE_API_BASE}/posts`,
postData,
{
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
}
}
);
}, 3, 1000);
// Late API returns { post: { _id: "...", ... } }
const post = response.data.post;
if (!post || !post._id) {
throw new Error('Invalid response from Late API: missing post._id');
}
return {
id: post._id,
scheduled_at: post.scheduledFor || publishDate.toISOString(),
scheduledFor: post.scheduledFor || publishDate.toISOString(), // For backward compatibility
status: post.status || 'scheduled',
service: 'late',
platforms: platforms
};
} catch (error) {
const axiosError = error as AxiosError;
const errorDetails = {
message: axiosError.message,
status: axiosError.response?.status,
statusText: axiosError.response?.statusText,
data: axiosError.response?.data,
config: {
url: axiosError.config?.url,
method: axiosError.config?.method,
}
};
logger.error('Error scheduling post with Late API', error instanceof Error ? error : new Error(String(error)), errorDetails);
// Create a more informative error message
const errorMessage = `Request failed with status code ${axiosError.response?.status || 'unknown'}. ${JSON.stringify(axiosError.response?.data || { message: axiosError.message })}`;
throw new Error(errorMessage);
}
}
/**
* Schedule Instagram post
*/
async schedulePost(
imagePathOrUrl: string,
content: InstagramContent,
publishDate: Date,
mediaType: InstagramMediaType = 'post',
isPublicURL: boolean = false
): Promise<LateAPIPostResponse> {
return await this.scheduleToMultiplePlatforms(
imagePathOrUrl,
null,
content,
publishDate,
['instagram'],
mediaType,
isPublicURL
);
}
/**
* Get scheduled posts
*/
async getScheduledPosts(filters: Record<string, unknown> = {}): Promise<unknown[]> {
// Dev mode: return mock data
if (this.isDevMode) {
logger.debug('DEV MODE: Get scheduled posts skipped', { filters });
return [];
}
if (!this.apiKey) {
throw new Error('Late API key not configured');
}
const params = {
platform: 'instagram',
...filters
};
return await retry(async () => {
const response = await axios.get(
`${LATE_API_BASE}/posts`,
{
headers: {
'Authorization': `Bearer ${this.apiKey}`
},
params
}
);
return response.data as unknown[];
}, 3, 1000).catch((error) => {
const axiosError = error as AxiosError;
logger.error('Error fetching posts from Late API', error instanceof Error ? error : new Error(String(error)), {
filters,
status: axiosError.response?.status,
data: axiosError.response?.data,
});
throw error;
});
}
/**
* Delete scheduled post
*/
async deleteScheduledPost(postId: string): Promise<unknown> {
// Dev mode: output JSON instead of real API call
if (this.isDevMode) {
logger.info('DEV MODE: Dry-run (no actual API call)', {
endpoint: `${LATE_API_BASE}/posts/${postId}`,
method: 'DELETE',
});
return { message: 'dev-mode: post would be deleted', postId };
}
if (!this.apiKey) {
throw new Error('Late API key not configured');
}
return await retry(async () => {
const response = await axios.delete(
`${LATE_API_BASE}/posts/${postId}`,
{
headers: {
'Authorization': `Bearer ${this.apiKey}`
}
}
);
return response.data;
}, 3, 1000).catch((error) => {
const axiosError = error as AxiosError;
logger.error('Error deleting post from Late API', error instanceof Error ? error : new Error(String(error)), {
postId,
status: axiosError.response?.status,
data: axiosError.response?.data,
});
throw error;
});
}
/**
* Get account information
*/
async getAccountInfo(): Promise<unknown[]> {
// Dev mode: return mock data
if (this.isDevMode) {
logger.debug('DEV MODE: Account info request skipped');
return [
{ _id: this.instagramAccountId, platform: 'instagram', username: 'dev-instagram' },
{ _id: this.pinterestAccountId, platform: 'pinterest', username: 'dev-pinterest' }
];
}
if (!this.apiKey || !this.profileId) {
throw new Error('Late API credentials not configured');
}
return await retry(async () => {
const response = await axios.get(
`${LATE_API_BASE}/accounts`,
{
headers: {
'Authorization': `Bearer ${this.apiKey}`
},
params: {
profileId: this.profileId
}
}
);
// Late API returns { accounts: [...] }
const data = response.data as { accounts?: unknown[] };
return data.accounts || [];
}, 3, 1000).catch((error) => {
const axiosError = error as AxiosError;
logger.error('Error fetching account info from Late API', error instanceof Error ? error : new Error(String(error)), {
status: axiosError.response?.status,
data: axiosError.response?.data,
});
throw error;
});
}
}
// Export singleton instance
export const lateService = new LateService();