From 3971c6f9120db20159ef5bef32f6cda66db44bc8 Mon Sep 17 00:00:00 2001 From: michaelschal <35957947-michaelschal@users.noreply.replit.com> Date: Wed, 8 Oct 2025 13:16:55 +0000 Subject: [PATCH] Add support for publishing posts to both feed and stories simultaneously MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/src/components/calendar-view.tsx | 61 +++++++++++++++--- client/src/components/sidebar.tsx | 18 +++++- client/src/pages/new-post.tsx | 40 +++++++++++- init.sql | 4 ++ server/routes.ts | 82 ++++++++++++++++++++++--- server/services/facebook.ts | 47 ++++++++++++-- server/services/scheduler.ts | 33 ++++++++++ server/storage.ts | 5 ++ shared/schema.ts | 2 +- 9 files changed, 268 insertions(+), 24 deletions(-) diff --git a/client/src/components/calendar-view.tsx b/client/src/components/calendar-view.tsx index 04d216c..6a40ccc 100644 --- a/client/src/components/calendar-view.tsx +++ b/client/src/components/calendar-view.tsx @@ -1,16 +1,45 @@ import { useState } from "react"; import { Button } from "@/components/ui/button"; -import { ChevronLeft, ChevronRight, Calendar as CalendarIcon } from "lucide-react"; -import { useQuery } from "@tanstack/react-query"; +import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Trash2 } from "lucide-react"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { useToast } from "@/hooks/use-toast"; +import { apiRequest, queryClient } from "@/lib/queryClient"; import type { ScheduledPost } from "@shared/schema"; export default function CalendarView() { const [currentDate, setCurrentDate] = useState(new Date()); + const { toast } = useToast(); const { data: scheduledPosts = [] } = useQuery({ queryKey: ["/api/scheduled-posts"], }); + const deletePostMutation = useMutation({ + mutationFn: (postId: string) => + apiRequest('DELETE', `/api/scheduled-posts/${postId}`, {}), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'] }); + toast({ + title: "Publication supprimée", + description: "La publication programmée a été supprimée avec succès", + }); + }, + onError: () => { + toast({ + title: "Erreur", + description: "Impossible de supprimer la publication", + variant: "destructive", + }); + }, + }); + + const handleDeletePost = (postId: string, e: React.MouseEvent) => { + e.stopPropagation(); + if (confirm("Êtes-vous sûr de vouloir supprimer cette publication programmée ?")) { + deletePostMutation.mutate(postId); + } + }; + const getPostsForDate = (date: Date) => { return (scheduledPosts || []).filter(post => { if (!post.scheduledAt) return false; @@ -161,14 +190,28 @@ export default function CalendarView() {
-
{time}
-
{post.postType || 'Post'}
+
+
+
{time}
+
{post.postType || 'Post'}
+
+ {isPending && ( + + )} +
); })} @@ -186,11 +229,11 @@ export default function CalendarView() {
-
+
Programmé
-
+
Publié
diff --git a/client/src/components/sidebar.tsx b/client/src/components/sidebar.tsx index 0346ec4..d7cbb8f 100644 --- a/client/src/components/sidebar.tsx +++ b/client/src/components/sidebar.tsx @@ -38,7 +38,6 @@ export default function Sidebar() { { icon: PlusCircle, label: "Nouvelle publication", href: "/new", badge: null }, { icon: Calendar, label: "Calendrier", href: "/calendar", badge: null }, { icon: Images, label: "Médiathèque", href: "/media", badge: null }, - { icon: Users, label: "Pages gérées", href: "/pages", badge: null }, { icon: Bot, label: "Assistant IA", href: "/ai", badge: null }, ]; @@ -141,6 +140,23 @@ export default function Sidebar() {

Administration

+ + {location === "/pages" && ( +
+ )} + + Pages gérées + ([]); const [postText, setPostText] = useState(''); + const [postType, setPostType] = useState<'feed' | 'story' | 'both'>('feed'); const { data: pages = [] } = useQuery({ queryKey: ['/api/pages'], @@ -115,12 +117,21 @@ export default function NewPost() { return; } + if ((postType === 'story' || postType === 'both') && !selectedMedia) { + toast({ + title: "Média requis", + description: "Les stories nécessitent une image ou vidéo", + variant: "destructive", + }); + return; + } + createPostMutation.mutate({ content: postText, scheduledFor: scheduledDate || undefined, mediaId: selectedMedia || undefined, pageIds: selectedPages, - postType: 'feed', // Default to feed post + postType, }); }; @@ -332,6 +343,33 @@ export default function NewPost() { + + + Type de publication + Choisissez où publier + + +
+ + +

+ {postType === 'feed' && 'Publication classique dans le fil d\'actualité'} + {postType === 'story' && 'Story éphémère (24h) - Nécessite un média'} + {postType === 'both' && 'Publie à la fois dans le feed et en story - Nécessite un média'} +

+
+
+
+ Planification diff --git a/init.sql b/init.sql index 6ef5940..a07f694 100644 --- a/init.sql +++ b/init.sql @@ -1,6 +1,10 @@ -- Script d'initialisation de la base de données Social Flow -- Ce fichier contient les commandes SQL pour créer les tables et l'utilisateur admin par défaut +-- IMPORTANT: Pour les serveurs privés existants, ajouter la valeur 'both' à l'enum post_type +-- Commande à exécuter manuellement sur votre serveur privé : +-- ALTER TYPE post_type ADD VALUE IF NOT EXISTS 'both'; + -- Créer l'enum pour les rôles utilisateur (si pas déjà créé par drizzle-kit push) DO $$ BEGIN CREATE TYPE user_role AS ENUM ('admin', 'user'); diff --git a/server/routes.ts b/server/routes.ts index 4075ff7..5f8d8fb 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -445,6 +445,11 @@ export async function registerRoutes(app: Express): Promise { 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 { ? 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 { } }); + 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" }); diff --git a/server/services/facebook.ts b/server/services/facebook.ts index bacf4e2..25f5d44 100644 --- a/server/services/facebook.ts +++ b/server/services/facebook.ts @@ -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 { + // 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(); diff --git a/server/services/scheduler.ts b/server/services/scheduler.ts index 5254e89..f1354b6 100644 --- a/server/services/scheduler.ts +++ b/server/services/scheduler.ts @@ -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; diff --git a/server/storage.ts b/server/storage.ts index 750a44c..d1ef1d7 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -62,6 +62,7 @@ export interface IStorage { // Scheduled Posts getScheduledPosts(userId: string, startDate?: Date, endDate?: Date): Promise; getScheduledPost(id: string): Promise; + getScheduledPostsByPost(postId: string): Promise; createScheduledPost(scheduledPost: InsertScheduledPost): Promise; updateScheduledPost(id: string, scheduledPost: Partial): Promise; deleteScheduledPost(id: string): Promise; @@ -210,6 +211,10 @@ export class DatabaseStorage implements IStorage { return scheduledPost || undefined; } + async getScheduledPostsByPost(postId: string): Promise { + return await db.select().from(scheduledPosts).where(eq(scheduledPosts.postId, postId)); + } + async createScheduledPost(scheduledPost: InsertScheduledPost): Promise { const [newScheduledPost] = await db.insert(scheduledPosts).values(scheduledPost).returning(); return newScheduledPost; diff --git a/shared/schema.ts b/shared/schema.ts index c2ea279..12e6b21 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -5,7 +5,7 @@ import { createInsertSchema } from "drizzle-zod"; import { z } from "zod"; export const platformEnum = pgEnum("platform", ["facebook", "instagram"]); -export const postTypeEnum = pgEnum("post_type", ["feed", "story"]); +export const postTypeEnum = pgEnum("post_type", ["feed", "story", "both"]); export const postStatusEnum = pgEnum("post_status", ["draft", "scheduled", "published", "failed"]); export const mediaTypeEnum = pgEnum("media_type", ["image", "video"]); export const userRoleEnum = pgEnum("user_role", ["admin", "user"]);