mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
La route /api/ai/generate connaissait la cause exacte de l'échec (configuration absente, clé invalide, crédits épuisés, modèle inconnu) mais la remplaçait par un « Failed to generate text » en 500, et l'interface affichait de son côté un message figé. Sans accès aux logs du serveur, l'utilisateur n'avait donc aucun moyen de savoir quoi corriger. Le motif renvoyé par OpenRouter est désormais extrait, tronqué et propagé jusqu'au bandeau d'erreur, avec un code HTTP qui distingue un problème de configuration (400) d'un refus du service tiers (502). La clé API n'apparaît à aucun moment dans ces messages. Même traitement pour /api/reels/generate-text, qui souffrait du même masquage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018uUPZ1GNpEkYB1dpXHYPek
1958 lines
69 KiB
TypeScript
1958 lines
69 KiB
TypeScript
import express, { type Express, type Request, type Response, type NextFunction } from "express";
|
|
import { createServer, type Server } from "http";
|
|
import fs from "fs";
|
|
import os from "os";
|
|
import path from "path";
|
|
import { storage } from "./storage";
|
|
import { db, pool } from "./db";
|
|
import multer from "multer";
|
|
import bcrypt from "bcrypt";
|
|
import passport from "./auth";
|
|
import { z } from "zod";
|
|
import { openRouterService, describeGenerationError } from "./services/openrouter";
|
|
import { minioService as cloudinaryService, buildMinioUrl } from "./services/minio";
|
|
import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema, insertCloudinaryConfigSchema, updateCloudinaryConfigSchema, insertOpenrouterConfigSchema, updateOpenrouterConfigSchema, insertUserSchema, postMedia, type SocialPage } from "@shared/schema";
|
|
import type { User, InsertUser, ScheduledPost } from "@shared/schema";
|
|
import { analyticsRouter } from "./routes/analytics";
|
|
import { reelsRouter } from "./routes/reels";
|
|
import { remotionRouter } from "./routes/remotion";
|
|
import { externalRouter } from "./routes/external";
|
|
import { tiktokRouter } from "./routes/tiktok";
|
|
import { legalRouter } from "./routes/legal";
|
|
import { insertAudioTrackSchema } from "@shared/schema";
|
|
import * as musicMetadata from "music-metadata";
|
|
|
|
// Types MIME autorisés pour les uploads
|
|
const ALLOWED_MIME_TYPES = [
|
|
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
|
'video/mp4', 'video/quicktime', 'video/webm'
|
|
];
|
|
|
|
// Types MIME autorisés pour l'audio
|
|
const ALLOWED_AUDIO_MIME_TYPES = [
|
|
'audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg'
|
|
];
|
|
|
|
// Configuration multer avec validation de taille et type
|
|
// Configuration multer avec validation de taille (4GB) et stockage disque temporaire
|
|
const upload = multer({
|
|
storage: multer.diskStorage({
|
|
destination: os.tmpdir(),
|
|
filename: (req, file, cb) => {
|
|
// Nettoyer le nom de fichier pour éviter les problèmes d'encodage
|
|
const safeName = file.originalname.replace(/[^a-zA-Z0-9.]/g, '_');
|
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
|
cb(null, uniqueSuffix + '-' + safeName);
|
|
}
|
|
}),
|
|
limits: {
|
|
fileSize: 4 * 1024 * 1024 * 1024, // 4GB max
|
|
files: 10 // 10 fichiers max
|
|
},
|
|
fileFilter: (req, file, cb) => {
|
|
// Accepter plus de formats vidéo si nécessaire (ex: mkv, avi) pour le transcodage
|
|
// Mais on garde les limites actuelles pour l'instant
|
|
if (ALLOWED_MIME_TYPES.includes(file.mimetype) || file.mimetype === 'application/octet-stream') { // iOS envoie parfois octet-stream
|
|
cb(null, true);
|
|
} else {
|
|
cb(new Error(`Type de fichier non autorisé: ${file.mimetype}`));
|
|
}
|
|
}
|
|
});
|
|
|
|
// Setup audio upload with different filters
|
|
// Files saved directly to the persistent local folder
|
|
const AUDIO_UPLOAD_DIR = process.env.AUDIO_UPLOAD_DIR || '/app/uploads/audio';
|
|
|
|
// Ensure the directory exists at startup
|
|
import { mkdirSync } from 'fs';
|
|
try { mkdirSync(AUDIO_UPLOAD_DIR, { recursive: true }); } catch { /* already exists */ }
|
|
|
|
const audioUpload = multer({
|
|
storage: multer.diskStorage({
|
|
destination: AUDIO_UPLOAD_DIR,
|
|
filename: (req, file, cb) => {
|
|
const safeName = file.originalname.replace(/[^a-zA-Z0-9.]/g, '_');
|
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
|
cb(null, uniqueSuffix + '-' + safeName);
|
|
}
|
|
}),
|
|
limits: {
|
|
fileSize: 50 * 1024 * 1024, // 50MB max for audio
|
|
files: 20
|
|
},
|
|
fileFilter: (req, file, cb) => {
|
|
if (ALLOWED_AUDIO_MIME_TYPES.includes(file.mimetype) || file.originalname.match(/\.(mp3|wav|ogg)$/i)) {
|
|
cb(null, true);
|
|
} else {
|
|
cb(new Error(`Type de fichier audio non autorisé: ${file.mimetype}`));
|
|
}
|
|
}
|
|
});
|
|
|
|
// Middleware pour vérifier l'authentification
|
|
function requireAuth(req: Request, res: Response, next: NextFunction) {
|
|
if (!req.isAuthenticated()) {
|
|
return res.status(401).json({ error: "Non authentifié" });
|
|
}
|
|
next();
|
|
}
|
|
|
|
// Middleware pour vérifier le rôle admin
|
|
function requireAdmin(req: Request, res: Response, next: NextFunction) {
|
|
if (!req.isAuthenticated()) {
|
|
return res.status(401).json({ error: "Non authentifié" });
|
|
}
|
|
const user = req.user as User;
|
|
if (user.role !== "admin") {
|
|
return res.status(403).json({ error: "Accès refusé. Réservé aux administrateurs." });
|
|
}
|
|
next();
|
|
}
|
|
|
|
export async function registerRoutes(app: Express): Promise<Server> {
|
|
|
|
|
|
|
|
// Routes d'authentification
|
|
app.post("/api/auth/login", (req, res, next) => {
|
|
passport.authenticate("local", (err: any, user: User | false, info: any) => {
|
|
if (err) {
|
|
return next(err);
|
|
}
|
|
if (!user) {
|
|
return res.status(401).json({ error: info?.message || "Authentification échouée" });
|
|
}
|
|
req.logIn(user, (err) => {
|
|
if (err) {
|
|
return next(err);
|
|
}
|
|
return res.json({
|
|
id: user.id,
|
|
username: user.username,
|
|
role: user.role,
|
|
});
|
|
});
|
|
})(req, res, next);
|
|
});
|
|
|
|
app.post("/api/auth/logout", (req, res) => {
|
|
req.logout((err) => {
|
|
if (err) {
|
|
return res.status(500).json({ error: "Erreur lors de la déconnexion" });
|
|
}
|
|
res.json({ message: "Déconnecté avec succès" });
|
|
});
|
|
});
|
|
|
|
app.get("/api/auth/session", (req, res) => {
|
|
if (req.isAuthenticated()) {
|
|
const user = req.user as User;
|
|
res.json({
|
|
id: user.id,
|
|
username: user.username,
|
|
role: user.role,
|
|
});
|
|
} else {
|
|
res.status(401).json({ error: "Non authentifié" });
|
|
}
|
|
});
|
|
|
|
// Route pour obtenir la liste de tous les utilisateurs (réservée aux admins)
|
|
app.get("/api/users", requireAdmin, async (req, res) => {
|
|
try {
|
|
const allUsers = await storage.getAllUsers();
|
|
|
|
// Ne pas envoyer les mots de passe
|
|
const safeUsers = allUsers.map(user => ({
|
|
id: user.id,
|
|
username: user.username,
|
|
role: user.role,
|
|
}));
|
|
|
|
res.json(safeUsers);
|
|
} catch (error) {
|
|
console.error("Error fetching users:", error);
|
|
res.status(500).json({ error: "Erreur lors de la récupération des utilisateurs" });
|
|
}
|
|
});
|
|
|
|
// Route pour créer un nouvel utilisateur (réservée aux admins)
|
|
app.post("/api/users", requireAdmin, async (req, res) => {
|
|
try {
|
|
// Validation Zod
|
|
const createUserSchema = insertUserSchema.extend({
|
|
username: insertUserSchema.shape.username.min(3, "Le nom d'utilisateur doit contenir au moins 3 caractères"),
|
|
password: insertUserSchema.shape.password.min(4, "Le mot de passe doit contenir au moins 4 caractères"),
|
|
});
|
|
|
|
const validatedData = createUserSchema.parse(req.body);
|
|
|
|
// Vérifier si l'utilisateur existe déjà
|
|
const existingUser = await storage.getUserByUsername(validatedData.username);
|
|
if (existingUser) {
|
|
return res.status(409).json({ error: "Ce nom d'utilisateur existe déjà" });
|
|
}
|
|
|
|
// Hasher le mot de passe
|
|
const hashedPassword = await bcrypt.hash(validatedData.password, 10);
|
|
|
|
const newUser = await storage.createUser({
|
|
username: validatedData.username,
|
|
password: hashedPassword,
|
|
role: validatedData.role || "user",
|
|
});
|
|
|
|
res.json({
|
|
id: newUser.id,
|
|
username: newUser.username,
|
|
role: newUser.role,
|
|
});
|
|
} catch (error: any) {
|
|
console.error("Error creating user:", error);
|
|
if (error.name === 'ZodError') {
|
|
return res.status(400).json({ error: error.errors[0]?.message || "Données invalides" });
|
|
}
|
|
res.status(500).json({ error: "Erreur lors de la création de l'utilisateur" });
|
|
}
|
|
});
|
|
|
|
// Route pour modifier un utilisateur (réservée aux admins)
|
|
app.patch("/api/users/:id", requireAdmin, async (req, res) => {
|
|
try {
|
|
const userId = req.params.id;
|
|
const { username, password, role } = req.body;
|
|
|
|
// Vérifier si l'utilisateur existe
|
|
const existingUser = await storage.getUser(userId);
|
|
if (!existingUser) {
|
|
return res.status(404).json({ error: "Utilisateur non trouvé" });
|
|
}
|
|
|
|
const updateData: Partial<InsertUser> = {};
|
|
|
|
if (username && username !== existingUser.username) {
|
|
// Vérifier si le nouveau username est déjà pris
|
|
const userWithSameUsername = await storage.getUserByUsername(username);
|
|
if (userWithSameUsername && userWithSameUsername.id !== userId) {
|
|
return res.status(409).json({ error: "Ce nom d'utilisateur est déjà utilisé" });
|
|
}
|
|
updateData.username = username;
|
|
}
|
|
|
|
if (password) {
|
|
// Hasher le nouveau mot de passe
|
|
updateData.password = await bcrypt.hash(password, 10);
|
|
}
|
|
|
|
if (role && (role === "admin" || role === "user")) {
|
|
updateData.role = role;
|
|
}
|
|
|
|
if (Object.keys(updateData).length === 0) {
|
|
return res.status(400).json({ error: "Aucune modification fournie" });
|
|
}
|
|
|
|
const updatedUser = await storage.updateUser(userId, updateData);
|
|
|
|
res.json({
|
|
id: updatedUser.id,
|
|
username: updatedUser.username,
|
|
role: updatedUser.role,
|
|
});
|
|
} catch (error: any) {
|
|
console.error("Error updating user:", error);
|
|
res.status(500).json({ error: "Erreur lors de la modification de l'utilisateur" });
|
|
}
|
|
});
|
|
|
|
// Route pour supprimer un utilisateur (réservée aux admins)
|
|
app.delete("/api/users/:id", requireAdmin, async (req, res) => {
|
|
try {
|
|
const userId = req.params.id;
|
|
const currentUser = req.user as User;
|
|
|
|
// Empêcher l'admin de se supprimer lui-même
|
|
if (userId === currentUser.id) {
|
|
return res.status(400).json({ error: "Vous ne pouvez pas supprimer votre propre compte" });
|
|
}
|
|
|
|
// Vérifier si l'utilisateur existe
|
|
const existingUser = await storage.getUser(userId);
|
|
if (!existingUser) {
|
|
return res.status(404).json({ error: "Utilisateur non trouvé" });
|
|
}
|
|
|
|
await storage.deleteUser(userId);
|
|
|
|
res.json({ success: true, message: "Utilisateur supprimé avec succès" });
|
|
} catch (error: any) {
|
|
console.error("Error deleting user:", error);
|
|
res.status(500).json({ error: "Erreur lors de la suppression de l'utilisateur" });
|
|
}
|
|
});
|
|
|
|
// Route pour obtenir les permissions d'un utilisateur (réservée aux admins)
|
|
app.get("/api/users/:id/page-permissions", requireAdmin, async (req, res) => {
|
|
try {
|
|
const userId = req.params.id;
|
|
const permissions = await storage.getUserPagePermissions(userId);
|
|
res.json(permissions);
|
|
} catch (error) {
|
|
console.error("Error fetching user permissions:", error);
|
|
res.status(500).json({ error: "Erreur lors de la récupération des permissions" });
|
|
}
|
|
});
|
|
|
|
// Route pour mettre à jour les permissions d'un utilisateur (réservée aux admins)
|
|
app.post("/api/users/:id/page-permissions", requireAdmin, async (req, res) => {
|
|
try {
|
|
const userId = req.params.id;
|
|
const { pageIds } = req.body as { pageIds: string[] };
|
|
|
|
if (!Array.isArray(pageIds)) {
|
|
return res.status(400).json({ error: "pageIds doit être un tableau" });
|
|
}
|
|
|
|
// Supprimer toutes les permissions existantes de l'utilisateur
|
|
await storage.deleteAllUserPagePermissions(userId);
|
|
|
|
// Créer les nouvelles permissions
|
|
const permissions = await Promise.all(
|
|
pageIds.map(pageId =>
|
|
storage.createPagePermission({ userId, pageId })
|
|
)
|
|
);
|
|
|
|
res.json(permissions);
|
|
} catch (error) {
|
|
console.error("Error updating user permissions:", error);
|
|
res.status(500).json({ error: "Erreur lors de la mise à jour des permissions" });
|
|
}
|
|
});
|
|
|
|
// Route pour migrer les permissions existantes (réservée aux admins)
|
|
app.post("/api/admin/migrate-permissions", requireAdmin, async (req, res) => {
|
|
try {
|
|
// Récupérer tous les utilisateurs
|
|
const allUsers = await storage.getAllUsers();
|
|
let migratedCount = 0;
|
|
|
|
for (const user of allUsers) {
|
|
// Récupérer toutes les pages créées par cet utilisateur
|
|
const userPages = await storage.getSocialPages(user.id);
|
|
|
|
for (const page of userPages) {
|
|
// Vérifier si une permission existe déjà
|
|
const existingPermissions = await storage.getUserPagePermissions(user.id);
|
|
const hasPermission = existingPermissions.some(p => p.pageId === page.id);
|
|
|
|
if (!hasPermission) {
|
|
// Créer la permission
|
|
await storage.createPagePermission({ userId: user.id, pageId: page.id });
|
|
migratedCount++;
|
|
}
|
|
}
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
message: `${migratedCount} permissions migrées avec succès`,
|
|
migratedCount
|
|
});
|
|
} catch (error) {
|
|
console.error("Error migrating permissions:", error);
|
|
res.status(500).json({ error: "Erreur lors de la migration des permissions" });
|
|
}
|
|
});
|
|
|
|
// Route pour vérifier si le mot de passe admin par défaut a été changé
|
|
app.get("/api/auth/default-password-status", async (req, res) => {
|
|
try {
|
|
const adminUser = await storage.getUserByUsername("admin");
|
|
|
|
if (!adminUser) {
|
|
return res.json({ isDefault: false });
|
|
}
|
|
|
|
// Vérifier si le mot de passe correspond à "admin"
|
|
const isDefaultPassword = await bcrypt.compare("admin", adminUser.password);
|
|
|
|
res.json({ isDefault: isDefaultPassword });
|
|
} catch (error) {
|
|
console.error("Error checking default password status:", error);
|
|
res.status(500).json({ error: "Erreur lors de la vérification du statut du mot de passe" });
|
|
}
|
|
});
|
|
|
|
// External API (clé API, pas de session)
|
|
app.use("/api/v1", externalRouter);
|
|
|
|
// Paramètres de l'API externe (admin seulement)
|
|
app.get("/api/settings/external-api", requireAdmin, async (req, res) => {
|
|
try {
|
|
const config = await storage.getAppConfig();
|
|
res.json({ configured: !!(config?.externalApiKey) });
|
|
} catch (error) {
|
|
console.error("Error fetching app config:", error);
|
|
res.status(500).json({ error: "Erreur lors de la récupération de la configuration" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/settings/external-api", requireAdmin, async (req, res) => {
|
|
try {
|
|
const { apiKey } = req.body;
|
|
if (!apiKey || typeof apiKey !== "string" || apiKey.trim() === "") {
|
|
return res.status(400).json({ error: "La clé API est requise" });
|
|
}
|
|
await storage.upsertAppConfig({ externalApiKey: apiKey.trim() });
|
|
res.json({ success: true, configured: true });
|
|
} catch (error) {
|
|
console.error("Error saving app config:", error);
|
|
res.status(500).json({ error: "Erreur lors de la sauvegarde de la configuration" });
|
|
}
|
|
});
|
|
|
|
app.delete("/api/settings/external-api", requireAdmin, async (req, res) => {
|
|
try {
|
|
await storage.upsertAppConfig({ externalApiKey: null });
|
|
res.json({ success: true, configured: false });
|
|
} catch (error) {
|
|
console.error("Error deleting app config:", error);
|
|
res.status(500).json({ error: "Erreur lors de la suppression de la clé" });
|
|
}
|
|
});
|
|
|
|
// Google Gemini API Key (TTS)
|
|
app.get("/api/settings/gemini", requireAdmin, async (req, res) => {
|
|
try {
|
|
const config = await storage.getAppConfig();
|
|
res.json({ configured: !!(config?.geminiApiKey) });
|
|
} catch (error) {
|
|
console.error("Error fetching Gemini config:", error);
|
|
res.status(500).json({ error: "Erreur lors de la récupération de la configuration" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/settings/gemini", requireAdmin, async (req, res) => {
|
|
try {
|
|
const { apiKey } = req.body;
|
|
if (!apiKey || typeof apiKey !== "string" || apiKey.trim() === "") {
|
|
return res.status(400).json({ error: "La clé API est requise" });
|
|
}
|
|
await storage.upsertAppConfig({ geminiApiKey: apiKey.trim() });
|
|
res.json({ success: true, configured: true });
|
|
} catch (error) {
|
|
console.error("Error saving Gemini config:", error);
|
|
res.status(500).json({ error: "Erreur lors de la sauvegarde de la configuration" });
|
|
}
|
|
});
|
|
|
|
app.delete("/api/settings/gemini", requireAdmin, async (req, res) => {
|
|
try {
|
|
await storage.upsertAppConfig({ geminiApiKey: null });
|
|
res.json({ success: true, configured: false });
|
|
} catch (error) {
|
|
console.error("Error deleting Gemini config:", error);
|
|
res.status(500).json({ error: "Erreur lors de la suppression de la clé" });
|
|
}
|
|
});
|
|
|
|
// Analytics Routes
|
|
app.use("/api/analytics", requireAuth, analyticsRouter);
|
|
|
|
// Fichiers servis à la racine du domaine, sans authentification : vérification
|
|
// de propriété du domaine par TikTok ("URL properties"), Google, Meta…
|
|
// Monté ici pour être servi avant le catch-all du frontend, et sans dépendre
|
|
// d'une reconstruction du client.
|
|
app.use(express.static(path.resolve(import.meta.dirname, "..", "public-root"), {
|
|
dotfiles: "ignore",
|
|
index: false,
|
|
}));
|
|
|
|
// Pages légales publiques (exigées par TikTok pour l'audit de l'application)
|
|
app.use(legalRouter);
|
|
|
|
// TikTok Routes (OAuth multi-comptes) — avant le routeur Reels monté sur /api
|
|
app.use("/api/tiktok", requireAuth, tiktokRouter);
|
|
|
|
// Reels & Music Routes
|
|
app.use("/api", requireAuth, reelsRouter);
|
|
app.use("/api/remotion", requireAuth, remotionRouter);
|
|
|
|
|
|
// Route SQL (réservée aux admins) - DÉSACTIVÉE EN PRODUCTION sauf si explicitement autorisée
|
|
app.post("/api/sql/execute", requireAdmin, async (req, res) => {
|
|
// Vérification de sécurité désactivée pour permettre l'accès
|
|
// if (process.env.NODE_ENV === 'production' && process.env.ALLOW_SQL_CONSOLE !== 'true') { ... }
|
|
|
|
try {
|
|
// Validation Zod
|
|
const sqlQuerySchema = z.object({
|
|
query: z.string().min(1, "La requête SQL ne peut pas être vide"),
|
|
});
|
|
|
|
const validatedData = sqlQuerySchema.parse(req.body);
|
|
|
|
const { db } = await import("./db");
|
|
const result = await db.execute(validatedData.query);
|
|
|
|
res.json({
|
|
success: true,
|
|
result,
|
|
});
|
|
} catch (error: any) {
|
|
console.error("Error executing SQL:", error);
|
|
if (error.name === 'ZodError') {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: error.errors[0]?.message || "Données invalides",
|
|
});
|
|
}
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message || "Erreur lors de l'exécution de la requête SQL",
|
|
});
|
|
}
|
|
});
|
|
|
|
// Route pour obtenir la liste des tables (réservée aux admins)
|
|
app.get("/api/sql/tables", requireAdmin, async (req, res) => {
|
|
try {
|
|
const { db } = await import("./db");
|
|
const result = await db.execute<{ tablename: string }>(
|
|
`SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename;`
|
|
);
|
|
|
|
res.json({
|
|
tables: result.rows || result,
|
|
});
|
|
} catch (error: any) {
|
|
console.error("Error fetching tables:", error);
|
|
res.status(500).json({
|
|
error: error.message || "Erreur lors de la récupération des tables",
|
|
});
|
|
}
|
|
});
|
|
|
|
|
|
// Stats endpoint
|
|
app.get("/api/stats", requireAuth, async (req, res) => {
|
|
try {
|
|
const user = req.user as User;
|
|
const userId = user.id;
|
|
|
|
const posts = await storage.getPosts(userId);
|
|
const pages = await storage.getSocialPages(userId);
|
|
const media = await storage.getMedia(userId);
|
|
const aiGenerations = await storage.getAiGenerations(userId);
|
|
|
|
// Statistiques actuelles
|
|
const scheduledPosts = posts.filter(p => p.status === "scheduled").length;
|
|
const currentAiTexts = aiGenerations.length;
|
|
const currentMedia = media.length;
|
|
|
|
// Calculer les statistiques de la période précédente (hier pour les textes IA, mois dernier pour les posts)
|
|
const now = new Date();
|
|
const yesterday = new Date(now);
|
|
yesterday.setDate(yesterday.getDate() - 1);
|
|
yesterday.setHours(0, 0, 0, 0);
|
|
|
|
const lastMonth = new Date(now);
|
|
lastMonth.setMonth(lastMonth.getMonth() - 1);
|
|
|
|
// Textes IA générés hier
|
|
const aiTextsYesterday = aiGenerations.filter(gen => {
|
|
if (!gen.createdAt) return false;
|
|
const genDate = new Date(gen.createdAt);
|
|
genDate.setHours(0, 0, 0, 0);
|
|
return genDate.getTime() === yesterday.getTime();
|
|
}).length;
|
|
|
|
// Posts planifiés le mois dernier
|
|
const scheduledPostsLastMonth = posts.filter(p => {
|
|
if (!p.createdAt) return false;
|
|
const createdDate = new Date(p.createdAt);
|
|
return p.status === "scheduled" && createdDate < lastMonth;
|
|
}).length;
|
|
|
|
// Calculer les variations en pourcentage
|
|
const aiTextChange = aiTextsYesterday > 0
|
|
? Math.round(((currentAiTexts - aiTextsYesterday) / aiTextsYesterday) * 100)
|
|
: currentAiTexts > 0 ? 100 : 0;
|
|
|
|
const scheduledPostsChange = scheduledPostsLastMonth > 0
|
|
? Math.round(((scheduledPosts - scheduledPostsLastMonth) / scheduledPostsLastMonth) * 100)
|
|
: scheduledPosts > 0 ? 100 : 0;
|
|
|
|
res.json({
|
|
scheduledPosts,
|
|
scheduledPostsChange: scheduledPostsChange > 0 ? `+${scheduledPostsChange}%` : `${scheduledPostsChange}%`,
|
|
scheduledPostsTrending: scheduledPostsChange >= 0 ? "up" : "down",
|
|
connectedPages: pages.length,
|
|
aiTextsGenerated: currentAiTexts,
|
|
aiTextsChange: aiTextChange > 0 ? `+${aiTextChange}%` : `${aiTextChange}%`,
|
|
aiTextsTrending: aiTextChange >= 0 ? "up" : "down",
|
|
mediaStored: media.length,
|
|
});
|
|
} catch (error) {
|
|
console.error("Error fetching stats:", error);
|
|
res.status(500).json({ error: "Failed to fetch stats" });
|
|
}
|
|
});
|
|
|
|
// Get available AI models from OpenRouter
|
|
app.get("/api/ai/models", requireAdmin, async (req, res) => {
|
|
try {
|
|
const models = await openRouterService.getAvailableModels();
|
|
res.json({ models });
|
|
} catch (error) {
|
|
console.error("Error fetching models:", error);
|
|
res.status(500).json({ error: "Failed to fetch models" });
|
|
}
|
|
});
|
|
|
|
// AI text generation
|
|
app.post("/api/ai/generate", requireAuth, async (req, res) => {
|
|
try {
|
|
const user = req.user as User;
|
|
const userId = user.id;
|
|
const { model, ...productInfo } = req.body;
|
|
|
|
const generatedTexts = await openRouterService.generatePostText(productInfo, userId, model);
|
|
|
|
// Save to database
|
|
await storage.createAiGeneration({
|
|
userId,
|
|
productInfo,
|
|
generatedTexts,
|
|
});
|
|
|
|
res.json({ variants: generatedTexts });
|
|
} catch (error) {
|
|
console.error("Error generating text:", error);
|
|
const { status, message } = describeGenerationError(error);
|
|
res.status(status).json({ error: message });
|
|
}
|
|
});
|
|
|
|
// Media upload
|
|
app.post("/api/media/upload", requireAuth, upload.single("file"), async (req, res) => {
|
|
try {
|
|
if (!req.file) {
|
|
return res.status(400).json({ error: "No file uploaded" });
|
|
}
|
|
|
|
console.log(`📂 Processing upload: ${req.file.originalname} (${(req.file.size / 1024 / 1024).toFixed(2)} MB)`);
|
|
|
|
const user = req.user as User;
|
|
const userId = user.id;
|
|
|
|
// Upload using file path (streamed from disk)
|
|
// req.file.path est disponible avec DiskStorage
|
|
const uploadResult = await cloudinaryService.uploadMedia(
|
|
req.file.path,
|
|
req.file.originalname,
|
|
userId,
|
|
req.file.mimetype
|
|
);
|
|
|
|
// Clean up temp file immediately after upload to free disk space
|
|
await fs.promises.unlink(req.file.path).catch(err => console.error("Failed to cleanup temp file:", err));
|
|
|
|
const mediaItem = await storage.createMedia({
|
|
userId,
|
|
// Détection basique basée sur le mimetype ou le résultat Cloudinary
|
|
type: req.file.mimetype.startsWith("video/") || req.file.originalname.match(/\.(mp4|mov|avi|mkv)$/i) ? "video" : "image",
|
|
cloudinaryPublicId: uploadResult.publicId,
|
|
originalUrl: uploadResult.originalUrl,
|
|
facebookFeedUrl: uploadResult.facebookFeedUrl,
|
|
instagramFeedUrl: uploadResult.instagramFeedUrl,
|
|
instagramStoryUrl: uploadResult.instagramStoryUrl,
|
|
fileName: req.file.originalname,
|
|
fileSize: req.file.size,
|
|
});
|
|
|
|
res.json(mediaItem);
|
|
} catch (error) {
|
|
console.error("Error uploading media:", error);
|
|
|
|
// Attempt cleanup on error
|
|
if (req.file && req.file.path) {
|
|
await fs.promises.unlink(req.file.path).catch(() => { });
|
|
}
|
|
|
|
const errorMessage = error instanceof Error ? error.message : "Failed to upload media";
|
|
res.status(500).json({ error: errorMessage });
|
|
}
|
|
});
|
|
|
|
// One-time migration: fix absolute localhost URLs → relative paths in media table
|
|
app.post("/api/media/fix-urls", requireAuth, async (req, res) => {
|
|
try {
|
|
const user = req.user as User;
|
|
if ((user as any).role !== 'admin') {
|
|
return res.status(403).json({ error: "Admin only" });
|
|
}
|
|
|
|
// Convert any absolute URL like http(s)://hostname/uploads/... → /uploads/...
|
|
const result = await pool.query(`
|
|
UPDATE media SET
|
|
original_url = regexp_replace(original_url, '^https?://[^/]+(/uploads/)', '\\1'),
|
|
facebook_feed_url = regexp_replace(facebook_feed_url, '^https?://[^/]+(/uploads/)', '\\1'),
|
|
instagram_feed_url = regexp_replace(instagram_feed_url, '^https?://[^/]+(/uploads/)', '\\1'),
|
|
instagram_story_url= regexp_replace(instagram_story_url,'^https?://[^/]+(/uploads/)', '\\1')
|
|
WHERE
|
|
original_url ~ '^https?://[^/]+/uploads/'
|
|
OR facebook_feed_url ~ '^https?://[^/]+/uploads/'
|
|
OR instagram_feed_url ~ '^https?://[^/]+/uploads/'
|
|
OR instagram_story_url ~ '^https?://[^/]+/uploads/'
|
|
`);
|
|
|
|
res.json({ updated: result.rowCount });
|
|
} catch (error) {
|
|
console.error("Error fixing media URLs:", error);
|
|
res.status(500).json({ error: "Migration failed" });
|
|
}
|
|
});
|
|
|
|
// Get media
|
|
app.get("/api/media", requireAuth, async (req, res) => {
|
|
try {
|
|
const user = req.user as User;
|
|
const userId = user.id;
|
|
const media = await storage.getMedia(userId);
|
|
res.json(media);
|
|
} catch (error) {
|
|
console.error("Error fetching media:", error);
|
|
res.status(500).json({ error: "Failed to fetch media" });
|
|
}
|
|
});
|
|
|
|
// Apply overlays to image using Sharp (server-side processing)
|
|
// IMPORTANT: This must be BEFORE /api/media/:id to avoid being intercepted by :id parameter
|
|
app.post("/api/media/apply-overlays", requireAuth, async (req, res) => {
|
|
try {
|
|
const { imageUrl, ribbon, priceBadge, logo } = req.body;
|
|
const user = req.user as User;
|
|
const userId = user.id;
|
|
|
|
if (!imageUrl) {
|
|
return res.status(400).json({ error: "Image URL required" });
|
|
}
|
|
|
|
console.log("🎨 Applying overlays server-side with Sharp...");
|
|
console.log("📥 Image URL:", imageUrl);
|
|
console.log("🎀 Ribbon:", ribbon);
|
|
console.log("💰 Price Badge:", priceBadge);
|
|
console.log("🏢 Logo:", logo);
|
|
|
|
// Download image from URL
|
|
const imageResponse = await fetch(imageUrl);
|
|
const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
|
|
console.log("✅ Image downloaded:", imageBuffer.length, "bytes");
|
|
|
|
// Load with Sharp to process
|
|
const sharp = (await import("sharp")).default;
|
|
let image = sharp(imageBuffer).rotate();
|
|
|
|
// Get image metadata for dimensions
|
|
const metadata = await image.metadata();
|
|
const width = metadata.width!;
|
|
const height = metadata.height!;
|
|
|
|
console.log(`📐 Image dimensions: ${width}x${height}`);
|
|
|
|
// Create SVG overlays
|
|
const overlays: any[] = [];
|
|
|
|
// Add ribbon overlay
|
|
if (ribbon) {
|
|
const ribbonSize = 150;
|
|
const color = ribbon.color === 'red' ? '#dc2626' : '#eab308';
|
|
const fontSize = ribbon.text.length <= 5 ? 22 : ribbon.text.length <= 8 ? 18 : ribbon.text.length <= 11 ? 15 : 12;
|
|
|
|
// Position mapping for each corner
|
|
const positions: Record<string, { x: number; y: number; polygon: string; textX: number; textY: number; textRotation: number }> = {
|
|
north_west: {
|
|
x: 0,
|
|
y: 0,
|
|
polygon: `0,0 ${ribbonSize},0 0,${ribbonSize}`, // Top-left triangle
|
|
textX: ribbonSize * 0.3,
|
|
textY: ribbonSize * 0.3,
|
|
textRotation: -45
|
|
},
|
|
north_east: {
|
|
x: width - ribbonSize,
|
|
y: 0,
|
|
polygon: `0,0 ${ribbonSize},0 ${ribbonSize},${ribbonSize}`, // Top-right triangle
|
|
textX: ribbonSize * 0.7,
|
|
textY: ribbonSize * 0.3,
|
|
textRotation: 45
|
|
},
|
|
south_west: {
|
|
x: 0,
|
|
y: height - ribbonSize,
|
|
polygon: `0,0 0,${ribbonSize} ${ribbonSize},${ribbonSize}`, // Bottom-left triangle
|
|
textX: ribbonSize * 0.3,
|
|
textY: ribbonSize * 0.7,
|
|
textRotation: -135
|
|
},
|
|
south_east: {
|
|
x: width - ribbonSize,
|
|
y: height - ribbonSize,
|
|
polygon: `${ribbonSize},0 0,${ribbonSize} ${ribbonSize},${ribbonSize}`, // Bottom-right triangle
|
|
textX: ribbonSize * 0.7,
|
|
textY: ribbonSize * 0.7,
|
|
textRotation: 135
|
|
}
|
|
};
|
|
|
|
const pos = positions[ribbon.position] || positions.north_west;
|
|
|
|
const ribbonSvg = `
|
|
<svg width="${ribbonSize}" height="${ribbonSize}" xmlns="http://www.w3.org/2000/svg">
|
|
<polygon points="${pos.polygon}" fill="${color}"/>
|
|
<text x="${pos.textX}" y="${pos.textY}"
|
|
font-family="Arial, sans-serif" font-size="${fontSize}" font-weight="bold"
|
|
fill="white" text-anchor="middle" dominant-baseline="middle"
|
|
transform="rotate(${pos.textRotation}, ${pos.textX}, ${pos.textY})">
|
|
${ribbon.text}
|
|
</text>
|
|
</svg>
|
|
`;
|
|
|
|
overlays.push({
|
|
input: Buffer.from(ribbonSvg),
|
|
top: pos.y,
|
|
left: pos.x
|
|
});
|
|
}
|
|
|
|
// Add price badge overlay
|
|
if (priceBadge) {
|
|
const padding = 16;
|
|
const badgeText = `${priceBadge.price} €`;
|
|
const fontSize = priceBadge.size;
|
|
const textWidth = badgeText.length * fontSize * 0.6; // Approximate
|
|
const badgeWidth = textWidth + padding * 2;
|
|
const badgeHeight = fontSize + padding;
|
|
const color = priceBadge.color === 'red' ? '#dc2626' : '#eab308';
|
|
|
|
let x = padding;
|
|
let y = padding;
|
|
|
|
if (priceBadge.position === 'north_east') {
|
|
x = width - badgeWidth - padding;
|
|
} else if (priceBadge.position === 'south_west') {
|
|
y = height - badgeHeight - padding;
|
|
} else if (priceBadge.position === 'south_east') {
|
|
x = width - badgeWidth - padding;
|
|
y = height - badgeHeight - padding;
|
|
}
|
|
|
|
const badgeSvg = `
|
|
<svg width="${badgeWidth}" height="${badgeHeight}">
|
|
<rect x="0" y="0" width="${badgeWidth}" height="${badgeHeight}"
|
|
rx="${badgeHeight / 2}" ry="${badgeHeight / 2}" fill="${color}"/>
|
|
<text x="${badgeWidth / 2}" y="${badgeHeight / 2}"
|
|
font-family="Arial, sans-serif" font-size="${fontSize}" font-weight="bold"
|
|
fill="white" text-anchor="middle" dominant-baseline="middle">
|
|
${badgeText}
|
|
</text>
|
|
</svg>
|
|
`;
|
|
|
|
overlays.push({
|
|
input: Buffer.from(badgeSvg),
|
|
top: y,
|
|
left: x
|
|
});
|
|
}
|
|
|
|
// Add logo overlay
|
|
if (logo && logo.enabled) {
|
|
console.log("🏢 Adding logo overlay...");
|
|
|
|
// Get Cloudinary config to get logo public ID
|
|
const cloudinaryConfig = await storage.getCloudinaryConfig();
|
|
if (cloudinaryConfig && cloudinaryConfig.logoPublicId) {
|
|
try {
|
|
// Build logo URL from Cloudinary
|
|
const logoUrl = buildMinioUrl(cloudinaryConfig.cloudName, cloudinaryConfig.logoPublicId, cloudinaryConfig.publicUrl);
|
|
console.log("📥 Downloading logo from:", logoUrl);
|
|
|
|
// Download logo
|
|
const logoResponse = await fetch(logoUrl);
|
|
const logoBuffer = Buffer.from(await logoResponse.arrayBuffer());
|
|
|
|
// Determine logo size based on selection with safety cap
|
|
const padding = 20;
|
|
const maxLogoWidth = width - (padding * 2); // Ensure logo fits within canvas
|
|
|
|
const logoSizePercentages = {
|
|
small: 0.35, // 35% of image width
|
|
medium: 0.50, // 50% of image width
|
|
large: 0.70 // 70% of image width
|
|
};
|
|
|
|
const requestedWidth = Math.round(width * logoSizePercentages[logo.size as keyof typeof logoSizePercentages] || logoSizePercentages.medium);
|
|
const logoWidth = Math.min(requestedWidth, maxLogoWidth);
|
|
|
|
// Resize logo preserving aspect ratio and apply opacity
|
|
const resizedLogo = await sharp(logoBuffer)
|
|
.resize({ width: logoWidth, fit: 'contain' })
|
|
.ensureAlpha()
|
|
.toBuffer();
|
|
|
|
// Get resized logo dimensions
|
|
const logoMetadata = await sharp(resizedLogo).metadata();
|
|
const logoHeight = logoMetadata.height || logoWidth;
|
|
|
|
// Ensure logo fits within height as well
|
|
const maxLogoHeight = height - (padding * 2);
|
|
if (logoHeight > maxLogoHeight) {
|
|
console.warn(`⚠️ Logo height ${logoHeight}px exceeds max ${maxLogoHeight}px, would be clipped`);
|
|
}
|
|
|
|
// Calculate position with padding
|
|
let logoX = padding;
|
|
let logoY = padding;
|
|
|
|
if (logo.position === 'north_east') {
|
|
logoX = width - logoWidth - padding;
|
|
} else if (logo.position === 'south_west') {
|
|
logoY = height - logoHeight - padding;
|
|
} else if (logo.position === 'south_east') {
|
|
logoX = width - logoWidth - padding;
|
|
logoY = height - logoHeight - padding;
|
|
} else if (logo.position === 'center') {
|
|
logoX = Math.round((width - logoWidth) / 2);
|
|
logoY = Math.round((height - logoHeight) / 2);
|
|
}
|
|
|
|
// Apply opacity by manipulating alpha channel
|
|
let logoWithOpacity = resizedLogo;
|
|
if (logo.opacity < 100) {
|
|
// Create a semi-transparent version of the logo
|
|
const opacityFactor = logo.opacity / 100;
|
|
logoWithOpacity = await sharp(resizedLogo)
|
|
.ensureAlpha()
|
|
.linear(opacityFactor, 0)
|
|
.toBuffer();
|
|
}
|
|
|
|
// Add logo overlay
|
|
overlays.push({
|
|
input: logoWithOpacity,
|
|
top: logoY,
|
|
left: logoX,
|
|
blend: 'over'
|
|
});
|
|
|
|
console.log(`✅ Logo added at position ${logo.position} with ${logo.opacity}% opacity`);
|
|
} catch (logoError) {
|
|
console.error("⚠️ Failed to apply logo:", logoError);
|
|
// Continue without logo if it fails
|
|
}
|
|
} else {
|
|
console.log("⚠️ No logo configured in settings");
|
|
}
|
|
}
|
|
|
|
// Apply all overlays
|
|
if (overlays.length > 0) {
|
|
image = image.composite(overlays);
|
|
}
|
|
|
|
// Convert to buffer
|
|
const outputBuffer = await image.jpeg({ quality: 95 }).toBuffer();
|
|
|
|
console.log("✅ Overlays applied, uploading to local storage...");
|
|
|
|
// Upload to local storage
|
|
const uploadResult = await cloudinaryService.uploadMedia(
|
|
outputBuffer,
|
|
`edited_${Date.now()}.jpg`,
|
|
userId,
|
|
'image/jpeg'
|
|
);
|
|
|
|
// Save to database
|
|
// IMPORTANT: For edited images with overlays, use originalUrl for ALL formats
|
|
// because Cloudinary transformations would recreate versions WITHOUT the overlays
|
|
const mediaItem = await storage.createMedia({
|
|
userId,
|
|
type: "image",
|
|
cloudinaryPublicId: uploadResult.publicId,
|
|
originalUrl: uploadResult.originalUrl,
|
|
facebookFeedUrl: uploadResult.originalUrl, // Use original with overlays
|
|
instagramFeedUrl: uploadResult.originalUrl, // Use original with overlays
|
|
instagramStoryUrl: uploadResult.originalUrl, // Use original with overlays
|
|
fileName: `edited_${Date.now()}.jpg`,
|
|
fileSize: outputBuffer.length,
|
|
});
|
|
|
|
console.log("✅ Image saved with ID:", mediaItem.id);
|
|
res.json(mediaItem);
|
|
} catch (error) {
|
|
console.error("💥 Error applying overlays:", error);
|
|
const errorMessage = error instanceof Error ? error.message : "Failed to apply overlays";
|
|
res.status(500).json({ error: errorMessage });
|
|
}
|
|
});
|
|
|
|
// Delete media
|
|
app.delete("/api/media/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const user = req.user as User;
|
|
const userId = user.id;
|
|
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" });
|
|
}
|
|
});
|
|
|
|
// Audio Tracks Management (Admin Only) - Multi-file upload (stored locally)
|
|
app.post("/api/audio-tracks", requireAdmin, audioUpload.array("files", 20), async (req, res) => {
|
|
try {
|
|
const files = req.files as Express.Multer.File[];
|
|
if (!files || files.length === 0) {
|
|
return res.status(400).json({ error: "No audio file uploaded" });
|
|
}
|
|
|
|
const user = req.user as User;
|
|
const results = [];
|
|
|
|
for (const file of files) {
|
|
try {
|
|
console.log(`🎵 Audio saved locally: ${file.originalname} (${(file.size / 1024 / 1024).toFixed(2)} MB)`);
|
|
|
|
// Multer/busboy reçoit le nom en latin1, mais le navigateur l'envoie en UTF-8 → on reconvertit
|
|
const decodedName = Buffer.from(file.originalname, 'latin1').toString('utf8');
|
|
const publicUrl = `/uploads/audio/${file.filename}`;
|
|
|
|
let duration = 0;
|
|
try {
|
|
const meta = await musicMetadata.parseFile(file.path);
|
|
duration = Math.round(meta.format.duration ?? 0);
|
|
} catch { /* non-fatal */ }
|
|
|
|
const track = await storage.createAudioTrack({
|
|
userId: user.id,
|
|
title: decodedName.replace(/\.[^/.]+$/, ""),
|
|
fileName: decodedName,
|
|
url: publicUrl,
|
|
duration,
|
|
});
|
|
|
|
results.push({ success: true, track });
|
|
} catch (fileError) {
|
|
// Remove the file if DB insert fails
|
|
await fs.promises.unlink(file.path).catch(() => { });
|
|
results.push({ success: false, fileName: file.originalname, error: fileError instanceof Error ? fileError.message : "Save failed" });
|
|
}
|
|
}
|
|
|
|
res.json({ results });
|
|
} catch (error) {
|
|
console.error("Error uploading audio tracks:", error);
|
|
if (req.files) {
|
|
for (const file of req.files as Express.Multer.File[]) {
|
|
await fs.promises.unlink(file.path).catch(() => { });
|
|
}
|
|
}
|
|
const errorMessage = error instanceof Error ? error.message : "Failed to upload audio tracks";
|
|
res.status(500).json({ error: errorMessage });
|
|
}
|
|
});
|
|
|
|
// Route admin : correction des titres corrompus (latin1 lu comme UTF-8)
|
|
// Se déclenche aussi automatiquement au démarrage
|
|
const fixAudioTrackEncoding = async () => {
|
|
try {
|
|
const tracks = await storage.getAudioTracks();
|
|
let fixed = 0;
|
|
for (const track of tracks) {
|
|
// Détecte les séquences typiques latin1/utf8 mal interprétées (ex: é, à , è)
|
|
if (/[\xC0-\xC3][\x80-\xBF]/.test(track.title)) {
|
|
const corrected = Buffer.from(track.title, 'latin1').toString('utf8');
|
|
const correctedFileName = Buffer.from(track.fileName, 'latin1').toString('utf8');
|
|
await pool.query(
|
|
`UPDATE audio_tracks SET title = $1, file_name = $2 WHERE id = $3`,
|
|
[corrected, correctedFileName, track.id]
|
|
);
|
|
fixed++;
|
|
}
|
|
}
|
|
if (fixed > 0) console.log(`🎵 Fixed encoding for ${fixed} audio track(s).`);
|
|
} catch (e) {
|
|
console.error('Error fixing audio track encoding:', e);
|
|
}
|
|
};
|
|
// Lancer la correction au démarrage
|
|
fixAudioTrackEncoding().catch(() => { });
|
|
|
|
app.post("/api/audio-tracks/fix-encoding", requireAdmin, async (req, res) => {
|
|
try {
|
|
await fixAudioTrackEncoding();
|
|
const tracks = await storage.getAudioTracks();
|
|
res.json({ success: true, tracks });
|
|
} catch (error) {
|
|
res.status(500).json({ error: "Failed to fix encoding" });
|
|
}
|
|
});
|
|
|
|
app.get("/api/audio-tracks", requireAuth, async (req, res) => {
|
|
try {
|
|
// Both admin and regular users can list audio tracks (to pick them for reels)
|
|
const tracks = await storage.getAudioTracks();
|
|
res.json(tracks);
|
|
} catch (error) {
|
|
console.error("Error fetching audio tracks:", error);
|
|
res.status(500).json({ error: "Failed to fetch audio tracks" });
|
|
}
|
|
});
|
|
|
|
app.delete("/api/audio-tracks/:id", requireAdmin, async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
const track = await storage.getAudioTrack(id);
|
|
if (!track) {
|
|
return res.status(404).json({ error: "Audio track not found" });
|
|
}
|
|
|
|
// Try to delete from MinIO if the URL matches the MinIO public URL pattern
|
|
const minioBase = (process.env.MINIO_PUBLIC_URL || process.env.MINIO_ENDPOINT || '').replace(/\/$/, '');
|
|
if (minioBase && track.url.startsWith(minioBase)) {
|
|
// URL = ${minioBase}/${bucket}/${objectKey} → extract objectKey
|
|
const withoutBase = track.url.slice(minioBase.length + 1); // strip "base/"
|
|
const objectKey = withoutBase.split('/').slice(1).join('/'); // strip bucket
|
|
if (objectKey) {
|
|
try {
|
|
await cloudinaryService.deleteMedia(objectKey, track.userId || '', 'video');
|
|
} catch (minioError) {
|
|
console.warn(`Failed to delete audio from MinIO: ${objectKey}`, minioError);
|
|
}
|
|
}
|
|
}
|
|
|
|
await storage.deleteAudioTrack(id);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error("Error deleting audio track:", error);
|
|
res.status(500).json({ error: "Failed to delete audio track" });
|
|
}
|
|
});
|
|
|
|
// Posts
|
|
app.get("/api/posts", requireAuth, async (req, res) => {
|
|
try {
|
|
const user = req.user as User;
|
|
const userId = user.id;
|
|
const posts = await storage.getPosts(userId);
|
|
res.json(posts);
|
|
} catch (error) {
|
|
console.error("Error fetching posts:", error);
|
|
res.status(500).json({ error: "Failed to fetch posts" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/posts", requireAuth, async (req, res) => {
|
|
try {
|
|
const user = req.user as User;
|
|
const userId = user.id;
|
|
const { pageIds, postType, mediaId, mediaIds, ...postFields } = req.body;
|
|
|
|
// Convert mediaIds to standardized format: array of { mediaId, displayOrder }
|
|
let finalMediaItems: Array<{ mediaId: string; displayOrder: number }> = [];
|
|
|
|
if (mediaIds && Array.isArray(mediaIds)) {
|
|
// New format: array of objects or strings
|
|
finalMediaItems = mediaIds.map((item: any, index: number) => {
|
|
if (typeof item === 'string') {
|
|
// Legacy array of strings: use index as displayOrder
|
|
return { mediaId: item, displayOrder: index };
|
|
} else if (item.mediaId) {
|
|
// New format: object with mediaId and displayOrder
|
|
return {
|
|
mediaId: item.mediaId,
|
|
displayOrder: item.displayOrder ?? index,
|
|
};
|
|
}
|
|
throw new Error("Invalid media format");
|
|
});
|
|
} else if (mediaId) {
|
|
// Legacy single mediaId
|
|
finalMediaItems = [{ mediaId, displayOrder: 0 }];
|
|
}
|
|
|
|
// Validate max 10 photos
|
|
if (finalMediaItems.length > 10) {
|
|
return res.status(400).json({ error: "Maximum 10 photos autorisées par publication" });
|
|
}
|
|
|
|
// Validate that stories require media
|
|
if ((postType === 'story' || postType === 'both') && finalMediaItems.length === 0) {
|
|
return res.status(400).json({ error: "Les stories nécessitent au moins un média (image ou vidéo)" });
|
|
}
|
|
|
|
// Security: Verify user has access to all specified pages (unless admin)
|
|
if (user.role !== 'admin' && pageIds && Array.isArray(pageIds) && pageIds.length > 0) {
|
|
const accessiblePages = await storage.getUserAccessiblePages(userId);
|
|
const accessiblePageIds = accessiblePages.map(p => p.id);
|
|
|
|
const hasAccessToAllPages = pageIds.every(pageId =>
|
|
accessiblePageIds.includes(pageId)
|
|
);
|
|
|
|
if (!hasAccessToAllPages) {
|
|
return res.status(403).json({
|
|
error: "Vous n'avez pas accès à certaines pages sélectionnées"
|
|
});
|
|
}
|
|
}
|
|
|
|
// Convert scheduledFor string to Date if provided
|
|
if (postFields.scheduledFor && typeof postFields.scheduledFor === 'string') {
|
|
postFields.scheduledFor = new Date(postFields.scheduledFor);
|
|
}
|
|
|
|
// Set status to "scheduled" if scheduledFor is provided, otherwise "draft"
|
|
if (postFields.scheduledFor) {
|
|
postFields.status = "scheduled";
|
|
|
|
// Validate that scheduled posts require at least one page
|
|
if (!pageIds || !Array.isArray(pageIds) || pageIds.length === 0) {
|
|
return res.status(400).json({ error: "Les posts programmés nécessitent au moins une page cible" });
|
|
}
|
|
}
|
|
|
|
// Create the post
|
|
const postData = insertPostSchema.parse({ ...postFields, userId });
|
|
const post = await storage.createPost(postData);
|
|
|
|
// Link media to post if provided (with display order)
|
|
if (finalMediaItems.length > 0) {
|
|
const postMediaValues = finalMediaItems.map(item => ({
|
|
postId: post.id,
|
|
mediaId: item.mediaId,
|
|
displayOrder: item.displayOrder,
|
|
}));
|
|
await db.insert(postMedia).values(postMediaValues);
|
|
}
|
|
|
|
// Create scheduled posts for each selected page
|
|
if (pageIds && Array.isArray(pageIds) && pageIds.length > 0) {
|
|
const scheduledAt = postFields.scheduledFor
|
|
? new Date(postFields.scheduledFor)
|
|
: new Date(); // Publish immediately if no date specified
|
|
|
|
const finalPostType = postType || 'feed';
|
|
|
|
for (const pageId of pageIds) {
|
|
// If postType is "both", create two separate scheduled posts (story + feed)
|
|
if (finalPostType === 'both') {
|
|
await storage.createScheduledPost({
|
|
postId: post.id,
|
|
pageId,
|
|
postType: 'story',
|
|
scheduledAt,
|
|
});
|
|
await storage.createScheduledPost({
|
|
postId: post.id,
|
|
pageId,
|
|
postType: 'feed',
|
|
scheduledAt,
|
|
});
|
|
} else {
|
|
await storage.createScheduledPost({
|
|
postId: post.id,
|
|
pageId,
|
|
postType: finalPostType,
|
|
scheduledAt,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
res.json(post);
|
|
} catch (error) {
|
|
console.error("Error creating post:", error);
|
|
const errorMessage = error instanceof Error ? error.message : "Failed to create post";
|
|
res.status(500).json({ error: errorMessage });
|
|
}
|
|
});
|
|
|
|
app.get("/api/posts/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const user = req.user as User;
|
|
const userId = user.id;
|
|
const { id } = req.params;
|
|
|
|
const postWithMedia = await storage.getPostWithMedia(id);
|
|
if (!postWithMedia) {
|
|
return res.status(404).json({ error: "Post not found" });
|
|
}
|
|
|
|
// Admin peut voir tous les posts
|
|
if (user.role === 'admin') {
|
|
return res.json(postWithMedia);
|
|
}
|
|
|
|
// Propriétaire peut voir son propre post
|
|
if (postWithMedia.post.userId === userId) {
|
|
return res.json(postWithMedia);
|
|
}
|
|
|
|
// Utilisateur standard peut voir les posts des pages qui lui sont assignées
|
|
const scheduledPostsForPost = await storage.getScheduledPostsByPost(id);
|
|
if (scheduledPostsForPost.length > 0) {
|
|
const accessiblePages = await storage.getUserAccessiblePages(userId);
|
|
const accessiblePageIds = accessiblePages.map(p => p.id);
|
|
|
|
// Vérifier si au moins une page du post est accessible
|
|
const hasAccess = scheduledPostsForPost.some(sp => accessiblePageIds.includes(sp.pageId));
|
|
if (hasAccess) {
|
|
return res.json(postWithMedia);
|
|
}
|
|
}
|
|
|
|
return res.status(403).json({ error: "Unauthorized" });
|
|
} catch (error) {
|
|
console.error("Error fetching post:", error);
|
|
res.status(500).json({ error: "Failed to fetch post" });
|
|
}
|
|
});
|
|
|
|
app.patch("/api/posts/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const user = req.user as User;
|
|
const userId = user.id;
|
|
const { id } = req.params;
|
|
const { content } = req.body;
|
|
|
|
const post = await storage.getPost(id);
|
|
if (!post) {
|
|
return res.status(404).json({ error: "Post not found" });
|
|
}
|
|
|
|
if (post.userId !== userId) {
|
|
return res.status(403).json({ error: "Unauthorized" });
|
|
}
|
|
|
|
const updatedPost = await storage.updatePost(id, { content });
|
|
res.json(updatedPost);
|
|
} catch (error) {
|
|
console.error("Error updating post:", error);
|
|
res.status(500).json({ error: "Failed to update post" });
|
|
}
|
|
});
|
|
|
|
app.patch("/api/posts/:id/media", requireAuth, async (req, res) => {
|
|
try {
|
|
const user = req.user as User;
|
|
const userId = user.id;
|
|
const { id } = req.params;
|
|
const { mediaIds } = req.body;
|
|
|
|
const post = await storage.getPost(id);
|
|
if (!post) {
|
|
return res.status(404).json({ error: "Post not found" });
|
|
}
|
|
|
|
if (post.userId !== userId) {
|
|
return res.status(403).json({ error: "Unauthorized" });
|
|
}
|
|
|
|
if (!Array.isArray(mediaIds)) {
|
|
return res.status(400).json({ error: "mediaIds must be an array" });
|
|
}
|
|
|
|
if (mediaIds.length > 10) {
|
|
return res.status(400).json({ error: "Maximum 10 photos autorisées par publication" });
|
|
}
|
|
|
|
await storage.updatePostMedia(id, mediaIds);
|
|
const updatedPostWithMedia = await storage.getPostWithMedia(id);
|
|
res.json(updatedPostWithMedia);
|
|
} catch (error) {
|
|
console.error("Error updating post media:", error);
|
|
res.status(500).json({ error: "Failed to update post media" });
|
|
}
|
|
});
|
|
|
|
// Scheduled posts
|
|
app.get("/api/scheduled-posts", requireAuth, async (req, res) => {
|
|
try {
|
|
const user = req.user as User;
|
|
const userId = user.id;
|
|
const { startDate, endDate } = req.query;
|
|
|
|
const start = startDate ? new Date(startDate as string) : undefined;
|
|
const end = endDate ? new Date(endDate as string) : undefined;
|
|
|
|
let scheduledPosts;
|
|
|
|
if (user.role === 'admin') {
|
|
// Admin voit tous les posts programmés - on récupère toutes les pages
|
|
const allPages = await storage.getAllUsers().then(users =>
|
|
Promise.all(users.map(u => storage.getSocialPages(u.id)))
|
|
).then(pagesArrays => pagesArrays.flat());
|
|
const allPageIds = allPages.map(p => p.id);
|
|
|
|
if (allPageIds.length > 0) {
|
|
scheduledPosts = await storage.getScheduledPostsByPages(allPageIds, start, end);
|
|
} else {
|
|
scheduledPosts = [];
|
|
}
|
|
} else {
|
|
// User voit uniquement les posts programmés sur les pages qui lui sont attribuées
|
|
const accessiblePages = await storage.getUserAccessiblePages(userId);
|
|
const accessiblePageIds = accessiblePages.map(p => p.id);
|
|
|
|
if (accessiblePageIds.length > 0) {
|
|
scheduledPosts = await storage.getScheduledPostsByPages(accessiblePageIds, start, end);
|
|
} else {
|
|
scheduledPosts = [];
|
|
}
|
|
}
|
|
|
|
res.json(scheduledPosts);
|
|
} catch (error) {
|
|
console.error("Error fetching scheduled posts:", error);
|
|
res.status(500).json({ error: "Failed to fetch scheduled posts" });
|
|
}
|
|
});
|
|
|
|
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 exists
|
|
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) {
|
|
return res.status(404).json({ error: "Post not found" });
|
|
}
|
|
|
|
// Admin peut tout supprimer, user peut supprimer uniquement ses propres posts
|
|
if (user.role !== 'admin' && 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.patch("/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 exists
|
|
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) {
|
|
return res.status(404).json({ error: "Post not found" });
|
|
}
|
|
|
|
// Admin peut tout modifier, user peut modifier uniquement ses propres posts
|
|
if (user.role !== 'admin' && post.userId !== userId) {
|
|
return res.status(403).json({ error: "Unauthorized" });
|
|
}
|
|
|
|
// Only allow updating scheduledAt and pageId
|
|
const { scheduledAt, pageId } = req.body;
|
|
const updateData: Partial<ScheduledPost> = {};
|
|
|
|
if (scheduledAt) {
|
|
updateData.scheduledAt = new Date(scheduledAt);
|
|
// Synchroniser avec la table posts
|
|
await storage.updatePost(scheduledPost.postId, { scheduledFor: new Date(scheduledAt) });
|
|
}
|
|
|
|
if (pageId) {
|
|
updateData.pageId = pageId;
|
|
}
|
|
|
|
const updated = await storage.updateScheduledPost(id, updateData);
|
|
res.json(updated);
|
|
} catch (error) {
|
|
console.error("Error updating scheduled post:", error);
|
|
res.status(500).json({ error: "Failed to update scheduled post" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/scheduled-posts", requireAuth, async (req, res) => {
|
|
try {
|
|
const scheduledPostData = insertScheduledPostSchema.parse(req.body);
|
|
|
|
// If postType is "both", create two separate scheduled posts (story + feed)
|
|
// This prevents retry loops - each post type is independent
|
|
if (scheduledPostData.postType === 'both') {
|
|
const [storyPost, feedPost] = await Promise.all([
|
|
storage.createScheduledPost({
|
|
...scheduledPostData,
|
|
postType: 'story' as const,
|
|
}),
|
|
storage.createScheduledPost({
|
|
...scheduledPostData,
|
|
postType: 'feed' as const,
|
|
}),
|
|
]);
|
|
// Return array so frontend knows it's a split operation
|
|
res.json([storyPost, feedPost]);
|
|
} else {
|
|
const scheduledPost = await storage.createScheduledPost(scheduledPostData);
|
|
res.json(scheduledPost);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error creating scheduled post:", error);
|
|
res.status(500).json({ error: "Failed to create scheduled post" });
|
|
}
|
|
});
|
|
|
|
// Social pages
|
|
app.get("/api/pages", requireAuth, async (req, res) => {
|
|
try {
|
|
const user = req.user as User;
|
|
const userId = user.id;
|
|
|
|
let pages: SocialPage[];
|
|
|
|
if (user.role === 'admin') {
|
|
// Les admins voient toutes les pages de tous les utilisateurs
|
|
const allUsers = await storage.getAllUsers();
|
|
const allPages = await Promise.all(
|
|
allUsers.map(u => storage.getSocialPages(u.id))
|
|
);
|
|
pages = allPages.flat();
|
|
} else {
|
|
// Les utilisateurs normaux voient uniquement les pages auxquelles ils ont accès
|
|
pages = await storage.getUserAccessiblePages(userId);
|
|
}
|
|
|
|
res.json(pages);
|
|
} catch (error) {
|
|
console.error("Error fetching pages:", error);
|
|
res.status(500).json({ error: "Failed to fetch pages" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/pages", requireAuth, async (req, res) => {
|
|
try {
|
|
const user = req.user as User;
|
|
const userId = user.id;
|
|
|
|
// TikTok n'autorise pas la saisie manuelle d'un token : le compte doit
|
|
// passer par le flux OAuth (/api/tiktok/connect).
|
|
if (req.body?.platform === 'tiktok') {
|
|
return res.status(400).json({
|
|
error: "Un compte TikTok se connecte via l'autorisation TikTok, pas par saisie manuelle d'un jeton.",
|
|
});
|
|
}
|
|
|
|
// Calculate token expiration date (60 days from now)
|
|
const tokenExpiresAt = new Date();
|
|
tokenExpiresAt.setDate(tokenExpiresAt.getDate() + 60);
|
|
|
|
const pageData = insertSocialPageSchema.parse({
|
|
...req.body,
|
|
userId,
|
|
tokenExpiresAt
|
|
});
|
|
const page = await storage.createSocialPage(pageData);
|
|
res.json(page);
|
|
} catch (error) {
|
|
console.error("Error creating page:", error);
|
|
res.status(500).json({ error: "Failed to create page" });
|
|
}
|
|
});
|
|
|
|
app.put("/api/pages/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const user = req.user as User;
|
|
const userId = user.id;
|
|
const pageId = req.params.id;
|
|
|
|
const existingPage = await storage.getSocialPage(pageId);
|
|
if (!existingPage || existingPage.userId !== userId) {
|
|
return res.status(404).json({ error: "Page non trouvée" });
|
|
}
|
|
|
|
let parsedData = insertSocialPageSchema.partial().parse(req.body);
|
|
let pageData: Partial<SocialPage> = { ...parsedData };
|
|
|
|
// If accessToken is being updated, recalculate expiration date (60 days from now)
|
|
// AND reset the status to valid so the UI updates immediately
|
|
// (les tokens TikTok ont leur propre cycle de vie, géré par le service OAuth)
|
|
if (parsedData.accessToken && existingPage.platform !== 'tiktok') {
|
|
const tokenExpiresAt = new Date();
|
|
tokenExpiresAt.setDate(tokenExpiresAt.getDate() + 60);
|
|
pageData = {
|
|
...pageData,
|
|
tokenExpiresAt,
|
|
tokenStatus: 'valid',
|
|
lastTokenCheck: new Date()
|
|
};
|
|
}
|
|
|
|
const updatedPage = await storage.updateSocialPage(pageId, pageData);
|
|
res.json(updatedPage);
|
|
} catch (error) {
|
|
console.error("Error updating page:", error);
|
|
res.status(500).json({ error: "Failed to update page" });
|
|
}
|
|
});
|
|
|
|
app.delete("/api/pages/:id", requireAuth, async (req, res) => {
|
|
try {
|
|
const user = req.user as User;
|
|
const userId = user.id;
|
|
const pageId = req.params.id;
|
|
|
|
const existingPage = await storage.getSocialPage(pageId);
|
|
if (!existingPage || existingPage.userId !== userId) {
|
|
return res.status(404).json({ error: "Page non trouvée" });
|
|
}
|
|
|
|
await storage.deleteSocialPage(pageId);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error("Error deleting page:", error);
|
|
res.status(500).json({ error: "Failed to delete page" });
|
|
}
|
|
});
|
|
|
|
// AI Generations
|
|
app.get("/api/ai/generations", requireAdmin, async (req, res) => {
|
|
try {
|
|
const user = req.user as User;
|
|
const userId = user.id;
|
|
const generations = await storage.getAiGenerations(userId);
|
|
res.json(generations);
|
|
} catch (error) {
|
|
console.error("Error fetching AI generations:", error);
|
|
res.status(500).json({ error: "Failed to fetch AI generations" });
|
|
}
|
|
});
|
|
|
|
// Cloudinary Config
|
|
app.get("/api/cloudinary/config", requireAuth, async (req, res) => {
|
|
try {
|
|
// Configuration est maintenant globale, plus de "userId"
|
|
const config = await storage.getCloudinaryConfig();
|
|
|
|
if (!config) {
|
|
return res.json(null);
|
|
}
|
|
|
|
// Don't send the API secret to the client
|
|
const { apiSecret, ...safeConfig } = config;
|
|
const logoUrl = config.logoPublicId ? buildMinioUrl(config.cloudName, config.logoPublicId, config.publicUrl) : null;
|
|
res.json({ ...safeConfig, logoUrl });
|
|
} catch (error) {
|
|
console.error("Error fetching Cloudinary config:", error);
|
|
res.status(500).json({ error: "Failed to fetch Cloudinary config" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/cloudinary/config", requireAdmin, async (req, res) => {
|
|
try {
|
|
// Check if config already exists globally
|
|
const existingConfig = await storage.getCloudinaryConfig();
|
|
|
|
let config;
|
|
if (existingConfig) {
|
|
// Pour les mises à jour, utiliser le schéma qui rend les secrets optionnels
|
|
const updateData = updateCloudinaryConfigSchema.parse(req.body);
|
|
|
|
// Si apiKey/apiSecret ne sont pas fournis, garder les anciens
|
|
const finalData = {
|
|
...updateData,
|
|
apiKey: updateData.apiKey || existingConfig.apiKey,
|
|
apiSecret: updateData.apiSecret || existingConfig.apiSecret,
|
|
};
|
|
|
|
config = await storage.updateCloudinaryConfig(finalData);
|
|
} else {
|
|
// Pour les créations, exiger tous les champs
|
|
const configData = insertCloudinaryConfigSchema.parse(req.body);
|
|
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" });
|
|
}
|
|
});
|
|
|
|
// Upload company logo
|
|
app.post("/api/cloudinary/logo", requireAdmin, upload.single("logo"), async (req, res) => {
|
|
try {
|
|
if (!req.file) {
|
|
return res.status(400).json({ error: "No logo file uploaded" });
|
|
}
|
|
|
|
// Get or auto-create config row (local storage doesn't need credentials)
|
|
let cloudinaryConfig = await storage.getCloudinaryConfig();
|
|
if (!cloudinaryConfig) {
|
|
cloudinaryConfig = await storage.createCloudinaryConfig({
|
|
cloudName: 'local', apiKey: 'local', apiSecret: 'local',
|
|
});
|
|
}
|
|
|
|
// Delete old logo if exists
|
|
if (cloudinaryConfig.logoPublicId) {
|
|
try {
|
|
await cloudinaryService.deleteLogo(cloudinaryConfig.logoPublicId);
|
|
} catch (error) {
|
|
console.warn("Failed to delete old logo:", error);
|
|
// Continue anyway - not critical
|
|
}
|
|
}
|
|
|
|
// Read from disk (multer uses diskStorage, not memoryStorage)
|
|
const logoBuffer = await fs.promises.readFile(req.file.path);
|
|
await fs.promises.unlink(req.file.path).catch(() => {});
|
|
|
|
const uploadResult = await cloudinaryService.uploadLogo(
|
|
logoBuffer,
|
|
req.file.originalname
|
|
);
|
|
|
|
// Update global config with new logo public ID
|
|
const updatedConfig = await storage.updateCloudinaryConfig({
|
|
logoPublicId: uploadResult.publicId,
|
|
});
|
|
|
|
res.json({
|
|
logoPublicId: uploadResult.publicId,
|
|
logoUrl: uploadResult.url,
|
|
});
|
|
} catch (error) {
|
|
console.error("Error uploading logo:", error);
|
|
const errorMessage = error instanceof Error ? error.message : "Failed to upload logo";
|
|
res.status(500).json({ error: errorMessage });
|
|
}
|
|
});
|
|
|
|
// Delete company logo
|
|
app.delete("/api/cloudinary/logo", requireAdmin, async (req, res) => {
|
|
try {
|
|
const cloudinaryConfig = await storage.getCloudinaryConfig();
|
|
if (!cloudinaryConfig || !cloudinaryConfig.logoPublicId) {
|
|
return res.status(404).json({ error: "No logo found" });
|
|
}
|
|
|
|
// Delete from Cloudinary
|
|
await cloudinaryService.deleteLogo(cloudinaryConfig.logoPublicId);
|
|
|
|
// Remove from global config
|
|
await storage.updateCloudinaryConfig({
|
|
logoPublicId: null,
|
|
});
|
|
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error("Error deleting logo:", error);
|
|
const errorMessage = error instanceof Error ? error.message : "Failed to delete logo";
|
|
res.status(500).json({ error: errorMessage });
|
|
}
|
|
});
|
|
|
|
// OpenRouter models list
|
|
app.get("/api/openrouter/models", requireAuth, async (req, res) => {
|
|
try {
|
|
const response = await fetch("https://openrouter.ai/api/v1/models", {
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`OpenRouter API error: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
res.json(data);
|
|
} catch (error) {
|
|
console.error("Error fetching OpenRouter models:", error);
|
|
res.status(500).json({ error: "Failed to fetch OpenRouter models" });
|
|
}
|
|
});
|
|
|
|
// OpenRouter configuration
|
|
app.get("/api/openrouter/config", requireAdmin, async (req, res) => {
|
|
try {
|
|
const user = req.user as User;
|
|
const userId = user.id;
|
|
const config = await storage.getOpenrouterConfig(userId);
|
|
|
|
if (!config) {
|
|
return res.json(null);
|
|
}
|
|
|
|
// Don't send the API key to the client
|
|
const { apiKey, ...safeConfig } = config;
|
|
res.json(safeConfig);
|
|
} catch (error) {
|
|
console.error("Error fetching OpenRouter config:", error);
|
|
res.status(500).json({ error: "Failed to fetch OpenRouter config" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/openrouter/config", requireAdmin, async (req, res) => {
|
|
try {
|
|
const user = req.user as User;
|
|
const userId = user.id;
|
|
|
|
// Check if config already exists
|
|
const existingConfig = await storage.getOpenrouterConfig(userId);
|
|
|
|
let config;
|
|
if (existingConfig) {
|
|
// Pour les mises à jour, utiliser le schéma qui rend apiKey optionnel
|
|
const updateData = updateOpenrouterConfigSchema.parse({
|
|
...req.body,
|
|
userId,
|
|
});
|
|
|
|
// Si apiKey n'est pas fourni ou vide, garder l'ancien
|
|
const incomingKey = updateData.apiKey?.trim() || undefined;
|
|
const finalData = {
|
|
...updateData,
|
|
apiKey: incomingKey || existingConfig.apiKey,
|
|
};
|
|
|
|
if (!finalData.apiKey) {
|
|
return res.status(400).json({ error: "Clé API OpenRouter manquante. Veuillez entrer une clé API valide." });
|
|
}
|
|
|
|
config = await storage.updateOpenrouterConfig(userId, finalData);
|
|
} else {
|
|
// Pour les créations, exiger tous les champs
|
|
const configData = insertOpenrouterConfigSchema.parse({
|
|
...req.body,
|
|
userId,
|
|
});
|
|
config = await storage.createOpenrouterConfig(configData);
|
|
}
|
|
|
|
// Don't send the API key back
|
|
const { apiKey, ...safeConfig } = config;
|
|
res.json(safeConfig);
|
|
} catch (error) {
|
|
console.error("Error saving OpenRouter config:", error);
|
|
res.status(500).json({ error: "Failed to save OpenRouter config" });
|
|
}
|
|
});
|
|
|
|
// FFmpeg API Configuration
|
|
app.get("/api/ffmpeg/config", requireAdmin, async (req, res) => {
|
|
try {
|
|
const user = req.user as User;
|
|
// Lire depuis les variables d'environnement ou un storage dédié
|
|
// Pour simplifier, on renvoie juste si c'est configuré ou non
|
|
const apiUrl = process.env.FFMPEG_API_URL || "";
|
|
|
|
res.json({
|
|
apiUrl: apiUrl,
|
|
configured: !!apiUrl && !!process.env.FFMPEG_API_KEY,
|
|
});
|
|
} catch (error) {
|
|
console.error("Error fetching FFmpeg config:", error);
|
|
res.status(500).json({ error: "Failed to fetch FFmpeg config" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/ffmpeg/config", requireAdmin, async (req, res) => {
|
|
try {
|
|
const { apiUrl, apiKey } = req.body;
|
|
|
|
if (!apiUrl) {
|
|
return res.status(400).json({ error: "URL de l'API FFmpeg requise" });
|
|
}
|
|
|
|
// Configurer le service FFmpeg avec les nouvelles valeurs
|
|
const { ffmpegService } = await import("./services/ffmpeg");
|
|
|
|
// Si apiKey fourni, configurer avec
|
|
if (apiKey) {
|
|
ffmpegService.configure(apiUrl, apiKey);
|
|
} else if (process.env.FFMPEG_API_KEY) {
|
|
// Garder la clé existante
|
|
ffmpegService.configure(apiUrl, process.env.FFMPEG_API_KEY);
|
|
} else {
|
|
return res.status(400).json({ error: "Clé API FFmpeg requise pour la première configuration" });
|
|
}
|
|
|
|
// Stocker dans les variables d'environnement (pour cette session)
|
|
process.env.FFMPEG_API_URL = apiUrl;
|
|
if (apiKey) {
|
|
process.env.FFMPEG_API_KEY = apiKey;
|
|
}
|
|
|
|
// Tester la connexion
|
|
const isHealthy = await ffmpegService.healthCheck();
|
|
|
|
res.json({
|
|
success: true,
|
|
apiUrl: apiUrl,
|
|
configured: true,
|
|
healthy: isHealthy,
|
|
});
|
|
} catch (error) {
|
|
console.error("Error saving FFmpeg config:", error);
|
|
res.status(500).json({ error: "Failed to save FFmpeg config" });
|
|
}
|
|
});
|
|
|
|
const httpServer = createServer(app);
|
|
|
|
return httpServer;
|
|
}
|