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"]);