diff --git a/.replit b/.replit index ffeb98a..7852907 100644 --- a/.replit +++ b/.replit @@ -18,6 +18,10 @@ externalPort = 80 localPort = 5555 externalPort = 3002 +[[ports]] +localPort = 37219 +externalPort = 3003 + [[ports]] localPort = 40537 externalPort = 3000 diff --git a/server/services/facebook.ts b/server/services/facebook.ts new file mode 100644 index 0000000..b5e9ed9 --- /dev/null +++ b/server/services/facebook.ts @@ -0,0 +1,174 @@ +import { storage } from '../storage'; +import type { Post, SocialPage, Media } from '@shared/schema'; + +interface FacebookPhotoResponse { + id: string; + post_id: string; +} + +interface FacebookFeedResponse { + id: string; +} + +interface FacebookError { + error: { + message: string; + type: string; + code: number; + }; +} + +export class FacebookService { + private baseUrl = 'https://graph.facebook.com/v23.0'; + + async publishPost(post: Post, page: SocialPage, postType: string, media?: Media): Promise { + if (page.platform !== 'facebook') { + throw new Error('This service only supports Facebook pages'); + } + + if (!page.accessToken) { + throw new Error('No access token found for this page'); + } + + // If there's media, publish as photo post, otherwise as text post + if (media) { + return await this.publishPhotoPost(post, page, media); + } else { + return await this.publishTextPost(post, page); + } + } + + private async publishTextPost(post: Post, page: SocialPage): Promise { + const url = `${this.baseUrl}/${page.pageId}/feed`; + + const params = new URLSearchParams({ + access_token: page.accessToken!, + message: post.content, + }); + + const response = await fetch(url, { + method: 'POST', + body: params, + }); + + if (!response.ok) { + const error = await response.json() as FacebookError; + throw new Error(`Facebook API error: ${error.error.message} (code: ${error.error.code})`); + } + + const data = await response.json() as FacebookFeedResponse; + return data.id; + } + + private async publishPhotoPost(post: Post, page: SocialPage, media: Media): Promise { + const url = `${this.baseUrl}/${page.pageId}/photos`; + + // Use the Facebook Feed optimized URL (1200x630) + const photoUrl = media.facebookFeedUrl || media.originalUrl; + + const params = new URLSearchParams({ + access_token: page.accessToken!, + message: post.content, + url: photoUrl, + }); + + const response = await fetch(url, { + method: 'POST', + body: params, + }); + + if (!response.ok) { + const error = await response.json() as FacebookError; + throw new Error(`Facebook API error: ${error.error.message} (code: ${error.error.code})`); + } + + const data = await response.json() as FacebookPhotoResponse; + // Return the post_id which is the full post identifier + return data.post_id || data.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'); + } + + if (!page.accessToken) { + throw new Error('No access token found for this page'); + } + + // Facebook requires Unix timestamp for scheduled posts + const scheduledTimestamp = Math.floor(scheduledTime.getTime() / 1000); + const now = Math.floor(Date.now() / 1000); + + // Facebook requires scheduled time to be at least 10 minutes in the future + if (scheduledTimestamp < now + 600) { + throw new Error('Scheduled time must be at least 10 minutes in the future'); + } + + // Maximum 6 months in the future + const sixMonthsFromNow = now + (6 * 30 * 24 * 60 * 60); + if (scheduledTimestamp > sixMonthsFromNow) { + throw new Error('Scheduled time cannot be more than 6 months in the future'); + } + + // For scheduled posts with photos + if (media) { + return await this.schedulePhotoPost(post, page, scheduledTimestamp, media); + } else { + return await this.scheduleTextPost(post, page, scheduledTimestamp); + } + } + + private async scheduleTextPost(post: Post, page: SocialPage, scheduledTimestamp: number): Promise { + const url = `${this.baseUrl}/${page.pageId}/feed`; + + const params = new URLSearchParams({ + access_token: page.accessToken!, + message: post.content, + published: 'false', + scheduled_publish_time: scheduledTimestamp.toString(), + }); + + const response = await fetch(url, { + method: 'POST', + body: params, + }); + + if (!response.ok) { + const error = await response.json() as FacebookError; + throw new Error(`Facebook API error: ${error.error.message} (code: ${error.error.code})`); + } + + const data = await response.json() as FacebookFeedResponse; + return data.id; + } + + private async schedulePhotoPost(post: Post, page: SocialPage, scheduledTimestamp: number, media: Media): Promise { + const url = `${this.baseUrl}/${page.pageId}/photos`; + + const photoUrl = media.facebookFeedUrl || media.originalUrl; + + const params = new URLSearchParams({ + access_token: page.accessToken!, + message: post.content, + url: photoUrl, + published: 'false', + scheduled_publish_time: scheduledTimestamp.toString(), + }); + + const response = await fetch(url, { + method: 'POST', + body: params, + }); + + if (!response.ok) { + const error = await response.json() as FacebookError; + throw new Error(`Facebook API error: ${error.error.message} (code: ${error.error.code})`); + } + + const data = await response.json() as FacebookPhotoResponse; + return data.post_id || data.id; + } +} + +export const facebookService = new FacebookService(); diff --git a/server/services/scheduler.ts b/server/services/scheduler.ts index 14008f3..5254e89 100644 --- a/server/services/scheduler.ts +++ b/server/services/scheduler.ts @@ -1,5 +1,6 @@ import cron, { ScheduledTask } from "node-cron"; import { storage } from "../storage"; +import { facebookService } from "./facebook"; export class SchedulerService { private task: ScheduledTask | null = null; @@ -47,14 +48,35 @@ export class SchedulerService { throw new Error("Post or page not found"); } - // In a real implementation, this would call Facebook/Instagram Graph API - // For now, we'll simulate the publishing console.log(`Publishing post ${post.id} to ${page.platform} page ${page.pageName}`); + // Get media if post has media + const postMediaList = await storage.getPostMedia(post.id); + const media = postMediaList.length > 0 ? await storage.getMediaById(postMediaList[0].mediaId) : undefined; + + // Publish to Facebook/Instagram + let externalPostId: string; + + if (page.platform === 'facebook') { + externalPostId = await facebookService.publishPost(post, page, scheduledPost.postType, media || undefined); + } else if (page.platform === 'instagram') { + // Instagram publishing not yet implemented + console.warn(`Instagram publishing not yet implemented for post ${post.id}`); + await storage.updateScheduledPost(scheduledPost.id, { + error: 'Instagram publishing not yet implemented', + }); + await storage.updatePost(post.id, { + status: "failed", + }); + return; + } else { + throw new Error(`Unsupported platform: ${page.platform}`); + } + // Update the scheduled post as published await storage.updateScheduledPost(scheduledPost.id, { publishedAt: new Date(), - externalPostId: `simulated-${Date.now()}`, // In real app, this would be the ID from FB/IG + externalPostId, }); // Update post status @@ -62,7 +84,7 @@ export class SchedulerService { status: "published", }); - console.log(`Successfully published post ${post.id}`); + console.log(`Successfully published post ${post.id} with external ID: ${externalPostId}`); } } diff --git a/server/storage.ts b/server/storage.ts index 4f5a9b1..750a44c 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -3,6 +3,7 @@ import { socialPages, media, posts, + postMedia, scheduledPosts, aiGenerations, cloudinaryConfig, @@ -15,6 +16,7 @@ import { type InsertMedia, type Post, type InsertPost, + type PostMedia, type ScheduledPost, type InsertScheduledPost, type AiGeneration, @@ -48,6 +50,7 @@ export interface IStorage { getMediaById(id: string): Promise; createMedia(media: InsertMedia): Promise; deleteMedia(id: string): Promise; + getPostMedia(postId: string): Promise; // Posts getPosts(userId: string): Promise; @@ -152,6 +155,10 @@ export class DatabaseStorage implements IStorage { await db.delete(media).where(eq(media.id, id)); } + async getPostMedia(postId: string): Promise { + return await db.select().from(postMedia).where(eq(postMedia.postId, postId)); + } + // Posts async getPosts(userId: string): Promise { return await db.select().from(posts).where(eq(posts.userId, userId)).orderBy(desc(posts.createdAt)); diff --git a/shared/schema.ts b/shared/schema.ts index 144f27d..c50590b 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -254,3 +254,5 @@ export type InsertCloudinaryConfig = z.infer; + +export type PostMedia = typeof postMedia.$inferSelect;