mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-24 15:38:10 +02:00
9 files changed
+417
-35
No files matched your search
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Settings as SettingsIcon, Bell, Key, Shield } from "lucide-react";
|
import { Settings as SettingsIcon, Bell, Key, Shield, Cloud } from "lucide-react";
|
||||||
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import Sidebar from "@/components/sidebar";
|
import Sidebar from "@/components/sidebar";
|
||||||
import TopBar from "@/components/topbar";
|
import TopBar from "@/components/topbar";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -7,11 +8,52 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [notifications, setNotifications] = useState(true);
|
const [notifications, setNotifications] = useState(true);
|
||||||
const [autoPublish, setAutoPublish] = useState(true);
|
const [autoPublish, setAutoPublish] = useState(true);
|
||||||
|
const [cloudName, setCloudName] = useState("");
|
||||||
|
const [apiKey, setApiKey] = useState("");
|
||||||
|
const [apiSecret, setApiSecret] = useState("");
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { data: cloudinaryConfig } = useQuery({
|
||||||
|
queryKey: ['/api/cloudinary/config'],
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (cloudinaryConfig) {
|
||||||
|
setCloudName((cloudinaryConfig as any).cloudName || "");
|
||||||
|
setApiKey((cloudinaryConfig as any).apiKey || "");
|
||||||
|
}
|
||||||
|
}, [cloudinaryConfig]);
|
||||||
|
|
||||||
|
const saveCloudinaryMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
apiRequest('POST', '/api/cloudinary/config', {
|
||||||
|
cloudName,
|
||||||
|
apiKey,
|
||||||
|
apiSecret,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['/api/cloudinary/config'] });
|
||||||
|
toast({
|
||||||
|
title: "Configuration sauvegardée",
|
||||||
|
description: "Vos paramètres Cloudinary ont été enregistrés",
|
||||||
|
});
|
||||||
|
setApiSecret(""); // Clear the secret after saving
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: "Erreur",
|
||||||
|
description: "Impossible de sauvegarder la configuration",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden bg-background">
|
<div className="flex h-screen overflow-hidden bg-background">
|
||||||
@@ -109,6 +151,67 @@ export default function Settings() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Cloud className="w-5 h-5" />
|
||||||
|
Configuration Cloudinary
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configurez Cloudinary pour le stockage d'images dans le cloud
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cloudName">Cloud Name</Label>
|
||||||
|
<Input
|
||||||
|
id="cloudName"
|
||||||
|
type="text"
|
||||||
|
value={cloudName}
|
||||||
|
onChange={(e) => setCloudName(e.target.value)}
|
||||||
|
placeholder="mon-cloud-name"
|
||||||
|
data-testid="input-cloud-name"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Trouvez-le dans votre tableau de bord Cloudinary
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="apiKey">API Key</Label>
|
||||||
|
<Input
|
||||||
|
id="apiKey"
|
||||||
|
type="text"
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
placeholder="123456789012345"
|
||||||
|
data-testid="input-api-key"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="apiSecret">API Secret</Label>
|
||||||
|
<Input
|
||||||
|
id="apiSecret"
|
||||||
|
type="password"
|
||||||
|
value={apiSecret}
|
||||||
|
onChange={(e) => setApiSecret(e.target.value)}
|
||||||
|
placeholder="••••••••••••••••"
|
||||||
|
data-testid="input-api-secret"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Votre API Secret restera sécurisé et ne sera jamais exposé
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => saveCloudinaryMutation.mutate()}
|
||||||
|
disabled={saveCloudinaryMutation.isPending || !cloudName || !apiKey || !apiSecret}
|
||||||
|
data-testid="button-save-cloudinary"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{saveCloudinaryMutation.isPending ? "Enregistrement..." : "Enregistrer Cloudinary"}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
@@ -125,12 +228,6 @@ export default function Settings() {
|
|||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button data-testid="button-save-settings">
|
|
||||||
Enregistrer les modifications
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -40,8 +40,6 @@ services:
|
|||||||
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
|
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
|
||||||
SESSION_SECRET: ${SESSION_SECRET:-your-secret-key-change-me}
|
SESSION_SECRET: ${SESSION_SECRET:-your-secret-key-change-me}
|
||||||
APP_URL: ${APP_URL:-http://localhost:5000}
|
APP_URL: ${APP_URL:-http://localhost:5000}
|
||||||
volumes:
|
|
||||||
- ./uploads:/app/uploads
|
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
- nginx_default
|
- nginx_default
|
||||||
|
|||||||
Generated
+25
@@ -44,6 +44,7 @@
|
|||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/pg": "^8.15.5",
|
"@types/pg": "^8.15.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"cloudinary": "^2.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"connect-pg-simple": "^10.0.0",
|
"connect-pg-simple": "^10.0.0",
|
||||||
@@ -4548,6 +4549,19 @@
|
|||||||
"url": "https://polar.sh/cva"
|
"url": "https://polar.sh/cva"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cloudinary": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-qrqDn31+qkMCzKu1GfRpzPNAO86jchcNwEHCUiqvPHNSFqu7FTNF9FuAkBUyvM1CFFgFPu64NT0DyeREwLwK0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"q": "^1.5.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=9"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/clsx": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
@@ -7020,6 +7034,17 @@
|
|||||||
"integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==",
|
"integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/q": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==",
|
||||||
|
"deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6.0",
|
||||||
|
"teleport": ">=0.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.13.0",
|
"version": "6.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/pg": "^8.15.5",
|
"@types/pg": "^8.15.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"cloudinary": "^2.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"connect-pg-simple": "^10.0.0",
|
"connect-pg-simple": "^10.0.0",
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ Preferred communication style: Simple, everyday language.
|
|||||||
- `/api/pages/*` - Social page management
|
- `/api/pages/*` - Social page management
|
||||||
- `/api/posts/*` - Post and scheduling management
|
- `/api/posts/*` - Post and scheduling management
|
||||||
|
|
||||||
**File Upload**: Multer middleware for handling multipart/form-data with in-memory storage strategy
|
**File Upload**: Multer middleware for handling multipart/form-data with in-memory storage strategy, integrated with Cloudinary for cloud storage
|
||||||
|
|
||||||
**Background Processing**:
|
**Background Processing**:
|
||||||
- Node-cron for scheduled task execution
|
- Node-cron for scheduled task execution
|
||||||
@@ -65,7 +65,7 @@ Preferred communication style: Simple, everyday language.
|
|||||||
**Session Management**: Stateless authentication with session cookies (connect-pg-simple for PostgreSQL session store)
|
**Session Management**: Stateless authentication with session cookies (connect-pg-simple for PostgreSQL session store)
|
||||||
|
|
||||||
**Key Architectural Patterns**:
|
**Key Architectural Patterns**:
|
||||||
- Service layer pattern (OpenRouterService, ImageProcessor, SchedulerService)
|
- Service layer pattern (OpenRouterService, CloudinaryService, SchedulerService)
|
||||||
- Storage abstraction layer for database operations
|
- Storage abstraction layer for database operations
|
||||||
- Middleware-based request processing pipeline
|
- Middleware-based request processing pipeline
|
||||||
- Separation of concerns between routes, services, and storage
|
- Separation of concerns between routes, services, and storage
|
||||||
@@ -80,8 +80,9 @@ Preferred communication style: Simple, everyday language.
|
|||||||
|
|
||||||
Core Tables:
|
Core Tables:
|
||||||
- `users` - User authentication and profiles
|
- `users` - User authentication and profiles
|
||||||
|
- `cloudinary_config` - Cloudinary configuration per user (cloud name, API key, API secret)
|
||||||
- `social_pages` - Connected Facebook/Instagram pages with access tokens
|
- `social_pages` - Connected Facebook/Instagram pages with access tokens
|
||||||
- `media` - Uploaded media files with platform-specific processed versions
|
- `media` - Uploaded media files with Cloudinary public IDs and transformation URLs
|
||||||
- `posts` - Post content with AI generation tracking and status
|
- `posts` - Post content with AI generation tracking and status
|
||||||
- `scheduled_posts` - Scheduled publication queue with page assignments
|
- `scheduled_posts` - Scheduled publication queue with page assignments
|
||||||
- `ai_generations` - History of AI-generated content
|
- `ai_generations` - History of AI-generated content
|
||||||
@@ -101,20 +102,21 @@ Core Tables:
|
|||||||
|
|
||||||
### Media Processing Pipeline
|
### Media Processing Pipeline
|
||||||
|
|
||||||
**Image Processing Library**: Sharp for high-performance image manipulation
|
**Cloud Storage**: Cloudinary for image/video storage and transformation
|
||||||
|
|
||||||
**Format Optimization Strategy**:
|
**Format Optimization Strategy**:
|
||||||
- Facebook Feed: 1200x630px (landscape format)
|
- Facebook Feed: 1200x630px (landscape format)
|
||||||
- Instagram Feed: 1080x1080px (square format)
|
- Instagram Feed: 1080x1080px (square format)
|
||||||
- Instagram Story: 1080x1920px (vertical format)
|
- Instagram Story: 1080x1920px (vertical format)
|
||||||
|
|
||||||
**Storage**: Local filesystem storage in `/uploads` directory with unique UUID-based filenames
|
**Storage**: Cloud-based storage via Cloudinary CDN with automatic transformations
|
||||||
|
|
||||||
**Processing Flow**:
|
**Processing Flow**:
|
||||||
1. Upload original file to memory buffer
|
1. Upload original file to memory buffer via Multer
|
||||||
2. Generate three optimized versions for different platforms
|
2. Upload to Cloudinary with unique public_id
|
||||||
3. Store all versions with references in database
|
3. Cloudinary automatically generates transformation URLs for all platform formats
|
||||||
4. Serve via `/uploads` static route
|
4. Store Cloudinary public_id and transformation URLs in database
|
||||||
|
5. Serve optimized images via Cloudinary CDN
|
||||||
|
|
||||||
## External Dependencies
|
## External Dependencies
|
||||||
|
|
||||||
@@ -126,6 +128,16 @@ Core Tables:
|
|||||||
- Configuration: Requires `OPENROUTER_API_KEY` environment variable
|
- Configuration: Requires `OPENROUTER_API_KEY` environment variable
|
||||||
- Temperature: 0.8 for creative variation
|
- Temperature: 0.8 for creative variation
|
||||||
|
|
||||||
|
### Cloud Storage
|
||||||
|
|
||||||
|
**Cloudinary**:
|
||||||
|
- Purpose: Cloud-based image and video storage with automatic transformations
|
||||||
|
- Configuration: User-configurable via Settings page (cloud name, API key, API secret)
|
||||||
|
- Storage: Images stored in Cloudinary cloud with unique public IDs
|
||||||
|
- Transformations: Automatic generation of platform-specific formats (Facebook Feed, Instagram Feed, Instagram Story)
|
||||||
|
- CDN: Global content delivery network for fast image loading
|
||||||
|
- Status: Fully integrated for media uploads
|
||||||
|
|
||||||
### Social Media APIs
|
### Social Media APIs
|
||||||
|
|
||||||
**Facebook Graph API**:
|
**Facebook Graph API**:
|
||||||
|
|||||||
+103
-15
@@ -3,16 +3,12 @@ import { createServer, type Server } from "http";
|
|||||||
import { storage } from "./storage";
|
import { storage } from "./storage";
|
||||||
import multer from "multer";
|
import multer from "multer";
|
||||||
import { openRouterService } from "./services/openrouter";
|
import { openRouterService } from "./services/openrouter";
|
||||||
import { imageProcessor } from "./services/imageProcessor";
|
import { cloudinaryService } from "./services/cloudinary";
|
||||||
import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema } from "@shared/schema";
|
import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema, insertCloudinaryConfigSchema } from "@shared/schema";
|
||||||
import express from "express";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
const upload = multer({ storage: multer.memoryStorage() });
|
const upload = multer({ storage: multer.memoryStorage() });
|
||||||
|
|
||||||
export async function registerRoutes(app: Express): Promise<Server> {
|
export async function registerRoutes(app: Express): Promise<Server> {
|
||||||
// Serve uploaded files
|
|
||||||
app.use("/uploads", express.static(path.join(process.cwd(), "uploads")));
|
|
||||||
|
|
||||||
// Stats endpoint
|
// Stats endpoint
|
||||||
app.get("/api/stats", async (req, res) => {
|
app.get("/api/stats", async (req, res) => {
|
||||||
@@ -70,20 +66,31 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
|
|
||||||
const userId = "demo-user"; // In real app, get from session/auth
|
const userId = "demo-user"; // In real app, get from session/auth
|
||||||
|
|
||||||
const originalUrl = await imageProcessor.saveOriginalFile(req.file.buffer, req.file.originalname);
|
// Check if Cloudinary is configured
|
||||||
|
const cloudinaryConfig = await storage.getCloudinaryConfig(userId);
|
||||||
|
|
||||||
let processedUrls = null;
|
if (!cloudinaryConfig) {
|
||||||
if (req.file.mimetype.startsWith("image/")) {
|
return res.status(400).json({
|
||||||
processedUrls = await imageProcessor.processImage(req.file.buffer, req.file.originalname);
|
error: "Cloudinary not configured. Please configure Cloudinary in Settings first."
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Upload to Cloudinary
|
||||||
|
const uploadResult = await cloudinaryService.uploadMedia(
|
||||||
|
req.file.buffer,
|
||||||
|
req.file.originalname,
|
||||||
|
userId,
|
||||||
|
req.file.mimetype
|
||||||
|
);
|
||||||
|
|
||||||
const mediaItem = await storage.createMedia({
|
const mediaItem = await storage.createMedia({
|
||||||
userId,
|
userId,
|
||||||
type: req.file.mimetype.startsWith("video/") ? "video" : "image",
|
type: req.file.mimetype.startsWith("video/") ? "video" : "image",
|
||||||
originalUrl,
|
cloudinaryPublicId: uploadResult.publicId,
|
||||||
facebookFeedUrl: processedUrls?.facebookFeed || null,
|
originalUrl: uploadResult.originalUrl,
|
||||||
instagramFeedUrl: processedUrls?.instagramFeed || null,
|
facebookFeedUrl: uploadResult.facebookFeedUrl,
|
||||||
instagramStoryUrl: processedUrls?.instagramStory || null,
|
instagramFeedUrl: uploadResult.instagramFeedUrl,
|
||||||
|
instagramStoryUrl: uploadResult.instagramStoryUrl,
|
||||||
fileName: req.file.originalname,
|
fileName: req.file.originalname,
|
||||||
fileSize: req.file.size,
|
fileSize: req.file.size,
|
||||||
});
|
});
|
||||||
@@ -91,7 +98,8 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
res.json(mediaItem);
|
res.json(mediaItem);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error uploading media:", error);
|
console.error("Error uploading media:", error);
|
||||||
res.status(500).json({ error: "Failed to upload media" });
|
const errorMessage = error instanceof Error ? error.message : "Failed to upload media";
|
||||||
|
res.status(500).json({ error: errorMessage });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -107,6 +115,39 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Delete media
|
||||||
|
app.delete("/api/media/:id", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = "demo-user"; // In real app, get from session/auth
|
||||||
|
const mediaId = req.params.id;
|
||||||
|
|
||||||
|
// Get media to find cloudinary public ID
|
||||||
|
const allMedia = await storage.getMedia(userId);
|
||||||
|
const mediaToDelete = allMedia.find(m => m.id === mediaId);
|
||||||
|
|
||||||
|
if (!mediaToDelete) {
|
||||||
|
return res.status(404).json({ error: "Media not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete from Cloudinary
|
||||||
|
if (mediaToDelete.cloudinaryPublicId) {
|
||||||
|
await cloudinaryService.deleteMedia(
|
||||||
|
mediaToDelete.cloudinaryPublicId,
|
||||||
|
userId,
|
||||||
|
mediaToDelete.type
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete from database
|
||||||
|
await storage.deleteMedia(mediaId);
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting media:", error);
|
||||||
|
res.status(500).json({ error: "Failed to delete media" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Posts
|
// Posts
|
||||||
app.get("/api/posts", async (req, res) => {
|
app.get("/api/posts", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -195,6 +236,53 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cloudinary Config
|
||||||
|
app.get("/api/cloudinary/config", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = "demo-user"; // In real app, get from session/auth
|
||||||
|
const config = await storage.getCloudinaryConfig(userId);
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return res.json(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't send the API secret to the client
|
||||||
|
const { apiSecret, ...safeConfig } = config;
|
||||||
|
res.json(safeConfig);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching Cloudinary config:", error);
|
||||||
|
res.status(500).json({ error: "Failed to fetch Cloudinary config" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/cloudinary/config", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = "demo-user"; // In real app, get from session/auth
|
||||||
|
|
||||||
|
const configData = insertCloudinaryConfigSchema.parse({
|
||||||
|
...req.body,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if config already exists
|
||||||
|
const existingConfig = await storage.getCloudinaryConfig(userId);
|
||||||
|
|
||||||
|
let config;
|
||||||
|
if (existingConfig) {
|
||||||
|
config = await storage.updateCloudinaryConfig(userId, configData);
|
||||||
|
} else {
|
||||||
|
config = await storage.createCloudinaryConfig(configData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't send the API secret back
|
||||||
|
const { apiSecret, ...safeConfig } = config;
|
||||||
|
res.json(safeConfig);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error saving Cloudinary config:", error);
|
||||||
|
res.status(500).json({ error: "Failed to save Cloudinary config" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const httpServer = createServer(app);
|
const httpServer = createServer(app);
|
||||||
|
|
||||||
return httpServer;
|
return httpServer;
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { v2 as cloudinary } from 'cloudinary';
|
||||||
|
import { storage } from '../storage';
|
||||||
|
|
||||||
|
class CloudinaryService {
|
||||||
|
async uploadMedia(
|
||||||
|
buffer: Buffer,
|
||||||
|
fileName: string,
|
||||||
|
userId: string,
|
||||||
|
mimeType: string
|
||||||
|
): Promise<{
|
||||||
|
publicId: string;
|
||||||
|
originalUrl: string;
|
||||||
|
facebookFeedUrl: string | null;
|
||||||
|
instagramFeedUrl: string | null;
|
||||||
|
instagramStoryUrl: string | null;
|
||||||
|
}> {
|
||||||
|
// Get user's Cloudinary config
|
||||||
|
const config = await storage.getCloudinaryConfig(userId);
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
throw new Error('Cloudinary configuration not found. Please configure Cloudinary in Settings.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure Cloudinary with user's credentials
|
||||||
|
cloudinary.config({
|
||||||
|
cloud_name: config.cloudName,
|
||||||
|
api_key: config.apiKey,
|
||||||
|
api_secret: config.apiSecret,
|
||||||
|
secure: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isVideo = mimeType.startsWith('video/');
|
||||||
|
|
||||||
|
// Upload to Cloudinary
|
||||||
|
const uploadResult = await new Promise<any>((resolve, reject) => {
|
||||||
|
const uploadStream = cloudinary.uploader.upload_stream(
|
||||||
|
{
|
||||||
|
folder: 'social-flow',
|
||||||
|
public_id: `${Date.now()}-${fileName.replace(/\.[^/.]+$/, '')}`,
|
||||||
|
resource_type: isVideo ? 'video' : 'image',
|
||||||
|
},
|
||||||
|
(error, result) => {
|
||||||
|
if (error) reject(error);
|
||||||
|
else resolve(result);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
uploadStream.end(buffer);
|
||||||
|
});
|
||||||
|
|
||||||
|
const publicId = uploadResult.public_id;
|
||||||
|
const originalUrl = uploadResult.secure_url;
|
||||||
|
|
||||||
|
// Generate transformation URLs only for images
|
||||||
|
let facebookFeedUrl = null;
|
||||||
|
let instagramFeedUrl = null;
|
||||||
|
let instagramStoryUrl = null;
|
||||||
|
|
||||||
|
if (!isVideo) {
|
||||||
|
facebookFeedUrl = cloudinary.url(publicId, {
|
||||||
|
transformation: [
|
||||||
|
{ width: 1200, height: 630, crop: 'fill', gravity: 'auto' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
instagramFeedUrl = cloudinary.url(publicId, {
|
||||||
|
transformation: [
|
||||||
|
{ width: 1080, height: 1080, crop: 'fill', gravity: 'auto' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
instagramStoryUrl = cloudinary.url(publicId, {
|
||||||
|
transformation: [
|
||||||
|
{ width: 1080, height: 1920, crop: 'fill', gravity: 'auto' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
publicId,
|
||||||
|
originalUrl,
|
||||||
|
facebookFeedUrl,
|
||||||
|
instagramFeedUrl,
|
||||||
|
instagramStoryUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMedia(publicId: string, userId: string, mediaType: 'image' | 'video'): Promise<void> {
|
||||||
|
const config = await storage.getCloudinaryConfig(userId);
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
throw new Error('Cloudinary configuration not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
cloudinary.config({
|
||||||
|
cloud_name: config.cloudName,
|
||||||
|
api_key: config.apiKey,
|
||||||
|
api_secret: config.apiSecret,
|
||||||
|
secure: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await cloudinary.uploader.destroy(publicId, {
|
||||||
|
resource_type: mediaType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cloudinaryService = new CloudinaryService();
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
posts,
|
posts,
|
||||||
scheduledPosts,
|
scheduledPosts,
|
||||||
aiGenerations,
|
aiGenerations,
|
||||||
|
cloudinaryConfig,
|
||||||
type User,
|
type User,
|
||||||
type InsertUser,
|
type InsertUser,
|
||||||
type SocialPage,
|
type SocialPage,
|
||||||
@@ -17,6 +18,8 @@ import {
|
|||||||
type InsertScheduledPost,
|
type InsertScheduledPost,
|
||||||
type AiGeneration,
|
type AiGeneration,
|
||||||
type InsertAiGeneration,
|
type InsertAiGeneration,
|
||||||
|
type CloudinaryConfig,
|
||||||
|
type InsertCloudinaryConfig,
|
||||||
} from "@shared/schema";
|
} from "@shared/schema";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { eq, and, gte, lte, desc, isNull } from "drizzle-orm";
|
import { eq, and, gte, lte, desc, isNull } from "drizzle-orm";
|
||||||
@@ -58,6 +61,11 @@ export interface IStorage {
|
|||||||
// AI Generations
|
// AI Generations
|
||||||
getAiGenerations(userId: string): Promise<AiGeneration[]>;
|
getAiGenerations(userId: string): Promise<AiGeneration[]>;
|
||||||
createAiGeneration(generation: InsertAiGeneration): Promise<AiGeneration>;
|
createAiGeneration(generation: InsertAiGeneration): Promise<AiGeneration>;
|
||||||
|
|
||||||
|
// Cloudinary Config
|
||||||
|
getCloudinaryConfig(userId: string): Promise<CloudinaryConfig | undefined>;
|
||||||
|
createCloudinaryConfig(config: InsertCloudinaryConfig): Promise<CloudinaryConfig>;
|
||||||
|
updateCloudinaryConfig(userId: string, config: Partial<InsertCloudinaryConfig>): Promise<CloudinaryConfig>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DatabaseStorage implements IStorage {
|
export class DatabaseStorage implements IStorage {
|
||||||
@@ -208,6 +216,25 @@ export class DatabaseStorage implements IStorage {
|
|||||||
const [newGeneration] = await db.insert(aiGenerations).values(generation).returning();
|
const [newGeneration] = await db.insert(aiGenerations).values(generation).returning();
|
||||||
return newGeneration;
|
return newGeneration;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cloudinary Config
|
||||||
|
async getCloudinaryConfig(userId: string): Promise<CloudinaryConfig | undefined> {
|
||||||
|
const [config] = await db.select().from(cloudinaryConfig).where(eq(cloudinaryConfig.userId, userId));
|
||||||
|
return config || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCloudinaryConfig(config: InsertCloudinaryConfig): Promise<CloudinaryConfig> {
|
||||||
|
const [newConfig] = await db.insert(cloudinaryConfig).values(config).returning();
|
||||||
|
return newConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCloudinaryConfig(userId: string, config: Partial<InsertCloudinaryConfig>): Promise<CloudinaryConfig> {
|
||||||
|
const [updated] = await db.update(cloudinaryConfig)
|
||||||
|
.set({ ...config, updatedAt: new Date() })
|
||||||
|
.where(eq(cloudinaryConfig.userId, userId))
|
||||||
|
.returning();
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const storage = new DatabaseStorage();
|
export const storage = new DatabaseStorage();
|
||||||
+29
-1
@@ -27,10 +27,21 @@ export const socialPages = pgTable("social_pages", {
|
|||||||
createdAt: timestamp("created_at").defaultNow(),
|
createdAt: timestamp("created_at").defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const cloudinaryConfig = pgTable("cloudinary_config", {
|
||||||
|
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
|
userId: varchar("user_id").notNull().unique().references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
cloudName: text("cloud_name").notNull(),
|
||||||
|
apiKey: text("api_key").notNull(),
|
||||||
|
apiSecret: text("api_secret").notNull(),
|
||||||
|
createdAt: timestamp("created_at").defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at").defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
export const media = pgTable("media", {
|
export const media = pgTable("media", {
|
||||||
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
type: mediaTypeEnum("type").notNull(),
|
type: mediaTypeEnum("type").notNull(),
|
||||||
|
cloudinaryPublicId: text("cloudinary_public_id"),
|
||||||
originalUrl: text("original_url").notNull(),
|
originalUrl: text("original_url").notNull(),
|
||||||
facebookFeedUrl: text("facebook_feed_url"),
|
facebookFeedUrl: text("facebook_feed_url"),
|
||||||
instagramFeedUrl: text("instagram_feed_url"),
|
instagramFeedUrl: text("instagram_feed_url"),
|
||||||
@@ -79,11 +90,19 @@ export const aiGenerations = pgTable("ai_generations", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
export const usersRelations = relations(users, ({ many }) => ({
|
export const usersRelations = relations(users, ({ many, one }) => ({
|
||||||
socialPages: many(socialPages),
|
socialPages: many(socialPages),
|
||||||
media: many(media),
|
media: many(media),
|
||||||
posts: many(posts),
|
posts: many(posts),
|
||||||
aiGenerations: many(aiGenerations),
|
aiGenerations: many(aiGenerations),
|
||||||
|
cloudinaryConfig: one(cloudinaryConfig),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const cloudinaryConfigRelations = relations(cloudinaryConfig, ({ one }) => ({
|
||||||
|
user: one(users, {
|
||||||
|
fields: [cloudinaryConfig.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const socialPagesRelations = relations(socialPages, ({ one, many }) => ({
|
export const socialPagesRelations = relations(socialPages, ({ one, many }) => ({
|
||||||
@@ -174,6 +193,12 @@ export const insertAiGenerationSchema = createInsertSchema(aiGenerations).omit({
|
|||||||
createdAt: true,
|
createdAt: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const insertCloudinaryConfigSchema = createInsertSchema(cloudinaryConfig).omit({
|
||||||
|
id: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
});
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export type User = typeof users.$inferSelect;
|
export type User = typeof users.$inferSelect;
|
||||||
export type InsertUser = z.infer<typeof insertUserSchema>;
|
export type InsertUser = z.infer<typeof insertUserSchema>;
|
||||||
@@ -192,3 +217,6 @@ export type InsertScheduledPost = z.infer<typeof insertScheduledPostSchema>;
|
|||||||
|
|
||||||
export type AiGeneration = typeof aiGenerations.$inferSelect;
|
export type AiGeneration = typeof aiGenerations.$inferSelect;
|
||||||
export type InsertAiGeneration = z.infer<typeof insertAiGenerationSchema>;
|
export type InsertAiGeneration = z.infer<typeof insertAiGenerationSchema>;
|
||||||
|
|
||||||
|
export type CloudinaryConfig = typeof cloudinaryConfig.$inferSelect;
|
||||||
|
export type InsertCloudinaryConfig = z.infer<typeof insertCloudinaryConfigSchema>;
|
||||||
Reference in new issue
Block a user