diff --git a/server/services/facebook.ts b/server/services/facebook.ts index 25f5d44..8980d77 100644 --- a/server/services/facebook.ts +++ b/server/services/facebook.ts @@ -21,7 +21,7 @@ interface FacebookError { export class FacebookService { private baseUrl = 'https://graph.facebook.com/v23.0'; - async publishPost(post: Post, page: SocialPage, postType: string, media?: Media): Promise { + async publishPost(post: Post, page: SocialPage, postType: string, mediaList: Media[] = []): Promise { if (page.platform !== 'facebook') { throw new Error('This service only supports Facebook pages'); } @@ -39,15 +39,28 @@ export class FacebookService { if (postType === 'story') { // Publish as story only - if (!media) { + if (mediaList.length === 0) { throw new Error('Stories require media (photo or video)'); } - return await this.publishStory(post, page, media); + return await this.publishStory(post, page, mediaList[0]); } else { // Default to feed - if (media) { - return await this.publishPhotoPost(post, page, media); + // Count images only (videos not supported in multi-photo carousel) + const imageMedia = mediaList.filter(m => m.type === 'image'); + + if (imageMedia.length > 1) { + // Multi-photo carousel (images only) + return await this.publishMultiPhotoPost(post, page, mediaList); + } else if (imageMedia.length === 1) { + // Single image (even if mixed with videos, prioritize the image) + return await this.publishPhotoPost(post, page, imageMedia[0]); + } else if (mediaList.length >= 1) { + // No images, but has media (likely video) - publish first media + // Note: Facebook /photos endpoint doesn't support videos well, this may fail + console.warn(`Publishing non-image media to Facebook feed - this may fail`); + return await this.publishPhotoPost(post, page, mediaList[0]); } else { + // Text only return await this.publishTextPost(post, page); } } @@ -100,6 +113,79 @@ export class FacebookService { return data.post_id || data.id; } + private async publishMultiPhotoPost(post: Post, page: SocialPage, mediaList: Media[]): Promise { + // Validate that all media are images + const imageMedia = mediaList.filter(m => m.type === 'image'); + if (imageMedia.length === 0) { + throw new Error('Multi-photo posts require at least one image'); + } + + if (imageMedia.length !== mediaList.length) { + console.warn(`Filtered out ${mediaList.length - imageMedia.length} non-image media from multi-photo post`); + } + + // Step 1: Upload all photos as unpublished and collect photo IDs + const photoIds: string[] = []; + + for (const media of imageMedia) { + const photoUrl = media.facebookFeedUrl || media.originalUrl; + + if (!photoUrl) { + console.warn(`Skipping media ${media.id} - no valid URL found`); + continue; + } + + const uploadParams = new URLSearchParams({ + access_token: page.accessToken!, + url: photoUrl, + published: 'false', + }); + + const uploadUrl = `${this.baseUrl}/${page.pageId}/photos?${uploadParams.toString()}`; + + const uploadResponse = await fetch(uploadUrl, { + method: 'POST', + }); + + if (!uploadResponse.ok) { + const error = await uploadResponse.json() as FacebookError; + throw new Error(`Facebook API error uploading photo: ${error.error.message} (code: ${error.error.code})`); + } + + const uploadData = await uploadResponse.json() as FacebookPhotoResponse; + photoIds.push(uploadData.id); + } + + if (photoIds.length === 0) { + throw new Error('No photos were successfully uploaded'); + } + + // Step 2: Create feed post with all attached photos + const feedParams = new URLSearchParams({ + access_token: page.accessToken!, + message: post.content, + }); + + // Add each photo as attached_media + photoIds.forEach((photoId, index) => { + feedParams.append(`attached_media[${index}]`, JSON.stringify({ media_fbid: photoId })); + }); + + const feedUrl = `${this.baseUrl}/${page.pageId}/feed?${feedParams.toString()}`; + + const feedResponse = await fetch(feedUrl, { + method: 'POST', + }); + + if (!feedResponse.ok) { + const error = await feedResponse.json() as FacebookError; + throw new Error(`Facebook API error creating feed post: ${error.error.message} (code: ${error.error.code})`); + } + + const feedData = await feedResponse.json() as FacebookFeedResponse; + return feedData.id; + } + async schedulePost(post: Post, page: SocialPage, scheduledTime: Date, media?: Media): Promise { if (page.platform !== 'facebook') { throw new Error('This service only supports Facebook pages'); diff --git a/server/services/scheduler.ts b/server/services/scheduler.ts index f1354b6..cb4ee8e 100644 --- a/server/services/scheduler.ts +++ b/server/services/scheduler.ts @@ -67,13 +67,19 @@ export class SchedulerService { console.log(`Publishing post ${post.id} to ${page.platform} page ${page.pageName}`); - // Get media if post has media + // Get all media for the post (for multi-photo carousels) const postMediaList = await storage.getPostMedia(post.id); - const media = postMediaList.length > 0 ? await storage.getMediaById(postMediaList[0].mediaId) : undefined; + const mediaList: any[] = []; + for (const pm of postMediaList) { + const media = await storage.getMediaById(pm.mediaId); + if (media) { + mediaList.push(media); + } + } // Validate that story posts have media // Note: "both" should never reach here since they're split at creation - if (scheduledPost.postType === 'story' && !media) { + if (scheduledPost.postType === 'story' && mediaList.length === 0) { const errorMsg = 'Les stories nécessitent un média. Ce post ne peut pas être publié.'; console.error(`Deleting invalid story post ${scheduledPost.id} without media: ${errorMsg}`); @@ -91,7 +97,7 @@ export class SchedulerService { let externalPostId: string; if (page.platform === 'facebook') { - externalPostId = await facebookService.publishPost(post, page, scheduledPost.postType, media || undefined); + externalPostId = await facebookService.publishPost(post, page, scheduledPost.postType, mediaList); } else if (page.platform === 'instagram') { // Instagram publishing not yet implemented console.warn(`Instagram publishing not yet implemented for post ${post.id}`);