Add support for publishing posts to both feed and stories simultaneously

Introduce a new 'both' post type allowing simultaneous publishing to Facebook and Instagram feeds and stories. This includes UI updates for selecting post types, backend logic for splitting 'both' posts into separate feed and story entries, and the ability to delete scheduled posts. The changes also include a new 'Pages gérées' link in the sidebar and necessary database schema updates.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: ae4037a0-2a6f-4530-9bac-79b543286bda
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/397bca8c-984f-43ff-841a-10897aeb8140/ae4037a0-2a6f-4530-9bac-79b543286bda/WuFXYmm
This commit is contained in:
michaelschal committed 2025-10-08 13:16:55 +00:00
1 parent 02794fecc2
commit 3971c6f912
9 files changed
+268 -24

No files matched your search

+74 -8
View File
@@ -445,6 +445,11 @@ export async function registerRoutes(app: Express): Promise<Server> {
const userId = user.id;
const { pageIds, postType, mediaId, ...postFields } = req.body;
// Validate that stories require media
if ((postType === 'story' || postType === 'both') && !mediaId) {
return res.status(400).json({ error: "Les stories nécessitent un média (image ou vidéo)" });
}
// Convert scheduledFor string to Date if provided
if (postFields.scheduledFor && typeof postFields.scheduledFor === 'string') {
postFields.scheduledFor = new Date(postFields.scheduledFor);
@@ -468,13 +473,31 @@ export async function registerRoutes(app: Express): Promise<Server> {
? new Date(postFields.scheduledFor)
: new Date(); // Publish immediately if no date specified
const finalPostType = postType || 'feed';
for (const pageId of pageIds) {
await storage.createScheduledPost({
postId: post.id,
pageId,
postType: postType || 'feed',
scheduledAt,
});
// If postType is "both", create two separate scheduled posts (story + feed)
if (finalPostType === 'both') {
await storage.createScheduledPost({
postId: post.id,
pageId,
postType: 'story',
scheduledAt,
});
await storage.createScheduledPost({
postId: post.id,
pageId,
postType: 'feed',
scheduledAt,
});
} else {
await storage.createScheduledPost({
postId: post.id,
pageId,
postType: finalPostType,
scheduledAt,
});
}
}
}
@@ -504,11 +527,54 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
app.delete("/api/scheduled-posts/:id", requireAuth, async (req, res) => {
try {
const user = req.user as User;
const userId = user.id;
const { id } = req.params;
// Verify the scheduled post belongs to the user before deleting
const scheduledPost = await storage.getScheduledPost(id);
if (!scheduledPost) {
return res.status(404).json({ error: "Scheduled post not found" });
}
const post = await storage.getPost(scheduledPost.postId);
if (!post || post.userId !== userId) {
return res.status(403).json({ error: "Unauthorized" });
}
await storage.deleteScheduledPost(id);
res.json({ success: true });
} catch (error) {
console.error("Error deleting scheduled post:", error);
res.status(500).json({ error: "Failed to delete scheduled post" });
}
});
app.post("/api/scheduled-posts", requireAuth, async (req, res) => {
try {
const scheduledPostData = insertScheduledPostSchema.parse(req.body);
const scheduledPost = await storage.createScheduledPost(scheduledPostData);
res.json(scheduledPost);
// If postType is "both", create two separate scheduled posts (story + feed)
// This prevents retry loops - each post type is independent
if (scheduledPostData.postType === 'both') {
const [storyPost, feedPost] = await Promise.all([
storage.createScheduledPost({
...scheduledPostData,
postType: 'story' as const,
}),
storage.createScheduledPost({
...scheduledPostData,
postType: 'feed' as const,
}),
]);
// Return array so frontend knows it's a split operation
res.json([storyPost, feedPost]);
} else {
const scheduledPost = await storage.createScheduledPost(scheduledPostData);
res.json(scheduledPost);
}
} catch (error) {
console.error("Error creating scheduled post:", error);
res.status(500).json({ error: "Failed to create scheduled post" });
+43 -4
View File
@@ -30,11 +30,26 @@ export class FacebookService {
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);
// Handle different post types
// Note: "both" type should never reach here - new posts are split at creation
// If it does reach here (legacy data), throw an error
if (postType === 'both') {
throw new Error('postType "both" is not supported. Posts should be split into separate feed and story entries at creation.');
}
if (postType === 'story') {
// Publish as story only
if (!media) {
throw new Error('Stories require media (photo or video)');
}
return await this.publishStory(post, page, media);
} else {
return await this.publishTextPost(post, page);
// Default to feed
if (media) {
return await this.publishPhotoPost(post, page, media);
} else {
return await this.publishTextPost(post, page);
}
}
}
@@ -165,6 +180,30 @@ export class FacebookService {
const data = await response.json() as FacebookPhotoResponse;
return data.post_id || data.id;
}
private async publishStory(post: Post, page: SocialPage, media: Media): Promise<string> {
// Use vertical format for stories (Instagram Story URL 1080x1920)
const photoUrl = media.instagramStoryUrl || media.originalUrl;
const params = new URLSearchParams({
access_token: page.accessToken!,
url: photoUrl,
});
const url = `${this.baseUrl}/${page.pageId}/photo_stories?${params.toString()}`;
const response = await fetch(url, {
method: 'POST',
});
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.id;
}
}
export const facebookService = new FacebookService();
+33
View File
@@ -27,6 +27,23 @@ export class SchedulerService {
for (const scheduledPost of pendingPosts) {
try {
// Reject legacy "both" posts - they should have been split at creation
// New posts are automatically split in /api/posts
if (scheduledPost.postType === 'both') {
const errorMsg = 'Legacy postType "both" is no longer supported. Deleting this scheduled entry. Please recreate this post - it will automatically be split into separate feed and story posts.';
console.error(`Rejecting and deleting legacy "both" post ${scheduledPost.id}: ${errorMsg}`);
// Delete the legacy post entirely (don't mark as published - that's misleading)
await storage.deleteScheduledPost(scheduledPost.id);
// Update parent post status to draft so user can reschedule
await storage.updatePost(scheduledPost.postId, {
status: "draft",
});
continue;
}
await this.publishPost(scheduledPost);
} catch (error) {
console.error(`Error publishing post ${scheduledPost.id}:`, error);
@@ -54,6 +71,22 @@ export class SchedulerService {
const postMediaList = await storage.getPostMedia(post.id);
const media = postMediaList.length > 0 ? await storage.getMediaById(postMediaList[0].mediaId) : undefined;
// Validate that story posts have media
// Note: "both" should never reach here since they're split at creation
if (scheduledPost.postType === 'story' && !media) {
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}`);
// Delete the invalid scheduled post (don't mark as published - that's misleading)
await storage.deleteScheduledPost(scheduledPost.id);
// Set post to draft so user can fix and reschedule
await storage.updatePost(post.id, {
status: "draft",
});
return;
}
// Publish to Facebook/Instagram
let externalPostId: string;
+5
View File
@@ -62,6 +62,7 @@ export interface IStorage {
// Scheduled Posts
getScheduledPosts(userId: string, startDate?: Date, endDate?: Date): Promise<ScheduledPost[]>;
getScheduledPost(id: string): Promise<ScheduledPost | undefined>;
getScheduledPostsByPost(postId: string): Promise<ScheduledPost[]>;
createScheduledPost(scheduledPost: InsertScheduledPost): Promise<ScheduledPost>;
updateScheduledPost(id: string, scheduledPost: Partial<ScheduledPost>): Promise<ScheduledPost>;
deleteScheduledPost(id: string): Promise<void>;
@@ -210,6 +211,10 @@ export class DatabaseStorage implements IStorage {
return scheduledPost || undefined;
}
async getScheduledPostsByPost(postId: string): Promise<ScheduledPost[]> {
return await db.select().from(scheduledPosts).where(eq(scheduledPosts.postId, postId));
}
async createScheduledPost(scheduledPost: InsertScheduledPost): Promise<ScheduledPost> {
const [newScheduledPost] = await db.insert(scheduledPosts).values(scheduledPost).returning();
return newScheduledPost;