mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
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:
1 parent
02794fecc2
commit
3971c6f912
9 files changed
+256
-12
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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={`
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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" });
|
||||
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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"]);
|
||||
|
||||
Reference in new issue
Block a user