mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-20 13:38:10 +02:00
Integrate Cloudinary for cloud-based image and video storage
Implement Cloudinary integration for uploading, storing, and transforming media files, including a new settings section for user configuration and API endpoints for media management. Replit-Commit-Author: Agent Replit-Commit-Session-Id: ae4037a0-2a6f-4530-9bac-79b543286bda Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/397bca8c-984f-43ff-841a-10897aeb8140/ae4037a0-2a6f-4530-9bac-79b543286bda/p0c5kxJ
This commit is contained in:
1 parent
66e20a83e7
commit
bee6f53f23
6 files changed
+363
-35
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { Settings as SettingsIcon, Bell, Key, Shield } from "lucide-react";
|
||||
import { useState, useEffect } from "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 TopBar from "@/components/topbar";
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
|
||||
export default function Settings() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [notifications, setNotifications] = 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 (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
@@ -109,6 +151,67 @@ export default function Settings() {
|
||||
</CardContent>
|
||||
</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>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -125,12 +228,6 @@ export default function Settings() {
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button data-testid="button-save-settings">
|
||||
Enregistrer les modifications
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -40,8 +40,6 @@ services:
|
||||
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
|
||||
SESSION_SECRET: ${SESSION_SECRET:-your-secret-key-change-me}
|
||||
APP_URL: ${APP_URL:-http://localhost:5000}
|
||||
volumes:
|
||||
- ./uploads:/app/uploads
|
||||
networks:
|
||||
- internal
|
||||
- nginx_default
|
||||
|
||||
@@ -55,7 +55,7 @@ Preferred communication style: Simple, everyday language.
|
||||
- `/api/pages/*` - Social page 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**:
|
||||
- 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)
|
||||
|
||||
**Key Architectural Patterns**:
|
||||
- Service layer pattern (OpenRouterService, ImageProcessor, SchedulerService)
|
||||
- Service layer pattern (OpenRouterService, CloudinaryService, SchedulerService)
|
||||
- Storage abstraction layer for database operations
|
||||
- Middleware-based request processing pipeline
|
||||
- Separation of concerns between routes, services, and storage
|
||||
@@ -80,8 +80,9 @@ Preferred communication style: Simple, everyday language.
|
||||
|
||||
Core Tables:
|
||||
- `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
|
||||
- `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
|
||||
- `scheduled_posts` - Scheduled publication queue with page assignments
|
||||
- `ai_generations` - History of AI-generated content
|
||||
@@ -101,20 +102,21 @@ Core Tables:
|
||||
|
||||
### Media Processing Pipeline
|
||||
|
||||
**Image Processing Library**: Sharp for high-performance image manipulation
|
||||
**Cloud Storage**: Cloudinary for image/video storage and transformation
|
||||
|
||||
**Format Optimization Strategy**:
|
||||
- Facebook Feed: 1200x630px (landscape format)
|
||||
- Instagram Feed: 1080x1080px (square 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**:
|
||||
1. Upload original file to memory buffer
|
||||
2. Generate three optimized versions for different platforms
|
||||
3. Store all versions with references in database
|
||||
4. Serve via `/uploads` static route
|
||||
1. Upload original file to memory buffer via Multer
|
||||
2. Upload to Cloudinary with unique public_id
|
||||
3. Cloudinary automatically generates transformation URLs for all platform formats
|
||||
4. Store Cloudinary public_id and transformation URLs in database
|
||||
5. Serve optimized images via Cloudinary CDN
|
||||
|
||||
## External Dependencies
|
||||
|
||||
@@ -126,6 +128,16 @@ Core Tables:
|
||||
- Configuration: Requires `OPENROUTER_API_KEY` environment variable
|
||||
- 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
|
||||
|
||||
**Facebook Graph API**:
|
||||
|
||||
+104
-16
@@ -3,16 +3,12 @@ import { createServer, type Server } from "http";
|
||||
import { storage } from "./storage";
|
||||
import multer from "multer";
|
||||
import { openRouterService } from "./services/openrouter";
|
||||
import { imageProcessor } from "./services/imageProcessor";
|
||||
import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema } from "@shared/schema";
|
||||
import express from "express";
|
||||
import path from "path";
|
||||
import { cloudinaryService } from "./services/cloudinary";
|
||||
import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema, insertCloudinaryConfigSchema } from "@shared/schema";
|
||||
|
||||
const upload = multer({ storage: multer.memoryStorage() });
|
||||
|
||||
export async function registerRoutes(app: Express): Promise<Server> {
|
||||
// Serve uploaded files
|
||||
app.use("/uploads", express.static(path.join(process.cwd(), "uploads")));
|
||||
|
||||
// Stats endpoint
|
||||
app.get("/api/stats", async (req, res) => {
|
||||
@@ -69,21 +65,32 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
}
|
||||
|
||||
const userId = "demo-user"; // In real app, get from session/auth
|
||||
|
||||
// Check if Cloudinary is configured
|
||||
const cloudinaryConfig = await storage.getCloudinaryConfig(userId);
|
||||
|
||||
const originalUrl = await imageProcessor.saveOriginalFile(req.file.buffer, req.file.originalname);
|
||||
|
||||
let processedUrls = null;
|
||||
if (req.file.mimetype.startsWith("image/")) {
|
||||
processedUrls = await imageProcessor.processImage(req.file.buffer, req.file.originalname);
|
||||
if (!cloudinaryConfig) {
|
||||
return res.status(400).json({
|
||||
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({
|
||||
userId,
|
||||
type: req.file.mimetype.startsWith("video/") ? "video" : "image",
|
||||
originalUrl,
|
||||
facebookFeedUrl: processedUrls?.facebookFeed || null,
|
||||
instagramFeedUrl: processedUrls?.instagramFeed || null,
|
||||
instagramStoryUrl: processedUrls?.instagramStory || null,
|
||||
cloudinaryPublicId: uploadResult.publicId,
|
||||
originalUrl: uploadResult.originalUrl,
|
||||
facebookFeedUrl: uploadResult.facebookFeedUrl,
|
||||
instagramFeedUrl: uploadResult.instagramFeedUrl,
|
||||
instagramStoryUrl: uploadResult.instagramStoryUrl,
|
||||
fileName: req.file.originalname,
|
||||
fileSize: req.file.size,
|
||||
});
|
||||
@@ -91,7 +98,8 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
res.json(mediaItem);
|
||||
} catch (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
|
||||
app.get("/api/posts", async (req, res) => {
|
||||
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);
|
||||
|
||||
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,
|
||||
scheduledPosts,
|
||||
aiGenerations,
|
||||
cloudinaryConfig,
|
||||
type User,
|
||||
type InsertUser,
|
||||
type SocialPage,
|
||||
@@ -17,6 +18,8 @@ import {
|
||||
type InsertScheduledPost,
|
||||
type AiGeneration,
|
||||
type InsertAiGeneration,
|
||||
type CloudinaryConfig,
|
||||
type InsertCloudinaryConfig,
|
||||
} from "@shared/schema";
|
||||
import { db } from "./db";
|
||||
import { eq, and, gte, lte, desc, isNull } from "drizzle-orm";
|
||||
@@ -58,6 +61,11 @@ export interface IStorage {
|
||||
// AI Generations
|
||||
getAiGenerations(userId: string): 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 {
|
||||
@@ -208,6 +216,25 @@ export class DatabaseStorage implements IStorage {
|
||||
const [newGeneration] = await db.insert(aiGenerations).values(generation).returning();
|
||||
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();
|
||||
Reference in new issue
Block a user