mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
10 files changed
+268
-24
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
@@ -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,14 +190,28 @@ 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="font-semibold">{time}</div>
|
||||
<div className="truncate opacity-90">{post.postType || 'Post'}</div>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
@@ -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');
|
||||
|
||||
+74
-8
@@ -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) {
|
||||
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<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);
|
||||
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" });
|
||||
|
||||
@@ -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<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