mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
Ajoute la gestion multi-comptes TikTok et permet d'envoyer la même vidéo sur plusieurs pages Facebook ET plusieurs comptes TikTok en une seule publication. Un compte TikTok vit dans social_pages comme une page Facebook : il hérite donc des permissions par utilisateur, de la planification et du calendrier. Trois particularités de l'API TikTok structurent l'implémentation : - pas de jeton saisi à la main : chaque compte passe par OAuth, et l'access token (24h) est renouvelé via le refresh token (1 an) avant chaque publication ; - la publication est asynchrone : l'upload rend un publish_id, et un poller récupère l'identifiant définitif du post ; - creator_info doit être interrogé avant chaque envoi pour ne demander qu'un niveau de confidentialité réellement autorisé sur le compte. Serveur : - service TikTok (OAuth, creator_info, upload FILE_UPLOAD par chunks, suivi de statut) et routes de connexion/configuration - schéma : platform 'tiktok', refresh token et scopes sur social_pages, publish_id/publish_status sur scheduled_posts, table tiktok_config - routage par plateforme dans les deux flux de reels et le planificateur - cron de suivi des publications, token manager et analytics adaptés Client : - connexion et reconnexion des comptes TikTok depuis « Pages gérées » - sélection combinée pages Facebook / comptes TikTok à la publication - configuration de l'application TikTok dans les paramètres (admin) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018uUPZ1GNpEkYB1dpXHYPek
1943 lines
68 KiB
TypeScript
1943 lines
68 KiB
TypeScript
import type { Express, Request, Response, NextFunction } from "express";
|
|
import { createServer, type Server } from "http";
|
|
import fs from "fs";
|
|
import os from "os";
|
|
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 } 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 { 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);
|
|
|
|
// 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);
|
|
res.status(500).json({ error: "Failed to generate text" });
|
|
}
|
|
});
|
|
|
|
// 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;
|
|
}
|