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
+256 -12

No files matched your search

+50 -7
View File
@@ -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<ScheduledPost[]>({
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,15 +190,29 @@ export default function CalendarView() {
<div
key={idx}
className={`
px-3 py-2 rounded-lg text-xs cursor-pointer transition-all font-medium shadow-sm
${isPending ? 'bg-warning/20 text-warning hover:bg-warning/30' : ''}
${isPublished ? 'bg-success/20 text-success hover:bg-success/30' : ''}
px-3 py-2 rounded-lg text-xs transition-all font-medium shadow-sm group relative
${isPending ? 'bg-blue-500/20 text-blue-600 hover:bg-blue-500/30' : ''}
${isPublished ? 'bg-green-500/20 text-green-600 hover:bg-green-500/30' : ''}
`}
data-testid={`calendar-post-${post.id}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="font-semibold">{time}</div>
<div className="truncate opacity-90">{post.postType || 'Post'}</div>
</div>
{isPending && (
<button
onClick={(e) => handleDeletePost(post.id, e)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-red-500/20 rounded"
data-testid={`button-delete-post-${post.id}`}
title="Supprimer"
>
<Trash2 className="w-3 h-3 text-red-500" />
</button>
)}
</div>
</div>
);
})}
{postsForDay.length > 2 && (
@@ -186,11 +229,11 @@ export default function CalendarView() {
<div className="mt-8 flex items-center justify-center gap-8 flex-wrap">
<div className="flex items-center gap-3">
<div className="w-4 h-4 rounded-md bg-warning/20 border-2 border-warning"></div>
<div className="w-4 h-4 rounded-md bg-blue-500/20 border-2 border-blue-500"></div>
<span className="text-sm text-muted-foreground font-medium">Programmé</span>
</div>
<div className="flex items-center gap-3">
<div className="w-4 h-4 rounded-md bg-success/20 border-2 border-success"></div>
<div className="w-4 h-4 rounded-md bg-green-500/20 border-2 border-green-500"></div>
<span className="text-sm text-muted-foreground font-medium">Publié</span>
</div>
</div>
+17 -1
View File
@@ -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() {
<div className="mb-2">
<p className="text-xs font-semibold text-muted-foreground mb-2 px-4 uppercase tracking-wider">Administration</p>
</div>
<Link
href="/pages"
className={`
flex items-center gap-3 px-4 py-3 rounded-xl transition-all relative
${location === "/pages"
? 'bg-gradient-to-r from-primary/10 to-secondary/10 text-primary shadow-sm'
: 'text-muted-foreground hover:bg-sidebar-accent hover:text-foreground'
}
`}
data-testid="link-pages-gérées"
>
{location === "/pages" && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 gradient-primary rounded-r-full" />
)}
<Users className="w-5 h-5" />
<span>Pages gérées</span>
</Link>
<Link
href="/users"
className={`
+39 -1
View File
@@ -10,6 +10,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import type { SocialPage, Media } from "@shared/schema";
@@ -25,6 +26,7 @@ export default function NewPost() {
const [scheduledDate, setScheduledDate] = useState('');
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
const [postText, setPostText] = useState('');
const [postType, setPostType] = useState<'feed' | 'story' | 'both'>('feed');
const { data: pages = [] } = useQuery<SocialPage[]>({
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() {
</CardContent>
</Card>
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-6">
<CardTitle>Type de publication</CardTitle>
<CardDescription>Choisissez où publier</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="postType">Format</Label>
<Select value={postType} onValueChange={(value: 'feed' | 'story' | 'both') => setPostType(value)}>
<SelectTrigger id="postType" data-testid="select-post-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="feed" data-testid="option-feed">Feed uniquement</SelectItem>
<SelectItem value="story" data-testid="option-story">Story uniquement</SelectItem>
<SelectItem value="both" data-testid="option-both">Feed + Story</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{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'}
</p>
</div>
</CardContent>
</Card>
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-6">
<CardTitle>Planification</CardTitle>
+4
View File
@@ -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');
+67 -1
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) {
// If postType is "both", create two separate scheduled posts (story + feed)
if (finalPostType === 'both') {
await storage.createScheduledPost({
postId: post.id,
pageId,
postType: postType || 'feed',
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);
// 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" });
+40 -1
View File
@@ -30,13 +30,28 @@ 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
// 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 {
// Default to feed
if (media) {
return await this.publishPhotoPost(post, page, media);
} else {
return await this.publishTextPost(post, page);
}
}
}
private async publishTextPost(post: Post, page: SocialPage): Promise<string> {
const params = new URLSearchParams({
@@ -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;
+1 -1
View File
@@ -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"]);