mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-22 14:38:11 +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
+268
-24
No files matched your search
@@ -1,16 +1,45 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Trash2 } from "lucide-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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";
|
import type { ScheduledPost } from "@shared/schema";
|
||||||
|
|
||||||
export default function CalendarView() {
|
export default function CalendarView() {
|
||||||
const [currentDate, setCurrentDate] = useState(new Date());
|
const [currentDate, setCurrentDate] = useState(new Date());
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { data: scheduledPosts = [] } = useQuery<ScheduledPost[]>({
|
const { data: scheduledPosts = [] } = useQuery<ScheduledPost[]>({
|
||||||
queryKey: ["/api/scheduled-posts"],
|
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) => {
|
const getPostsForDate = (date: Date) => {
|
||||||
return (scheduledPosts || []).filter(post => {
|
return (scheduledPosts || []).filter(post => {
|
||||||
if (!post.scheduledAt) return false;
|
if (!post.scheduledAt) return false;
|
||||||
@@ -161,14 +190,28 @@ export default function CalendarView() {
|
|||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
className={`
|
className={`
|
||||||
px-3 py-2 rounded-lg text-xs cursor-pointer transition-all font-medium shadow-sm
|
px-3 py-2 rounded-lg text-xs transition-all font-medium shadow-sm group relative
|
||||||
${isPending ? 'bg-warning/20 text-warning hover:bg-warning/30' : ''}
|
${isPending ? 'bg-blue-500/20 text-blue-600 hover:bg-blue-500/30' : ''}
|
||||||
${isPublished ? 'bg-success/20 text-success hover:bg-success/30' : ''}
|
${isPublished ? 'bg-green-500/20 text-green-600 hover:bg-green-500/30' : ''}
|
||||||
`}
|
`}
|
||||||
data-testid={`calendar-post-${post.id}`}
|
data-testid={`calendar-post-${post.id}`}
|
||||||
>
|
>
|
||||||
<div className="font-semibold">{time}</div>
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="truncate opacity-90">{post.postType || 'Post'}</div>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -186,11 +229,11 @@ export default function CalendarView() {
|
|||||||
|
|
||||||
<div className="mt-8 flex items-center justify-center gap-8 flex-wrap">
|
<div className="mt-8 flex items-center justify-center gap-8 flex-wrap">
|
||||||
<div className="flex items-center gap-3">
|
<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>
|
<span className="text-sm text-muted-foreground font-medium">Programmé</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<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>
|
<span className="text-sm text-muted-foreground font-medium">Publié</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ export default function Sidebar() {
|
|||||||
{ icon: PlusCircle, label: "Nouvelle publication", href: "/new", badge: null },
|
{ icon: PlusCircle, label: "Nouvelle publication", href: "/new", badge: null },
|
||||||
{ icon: Calendar, label: "Calendrier", href: "/calendar", badge: null },
|
{ icon: Calendar, label: "Calendrier", href: "/calendar", badge: null },
|
||||||
{ icon: Images, label: "Médiathèque", href: "/media", 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 },
|
{ icon: Bot, label: "Assistant IA", href: "/ai", badge: null },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -141,6 +140,23 @@ export default function Sidebar() {
|
|||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<p className="text-xs font-semibold text-muted-foreground mb-2 px-4 uppercase tracking-wider">Administration</p>
|
<p className="text-xs font-semibold text-muted-foreground mb-2 px-4 uppercase tracking-wider">Administration</p>
|
||||||
</div>
|
</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
|
<Link
|
||||||
href="/users"
|
href="/users"
|
||||||
className={`
|
className={`
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
import type { SocialPage, Media } from "@shared/schema";
|
import type { SocialPage, Media } from "@shared/schema";
|
||||||
@@ -25,6 +26,7 @@ export default function NewPost() {
|
|||||||
const [scheduledDate, setScheduledDate] = useState('');
|
const [scheduledDate, setScheduledDate] = useState('');
|
||||||
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
|
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
|
||||||
const [postText, setPostText] = useState('');
|
const [postText, setPostText] = useState('');
|
||||||
|
const [postType, setPostType] = useState<'feed' | 'story' | 'both'>('feed');
|
||||||
|
|
||||||
const { data: pages = [] } = useQuery<SocialPage[]>({
|
const { data: pages = [] } = useQuery<SocialPage[]>({
|
||||||
queryKey: ['/api/pages'],
|
queryKey: ['/api/pages'],
|
||||||
@@ -115,12 +117,21 @@ export default function NewPost() {
|
|||||||
return;
|
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({
|
createPostMutation.mutate({
|
||||||
content: postText,
|
content: postText,
|
||||||
scheduledFor: scheduledDate || undefined,
|
scheduledFor: scheduledDate || undefined,
|
||||||
mediaId: selectedMedia || undefined,
|
mediaId: selectedMedia || undefined,
|
||||||
pageIds: selectedPages,
|
pageIds: selectedPages,
|
||||||
postType: 'feed', // Default to feed post
|
postType,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -332,6 +343,33 @@ export default function NewPost() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||||
<CardHeader className="p-6">
|
<CardHeader className="p-6">
|
||||||
<CardTitle>Planification</CardTitle>
|
<CardTitle>Planification</CardTitle>
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
-- Script d'initialisation de la base de données Social Flow
|
-- 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
|
-- 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)
|
-- Créer l'enum pour les rôles utilisateur (si pas déjà créé par drizzle-kit push)
|
||||||
DO $$ BEGIN
|
DO $$ BEGIN
|
||||||
CREATE TYPE user_role AS ENUM ('admin', 'user');
|
CREATE TYPE user_role AS ENUM ('admin', 'user');
|
||||||
|
|||||||
+74
-8
@@ -445,6 +445,11 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
const userId = user.id;
|
const userId = user.id;
|
||||||
const { pageIds, postType, mediaId, ...postFields } = req.body;
|
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
|
// Convert scheduledFor string to Date if provided
|
||||||
if (postFields.scheduledFor && typeof postFields.scheduledFor === 'string') {
|
if (postFields.scheduledFor && typeof postFields.scheduledFor === 'string') {
|
||||||
postFields.scheduledFor = new Date(postFields.scheduledFor);
|
postFields.scheduledFor = new Date(postFields.scheduledFor);
|
||||||
@@ -468,13 +473,31 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
? new Date(postFields.scheduledFor)
|
? new Date(postFields.scheduledFor)
|
||||||
: new Date(); // Publish immediately if no date specified
|
: new Date(); // Publish immediately if no date specified
|
||||||
|
|
||||||
|
const finalPostType = postType || 'feed';
|
||||||
|
|
||||||
for (const pageId of pageIds) {
|
for (const pageId of pageIds) {
|
||||||
await storage.createScheduledPost({
|
// If postType is "both", create two separate scheduled posts (story + feed)
|
||||||
postId: post.id,
|
if (finalPostType === 'both') {
|
||||||
pageId,
|
await storage.createScheduledPost({
|
||||||
postType: postType || 'feed',
|
postId: post.id,
|
||||||
scheduledAt,
|
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) => {
|
app.post("/api/scheduled-posts", requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const scheduledPostData = insertScheduledPostSchema.parse(req.body);
|
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) {
|
} catch (error) {
|
||||||
console.error("Error creating scheduled post:", error);
|
console.error("Error creating scheduled post:", error);
|
||||||
res.status(500).json({ error: "Failed to create scheduled post" });
|
res.status(500).json({ error: "Failed to create scheduled post" });
|
||||||
|
|||||||
@@ -30,11 +30,26 @@ export class FacebookService {
|
|||||||
throw new Error('No access token found for this page');
|
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
|
||||||
if (media) {
|
// Note: "both" type should never reach here - new posts are split at creation
|
||||||
return await this.publishPhotoPost(post, page, media);
|
// 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 {
|
} 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;
|
const data = await response.json() as FacebookPhotoResponse;
|
||||||
return data.post_id || data.id;
|
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();
|
export const facebookService = new FacebookService();
|
||||||
@@ -27,6 +27,23 @@ export class SchedulerService {
|
|||||||
|
|
||||||
for (const scheduledPost of pendingPosts) {
|
for (const scheduledPost of pendingPosts) {
|
||||||
try {
|
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);
|
await this.publishPost(scheduledPost);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error publishing post ${scheduledPost.id}:`, error);
|
console.error(`Error publishing post ${scheduledPost.id}:`, error);
|
||||||
@@ -54,6 +71,22 @@ export class SchedulerService {
|
|||||||
const postMediaList = await storage.getPostMedia(post.id);
|
const postMediaList = await storage.getPostMedia(post.id);
|
||||||
const media = postMediaList.length > 0 ? await storage.getMediaById(postMediaList[0].mediaId) : undefined;
|
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
|
// Publish to Facebook/Instagram
|
||||||
let externalPostId: string;
|
let externalPostId: string;
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export interface IStorage {
|
|||||||
// Scheduled Posts
|
// Scheduled Posts
|
||||||
getScheduledPosts(userId: string, startDate?: Date, endDate?: Date): Promise<ScheduledPost[]>;
|
getScheduledPosts(userId: string, startDate?: Date, endDate?: Date): Promise<ScheduledPost[]>;
|
||||||
getScheduledPost(id: string): Promise<ScheduledPost | undefined>;
|
getScheduledPost(id: string): Promise<ScheduledPost | undefined>;
|
||||||
|
getScheduledPostsByPost(postId: string): Promise<ScheduledPost[]>;
|
||||||
createScheduledPost(scheduledPost: InsertScheduledPost): Promise<ScheduledPost>;
|
createScheduledPost(scheduledPost: InsertScheduledPost): Promise<ScheduledPost>;
|
||||||
updateScheduledPost(id: string, scheduledPost: Partial<ScheduledPost>): Promise<ScheduledPost>;
|
updateScheduledPost(id: string, scheduledPost: Partial<ScheduledPost>): Promise<ScheduledPost>;
|
||||||
deleteScheduledPost(id: string): Promise<void>;
|
deleteScheduledPost(id: string): Promise<void>;
|
||||||
@@ -210,6 +211,10 @@ export class DatabaseStorage implements IStorage {
|
|||||||
return scheduledPost || undefined;
|
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> {
|
async createScheduledPost(scheduledPost: InsertScheduledPost): Promise<ScheduledPost> {
|
||||||
const [newScheduledPost] = await db.insert(scheduledPosts).values(scheduledPost).returning();
|
const [newScheduledPost] = await db.insert(scheduledPosts).values(scheduledPost).returning();
|
||||||
return newScheduledPost;
|
return newScheduledPost;
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ import { createInsertSchema } from "drizzle-zod";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const platformEnum = pgEnum("platform", ["facebook", "instagram"]);
|
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 postStatusEnum = pgEnum("post_status", ["draft", "scheduled", "published", "failed"]);
|
||||||
export const mediaTypeEnum = pgEnum("media_type", ["image", "video"]);
|
export const mediaTypeEnum = pgEnum("media_type", ["image", "video"]);
|
||||||
export const userRoleEnum = pgEnum("user_role", ["admin", "user"]);
|
export const userRoleEnum = pgEnum("user_role", ["admin", "user"]);
|
||||||
|
|||||||
Reference in new issue
Block a user