From b451826ec59056f4dba8126c603a9160fd6a31a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:49:03 +0000 Subject: [PATCH] Retrait de l'ancien serveur Node/TypeScript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le serveur Rust (server/src) le remplace entièrement : routes, services, migrations Drizzle et scripts SQL d'initialisation sont supprimés. Le schéma est désormais créé et migré par server/src/migrate.rs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013qMexTfVRaegbPAgsagCAJ --- drizzle.config.ts | 14 - init.sql | 57 - migration-docker.sql | 233 -- migrations/0000_spicy_cargill.sql | 151 -- migrations/meta/0000_snapshot.json | 1047 --------- migrations/meta/_journal.json | 13 - server/auth.ts | 45 - server/cron.ts | 51 - server/db.ts | 12 - server/index.ts | 241 -- server/init-admin.ts | 30 - server/middleware/apiKey.ts | 28 - server/migrate.ts | 299 --- server/reproduce_emoji.ts | 32 - server/routes.ts | 2120 ------------------ server/routes/analytics.ts | 79 - server/routes/external.ts | 497 ---- server/routes/facebook.ts | 300 --- server/routes/legal.ts | 269 --- server/routes/reels.ts | 470 ---- server/routes/tiktok.ts | 226 -- server/services/analytics.ts | 297 --- server/services/cloudinary.ts | 204 -- server/services/emojiRenderer.ts | 238 -- server/services/facebook.ts | 840 ------- server/services/facebook_oauth.ts | 295 --- server/services/ffmpeg.ts | 356 --- server/services/freesound.ts | 251 --- server/services/imageProcessor.ts | 301 --- server/services/jamendo.ts | 296 --- server/services/media-purge.ts | 64 - server/services/minio.ts | 143 -- server/services/openrouter.ts | 289 --- server/services/reels/assets.ts | 94 - server/services/reels/index.ts | 12 - server/services/reels/publish.ts | 189 -- server/services/reels/queue.ts | 278 --- server/services/reels/remotionAssets.test.ts | 12 - server/services/reels/remotionAssets.ts | 58 - server/services/reels/remotionRenderer.ts | 85 - server/services/reels/videoPipeline.ts | 160 -- server/services/scheduler.ts | 182 -- server/services/thumbnail.ts | 101 - server/services/tiktok.ts | 574 ----- server/services/token_manager.ts | 358 --- server/services/ttsSync.test.ts | 14 - server/services/ttsSync.ts | 49 - server/services/twemoji-parser.d.ts | 11 - server/src/main.rs | 2 + server/storage.ts | 831 ------- server/utils/encryption.ts | 152 -- server/utils/graph_client.ts | 94 - server/utils/public_url.ts | 47 - server/vite.ts | 85 - 54 files changed, 2 insertions(+), 13174 deletions(-) delete mode 100644 drizzle.config.ts delete mode 100644 init.sql delete mode 100644 migration-docker.sql delete mode 100644 migrations/0000_spicy_cargill.sql delete mode 100644 migrations/meta/0000_snapshot.json delete mode 100644 migrations/meta/_journal.json delete mode 100644 server/auth.ts delete mode 100644 server/cron.ts delete mode 100644 server/db.ts delete mode 100644 server/index.ts delete mode 100644 server/init-admin.ts delete mode 100644 server/middleware/apiKey.ts delete mode 100644 server/migrate.ts delete mode 100644 server/reproduce_emoji.ts delete mode 100644 server/routes.ts delete mode 100644 server/routes/analytics.ts delete mode 100644 server/routes/external.ts delete mode 100644 server/routes/facebook.ts delete mode 100644 server/routes/legal.ts delete mode 100644 server/routes/reels.ts delete mode 100644 server/routes/tiktok.ts delete mode 100644 server/services/analytics.ts delete mode 100644 server/services/cloudinary.ts delete mode 100644 server/services/emojiRenderer.ts delete mode 100644 server/services/facebook.ts delete mode 100644 server/services/facebook_oauth.ts delete mode 100644 server/services/ffmpeg.ts delete mode 100644 server/services/freesound.ts delete mode 100644 server/services/imageProcessor.ts delete mode 100644 server/services/jamendo.ts delete mode 100644 server/services/media-purge.ts delete mode 100644 server/services/minio.ts delete mode 100644 server/services/openrouter.ts delete mode 100644 server/services/reels/assets.ts delete mode 100644 server/services/reels/index.ts delete mode 100644 server/services/reels/publish.ts delete mode 100644 server/services/reels/queue.ts delete mode 100644 server/services/reels/remotionAssets.test.ts delete mode 100644 server/services/reels/remotionAssets.ts delete mode 100644 server/services/reels/remotionRenderer.ts delete mode 100644 server/services/reels/videoPipeline.ts delete mode 100644 server/services/scheduler.ts delete mode 100644 server/services/thumbnail.ts delete mode 100644 server/services/tiktok.ts delete mode 100644 server/services/token_manager.ts delete mode 100644 server/services/ttsSync.test.ts delete mode 100644 server/services/ttsSync.ts delete mode 100644 server/services/twemoji-parser.d.ts delete mode 100644 server/storage.ts delete mode 100644 server/utils/encryption.ts delete mode 100644 server/utils/graph_client.ts delete mode 100644 server/utils/public_url.ts delete mode 100644 server/vite.ts diff --git a/drizzle.config.ts b/drizzle.config.ts deleted file mode 100644 index 4cd7b4c..0000000 --- a/drizzle.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { defineConfig } from "drizzle-kit"; - -if (!process.env.DATABASE_URL) { - throw new Error("DATABASE_URL, ensure the database is provisioned"); -} - -export default defineConfig({ - out: "./migrations", - schema: "./shared/schema.ts", - dialect: "postgresql", - dbCredentials: { - url: process.env.DATABASE_URL, - }, -}); diff --git a/init.sql b/init.sql deleted file mode 100644 index faa59de..0000000 --- a/init.sql +++ /dev/null @@ -1,57 +0,0 @@ --- Script d'initialisation de la base de données Social Flow --- Ce fichier contient les commandes SQL pour créer les tables et l'utilisateur admin par défaut - --- IMPORTANT: Pour les serveurs privés existants, ajouter la valeur 'both' à l'enum post_type --- Commande à exécuter manuellement sur votre serveur privé : --- ALTER TYPE post_type ADD VALUE IF NOT EXISTS 'both'; - --- Créer l'enum pour les rôles utilisateur (si pas déjà créé par drizzle-kit push) -DO $$ BEGIN - CREATE TYPE user_role AS ENUM ('admin', 'user'); -EXCEPTION - WHEN duplicate_object THEN null; -END $$; - --- Créer l'utilisateur admin par défaut (username: admin, password: admin) --- Note: Le hash bcrypt pour "admin" est généré avec un salt de 10 -INSERT INTO users (id, username, password, role) -VALUES ( - 'admin-user-id', - 'admin', - '$2b$10$mw7B1qBoTxxT7BT.Mv9uTeExX27y5nPVN4/e6L7W1VqkTNe/tGw9G', - 'admin' -) ON CONFLICT (username) DO NOTHING; - --- Créer un utilisateur demo pour les tests -INSERT INTO users (id, username, password, role) -VALUES ( - 'demo-user', - 'demo', - '$2b$10$mw7B1qBoTxxT7BT.Mv9uTeExX27y5nPVN4/e6L7W1VqkTNe/tGw9G', - 'user' -) ON CONFLICT (username) DO NOTHING; - --- Créer la table user_page_permissions (permissions d'accès aux pages par utilisateur) -CREATE TABLE IF NOT EXISTS user_page_permissions ( - id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(), - user_id VARCHAR NOT NULL REFERENCES users(id) ON DELETE CASCADE, - page_id VARCHAR NOT NULL REFERENCES social_pages(id) ON DELETE CASCADE, - created_at TIMESTAMP DEFAULT NOW(), - UNIQUE(user_id, page_id) -); - --- Index pour optimiser les requêtes de permissions -CREATE INDEX IF NOT EXISTS idx_user_page_permissions_user_id ON user_page_permissions(user_id); -CREATE INDEX IF NOT EXISTS idx_user_page_permissions_page_id ON user_page_permissions(page_id); - --- Liste des tables créées automatiquement par Drizzle ORM : --- - users (utilisateurs avec rôles) --- - social_pages (pages Facebook/Instagram connectées) --- - cloudinary_config (configuration Cloudinary par utilisateur) --- - openrouter_config (configuration OpenRouter par utilisateur) --- - media (fichiers médias stockés dans Cloudinary) --- - posts (publications créées) --- - scheduled_posts (publications programmées) --- - ai_generations (historique des générations IA) --- - post_media (relation many-to-many entre posts et media) --- - user_page_permissions (permissions d'accès aux pages par utilisateur) diff --git a/migration-docker.sql b/migration-docker.sql deleted file mode 100644 index 8a8d05c..0000000 --- a/migration-docker.sql +++ /dev/null @@ -1,233 +0,0 @@ --- ============================================================ --- MIGRATION SQL POUR SERVEUR DOCKER EXISTANT --- Script de mise à jour pour Social Flow --- ============================================================ --- Ce fichier contient toutes les commandes SQL pour mettre à jour --- une base de données existante vers la dernière version du schéma --- ============================================================ - --- ÉTAPE 1: Créer les ENUMs s'ils n'existent pas déjà --- ============================================================ - -DO $$ BEGIN - CREATE TYPE platform AS ENUM ('facebook', 'instagram'); -EXCEPTION - WHEN duplicate_object THEN null; -END $$; - -DO $$ BEGIN - CREATE TYPE post_type AS ENUM ('feed', 'story', 'both'); -EXCEPTION - WHEN duplicate_object THEN null; -END $$; - -DO $$ BEGIN - CREATE TYPE post_status AS ENUM ('draft', 'scheduled', 'published', 'failed'); -EXCEPTION - WHEN duplicate_object THEN null; -END $$; - -DO $$ BEGIN - CREATE TYPE media_type AS ENUM ('image', 'video'); -EXCEPTION - WHEN duplicate_object THEN null; -END $$; - -DO $$ BEGIN - CREATE TYPE user_role AS ENUM ('admin', 'user'); -EXCEPTION - WHEN duplicate_object THEN null; -END $$; - --- IMPORTANT: Ajouter 'both' à post_type si serveur existant --- Cette valeur a été ajoutée récemment -DO $$ BEGIN - ALTER TYPE post_type ADD VALUE IF NOT EXISTS 'both'; -EXCEPTION - WHEN duplicate_object THEN null; - WHEN others THEN null; -END $$; - --- ÉTAPE 2: Créer les tables principales --- ============================================================ - --- Table users -CREATE TABLE IF NOT EXISTS users ( - id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(), - username TEXT NOT NULL UNIQUE, - password TEXT NOT NULL, - role user_role NOT NULL DEFAULT 'user' -); - --- Table social_pages -CREATE TABLE IF NOT EXISTS social_pages ( - id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(), - user_id VARCHAR NOT NULL REFERENCES users(id) ON DELETE CASCADE, - platform platform NOT NULL, - page_id TEXT NOT NULL, - page_name TEXT NOT NULL, - access_token TEXT NOT NULL, - followers_count INTEGER DEFAULT 0, - is_active TEXT NOT NULL DEFAULT 'true', - created_at TIMESTAMP DEFAULT NOW() -); - --- Table cloudinary_config -CREATE TABLE IF NOT EXISTS cloudinary_config ( - id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(), - user_id VARCHAR NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, - cloud_name TEXT NOT NULL, - api_key TEXT NOT NULL, - api_secret TEXT NOT NULL, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW() -); - --- Table openrouter_config -CREATE TABLE IF NOT EXISTS openrouter_config ( - id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(), - user_id VARCHAR NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, - api_key TEXT NOT NULL, - model TEXT NOT NULL DEFAULT 'anthropic/claude-sonnet-4.5', - system_prompt TEXT NOT NULL DEFAULT 'Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l''engagement.', - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW() -); - --- Table media -CREATE TABLE IF NOT EXISTS media ( - id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(), - user_id VARCHAR NOT NULL REFERENCES users(id) ON DELETE CASCADE, - type media_type NOT NULL, - cloudinary_public_id TEXT, - original_url TEXT NOT NULL, - facebook_landscape_url TEXT, - facebook_feed_url TEXT, - instagram_feed_url TEXT, - instagram_story_url TEXT, - file_name TEXT NOT NULL, - file_size INTEGER NOT NULL, - created_at TIMESTAMP DEFAULT NOW() -); - --- Table posts -CREATE TABLE IF NOT EXISTS posts ( - id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(), - user_id VARCHAR NOT NULL REFERENCES users(id) ON DELETE CASCADE, - content TEXT NOT NULL, - ai_generated TEXT NOT NULL DEFAULT 'false', - product_info JSONB, - status post_status NOT NULL DEFAULT 'draft', - scheduled_for TIMESTAMP, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW() -); - --- Table post_media -CREATE TABLE IF NOT EXISTS post_media ( - id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(), - post_id VARCHAR NOT NULL REFERENCES posts(id) ON DELETE CASCADE, - media_id VARCHAR NOT NULL REFERENCES media(id) ON DELETE CASCADE, - display_order INTEGER NOT NULL DEFAULT 0 -); - --- Table scheduled_posts -CREATE TABLE IF NOT EXISTS scheduled_posts ( - id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(), - post_id VARCHAR NOT NULL REFERENCES posts(id) ON DELETE CASCADE, - page_id VARCHAR NOT NULL REFERENCES social_pages(id) ON DELETE CASCADE, - post_type post_type NOT NULL, - scheduled_at TIMESTAMP NOT NULL, - published_at TIMESTAMP, - external_post_id TEXT, - error TEXT -); - --- Table ai_generations -CREATE TABLE IF NOT EXISTS ai_generations ( - id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(), - user_id VARCHAR NOT NULL REFERENCES users(id) ON DELETE CASCADE, - product_info JSONB NOT NULL, - generated_texts JSONB NOT NULL, - selected_text TEXT, - created_at TIMESTAMP DEFAULT NOW() -); - --- Table user_page_permissions -CREATE TABLE IF NOT EXISTS user_page_permissions ( - id VARCHAR PRIMARY KEY DEFAULT gen_random_uuid(), - user_id VARCHAR NOT NULL REFERENCES users(id) ON DELETE CASCADE, - page_id VARCHAR NOT NULL REFERENCES social_pages(id) ON DELETE CASCADE, - created_at TIMESTAMP DEFAULT NOW(), - UNIQUE(user_id, page_id) -); - --- ÉTAPE 3: Créer les index pour optimiser les performances --- ============================================================ - --- Index pour user_page_permissions -CREATE INDEX IF NOT EXISTS idx_user_page_permissions_user_id ON user_page_permissions(user_id); -CREATE INDEX IF NOT EXISTS idx_user_page_permissions_page_id ON user_page_permissions(page_id); - --- Index pour scheduled_posts -CREATE INDEX IF NOT EXISTS idx_scheduled_posts_scheduled_at ON scheduled_posts(scheduled_at); -CREATE INDEX IF NOT EXISTS idx_scheduled_posts_post_id ON scheduled_posts(post_id); -CREATE INDEX IF NOT EXISTS idx_scheduled_posts_page_id ON scheduled_posts(page_id); - --- Index pour posts -CREATE INDEX IF NOT EXISTS idx_posts_user_id ON posts(user_id); -CREATE INDEX IF NOT EXISTS idx_posts_status ON posts(status); -CREATE INDEX IF NOT EXISTS idx_posts_scheduled_for ON posts(scheduled_for); - --- Index pour media -CREATE INDEX IF NOT EXISTS idx_media_user_id ON media(user_id); - --- ÉTAPE 4: Créer les utilisateurs par défaut --- ============================================================ - --- Créer l'utilisateur admin (username: admin, password: admin) -INSERT INTO users (id, username, password, role) -VALUES ( - 'admin-user-id', - 'admin', - '$2b$10$mw7B1qBoTxxT7BT.Mv9uTeExX27y5nPVN4/e6L7W1VqkTNe/tGw9G', - 'admin' -) ON CONFLICT (username) DO NOTHING; - --- Créer un utilisateur demo (username: demo, password: admin) -INSERT INTO users (id, username, password, role) -VALUES ( - 'demo-user', - 'demo', - '$2b$10$mw7B1qBoTxxT7BT.Mv9uTeExX27y5nPVN4/e6L7W1VqkTNe/tGw9G', - 'user' -) ON CONFLICT (username) DO NOTHING; - --- ÉTAPE 5: Mise à jour des posts programmés existants (si migration) --- ============================================================ --- Mettre à jour le statut des posts programmés qui étaient en "draft" -UPDATE posts -SET status = 'scheduled' -WHERE scheduled_for IS NOT NULL - AND status = 'draft'; - --- ============================================================ --- FIN DE LA MIGRATION --- ============================================================ --- --- INSTRUCTIONS D'UTILISATION: --- --- 1. Pour un NOUVEAU serveur Docker: --- - Rien à faire ! Le docker-compose.yml exécute automatiquement --- "drizzle-kit push --force" qui crée toutes les tables --- --- 2. Pour un serveur Docker EXISTANT avec données: --- - Connectez-vous à PostgreSQL: --- docker exec -it socialflow-db psql -U socialflow -d socialflow --- - Exécutez ce fichier: --- \i /chemin/vers/migration-docker.sql --- --- 3. Alternative avec commande directe: --- docker exec -i socialflow-db psql -U socialflow -d socialflow < migration-docker.sql --- --- ============================================================ diff --git a/migrations/0000_spicy_cargill.sql b/migrations/0000_spicy_cargill.sql deleted file mode 100644 index 2c7c1fd..0000000 --- a/migrations/0000_spicy_cargill.sql +++ /dev/null @@ -1,151 +0,0 @@ -CREATE TYPE "public"."media_type" AS ENUM('image', 'video');--> statement-breakpoint -CREATE TYPE "public"."platform" AS ENUM('facebook', 'instagram');--> statement-breakpoint -CREATE TYPE "public"."post_status" AS ENUM('draft', 'scheduled', 'published', 'failed');--> statement-breakpoint -CREATE TYPE "public"."post_type" AS ENUM('feed', 'story', 'both');--> statement-breakpoint -CREATE TYPE "public"."token_status" AS ENUM('valid', 'expiring', 'expired', 'error');--> statement-breakpoint -CREATE TYPE "public"."user_role" AS ENUM('admin', 'user');--> statement-breakpoint -CREATE TABLE "ai_generations" ( - "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "user_id" varchar NOT NULL, - "product_info" jsonb NOT NULL, - "generated_texts" jsonb NOT NULL, - "selected_text" text, - "created_at" timestamp DEFAULT now() -); ---> statement-breakpoint -CREATE TABLE "cloudinary_config" ( - "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "user_id" varchar NOT NULL, - "cloud_name" text NOT NULL, - "api_key" text NOT NULL, - "api_secret" text NOT NULL, - "logo_public_id" text, - "created_at" timestamp DEFAULT now(), - "updated_at" timestamp DEFAULT now(), - CONSTRAINT "cloudinary_config_user_id_unique" UNIQUE("user_id") -); ---> statement-breakpoint -CREATE TABLE "media" ( - "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "user_id" varchar NOT NULL, - "type" "media_type" NOT NULL, - "cloudinary_public_id" text, - "original_url" text NOT NULL, - "facebook_landscape_url" text, - "facebook_feed_url" text, - "instagram_feed_url" text, - "instagram_story_url" text, - "file_name" text NOT NULL, - "file_size" integer NOT NULL, - "created_at" timestamp DEFAULT now() -); ---> statement-breakpoint -CREATE TABLE "openrouter_config" ( - "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "user_id" varchar NOT NULL, - "api_key" text NOT NULL, - "model" text DEFAULT 'anthropic/claude-3.5-sonnet' NOT NULL, - "system_prompt" text DEFAULT 'Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l''engagement.' NOT NULL, - "created_at" timestamp DEFAULT now(), - "updated_at" timestamp DEFAULT now(), - CONSTRAINT "openrouter_config_user_id_unique" UNIQUE("user_id") -); ---> statement-breakpoint -CREATE TABLE "page_analytics_history" ( - "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "page_id" varchar NOT NULL, - "date" timestamp NOT NULL, - "followers_count" integer DEFAULT 0, - "page_views" integer DEFAULT 0, - "page_reach" integer DEFAULT 0, - "created_at" timestamp DEFAULT now() -); ---> statement-breakpoint -CREATE TABLE "post_analytics" ( - "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "post_id" varchar NOT NULL, - "fetched_at" timestamp DEFAULT now(), - "impressions" integer DEFAULT 0, - "reach" integer DEFAULT 0, - "engagement" integer DEFAULT 0, - "reactions" integer DEFAULT 0, - "comments" integer DEFAULT 0, - "shares" integer DEFAULT 0, - "clicks" integer DEFAULT 0, - "raw_data" jsonb -); ---> statement-breakpoint -CREATE TABLE "post_media" ( - "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "post_id" varchar NOT NULL, - "media_id" varchar NOT NULL, - "display_order" integer DEFAULT 0 NOT NULL -); ---> statement-breakpoint -CREATE TABLE "posts" ( - "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "user_id" varchar NOT NULL, - "content" text NOT NULL, - "ai_generated" text DEFAULT 'false' NOT NULL, - "product_info" jsonb, - "status" "post_status" DEFAULT 'draft' NOT NULL, - "scheduled_for" timestamp, - "created_at" timestamp DEFAULT now(), - "updated_at" timestamp DEFAULT now() -); ---> statement-breakpoint -CREATE TABLE "scheduled_posts" ( - "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "post_id" varchar NOT NULL, - "page_id" varchar NOT NULL, - "post_type" "post_type" NOT NULL, - "scheduled_at" timestamp NOT NULL, - "published_at" timestamp, - "external_post_id" text, - "error" text -); ---> statement-breakpoint -CREATE TABLE "social_pages" ( - "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "user_id" varchar NOT NULL, - "platform" "platform" NOT NULL, - "page_id" text NOT NULL, - "page_name" text NOT NULL, - "access_token" text NOT NULL, - "token_expires_at" timestamp, - "followers_count" integer DEFAULT 0, - "is_active" text DEFAULT 'true' NOT NULL, - "token_status" "token_status" DEFAULT 'valid', - "last_token_check" timestamp, - "created_at" timestamp DEFAULT now() -); ---> statement-breakpoint -CREATE TABLE "user_page_permissions" ( - "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "user_id" varchar NOT NULL, - "page_id" varchar NOT NULL, - "created_at" timestamp DEFAULT now() -); ---> statement-breakpoint -CREATE TABLE "users" ( - "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "username" text NOT NULL, - "password" text NOT NULL, - "role" "user_role" DEFAULT 'user' NOT NULL, - CONSTRAINT "users_username_unique" UNIQUE("username") -); ---> statement-breakpoint -ALTER TABLE "ai_generations" ADD CONSTRAINT "ai_generations_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "cloudinary_config" ADD CONSTRAINT "cloudinary_config_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "media" ADD CONSTRAINT "media_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "openrouter_config" ADD CONSTRAINT "openrouter_config_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "page_analytics_history" ADD CONSTRAINT "page_analytics_history_page_id_social_pages_id_fk" FOREIGN KEY ("page_id") REFERENCES "public"."social_pages"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "post_analytics" ADD CONSTRAINT "post_analytics_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "post_media" ADD CONSTRAINT "post_media_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "post_media" ADD CONSTRAINT "post_media_media_id_media_id_fk" FOREIGN KEY ("media_id") REFERENCES "public"."media"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "posts" ADD CONSTRAINT "posts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "scheduled_posts" ADD CONSTRAINT "scheduled_posts_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "scheduled_posts" ADD CONSTRAINT "scheduled_posts_page_id_social_pages_id_fk" FOREIGN KEY ("page_id") REFERENCES "public"."social_pages"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "social_pages" ADD CONSTRAINT "social_pages_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "user_page_permissions" ADD CONSTRAINT "user_page_permissions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "user_page_permissions" ADD CONSTRAINT "user_page_permissions_page_id_social_pages_id_fk" FOREIGN KEY ("page_id") REFERENCES "public"."social_pages"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/migrations/meta/0000_snapshot.json b/migrations/meta/0000_snapshot.json deleted file mode 100644 index a49f52c..0000000 --- a/migrations/meta/0000_snapshot.json +++ /dev/null @@ -1,1047 +0,0 @@ -{ - "id": "0e3307c4-094d-4ea4-a1dc-8bd9a4d96401", - "prevId": "00000000-0000-0000-0000-000000000000", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.ai_generations": { - "name": "ai_generations", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "varchar", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "varchar", - "primaryKey": false, - "notNull": true - }, - "product_info": { - "name": "product_info", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "generated_texts": { - "name": "generated_texts", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "selected_text": { - "name": "selected_text", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "ai_generations_user_id_users_id_fk": { - "name": "ai_generations_user_id_users_id_fk", - "tableFrom": "ai_generations", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.cloudinary_config": { - "name": "cloudinary_config", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "varchar", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "varchar", - "primaryKey": false, - "notNull": true - }, - "cloud_name": { - "name": "cloud_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "api_key": { - "name": "api_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "api_secret": { - "name": "api_secret", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "logo_public_id": { - "name": "logo_public_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "cloudinary_config_user_id_users_id_fk": { - "name": "cloudinary_config_user_id_users_id_fk", - "tableFrom": "cloudinary_config", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "cloudinary_config_user_id_unique": { - "name": "cloudinary_config_user_id_unique", - "nullsNotDistinct": false, - "columns": [ - "user_id" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.media": { - "name": "media", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "varchar", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "varchar", - "primaryKey": false, - "notNull": true - }, - "type": { - "name": "type", - "type": "media_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "cloudinary_public_id": { - "name": "cloudinary_public_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "original_url": { - "name": "original_url", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "facebook_landscape_url": { - "name": "facebook_landscape_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "facebook_feed_url": { - "name": "facebook_feed_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "instagram_feed_url": { - "name": "instagram_feed_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "instagram_story_url": { - "name": "instagram_story_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "file_name": { - "name": "file_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "file_size": { - "name": "file_size", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "media_user_id_users_id_fk": { - "name": "media_user_id_users_id_fk", - "tableFrom": "media", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.openrouter_config": { - "name": "openrouter_config", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "varchar", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "varchar", - "primaryKey": false, - "notNull": true - }, - "api_key": { - "name": "api_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "model": { - "name": "model", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'anthropic/claude-3.5-sonnet'" - }, - "system_prompt": { - "name": "system_prompt", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'Tu es un expert en marketing des réseaux sociaux. Génère 3 variations de textes engageants pour des publications Facebook et Instagram à partir des informations produit fournies. Chaque variation doit être unique, captivante et optimisée pour l''engagement.'" - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "openrouter_config_user_id_users_id_fk": { - "name": "openrouter_config_user_id_users_id_fk", - "tableFrom": "openrouter_config", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "openrouter_config_user_id_unique": { - "name": "openrouter_config_user_id_unique", - "nullsNotDistinct": false, - "columns": [ - "user_id" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.page_analytics_history": { - "name": "page_analytics_history", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "varchar", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "page_id": { - "name": "page_id", - "type": "varchar", - "primaryKey": false, - "notNull": true - }, - "date": { - "name": "date", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "followers_count": { - "name": "followers_count", - "type": "integer", - "primaryKey": false, - "notNull": false, - "default": 0 - }, - "page_views": { - "name": "page_views", - "type": "integer", - "primaryKey": false, - "notNull": false, - "default": 0 - }, - "page_reach": { - "name": "page_reach", - "type": "integer", - "primaryKey": false, - "notNull": false, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "page_analytics_history_page_id_social_pages_id_fk": { - "name": "page_analytics_history_page_id_social_pages_id_fk", - "tableFrom": "page_analytics_history", - "tableTo": "social_pages", - "columnsFrom": [ - "page_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.post_analytics": { - "name": "post_analytics", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "varchar", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "post_id": { - "name": "post_id", - "type": "varchar", - "primaryKey": false, - "notNull": true - }, - "fetched_at": { - "name": "fetched_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false, - "default": "now()" - }, - "impressions": { - "name": "impressions", - "type": "integer", - "primaryKey": false, - "notNull": false, - "default": 0 - }, - "reach": { - "name": "reach", - "type": "integer", - "primaryKey": false, - "notNull": false, - "default": 0 - }, - "engagement": { - "name": "engagement", - "type": "integer", - "primaryKey": false, - "notNull": false, - "default": 0 - }, - "reactions": { - "name": "reactions", - "type": "integer", - "primaryKey": false, - "notNull": false, - "default": 0 - }, - "comments": { - "name": "comments", - "type": "integer", - "primaryKey": false, - "notNull": false, - "default": 0 - }, - "shares": { - "name": "shares", - "type": "integer", - "primaryKey": false, - "notNull": false, - "default": 0 - }, - "clicks": { - "name": "clicks", - "type": "integer", - "primaryKey": false, - "notNull": false, - "default": 0 - }, - "raw_data": { - "name": "raw_data", - "type": "jsonb", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "post_analytics_post_id_posts_id_fk": { - "name": "post_analytics_post_id_posts_id_fk", - "tableFrom": "post_analytics", - "tableTo": "posts", - "columnsFrom": [ - "post_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.post_media": { - "name": "post_media", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "varchar", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "post_id": { - "name": "post_id", - "type": "varchar", - "primaryKey": false, - "notNull": true - }, - "media_id": { - "name": "media_id", - "type": "varchar", - "primaryKey": false, - "notNull": true - }, - "display_order": { - "name": "display_order", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - } - }, - "indexes": {}, - "foreignKeys": { - "post_media_post_id_posts_id_fk": { - "name": "post_media_post_id_posts_id_fk", - "tableFrom": "post_media", - "tableTo": "posts", - "columnsFrom": [ - "post_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "post_media_media_id_media_id_fk": { - "name": "post_media_media_id_media_id_fk", - "tableFrom": "post_media", - "tableTo": "media", - "columnsFrom": [ - "media_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.posts": { - "name": "posts", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "varchar", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "varchar", - "primaryKey": false, - "notNull": true - }, - "content": { - "name": "content", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "ai_generated": { - "name": "ai_generated", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'false'" - }, - "product_info": { - "name": "product_info", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "post_status", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'draft'" - }, - "scheduled_for": { - "name": "scheduled_for", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "posts_user_id_users_id_fk": { - "name": "posts_user_id_users_id_fk", - "tableFrom": "posts", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.scheduled_posts": { - "name": "scheduled_posts", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "varchar", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "post_id": { - "name": "post_id", - "type": "varchar", - "primaryKey": false, - "notNull": true - }, - "page_id": { - "name": "page_id", - "type": "varchar", - "primaryKey": false, - "notNull": true - }, - "post_type": { - "name": "post_type", - "type": "post_type", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "scheduled_at": { - "name": "scheduled_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "published_at": { - "name": "published_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "external_post_id": { - "name": "external_post_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "error": { - "name": "error", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "scheduled_posts_post_id_posts_id_fk": { - "name": "scheduled_posts_post_id_posts_id_fk", - "tableFrom": "scheduled_posts", - "tableTo": "posts", - "columnsFrom": [ - "post_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "scheduled_posts_page_id_social_pages_id_fk": { - "name": "scheduled_posts_page_id_social_pages_id_fk", - "tableFrom": "scheduled_posts", - "tableTo": "social_pages", - "columnsFrom": [ - "page_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.social_pages": { - "name": "social_pages", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "varchar", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "varchar", - "primaryKey": false, - "notNull": true - }, - "platform": { - "name": "platform", - "type": "platform", - "typeSchema": "public", - "primaryKey": false, - "notNull": true - }, - "page_id": { - "name": "page_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "page_name": { - "name": "page_name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "access_token": { - "name": "access_token", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token_expires_at": { - "name": "token_expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "followers_count": { - "name": "followers_count", - "type": "integer", - "primaryKey": false, - "notNull": false, - "default": 0 - }, - "is_active": { - "name": "is_active", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'true'" - }, - "token_status": { - "name": "token_status", - "type": "token_status", - "typeSchema": "public", - "primaryKey": false, - "notNull": false, - "default": "'valid'" - }, - "last_token_check": { - "name": "last_token_check", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "social_pages_user_id_users_id_fk": { - "name": "social_pages_user_id_users_id_fk", - "tableFrom": "social_pages", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.user_page_permissions": { - "name": "user_page_permissions", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "varchar", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "varchar", - "primaryKey": false, - "notNull": true - }, - "page_id": { - "name": "page_id", - "type": "varchar", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "user_page_permissions_user_id_users_id_fk": { - "name": "user_page_permissions_user_id_users_id_fk", - "tableFrom": "user_page_permissions", - "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "user_page_permissions_page_id_social_pages_id_fk": { - "name": "user_page_permissions_page_id_social_pages_id_fk", - "tableFrom": "user_page_permissions", - "tableTo": "social_pages", - "columnsFrom": [ - "page_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.users": { - "name": "users", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "varchar", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "username": { - "name": "username", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "password": { - "name": "password", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "role": { - "name": "role", - "type": "user_role", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'user'" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "users_username_unique": { - "name": "users_username_unique", - "nullsNotDistinct": false, - "columns": [ - "username" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": { - "public.media_type": { - "name": "media_type", - "schema": "public", - "values": [ - "image", - "video" - ] - }, - "public.platform": { - "name": "platform", - "schema": "public", - "values": [ - "facebook", - "instagram" - ] - }, - "public.post_status": { - "name": "post_status", - "schema": "public", - "values": [ - "draft", - "scheduled", - "published", - "failed" - ] - }, - "public.post_type": { - "name": "post_type", - "schema": "public", - "values": [ - "feed", - "story", - "both" - ] - }, - "public.token_status": { - "name": "token_status", - "schema": "public", - "values": [ - "valid", - "expiring", - "expired", - "error" - ] - }, - "public.user_role": { - "name": "user_role", - "schema": "public", - "values": [ - "admin", - "user" - ] - } - }, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json deleted file mode 100644 index f9d6f4f..0000000 --- a/migrations/meta/_journal.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "version": "7", - "dialect": "postgresql", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1768914136854, - "tag": "0000_spicy_cargill", - "breakpoints": true - } - ] -} \ No newline at end of file diff --git a/server/auth.ts b/server/auth.ts deleted file mode 100644 index 7d35fcc..0000000 --- a/server/auth.ts +++ /dev/null @@ -1,45 +0,0 @@ -import passport from "passport"; -import { Strategy as LocalStrategy } from "passport-local"; -import bcrypt from "bcrypt"; -import { storage } from "./storage"; -import type { User } from "@shared/schema"; - -// Configuration de la stratégie locale -passport.use( - new LocalStrategy(async (username, password, done) => { - try { - const user = await storage.getUserByUsername(username); - - if (!user) { - return done(null, false, { message: "Nom d'utilisateur ou mot de passe incorrect" }); - } - - const isValidPassword = await bcrypt.compare(password, user.password); - - if (!isValidPassword) { - return done(null, false, { message: "Nom d'utilisateur ou mot de passe incorrect" }); - } - - return done(null, user); - } catch (error) { - return done(error); - } - }) -); - -// Sérialisation de l'utilisateur pour la session -passport.serializeUser((user: Express.User, done) => { - done(null, (user as User).id); -}); - -// Désérialisation de l'utilisateur depuis la session -passport.deserializeUser(async (id: string, done) => { - try { - const user = await storage.getUser(id); - done(null, user); - } catch (error) { - done(error); - } -}); - -export default passport; diff --git a/server/cron.ts b/server/cron.ts deleted file mode 100644 index 03e41be..0000000 --- a/server/cron.ts +++ /dev/null @@ -1,51 +0,0 @@ -import cron from 'node-cron'; -import { TokenManager } from './services/token_manager'; -import { AnalyticsService } from './services/analytics'; -import { purgeOldMedia } from './services/media-purge'; -import { tiktokService } from './services/tiktok'; - -export function startTokenCron() { - // Toutes les 2 minutes — suivi des publications TikTok (API asynchrone) - cron.schedule('*/2 * * * *', async () => { - try { - await tiktokService.syncPendingPublications(); - } catch (error) { - console.error('[Cron] TikTok publish status sync failed:', error); - } - }); - console.log('[Cron] TikTok publish status job scheduled (*/2 * * * *).'); - - // Deux fois par jour (00:00 et 12:00) — contrôle et renouvellement des jetons. - // Un jeton révoqué par Meta ne prévient pas : passer deux fois par jour - // réduit d'autant la fenêtre pendant laquelle une publication échouerait. - cron.schedule('0 0,12 * * *', async () => { - console.log('[Cron] Running token check...'); - const results = await TokenManager.checkAndRefreshTokens(); - const renewed = results.filter(r => r.renewed).length; - const failing = results.filter(r => r.status === 'expired' || r.status === 'error').length; - console.log(`[Cron] Token check done: ${results.length} contrôlés, ${renewed} renouvelés, ${failing} en échec.`); - }); - console.log('[Cron] Token refresh job scheduled (0 0,12 * * *).'); - - // Run twice daily (08:00 and 20:00) — Analytics sync for all pages - cron.schedule('0 8,20 * * *', async () => { - console.log('[Cron] Running analytics sync for all pages...'); - try { - await AnalyticsService.syncAllPages(); - } catch (error) { - console.error('[Cron] Analytics sync failed:', error); - } - }); - console.log('[Cron] Analytics sync job scheduled (0 8,20 * * *).'); - - // Run every day at 03:00 AM — Cleanup old media from Cloudinary and DB - cron.schedule('0 3 * * *', async () => { - console.log('[Cron] Running daily media purge check...'); - try { - await purgeOldMedia(); - } catch (error) { - console.error('[Cron] Media purge check failed:', error); - } - }); - console.log('[Cron] Media purge job scheduled (0 3 * * *).'); -} diff --git a/server/db.ts b/server/db.ts deleted file mode 100644 index d74a15a..0000000 --- a/server/db.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Pool } from 'pg'; -import { drizzle } from 'drizzle-orm/node-postgres'; -import * as schema from "@shared/schema"; - -if (!process.env.DATABASE_URL) { - throw new Error( - "DATABASE_URL must be set. Did you forget to provision a database?", - ); -} - -export const pool = new Pool({ connectionString: process.env.DATABASE_URL }); -export const db = drizzle(pool, { schema }); \ No newline at end of file diff --git a/server/index.ts b/server/index.ts deleted file mode 100644 index 8f52097..0000000 --- a/server/index.ts +++ /dev/null @@ -1,241 +0,0 @@ -import express, { type Request, Response, NextFunction } from "express"; -import path from "path"; -import fs from "fs"; -import session from "express-session"; -import connectPgSimple from "connect-pg-simple"; -import pg from "pg"; -import crypto from "crypto"; -import helmet from "helmet"; -import rateLimit from "express-rate-limit"; -import passport from "./auth"; -import { registerRoutes } from "./routes"; -import { setupVite, serveStatic, log } from "./vite"; -import { schedulerService } from "./services/scheduler"; -import { ensureAdminUserExists } from "./init-admin"; -import { startTokenCron } from "./cron"; -import { migrate } from "./migrate"; -import { startReelProcessing } from "./services/reels"; -import { jamendoService } from "./services/jamendo"; -import { ffmpegService } from "./services/ffmpeg"; - -const app = express(); -const PgSession = connectPgSimple(session); - -// Faire confiance au reverse proxy (nginx) pour X-Forwarded-For -// Nécessaire pour que express-rate-limit identifie correctement les IPs -app.set('trust proxy', 1); - -// Headers de sécurité HTTP avec helmet -app.use(helmet({ - contentSecurityPolicy: { - directives: { - defaultSrc: ["'self'"], - scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"], - styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"], - imgSrc: ["'self'", "data:", "https:", "blob:"], - fontSrc: ["'self'", "https://fonts.gstatic.com"], - connectSrc: ["'self'", "https://graph.facebook.com", "https://openrouter.ai", "https://res.cloudinary.com", "wss:", "ws:"], - mediaSrc: ["'self'", "https:", "data:"], - } - }, - crossOriginEmbedderPolicy: false, -})); - -// Rate limiting global - 1000 requêtes par 15 minutes par IP -const globalLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, - max: 1000, - message: { error: 'Trop de requêtes, réessayez plus tard' }, - standardHeaders: true, - legacyHeaders: false, -}); - -// Rate limiting pour l'authentification - 30 tentatives par 15 minutes -const authLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, - max: 30, - message: { error: 'Trop de tentatives de connexion, réessayez dans 15 minutes' }, - standardHeaders: true, - legacyHeaders: false, -}); - -app.use('/api/', globalLimiter); -app.use('/api/auth/login', authLimiter); - -// Serve locally stored audio files -const AUDIO_UPLOAD_DIR = process.env.AUDIO_UPLOAD_DIR || '/app/uploads/audio'; -app.use('/uploads/audio', express.static(AUDIO_UPLOAD_DIR)); - -// Serve locally stored media files (images, videos, logos, stories) -const MEDIA_UPLOAD_DIR = path.join(process.cwd(), 'uploads', 'media'); -const LOGOS_UPLOAD_DIR = path.join(process.cwd(), 'uploads', 'logos'); -const STORIES_UPLOAD_DIR = path.join(process.cwd(), 'uploads', 'stories'); -try { fs.mkdirSync(MEDIA_UPLOAD_DIR, { recursive: true }); } catch { /* ignore */ } -try { fs.mkdirSync(LOGOS_UPLOAD_DIR, { recursive: true }); } catch { /* ignore */ } -try { fs.mkdirSync(STORIES_UPLOAD_DIR, { recursive: true }); } catch { /* ignore */ } -app.use('/uploads/media', express.static(MEDIA_UPLOAD_DIR)); -app.use('/uploads/logos', express.static(LOGOS_UPLOAD_DIR)); -app.use('/uploads/stories', express.static(STORIES_UPLOAD_DIR)); - -// Serve temp uploads for Remotion -const TEMP_UPLOAD_DIR = process.env.TEMP_UPLOAD_DIR || path.join(process.cwd(), 'uploads', 'temp'); -try { fs.mkdirSync(TEMP_UPLOAD_DIR, { recursive: true }); } catch { /* ignore */ } -app.use('/uploads/temp', express.static(TEMP_UPLOAD_DIR)); - -declare module 'http' { - interface IncomingMessage { - rawBody: unknown - } -} -app.use(express.json({ - verify: (req, _res, buf) => { - req.rawBody = buf; - } -})); -app.use(express.urlencoded({ extended: false })); - -// Validation renforcée du SESSION_SECRET -if (!process.env.SESSION_SECRET && process.env.NODE_ENV === 'production') { - console.error('❌ SESSION_SECRET non défini en production. Arrêt du serveur.'); - process.exit(1); -} - -if (!process.env.SESSION_SECRET) { - console.warn('⚠️ SESSION_SECRET non défini. Utilisation d\'une clé aléatoire pour le développement.'); -} - -// Générer un secret aléatoire pour le dev si non défini -const sessionSecret = process.env.SESSION_SECRET || crypto.randomBytes(32).toString('hex'); - -// HTTPS déduit d'APP_URL ; à défaut, 'auto' laisse express-session décider par -// requête à partir de X-Forwarded-Proto (cf. `trust proxy` plus haut), ce qui -// évite un cookie non sécurisé derrière un reverse proxy HTTPS sans APP_URL. -const isHttps: boolean | 'auto' = process.env.APP_URL?.startsWith('https://') || 'auto'; - -// Configuration du store de session pour production -const sessionStore = process.env.NODE_ENV === 'production' && process.env.DATABASE_URL - ? new PgSession({ - pool: new pg.Pool({ - connectionString: process.env.DATABASE_URL, - }), - tableName: 'session', - createTableIfMissing: true, - }) - : undefined; // MemoryStore par défaut en dev - -app.use(session({ - store: sessionStore, - secret: sessionSecret, - resave: false, - saveUninitialized: false, - cookie: { - secure: isHttps, - httpOnly: true, - sameSite: 'lax', - maxAge: 7 * 24 * 60 * 60 * 1000, // 7 jours - } -})); - -// Initialisation de Passport -app.use(passport.initialize()); -app.use(passport.session()); - -app.use((req, res, next) => { - const start = Date.now(); - const path = req.path; - let capturedJsonResponse: Record | undefined = undefined; - - const originalResJson = res.json; - res.json = function (bodyJson, ...args) { - capturedJsonResponse = bodyJson; - return originalResJson.apply(res, [bodyJson, ...args]); - }; - - res.on("finish", () => { - const duration = Date.now() - start; - if (path.startsWith("/api")) { - // Ne pas logger les 401 sur /api/auth/session (vérification normale de session) - if (path === "/api/auth/session" && res.statusCode === 401) { - return; - } - - let logLine = `${req.method} ${path} ${res.statusCode} in ${duration}ms`; - if (capturedJsonResponse) { - logLine += ` :: ${JSON.stringify(capturedJsonResponse)}`; - } - - if (logLine.length > 80) { - logLine = logLine.slice(0, 79) + "…"; - } - - log(logLine); - } - }); - - next(); -}); - -(async () => { - // Run database migration first - await migrate(); - - const server = await registerRoutes(app); - - app.use((err: any, _req: Request, res: Response, _next: NextFunction) => { - const status = err.status || err.statusCode || 500; - const message = err.message || "Internal Server Error"; - - res.status(status).json({ message }); - throw err; - }); - - // importantly only setup vite in development and after - // setting up all the other routes so the catch-all route - // doesn't interfere with the other routes - if (app.get("env") === "development") { - await setupVite(app, server); - } else { - serveStatic(app); - } - - // ALWAYS serve the app on the port specified in the environment variable PORT - // Other ports are firewalled. Default to 5000 if not specified. - // this serves both the API and the client. - // It is the only port that is not firewalled. - const port = parseInt(process.env.PORT || '5000', 10); - - // Initialiser l'utilisateur admin par défaut avant de démarrer le serveur - await ensureAdminUserExists(); - - // Configurer le service Jamendo pour les Reels - const jamendoClientId = process.env.JAMENDO_CLIENT_ID || '5b6bba49'; - jamendoService.configure(jamendoClientId); - - - // Configurer le service FFmpeg pour le traitement vidéo (si configuré) - if (process.env.FFMPEG_API_URL && process.env.FFMPEG_API_KEY) { - ffmpegService.configure(process.env.FFMPEG_API_URL, process.env.FFMPEG_API_KEY); - } else { - log('⚠️ FFmpeg API not configured - video processing will be skipped'); - } - - server.listen({ - port, - host: "0.0.0.0", - reusePort: true, - }, () => { - log(`serving on port ${port}`); - - // Démarrer le scheduler pour les publications programmées - schedulerService.start(); - - // Start Token Refresh Cron - startTokenCron(); - - // File des rendus de Reels : démarrée une fois le serveur à l'écoute, car - // le rendu d'images lit la voix et la musique via http://localhost. - startReelProcessing().catch((error) => { - console.error("❌ Démarrage de la file des Reels impossible :", error); - }); - }); -})(); diff --git a/server/init-admin.ts b/server/init-admin.ts deleted file mode 100644 index edc43a7..0000000 --- a/server/init-admin.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { storage } from "./storage"; -import bcrypt from "bcrypt"; - -export async function ensureAdminUserExists() { - try { - const adminUser = await storage.getUserByUsername("admin"); - - if (!adminUser) { - console.log("🔧 Création de l'utilisateur admin par défaut..."); - - const hashedPassword = await bcrypt.hash("admin", 10); - - await storage.createUser({ - username: "admin", - password: hashedPassword, - role: "admin", - }); - - console.log("✅ Utilisateur admin créé avec succès"); - console.log(" Username: admin"); - console.log(" Password: admin"); - console.log(" ⚠️ IMPORTANT: Changez ce mot de passe immédiatement !"); - } else { - console.log("✓ Utilisateur admin existe déjà"); - } - } catch (error) { - console.error("❌ Erreur lors de la vérification/création de l'utilisateur admin:", error); - throw error; - } -} diff --git a/server/middleware/apiKey.ts b/server/middleware/apiKey.ts deleted file mode 100644 index d01aa0a..0000000 --- a/server/middleware/apiKey.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { Request, Response, NextFunction } from "express"; -import { storage } from "../storage"; - -export async function requireApiKey(req: Request, res: Response, next: NextFunction) { - const provided = req.headers["x-api-key"]; - - if (!provided) { - return res.status(401).json({ error: "Clé API manquante (header X-API-Key requis)" }); - } - - try { - const config = await storage.getAppConfig(); - const key = config?.externalApiKey || process.env.EXTERNAL_API_KEY; - - if (!key) { - return res.status(503).json({ error: "API externe non configurée" }); - } - - if (provided !== key) { - return res.status(401).json({ error: "Clé API invalide" }); - } - - next(); - } catch (error) { - console.error("[apiKey middleware] Error:", error); - return res.status(500).json({ error: "Erreur interne lors de la vérification de la clé API" }); - } -} diff --git a/server/migrate.ts b/server/migrate.ts deleted file mode 100644 index 693e4eb..0000000 --- a/server/migrate.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { pool } from "./db"; - -export async function migrate() { - const client = await pool.connect(); - try { - console.log("[Migration] Starting safe migration Check..."); - - // 1. Create Enums if not exist and Update Enums - await client.query(` - DO $$ BEGIN - CREATE TYPE "token_status" AS ENUM('valid', 'expiring', 'expired', 'error'); - EXCEPTION - WHEN duplicate_object THEN null; - END $$; - `); - - // Add 'reel' to post_type enum if not exists - await client.query(`ALTER TYPE "post_type" ADD VALUE IF NOT EXISTS 'reel';`); - - // Add 'tiktok' to platform enum if not exists - await client.query(`ALTER TYPE "platform" ADD VALUE IF NOT EXISTS 'tiktok';`); - - // 2. Create Tables if not exist - - // post_analytics - await client.query(` - CREATE TABLE IF NOT EXISTS "post_analytics" ( - "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "post_id" varchar NOT NULL REFERENCES "posts"("id") ON DELETE cascade, - "fetched_at" timestamp DEFAULT now(), - "impressions" integer DEFAULT 0, - "reach" integer DEFAULT 0, - "engagement" integer DEFAULT 0, - "reactions" integer DEFAULT 0, - "comments" integer DEFAULT 0, - "shares" integer DEFAULT 0, - "clicks" integer DEFAULT 0, - "raw_data" jsonb - ); - `); - - // page_analytics_history - await client.query(` - CREATE TABLE IF NOT EXISTS "page_analytics_history" ( - "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "page_id" varchar NOT NULL REFERENCES "social_pages"("id") ON DELETE cascade, - "date" timestamp NOT NULL, - "followers_count" integer DEFAULT 0, - "page_views" integer DEFAULT 0, - "page_reach" integer DEFAULT 0, - "created_at" timestamp DEFAULT now() - ); - `); - - // 3. Add Columns to existing tables if not exist - - // social_pages.token_status - await client.query(` - ALTER TABLE "social_pages" - ADD COLUMN IF NOT EXISTS "token_status" "token_status" DEFAULT 'valid'; - `); - - // social_pages.last_token_check - await client.query(` - ALTER TABLE "social_pages" - ADD COLUMN IF NOT EXISTS "last_token_check" timestamp; - `); - - // music_favorites - await client.query(` - CREATE TABLE IF NOT EXISTS "music_favorites" ( - "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade, - "track_id" text NOT NULL, - "title" text NOT NULL, - "artist" text NOT NULL, - "album_name" text, - "duration" integer NOT NULL, - "preview_url" text NOT NULL, - "download_url" text NOT NULL, - "image_url" text, - "license" text, - "created_at" timestamp DEFAULT now() - ); - `); - - // freesound_config - await client.query(` - CREATE TABLE IF NOT EXISTS "freesound_config" ( - "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade, - "client_id" text NOT NULL, - "client_secret" text NOT NULL, - "created_at" timestamp DEFAULT now(), - "updated_at" timestamp DEFAULT now(), - CONSTRAINT freesound_config_user_id_unique UNIQUE ("user_id") - ); - `); - - // posts.generation_status (Reel progress tracking) - await client.query(` - ALTER TABLE "posts" - ADD COLUMN IF NOT EXISTS "generation_status" text; - `); - - // posts.generation_progress - await client.query(` - ALTER TABLE "posts" - ADD COLUMN IF NOT EXISTS "generation_progress" integer DEFAULT 0; - `); - - // posts.generation_error - await client.query(` - ALTER TABLE "posts" - ADD COLUMN IF NOT EXISTS "generation_error" text; - `); - - // cloudinary_config.endpoint_url (MinIO internal endpoint URL) - await client.query(` - ALTER TABLE "cloudinary_config" - ADD COLUMN IF NOT EXISTS "endpoint_url" text; - `); - - // cloudinary_config.public_url (MinIO public-facing base URL) - await client.query(` - ALTER TABLE "cloudinary_config" - ADD COLUMN IF NOT EXISTS "public_url" text; - `); - // page_analytics_history.page_engagement - await client.query(` - ALTER TABLE "page_analytics_history" - ADD COLUMN IF NOT EXISTS "page_engagement" integer DEFAULT 0; - `); - - // audio_tracks (bibliothèque audio interne) - await client.query(` - CREATE TABLE IF NOT EXISTS "audio_tracks" ( - "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "user_id" varchar REFERENCES "users"("id") ON DELETE cascade, - "title" text NOT NULL, - "file_name" text NOT NULL, - "url" text NOT NULL, - "duration" integer DEFAULT 0, - "created_at" timestamp DEFAULT now() - ); - `); - - // app_config (global application settings) - await client.query(` - CREATE TABLE IF NOT EXISTS "app_config" ( - "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "external_api_key" text, - "created_at" timestamp DEFAULT now(), - "updated_at" timestamp DEFAULT now() - ); - `); - - // app_config.gemini_api_key - await client.query(` - ALTER TABLE "app_config" - ADD COLUMN IF NOT EXISTS "gemini_api_key" text; - `); - - // app_config.qwen_tts_url / qwen_tts_api_key (service Qwen3-TTS) - await client.query(` - ALTER TABLE "app_config" - ADD COLUMN IF NOT EXISTS "qwen_tts_url" text, - ADD COLUMN IF NOT EXISTS "qwen_tts_api_key" text; - `); - - // media.thumbnail_url (vignette conservée après suppression de la vidéo) - await client.query(` - ALTER TABLE "media" - ADD COLUMN IF NOT EXISTS "thumbnail_url" text; - `); - - // tiktok_config (application développeur TikTok, globale) - await client.query(` - CREATE TABLE IF NOT EXISTS "tiktok_config" ( - "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "client_key" text NOT NULL, - "client_secret" text NOT NULL, - "created_at" timestamp DEFAULT now(), - "updated_at" timestamp DEFAULT now() - ); - `); - - // social_pages : champs OAuth TikTok - await client.query(` - ALTER TABLE "social_pages" - ADD COLUMN IF NOT EXISTS "refresh_token" text, - ADD COLUMN IF NOT EXISTS "refresh_token_expires_at" timestamp, - ADD COLUMN IF NOT EXISTS "scopes" text, - ADD COLUMN IF NOT EXISTS "avatar_url" text; - `); - - // facebook_config (application développeur Facebook, globale) - await client.query(` - CREATE TABLE IF NOT EXISTS "facebook_config" ( - "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "app_id" text NOT NULL, - "app_secret" text NOT NULL, - "created_at" timestamp DEFAULT now(), - "updated_at" timestamp DEFAULT now() - ); - `); - - // social_pages : renouvellement automatique des tokens Facebook - await client.query(` - ALTER TABLE "social_pages" - ADD COLUMN IF NOT EXISTS "user_access_token" text, - ADD COLUMN IF NOT EXISTS "user_token_expires_at" timestamp, - ADD COLUMN IF NOT EXISTS "token_error" text; - `); - - // scheduled_posts : suivi de la publication asynchrone TikTok - await client.query(` - ALTER TABLE "scheduled_posts" - ADD COLUMN IF NOT EXISTS "publish_id" text, - ADD COLUMN IF NOT EXISTS "publish_status" text; - `); - - // reel_jobs : file d'attente persistante des rendus de Reels - await client.query(` - CREATE TABLE IF NOT EXISTS "reel_jobs" ( - "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade, - "post_id" varchar REFERENCES "posts"("id") ON DELETE cascade, - "kind" text NOT NULL, - "params" jsonb NOT NULL, - "status" text NOT NULL DEFAULT 'pending', - "step" text, - "progress" integer NOT NULL DEFAULT 0, - "attempts" integer NOT NULL DEFAULT 0, - "result" jsonb, - "error" text, - "heartbeat_at" timestamp, - "created_at" timestamp NOT NULL DEFAULT now(), - "updated_at" timestamp NOT NULL DEFAULT now() - ); - `); - - // 4. Index de performance - // - // Postgres n'indexe pas les clés étrangères tout seul : sans ces index, le - // calendrier, l'historique et le scheduler faisaient des parcours complets - // de table à chaque appel. Ils existaient dans migration-docker.sql, qui - // n'est monté nulle part — ce fichier-ci est le seul exécuté au démarrage. - // - // Chaque index est créé isolément : une table absente sur une vieille - // installation ne doit pas empêcher la création des suivants. - const indexes: [string, string][] = [ - // Filtres par propriétaire - ["idx_posts_user_id", `CREATE INDEX IF NOT EXISTS "idx_posts_user_id" ON "posts" ("user_id")`], - ["idx_media_user_id", `CREATE INDEX IF NOT EXISTS "idx_media_user_id" ON "media" ("user_id")`], - ["idx_social_pages_user_id", `CREATE INDEX IF NOT EXISTS "idx_social_pages_user_id" ON "social_pages" ("user_id")`], - ["idx_ai_generations_user_id", `CREATE INDEX IF NOT EXISTS "idx_ai_generations_user_id" ON "ai_generations" ("user_id")`], - - // Jointures - ["idx_scheduled_posts_post_id", `CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_post_id" ON "scheduled_posts" ("post_id")`], - ["idx_post_media_post_id", `CREATE INDEX IF NOT EXISTS "idx_post_media_post_id" ON "post_media" ("post_id")`], - ["idx_post_media_media_id", `CREATE INDEX IF NOT EXISTS "idx_post_media_media_id" ON "post_media" ("media_id")`], - ["idx_post_analytics_post_id", `CREATE INDEX IF NOT EXISTS "idx_post_analytics_post_id" ON "post_analytics" ("post_id")`], - ["idx_page_analytics_history_page_date", `CREATE INDEX IF NOT EXISTS "idx_page_analytics_history_page_date" ON "page_analytics_history" ("page_id", "date")`], - ["idx_user_page_permissions_user_id", `CREATE INDEX IF NOT EXISTS "idx_user_page_permissions_user_id" ON "user_page_permissions" ("user_id")`], - ["idx_user_page_permissions_page_id", `CREATE INDEX IF NOT EXISTS "idx_user_page_permissions_page_id" ON "user_page_permissions" ("page_id")`], - - // Calendrier et historique : « les publications de ces pages, sur cette période » - ["idx_scheduled_posts_page_scheduled_at", `CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_page_scheduled_at" ON "scheduled_posts" ("page_id", "scheduled_at")`], - - // Scheduler (toutes les minutes) : seules les publications non encore - // traitées comptent, d'où un index partiel qui reste minuscule. - ["idx_scheduled_posts_pending", `CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_pending" ON "scheduled_posts" ("scheduled_at") WHERE "published_at" IS NULL`], - - // Suivi asynchrone TikTok (toutes les deux minutes) - ["idx_scheduled_posts_publish_id", `CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_publish_id" ON "scheduled_posts" ("publish_id") WHERE "publish_id" IS NOT NULL`], - - // Reels en cours de génération - ["idx_reel_jobs_queue", `CREATE INDEX IF NOT EXISTS "idx_reel_jobs_queue" ON "reel_jobs" ("created_at") WHERE "status" IN ('pending', 'processing')`], - ["idx_posts_generation_status", `CREATE INDEX IF NOT EXISTS "idx_posts_generation_status" ON "posts" ("generation_status") WHERE "generation_status" IS NOT NULL`], - ]; - - for (const [name, statement] of indexes) { - try { - await client.query(statement); - } catch (error) { - console.warn(`[Migration] Index ${name} non créé:`, error instanceof Error ? error.message : error); - } - } - - console.log("[Migration] Safe migration completed."); - } catch (error) { - console.error("[Migration] Error during migration:", error); - // Don't kill the process, let the app try to start even if migration failed - // (It might have failed because tables already existed in a different state) - } finally { - client.release(); - } -} diff --git a/server/reproduce_emoji.ts b/server/reproduce_emoji.ts deleted file mode 100644 index 2bd1008..0000000 --- a/server/reproduce_emoji.ts +++ /dev/null @@ -1,32 +0,0 @@ - -import { emojiRenderer } from "./services/emojiRenderer"; -import { createCanvas } from "canvas"; -import fs from "fs"; -import path from "path"; - -async function run() { - const width = 500; - const height = 200; - const canvas = createCanvas(width, height); - const ctx = canvas.getContext("2d"); - - ctx.fillStyle = "white"; - ctx.fillRect(0, 0, width, height); - - ctx.fillStyle = "black"; - ctx.font = "30px Arial"; - - const text = "Hello 🌍 World! 😂"; - console.log(`Rendering text: ${text}`); - - try { - await emojiRenderer.drawText(text, ctx, 50, 100, 30); - const buffer = canvas.toBuffer("image/png"); - fs.writeFileSync("emoji_test.png", buffer); - console.log("Image saved to emoji_test.png"); - } catch (error) { - console.error("Error rendering emoji:", error); - } -} - -run(); diff --git a/server/routes.ts b/server/routes.ts deleted file mode 100644 index aaddc80..0000000 --- a/server/routes.ts +++ /dev/null @@ -1,2120 +0,0 @@ -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 { normalizeQwenUrl } from "./services/reels/assets"; -import { reelsRouter } from "./routes/reels"; -import { externalRouter } from "./routes/external"; -import { tiktokRouter } from "./routes/tiktok"; -import { facebookRouter } from "./routes/facebook"; -import { legalRouter } from "./routes/legal"; -import { createVideoThumbnail } from "./services/thumbnail"; -import { insertAudioTrackSchema } from "@shared/schema"; -import { agendaItemFromScheduledPost, agendaItemsFromReelJob, type AgendaItem, type AgendaPage } from "@shared/agenda"; -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(); -} - -/** Convertit un paramètre de requête en entier positif, ou `undefined`. */ -function parsePositiveInt(value: unknown): number | undefined { - if (typeof value !== "string" || value === "") return undefined; - const parsed = Number.parseInt(value, 10); - return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined; -} - -/** Convertit un paramètre de requête en date, ou `undefined` s'il est inutilisable. */ -function parseDateParam(value: unknown): Date | undefined { - if (typeof value !== "string" || value === "") return undefined; - const date = new Date(value); - return Number.isNaN(date.getTime()) ? undefined : date; -} - -export async function registerRoutes(app: Express): Promise { - - - - // 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 = {}; - - 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é" }); - } - }); - - // Qwen3-TTS : service local ou serveur GPU distant (ex. Unraid) - app.get("/api/settings/qwen", requireAdmin, async (req, res) => { - try { - const config = await storage.getAppConfig(); - res.json({ url: config?.qwenTtsUrl ?? "", hasApiKey: !!config?.qwenTtsApiKey }); - } catch (error) { - console.error("Error fetching Qwen config:", error); - res.status(500).json({ error: "Erreur lors de la récupération de la configuration" }); - } - }); - - app.post("/api/settings/qwen", requireAdmin, async (req, res) => { - try { - const url = normalizeQwenUrl(req.body?.url); - if (!url) { - return res.status(400).json({ error: "Adresse invalide (ex. http://192.168.1.20:8001)" }); - } - const { apiKey } = req.body; - await storage.upsertAppConfig({ - qwenTtsUrl: url, - // Clé laissée vide : on garde celle déjà enregistrée - ...(typeof apiKey === "string" && apiKey.trim() ? { qwenTtsApiKey: apiKey.trim() } : {}), - }); - res.json({ success: true }); - } catch (error) { - console.error("Error saving Qwen config:", error); - res.status(500).json({ error: "Erreur lors de la sauvegarde de la configuration" }); - } - }); - - app.delete("/api/settings/qwen", requireAdmin, async (req, res) => { - try { - await storage.upsertAppConfig({ qwenTtsUrl: null, qwenTtsApiKey: null }); - res.json({ success: true }); - } catch (error) { - console.error("Error deleting Qwen config:", error); - res.status(500).json({ error: "Erreur lors de la suppression de la configuration" }); - } - }); - - // Teste la connexion depuis le service FFmpeg (c'est lui qui appelle Qwen) - app.post("/api/settings/qwen/test", requireAdmin, async (req, res) => { - try { - const config = await storage.getAppConfig(); - const url = normalizeQwenUrl(req.body?.url) ?? config?.qwenTtsUrl ?? undefined; - const apiKey = (typeof req.body?.apiKey === "string" && req.body.apiKey.trim()) || config?.qwenTtsApiKey || undefined; - const { ffmpegService } = await import("./services/ffmpeg"); - const catalog = await ffmpegService.listVoices(url ? { url, apiKey } : undefined); - if (!catalog.qwen_available || !catalog.qwen_status) { - return res.json({ ok: false, error: "Service Qwen injoignable (adresse, port, clé ou pare-feu)" }); - } - res.json({ ok: true, ...catalog.qwen_status, voices: catalog.qwen.length }); - } catch (error) { - console.error("Error testing Qwen config:", error); - res.json({ ok: false, error: error instanceof Error ? error.message : "Test impossible" }); - } - }); - - // 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); - app.use("/api/facebook", requireAuth, facebookRouter); - - // Reels & Music Routes - app.use("/api", requireAuth, reelsRouter); - - - // 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; - - // 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 today = new Date(yesterday); - today.setDate(today.getDate() + 1); - - const lastMonth = new Date(now); - lastMonth.setMonth(lastMonth.getMonth() - 1); - - const stats = await storage.getDashboardStats(userId, { - yesterdayStart: yesterday, - yesterdayEnd: today, - lastMonth, - }); - - const scheduledPosts = stats.scheduledPosts; - const scheduledPostsLastMonth = stats.scheduledPostsLastMonth; - const currentAiTexts = stats.aiTextsGenerated; - const aiTextsYesterday = stats.aiTextsYesterday; - - // 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: stats.connectedPages, - aiTextsGenerated: currentAiTexts, - aiTextsChange: aiTextChange > 0 ? `+${aiTextChange}%` : `${aiTextChange}%`, - aiTextsTrending: aiTextChange >= 0 ? "up" : "down", - mediaStored: stats.mediaStored, - }); - } 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 - ); - - // Détection basique basée sur le mimetype ou l'extension - const isVideo = req.file.mimetype.startsWith("video/") || /\.(mp4|mov|avi|mkv|webm)$/i.test(req.file.originalname); - - // Vignette extraite tant que la vidéo est sur le disque : elle survit à la - // suppression du fichier après publication (historique illustré) - const thumbnailUrl = isVideo ? await createVideoThumbnail(req.file.path) : null; - - // 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, - type: isVideo ? "video" : "image", - thumbnailUrl, - 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; - - // Sans paramètre, la médiathèque entière est renvoyée comme avant : les - // écrans qui doivent retrouver un média par son identifiant en dépendent. - const limit = parsePositiveInt(req.query.limit); - const offset = parsePositiveInt(req.query.offset); - const media = await storage.getMedia(userId, { limit, offset }); - - // Le total permet à l'appelant paginé de savoir s'il reste des pages — - // et à la médiathèque d'afficher un compteur juste sans tout charger. - if (limit !== undefined) { - res.setHeader("X-Total-Count", String(await storage.getMediaCount(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 = { - 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 = ` - - - - ${ribbon.text} - - - `; - - 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 = ` - - - - ${badgeText} - - - `; - - 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, alsoStory, ...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)" }); - } - - // Reel déjà monté : une seule vidéo, publiée telle quelle sur Facebook ou TikTok - if (postType === 'reel') { - if (finalMediaItems.length !== 1) { - return res.status(400).json({ error: "Un Reel nécessite exactement une vidéo" }); - } - const reelMedia = await storage.getMediaById(finalMediaItems[0].mediaId); - if (!reelMedia || reelMedia.type !== 'video' || (user.role !== 'admin' && reelMedia.userId !== userId)) { - return res.status(400).json({ error: "Le média d'un Reel doit être une de vos vidéos" }); - } - for (const pageId of Array.isArray(pageIds) ? pageIds : []) { - const page = await storage.getSocialPage(pageId); - if (!page || (page.platform !== 'facebook' && page.platform !== 'tiktok')) { - return res.status(400).json({ error: "Les Reels ne peuvent être publiés que sur Facebook ou TikTok" }); - } - } - } - - // 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, - }); - // Reel doublé d'une story : seulement sur Facebook, TikTok n'en a pas - if (finalPostType === 'reel' && alsoStory === true) { - const page = await storage.getSocialPage(pageId); - if (page?.platform === 'facebook') { - await storage.createScheduledPost({ - postId: post.id, - pageId, - postType: 'story', - 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" }); - } - }); - - // Agenda : ce qui est déjà prévu par jour et par magasin, Reels en montage compris. - // Sous /api/scheduled-posts pour être rafraîchi avec les publications planifiées. - app.get("/api/scheduled-posts/agenda", requireAuth, async (req, res) => { - try { - const user = req.user as User; - const start = parseDateParam(req.query.startDate); - const end = parseDateParam(req.query.endDate); - - const pages = user.role === 'admin' - ? await storage.getAllSocialPages() - : await storage.getUserAccessiblePages(user.id); - if (pages.length === 0) return res.json([]); - - const pagesById = new Map(pages.map(p => [p.id, { - id: p.id, - pageName: p.pageName, - platform: p.platform, - avatarUrl: p.avatarUrl ?? null, - }])); - - const [scheduled, renderingJobs] = await Promise.all([ - storage.getScheduledPostsByPages(Array.from(pagesById.keys()), start, end), - storage.getRenderingReelJobs(), - ]); - - const items: AgendaItem[] = scheduled.map(agendaItemFromScheduledPost); - // Un rendu qui vient de finir peut avoir déjà créé ses publications. - const plannedPosts = new Set(items.map(i => i.postId)); - for (const job of renderingJobs) { - if (job.postId && plannedPosts.has(job.postId)) continue; - items.push(...agendaItemsFromReelJob(job, pagesById, start, end)); - } - - items.sort((a, b) => a.scheduledAt.localeCompare(b.scheduledAt)); - res.json(items); - } catch (error) { - console.error("Error fetching publication agenda:", error); - res.status(500).json({ error: "Failed to fetch publication agenda" }); - } - }); - - // 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; - - // Une date illisible est ignorée plutôt que transmise telle quelle à SQL. - const start = parseDateParam(startDate); - const end = parseDateParam(endDate); - - let scheduledPosts; - - if (user.role === 'admin') { - // Admin voit tous les posts programmés - on récupère toutes les pages - const allPages = await storage.getAllSocialPages(); - 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 = {}; - - 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 - - /** - * Vue client d'une page : sans jetons, avec un indicateur de renouvellement - * automatique (vrai quand un jeton utilisateur longue durée est enregistré). - */ - function toClientPage(page: SocialPage) { - const { accessToken, userAccessToken, refreshToken, ...safe } = page; - return { - ...safe, - autoRenew: page.platform === 'tiktok' ? !!refreshToken : !!userAccessToken, - }; - } - - 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 - pages = await storage.getAllSocialPages(); - } else { - // Les utilisateurs normaux voient uniquement les pages auxquelles ils ont accès - pages = await storage.getUserAccessiblePages(userId); - } - - // Les jetons n'ont rien à faire dans le navigateur : on ne renvoie que - // l'information dont l'UI a besoin, dont la capacité à se renouveler seule. - res.json(pages.map(toClientPage)); - } 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(toClientPage(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 = { ...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(toClientPage(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; -} diff --git a/server/routes/analytics.ts b/server/routes/analytics.ts deleted file mode 100644 index b265108..0000000 --- a/server/routes/analytics.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { Router } from 'express'; -import { db } from '../db'; -import { AnalyticsService } from '../services/analytics'; -import { TokenManager } from '../services/token_manager'; -import { postAnalytics, pageAnalyticsHistory, socialPages } from '../../shared/schema'; -import { eq, desc } from 'drizzle-orm'; - -const router = Router(); - -// Get Post Analytics -router.get('/posts/:postId', async (req, res) => { - try { - const { postId } = req.params; - const analytics = await db.query.postAnalytics.findFirst({ - where: eq(postAnalytics.postId, postId) - }); - res.json(analytics || {}); - } catch (error) { - res.status(500).json({ error: 'Failed to fetch post analytics' }); - } -}); - -// Refresh Post Analytics -router.post('/posts/:postId/refresh', async (req, res) => { - try { - const { postId } = req.params; - await AnalyticsService.syncPostAnalytics(postId); - const updated = await db.query.postAnalytics.findFirst({ - where: eq(postAnalytics.postId, postId) - }); - res.json(updated); - } catch (error) { - res.status(500).json({ error: 'Failed to refresh post analytics' }); - } -}); - -// Get Page Analytics History -router.get('/pages/:pageId/history', async (req, res) => { - try { - const { pageId } = req.params; - const history = await db.query.pageAnalyticsHistory.findMany({ - where: eq(pageAnalyticsHistory.pageId, pageId), - orderBy: [desc(pageAnalyticsHistory.date)], - limit: 30 // Last 30 entries - }); - res.json(history); - } catch (error) { - res.status(500).json({ error: 'Failed to fetch page history' }); - } -}); - -// Refresh Page Analytics -router.post('/pages/:pageId/refresh', async (req, res) => { - try { - const { pageId } = req.params; - await AnalyticsService.syncPageAnalytics(pageId); - - const page = await db.query.socialPages.findFirst({ - where: eq(socialPages.id, pageId) - }); - res.json(page); - } catch (error: any) { - console.error('Refresh error:', error); - res.status(500).json({ error: error.message || 'Failed to refresh page analytics' }); - } -}); - -// Manual Token Check Trigger -router.post('/tokens/check', async (req, res) => { - try { - // This runs in background, but we can await it for manual trigger - await TokenManager.checkAndRefreshTokens(); - res.json({ success: true, message: 'Token check completed' }); - } catch (error) { - res.status(500).json({ error: 'Token check failed' }); - } -}); - -export const analyticsRouter = router; diff --git a/server/routes/external.ts b/server/routes/external.ts deleted file mode 100644 index 44bdc69..0000000 --- a/server/routes/external.ts +++ /dev/null @@ -1,497 +0,0 @@ -import { Router } from "express"; -import { z } from "zod"; -import path from "path"; -import { storage } from "../storage"; -import { db } from "../db"; -import { postMedia } from "@shared/schema"; -import { minioService } from "../services/minio"; -import { requireApiKey } from "../middleware/apiKey"; - -const router = Router(); - -router.use(requireApiKey); - -const publishSchema = z.object({ - content: z.string().min(1, "Le contenu est requis"), - imageUrl: z.string().url("URL d'image invalide").optional(), - pageIds: z.array(z.string()).min(1, "Au moins une page est requise"), - scheduledAt: z.string().datetime({ offset: true }).optional(), - postType: z.enum(["feed", "story", "both"]).default("feed"), - userId: z.string().optional(), -}); - -const IMAGE_MIME_TYPES: Record = { - "image/jpeg": ".jpg", - "image/png": ".png", - "image/webp": ".webp", - "image/gif": ".gif", -}; - -async function downloadImage(url: string): Promise<{ buffer: Buffer; ext: string; mimeType: string }> { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 10_000); - - let response: Response; - try { - response = await fetch(url, { signal: controller.signal }); - } finally { - clearTimeout(timeout); - } - - if (!response.ok) { - throw new Error(`Impossible de télécharger l'image (HTTP ${response.status})`); - } - - const contentType = response.headers.get("content-type")?.split(";")[0].trim() ?? ""; - const ext = IMAGE_MIME_TYPES[contentType]; - - if (!ext) { - throw new Error(`Type MIME non supporté: ${contentType}. Formats acceptés: JPEG, PNG, WebP, GIF`); - } - - const buffer = Buffer.from(await response.arrayBuffer()); - - const MAX_SIZE = 10 * 1024 * 1024; // 10 MB - if (buffer.length > MAX_SIZE) { - throw new Error(`Image trop volumineuse (${(buffer.length / 1024 / 1024).toFixed(1)} MB, max 10 MB)`); - } - - return { buffer, ext, mimeType: contentType }; -} - -/** - * POST /api/v1/publish - * - * Crée une publication (avec image optionnelle téléchargée depuis une URL) - * et la programme pour une ou plusieurs pages Facebook/Instagram. - * - * Headers: - * X-API-Key: - * - * Body: - * content string Texte de la publication - * imageUrl string? URL publique de l'image à télécharger - * pageIds string[] IDs des pages cibles (social_pages.id) - * scheduledAt ISO8601? Date/heure de publication (absent = immédiat) - * postType feed|story|both Type de publication (défaut: feed) - * userId string? ID utilisateur propriétaire (défaut: premier admin) - */ -router.post("/publish", async (req, res) => { - try { - const parsed = publishSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ - error: "Données invalides", - details: parsed.error.flatten().fieldErrors, - }); - } - - const { content, imageUrl, pageIds, scheduledAt, postType, userId: bodyUserId } = parsed.data; - - // Résoudre l'utilisateur propriétaire - let ownerId = bodyUserId; - if (!ownerId) { - const users = await storage.getAllUsers(); - const admin = users.find((u) => u.role === "admin"); - if (!admin) { - return res.status(500).json({ error: "Aucun utilisateur admin trouvé" }); - } - ownerId = admin.id; - } else { - const user = await storage.getUser(ownerId); - if (!user) { - return res.status(400).json({ error: `Utilisateur introuvable: ${ownerId}` }); - } - } - - // Vérifier que toutes les pages existent - const resolvedPages: Array<{ id: string; pageName: string }> = []; - for (const pageId of pageIds) { - const page = await storage.getSocialPage(pageId); - if (!page) { - return res.status(400).json({ error: `Page introuvable: ${pageId}` }); - } - resolvedPages.push({ id: page.id, pageName: page.pageName }); - } - - // Validation story → image obligatoire - if ((postType === "story" || postType === "both") && !imageUrl) { - return res.status(400).json({ error: "Les stories nécessitent une image (imageUrl requis)" }); - } - - // Télécharger l'image si fournie - let mediaRecord: { id: string; originalUrl: string } | null = null; - if (imageUrl) { - const { buffer, ext, mimeType } = await downloadImage(imageUrl); - - const fileName = `external-${Date.now()}${ext}`; - const uploaded = await minioService.uploadMedia(buffer, fileName, ownerId, mimeType); - - const mediaType: "image" | "video" = mimeType.startsWith("video/") ? "video" : "image"; - mediaRecord = await storage.createMedia({ - userId: ownerId, - type: mediaType, - cloudinaryPublicId: uploaded.publicId, - originalUrl: uploaded.originalUrl, - facebookFeedUrl: uploaded.facebookFeedUrl, - instagramFeedUrl: uploaded.instagramFeedUrl, - instagramStoryUrl: uploaded.instagramStoryUrl, - fileName, - fileSize: buffer.length, - }); - } - - // Créer le post - const scheduledFor = scheduledAt ? new Date(scheduledAt) : null; - - const post = await storage.createPost({ - userId: ownerId, - content, - status: scheduledFor ? "scheduled" : "scheduled", - scheduledFor: scheduledFor ?? new Date(), - aiGenerated: "false", - }); - - // Lier le média si présent - if (mediaRecord) { - await db.insert(postMedia).values({ - postId: post.id, - mediaId: mediaRecord.id, - displayOrder: 0, - }); - } - - // Créer les scheduled_posts par page - const scheduledAt_ = scheduledFor ?? new Date(); - const createdSchedules: Array<{ pageId: string; pageName: string; scheduledPostId: string }> = []; - - for (const page of resolvedPages) { - if (postType === "both") { - const story = await storage.createScheduledPost({ - postId: post.id, - pageId: page.id, - postType: "story", - scheduledAt: scheduledAt_, - }); - const feed = await storage.createScheduledPost({ - postId: post.id, - pageId: page.id, - postType: "feed", - scheduledAt: scheduledAt_, - }); - createdSchedules.push( - { pageId: page.id, pageName: page.pageName, scheduledPostId: story.id }, - { pageId: page.id, pageName: page.pageName, scheduledPostId: feed.id } - ); - } else { - const sp = await storage.createScheduledPost({ - postId: post.id, - pageId: page.id, - postType: postType, - scheduledAt: scheduledAt_, - }); - createdSchedules.push({ pageId: page.id, pageName: page.pageName, scheduledPostId: sp.id }); - } - } - - return res.status(201).json({ - success: true, - post: { - id: post.id, - content: post.content, - status: post.status, - scheduledAt: scheduledAt_, - postType, - pages: createdSchedules, - media: mediaRecord - ? { id: mediaRecord.id, url: mediaRecord.originalUrl } - : null, - }, - }); - } catch (error) { - console.error("[external API] Error:", error); - const message = error instanceof Error ? error.message : "Erreur interne"; - return res.status(500).json({ error: message }); - } -}); - -/** - * GET /api/v1/pages - * - * Liste toutes les pages disponibles (id + nom + plateforme). - * Utile pour connaître les pageIds à passer à /publish. - */ -router.get("/pages", async (_req, res) => { - try { - const users = await storage.getAllUsers(); - const admin = users.find((u) => u.role === "admin"); - if (!admin) { - return res.status(500).json({ error: "Aucun utilisateur admin trouvé" }); - } - - const pages = await storage.getSocialPages(admin.id); - return res.json( - pages.map((p) => ({ - id: p.id, - pageName: p.pageName, - platform: p.platform, - pageId: p.pageId, - tokenStatus: p.tokenStatus, - })) - ); - } catch (error) { - console.error("[external API] Error listing pages:", error); - return res.status(500).json({ error: "Erreur interne" }); - } -}); - -/** - * GET /api/v1/posts - * - * Liste les publications planifiées (à venir). - * - * Query params: - * pageId string? Filtrer par page spécifique - * from ISO8601? Date de début (scheduledAt >= from) - * to ISO8601? Date de fin (scheduledAt <= to) - * status string? Statut du post (défaut: scheduled) - */ -router.get("/posts", async (req, res) => { - try { - const { pageId, from, to, status } = req.query as Record; - const postStatus = status || "scheduled"; - - // Récupérer toutes les pages (ou une seule si filtrée) - let pageIds: string[]; - if (pageId) { - const page = await storage.getSocialPage(pageId); - if (!page) { - return res.status(404).json({ error: `Page introuvable: ${pageId}` }); - } - pageIds = [pageId]; - } else { - pageIds = (await storage.getAllSocialPages()).map(p => p.id); - } - - if (pageIds.length === 0) { - return res.json([]); - } - - const startDate = from ? new Date(from) : undefined; - const endDate = to ? new Date(to) : undefined; - - const scheduledPosts = await storage.getScheduledPostsByPages(pageIds, startDate, endDate); - - // Grouper par postId, filtrer par statut et posts non publiés - const postMap = new Map; - schedules: Array<{ - id: string; - pageId: string; - pageName: string; - platform: string; - postType: string; - scheduledAt: Date; - }>; - }>(); - - for (const sp of scheduledPosts) { - // sp = { ...scheduled_posts, post: Post, page: SocialPage } - const post = sp.post; - if (!post) continue; - - // Ne montrer que les posts non publiés avec le bon statut - if (sp.publishedAt) continue; - if (post.status !== postStatus && postStatus !== "all") continue; - - if (!postMap.has(post.id)) { - // Récupérer les médias du post, en une requête plutôt qu'une par média - const postMediaLinks = await storage.getPostMedia(post.id); - const byId = new Map( - (await storage.getMediaByIds(postMediaLinks.map(link => link.mediaId))) - .map(item => [item.id, item]) - ); - const mediaItems = postMediaLinks - .map(link => byId.get(link.mediaId)) - .filter((item): item is NonNullable => item !== undefined) - .map(item => ({ - id: item.id, - url: item.originalUrl, - type: item.type, - })); - - postMap.set(post.id, { - id: post.id, - content: post.content, - status: post.status, - scheduledFor: post.scheduledFor, - createdAt: post.createdAt, - media: mediaItems, - schedules: [], - }); - } - - const page = sp.page; - postMap.get(post.id)!.schedules.push({ - id: sp.id, - pageId: sp.pageId, - pageName: page?.pageName || "Inconnu", - platform: page?.platform || "facebook", - postType: sp.postType, - scheduledAt: sp.scheduledAt, - }); - } - - return res.json(Array.from(postMap.values())); - } catch (error) { - console.error("[external API] GET /posts error:", error); - return res.status(500).json({ error: "Erreur interne" }); - } -}); - -/** - * PATCH /api/v1/posts/:id - * - * Modifier une publication planifiée. - * - * Body: - * content string? Nouveau texte - * scheduledAt ISO8601? Nouvelle date/heure de publication - * imageUrl string? URL d'une nouvelle image (remplace l'existante) - */ -router.patch("/posts/:id", async (req, res) => { - try { - const { id } = req.params; - const { content, scheduledAt, imageUrl } = req.body; - - // Vérifier que le post existe et est modifiable - const post = await storage.getPost(id); - if (!post) { - return res.status(404).json({ error: "Post introuvable" }); - } - if (post.status === "published") { - return res.status(400).json({ error: "Impossible de modifier un post déjà publié" }); - } - - // Mettre à jour le contenu si fourni - if (content !== undefined) { - await storage.updatePost(id, { content }); - } - - // Mettre à jour la date de planification si fournie - if (scheduledAt !== undefined) { - const newDate = new Date(scheduledAt); - await storage.updatePost(id, { scheduledFor: newDate }); - - // Propager à tous les scheduled_posts du post - const scheduledPosts = await storage.getScheduledPostsByPost(id); - for (const sp of scheduledPosts) { - if (!sp.publishedAt) { - await storage.updateScheduledPost(sp.id, { scheduledAt: newDate }); - } - } - } - - // Remplacer le média si une nouvelle imageUrl est fournie - if (imageUrl !== undefined) { - // Résoudre le userId pour le stockage du média - const ownerId = post.userId; - const { buffer, ext, mimeType } = await downloadImage(imageUrl); - const fileName = `external-edit-${Date.now()}${ext}`; - const uploaded = await minioService.uploadMedia(buffer, fileName, ownerId, mimeType); - const mediaType: "image" | "video" = mimeType.startsWith("video/") ? "video" : "image"; - const mediaRecord = await storage.createMedia({ - userId: ownerId, - type: mediaType, - cloudinaryPublicId: uploaded.publicId, - originalUrl: uploaded.originalUrl, - facebookFeedUrl: uploaded.facebookFeedUrl, - instagramFeedUrl: uploaded.instagramFeedUrl, - instagramStoryUrl: uploaded.instagramStoryUrl, - fileName, - fileSize: buffer.length, - }); - - // Remplacer les médias existants par le nouveau - await storage.updatePostMedia(id, [mediaRecord.id]); - } - - // Retourner le post mis à jour - const updatedPost = await storage.getPost(id); - const postMediaLinks = await storage.getPostMedia(id); - const mediaItems: Array<{ id: string; url: string; type: string }> = []; - for (const link of postMediaLinks) { - const mediaItem = await storage.getMediaById(link.mediaId); - if (mediaItem) { - mediaItems.push({ - id: mediaItem.id, - url: mediaItem.originalUrl, - type: mediaItem.type, - }); - } - } - - const scheduledPosts = await storage.getScheduledPostsByPost(id); - - return res.json({ - success: true, - post: { - id: updatedPost!.id, - content: updatedPost!.content, - status: updatedPost!.status, - scheduledFor: updatedPost!.scheduledFor, - createdAt: updatedPost!.createdAt, - media: mediaItems, - schedules: await Promise.all(scheduledPosts.filter(sp => !sp.publishedAt).map(async sp => { - const page = await storage.getSocialPage(sp.pageId); - return { - id: sp.id, - pageId: sp.pageId, - pageName: page?.pageName || "Inconnu", - platform: page?.platform || "facebook", - postType: sp.postType, - scheduledAt: sp.scheduledAt, - }; - })), - }, - }); - } catch (error) { - console.error("[external API] PATCH /posts/:id error:", error); - const message = error instanceof Error ? error.message : "Erreur interne"; - return res.status(500).json({ error: message }); - } -}); - -/** - * DELETE /api/v1/posts/:id - * - * Supprimer un post planifié et toutes ses planifications associées. - * La suppression en cascade gère les scheduled_posts et post_media. - */ -router.delete("/posts/:id", async (req, res) => { - try { - const { id } = req.params; - - const post = await storage.getPost(id); - if (!post) { - return res.status(404).json({ error: "Post introuvable" }); - } - if (post.status === "published") { - return res.status(400).json({ error: "Impossible de supprimer un post déjà publié" }); - } - - await storage.deletePost(id); - - return res.json({ success: true, deleted: id }); - } catch (error) { - console.error("[external API] DELETE /posts/:id error:", error); - return res.status(500).json({ error: "Erreur interne" }); - } -}); - -export { router as externalRouter }; diff --git a/server/routes/facebook.ts b/server/routes/facebook.ts deleted file mode 100644 index 2f4c742..0000000 --- a/server/routes/facebook.ts +++ /dev/null @@ -1,300 +0,0 @@ -/** - * Connexion et renouvellement des pages Facebook / Instagram. - * - * Une page connectée ici embarque le jeton utilisateur longue durée qui a servi - * à l'obtenir : c'est ce qui permet au `TokenManager` de régénérer son jeton de - * page tout seul, au lieu d'exiger un copier-coller depuis le Graph Explorer - * tous les deux mois. - */ - -import crypto from 'crypto'; -import { Router, Request, Response } from 'express'; -import type { User } from '@shared/schema'; -import { storage } from '../storage'; -import { facebookOAuthService } from '../services/facebook_oauth'; -import { TokenManager } from '../services/token_manager'; -import { encrypt } from '../utils/encryption'; -import { insertFacebookConfigSchema, updateFacebookConfigSchema } from '@shared/schema'; - -export const facebookRouter = Router(); - -/** Page vers laquelle on renvoie le navigateur à la fin du flux OAuth. */ -const RETURN_PATH = '/pages'; - -function isAdmin(req: Request): boolean { - const user = req.user as User | undefined; - return user?.role === 'admin'; -} - -function buildReturnUrl(status: 'success' | 'error', message: string): string { - const params = new URLSearchParams({ facebook: status, message }); - return `${RETURN_PATH}?${params.toString()}`; -} - -/** - * État de la configuration Facebook. - * L'App Secret n'est jamais renvoyé au client. - */ -facebookRouter.get('/config', async (req: Request, res: Response) => { - try { - const config = await storage.getFacebookConfig(); - const configured = await facebookOAuthService.isConfigured(); - - // Tout le monde a besoin de savoir si la connexion est possible ; seuls les - // admins voient les détails de l'application développeur. - if (!isAdmin(req)) { - return res.json({ configured }); - } - - res.json({ - configured, - appId: config?.appId || (process.env.FACEBOOK_APP_ID ? "(défini par variable d'environnement)" : ''), - hasAppSecret: !!(config?.appSecret || process.env.FACEBOOK_APP_SECRET), - redirectUri: facebookOAuthService.getRedirectUri(req), - scopes: facebookOAuthService.getScopes().split(','), - }); - } catch (error) { - console.error('❌ [Facebook] Erreur lecture configuration:', error); - res.status(500).json({ error: 'Impossible de lire la configuration Facebook' }); - } -}); - -/** Enregistre les identifiants de l'application Facebook (admin). */ -facebookRouter.put('/config', async (req: Request, res: Response) => { - try { - if (!isAdmin(req)) { - return res.status(403).json({ error: 'Accès refusé. Réservé aux administrateurs.' }); - } - - const existing = await storage.getFacebookConfig(); - // Le secret peut être omis lors d'une mise à jour : on garde l'existant - const schema = existing ? updateFacebookConfigSchema : insertFacebookConfigSchema; - const data = schema.parse(req.body); - - const config = await storage.upsertFacebookConfig(data); - - res.json({ - success: true, - appId: config.appId, - redirectUri: facebookOAuthService.getRedirectUri(req), - }); - } catch (error: any) { - console.error('❌ [Facebook] Erreur enregistrement configuration:', error); - res.status(400).json({ - error: error?.errors?.[0]?.message || error?.message || 'Configuration Facebook invalide', - }); - } -}); - -/** - * Démarre la connexion des pages Facebook. - * À appeler par navigation complète du navigateur (window.location), pas en fetch. - */ -facebookRouter.get('/connect', async (req: Request, res: Response) => { - try { - if (!(await facebookOAuthService.isConfigured())) { - return res.redirect( - buildReturnUrl('error', "Facebook n'est pas configuré. Renseignez d'abord l'App ID et l'App Secret.") - ); - } - - // Protection CSRF : l'état est vérifié au retour - const state = crypto.randomBytes(16).toString('hex'); - (req.session as any).facebookOAuthState = state; - - const authUrl = await facebookOAuthService.buildAuthorizationUrl(state, req); - - // La session doit être écrite avant la redirection, sinon l'état est perdu - req.session.save((err) => { - if (err) { - console.error('❌ [Facebook] Impossible de sauvegarder la session OAuth:', err); - return res.redirect(buildReturnUrl('error', 'Erreur de session, réessayez.')); - } - res.redirect(authUrl); - }); - } catch (error) { - console.error('❌ [Facebook] Erreur démarrage OAuth:', error); - res.redirect( - buildReturnUrl('error', error instanceof Error ? error.message : 'Erreur de connexion Facebook') - ); - } -}); - -/** - * Retour d'autorisation Facebook : échange le code contre un jeton utilisateur - * longue durée, puis crée ou met à jour chaque page administrée. - */ -facebookRouter.get('/callback', async (req: Request, res: Response) => { - const user = req.user as User; - const { code, state, error: oauthError, error_description: oauthErrorDescription } = req.query; - - try { - if (oauthError) { - const description = (oauthErrorDescription as string) || (oauthError as string); - return res.redirect(buildReturnUrl('error', `Autorisation Facebook refusée : ${description}`)); - } - - const expectedState = (req.session as any)?.facebookOAuthState; - delete (req.session as any)?.facebookOAuthState; - - if (!state || !expectedState || state !== expectedState) { - return res.redirect(buildReturnUrl('error', 'Requête de connexion Facebook invalide (état non reconnu).')); - } - - if (!code || typeof code !== 'string') { - return res.redirect(buildReturnUrl('error', "Aucun code d'autorisation reçu de Facebook.")); - } - - const userToken = await facebookOAuthService.exchangeCodeForUserToken(code, req); - const targets = await facebookOAuthService.listPages(userToken.accessToken); - - if (targets.length === 0) { - return res.redirect( - buildReturnUrl( - 'error', - "Aucune page Facebook administrée par ce compte. Connectez-vous avec un compte administrateur de la page." - ) - ); - } - - // Le jeton utilisateur est la clé du renouvellement automatique : il est - // conservé chiffré, sur chaque page issue de cette autorisation. - const encryptedUserToken = encrypt(userToken.accessToken); - const existingPages = await storage.getSocialPages(user.id); - - let created = 0; - let updated = 0; - - for (const target of targets) { - const existing = existingPages.find( - (page) => page.platform === 'facebook' && page.pageId === target.id - ); - - if (existing) { - await storage.updateSocialPage(existing.id, { - pageName: target.name, - accessToken: target.accessToken, - // Un jeton de page dérivé d'un jeton utilisateur longue durée n'expire pas. - tokenExpiresAt: null, - userAccessToken: encryptedUserToken, - userTokenExpiresAt: userToken.expiresAt, - followersCount: target.followersCount, - tokenStatus: 'valid', - tokenError: null, - lastTokenCheck: new Date(), - isActive: 'true', - }); - updated++; - continue; - } - - const page = await storage.createSocialPage({ - userId: user.id, - platform: 'facebook', - pageId: target.id, - pageName: target.name, - accessToken: target.accessToken, - tokenExpiresAt: null, - userAccessToken: encryptedUserToken, - userTokenExpiresAt: userToken.expiresAt, - followersCount: target.followersCount, - isActive: 'true', - }); - created++; - - // Les non-admins ne voient que les pages qui leur sont explicitement - // attribuées : sans cette permission, ils ne verraient pas leur propre page. - if (user.role !== 'admin') { - try { - await storage.createPagePermission({ userId: user.id, pageId: page.id }); - } catch (permissionError) { - console.error('⚠️ [Facebook] Impossible de créer la permission de page:', permissionError); - } - } - } - - // Un compte Instagram professionnel publie avec le jeton de la page Facebook - // à laquelle il est rattaché : on le rafraîchit dans la foulée, sans quoi il - // resterait sur un jeton saisi à la main, condamné à expirer. - for (const page of existingPages) { - if (page.platform !== 'instagram') continue; - const linked = targets.find((target) => target.instagram?.id === page.pageId); - if (!linked) continue; - - await storage.updateSocialPage(page.id, { - accessToken: linked.accessToken, - tokenExpiresAt: null, - userAccessToken: encryptedUserToken, - userTokenExpiresAt: userToken.expiresAt, - tokenStatus: 'valid', - tokenError: null, - lastTokenCheck: new Date(), - }); - updated++; - } - - const summary = [ - created > 0 ? `${created} page${created > 1 ? 's' : ''} connectée${created > 1 ? 's' : ''}` : null, - updated > 0 ? `${updated} page${updated > 1 ? 's' : ''} mise${updated > 1 ? 's' : ''} à jour` : null, - ] - .filter(Boolean) - .join(', '); - - console.log(`✅ [Facebook] ${summary} pour ${user.username}`); - res.redirect( - buildReturnUrl('success', `${summary}. Les jetons se renouvellent désormais automatiquement.`) - ); - } catch (error) { - console.error('❌ [Facebook] Erreur callback OAuth:', error); - res.redirect( - buildReturnUrl('error', error instanceof Error ? error.message : 'Connexion Facebook impossible') - ); - } -}); - -/** Contrôle (et renouvelle si besoin) le jeton d'une page à la demande. */ -facebookRouter.post('/pages/:id/refresh', async (req: Request, res: Response) => { - try { - const user = req.user as User; - const page = await storage.getSocialPage(req.params.id); - - if (!page) { - return res.status(404).json({ error: 'Page non trouvée' }); - } - - if (user.role !== 'admin' && page.userId !== user.id) { - const accessible = await storage.getUserAccessiblePages(user.id); - if (!accessible.some((p) => p.id === page.id)) { - return res.status(403).json({ error: 'Accès refusé à cette page' }); - } - } - - const result = await TokenManager.checkPage(page.id); - res.json(result); - } catch (error) { - console.error('❌ [Facebook] Erreur renouvellement du jeton:', error); - res.status(502).json({ - error: error instanceof Error ? error.message : 'Impossible de contrôler le jeton', - }); - } -}); - -/** Relance le contrôle de toutes les pages (admin). */ -facebookRouter.post('/tokens/check', async (req: Request, res: Response) => { - try { - if (!isAdmin(req)) { - return res.status(403).json({ error: 'Accès refusé. Réservé aux administrateurs.' }); - } - - const results = await TokenManager.checkAndRefreshTokens(); - res.json({ - checked: results.length, - renewed: results.filter((r) => r.renewed).length, - failing: results.filter((r) => r.status === 'expired' || r.status === 'error').length, - results, - }); - } catch (error) { - console.error('❌ [Facebook] Erreur contrôle global des jetons:', error); - res.status(500).json({ error: 'Contrôle des jetons impossible' }); - } -}); diff --git a/server/routes/legal.ts b/server/routes/legal.ts deleted file mode 100644 index 0a77948..0000000 --- a/server/routes/legal.ts +++ /dev/null @@ -1,269 +0,0 @@ -/** - * Pages légales publiques (conditions d'utilisation et politique de confidentialité). - * - * TikTok exige une URL publique pour chacune lors de la création de l'application - * développeur, et les relecteurs les ouvrent réellement pendant l'audit. Elles sont - * servies en HTML depuis le serveur, sans authentification, pour rester lisibles - * même par un robot qui n'exécute pas le JavaScript du client. - * - * Les mentions de l'exploitant sont préremplies avec l'identité de la société - * exploitante et restent surchargeables par variables d'environnement : - * LEGAL_COMPANY_NAME, LEGAL_TRADE_NAME, LEGAL_COMPANY_LEGAL_FORM, - * LEGAL_COMPANY_RCS, LEGAL_COMPANY_ADDRESS, LEGAL_CONTACT_EMAIL - */ - -import { Router, Request, Response } from 'express'; - -export const legalRouter = Router(); - -function getCompanyInfo() { - return { - name: process.env.LEGAL_COMPANY_NAME || 'FROUARD DISTRIBUTION', - tradeName: process.env.LEGAL_TRADE_NAME || "LA FOIR'FOUILLE", - legalForm: process.env.LEGAL_COMPANY_LEGAL_FORM - || 'Société par actions simplifiée au capital de 50 000 €', - rcs: process.env.LEGAL_COMPANY_RCS || '478 693 518 R.C.S. Nancy', - address: process.env.LEGAL_COMPANY_ADDRESS || '13 rue du Saule Gaillard, 54390 Frouard', - // Aucune adresse de contact ne figure au registre : elle doit être fournie, - // le RGPD imposant un point de contact pour l'exercice des droits. - email: process.env.LEGAL_CONTACT_EMAIL || '[Adresse e-mail de contact à renseigner]', - }; -} - -function escapeHtml(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} - -function renderPage(title: string, body: string): string { - return ` - - - - - ${escapeHtml(title)} - - - -${body} - -`; -} - -function renderFooter(): string { - const company = getCompanyInfo(); - return `
-

- ${escapeHtml(company.name)} – enseigne ${escapeHtml(company.tradeName)}
- ${escapeHtml(company.legalForm)}
- ${escapeHtml(company.address)}
- ${escapeHtml(company.rcs)}
- Contact : ${escapeHtml(company.email)} -

-

Conditions d'utilisation · Politique de confidentialité

-
`; -} - -legalRouter.get('/terms', (_req: Request, res: Response) => { - const company = getCompanyInfo(); - - const body = ` -

Conditions d'utilisation

-

Dernière mise à jour : 11 août 2026

- -

- Cette application est un outil interne de gestion des réseaux sociaux exploité par - ${escapeHtml(company.name)}, ${escapeHtml(company.legalForm)}, dont le siège est situé - ${escapeHtml(company.address)}, immatriculée au ${escapeHtml(company.rcs)}, qui exploite le - magasin sous l'enseigne ${escapeHtml(company.tradeName)} (« nous »). Elle permet à nos équipes - de créer des vidéos et des publications promotionnelles, puis de les diffuser sur les comptes - et pages officiels de nos magasins. -

- -

1. Accès au service

-

- L'accès est réservé aux personnes que nous avons expressément autorisées, au moyen d'un compte - nominatif. Chaque utilisateur est responsable de la confidentialité de ses identifiants et des - actions effectuées depuis son compte. Nous pouvons suspendre ou retirer un accès à tout moment. -

- -

2. Comptes de réseaux sociaux connectés

-

- L'application publie sur des comptes Facebook et TikTok qui nous appartiennent ou que nous - sommes habilités à administrer. En connectant un compte, l'utilisateur déclare disposer des - droits nécessaires pour le faire. Un compte peut être déconnecté à tout moment depuis - l'application, ou par le retrait de l'autorisation directement dans les réglages de la - plateforme concernée. -

- -

3. Contenus publiés

-

- Les utilisateurs restent responsables des contenus qu'ils créent et diffusent. Ces contenus - doivent respecter la loi applicable ainsi que les règles des plateformes de destination, - notamment les Conditions d'utilisation et les Règles communautaires de TikTok et de Meta. - Sont notamment proscrits les contenus dont les utilisateurs ne détiennent pas les droits, - y compris pour les images et les musiques utilisées. -

- -

4. Disponibilité

-

- Le service est fourni « en l'état ». Il dépend de services tiers (plateformes de publication, - hébergement, traitement vidéo) dont les interruptions ou modifications peuvent affecter son - fonctionnement. Nous ne garantissons ni une disponibilité continue, ni la publication effective - d'un contenu par une plateforme tierce, celle-ci pouvant la refuser selon ses propres règles. -

- -

5. Responsabilité

-

- Dans les limites permises par la loi, notre responsabilité ne saurait être engagée au titre des - dommages indirects résultant de l'utilisation du service, notamment une perte de contenu, une - publication manquée ou la suspension d'un compte par une plateforme tierce. -

- -

6. Modification des conditions

-

- Ces conditions peuvent être modifiées. La date de dernière mise à jour figure en tête de page. -

- -

7. Contact

-

Pour toute question relative à ces conditions : ${escapeHtml(company.email)}

- - ${renderFooter()}`; - - res.type('html').send(renderPage("Conditions d'utilisation", body)); -}); - -legalRouter.get('/privacy', (_req: Request, res: Response) => { - const company = getCompanyInfo(); - - const body = ` -

Politique de confidentialité

-

Dernière mise à jour : 11 août 2026

- -

- Cette politique décrit les données traitées par notre outil interne de gestion des réseaux - sociaux. Le responsable de traitement est ${escapeHtml(company.name)}, - ${escapeHtml(company.legalForm)}, dont le siège est situé ${escapeHtml(company.address)}, - immatriculée au ${escapeHtml(company.rcs)}, exploitant le magasin sous l'enseigne - ${escapeHtml(company.tradeName)}. -

- -

1. Données de nos utilisateurs internes

-

- Pour chaque personne autorisée à utiliser l'outil, nous conservons un identifiant de connexion, - un mot de passe stocké sous forme chiffrée, un rôle et la liste des comptes auxquels elle a - accès. Ces données servent uniquement à l'authentification et à la gestion des droits. -

- -

2. Données issues des comptes de réseaux sociaux connectés

-

- Lorsqu'un compte TikTok est connecté, nous recevons de TikTok, avec l'autorisation explicite du - titulaire du compte et dans le cadre du périmètre user.info.basic : -

-
    -
  • l'identifiant technique du compte (open_id) ;
  • -
  • le nom d'affichage du compte ;
  • -
  • l'image de profil du compte ;
  • -
  • un jeton d'accès et un jeton de renouvellement, qui nous permettent de publier en son nom.
  • -
-

- Nous procédons de manière équivalente pour les pages Facebook connectées. Nous ne collectons ni - la liste des abonnés, ni les messages privés, ni les données de navigation des visiteurs de ces - comptes. -

- -

3. Finalités

-

- Ces données sont utilisées exclusivement pour identifier le compte de destination dans - l'interface et y publier les vidéos et publications créées par nos équipes, immédiatement ou à - l'heure programmée. Elles ne servent à aucune autre finalité. -

- -

4. Données que nous transmettons aux plateformes

-

- Lors d'une publication, nous transmettons à la plateforme concernée le fichier vidéo ou image, - le texte de la publication, ainsi que les paramètres de diffusion associés (niveau de - confidentialité, autorisation des commentaires). Ces contenus sont ensuite régis par la - politique de confidentialité de la plateforme concernée. -

- -

5. Conservation et sécurité

-
    -
  • Les jetons d'accès et de renouvellement sont chiffrés au repos (AES-256-GCM).
  • -
  • - Ils sont conservés tant que le compte reste connecté à l'application, et supprimés - immédiatement lorsque le compte en est retiré. -
  • -
  • Les médias sources sont supprimés automatiquement après leur publication et leur péremption.
  • -
  • L'accès à l'application est restreint aux personnes autorisées et protégé par mot de passe.
  • -
- -

6. Partage avec des tiers

-

- Nous ne vendons ni ne louons ces données, et ne les transmettons à aucun tiers à des fins - publicitaires ou de profilage. Elles ne sont communiquées qu'aux plateformes de destination - (TikTok, Meta) dans la stricte mesure nécessaire à la publication demandée, ainsi qu'à nos - prestataires techniques d'hébergement et de traitement vidéo agissant sur nos instructions. -

- -

7. Retirer une autorisation

-

- Le titulaire d'un compte connecté peut retirer son autorisation à tout moment : -

-
    -
  • en demandant la déconnexion du compte depuis l'application, ce qui supprime les jetons ;
  • -
  • - ou directement dans TikTok, via Profil › Paramètres et confidentialité › - Sécurité et autorisations › Gérer les autorisations des applications. -
  • -
-

- Le retrait de l'autorisation empêche toute publication ultérieure. Il n'entraîne pas la - suppression des contenus déjà publiés sur la plateforme, qui doivent être supprimés depuis - celle-ci. -

- -

8. Vos droits

-

- Conformément au Règlement général sur la protection des données, vous disposez d'un droit - d'accès, de rectification, d'effacement, de limitation et de portabilité des données vous - concernant, ainsi que du droit d'introduire une réclamation auprès de la CNIL. Pour exercer ces - droits, écrivez à ${escapeHtml(company.email)}. -

- -

9. Modification de cette politique

-

- Cette politique peut être mise à jour. La date de dernière mise à jour figure en tête de page. -

- - ${renderFooter()}`; - - res.type('html').send(renderPage('Politique de confidentialité', body)); -}); diff --git a/server/routes/reels.ts b/server/routes/reels.ts deleted file mode 100644 index 851625a..0000000 --- a/server/routes/reels.ts +++ /dev/null @@ -1,470 +0,0 @@ -/** - * Routes API pour les Reels Facebook et la musique Jamendo - */ - -import { Router, Request, Response } from 'express'; -import type { User } from '@shared/schema'; -import { storage } from '../storage'; -import { ffmpegService, FFmpegServiceError } from '../services/ffmpeg'; -import { ttsPreviewSchema, videoReelParamsSchema, type VideoReelParams } from '@shared/reel'; -import { srtText } from '@shared/srt'; -import { enqueueReelJob, countActiveReelJobs } from '../services/reels/queue'; -import { - qwenTargetFromConfig, - resolveGeminiApiKey, - resolveLogoPath, - resolveQwenTarget, - resolveStoreName, -} from '../services/reels/assets'; -import { configuredRenderer } from '../services/reels/videoPipeline'; -import { openRouterService, describeGenerationError } from '../services/openrouter'; - -import { estimateVoiceTiming } from '../services/ttsSync'; -/** Piste musicale telle qu'attendue par le client. */ -interface MusicTrack { - id: string; - title: string; - artist: string; - albumName: string; - duration: number; - previewUrl: string; - downloadUrl: string; - imageUrl: string; - license: string; -} - -export const reelsRouter = Router(); - -// ============================================ -// ROUTES MUSIQUE JAMENDO -// ============================================ - -/** - * Recherche de musiques par durée - * GET /api/music/search?minDuration=10&maxDuration=60&genre=pop&limit=10 - */ -reelsRouter.get('/music/search', async (req: Request, res: Response) => { - try { - const { - minDuration = '10', - maxDuration = '120', - genre, - limit = '10', - offset = '0', - search, - } = req.query; - - res.json({ - tracks: [], - pagination: { - offset: parseInt(offset as string), - limit: parseInt(limit as string), - hasMore: false, - }, - }); - } catch (error) { - console.error('❌ Error searching music:', error); - res.status(500).json({ error: 'Erreur lors de la recherche de musique' }); - } -}); - -/** - * Voir plus de musiques (pagination) - * GET /api/music/more?offset=10&minDuration=10&maxDuration=60 - */ -reelsRouter.get('/music/more', async (req: Request, res: Response) => { - try { - const { - minDuration = '10', - maxDuration = '120', - genre, - limit = '10', - offset = '0', - search, - } = req.query; - - res.json({ - tracks: [], - pagination: { - offset: parseInt(offset as string), - limit: parseInt(limit as string), - hasMore: false, - }, - }); - } catch (error) { - console.error('❌ Error loading more music:', error); - res.status(500).json({ error: 'Erreur lors du chargement des musiques' }); - } -}); - -/** - * Obtenir les tracks populaires - * GET /api/music/popular - */ -reelsRouter.get('/music/popular', async (req: Request, res: Response) => { - try { - res.json({ tracks: [] }); - } catch (error) { - console.error('❌ Error fetching popular music:', error); - res.status(500).json({ error: 'Erreur lors de la récupération des musiques populaires' }); - } -}); - -// ============================================ -// ROUTES FAVORIS MUSIQUE -// ============================================ - -/** - * Obtenir les favoris de l'utilisateur - * GET /api/music/favorites - */ -reelsRouter.get('/music/favorites', async (req: Request, res: Response) => { - try { - const user = req.user as User; - const favorites = await storage.getMusicFavorites(user.id); - - // Convertir en format MusicTrack pour le client - const tracks: MusicTrack[] = favorites.map(f => ({ - id: f.trackId, - title: f.title, - artist: f.artist, - albumName: f.albumName || '', - duration: f.duration, - previewUrl: f.previewUrl, - downloadUrl: f.downloadUrl, - imageUrl: f.imageUrl || '', - license: f.license || '', - })); - - res.json({ tracks, isFavorites: true }); - } catch (error) { - console.error('❌ Error fetching music favorites:', error); - res.status(500).json({ error: 'Erreur lors de la récupération des favoris' }); - } -}); - -/** - * Vérifier si un track est en favori - * GET /api/music/favorites/check/:trackId - */ -reelsRouter.get('/music/favorites/check/:trackId', async (req: Request, res: Response) => { - try { - const user = req.user as User; - const { trackId } = req.params; - - const isFavorite = await storage.isMusicFavorite(user.id, trackId); - res.json({ isFavorite }); - } catch (error) { - console.error('❌ Error checking music favorite:', error); - res.status(500).json({ error: 'Erreur lors de la vérification du favori' }); - } -}); - -/** - * Ajouter un favori - * POST /api/music/favorites - */ -reelsRouter.post('/music/favorites', async (req: Request, res: Response) => { - try { - const user = req.user as User; - const { trackId, title, artist, albumName, duration, previewUrl, downloadUrl, imageUrl, license } = req.body; - - if (!trackId || !title) { - return res.status(400).json({ error: 'trackId et title requis' }); - } - - // Vérifier si déjà en favori - const alreadyFavorite = await storage.isMusicFavorite(user.id, trackId); - if (alreadyFavorite) { - return res.json({ success: true, message: 'Déjà en favori' }); - } - - await storage.addMusicFavorite({ - userId: user.id, - trackId, - title, - artist: artist || '', - albumName: albumName || null, - duration: duration || 0, - previewUrl: previewUrl || '', - downloadUrl: downloadUrl || '', - imageUrl: imageUrl || null, - license: license || null, - }); - - res.json({ success: true }); - } catch (error) { - console.error('❌ Error adding music favorite:', error); - res.status(500).json({ error: 'Erreur lors de l\'ajout du favori' }); - } -}); - -/** - * Supprimer un favori - * DELETE /api/music/favorites/:trackId - */ -reelsRouter.delete('/music/favorites/:trackId', async (req: Request, res: Response) => { - try { - const user = req.user as User; - const { trackId } = req.params; - - await storage.removeMusicFavorite(user.id, trackId); - res.json({ success: true }); - } catch (error) { - console.error('❌ Error removing music favorite:', error); - res.status(500).json({ error: 'Erreur lors de la suppression du favori' }); - } -}); - -// ============================================ -// ROUTE DÉTAILS TRACK (après les routes /music/favorites et /music/popular) -// ============================================ - -/** - * Détails d'un track - * GET /api/music/:trackId - */ -reelsRouter.get('/music/:trackId', async (req: Request, res: Response) => { - try { - const { trackId } = req.params; - return res.status(404).json({ error: 'Musique non trouvée' }); - } catch (error) { - console.error('❌ Error fetching music details:', error); - res.status(500).json({ error: 'Erreur lors de la récupération des détails' }); - } -}); - -// ============================================ -// ROUTES REELS -// ============================================ - -/** - * Générer des textes IA pour un Reel (utilise le même service que les posts) - * POST /api/reels/generate-text - */ -reelsRouter.post('/reels/generate-text', async (req: Request, res: Response) => { - try { - const user = req.user as User; - const { productInfo, model } = req.body; - - if (!productInfo) { - return res.status(400).json({ error: 'Informations produit requises' }); - } - - // Convertir la chaîne de texte simple en objet ProductInfo - // Si l'utilisateur envoie une chaîne, on l'utilise comme description du produit - const productInfoObject = typeof productInfo === 'string' - ? { - name: productInfo.trim(), // Utiliser le texte comme nom - description: productInfo.trim(), // Et comme description - } - : productInfo; - - const generatedTexts = await openRouterService.generatePostText(productInfoObject, user.id, model); - - // Sauvegarder la génération - await storage.createAiGeneration({ - userId: user.id, - productInfo: productInfoObject, - generatedTexts, - }); - - res.json({ variants: generatedTexts }); - } catch (error) { - console.error('❌ Error generating Reel text:', error); - const { status, message } = describeGenerationError(error); - res.status(status).json({ error: message }); - } -}); - -/** - * Voix disponibles (dont les voix clonées Qwen) et disponibilité du moteur local - * GET /api/reels/voices - */ -reelsRouter.get('/reels/voices', async (_req: Request, res: Response) => { - try { - const catalog = await ffmpegService.listVoices(qwenTargetFromConfig(await storage.getAppConfig())); - res.json({ qwenAvailable: catalog.qwen_available, qwen: catalog.qwen, qwenStatus: catalog.qwen_status }); - } catch (error) { - console.error('❌ Error listing voices:', error); - res.json({ qwenAvailable: false, qwen: [], qwenStatus: null }); - } -}); - -/** - * Prévisualiser la voix TTS - * POST /api/reels/tts-preview - */ -reelsRouter.post('/reels/tts-preview', async (req: Request, res: Response) => { - const parsed = ttsPreviewSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Paramètres invalides' }); - } - const { text, ttsVoice, ttsEngine, ttsStyle } = parsed.data; - - try { - const preview = await ffmpegService.previewVoice(text, { - voice: ttsVoice, - engine: ttsEngine, - style: ttsStyle, - geminiApiKey: await resolveGeminiApiKey(ttsEngine), - qwen: await resolveQwenTarget(ttsEngine), - }); - res.json({ - success: true, - audioBase64: preview.audio.toString('base64'), - duration: preview.duration, - words: preview.words, - engine: preview.engine, - voice: preview.voice, - warnings: preview.warnings, - }); - } catch (error) { - console.error('❌ Error generating TTS preview:', error); - const message = error instanceof Error ? error.message : 'Erreur lors de la génération de la voix'; - res.status(error instanceof FFmpegServiceError && error.status === 400 ? 400 : 502).json({ error: message }); - } -}); - -/** - * Calculer la synchronisation texte/voix TTS - * POST /api/reels/sync-info - */ -reelsRouter.post('/reels/sync-info', async (req: Request, res: Response) => { - try { - const { text } = req.body; - if (!text) { - return res.status(400).json({ error: 'Texte requis' }); - } - // Estimation locale : ne déclenche aucune synthèse (payante avec Gemini) - res.json(estimateVoiceTiming(text)); - } catch (error) { - console.error('❌ Error calculating sync:', error); - res.status(500).json({ error: 'Erreur de calcul de synchronisation' }); - } -}); - -/** - * Créer et publier un Reel : le rendu part dans la file persistante. - * POST /api/reels - */ -reelsRouter.post('/reels', async (req: Request, res: Response) => { - try { - const user = req.user as User; - const parsed = videoReelParamsSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Paramètres invalides' }); - } - - const { srtCues } = parsed.data; - const params: VideoReelParams = { - ...parsed.data, - // Avec un SRT, le texte libre n'est plus utilisé : la voix lit les sous-titres - overlayText: srtCues ? undefined : parsed.data.overlayText, - description: parsed.data.description || (srtCues ? srtText(srtCues) : undefined), - storeName: await resolveStoreName(user.id, parsed.data.pageIds[0]), - }; - - const waiting = await countActiveReelJobs(); - - const post = await storage.createPost({ - userId: user.id, - content: params.description || params.overlayText || '', - aiGenerated: 'false', - status: params.scheduledFor ? 'scheduled' : 'draft', - scheduledFor: params.scheduledFor ? new Date(params.scheduledFor) : undefined, - generationStatus: 'pending', - generationProgress: 0, - }); - - await enqueueReelJob({ kind: 'video', userId: user.id, postId: post.id, params }); - console.log(`✨ Reel en file. Post ${post.id}, ${waiting} job(s) avant lui.`); - - res.json({ - success: true, - postId: post.id, - queued: waiting > 0, - message: waiting > 0 - ? "File d'attente occupée. Votre vidéo sera traitée dès que possible." - : 'Traitement démarré en arrière-plan.', - results: [], - videoUrl: '', - }); - } catch (error) { - console.error('❌ Error initiating Reel:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Erreur lors de l\'initialisation du Reel', - }); - } -}); - -/** - * Obtenir la configuration des services Reels - * GET /api/reels/config - */ -reelsRouter.get('/reels/config', async (req: Request, res: Response) => { - try { - const [ffmpegConfigured, logoUrl] = await Promise.all([ - ffmpegService.healthCheck().catch(() => false), - resolveLogoPath(), - ]); - - res.json({ - jamendo: { configured: false }, - freesound: { configured: false }, - ffmpeg: { configured: ffmpegConfigured }, - // Aperçu : logo affiché par le lecteur, moteur de rendu utilisé - logoUrl: logoUrl ?? null, - renderer: configuredRenderer(), - }); - } catch (error) { - console.error('❌ Error fetching Reels config:', error); - res.status(500).json({ error: 'Erreur lors de la récupération de la configuration' }); - } -}); - -/** - * Reels en cours de génération - * GET /api/reels/ongoing - */ -reelsRouter.get('/reels/ongoing', async (req: Request, res: Response) => { - try { - const user = req.user as User; - const ongoing = await storage.getOngoingReelPosts(user.id); - res.json(ongoing); - } catch (error) { - console.error('❌ Error fetching ongoing reels:', error); - res.status(500).json({ error: 'Erreur lors de la récupération des reels en cours' }); - } -}); - -/** - * Supprimer un Reel - * DELETE /api/reels/:id - */ -reelsRouter.delete('/reels/:id', async (req: Request, res: Response) => { - try { - const user = req.user as User; - const { id } = req.params; - - // Vérifier si le post existe - const post = await storage.getPost(id); - if (!post) { - return res.status(404).json({ error: 'Reel non trouvé' }); - } - - // Vérifier les permissions (admin ou propriétaire) - if (user.role !== 'admin' && post.userId !== user.id) { - return res.status(403).json({ error: 'Non autorisé à supprimer ce Reel' }); - } - - // Supprimer le post (cascade supprimera scheduled_posts et liens media) - await storage.deletePost(id); - - res.json({ success: true, message: 'Reel supprimé avec succès' }); - } catch (error) { - console.error('❌ Error deleting Reel:', error); - res.status(500).json({ error: 'Erreur lors de la suppression du Reel' }); - } -}); diff --git a/server/routes/tiktok.ts b/server/routes/tiktok.ts deleted file mode 100644 index 2264354..0000000 --- a/server/routes/tiktok.ts +++ /dev/null @@ -1,226 +0,0 @@ -/** - * Routes de gestion des comptes TikTok. - * - * Un compte TikTok de magasin se connecte en OAuth (TikTok n'autorise pas la - * saisie manuelle d'un token), puis vit dans `social_pages` comme une page - * Facebook : mêmes permissions, même planification, même calendrier. - */ - -import crypto from 'crypto'; -import { Router, Request, Response } from 'express'; -import type { User } from '@shared/schema'; -import { storage } from '../storage'; -import { tiktokService } from '../services/tiktok'; -import { insertTiktokConfigSchema, updateTiktokConfigSchema } from '@shared/schema'; - -export const tiktokRouter = Router(); - -/** Page vers laquelle on renvoie le navigateur à la fin du flux OAuth. */ -const RETURN_PATH = '/pages'; - -function isAdmin(req: Request): boolean { - const user = req.user as User | undefined; - return user?.role === 'admin'; -} - -function buildReturnUrl(status: 'success' | 'error', message: string): string { - const params = new URLSearchParams({ tiktok: status, message }); - return `${RETURN_PATH}?${params.toString()}`; -} - -/** - * État de la configuration TikTok. - * Le client secret n'est jamais renvoyé au client. - */ -tiktokRouter.get('/config', async (req: Request, res: Response) => { - try { - const config = await storage.getTiktokConfig(); - const configured = await tiktokService.isConfigured(); - - // Tout le monde a besoin de savoir si la connexion est possible ; seuls les - // admins voient les détails de l'application développeur. - if (!isAdmin(req)) { - return res.json({ configured }); - } - - res.json({ - configured, - clientKey: config?.clientKey || (process.env.TIKTOK_CLIENT_KEY ? '(défini par variable d\'environnement)' : ''), - hasClientSecret: !!(config?.clientSecret || process.env.TIKTOK_CLIENT_SECRET), - redirectUri: tiktokService.getRedirectUri(req), - }); - } catch (error) { - console.error('❌ [TikTok] Erreur lecture configuration:', error); - res.status(500).json({ error: 'Impossible de lire la configuration TikTok' }); - } -}); - -/** Enregistre les identifiants de l'application développeur TikTok (admin). */ -tiktokRouter.put('/config', async (req: Request, res: Response) => { - try { - if (!isAdmin(req)) { - return res.status(403).json({ error: 'Accès refusé. Réservé aux administrateurs.' }); - } - - const existing = await storage.getTiktokConfig(); - // Le secret peut être omis lors d'une mise à jour : on garde l'existant - const schema = existing ? updateTiktokConfigSchema : insertTiktokConfigSchema; - const data = schema.parse(req.body); - - const config = await storage.upsertTiktokConfig(data); - - res.json({ - success: true, - clientKey: config.clientKey, - redirectUri: tiktokService.getRedirectUri(req), - }); - } catch (error: any) { - console.error('❌ [TikTok] Erreur enregistrement configuration:', error); - res.status(400).json({ - error: error?.errors?.[0]?.message || error?.message || 'Configuration TikTok invalide', - }); - } -}); - -/** - * Démarre la connexion d'un compte TikTok. - * À appeler par navigation complète du navigateur (window.location), pas en fetch. - */ -tiktokRouter.get('/connect', async (req: Request, res: Response) => { - try { - if (!(await tiktokService.isConfigured())) { - return res.redirect( - buildReturnUrl('error', "TikTok n'est pas configuré. Renseignez d'abord le client key et le client secret.") - ); - } - - // Protection CSRF : l'état est vérifié au retour - const state = crypto.randomBytes(16).toString('hex'); - (req.session as any).tiktokOAuthState = state; - - const authUrl = await tiktokService.buildAuthorizationUrl(state, req); - - // La session doit être écrite avant la redirection, sinon l'état est perdu - req.session.save((err) => { - if (err) { - console.error('❌ [TikTok] Impossible de sauvegarder la session OAuth:', err); - return res.redirect(buildReturnUrl('error', 'Erreur de session, réessayez.')); - } - res.redirect(authUrl); - }); - } catch (error) { - console.error('❌ [TikTok] Erreur démarrage OAuth:', error); - res.redirect( - buildReturnUrl('error', error instanceof Error ? error.message : 'Erreur de connexion TikTok') - ); - } -}); - -/** - * Retour d'autorisation TikTok : échange le code, puis crée ou met à jour le - * compte dans social_pages. - */ -tiktokRouter.get('/callback', async (req: Request, res: Response) => { - const user = req.user as User; - const { code, state, error: oauthError, error_description: oauthErrorDescription } = req.query; - - try { - if (oauthError) { - const description = (oauthErrorDescription as string) || (oauthError as string); - return res.redirect(buildReturnUrl('error', `Autorisation TikTok refusée : ${description}`)); - } - - const expectedState = (req.session as any)?.tiktokOAuthState; - delete (req.session as any)?.tiktokOAuthState; - - if (!state || !expectedState || state !== expectedState) { - return res.redirect(buildReturnUrl('error', 'Requête de connexion TikTok invalide (état non reconnu).')); - } - - if (!code || typeof code !== 'string') { - return res.redirect(buildReturnUrl('error', "Aucun code d'autorisation reçu de TikTok.")); - } - - const tokens = await tiktokService.exchangeCodeForToken(code, req); - const profile = await tiktokService.getUserInfo(tokens.access_token); - const openId = profile.openId || tokens.open_id; - - // Un compte déjà connecté est mis à jour plutôt que dupliqué - const existingPages = await storage.getSocialPages(user.id); - const existing = existingPages.find(p => p.platform === 'tiktok' && p.pageId === openId); - - if (existing) { - await storage.updateSocialPage(existing.id, { - pageName: profile.displayName, - avatarUrl: profile.avatarUrl || null, - isActive: 'true', - }); - await tiktokService.persistTokens(existing.id, tokens); - - console.log(`🔁 [TikTok] Compte reconnecté: ${profile.displayName}`); - return res.redirect(buildReturnUrl('success', `Compte TikTok "${profile.displayName}" reconnecté.`)); - } - - const page = await storage.createSocialPage({ - userId: user.id, - platform: 'tiktok', - pageId: openId, - pageName: profile.displayName, - accessToken: tokens.access_token, - tokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000), - refreshToken: tokens.refresh_token, - refreshTokenExpiresAt: new Date(Date.now() + tokens.refresh_expires_in * 1000), - scopes: tokens.scope, - avatarUrl: profile.avatarUrl || null, - isActive: 'true', - }); - - // Les non-admins ne voient que les pages qui leur sont explicitement - // attribuées : sans cette permission, ils ne verraient pas leur propre compte. - if (user.role !== 'admin') { - try { - await storage.createPagePermission({ userId: user.id, pageId: page.id }); - } catch (permissionError) { - console.error('⚠️ [TikTok] Impossible de créer la permission de page:', permissionError); - } - } - - console.log(`✅ [TikTok] Nouveau compte connecté: ${profile.displayName} (${openId})`); - res.redirect(buildReturnUrl('success', `Compte TikTok "${profile.displayName}" connecté.`)); - } catch (error) { - console.error('❌ [TikTok] Erreur callback OAuth:', error); - res.redirect( - buildReturnUrl('error', error instanceof Error ? error.message : 'Connexion TikTok impossible') - ); - } -}); - -/** - * Options réellement autorisées sur un compte (confidentialité, commentaires, - * duo, stitch, durée max). TikTok impose de les afficher avant publication. - */ -tiktokRouter.get('/accounts/:id/creator-info', async (req: Request, res: Response) => { - try { - const user = req.user as User; - const page = await storage.getSocialPage(req.params.id); - - if (!page || page.platform !== 'tiktok') { - return res.status(404).json({ error: 'Compte TikTok non trouvé' }); - } - - if (user.role !== 'admin' && page.userId !== user.id) { - const accessible = await storage.getUserAccessiblePages(user.id); - if (!accessible.some(p => p.id === page.id)) { - return res.status(403).json({ error: 'Accès refusé à ce compte' }); - } - } - - const creatorInfo = await tiktokService.getCreatorInfo(page); - res.json(creatorInfo); - } catch (error) { - console.error('❌ [TikTok] Erreur récupération creator info:', error); - res.status(502).json({ - error: error instanceof Error ? error.message : 'Impossible de contacter TikTok', - }); - } -}); diff --git a/server/services/analytics.ts b/server/services/analytics.ts deleted file mode 100644 index cf8d1c6..0000000 --- a/server/services/analytics.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { db } from '../db'; -import { - posts, - scheduledPosts, - postAnalytics, - pageAnalyticsHistory, - socialPages, - tokenStatusEnum -} from '../../shared/schema'; -import { eq, and, isNotNull, desc, gte, lt } from 'drizzle-orm'; -import { GraphAPIClient } from '../utils/graph_client'; -import { TokenManager } from './token_manager'; - -export class AnalyticsService { - - /** - * Fetches latest metrics for a specific post from Facebook/Instagram - * and updates the local postAnalytics table. - * Handles aggregation if a single Post content was published to multiple destinations. - */ - static async syncPostAnalytics(postId: string) { - console.log(`[AnalyticsService] Syncing post ${postId}`); - - // Find all published instances of this post with an external ID - const publishedInstances = await db.query.scheduledPosts.findMany({ - where: and( - eq(scheduledPosts.postId, postId), - isNotNull(scheduledPosts.externalPostId) - ), - with: { - page: true - } - }); - - if (publishedInstances.length === 0) { - console.log(`[AnalyticsService] No published external posts found for ${postId}`); - return; - } - - let totalImpressions = 0; - let totalReach = 0; - let totalEngagement = 0; - let totalReactions = 0; - let totalComments = 0; - let totalShares = 0; - let totalClicks = 0; - const rawDataList: any[] = []; - - for (const instance of publishedInstances) { - if (!instance.externalPostId || !instance.page) continue; - // Les métriques TikTok ne sont pas exposées par la Graph API - if (instance.page.platform === 'tiktok') continue; - - try { - // We need the page access token - let accessToken = instance.page.accessToken; - // Decrypt if needed - if (accessToken.includes(':')) { - try { accessToken = TokenManager.decrypt(accessToken); } catch { } - } - - // Determine fields based on platform (assuming Facbeook for now as per context) - // providing a generic list of fields - const fields = 'insights.metric(post_impressions,post_impressions_unique,post_engaged_users,post_clicks),shares,comments.summary(true),reactions.summary(true)'; - - const result = await GraphAPIClient.get(instance.externalPostId, { - accessToken, - params: { fields } - }); - - rawDataList.push({ id: instance.externalPostId, data: result }); - - // Parse Metrics - // Note: Graph API structure for insights is complex (data array). - // For simplicity in this implementation, we will try to extract what we can. - // This is a simplified mapper. - - const insights = result.insights?.data || []; - const findMetric = (name: string) => { - const m = insights.find((i: any) => i.name === name); - return m ? (m.values[0]?.value || 0) : 0; - }; - - const impressions = findMetric('post_impressions'); - const reach = findMetric('post_impressions_unique'); - const engagement = findMetric('post_engaged_users'); - const clicks = findMetric('post_clicks'); // might be 'post_clicks_by_type' - - const reactionCount = result.reactions?.summary?.total_count || 0; - const commentCount = result.comments?.summary?.total_count || 0; - const shareCount = result.shares?.count || 0; - - totalImpressions += impressions; - totalReach += reach; - totalEngagement += engagement; - totalClicks += clicks; // Aggregation might be complex for unique values, but sum is okay for now - totalReactions += reactionCount; - totalComments += commentCount; - totalShares += shareCount; - - } catch (error) { - console.error(`[AnalyticsService] Failed to fetch for instance ${instance.id}:`, error); - } - } - - // Update DB - // Check if exists - const existing = await db.query.postAnalytics.findFirst({ - where: eq(postAnalytics.postId, postId) - }); - - if (existing) { - await db.update(postAnalytics) - .set({ - fetchedAt: new Date(), - impressions: totalImpressions, - reach: totalReach, - engagement: totalEngagement, - reactions: totalReactions, - comments: totalComments, - shares: totalShares, - clicks: totalClicks, - rawData: rawDataList - }) - .where(eq(postAnalytics.id, existing.id)); - } else { - await db.insert(postAnalytics).values({ - postId, - fetchedAt: new Date(), - impressions: totalImpressions, - reach: totalReach, - engagement: totalEngagement, - reactions: totalReactions, - comments: totalComments, - shares: totalShares, - clicks: totalClicks, - rawData: rawDataList - }); - } - } - - /** - * Syncs page-level analytics (Followers, Reach, Engagement, Views) - * Each metric is fetched independently for maximum resilience. - */ - static async syncPageAnalytics(pageId: string) { - console.log(`[AnalyticsService] Syncing page ${pageId}`); - - const page = await db.query.socialPages.findFirst({ - where: eq(socialPages.id, pageId) - }); - - if (!page) return; - - let accessToken = page.accessToken; - if (accessToken.includes(':')) { - try { accessToken = TokenManager.decrypt(accessToken); } catch { } - } - - // 1. Fetch Basic Page Info (Followers/Fans) — Essential - // If this fails, the token is likely invalid → abort - let followers = page.followersCount || 0; - try { - const basicInfo = await GraphAPIClient.get(page.pageId, { - accessToken, - params: { fields: 'fan_count' } - }); - if (basicInfo.fan_count !== undefined) { - followers = basicInfo.fan_count; - } - } catch (error: any) { - console.error(`[AnalyticsService] Failed to fetch basic info for ${pageId}:`, error); - - const tokenPreview = accessToken ? `${accessToken.substring(0, 10)}...` : 'undefined'; - console.log(`[AnalyticsService] DEBUG: Token used: ${tokenPreview}`); - - // Auth errors → mark token as expired and abort - if (error.code === 190 || error.code === 463 || error.code === 467) { - console.log(`[AnalyticsService] Marking token as EXPIRED for page ${pageId}`); - await db.update(socialPages) - .set({ - tokenStatus: "expired", - lastTokenCheck: new Date() - }) - .where(eq(socialPages.id, pageId)); - return; - } - - throw new Error(`Failed to fetch Basic Info: ${error.message || error}`); - } - - // Helper: fetch a single page insight metric safely - const fetchInsight = async (metricName: string): Promise => { - try { - const result = await GraphAPIClient.get( - `${page.pageId}/insights/${metricName}`, - { - accessToken, - params: { period: 'day' } - } - ); - const data = result.data || []; - if (data.length > 0) { - const values = data[0].values || []; - // Take the most recent value (last entry = yesterday) - return values.length > 0 - ? (values[values.length - 1]?.value || 0) - : 0; - } - return 0; - } catch (error: any) { - if (error.code === 100) { - console.warn(`[AnalyticsService] Metric "${metricName}" unavailable for ${pageId} (Code 100). Skipping.`); - } else { - console.warn(`[AnalyticsService] Failed to fetch "${metricName}" for ${pageId}:`, error.message || error); - } - return 0; - } - }; - - // 2. Fetch each insight independently (resilient) - const pageReach = await fetchInsight('page_impressions_unique'); - const pageEngagement = await fetchInsight('page_post_engagements'); - const pageViews = await fetchInsight('page_views_total'); - - console.log(`[AnalyticsService] Metrics: followers=${followers}, reach=${pageReach}, engagement=${pageEngagement}, views=${pageViews}`); - - // 3. Update Page — auto-heal token status - await db.update(socialPages) - .set({ - followersCount: followers, - tokenStatus: 'valid', - lastTokenCheck: new Date() - }) - .where(eq(socialPages.id, pageId)); - - // 4. Upsert History snapshot — one entry per day per page - const today = new Date(); - today.setHours(0, 0, 0, 0); - const tomorrow = new Date(today); - tomorrow.setDate(tomorrow.getDate() + 1); - - const existingEntry = await db.query.pageAnalyticsHistory.findFirst({ - where: and( - eq(pageAnalyticsHistory.pageId, pageId), - gte(pageAnalyticsHistory.date, today), - lt(pageAnalyticsHistory.date, tomorrow) - ) - }); - - if (existingEntry) { - // Update today's existing entry with latest values - await db.update(pageAnalyticsHistory) - .set({ - followersCount: followers, - pageReach, - pageViews, - pageEngagement, - date: new Date(), - }) - .where(eq(pageAnalyticsHistory.id, existingEntry.id)); - } else { - // Insert new entry for today - await db.insert(pageAnalyticsHistory).values({ - pageId, - date: new Date(), - followersCount: followers, - pageReach, - pageViews, - pageEngagement, - }); - } - - console.log(`[AnalyticsService] Synced page ${pageId}: ${followers} followers, ${pageReach} reach, ${pageEngagement} engagement, ${pageViews} views`); - } - - /** - * Syncs analytics for ALL active pages. - * Called by the CRON job. - */ - static async syncAllPages(): Promise { - console.log('[AnalyticsService] Starting sync for all pages...'); - // Les statistiques passent par la Graph API : les comptes TikTok en sont exclus - const allPages = (await db.query.socialPages.findMany()) - .filter(page => page.platform !== 'tiktok'); - - for (const page of allPages) { - try { - await this.syncPageAnalytics(page.id); - } catch (error: any) { - console.error(`[AnalyticsService] Failed to sync page ${page.id}:`, error.message); - } - } - - console.log(`[AnalyticsService] Sync complete for ${allPages.length} pages.`); - } -} diff --git a/server/services/cloudinary.ts b/server/services/cloudinary.ts deleted file mode 100644 index 2764562..0000000 --- a/server/services/cloudinary.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { v2 as cloudinary } from 'cloudinary'; -import { storage } from '../storage'; - -class CloudinaryService { - async uploadMedia( - file: Buffer | string, - fileName: string, - userId: string, - mimeType: string - ): Promise<{ - publicId: string; - originalUrl: string; - facebookFeedUrl: string | null; - instagramFeedUrl: string | null; - instagramStoryUrl: string | null; - }> { - // Get any available Cloudinary config (shared across all users) - const config = await storage.getCloudinaryConfig(); - - if (!config) { - throw new Error('Cloudinary configuration not found. Please ask an administrator to configure Cloudinary in Settings first.'); - } - - // Configure Cloudinary with user's credentials - cloudinary.config({ - cloud_name: config.cloudName, - api_key: config.apiKey, - api_secret: config.apiSecret, - secure: true, - }); - - const isVideo = mimeType.startsWith('video/'); - - // Define eager transformations for images (generated during upload) - // Note: Eager transformations are not applied to videos in this flow to avoid timeouts or huge processing - const eagerTransformations = !isVideo ? [ - { width: 1080, height: 1080, crop: 'fill', gravity: 'center' }, // Facebook Feed (square, no borders) - { width: 1080, height: 1080, crop: 'pad', gravity: 'center', background: 'auto' }, // Instagram Feed - { width: 1080, height: 1920, crop: 'pad', gravity: 'center', background: 'auto' } // Instagram Story - ] : undefined; - - const uploadOptions = { - folder: 'social-flow', - public_id: `${Date.now()}-${fileName.replace(/\.[^/.]+$/, '')}`, - resource_type: isVideo ? 'video' : 'image', - eager: eagerTransformations, - eager_async: false, // Generate transformations immediately - }; - - let uploadResult; - - if (typeof file === 'string') { - // Upload from file path (streamed by SDK) - uploadResult = await cloudinary.uploader.upload(file, { - ...uploadOptions, - resource_type: isVideo ? 'video' : 'image', // Type assertion for SDK - } as any); - } else { - // Upload from buffer (memory) - uploadResult = await new Promise((resolve, reject) => { - const uploadStream = cloudinary.uploader.upload_stream( - uploadOptions as any, - (error, result) => { - if (error) reject(error); - else resolve(result); - } - ); - uploadStream.end(file); - }); - } - - const publicId = uploadResult.public_id; - const originalUrl = uploadResult.secure_url; - - // Extract eager transformation URLs (generated by Cloudinary during upload) - let facebookFeedUrl = null; - let instagramFeedUrl = null; - let instagramStoryUrl = null; - - if (!isVideo && uploadResult.eager && uploadResult.eager.length === 3) { - facebookFeedUrl = uploadResult.eager[0].secure_url; // Facebook Feed (1200x630) - instagramFeedUrl = uploadResult.eager[1].secure_url; // Instagram Feed (1080x1080) - instagramStoryUrl = uploadResult.eager[2].secure_url; // Instagram Story (1080x1920) - } - - return { - publicId, - originalUrl, - facebookFeedUrl, - instagramFeedUrl, - instagramStoryUrl, - }; - } - - async deleteMedia(publicId: string, userId: string, mediaType: 'image' | 'video'): Promise { - // Get any available Cloudinary config (shared across all users) - const config = await storage.getCloudinaryConfig(); - - if (!config) { - throw new Error('Cloudinary configuration not found. Please ask an administrator to configure Cloudinary in Settings first.'); - } - - cloudinary.config({ - cloud_name: config.cloudName, - api_key: config.apiKey, - api_secret: config.apiSecret, - secure: true, - }); - - await cloudinary.uploader.destroy(publicId, { - resource_type: mediaType, - }); - } - - async uploadStoryImageWithText(buffer: Buffer, originalFileName: string): Promise { - const config = await storage.getCloudinaryConfig(); - - if (!config) { - throw new Error('Cloudinary configuration not found. Please ask an administrator to configure Cloudinary in Settings first.'); - } - - cloudinary.config({ - cloud_name: config.cloudName, - api_key: config.apiKey, - api_secret: config.apiSecret, - secure: true, - }); - - const uploadResult = await new Promise((resolve, reject) => { - const uploadStream = cloudinary.uploader.upload_stream( - { - folder: 'social-flow/stories', - public_id: `story-${Date.now()}-${originalFileName.replace(/\.[^/.]+$/, '')}`, - resource_type: 'image', - }, - (error, result) => { - if (error) reject(error); - else resolve(result); - } - ); - - uploadStream.end(buffer); - }); - - return uploadResult.secure_url; - } - - async uploadLogo(buffer: Buffer, fileName: string): Promise<{ publicId: string; url: string }> { - const config = await storage.getCloudinaryConfig(); - - if (!config) { - throw new Error('Cloudinary configuration not found. Please ask an administrator to configure Cloudinary in Settings first.'); - } - - cloudinary.config({ - cloud_name: config.cloudName, - api_key: config.apiKey, - api_secret: config.apiSecret, - secure: true, - }); - - const uploadResult = await new Promise((resolve, reject) => { - const uploadStream = cloudinary.uploader.upload_stream( - { - folder: 'social-flow/logos', - public_id: `logo-${Date.now()}-${fileName.replace(/\.[^/.]+$/, '')}`, - resource_type: 'image', - }, - (error, result) => { - if (error) reject(error); - else resolve(result); - } - ); - - uploadStream.end(buffer); - }); - - return { - publicId: uploadResult.public_id, - url: uploadResult.secure_url, - }; - } - - async deleteLogo(publicId: string): Promise { - const config = await storage.getCloudinaryConfig(); - - if (!config) { - throw new Error('Cloudinary configuration not found. Please ask an administrator to configure Cloudinary in Settings first.'); - } - - cloudinary.config({ - cloud_name: config.cloudName, - api_key: config.apiKey, - api_secret: config.apiSecret, - secure: true, - }); - - await cloudinary.uploader.destroy(publicId, { - resource_type: 'image', - }); - } -} - -export const cloudinaryService = new CloudinaryService(); diff --git a/server/services/emojiRenderer.ts b/server/services/emojiRenderer.ts deleted file mode 100644 index 5574309..0000000 --- a/server/services/emojiRenderer.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { parse } from 'twemoji-parser'; -import { createCanvas, loadImage, CanvasRenderingContext2D } from 'canvas'; -import sharp from 'sharp'; - -interface EmojiSegment { - type: 'text' | 'emoji'; - content: string; - url?: string; -} - -/** - * Service to render text with emojis on canvas using Twemoji - * Converts emojis to images and renders them alongside text - */ -export class EmojiRenderer { - private emojiCache = new Map(); - - /** - * Parse text into segments of text and emojis - */ - private parseTextWithEmojis(text: string): EmojiSegment[] { - const emojis = parse(text); - if (emojis.length === 0) { - return [{ type: 'text', content: text }]; - } - - const segments: EmojiSegment[] = []; - let lastIndex = 0; - - for (const emoji of emojis) { - // Add text before emoji - if (emoji.indices[0] > lastIndex) { - const textContent = text.substring(lastIndex, emoji.indices[0]); - if (textContent) { - segments.push({ type: 'text', content: textContent }); - } - } - - // Add emoji - segments.push({ - type: 'emoji', - content: emoji.text, - url: emoji.url - }); - - lastIndex = emoji.indices[1]; - } - - // Add remaining text - if (lastIndex < text.length) { - const textContent = text.substring(lastIndex); - if (textContent) { - segments.push({ type: 'text', content: textContent }); - } - } - - return segments; - } - - /** - * Download and cache emoji image - */ - async getEmojiImage(url: string): Promise { - if (this.emojiCache.has(url)) { - return this.emojiCache.get(url)!; - } - - try { - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Failed to fetch emoji: ${response.status} ${response.statusText}`); - } - const buffer = Buffer.from(await response.arrayBuffer()); - - // Convert SVG to PNG using sharp because node-canvas struggles with SVGs lacking dimensions - const pngBuffer = await sharp(buffer).png().toBuffer(); - - this.emojiCache.set(url, pngBuffer); - return pngBuffer; - } catch (error) { - console.error(`Error downloading emoji from ${url}:`, error); - throw error; - } - } - - /** - * Measure the width of text with emojis - */ - async measureText(text: string, ctx: CanvasRenderingContext2D, fontSize: number): Promise { - const segments = this.parseTextWithEmojis(text); - let totalWidth = 0; - - for (const segment of segments) { - if (segment.type === 'text') { - totalWidth += ctx.measureText(segment.content).width; - } else { - // Emoji width is approximately equal to font size - totalWidth += fontSize; - } - } - - return totalWidth; - } - - /** - * Draw text with emojis on canvas - */ - async drawText( - text: string, - ctx: CanvasRenderingContext2D, - x: number, - y: number, - fontSize: number - ): Promise { - const segments = this.parseTextWithEmojis(text); - let currentX = x; - - for (const segment of segments) { - if (segment.type === 'text') { - ctx.fillText(segment.content, currentX, y); - currentX += ctx.measureText(segment.content).width; - } else if (segment.url) { - try { - const emojiBuffer = await this.getEmojiImage(segment.url); - const emojiImage = await loadImage(emojiBuffer); - - // Draw emoji slightly above baseline to align with text - const emojiSize = fontSize; - const emojiY = y - fontSize * 0.8; // Adjust vertical position - - ctx.drawImage(emojiImage, currentX, emojiY, emojiSize, emojiSize); - currentX += emojiSize; - } catch (error) { - console.error('Error loading emoji:', error); - // Fallback: draw text representation - ctx.fillText(segment.content, currentX, y); - currentX += ctx.measureText(segment.content).width; - } - } - } - } - - /** - * Break a long word that exceeds maxWidth into chunks - */ - private async breakLongWord( - word: string, - ctx: CanvasRenderingContext2D, - maxWidth: number, - fontSize: number - ): Promise { - const chunks: string[] = []; - let currentChunk = ''; - - // Split word into individual characters (graphemes for emoji support) - const chars = Array.from(word); - - for (const char of chars) { - const testChunk = currentChunk + char; - const width = await this.measureText(testChunk, ctx, fontSize); - - if (width > maxWidth && currentChunk) { - chunks.push(currentChunk); - currentChunk = char; - } else { - currentChunk = testChunk; - } - } - - if (currentChunk) { - chunks.push(currentChunk); - } - - return chunks; - } - - /** - * Wrap text with emojis into multiple lines - * Handles long words/emoji sequences by breaking them when needed - */ - async wrapText( - text: string, - ctx: CanvasRenderingContext2D, - maxWidth: number, - fontSize: number - ): Promise { - const words = text.split(' '); - const lines: string[] = []; - let currentLine = ''; - - for (const word of words) { - const testLine = currentLine ? `${currentLine} ${word}` : word; - const testWidth = await this.measureText(testLine, ctx, fontSize); - - if (testWidth > maxWidth && currentLine) { - // Current line would exceed width, push it and start new line - lines.push(currentLine); - currentLine = word; - - // Check if the word itself is too long for a single line - const wordWidth = await this.measureText(word, ctx, fontSize); - if (wordWidth > maxWidth) { - // Word is too long, need to break it into chunks - const chunks = await this.breakLongWord(word, ctx, maxWidth, fontSize); - for (let i = 0; i < chunks.length - 1; i++) { - lines.push(chunks[i]); - } - currentLine = chunks[chunks.length - 1]; - } - } else if (testWidth > maxWidth && !currentLine) { - // First word is too long, need to break it - const chunks = await this.breakLongWord(word, ctx, maxWidth, fontSize); - for (let i = 0; i < chunks.length - 1; i++) { - lines.push(chunks[i]); - } - currentLine = chunks[chunks.length - 1]; - } else { - currentLine = testLine; - } - } - - if (currentLine) { - lines.push(currentLine); - } - - return lines; - } - - /** - * Clear emoji cache - */ - clearCache(): void { - this.emojiCache.clear(); - } -} - -// Singleton instance -export const emojiRenderer = new EmojiRenderer(); diff --git a/server/services/facebook.ts b/server/services/facebook.ts deleted file mode 100644 index e9edc4e..0000000 --- a/server/services/facebook.ts +++ /dev/null @@ -1,840 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import { storage } from '../storage'; -import type { Post, SocialPage, Media } from '@shared/schema'; -import { imageProcessor } from './imageProcessor'; -import { minioService, resolvePublicUrl } from './minio'; - -interface FacebookPhotoResponse { - id: string; - post_id: string; -} - -interface FacebookFeedResponse { - id: string; -} - -interface FacebookVideoResponse { - id: string; - post_id?: string; -} - -interface FacebookError { - error: { - message: string; - type: string; - code: number; - }; -} - -export class FacebookService { - private baseUrl = 'https://graph.facebook.com/v23.0'; - - async publishPost(post: Post, page: SocialPage, postType: string, mediaList: Media[] = []): Promise { - if (page.platform !== 'facebook') { - throw new Error('This service only supports Facebook pages'); - } - - if (!page.accessToken) { - throw new Error('No access token found for this page'); - } - - // Handle different post types - // Note: "both" type should never reach here - new posts are split at creation - // If it does reach here (legacy data), throw an error - if (postType === 'both') { - throw new Error('postType "both" is not supported. Posts should be split into separate feed and story entries at creation.'); - } - - if (postType === 'story') { - // Publish as story - one story per media - if (mediaList.length === 0) { - throw new Error('Stories require media (photo or video)'); - } - - // Publish each media as a separate story - const storyIds: string[] = []; - for (const media of mediaList) { - const storyId = await this.publishStory(post, page, media); - storyIds.push(storyId); - } - - // Return all story IDs joined together - return storyIds.join(','); - } else if (postType === 'reel') { - // Publish as Reel - if (mediaList.length === 0) { - throw new Error('Reels require a video'); - } - - const videoMedia = mediaList.find(m => m.type === 'video'); - if (!videoMedia) { - throw new Error('Reels require a video media'); - } - - // Use public URL so getMediaBuffer can fetch via HTTP if local file is missing - const publicUrl = resolvePublicUrl(videoMedia.originalUrl); - const nodeBuffer = await this.getMediaBuffer(publicUrl); - return await this.publishVideoFromBuffer(page, nodeBuffer, post.content); - } else { - // Default to feed - const imageMedia = mediaList.filter(m => m.type === 'image'); - const videoMedia = mediaList.filter(m => m.type === 'video'); - - if (imageMedia.length > 1) { - // Multi-photo carousel (images only) - return await this.publishMultiPhotoPost(post, page, mediaList); - } else if (imageMedia.length === 1) { - // Single image (even if mixed with videos, prioritize the image) - return await this.publishPhotoPost(post, page, imageMedia[0]); - } else if (videoMedia.length >= 1) { - // Video post - use video endpoint - return await this.publishVideoPost(post, page, videoMedia[0]); - } else { - // Text only - return await this.publishTextPost(post, page); - } - } - } - - private async publishTextPost(post: Post, page: SocialPage): Promise { - const params = new URLSearchParams({ - access_token: page.accessToken!, - message: post.content, - }); - - const url = `${this.baseUrl}/${page.pageId}/feed?${params.toString()}`; - - const response = await fetch(url, { - method: 'POST', - }); - - if (!response.ok) { - const error = await response.json() as FacebookError; - throw new Error(`Facebook API error: ${error.error.message} (code: ${error.error.code})`); - } - - const data = await response.json() as FacebookFeedResponse; - return data.id; - } - - private async getMediaBuffer(originalUrl: string): Promise { - const localPath = originalUrl.startsWith('/') - ? path.join(process.cwd(), originalUrl) - : originalUrl; - - if (localPath.startsWith('http')) { - const response = await fetch(localPath); - if (!response.ok) throw new Error(`Failed to fetch media from ${localPath}`); - return Buffer.from(await response.arrayBuffer()); - } - - return fs.promises.readFile(localPath); - } - - private async publishPhotoPost(post: Post, page: SocialPage, media: Media): Promise { - const buffer = await this.getMediaBuffer(media.originalUrl); - const mimeType = media.originalUrl.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg'; - const blob = new Blob([buffer], { type: mimeType }); - - const formData = new FormData(); - formData.append('access_token', page.accessToken!); - if (post.content) { - formData.append('message', post.content); - } - formData.append('source', blob, path.basename(media.originalUrl)); - - const url = `${this.baseUrl}/${page.pageId}/photos`; - - const response = await fetch(url, { - method: 'POST', - body: formData, - }); - - if (!response.ok) { - const error = await response.json() as FacebookError; - throw new Error(`Facebook API error: ${error.error.message} (code: ${error.error.code})`); - } - - const data = await response.json() as FacebookPhotoResponse; - // Return the post_id which is the full post identifier - return data.post_id || data.id; - } - - /** - * Generates a Facebook-compatible video URL with Cloudinary transformations - * Facebook requires: H.264 video codec, AAC audio codec, MP4 container - */ - private getFacebookCompatibleVideoUrl(originalUrl: string): string { - // Check if this is a Cloudinary URL - const cloudinaryPattern = /https:\/\/res\.cloudinary\.com\/([^\/]+)\/video\/upload\//; - const match = originalUrl.match(cloudinaryPattern); - - if (!match) { - // Not a Cloudinary URL, return as-is (may fail on Facebook) - console.warn('Video URL is not from Cloudinary, cannot apply transformations:', originalUrl); - return originalUrl; - } - - const [fullMatch, cloudName] = match; - const videoPath = originalUrl.replace(fullMatch, ''); - - // Apply transformations for Facebook compatibility: - // - vc_h264: Video codec H.264 - // - ac_aac: Audio codec AAC - // - f_mp4: Force MP4 format - // - q_auto: Automatic quality optimization - const transformations = 'vc_h264,ac_aac,f_mp4,q_auto'; - - const transformedUrl = `https://res.cloudinary.com/${cloudName}/video/upload/${transformations}/${videoPath}`; - - console.log('🎬 Transforming video for Facebook compatibility:'); - console.log(' Original:', originalUrl); - console.log(' Transformed:', transformedUrl); - - return transformedUrl; - } - - private async publishVideoPost(post: Post, page: SocialPage, media: Media): Promise { - // Transform video URL to ensure Facebook compatibility (H.264/AAC/MP4) - const videoUrl = this.getFacebookCompatibleVideoUrl(resolvePublicUrl(media.originalUrl)); - - const params = new URLSearchParams({ - access_token: page.accessToken!, - description: post.content, - file_url: videoUrl, - }); - - const url = `${this.baseUrl}/${page.pageId}/videos?${params.toString()}`; - - const response = await fetch(url, { - method: 'POST', - }); - - if (!response.ok) { - const error = await response.json() as FacebookError; - throw new Error(`Facebook API error publishing video: ${error.error.message} (code: ${error.error.code})`); - } - - const data = await response.json() as FacebookVideoResponse; - // Return the post_id or id - return data.post_id || data.id; - } - - private async publishMultiPhotoPost(post: Post, page: SocialPage, mediaList: Media[]): Promise { - // Validate that all media are images - const imageMedia = mediaList.filter(m => m.type === 'image'); - if (imageMedia.length === 0) { - throw new Error('Multi-photo posts require at least one image'); - } - - if (imageMedia.length !== mediaList.length) { - console.warn(`Filtered out ${mediaList.length - imageMedia.length} non-image media from multi-photo post`); - } - - // Step 1: Upload all photos as unpublished and collect photo IDs - const photoIds: string[] = []; - - for (const media of imageMedia) { - if (!media.originalUrl) { - console.warn(`Skipping media ${media.id} - no valid URL found`); - continue; - } - - const buffer = await this.getMediaBuffer(media.originalUrl); - const mimeType = media.originalUrl.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg'; - const blob = new Blob([buffer], { type: mimeType }); - - const formData = new FormData(); - formData.append('access_token', page.accessToken!); - formData.append('published', 'false'); - formData.append('source', blob, path.basename(media.originalUrl)); - - const uploadUrl = `${this.baseUrl}/${page.pageId}/photos`; - - const uploadResponse = await fetch(uploadUrl, { - method: 'POST', - body: formData, - }); - - if (!uploadResponse.ok) { - const error = await uploadResponse.json() as FacebookError; - throw new Error(`Facebook API error uploading photo: ${error.error.message} (code: ${error.error.code})`); - } - - const uploadData = await uploadResponse.json() as FacebookPhotoResponse; - photoIds.push(uploadData.id); - } - - if (photoIds.length === 0) { - throw new Error('No photos were successfully uploaded'); - } - - // Step 2: Create feed post with all attached photos - const feedParams = new URLSearchParams({ - access_token: page.accessToken!, - message: post.content, - }); - - // Add each photo as attached_media - photoIds.forEach((photoId, index) => { - feedParams.append(`attached_media[${index}]`, JSON.stringify({ media_fbid: photoId })); - }); - - const feedUrl = `${this.baseUrl}/${page.pageId}/feed?${feedParams.toString()}`; - - const feedResponse = await fetch(feedUrl, { - method: 'POST', - }); - - if (!feedResponse.ok) { - const error = await feedResponse.json() as FacebookError; - throw new Error(`Facebook API error creating feed post: ${error.error.message} (code: ${error.error.code})`); - } - - const feedData = await feedResponse.json() as FacebookFeedResponse; - return feedData.id; - } - - async schedulePost(post: Post, page: SocialPage, scheduledTime: Date, media?: Media): Promise { - if (page.platform !== 'facebook') { - throw new Error('This service only supports Facebook pages'); - } - - if (!page.accessToken) { - throw new Error('No access token found for this page'); - } - - // Facebook requires Unix timestamp for scheduled posts - const scheduledTimestamp = Math.floor(scheduledTime.getTime() / 1000); - const now = Math.floor(Date.now() / 1000); - - // Facebook requires scheduled time to be at least 10 minutes in the future - if (scheduledTimestamp < now + 600) { - throw new Error('Scheduled time must be at least 10 minutes in the future'); - } - - // Maximum 6 months in the future - const sixMonthsFromNow = now + (6 * 30 * 24 * 60 * 60); - if (scheduledTimestamp > sixMonthsFromNow) { - throw new Error('Scheduled time cannot be more than 6 months in the future'); - } - - // For scheduled posts with photos - if (media) { - return await this.schedulePhotoPost(post, page, scheduledTimestamp, media); - } else { - return await this.scheduleTextPost(post, page, scheduledTimestamp); - } - } - - private async scheduleTextPost(post: Post, page: SocialPage, scheduledTimestamp: number): Promise { - const params = new URLSearchParams({ - access_token: page.accessToken!, - message: post.content, - published: '0', - scheduled_publish_time: scheduledTimestamp.toString(), - }); - - const url = `${this.baseUrl}/${page.pageId}/feed?${params.toString()}`; - - const response = await fetch(url, { - method: 'POST', - }); - - if (!response.ok) { - const error = await response.json() as FacebookError; - throw new Error(`Facebook API error: ${error.error.message} (code: ${error.error.code})`); - } - - const data = await response.json() as FacebookFeedResponse; - return data.id; - } - - private async schedulePhotoPost(post: Post, page: SocialPage, scheduledTimestamp: number, media: Media): Promise { - const buffer = await this.getMediaBuffer(media.originalUrl); - const mimeType = media.originalUrl.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg'; - const blob = new Blob([buffer], { type: mimeType }); - - const formData = new FormData(); - formData.append('access_token', page.accessToken!); - if (post.content) { - formData.append('message', post.content); - } - formData.append('published', '0'); - formData.append('scheduled_publish_time', scheduledTimestamp.toString()); - formData.append('source', blob, path.basename(media.originalUrl)); - - const url = `${this.baseUrl}/${page.pageId}/photos`; - - const response = await fetch(url, { - method: 'POST', - body: formData, - }); - - if (!response.ok) { - const error = await response.json() as FacebookError; - throw new Error(`Facebook API error: ${error.error.message} (code: ${error.error.code})`); - } - - const data = await response.json() as FacebookPhotoResponse; - return data.post_id || data.id; - } - - private async publishStory(post: Post, page: SocialPage, media: Media): Promise { - if (media.type === 'video') { - return this.publishVideoStory(post, page, media); - } - - let photoBuffer = await this.getMediaBuffer(media.originalUrl); - let originalFilename = path.basename(media.originalUrl); - - if (post.content && post.content.trim().length > 0) { - try { - const imageWithText = await imageProcessor.addTextToStoryImage(resolvePublicUrl(media.originalUrl), post.content); - await minioService.uploadStoryImageWithText(imageWithText, `story-${media.id}.png`); - photoBuffer = imageWithText; - originalFilename = `story-${media.id}.png`; - console.log('Story image with text generated locally'); - } catch (error) { - console.error('Error generating story image with text, using original:', error); - } - } - - const mimeType = originalFilename.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg'; - const blob = new Blob([photoBuffer], { type: mimeType }); - - const formData = new FormData(); - formData.append('access_token', page.accessToken!); - formData.append('published', 'false'); // Important: must be unpublished first - formData.append('source', blob, originalFilename); - - const uploadUrl = `${this.baseUrl}/${page.pageId}/photos`; - - const uploadResponse = await fetch(uploadUrl, { - method: 'POST', - body: formData, - }); - - if (!uploadResponse.ok) { - const error = await uploadResponse.json() as FacebookError; - throw new Error(`Facebook API error uploading photo for story: ${error.error.message} (code: ${error.error.code})`); - } - - const uploadData = await uploadResponse.json() as FacebookPhotoResponse; - const photoId = uploadData.id; - - // Step 2: Publish the photo as a story - const storyParams = new URLSearchParams({ - access_token: page.accessToken!, - photo_id: photoId, - }); - - const storyUrl = `${this.baseUrl}/${page.pageId}/photo_stories?${storyParams.toString()}`; - - const storyResponse = await fetch(storyUrl, { - method: 'POST', - }); - - if (!storyResponse.ok) { - const error = await storyResponse.json() as FacebookError; - throw new Error(`Facebook API error publishing story: ${error.error.message} (code: ${error.error.code})`); - } - - const storyData = await storyResponse.json() as { success: boolean; post_id: string }; - return storyData.post_id; - } - - private async publishVideoStory(post: Post, page: SocialPage, media: Media): Promise { - // Video stories require a 3-phase upload process: - // 1. START phase - Initialize the upload and get upload_url - // 2. UPLOAD phase - Upload the video to upload_url using file_url header - // 3. FINISH phase - Finalize and publish the story - - const videoUrl = resolvePublicUrl(media.originalUrl); - const accessToken = page.accessToken!; - - // Phase 1: START - Initialize the upload - const startParams = new URLSearchParams({ - access_token: accessToken, - upload_phase: 'start', - }); - - const startUrl = `${this.baseUrl}/${page.pageId}/video_stories?${startParams.toString()}`; - - const startResponse = await fetch(startUrl, { - method: 'POST', - }); - - if (!startResponse.ok) { - const error = await startResponse.json() as FacebookError; - throw new Error(`Facebook API error (START phase): ${error.error.message} (code: ${error.error.code})`); - } - - const startData = await startResponse.json() as { video_id: string; upload_url: string }; - const videoId = startData.video_id; - const uploadUrl = startData.upload_url; - - // Phase 2: UPLOAD - Upload the video using the upload_url - // IMPORTANT: file_url must be passed as a header, not a query parameter - const uploadResponse = await fetch(uploadUrl, { - method: 'POST', - headers: { - 'Authorization': `OAuth ${accessToken}`, - 'file_url': videoUrl, - }, - }); - - if (!uploadResponse.ok) { - const errorText = await uploadResponse.text(); - throw new Error(`Facebook API error (UPLOAD phase): ${errorText}`); - } - - // Phase 3: FINISH - Finalize and publish the story - const finishParams = new URLSearchParams({ - access_token: accessToken, - upload_phase: 'finish', - video_id: videoId, - }); - - const finishUrl = `${this.baseUrl}/${page.pageId}/video_stories?${finishParams.toString()}`; - - const finishResponse = await fetch(finishUrl, { - method: 'POST', - }); - - if (!finishResponse.ok) { - const error = await finishResponse.json() as FacebookError; - throw new Error(`Facebook API error (FINISH phase): ${error.error.message} (code: ${error.error.code})`); - } - - const finishData = await finishResponse.json() as { success: boolean; post_id?: string }; - - // Return the post_id from the finish response - return finishData.post_id || videoId; - } - - /** - * Publie une vidéo en story Facebook en envoyant directement les données - * binaires (start → upload → finish sur /video_stories). Sert à doubler un - * Reel d'une story : la vidéo rendue est déjà en mémoire. - */ - async publishVideoStoryFromBuffer(page: SocialPage, videoBuffer: Buffer): Promise { - if (!page.accessToken) throw new Error('No access token found for this page'); - const accessToken = page.accessToken; - const fileSize = videoBuffer.length; - - const startParams = new URLSearchParams({ - access_token: accessToken, - upload_phase: 'start', - }); - const startRes = await fetch(`${this.baseUrl}/${page.pageId}/video_stories?${startParams.toString()}`, { - method: 'POST', - }); - if (!startRes.ok) { - const error = await startRes.json() as FacebookError; - throw new Error(`Facebook Story API error (START phase): ${error.error.message} (code: ${error.error.code})`); - } - const { video_id: videoId, upload_url: uploadUrl } = await startRes.json() as { video_id: string; upload_url: string }; - - const uploadRes = await fetch(uploadUrl, { - method: 'POST', - headers: { - 'Authorization': `OAuth ${accessToken}`, - 'Content-Type': 'application/octet-stream', - 'offset': '0', - 'file_size': fileSize.toString(), - }, - body: new Blob([videoBuffer], { type: 'video/mp4' }), - }); - if (!uploadRes.ok) { - throw new Error(`Facebook Story API error (UPLOAD phase): ${await uploadRes.text()}`); - } - - const finishParams = new URLSearchParams({ - access_token: accessToken, - upload_phase: 'finish', - video_id: videoId, - }); - const finishRes = await fetch(`${this.baseUrl}/${page.pageId}/video_stories?${finishParams.toString()}`, { - method: 'POST', - }); - if (!finishRes.ok) { - const error = await finishRes.json() as FacebookError; - throw new Error(`Facebook Story API error (FINISH phase): ${error.error.message} (code: ${error.error.code})`); - } - - const finishData = await finishRes.json() as { success: boolean; post_id?: string }; - console.log(`✅ Story vidéo publiée sur ${page.pageName}`); - return finishData.post_id || videoId; - } - - /** - * Publie une vidéo comme Reel Facebook - * - * Utilise l'endpoint /{page-id}/video_reels avec un processus en 3 phases : - * 1. START - Initialise l'upload et récupère l'upload_url - * 2. UPLOAD - Upload la vidéo vers rupload.facebook.com - * 3. FINISH - Finalise et publie le Reel - * - * Spécifications vidéo requises : - * - Format : MP4, H.264 - * - Ratio : 9:16 (vertical) - * - Durée : 3-90 secondes - * - Résolution recommandée : 1080x1920 - * - * @param page - Page Facebook cible - * @param videoUrl - URL de la vidéo à publier - * @param description - Description du Reel (optionnel) - * @returns ID du Reel publié - */ - async publishReel( - page: SocialPage, - videoUrl: string, - description?: string - ): Promise { - if (page.platform !== 'facebook') { - throw new Error('This method only supports Facebook pages'); - } - - if (!page.accessToken) { - throw new Error('No access token found for this page'); - } - - console.log('🎬 Publishing Reel to Facebook Feed & Reels via /videos endpoint:', { - pageId: page.pageId, - pageName: page.pageName, - hasDescription: !!description, - }); - - // Use the /videos endpoint instead of /video_reels - // This ensures the video appears on the Page Timeline (Feed) AND in Reels (if 9:16) - const params = new URLSearchParams({ - access_token: page.accessToken, - description: description || '', - file_url: videoUrl, - }); - - const url = `${this.baseUrl}/${page.pageId}/videos?${params.toString()}`; - - const response = await fetch(url, { - method: 'POST', - }); - - if (!response.ok) { - const error = await response.json() as FacebookError; - throw new Error(`Facebook API error publishing video/reel: ${error.error.message} (code: ${error.error.code})`); - } - - const data = await response.json() as FacebookVideoResponse; - const postId = data.post_id || data.id; - - console.log('✅ Reel published successfully to Feed & Reels! ID:', postId); - - return postId; - } - - /** - * Publie une vidéo comme Reel Facebook via l'API Reels (start → upload → finish). - * Utilise l'endpoint /video_reels pour que la vidéo soit reconnue comme un Reel. - * - * Spécifications vidéo requises pour les Reels : - * - Format : MP4, H.264 - * - Ratio : 9:16 (vertical) - * - Durée : 3-90 secondes - * - Résolution recommandée : 1080x1920 - */ - async publishVideoFromBuffer( - page: SocialPage, - videoBuffer: Buffer | ArrayBuffer, - description?: string - ): Promise { - if (!page.accessToken) throw new Error('No access token found for this page'); - - const buf = Buffer.isBuffer(videoBuffer) ? videoBuffer : Buffer.from(videoBuffer); - const fileSize = buf.length; - const accessToken = page.accessToken; - - console.log('🎬 Publishing video as Facebook Reel via /video_reels endpoint:', { - pageId: page.pageId, - pageName: page.pageName, - size: `${(fileSize / 1024 / 1024).toFixed(2)} MB`, - }); - - // ── Phase 1: START - Initialize the Reel upload ────────────────────────── - const startParams = new URLSearchParams({ - access_token: accessToken, - upload_phase: 'start', - }); - - const startUrl = `${this.baseUrl}/${page.pageId}/video_reels?${startParams.toString()}`; - - const startRes = await fetch(startUrl, { method: 'POST' }); - const startRaw = await startRes.text(); - console.log('📤 Facebook Reel START response:', startRaw); - const startData = JSON.parse(startRaw) as any; - - if (!startRes.ok || startData.error) { - const subcode = startData.error?.error_subcode; - if (subcode === 1363042) { - throw new Error( - `Le token Facebook de la page "${page.pageName}" n'a pas la permission "publish_video". ` + - `Régénérez le token dans Graph API Explorer en ajoutant les permissions : ` + - `pages_manage_posts, pages_read_engagement, publish_video. ` + - `(code: ${startData.error?.code}, subcode: ${subcode})` - ); - } - throw new Error(`Facebook Reel START error: ${startData.error?.message ?? startRaw} (code: ${startData.error?.code}, subcode: ${subcode})`); - } - - const videoId = startData.video_id; - const uploadUrl = startData.upload_url; - console.log(`📤 Reel upload initialized: video_id=${videoId}, upload_url=${uploadUrl}`); - - // ── Phase 2: UPLOAD - Upload binary data to the upload_url ─────────────── - const videoBlob = new Blob([buf], { type: 'video/mp4' }); - const uploadRes = await fetch(uploadUrl, { - method: 'POST', - headers: { - 'Authorization': `OAuth ${accessToken}`, - 'Content-Type': 'application/octet-stream', - 'offset': '0', - 'file_size': fileSize.toString(), - }, - body: videoBlob, - }); - - if (!uploadRes.ok) { - const errorText = await uploadRes.text(); - throw new Error(`Facebook Reel UPLOAD error: ${errorText}`); - } - console.log(`📤 Reel video data uploaded successfully (${fileSize} bytes)`); - - // ── Phase 3: FINISH - Finalize and publish the Reel ────────────────────── - const finishParams = new URLSearchParams({ - access_token: accessToken, - upload_phase: 'finish', - video_id: videoId, - video_state: 'PUBLISHED', - }); - - if (description) { - finishParams.append('description', description); - } - - const finishUrl = `${this.baseUrl}/${page.pageId}/video_reels?${finishParams.toString()}`; - - const finishRes = await fetch(finishUrl, { method: 'POST' }); - const finishRaw = await finishRes.text(); - console.log('📤 Facebook Reel FINISH response:', finishRaw); - const finishData = JSON.parse(finishRaw) as any; - - if (!finishRes.ok || finishData.error) { - throw new Error(`Facebook Reel FINISH error: ${finishData.error?.message ?? finishRaw} (code: ${finishData.error?.code})`); - } - - const postId = finishData.post_id || videoId; - console.log('✅ Reel published successfully! ID:', postId); - return postId; - } - - /** - * Publie une vidéo comme Reel Facebook en uploadant directement les données binaires - * Utile quand on a la vidéo en base64 (après traitement FFmpeg) - * - * @param page - Page Facebook cible - * @param videoBuffer - Buffer de la vidéo - * @param description - Description du Reel (optionnel) - * @returns ID du Reel publié - */ - async publishReelFromBuffer( - page: SocialPage, - videoBuffer: ArrayBuffer, - description?: string - ): Promise { - if (page.platform !== 'facebook') { - throw new Error('This method only supports Facebook pages'); - } - - if (!page.accessToken) { - throw new Error('No access token found for this page'); - } - - const accessToken = page.accessToken; - const fileSize = videoBuffer.byteLength; - - console.log('🎬 Publishing Reel from buffer:', { - pageId: page.pageId, - fileSize: `${(fileSize / 1024 / 1024).toFixed(2)} MB`, - }); - - // Phase 1: START - const startParams = new URLSearchParams({ - access_token: accessToken, - upload_phase: 'start', - }); - - const startUrl = `${this.baseUrl}/${page.pageId}/video_reels?${startParams.toString()}`; - - const startResponse = await fetch(startUrl, { - method: 'POST', - }); - - if (!startResponse.ok) { - const error = await startResponse.json() as FacebookError; - throw new Error(`Facebook Reel API error (START phase): ${error.error.message} (code: ${error.error.code})`); - } - - const startData = await startResponse.json() as { video_id: string; upload_url: string }; - const videoId = startData.video_id; - const uploadUrl = startData.upload_url; - - // Phase 2: UPLOAD - Upload binary data directly - const videoBlob = new Blob([videoBuffer], { type: 'video/mp4' }); - const uploadResponse = await fetch(uploadUrl, { - method: 'POST', - headers: { - 'Authorization': `OAuth ${accessToken}`, - 'Content-Type': 'application/octet-stream', - 'offset': '0', - 'file_size': fileSize.toString(), - }, - body: videoBlob, - }); - - if (!uploadResponse.ok) { - const errorText = await uploadResponse.text(); - throw new Error(`Facebook Reel API error (UPLOAD phase): ${errorText}`); - } - - // Phase 3: FINISH - const finishParams = new URLSearchParams({ - access_token: accessToken, - upload_phase: 'finish', - video_id: videoId, - video_state: 'PUBLISHED', - }); - - if (description) { - finishParams.append('description', description); - } - - const finishUrl = `${this.baseUrl}/${page.pageId}/video_reels?${finishParams.toString()}`; - - const finishResponse = await fetch(finishUrl, { - method: 'POST', - }); - - if (!finishResponse.ok) { - const error = await finishResponse.json() as FacebookError; - throw new Error(`Facebook Reel API error (FINISH phase): ${error.error.message} (code: ${error.error.code})`); - } - - const finishData = await finishResponse.json() as { success: boolean; post_id?: string }; - - return finishData.post_id || videoId; - } -} - -export const facebookService = new FacebookService(); - diff --git a/server/services/facebook_oauth.ts b/server/services/facebook_oauth.ts deleted file mode 100644 index d412c9d..0000000 --- a/server/services/facebook_oauth.ts +++ /dev/null @@ -1,295 +0,0 @@ -/** - * Renouvellement des tokens Facebook. - * - * Le problème que ce service résout : un token de page saisi à la main finit - * toujours par mourir (60 jours pour un token issu du Graph Explorer, ou du - * jour au lendemain si le mot de passe change, si l'utilisateur perd son rôle - * d'admin, ou si Meta révoque l'autorisation) — et on ne l'apprend qu'au - * moment où une publication échoue. - * - * La parade est celle recommandée par Meta : - * 1. l'utilisateur autorise l'application (OAuth) ; - * 2. le code est échangé contre un token utilisateur court ; - * 3. ce token court est échangé contre un token utilisateur longue durée - * (~60 jours) — c'est lui que l'on conserve, chiffré ; - * 4. les tokens de page dérivés d'un token utilisateur longue durée - * n'expirent pas. - * - * Conserver le token utilisateur permet de régénérer un token de page sans - * intervention humaine, et de prolonger le token utilisateur lui-même avant - * son échéance : tant que l'utilisateur ne retire pas son autorisation, la - * chaîne se renouvelle toute seule. - */ - -import type { Request } from 'express'; -import { storage } from '../storage'; -import { resolvePublicBaseUrl } from '../utils/public_url'; - -const GRAPH_VERSION = process.env.FACEBOOK_GRAPH_VERSION?.trim() || 'v19.0'; -const GRAPH_URL = `https://graph.facebook.com/${GRAPH_VERSION}`; - -/** Permissions nécessaires pour publier et lire les statistiques d'une page. */ -const DEFAULT_SCOPES = [ - 'pages_show_list', - 'pages_manage_posts', - 'pages_read_engagement', - 'pages_manage_engagement', - 'read_insights', - 'business_management', - 'instagram_basic', - 'instagram_content_publish', -].join(','); - -export interface FacebookLongLivedToken { - accessToken: string; - /** Nul quand Facebook ne renvoie pas d'échéance (jeton sans expiration). */ - expiresAt: Date | null; -} - -export interface FacebookPageTarget { - id: string; - name: string; - accessToken: string; - followersCount: number; - /** Compte Instagram professionnel rattaché à la page, s'il existe. */ - instagram: { id: string; name: string } | null; -} - -/** Résultat de /debug_token : ce que Meta sait vraiment du jeton. */ -export interface FacebookTokenInfo { - isValid: boolean; - /** Nul = n'expire pas (cas normal d'un token de page). */ - expiresAt: Date | null; - scopes: string[]; - error: string | null; -} - -export class FacebookAuthError extends Error { - constructor(message: string) { - super(message); - this.name = 'FacebookAuthError'; - } -} - -export class FacebookOAuthService { - /** - * Identifiants de l'application Facebook (globaux à l'instance). - * La base prime sur les variables d'environnement. - */ - private async getCredentials(): Promise<{ appId: string; appSecret: string }> { - const config = await storage.getFacebookConfig(); - - const appId = config?.appId || process.env.FACEBOOK_APP_ID; - const appSecret = config?.appSecret || process.env.FACEBOOK_APP_SECRET; - - if (!appId || !appSecret) { - throw new FacebookAuthError( - "Facebook n'est pas configuré. Renseignez l'App ID et l'App Secret de " + - 'votre application Facebook dans les paramètres.' - ); - } - - return { appId, appSecret }; - } - - async isConfigured(): Promise { - try { - await this.getCredentials(); - return true; - } catch { - return false; - } - } - - /** - * URI de redirection OAuth. `req` permet de la déduire du domaine réellement - * utilisé quand `APP_URL` n'est pas défini : sans lui on renverrait - * `http://localhost:5555`, que Facebook rejette. - */ - getRedirectUri(req?: Request): string { - if (process.env.FACEBOOK_REDIRECT_URI) { - return process.env.FACEBOOK_REDIRECT_URI; - } - return `${resolvePublicBaseUrl(req)}/api/facebook/callback`; - } - - getScopes(): string { - return process.env.FACEBOOK_SCOPES || DEFAULT_SCOPES; - } - - /** URL d'autorisation à ouvrir pour connecter les pages d'un compte Facebook. */ - async buildAuthorizationUrl(state: string, req?: Request): Promise { - const { appId } = await this.getCredentials(); - - const params = new URLSearchParams({ - client_id: appId, - redirect_uri: this.getRedirectUri(req), - state, - scope: this.getScopes(), - response_type: 'code', - }); - - return `https://www.facebook.com/${GRAPH_VERSION}/dialog/oauth?${params.toString()}`; - } - - /** - * Échange le code d'autorisation contre un token utilisateur **longue durée**. - * L'étape intermédiaire (token court) n'est jamais conservée : seul le token - * longue durée rend les tokens de page permanents. - */ - async exchangeCodeForUserToken(code: string, req?: Request): Promise { - const { appId, appSecret } = await this.getCredentials(); - - const shortLived = await this.graphGet<{ access_token?: string }>('/oauth/access_token', { - client_id: appId, - client_secret: appSecret, - redirect_uri: this.getRedirectUri(req), - code, - }, "échange du code d'autorisation"); - - if (!shortLived.access_token) { - throw new FacebookAuthError('Facebook n\'a pas renvoyé de jeton pour ce code d\'autorisation.'); - } - - return this.extendUserToken(shortLived.access_token); - } - - /** - * Prolonge un token utilisateur. Appelé au retour d'OAuth (token court → - * ~60 jours) mais aussi périodiquement : ré-échanger un token longue durée - * encore valide repart pour 60 jours, ce qui évite la reconnexion manuelle. - */ - async extendUserToken(userToken: string): Promise { - const { appId, appSecret } = await this.getCredentials(); - - const longLived = await this.graphGet<{ access_token?: string; expires_in?: number }>( - '/oauth/access_token', - { - grant_type: 'fb_exchange_token', - client_id: appId, - client_secret: appSecret, - fb_exchange_token: userToken, - }, - 'prolongation du jeton utilisateur' - ); - - if (!longLived.access_token) { - throw new FacebookAuthError("Facebook n'a pas renvoyé de jeton longue durée."); - } - - return { - accessToken: longLived.access_token, - // expires_in absent ou nul = jeton sans expiration connue. - expiresAt: longLived.expires_in - ? new Date(Date.now() + longLived.expires_in * 1000) - : null, - }; - } - - /** - * Pages administrées par le compte, avec leur token de page. - * Ces tokens sont permanents lorsqu'ils dérivent d'un token utilisateur - * longue durée : c'est le cœur du renouvellement automatique. - */ - async listPages(userToken: string): Promise { - const response = await this.graphGet<{ - data?: { - id: string; - name: string; - access_token: string; - fan_count?: number; - followers_count?: number; - instagram_business_account?: { id: string; name?: string; username?: string }; - }[]; - }>( - '/me/accounts', - { - fields: 'id,name,access_token,fan_count,followers_count,instagram_business_account{id,name,username}', - limit: '200', - access_token: userToken, - }, - 'récupération des pages' - ); - - return (response.data ?? []).map((page) => ({ - id: page.id, - name: page.name, - accessToken: page.access_token, - followersCount: page.followers_count ?? page.fan_count ?? 0, - instagram: page.instagram_business_account - ? { - id: page.instagram_business_account.id, - name: - page.instagram_business_account.username || - page.instagram_business_account.name || - page.name, - } - : null, - })); - } - - /** - * Interroge Meta sur l'état réel d'un jeton. Un simple appel /me ne dit pas - * quand le jeton expire : /debug_token le dit, ce qui permet de renouveler - * *avant* la panne plutôt qu'après. - */ - async inspectToken(token: string): Promise { - const { appId, appSecret } = await this.getCredentials(); - - const response = await this.graphGet<{ - data?: { - is_valid?: boolean; - expires_at?: number; - scopes?: string[]; - error?: { message?: string }; - }; - }>( - '/debug_token', - { - input_token: token, - // Jeton d'application : `app_id|app_secret`, accepté par /debug_token. - access_token: `${appId}|${appSecret}`, - }, - 'inspection du jeton' - ); - - const data = response.data ?? {}; - - return { - isValid: data.is_valid === true, - // `expires_at: 0` signifie « n'expire pas » chez Meta. - expiresAt: data.expires_at ? new Date(data.expires_at * 1000) : null, - scopes: data.scopes ?? [], - error: data.error?.message ?? null, - }; - } - - private async graphGet( - endpoint: string, - params: Record, - context: string - ): Promise { - const url = new URL(`${GRAPH_URL}${endpoint}`); - Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value)); - - let response: Response; - try { - response = await fetch(url.toString()); - } catch (error) { - throw new FacebookAuthError( - `Facebook injoignable (${context}) : ${error instanceof Error ? error.message : 'erreur réseau'}` - ); - } - - const payload: any = await response.json().catch(() => ({})); - - if (!response.ok || payload?.error) { - const message = payload?.error?.message || `HTTP ${response.status}`; - throw new FacebookAuthError(`Facebook (${context}) : ${message}`); - } - - return payload as T; - } -} - -export const facebookOAuthService = new FacebookOAuthService(); diff --git a/server/services/ffmpeg.ts b/server/services/ffmpeg.ts deleted file mode 100644 index 724dbe0..0000000 --- a/server/services/ffmpeg.ts +++ /dev/null @@ -1,356 +0,0 @@ -/** - * Client du service FFmpeg (conteneur Python `ffmpeg-service`). - * - * Le service rend le Reel puis expose le MP4 en téléchargement - * (GET /files/{job}/output.mp4) : la vidéo ne transite plus en base64 dans du - * JSON, qui gonflait sa taille d'un tiers et la gardait entière en mémoire. - */ - -import fs from 'fs'; -import { Readable } from 'stream'; -import { pipeline } from 'stream/promises'; -import type { ReadableStream as WebReadableStream } from 'stream/web'; -import type { SrtCue } from '@shared/srt'; -import type { TtsEngine, TtsStyle, VoiceOption } from '@shared/voices'; - -/** Un rendu long (stabilisation + encodage) peut dépasser plusieurs minutes. */ -const PROCESS_TIMEOUT_MS = 20 * 60_000; -const DOWNLOAD_TIMEOUT_MS = 5 * 60_000; -// Qwen (local, CPU) met plusieurs fois la durée de la voix à la générer -const TTS_TIMEOUT_MS = 15 * 60_000; -const HEALTH_TIMEOUT_MS = 5_000; - -export interface ReelRenderOptions { - text?: string; - /** Sous-titres SRT : remplacent `text`, la voix lit chacun à son instant. */ - srtCues?: SrtCue[]; - musicUrl?: string; - ttsEnabled?: boolean; - ttsVoice?: string; - ttsEngine?: TtsEngine; - ttsStyle?: TtsStyle; - geminiApiKey?: string; - qwen?: QwenTarget; - fontSize?: number; - musicVolume?: number; - drawText?: boolean; - stabilize?: boolean; - watermarkUrl?: string; - storeName?: string; - enableEndingEffect?: boolean; - /** Petit logo pendant la vidéo (le grand logo de fin dépend de enableEndingEffect). */ - showLogo?: boolean; -} - -export interface ReelRenderResult { - video: Buffer; - duration: number; - ttsEngine?: string | null; - ttsVoice?: string | null; - warnings: string[]; -} - -/** Rendu préparé pour Remotion : fichiers déjà écrits sur le disque. */ -export interface PreparedReel { - videoPath: string; - audioPath: string | null; - totalDuration: number; - videoDuration: number; - logoStart: number | null; - /** Fondu final, calculé par le service pour ne jamais rogner la voix. */ - fadeStart: number | null; - fadeDuration: number | null; - words: TimedWord[]; - ttsEngine?: string | null; - ttsVoice?: string | null; - warnings: string[]; -} - -export interface TimedWord { - text: string; - start: number; - end: number; -} - -/** Service Qwen TTS (local ou serveur GPU) réglé dans les paramètres. */ -export interface QwenTarget { - url: string; - apiKey?: string; -} - -export interface VoiceCatalog { - qwen: VoiceOption[]; - qwen_available: boolean; - /** Modèle et carte graphique du service Qwen, null s'il ne répond pas. */ - qwen_status: { model?: string; device?: string; busy?: boolean } | null; - gemini: VoiceOption[]; - edge: VoiceOption[]; -} - -export interface VoicePreview { - audio: Buffer; - duration: number; - words: TimedWord[]; - engine: string; - voice: string; - warnings: string[]; -} - -interface FFmpegConfig { - apiUrl: string; - apiKey: string; -} - -/** Erreur renvoyée par le service, avec son message lisible. */ -export class FFmpegServiceError extends Error { - constructor(message: string, readonly status?: number) { - super(message); - this.name = 'FFmpegServiceError'; - } -} - -export class FFmpegService { - private config: FFmpegConfig | null = null; - - configure(apiUrl: string, apiKey: string): void { - this.config = { apiUrl: apiUrl.replace(/\/$/, ''), apiKey }; - console.log('🎬 FFmpeg Service configured:', this.config.apiUrl); - } - - private ensureConfigured(): FFmpegConfig { - if (!this.config) { - throw new FFmpegServiceError("Le service FFmpeg n'est pas configuré (FFMPEG_API_URL / FFMPEG_API_KEY)."); - } - return this.config; - } - - private async call(path: string, init: RequestInit & { timeoutMs: number }): Promise { - const config = this.ensureConfigured(); - const { timeoutMs, headers, ...rest } = init; - const response = await fetch(`${config.apiUrl}${path}`, { - ...rest, - headers: { 'X-API-Key': config.apiKey, ...headers }, - signal: AbortSignal.timeout(timeoutMs), - }); - if (!response.ok) { - const body = await response.text(); - let detail = body; - try { - detail = JSON.parse(body).detail ?? body; - } catch { /* corps non JSON */ } - throw new FFmpegServiceError(String(detail).slice(0, 2000), response.status); - } - return response; - } - - /** - * Rend un Reel à partir de l'URL d'une vidéo et renvoie le MP4 produit. - * Lève FFmpegServiceError en cas d'échec (voix comprise). - */ - async renderReel(videoUrl: string, options: ReelRenderOptions = {}): Promise { - const body = { - video_url: videoUrl, - text: options.text, - srt_cues: options.srtCues, - music_url: options.musicUrl, - tts_enabled: options.ttsEnabled ?? false, - tts_voice: options.ttsVoice, - tts_engine: options.ttsEngine, - tts_style: options.ttsStyle, - gemini_api_key: options.geminiApiKey, - qwen_tts_url: options.qwen?.url, - qwen_tts_api_key: options.qwen?.apiKey, - font_size: options.fontSize ?? 64, - music_volume: options.musicVolume ?? 0.25, - draw_text: options.drawText ?? true, - stabilize: options.stabilize ?? false, - watermark_url: options.watermarkUrl, - store_name: options.storeName, - enable_ending_effect: options.enableEndingEffect ?? true, - show_watermark: options.showLogo ?? true, - }; - - console.log('🎬 Rendu du Reel :', { - videoUrl, - textLength: options.text?.length ?? 0, - music: !!options.musicUrl, - tts: options.ttsEnabled ? `${options.ttsEngine}/${options.ttsVoice}/${options.ttsStyle ?? 'neutral'}` : false, - }); - - const response = await this.call('/process-reel', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - timeoutMs: PROCESS_TIMEOUT_MS, - }); - const data = await response.json() as { - job_id: string; - output_path: string; - duration: number; - tts_engine?: string | null; - tts_voice?: string | null; - warnings?: string[]; - }; - - try { - const file = await this.call(data.output_path, { method: 'GET', timeoutMs: DOWNLOAD_TIMEOUT_MS }); - const video = Buffer.from(await file.arrayBuffer()); - for (const warning of data.warnings ?? []) console.warn(`⚠️ [FFmpeg] ${warning}`); - return { - video, - duration: data.duration, - ttsEngine: data.tts_engine, - ttsVoice: data.tts_voice, - warnings: data.warnings ?? [], - }; - } finally { - // Le fichier n'est plus utile au service une fois récupéré - this.call(`/jobs/${data.job_id}`, { method: 'DELETE', timeoutMs: HEALTH_TIMEOUT_MS }) - .catch(() => { /* purgé plus tard par le service */ }); - } - } - - /** - * Prépare un rendu Remotion : image recadrée à la durée finale et piste son - * finale, écrites dans `targetDir`, plus le minutage des mots. - */ - async prepareReel( - videoUrl: string, - options: ReelRenderOptions & { hasLogo: boolean }, - targetDir: string, - ): Promise { - const response = await this.call('/prepare-reel', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - video_url: videoUrl, - text: options.text, - srt_cues: options.srtCues, - music_url: options.musicUrl, - tts_enabled: options.ttsEnabled ?? false, - tts_voice: options.ttsVoice, - tts_engine: options.ttsEngine, - tts_style: options.ttsStyle, - gemini_api_key: options.geminiApiKey, - qwen_tts_url: options.qwen?.url, - qwen_tts_api_key: options.qwen?.apiKey, - music_volume: options.musicVolume ?? 0.25, - draw_text: options.drawText ?? true, - stabilize: options.stabilize ?? false, - store_name: options.storeName, - enable_ending_effect: options.enableEndingEffect ?? true, - has_logo: options.hasLogo, - }), - timeoutMs: PROCESS_TIMEOUT_MS, - }); - const data = await response.json() as { - job_id: string; - video_path: string; - audio_path: string | null; - total_duration: number; - video_duration: number; - logo_start: number | null; - fade_start?: number | null; - fade_duration?: number | null; - words: TimedWord[]; - tts_engine?: string | null; - tts_voice?: string | null; - warnings?: string[]; - }; - - try { - await fs.promises.mkdir(targetDir, { recursive: true }); - const videoPath = `${targetDir}/video.mp4`; - await this.downloadTo(data.video_path, videoPath); - let audioPath: string | null = null; - if (data.audio_path) { - audioPath = `${targetDir}/audio.wav`; - await this.downloadTo(data.audio_path, audioPath); - } - for (const warning of data.warnings ?? []) console.warn(`⚠️ [FFmpeg] ${warning}`); - return { - videoPath, - audioPath, - totalDuration: data.total_duration, - videoDuration: data.video_duration, - logoStart: data.logo_start, - fadeStart: data.fade_start ?? null, - fadeDuration: data.fade_duration ?? null, - words: data.words ?? [], - ttsEngine: data.tts_engine, - ttsVoice: data.tts_voice, - warnings: data.warnings ?? [], - }; - } finally { - this.call(`/jobs/${data.job_id}`, { method: 'DELETE', timeoutMs: HEALTH_TIMEOUT_MS }) - .catch(() => { /* purgé plus tard par le service */ }); - } - } - - /** Télécharge un fichier du service en flux, sans le charger en mémoire. */ - private async downloadTo(servicePath: string, target: string): Promise { - const response = await this.call(servicePath, { method: 'GET', timeoutMs: DOWNLOAD_TIMEOUT_MS }); - if (!response.body) throw new FFmpegServiceError(`Réponse vide pour ${servicePath}`); - await pipeline( - Readable.fromWeb(response.body as unknown as WebReadableStream), - fs.createWriteStream(target), - ); - } - - async healthCheck(): Promise { - try { - await this.call('/health', { method: 'GET', timeoutMs: HEALTH_TIMEOUT_MS }); - return true; - } catch { - return false; - } - } - - /** Voix proposées par moteur ; `qwen_available` indique si le service local répond. */ - async listVoices(qwen?: QwenTarget): Promise { - const headers: Record = {}; - if (qwen?.url) headers['X-Qwen-Url'] = qwen.url; - if (qwen?.apiKey) headers['X-Qwen-Key'] = qwen.apiKey; - const response = await this.call('/voices', { method: 'GET', headers, timeoutMs: 30_000 }); - return await response.json() as VoiceCatalog; - } - - /** Génère la voix seule (aperçu), avec le minutage de chaque mot. */ - async previewVoice( - text: string, - options: { voice?: string; engine?: TtsEngine; style?: TtsStyle; geminiApiKey?: string; qwen?: QwenTarget } = {}, - ): Promise { - const response = await this.call('/preview-tts', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - text, - tts_voice: options.voice, - tts_engine: options.engine, - tts_style: options.style, - gemini_api_key: options.geminiApiKey, - qwen_tts_url: options.qwen?.url, - qwen_tts_api_key: options.qwen?.apiKey, - }), - timeoutMs: TTS_TIMEOUT_MS, - }); - const data = await response.json() as { - audio_base64: string; - duration: number; - words: TimedWord[]; - engine: string; - voice: string; - warnings?: string[]; - }; - return { - audio: Buffer.from(data.audio_base64, 'base64'), - duration: data.duration, - words: data.words ?? [], - engine: data.engine, - voice: data.voice, - warnings: data.warnings ?? [], - }; - } -} - -export const ffmpegService = new FFmpegService(); diff --git a/server/services/freesound.ts b/server/services/freesound.ts deleted file mode 100644 index e1e694b..0000000 --- a/server/services/freesound.ts +++ /dev/null @@ -1,251 +0,0 @@ -/** - * FreeSound Music API Service - * - * Intégration avec l'API FreeSound pour rechercher et récupérer des sons - * libres de droits pour les Reels. - * - * Documentation: https://freesound.org/docs/api/ - */ - -interface FreeSoundResult { - id: number; - name: string; - duration: number; - username: string; - tags: string[]; - license: string; - previews: { - 'preview-hq-mp3': string; - 'preview-lq-mp3': string; - 'preview-hq-ogg': string; - 'preview-lq-ogg': string; - }; - images: { - waveform_m: string; - waveform_l: string; - spectral_m: string; - spectral_l: string; - }; - download?: string; -} - -interface FreeSoundSearchResponse { - count: number; - next: string | null; - previous: string | null; - results: FreeSoundResult[]; -} - -export interface MusicTrack { - id: string; - title: string; - artist: string; - albumName: string; - duration: number; // en secondes - previewUrl: string; // URL audio streaming - downloadUrl: string; // URL téléchargement - imageUrl: string; // Waveform image - license: string; -} - -interface MusicSearchParams { - minDuration?: number; // Durée min en secondes - maxDuration?: number; // Durée max en secondes - genre?: string; // Genre musical / tag - limit?: number; // Nombre de résultats (default: 10) - offset?: number; // Offset pour pagination - search?: string; // Recherche texte libre -} - -interface FreeSoundConfig { - clientId: string; - clientSecret: string; -} - -const FREESOUND_API_BASE = 'https://freesound.org/apiv2'; - -export class FreeSoundService { - private config: FreeSoundConfig | null = null; - - /** - * Configure le service avec les credentials FreeSound - */ - configure(clientId: string, clientSecret: string): void { - this.config = { clientId, clientSecret }; - console.log('🎵 FreeSound Service configured'); - } - - /** - * Vérifie que le service est configuré - */ - private ensureConfigured(): FreeSoundConfig { - if (!this.config) { - throw new Error('FreeSound Service not configured. Call configure() first.'); - } - return this.config; - } - - /** - * Convertit un résultat FreeSound en format MusicTrack unifié - */ - private mapResult(result: FreeSoundResult): MusicTrack { - return { - id: result.id.toString(), - title: result.name, - artist: result.username, - albumName: 'FreeSound', - duration: Math.round(result.duration), - previewUrl: result.previews['preview-hq-mp3'] || result.previews['preview-lq-mp3'], - downloadUrl: result.previews['preview-hq-mp3'], // On utilise le preview HQ - imageUrl: result.images?.waveform_m || '', - license: result.license, - }; - } - - /** - * Recherche des musiques par durée - * - * @param params - Paramètres de recherche - * @returns Liste de tracks correspondants - */ - async searchMusicByDuration(params: MusicSearchParams = {}): Promise { - const config = this.ensureConfigured(); - - const { - minDuration = 10, - maxDuration = 120, - genre, - limit = 10, - offset = 0, - search, - } = params; - - // Construction du filtre de durée - const durationFilter = `duration:[${minDuration} TO ${maxDuration}]`; - - // Recherche de musiques/sons musicaux - let query = search || 'music'; - if (genre) { - query = `${query} ${genre}`; - } - - const queryParams = new URLSearchParams({ - token: config.clientSecret, // FreeSound utilise le token dans l'URL pour les requêtes simples - query: query, - filter: durationFilter, - page_size: limit.toString(), - page: Math.floor(offset / limit + 1).toString(), - fields: 'id,name,duration,username,tags,license,previews,images', - sort: 'rating_desc', - }); - - const url = `${FREESOUND_API_BASE}/search/text/?${queryParams.toString()}`; - - console.log('🎵 Searching FreeSound:', { - query, - duration: `${minDuration}-${maxDuration}s`, - limit, - offset, - }); - - try { - const response = await fetch(url); - - if (!response.ok) { - const errorText = await response.text(); - console.error('FreeSound API error:', response.status, errorText); - throw new Error(`FreeSound API error: ${response.status}`); - } - - const data = await response.json() as FreeSoundSearchResponse; - - const tracks = data.results.map(result => this.mapResult(result)); - - console.log(`✅ Found ${tracks.length} tracks on FreeSound`); - return tracks; - - } catch (error) { - console.error('❌ FreeSound search error:', error); - throw error; - } - } - - /** - * Récupère les détails d'un son par son ID - */ - async getMusicDetails(trackId: string): Promise { - const config = this.ensureConfigured(); - - const url = `${FREESOUND_API_BASE}/sounds/${trackId}/?token=${config.clientSecret}&fields=id,name,duration,username,tags,license,previews,images`; - - try { - const response = await fetch(url); - - if (!response.ok) { - console.error('FreeSound API error:', response.status); - return null; - } - - const result = await response.json() as FreeSoundResult; - return this.mapResult(result); - - } catch (error) { - console.error('❌ FreeSound getMusicDetails error:', error); - return null; - } - } - - /** - * Récupère des sons populaires (utilisé comme fallback ou découverte) - */ - async getPopularTracks(limit: number = 10): Promise { - const config = this.ensureConfigured(); - - const queryParams = new URLSearchParams({ - token: config.clientSecret, - query: 'music loop beat', - filter: 'duration:[15 TO 90]', - page_size: limit.toString(), - fields: 'id,name,duration,username,tags,license,previews,images', - sort: 'downloads_desc', - }); - - const url = `${FREESOUND_API_BASE}/search/text/?${queryParams.toString()}`; - - try { - const response = await fetch(url); - - if (!response.ok) { - throw new Error(`FreeSound API error: ${response.status}`); - } - - const data = await response.json() as FreeSoundSearchResponse; - return data.results.map(result => this.mapResult(result)); - - } catch (error) { - console.error('❌ FreeSound getPopularTracks error:', error); - return []; - } - } - - /** - * Retourne l'URL de téléchargement/preview - */ - getDownloadUrl(track: MusicTrack): string { - return track.downloadUrl; - } - - /** - * Vérifie la validité de la configuration - */ - async testConnection(): Promise { - try { - const tracks = await this.getPopularTracks(1); - return tracks.length > 0; - } catch { - return false; - } - } -} - -export const freeSoundService = new FreeSoundService(); diff --git a/server/services/imageProcessor.ts b/server/services/imageProcessor.ts deleted file mode 100644 index ebd6e3c..0000000 --- a/server/services/imageProcessor.ts +++ /dev/null @@ -1,301 +0,0 @@ -import sharp from "sharp"; -import { randomUUID } from "crypto"; -import path from "path"; -import fs from "fs/promises"; -import { createCanvas, loadImage, registerFont } from "canvas"; -import { removeHashtags } from "@shared/emoji"; -import { emojiRenderer } from "./emojiRenderer"; - -interface CropDimensions { - width: number; - height: number; -} - -interface ProcessedImages { - facebookFeed: string; - instagramFeed: string; - instagramStory: string; -} - -const CROP_FORMATS = { - facebookFeed: { width: 1200, height: 630 } as CropDimensions, - instagramFeed: { width: 1080, height: 1080 } as CropDimensions, - instagramStory: { width: 1080, height: 1920 } as CropDimensions, -}; - -// Register DejaVu Sans font for proper character rendering -try { - const fontPath = path.join(process.cwd(), "server", "fonts", "DejaVuSans.ttf"); - registerFont(fontPath, { family: 'DejaVu Sans' }); - console.log('✓ Font DejaVu Sans enregistrée avec succès'); -} catch (error) { - console.error('Erreur lors de l\'enregistrement de la police:', error); -} - -export class ImageProcessor { - private uploadsDir: string; - - constructor() { - this.uploadsDir = path.join(process.cwd(), "uploads"); - this.ensureUploadsDirExists(); - } - - private async ensureUploadsDirExists() { - try { - await fs.access(this.uploadsDir); - } catch { - await fs.mkdir(this.uploadsDir, { recursive: true }); - } - } - - async processImage(buffer: Buffer, originalFilename: string): Promise { - const fileExt = path.extname(originalFilename); - const baseId = randomUUID(); - - const processedImages: ProcessedImages = { - facebookFeed: "", - instagramFeed: "", - instagramStory: "", - }; - - // Process Facebook Feed (1200x630) - const fbFeedFilename = `${baseId}-fb-feed${fileExt}`; - const fbFeedPath = path.join(this.uploadsDir, fbFeedFilename); - await sharp(buffer) - .rotate() // Auto-rotate based on EXIF orientation - .resize(CROP_FORMATS.facebookFeed.width, CROP_FORMATS.facebookFeed.height, { - fit: "cover", - position: "center", - }) - .toFile(fbFeedPath); - processedImages.facebookFeed = `/uploads/${fbFeedFilename}`; - - // Process Instagram Feed (1080x1080) - const igFeedFilename = `${baseId}-ig-feed${fileExt}`; - const igFeedPath = path.join(this.uploadsDir, igFeedFilename); - await sharp(buffer) - .rotate() // Auto-rotate based on EXIF orientation - .resize(CROP_FORMATS.instagramFeed.width, CROP_FORMATS.instagramFeed.height, { - fit: "cover", - position: "center", - }) - .toFile(igFeedPath); - processedImages.instagramFeed = `/uploads/${igFeedFilename}`; - - // Process Instagram Story (1080x1920) - const igStoryFilename = `${baseId}-ig-story${fileExt}`; - const igStoryPath = path.join(this.uploadsDir, igStoryFilename); - await sharp(buffer) - .rotate() // Auto-rotate based on EXIF orientation - .resize(CROP_FORMATS.instagramStory.width, CROP_FORMATS.instagramStory.height, { - fit: "cover", - position: "center", - }) - .toFile(igStoryPath); - processedImages.instagramStory = `/uploads/${igStoryFilename}`; - - return processedImages; - } - - async saveOriginalFile(buffer: Buffer, filename: string): Promise { - const uniqueFilename = `${randomUUID()}-${filename}`; - const filePath = path.join(this.uploadsDir, uniqueFilename); - await fs.writeFile(filePath, buffer); - return `/uploads/${uniqueFilename}`; - } - - async deleteFile(filePath: string): Promise { - try { - const fullPath = path.join(process.cwd(), filePath); - await fs.unlink(fullPath); - } catch (error) { - console.error("Error deleting file:", error); - } - } - - async addTextToStoryImage(imageUrl: string, text: string): Promise { - const STORY_WIDTH = 1080; - const STORY_HEIGHT = 1920; - const TEXT_BOX_HEIGHT_RATIO = 0.25; - const MIN_FONT_SIZE = 12; - const MAX_FONT_SIZE = 72; - const PADDING = 40; - const LINE_HEIGHT_MULTIPLIER = 1.3; - - // Remove hashtags from text while keeping emojis - const cleanText = removeHashtags(text); - - // Download image from URL first (Sharp can't read URLs directly) - const imageResponse = await fetch(imageUrl); - if (!imageResponse.ok) { - throw new Error(`Failed to fetch image from URL: ${imageUrl}`); - } - const imageBuffer = Buffer.from(await imageResponse.arrayBuffer()); - - // Pre-process image with sharp to apply rotation - const rotationBuffer = await sharp(imageBuffer) - .rotate() // Apply EXIF rotation - .toBuffer(); - - const canvas = createCanvas(STORY_WIDTH, STORY_HEIGHT); - const ctx = canvas.getContext('2d'); - - const image = await loadImage(rotationBuffer); - - const imgWidth = image.width; - const imgHeight = image.height; - const targetRatio = STORY_WIDTH / STORY_HEIGHT; - const imgRatio = imgWidth / imgHeight; - - let drawWidth, drawHeight, offsetX, offsetY; - - if (imgRatio > targetRatio) { - drawHeight = STORY_HEIGHT; - drawWidth = imgWidth * (STORY_HEIGHT / imgHeight); - offsetX = (STORY_WIDTH - drawWidth) / 2; - offsetY = 0; - } else { - drawWidth = STORY_WIDTH; - drawHeight = imgHeight * (STORY_WIDTH / imgWidth); - offsetX = 0; - offsetY = (STORY_HEIGHT - drawHeight) / 2; - } - - ctx.fillStyle = 'black'; - ctx.fillRect(0, 0, STORY_WIDTH, STORY_HEIGHT); - ctx.drawImage(image, offsetX, offsetY, drawWidth, drawHeight); - - const textBoxHeight = STORY_HEIGHT * TEXT_BOX_HEIGHT_RATIO; - const textBoxY = STORY_HEIGHT - textBoxHeight; - - ctx.fillStyle = 'rgba(0, 0, 0, 0.6)'; - ctx.fillRect(0, textBoxY, STORY_WIDTH, textBoxHeight); - - const maxTextWidth = STORY_WIDTH - (PADDING * 2); - const maxTextHeight = textBoxHeight - (PADDING * 2); - - let fontSize = MAX_FONT_SIZE; - let lines: string[] = []; - let totalTextHeight = 0; - - while (fontSize >= MIN_FONT_SIZE) { - ctx.font = `bold ${fontSize}px "DejaVu Sans", Arial, sans-serif`; - - lines = await emojiRenderer.wrapText(cleanText, ctx, maxTextWidth, fontSize); - const lineHeight = fontSize * LINE_HEIGHT_MULTIPLIER; - totalTextHeight = lines.length * lineHeight; - - if (totalTextHeight <= maxTextHeight) { - break; - } - - fontSize -= 2; - } - - if (fontSize < MIN_FONT_SIZE) { - fontSize = MIN_FONT_SIZE; - ctx.font = `bold ${fontSize}px "DejaVu Sans", Arial, sans-serif`; - lines = await emojiRenderer.wrapText(cleanText, ctx, maxTextWidth, fontSize); - const lineHeight = fontSize * LINE_HEIGHT_MULTIPLIER; - totalTextHeight = lines.length * lineHeight; - - const maxLines = Math.floor(maxTextHeight / lineHeight); - if (lines.length > maxLines) { - lines = lines.slice(0, maxLines); - if (lines[maxLines - 1]) { - lines[maxLines - 1] = lines[maxLines - 1].substring(0, lines[maxLines - 1].length - 3) + '...'; - } - totalTextHeight = maxLines * lineHeight; - } - } - - ctx.fillStyle = 'white'; - ctx.textAlign = 'left'; - ctx.textBaseline = 'middle'; - - const lineHeight = fontSize * LINE_HEIGHT_MULTIPLIER; - const startY = textBoxY + (textBoxHeight - totalTextHeight) / 2 + lineHeight / 2; - - for (let index = 0; index < lines.length; index++) { - const line = lines[index]; - const y = startY + (index * lineHeight); - const lineWidth = await emojiRenderer.measureText(line, ctx, fontSize); - const x = (STORY_WIDTH - lineWidth) / 2; // Center the line - await emojiRenderer.drawText(line, ctx, x, y, fontSize); - } - - return canvas.toBuffer('image/png'); - } - - private wrapText(ctx: any, text: string, maxWidth: number): string[] { - const words = text.split(' '); - const lines: string[] = []; - let currentLine = ''; - - for (const word of words) { - const testLine = currentLine ? `${currentLine} ${word}` : word; - const metrics = ctx.measureText(testLine); - - if (metrics.width > maxWidth && currentLine) { - // Current line would exceed width, push it and start new line - lines.push(currentLine); - currentLine = word; - - // Check if the word itself is too long for a single line - const wordMetrics = ctx.measureText(word); - if (wordMetrics.width > maxWidth) { - // Word is too long, need to break it into chunks - const chunks = this.breakLongWord(ctx, word, maxWidth); - for (let i = 0; i < chunks.length - 1; i++) { - lines.push(chunks[i]); - } - currentLine = chunks[chunks.length - 1]; - } - } else if (metrics.width > maxWidth && !currentLine) { - // First word is too long, need to break it - const chunks = this.breakLongWord(ctx, word, maxWidth); - for (let i = 0; i < chunks.length - 1; i++) { - lines.push(chunks[i]); - } - currentLine = chunks[chunks.length - 1]; - } else { - currentLine = testLine; - } - } - - if (currentLine) { - lines.push(currentLine); - } - - return lines; - } - - /** - * Breaks a long word into chunks that fit within maxWidth - */ - private breakLongWord(ctx: any, word: string, maxWidth: number): string[] { - const chunks: string[] = []; - let currentChunk = ''; - - for (let i = 0; i < word.length; i++) { - const char = word[i]; - const testChunk = currentChunk + char; - const metrics = ctx.measureText(testChunk); - - if (metrics.width > maxWidth && currentChunk) { - chunks.push(currentChunk); - currentChunk = char; - } else { - currentChunk = testChunk; - } - } - - if (currentChunk) { - chunks.push(currentChunk); - } - - return chunks.length > 0 ? chunks : [word]; - } -} - -export const imageProcessor = new ImageProcessor(); diff --git a/server/services/jamendo.ts b/server/services/jamendo.ts deleted file mode 100644 index 34e1541..0000000 --- a/server/services/jamendo.ts +++ /dev/null @@ -1,296 +0,0 @@ -/** - * Jamendo Music API Service - * - * Intégration avec l'API Jamendo pour rechercher et récupérer des musiques - * libres de droits pour les Reels. - * - * Documentation: https://developer.jamendo.com/v3.0 - */ - -interface JamendoTrack { - id: string; - name: string; - duration: number; // en secondes - artist_id: string; - artist_name: string; - album_name: string; - album_id: string; - license_ccurl: string; - position: number; - releasedate: string; - album_image: string; - audio: string; // URL du fichier audio complet - audiodownload: string; // URL de téléchargement - prourl: string; - shorturl: string; - shareurl: string; - waveform: string; - image: string; - audiodownload_allowed: boolean; -} - -interface JamendoResponse { - headers: { - status: string; - code: number; - error_message: string; - warnings: string; - results_count: number; - }; - results: JamendoTrack[]; -} - -export interface MusicTrack { - id: string; - title: string; - artist: string; - albumName: string; - duration: number; // en secondes - previewUrl: string; // URL audio streaming - downloadUrl: string; // URL téléchargement - imageUrl: string; // Pochette album - license: string; -} - -interface MusicSearchParams { - minDuration?: number; // Durée min en secondes - maxDuration?: number; // Durée max en secondes - genre?: string; // Genre musical (ex: "pop", "rock", "electronic") - mood?: string; // Ambiance (ex: "happy", "sad", "energetic") - limit?: number; // Nombre de résultats (default: 10) - offset?: number; // Offset pour pagination - search?: string; // Recherche texte libre -} - -interface JamendoConfig { - clientId: string; -} - -const JAMENDO_API_BASE = 'https://api.jamendo.com/v3.0'; - -// Mapping des genres Jamendo -const GENRE_MAPPING: Record = { - 'pop': 'pop', - 'rock': 'rock', - 'electronic': 'electronic', - 'hiphop': 'hiphop', - 'jazz': 'jazz', - 'classical': 'classical', - 'ambient': 'ambient', - 'chill': 'chillout', - 'dance': 'dance', - 'indie': 'indie', -}; - -export class JamendoService { - private config: JamendoConfig | null = null; - - /** - * Configure le service avec le Client ID Jamendo - */ - configure(clientId: string): void { - this.config = { clientId }; - console.log('🎵 Jamendo Service configured'); - } - - /** - * Vérifie que le service est configuré - */ - private ensureConfigured(): JamendoConfig { - if (!this.config) { - throw new Error('Jamendo Service not configured. Call configure() first.'); - } - return this.config; - } - - /** - * Convertit un track Jamendo en format MusicTrack unifié - */ - private mapTrack(track: JamendoTrack): MusicTrack { - return { - id: track.id, - title: track.name, - artist: track.artist_name, - albumName: track.album_name, - duration: track.duration, - previewUrl: track.audio, - downloadUrl: track.audiodownload, - imageUrl: track.album_image || track.image, - license: track.license_ccurl, - }; - } - - /** - * Recherche des musiques par durée - * - * @param params - Paramètres de recherche - * @returns Liste de tracks correspondants - */ - async searchMusicByDuration(params: MusicSearchParams = {}): Promise { - const config = this.ensureConfigured(); - - const { - minDuration = 10, - maxDuration = 120, - genre, - limit = 10, - offset = 0, - search, - } = params; - - // Construction de l'URL avec les paramètres - const queryParams = new URLSearchParams({ - client_id: config.clientId, - format: 'json', - limit: limit.toString(), - offset: offset.toString(), - include: 'musicinfo', - audioformat: 'mp32', // MP3 320kbps - durationbetween: `${minDuration}_${maxDuration}`, - order: 'popularity_total', - }); - - // Ajouter le genre si spécifié - if (genre && GENRE_MAPPING[genre.toLowerCase()]) { - queryParams.append('tags', GENRE_MAPPING[genre.toLowerCase()]); - } else if (genre) { - queryParams.append('tags', genre.toLowerCase()); - } - - // Ajouter la recherche texte si spécifiée - if (search) { - queryParams.append('namesearch', search); - } - - const url = `${JAMENDO_API_BASE}/tracks/?${queryParams.toString()}`; - - console.log('🎵 Searching Jamendo:', { - duration: `${minDuration}-${maxDuration}s`, - genre, - limit, - offset, - }); - - try { - const response = await fetch(url); - - if (!response.ok) { - throw new Error(`Jamendo API error: ${response.status}`); - } - - const data = await response.json() as JamendoResponse; - - if (data.headers.code !== 0) { - throw new Error(`Jamendo API error: ${data.headers.error_message}`); - } - - const tracks = data.results.map(track => this.mapTrack(track)); - - console.log(`✅ Found ${tracks.length} tracks on Jamendo`); - return tracks; - - } catch (error) { - console.error('❌ Jamendo search error:', error); - throw error; - } - } - - /** - * Récupère les détails d'un track par son ID - */ - async getMusicDetails(trackId: string): Promise { - const config = this.ensureConfigured(); - - const queryParams = new URLSearchParams({ - client_id: config.clientId, - format: 'json', - id: trackId, - include: 'musicinfo', - audioformat: 'mp32', - }); - - const url = `${JAMENDO_API_BASE}/tracks/?${queryParams.toString()}`; - - try { - const response = await fetch(url); - - if (!response.ok) { - throw new Error(`Jamendo API error: ${response.status}`); - } - - const data = await response.json() as JamendoResponse; - - if (data.headers.code !== 0 || data.results.length === 0) { - return null; - } - - return this.mapTrack(data.results[0]); - - } catch (error) { - console.error('❌ Jamendo getMusicDetails error:', error); - return null; - } - } - - /** - * Récupère des tracks populaires (utilisé comme fallback ou découverte) - */ - async getPopularTracks(limit: number = 10): Promise { - const config = this.ensureConfigured(); - - const queryParams = new URLSearchParams({ - client_id: config.clientId, - format: 'json', - limit: limit.toString(), - include: 'musicinfo', - audioformat: 'mp32', - order: 'popularity_week', - durationbetween: '15_90', // Durée idéale pour Reels - }); - - const url = `${JAMENDO_API_BASE}/tracks/?${queryParams.toString()}`; - - try { - const response = await fetch(url); - - if (!response.ok) { - throw new Error(`Jamendo API error: ${response.status}`); - } - - const data = await response.json() as JamendoResponse; - - if (data.headers.code !== 0) { - throw new Error(`Jamendo API error: ${data.headers.error_message}`); - } - - return data.results.map(track => this.mapTrack(track)); - - } catch (error) { - console.error('❌ Jamendo getPopularTracks error:', error); - return []; - } - } - - /** - * Télécharge l'URL du fichier audio (avec redirection si nécessaire) - * Note: Jamendo fournit directement les URLs de téléchargement - */ - getDownloadUrl(track: MusicTrack): string { - // L'URL de téléchargement Jamendo est directement utilisable - return track.downloadUrl; - } - - /** - * Vérifie la validité de la configuration - */ - async testConnection(): Promise { - try { - const tracks = await this.getPopularTracks(1); - return tracks.length > 0; - } catch { - return false; - } - } -} - -export const jamendoService = new JamendoService(); diff --git a/server/services/media-purge.ts b/server/services/media-purge.ts deleted file mode 100644 index a99c2e1..0000000 --- a/server/services/media-purge.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { db } from '../db'; -import { media } from '@shared/schema'; -import { eq, lt, and } from 'drizzle-orm'; -import { minioService as storageService } from './minio'; - -/** - * Purge old local media to save disk space: - * - Images older than 30 days - * - Videos older than 7 days - */ -export async function purgeOldMedia() { - console.log('[Media Purge] Starting automatic purge of old media...'); - - try { - const thirtyDaysAgo = new Date(); - thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); - - const sevenDaysAgo = new Date(); - sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); - - const oldImages = await db.query.media.findMany({ - where: (m, { and, lt, eq }) => and(eq(m.type, 'image'), lt(m.createdAt, thirtyDaysAgo)) - }); - - const oldVideos = await db.query.media.findMany({ - where: (m, { and, lt, eq }) => and(eq(m.type, 'video'), lt(m.createdAt, sevenDaysAgo)) - }); - - const toDelete = [...oldImages, ...oldVideos]; - - if (toDelete.length === 0) { - console.log('[Media Purge] No old media found to purge.'); - return; - } - - console.log(`[Media Purge] Found ${oldImages.length} old images (>30 days) and ${oldVideos.length} old videos (>7 days) to purge.`); - - let successCount = 0; - let errorCount = 0; - - for (const item of toDelete) { - try { - if (item.cloudinaryPublicId) { - await storageService.deleteMedia(item.cloudinaryPublicId, item.userId, item.type === 'video' ? 'video' : 'image'); - } - // La vignette n'a plus de raison d'être une fois la ligne supprimée - if (item.thumbnailUrl?.startsWith('/uploads/')) { - await storageService - .deleteMedia(item.thumbnailUrl.replace('/uploads/', ''), item.userId, 'image') - .catch(err => console.warn(`[Media Purge] Vignette non supprimée pour ${item.id}:`, err)); - } - await db.delete(media).where(eq(media.id, item.id)); - successCount++; - } catch (err) { - console.error(`[Media Purge] Failed to delete media ${item.id}:`, err); - errorCount++; - } - } - - console.log(`[Media Purge] Purge completed. Deleted: ${successCount}, Failed: ${errorCount}.`); - } catch (error) { - console.error('[Media Purge] Fatal error during media purge process:', error); - } -} diff --git a/server/services/minio.ts b/server/services/minio.ts deleted file mode 100644 index d99537f..0000000 --- a/server/services/minio.ts +++ /dev/null @@ -1,143 +0,0 @@ -import fs from 'fs'; -import path from 'path'; - -const UPLOADS_BASE = path.resolve(process.cwd(), 'uploads'); - -function ensureDir(dir: string): void { - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } -} - -/** - * Builds the public URL for a locally stored file. - * Returns a relative path so it works on any domain/protocol. - * objectKey is the relative path from uploads/ (e.g. "media/123-file.jpg"). - */ -export function buildMinioUrl(_bucketName: string, objectKey: string, _publicUrlOverride?: string | null): string { - return `/uploads/${objectKey}`; -} - -/** - * Resolves a possibly-relative URL to an absolute URL for use by external - * services (Facebook API, Instagram API) that must download the file. - * Uses APP_URL env var (public-facing domain). - */ -export function resolvePublicUrl(url: string): string { - if (!url || !url.startsWith('/')) return url; - const base = (process.env.APP_URL || 'http://localhost:5555').replace(/\/$/, ''); - return `${base}${url}`; -} - -/** - * Resolves a possibly-relative URL to an absolute URL for use by internal - * Docker services (FFmpeg, etc.) that communicate via internal Docker network. - * Uses INTERNAL_APP_URL env var. - */ -export function resolveInternalUrl(url: string): string { - if (!url || !url.startsWith('/')) return url; - const base = (process.env.INTERNAL_APP_URL || process.env.APP_URL || 'http://localhost:5555').replace(/\/$/, ''); - return `${base}${url}`; -} - -/** - * Charge un média en mémoire, que son URL soit locale (/uploads/...) ou distante. - * Nécessaire pour les APIs qui veulent recevoir le fichier plutôt qu'une URL - * (upload TikTok, Reels Facebook). - */ -export async function readMediaBuffer(url: string): Promise { - if (url.startsWith('/')) { - const localPath = path.join(process.cwd(), url); - if (fs.existsSync(localPath)) { - return await fs.promises.readFile(localPath); - } - } - - const absoluteUrl = resolvePublicUrl(url); - const response = await fetch(absoluteUrl); - if (!response.ok) { - throw new Error(`Impossible de récupérer le média depuis ${absoluteUrl} (${response.status})`); - } - return Buffer.from(await response.arrayBuffer()); -} - -class LocalStorageService { - async uploadMedia( - file: Buffer | string, - fileName: string, - _userId: string, - _mimeType: string - ): Promise<{ - publicId: string; - originalUrl: string; - facebookFeedUrl: string | null; - instagramFeedUrl: string | null; - instagramStoryUrl: string | null; - }> { - const dir = path.join(UPLOADS_BASE, 'media'); - ensureDir(dir); - - const safeName = path.basename(fileName).replace(/[^a-zA-Z0-9._-]/g, '-'); - const objectKey = `media/${Date.now()}-${safeName}`; - const filePath = path.join(UPLOADS_BASE, objectKey); - - const body: Buffer = typeof file === 'string' ? fs.readFileSync(file) : file; - fs.writeFileSync(filePath, body); - - const url = buildMinioUrl('', objectKey); - return { publicId: objectKey, originalUrl: url, facebookFeedUrl: url, instagramFeedUrl: url, instagramStoryUrl: url }; - } - - async deleteMedia(publicId: string, _userId: string, _mediaType: 'image' | 'video'): Promise { - const filePath = path.join(UPLOADS_BASE, publicId); - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath); - } - } - - async uploadStoryImageWithText(buffer: Buffer, originalFileName: string): Promise { - const dir = path.join(UPLOADS_BASE, 'stories'); - ensureDir(dir); - - const safeName = path.basename(originalFileName).replace(/[^a-zA-Z0-9._-]/g, '-'); - const objectKey = `stories/story-${Date.now()}-${safeName}`; - - fs.writeFileSync(path.join(UPLOADS_BASE, objectKey), buffer); - return buildMinioUrl('', objectKey); - } - - /** - * Range une vignette vidéo. Elle survit volontairement à la suppression de la - * vidéo source, pour que l'historique reste illustré. - */ - async uploadThumbnail(buffer: Buffer, fileName: string): Promise<{ publicId: string; url: string }> { - const dir = path.join(UPLOADS_BASE, 'thumbnails'); - ensureDir(dir); - - const safeName = path.basename(fileName).replace(/[^a-zA-Z0-9._-]/g, '-'); - const objectKey = `thumbnails/${safeName}`; - - fs.writeFileSync(path.join(UPLOADS_BASE, objectKey), buffer); - return { publicId: objectKey, url: buildMinioUrl('', objectKey) }; - } - - async uploadLogo(buffer: Buffer, fileName: string): Promise<{ publicId: string; url: string }> { - const dir = path.join(UPLOADS_BASE, 'logos'); - ensureDir(dir); - - const ext = path.extname(fileName) || '.png'; - const objectKey = `logos/logo-${Date.now()}${ext}`; - - fs.writeFileSync(path.join(UPLOADS_BASE, objectKey), buffer); - return { publicId: objectKey, url: buildMinioUrl('', objectKey) }; - } - - async deleteLogo(publicId: string): Promise { - const filePath = path.join(UPLOADS_BASE, publicId); - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath); - } - } -} - -export const minioService = new LocalStorageService(); diff --git a/server/services/openrouter.ts b/server/services/openrouter.ts deleted file mode 100644 index 98d2fc5..0000000 --- a/server/services/openrouter.ts +++ /dev/null @@ -1,289 +0,0 @@ -interface ProductInfo { - name: string; - description?: string; - price?: string; - features?: string[]; - [key: string]: any; -} - -interface GeneratedText { - variant: string; - text: string; - characterCount: number; -} - -import crypto from 'crypto'; -import { storage } from '../storage'; - -/** - * Extrait le message d'erreur d'une réponse OpenRouter, qu'elle soit au format - * JSON ({ error: { message } }) ou en texte brut. Tronqué pour éviter de - * déverser une page HTML entière dans l'interface. - */ -function extractUpstreamMessage(body: string): string { - const trimmed = (body || '').trim(); - if (!trimmed) return 'aucun détail fourni'; - - try { - const parsed = JSON.parse(trimmed); - const message = parsed?.error?.message || parsed?.message || parsed?.error; - if (typeof message === 'string' && message.trim()) { - return message.trim().slice(0, 300); - } - } catch { - // Réponse non-JSON (page d'erreur HTML d'un proxy, par exemple) - } - - return trimmed.replace(/\s+/g, ' ').slice(0, 300); -} - -/** - * Traduit une erreur de génération en réponse HTTP exploitable côté interface. - * Sans cela, l'utilisateur ne voit qu'un 500 générique alors que la cause - * (configuration absente, clé invalide, crédits épuisés) est connue du serveur. - */ -export function describeGenerationError(error: unknown): { status: number; message: string } { - const message = error instanceof Error ? error.message : 'Erreur inconnue lors de la génération'; - - // Problème de configuration : l'administrateur peut le corriger lui-même - if (/Configuration OpenRouter non trouvée|Clé API OpenRouter manquante/.test(message)) { - return { status: 400, message }; - } - - // Refus du service tiers - if (/OpenRouter a refusé la requête|Réponse invalide de l'API OpenRouter/.test(message)) { - return { status: 502, message }; - } - - return { status: 500, message }; -} - -export class OpenRouterService { - private baseUrl = "https://openrouter.ai/api/v1/chat/completions"; - - async generatePostText(productInfo: ProductInfo, userId: string, modelOverride?: string): Promise { - // Prefer the requesting user's own configuration; fall back to the most - // recently saved shared configuration if this user hasn't set one up. - const config = (await storage.getOpenrouterConfig(userId)) || (await storage.getAnyOpenrouterConfig()); - - if (!config) { - throw new Error('Configuration OpenRouter non trouvée. Veuillez demander à un administrateur de configurer OpenRouter dans les Paramètres.'); - } - - if (!config.apiKey || !config.apiKey.trim()) { - throw new Error('Clé API OpenRouter manquante ou vide en base de données. Veuillez la ressaisir dans les Paramètres.'); - } - - const prompt = this.buildPrompt(productInfo, config.systemPrompt); - - // Use provided model or fall back to config model - const modelToUse = modelOverride || config.model; - - const keyFingerprint = crypto.createHash('sha256').update(config.apiKey).digest('hex').slice(0, 8); - console.log(`[OpenRouter] Generating with model="${modelToUse}" keyFingerprint=${keyFingerprint} keyLength=${config.apiKey.length} configUserId=${config.userId}`); - - try { - const response = await fetch(this.baseUrl, { - method: "POST", - headers: { - "Authorization": `Bearer ${config.apiKey.trim()}`, - "Content-Type": "application/json", - "HTTP-Referer": process.env.APP_URL || "http://localhost:5555", - "X-Title": "Social Flow" - }, - body: JSON.stringify({ - model: modelToUse, - messages: [ - { - role: "user", - content: prompt - } - ], - temperature: 0.8, - max_tokens: 1000 - }) - }); - - if (!response.ok) { - const errorText = await response.text(); - console.error('OpenRouter API Error Response:', errorText); - // Le motif renvoyé par OpenRouter (clé invalide, crédits épuisés, modèle - // inconnu…) est la seule information exploitable : on le fait remonter - // jusqu'à l'utilisateur, qui n'a pas accès aux logs du serveur. - throw new Error( - `OpenRouter a refusé la requête (${response.status} ${response.statusText}) ` + - `avec le modèle "${modelToUse}" : ${extractUpstreamMessage(errorText)}` - ); - } - - const data = await response.json(); - - if (!data.choices || !data.choices[0]) { - console.error('Invalid OpenRouter response - no choices:', { hasChoices: !!data.choices, model: modelToUse }); - throw new Error('Réponse invalide de l\'API OpenRouter. Vérifiez votre clé API et votre modèle.'); - } - - const content = data.choices[0]?.message?.content || ""; - - return this.parseGeneratedText(content); - } catch (error) { - console.error("Error generating text with OpenRouter:", error); - throw error; - } - } - - private buildPrompt(productInfo: ProductInfo, systemPrompt: string): string { - const features = productInfo.features?.join(", ") || ""; - - const productDetails = `Nom: ${productInfo.name} -${productInfo.description ? `Description: ${productInfo.description}` : ''} -${productInfo.price ? `Prix: ${productInfo.price}` : ''} -${features ? `Caractéristiques: ${features}` : ''}`; - - return `${systemPrompt} - -Informations produit: -${productDetails} - -Format de réponse: -VERSION 1 - DYNAMIQUE: -[texte] - -VERSION 2 - INFORMATIVE: -[texte] - -VERSION 3 - ÉMOTIONNELLE: -[texte]`; - } - - private parseGeneratedText(content: string): GeneratedText[] { - // Normaliser les retours à la ligne - const normalizedContent = content.replace(/\r\n/g, '\n').trim(); - - console.log('🔍 Parsing AI response (length:', normalizedContent.length, ')'); - - // Stratégie 1: Détection des headers VERSION avec parsing intelligent - const variants = this.tryMultipleParsingStrategies(normalizedContent); - - if (variants.length > 0) { - console.log('✅ Parsed successfully:', variants.length, 'variants'); - return variants; - } - - // Fallback: retourner le texte complet - console.log('⚠️ Parsing failed, using fallback'); - return [{ - variant: "Généré par IA", - text: normalizedContent, - characterCount: normalizedContent.length - }]; - } - - private tryMultipleParsingStrategies(content: string): GeneratedText[] { - // Stratégie 1: Split par headers de VERSION (le plus flexible) - const strategy1 = this.parseByVersionHeaders(content); - if (strategy1.length >= 3) return strategy1; - - // Stratégie 2: Regex avec markdown **VERSION N:** - const strategy2 = this.parseWithMarkdownHeaders(content); - if (strategy2.length >= 3) return strategy2; - - // Stratégie 3: Regex simple sans markdown - const strategy3 = this.parseWithSimpleHeaders(content); - if (strategy3.length >= 3) return strategy3; - - // Retourner le meilleur résultat obtenu - return strategy1.length > 0 ? strategy1 : (strategy2.length > 0 ? strategy2 : strategy3); - } - - private parseByVersionHeaders(content: string): GeneratedText[] { - const variants: GeneratedText[] = []; - - // Chercher tous les patterns VERSION N (très flexible) - const versionPattern = /(?:\*{2})?VERSION\s+(\d+)\s*[-–—]\s*([A-ZÀÂÄÉÈÊËÏÎÔÙÛÜ]+)(?:\*{2})?:?/gi; - const matches = Array.from(content.matchAll(versionPattern)); - - if (matches.length < 3) return variants; - - // Extraire le texte entre chaque header - for (let i = 0; i < matches.length; i++) { - const match = matches[i]; - const versionNum = match[1]; - const style = match[2]; - const startPos = match.index! + match[0].length; - const endPos = i < matches.length - 1 ? matches[i + 1].index! : content.length; - - let text = content.substring(startPos, endPos).trim(); - - // Nettoyer le texte (enlever les séparateurs, notes entre parenthèses au début, etc.) - text = text.replace(/^[-*]{3,}\s*/gm, ''); // Enlever les séparateurs - text = text.replace(/^\([^)]+\)\s*/gm, ''); // Enlever les notes entre parenthèses - text = text.trim(); - - if (text.length > 0) { - variants.push({ - variant: `Version ${versionNum} - ${this.capitalizeFirst(style.toLowerCase())}`, - text, - characterCount: text.length - }); - } - } - - return variants; - } - - private parseWithMarkdownHeaders(content: string): GeneratedText[] { - const variants: GeneratedText[] = []; - - const v1 = content.match(/\*{2}VERSION\s+1\s*[-–—]\s*DYNAMIQUE:?\*{2}\s*\n([\s\S]*?)(?=\*{2}VERSION\s+2|$)/i); - const v2 = content.match(/\*{2}VERSION\s+2\s*[-–—]\s*INFORMATIVE?:?\*{2}\s*\n([\s\S]*?)(?=\*{2}VERSION\s+3|$)/i); - const v3 = content.match(/\*{2}VERSION\s+3\s*[-–—]\s*[ÉE]MOTIONNELLE?:?\*{2}\s*\n([\s\S]*?)$/i); - - if (v1) variants.push({ variant: "Version 1 - Dynamique", text: v1[1].trim(), characterCount: v1[1].trim().length }); - if (v2) variants.push({ variant: "Version 2 - Informative", text: v2[1].trim(), characterCount: v2[1].trim().length }); - if (v3) variants.push({ variant: "Version 3 - Émotionnelle", text: v3[1].trim(), characterCount: v3[1].trim().length }); - - return variants; - } - - private parseWithSimpleHeaders(content: string): GeneratedText[] { - const variants: GeneratedText[] = []; - - const v1 = content.match(/VERSION\s+1\s*[-–—]\s*DYNAMIQUE:?\s*\n([\s\S]*?)(?=VERSION\s+2|$)/i); - const v2 = content.match(/VERSION\s+2\s*[-–—]\s*INFORMATIVE?:?\s*\n([\s\S]*?)(?=VERSION\s+3|$)/i); - const v3 = content.match(/VERSION\s+3\s*[-–—]\s*[ÉE]MOTIONNELLE?:?\s*\n([\s\S]*?)$/i); - - if (v1) variants.push({ variant: "Version 1 - Dynamique", text: v1[1].trim(), characterCount: v1[1].trim().length }); - if (v2) variants.push({ variant: "Version 2 - Informative", text: v2[1].trim(), characterCount: v2[1].trim().length }); - if (v3) variants.push({ variant: "Version 3 - Émotionnelle", text: v3[1].trim(), characterCount: v3[1].trim().length }); - - return variants; - } - - private capitalizeFirst(text: string): string { - return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase(); - } - - async getAvailableModels(): Promise { - try { - const response = await fetch("https://openrouter.ai/api/v1/models", { - method: "GET", - headers: { - "Content-Type": "application/json", - } - }); - - if (!response.ok) { - throw new Error(`Failed to fetch models: ${response.status} ${response.statusText}`); - } - - const data = await response.json(); - return data.data || []; - } catch (error) { - console.error("Error fetching OpenRouter models:", error); - throw error; - } - } -} - -export const openRouterService = new OpenRouterService(); diff --git a/server/services/reels/assets.ts b/server/services/reels/assets.ts deleted file mode 100644 index 6c82300..0000000 --- a/server/services/reels/assets.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Résolution des ressources d'un Reel (musique, logo, clé Gemini, nom du - * magasin), jusqu'ici recopiée dans chaque route. - */ - -import type { QwenTarget } from "../ffmpeg"; -import { storage } from "../../storage"; -import { buildMinioUrl, resolveInternalUrl } from "../minio"; - -/** - * URL de la musique téléchargeable par le service FFmpeg (réseau Docker - * interne : l'URL publique HTTPS n'y est pas joignable). - */ -export async function resolveMusicUrl( - musicTrackId?: string, - musicUrl?: string, -): Promise { - if (musicUrl) return resolveInternalUrl(musicUrl); - if (!musicTrackId?.startsWith("internal_")) return undefined; - - try { - const track = await storage.getAudioTrack(musicTrackId.slice("internal_".length)); - return track ? resolveInternalUrl(track.url) : undefined; - } catch (error) { - console.error("⚠️ [Reels] Piste audio introuvable :", error); - return undefined; - } -} - -/** Chemin relatif (/uploads/...) du logo configuré, s'il existe. */ -export async function resolveLogoPath(): Promise { - try { - const config = await storage.getCloudinaryConfig(); - if (config?.logoPublicId) { - return buildMinioUrl(config.cloudName, config.logoPublicId, config.publicUrl); - } - } catch (error) { - console.error("⚠️ [Reels] Configuration du logo illisible :", error); - } - return undefined; -} - -/** - * Clé Gemini : configuration de l'application, sinon variable d'environnement. - * Aussi transmise avec Qwen, qui se replie sur Gemini si le service local échoue. - */ -export async function resolveGeminiApiKey(ttsEngine?: string): Promise { - if (ttsEngine !== "gemini" && ttsEngine !== "qwen") return undefined; - const appConfig = await storage.getAppConfig(); - return appConfig?.geminiApiKey ?? process.env.GEMINI_API_KEY ?? undefined; -} - -/** Service Qwen TTS réglé dans les paramètres (sinon ffmpeg-api prend QWEN_TTS_URL). */ -export async function resolveQwenTarget(ttsEngine?: string): Promise { - if (ttsEngine !== "qwen") return undefined; - return qwenTargetFromConfig(await storage.getAppConfig()); -} - -/** Adresse http(s) du service Qwen saisie dans les paramètres, sans « / » final ; null si invalide. */ -export function normalizeQwenUrl(value: unknown): string | null { - if (typeof value !== "string" || !value.trim()) return null; - try { - const url = new URL(value.trim()); - if (url.protocol !== "http:" && url.protocol !== "https:") return null; - return url.toString().replace(/\/+$/, ""); - } catch { - return null; - } -} - -export function qwenTargetFromConfig( - appConfig: { qwenTtsUrl?: string | null; qwenTtsApiKey?: string | null } | undefined, -): QwenTarget | undefined { - if (!appConfig?.qwenTtsUrl) return undefined; - return { url: appConfig.qwenTtsUrl, apiKey: appConfig.qwenTtsApiKey ?? undefined }; -} - -/** Nom affiché dans l'outro : celui de la page choisie, sinon de la première page de l'utilisateur. */ -export async function resolveStoreName( - userId: string, - preferredPageId?: string, -): Promise { - try { - if (preferredPageId) { - const page = await storage.getSocialPage(preferredPageId); - if (page) return page.pageName; - } - const pages = await storage.getSocialPages(userId); - return pages[0]?.pageName; - } catch (error) { - console.error("⚠️ [Reels] Nom du magasin introuvable :", error); - return undefined; - } -} diff --git a/server/services/reels/index.ts b/server/services/reels/index.ts deleted file mode 100644 index 4de4b98..0000000 --- a/server/services/reels/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Point d'entrée des Reels : enregistre les traitements de la file et démarre - * le worker. - */ - -import { registerReelJobHandler, startReelWorker } from "./queue"; -import { runVideoReelJob } from "./videoPipeline"; - -export function startReelProcessing(): Promise { - registerReelJobHandler("video", runVideoReelJob); - return startReelWorker(); -} diff --git a/server/services/reels/publish.ts b/server/services/reels/publish.ts deleted file mode 100644 index bc88dbe..0000000 --- a/server/services/reels/publish.ts +++ /dev/null @@ -1,189 +0,0 @@ -/** - * Enregistrement et publication d'un Reel rendu, commun aux Reels vidéo et - * images. - * - * La vidéo est toujours envoyée en binaire : les fichiers vivent sous - * /uploads, une URL relative que Facebook ne peut pas télécharger (c'était le - * cas de l'ancien `publishReel(originalUrl)`). - */ - -import type { Media, SocialPage } from "@shared/schema"; -import { storage } from "../../storage"; -import { minioService } from "../minio"; -import { createVideoThumbnail } from "../thumbnail"; -import { facebookService } from "../facebook"; -import { tiktokService } from "../tiktok"; - -export interface PagePublishResult { - pageId: string; - success: boolean; - reelId?: string; - error?: string; - /** Échec de la story associée : n'empêche pas le Reel d'être publié. */ - storyError?: string; -} - -/** Range la vidéo rendue dans la médiathèque, avec sa vignette. */ -export async function storeRenderedVideo( - userId: string, - videoBuffer: Buffer, - fileName: string, -): Promise { - const uploaded = await minioService.uploadMedia(videoBuffer, fileName, userId, "video/mp4"); - // Vignette extraite maintenant : la vidéo sera supprimée du disque après - // publication, mais l'historique doit rester illustré. - const thumbnailUrl = await createVideoThumbnail(videoBuffer); - - return storage.createMedia({ - userId, - type: "video", - cloudinaryPublicId: uploaded.publicId, - originalUrl: uploaded.originalUrl, - facebookFeedUrl: uploaded.facebookFeedUrl, - instagramFeedUrl: uploaded.instagramFeedUrl, - instagramStoryUrl: uploaded.instagramStoryUrl, - thumbnailUrl, - fileName, - fileSize: videoBuffer.length, - }); -} - -/** - * Publie (ou planifie) la vidéo sur chaque page, crée les entrées - * `scheduled_posts` et met à jour le statut du post. - * - * Avec `alsoStory`, chaque page Facebook reçoit aussi la vidéo en story (TikTok - * n'a pas de stories). - */ -export async function publishReelToPages(options: { - postId: string; - pageIds: string[]; - videoBuffer: Buffer; - description: string; - scheduledFor?: string; - alsoStory?: boolean; -}): Promise { - const { postId, pageIds, videoBuffer, description, scheduledFor, alsoStory } = options; - const results: PagePublishResult[] = []; - - for (const pageId of pageIds) { - try { - const page = await storage.getSocialPage(pageId); - if (!page) { - results.push({ pageId, success: false, error: "Page non trouvée" }); - continue; - } - if (page.platform !== "facebook" && page.platform !== "tiktok") { - results.push({ - pageId, - success: false, - error: `Plateforme non supportée pour les reels : ${page.platform}`, - }); - continue; - } - - const scheduledPost = await storage.createScheduledPost({ - postId, - pageId: page.id, - postType: "reel", - scheduledAt: scheduledFor ? new Date(scheduledFor) : new Date(), - }); - const withStory = alsoStory && page.platform === "facebook"; - - if (scheduledFor) { - // Le planificateur publiera à l'heure dite - if (withStory) { - await storage.createScheduledPost({ - postId, - pageId: page.id, - postType: "story", - scheduledAt: new Date(scheduledFor), - }); - } - results.push({ pageId, success: true, reelId: "scheduled" }); - continue; - } - - console.log(`🚀 [Reels] Publication sur ${page.platform} ${page.pageName}...`); - if (page.platform === "facebook") { - const reelId = await facebookService.publishVideoFromBuffer(page, videoBuffer, description); - await storage.updateScheduledPost(scheduledPost.id, { - publishedAt: new Date(), - externalPostId: reelId, - }); - const storyError = withStory ? await publishStory(postId, page, videoBuffer) : undefined; - results.push({ pageId, success: true, reelId, storyError }); - } else { - // TikTok finalise la publication de son côté : l'envoi est marqué comme - // effectué pour ne pas republier, et le poller de statut renseignera - // l'identifiant définitif du post. - const publishId = await tiktokService.publishVideoFromBuffer(page, videoBuffer, description); - await storage.updateScheduledPost(scheduledPost.id, { - publishedAt: new Date(), - publishId, - publishStatus: "PROCESSING_UPLOAD", - }); - results.push({ pageId, success: true, reelId: publishId }); - } - } catch (error) { - console.error(`❌ [Reels] Publication sur la page ${pageId} impossible :`, error); - results.push({ - pageId, - success: false, - error: error instanceof Error ? error.message : "Erreur inconnue", - }); - } - } - - const succeeded = results.filter((r) => r.success).length; - const status = scheduledFor && succeeded > 0 ? "scheduled" : succeeded > 0 ? "published" : "failed"; - await storage.updatePost(postId, { status }); - - return results; -} - -/** - * Publie la story d'une page juste après son Reel : un échec est consigné sur - * son entrée planifiée (visible dans l'historique) et renvoyé, sans faire - * échouer le Reel. - */ -async function publishStory( - postId: string, - page: SocialPage, - videoBuffer: Buffer, -): Promise { - // Créée seulement maintenant : en attente pendant l'envoi du Reel, le - // planificateur aurait pu la publier une seconde fois. - let scheduledStoryId: string | undefined; - try { - ({ id: scheduledStoryId } = await storage.createScheduledPost({ - postId, - pageId: page.id, - postType: "story", - scheduledAt: new Date(), - })); - const storyId = await facebookService.publishVideoStoryFromBuffer(page, videoBuffer); - await storage.updateScheduledPost(scheduledStoryId, { - publishedAt: new Date(), - externalPostId: storyId, - }); - return undefined; - } catch (error) { - const message = error instanceof Error ? error.message : "Erreur inconnue"; - console.error(`❌ [Reels] Story sur ${page.pageName} impossible :`, error); - if (scheduledStoryId) { - // Marquée comme traitée pour que le planificateur ne la republie pas en boucle - await storage.updateScheduledPost(scheduledStoryId, { - publishedAt: new Date(), - error: message, - }).catch((e) => console.error(`⚠️ [Reels] Échec de la story non enregistré :`, e)); - } - return message; - } -} - -/** Message d'erreur lisible lorsque toutes les publications ont échoué. */ -export function describePublishFailure(results: PagePublishResult[]): string { - const details = results.map((r) => r.error).filter(Boolean).join(" ; "); - return `Publication impossible sur toutes les pages${details ? ` : ${details}` : ""}`; -} diff --git a/server/services/reels/queue.ts b/server/services/reels/queue.ts deleted file mode 100644 index 680536e..0000000 --- a/server/services/reels/queue.ts +++ /dev/null @@ -1,278 +0,0 @@ -/** - * File d'attente persistante des rendus de Reels. - * - * L'ancienne file comptait les posts « processing » pour décider de lancer un - * rendu : un redémarrage pendant un job laissait ce compteur à 1 pour toujours - * et bloquait tous les Reels suivants. Ici, chaque job est une ligne de - * `reel_jobs` : - * - le worker la réserve atomiquement (`FOR UPDATE SKIP LOCKED`), ce qui exclut - * tout double traitement, même avec plusieurs instances ; - * - un battement de cœur est écrit pendant le rendu ; au démarrage, les jobs - * dont le cœur ne bat plus sont remis en file (ou échouent après - * MAX_ATTEMPTS tentatives). - */ - -import { and, eq, sql } from "drizzle-orm"; -import { db } from "../../db"; -import { posts, reelJobs, type ReelJob } from "@shared/schema"; -import type { ReelJobKind, ReelJobStatus } from "@shared/reel"; - -const HEARTBEAT_INTERVAL_MS = 15_000; -/** Au-delà, un job « processing » est considéré comme orphelin. */ -const STALE_AFTER_MS = 2 * 60_000; -const POLL_INTERVAL_MS = 10_000; -const MAX_ATTEMPTS = 2; - -/** Outils fournis à un handler pour rendre compte de son avancement. */ -export interface JobContext { - job: ReelJob; - progress(progress: number, step?: string): Promise; -} - -export type JobHandler = (ctx: JobContext) => Promise; - -const handlers = new Map(); -let running = false; -let started = false; -let pollTimer: NodeJS.Timeout | null = null; - -export function registerReelJobHandler(kind: ReelJobKind, handler: JobHandler): void { - handlers.set(kind, handler); -} - -/** Ajoute un job à la file et réveille le worker. */ -export async function enqueueReelJob(input: { - kind: ReelJobKind; - userId: string; - postId?: string; - params: unknown; -}): Promise { - const [job] = await db - .insert(reelJobs) - .values({ - kind: input.kind, - userId: input.userId, - postId: input.postId, - params: input.params, - }) - .returning(); - - if (job.postId) { - await syncPost(job.postId, "pending", 0); - } - - kick(); - return job; -} - -export async function getReelJob(id: string): Promise { - const [job] = await db.select().from(reelJobs).where(eq(reelJobs.id, id)); - return job; -} - -/** Nombre de jobs qui passeront avant un nouveau job (utile pour informer l'utilisateur). */ -export async function countActiveReelJobs(): Promise { - const [row] = await db - .select({ count: sql`count(*)::int` }) - .from(reelJobs) - .where(sql`${reelJobs.status} IN ('pending', 'processing')`); - return row?.count ?? 0; -} - -/** - * Démarre le worker : récupère les jobs orphelins puis traite la file. - * Idempotent. - */ -export async function startReelWorker(): Promise { - if (started) return; - started = true; - - try { - await recoverStaleJobs(); - await failLegacyStuckPosts(); - } catch (error) { - console.error("❌ [ReelQueue] Récupération des jobs orphelins impossible :", error); - } - - console.log(`🎬 [ReelQueue] Worker démarré (traitements : ${Array.from(handlers.keys()).join(", ")})`); - pollTimer = setInterval(kick, POLL_INTERVAL_MS); - pollTimer.unref(); - kick(); -} - -/** Relance la boucle si elle est à l'arrêt. */ -function kick(): void { - if (!started || running) return; - running = true; - drain() - .catch((error) => console.error("❌ [ReelQueue] Erreur de la boucle :", error)) - .finally(() => { - running = false; - }); -} - -async function drain(): Promise { - for (;;) { - const job = await claimNextJob(); - if (!job) return; - await runJob(job); - } -} - -async function claimNextJob(): Promise { - const result = await db.execute<{ id: string }>(sql` - UPDATE reel_jobs - SET status = 'processing', - attempts = attempts + 1, - heartbeat_at = now(), - updated_at = now(), - error = NULL - WHERE id = ( - SELECT id FROM reel_jobs - WHERE status = 'pending' - ORDER BY created_at - FOR UPDATE SKIP LOCKED - LIMIT 1 - ) - RETURNING id - `); - const id = result.rows[0]?.id; - return id ? getReelJob(id) : undefined; -} - -async function runJob(job: ReelJob): Promise { - const handler = handlers.get(job.kind as ReelJobKind); - console.log(`🎬 [ReelQueue] Job ${job.id} (${job.kind}), tentative ${job.attempts}`); - - const heartbeat = setInterval(() => { - db.update(reelJobs) - .set({ heartbeatAt: new Date() }) - .where(eq(reelJobs.id, job.id)) - .catch((error) => console.warn(`⚠️ [ReelQueue] Heartbeat ${job.id} :`, error)); - }, HEARTBEAT_INTERVAL_MS); - - const ctx: JobContext = { - job, - async progress(progress, step) { - await updateJob(job, { progress, step }); - }, - }; - - try { - if (!handler) { - throw new Error(`Aucun traitement enregistré pour les jobs « ${job.kind} »`); - } - if (job.postId) { - await syncPost(job.postId, "processing", 0); - } - const result = await handler(ctx); - await updateJob(job, { status: "completed", progress: 100, step: "done", result: result ?? null }); - console.log(`✅ [ReelQueue] Job ${job.id} terminé`); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`❌ [ReelQueue] Job ${job.id} en échec :`, error); - await updateJob(job, { status: "failed", error: message }).catch((e) => - console.error(`⚠️ [ReelQueue] Statut d'échec non enregistré pour ${job.id} :`, e), - ); - } finally { - clearInterval(heartbeat); - } -} - -async function updateJob( - job: ReelJob, - patch: { - status?: ReelJobStatus; - progress?: number; - step?: string; - result?: unknown; - error?: string; - }, -): Promise { - await db - .update(reelJobs) - .set({ ...patch, heartbeatAt: new Date(), updatedAt: new Date() }) - .where(eq(reelJobs.id, job.id)); - - if (job.postId) { - const status = patch.status ?? "processing"; - const progress = patch.status === "failed" ? 0 : patch.progress; - await syncPost(job.postId, status, progress, patch.error); - } -} - -/** - * Reflète l'état du job sur le post : l'interface (`/reels/ongoing`) lit les - * colonnes `generation_*` du post. - */ -async function syncPost( - postId: string, - status: ReelJobStatus, - progress?: number, - error?: string, -): Promise { - const patch: Partial = { - generationStatus: status, - updatedAt: new Date(), - }; - if (progress !== undefined) patch.generationProgress = progress; - if (error !== undefined) patch.generationError = error; - if (status === "failed") patch.status = "failed"; - await db.update(posts).set(patch).where(eq(posts.id, postId)); -} - -/** - * Remet en file les jobs interrompus (serveur arrêté pendant un rendu), ou les - * fait échouer s'ils ont déjà épuisé leurs tentatives : un job qui fait tomber - * le serveur ne doit pas le faire tomber en boucle. - */ -async function recoverStaleJobs(): Promise { - const staleBefore = new Date(Date.now() - STALE_AFTER_MS); - const stale = await db - .select() - .from(reelJobs) - .where( - and( - eq(reelJobs.status, "processing"), - sql`(${reelJobs.heartbeatAt} IS NULL OR ${reelJobs.heartbeatAt} < ${staleBefore})`, - ), - ); - - for (const job of stale) { - if (job.attempts >= MAX_ATTEMPTS) { - console.warn(`⚠️ [ReelQueue] Job ${job.id} abandonné après ${job.attempts} tentatives`); - await updateJob(job, { - status: "failed", - error: "Le rendu a été interrompu à plusieurs reprises (redémarrage du serveur).", - }); - } else { - console.warn(`♻️ [ReelQueue] Job ${job.id} interrompu, remis en file`); - await db - .update(reelJobs) - .set({ status: "pending", step: null, progress: 0, updatedAt: new Date() }) - .where(eq(reelJobs.id, job.id)); - if (job.postId) await syncPost(job.postId, "pending", 0); - } - } -} - -/** - * Posts laissés « processing »/« pending » par l'ancienne file (avant - * reel_jobs) : ils n'ont aucun job pour les reprendre et bloquaient l'affichage - * des Reels en cours. On les marque en échec pour que l'utilisateur relance. - */ -async function failLegacyStuckPosts(): Promise { - const result = await db.execute(sql` - UPDATE posts - SET generation_status = 'failed', - generation_progress = 0, - generation_error = 'Génération interrompue, veuillez relancer le Reel.', - status = 'failed', - updated_at = now() - WHERE generation_status IN ('processing', 'pending') - AND NOT EXISTS (SELECT 1 FROM reel_jobs j WHERE j.post_id = posts.id) - `); - if (result.rowCount) { - console.warn(`⚠️ [ReelQueue] ${result.rowCount} Reel(s) de l'ancienne file marqués en échec`); - } -} diff --git a/server/services/reels/remotionAssets.test.ts b/server/services/reels/remotionAssets.test.ts deleted file mode 100644 index 8038a4f..0000000 --- a/server/services/reels/remotionAssets.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { toDataUrl } from "./remotionAssets"; - -describe("toDataUrl", () => { - it("refuse un chemin qui sort du dossier uploads", async () => { - await expect(toDataUrl("/uploads/../../etc/passwd")).rejects.toThrow(/refusé/); - }); - - it("laisse les URL externes telles quelles", async () => { - await expect(toDataUrl("https://exemple.fr/logo.png")).resolves.toBe("https://exemple.fr/logo.png"); - }); -}); diff --git a/server/services/reels/remotionAssets.ts b/server/services/reels/remotionAssets.ts deleted file mode 100644 index f12ef34..0000000 --- a/server/services/reels/remotionAssets.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Ressources fournies aux compositions Remotion pendant un rendu serveur. - */ - -import fs from "fs"; -import path from "path"; - -/** Dossier temporaire servi sous /uploads/temp (voir server/index.ts). */ -export const REMOTION_TEMP_DIR = - process.env.TEMP_UPLOAD_DIR || path.join(process.cwd(), "uploads", "temp"); - -/** - * URL HTTP locale d'un fichier servi par l'application. Vidéo et audio sont - * lus côté Node par le moteur de rendu : l'adresse locale suffit. - */ -export function localHttpUrl(url: string): string { - if (!url.startsWith("/")) return url; - const port = process.env.PORT || "5555"; - return `http://localhost:${port}${url}`; -} - -/** URL servie d'un fichier du dossier temporaire. */ -export function tempFileUrl(absolutePath: string): string { - const relative = path.relative(REMOTION_TEMP_DIR, absolutePath).split(path.sep).join("/"); - return localHttpUrl(`/uploads/temp/${relative}`); -} - -const MIME_BY_EXT: Record = { - png: "image/png", - gif: "image/gif", - webp: "image/webp", - svg: "image/svg+xml", -}; - -/** - * Convertit un chemin local /uploads/... en data URL : les images sont - * affichées par Chromium, qui ne lit pas http://localhost de façon fiable - * dans Docker. Refuse tout chemin sortant du dossier uploads. - */ -export async function toDataUrl(relativeUrl: string): Promise { - if (!relativeUrl.startsWith("/uploads/")) return relativeUrl; - const uploadsRoot = path.join(process.cwd(), "uploads") + path.sep; - const tempRoot = path.resolve(REMOTION_TEMP_DIR) + path.sep; - const filePath = relativeUrl.startsWith("/uploads/temp/") - ? path.resolve(REMOTION_TEMP_DIR, "." + relativeUrl.slice("/uploads/temp".length)) - : path.resolve(process.cwd(), "." + relativeUrl); - if (!filePath.startsWith(uploadsRoot) && !filePath.startsWith(tempRoot)) { - throw new Error(`Chemin d'image refusé : ${relativeUrl}`); - } - try { - const buffer = await fs.promises.readFile(filePath); - const ext = path.extname(filePath).slice(1).toLowerCase(); - return `data:${MIME_BY_EXT[ext] ?? "image/jpeg"};base64,${buffer.toString("base64")}`; - } catch { - console.warn(`⚠️ [Remotion] Image introuvable : ${filePath}`); - return relativeUrl; - } -} diff --git a/server/services/reels/remotionRenderer.ts b/server/services/reels/remotionRenderer.ts deleted file mode 100644 index 484d496..0000000 --- a/server/services/reels/remotionRenderer.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Rendu Remotion côté serveur, commun aux Reels vidéo et images : bundle mis - * en cache, réglages d'encodage des réseaux sociaux, progression. - */ - -import os from "os"; -import path from "path"; -import { bundle } from "@remotion/bundler"; -import { renderMedia, selectComposition } from "@remotion/renderer"; - -let bundleCache: Promise | null = null; - -/** Bundle des compositions, construit une fois par processus. */ -export function getRemotionBundle(): Promise { - if (!bundleCache) { - // process.cwd() = /app dans Docker, racine du projet en dev - const entryPoint = path.resolve(process.cwd(), "client/src/remotion/index.ts"); - console.log("📦 [Remotion] Bundle de", entryPoint); - bundleCache = bundle({ - entryPoint, - // Mêmes alias que Vite : les compositions importent @shared/* - webpackOverride: (config) => ({ - ...config, - resolve: { - ...config.resolve, - alias: { - ...(config.resolve?.alias ?? {}), - "@shared": path.resolve(process.cwd(), "shared"), - }, - }, - }), - }).catch((error) => { - bundleCache = null; // réessayer au prochain rendu - throw error; - }); - } - return bundleCache; -} - -/** Rendus en parallèle dans Chromium : par défaut la moitié des cœurs, 1 au minimum. */ -function renderConcurrency(): number { - const configured = Number(process.env.RENDER_CONCURRENCY); - if (Number.isFinite(configured) && configured > 0) return Math.floor(configured); - return Math.max(1, Math.floor(os.cpus().length / 2)); -} - -export async function renderReelComposition(options: { - compositionId: "ReelVideo"; - inputProps: Record; - outputLocation: string; - onProgress?: (ratio: number) => void; -}): Promise { - const serveUrl = await getRemotionBundle(); - const composition = await selectComposition({ - serveUrl, - id: options.compositionId, - inputProps: options.inputProps, - }); - - const startedAt = Date.now(); - await renderMedia({ - composition, - serveUrl, - inputProps: options.inputProps, - outputLocation: options.outputLocation, - codec: "h264", - // Qualité visuelle élevée, conforme aux recommandations Instagram/TikTok - crf: 18, - x264Preset: "medium", - pixelFormat: "yuv420p", - colorSpace: "bt709", - audioCodec: "aac", - audioBitrate: "192k", - concurrency: renderConcurrency(), - onProgress: ({ progress }) => options.onProgress?.(progress), - // Une image lente (décodage vidéo sur une petite machine) ne doit pas faire échouer le rendu - timeoutInMilliseconds: 60_000, - onBrowserLog: (log) => { - if (log.type === "error") console.warn("⚠️ [Remotion] Navigateur :", log.text.slice(0, 300)); - }, - }); - console.log( - `🎞️ [Remotion] ${options.compositionId} rendu en ${((Date.now() - startedAt) / 1000).toFixed(1)} s`, - ); -} diff --git a/server/services/reels/videoPipeline.ts b/server/services/reels/videoPipeline.ts deleted file mode 100644 index 7bce929..0000000 --- a/server/services/reels/videoPipeline.ts +++ /dev/null @@ -1,160 +0,0 @@ -/** - * Rendu d'un Reel à partir d'une vidéo, puis médiathèque et publication. - * Exécuté par la file `reel_jobs` (voir queue.ts). - * - * Deux moteurs : - * - « remotion » (défaut) : le service Python prépare l'image (recadrage, HDR, - * stabilisation) et la piste son finale, Remotion compose les sous-titres - * animés, le logo et l'effet de fin — le même rendu que l'aperçu ; - * - « ffmpeg » : tout est fait par le service Python (sous-titres ASS), - * plus rapide, conservé en secours (REEL_RENDERER=ffmpeg). - */ - -import fs from "fs"; -import path from "path"; -import { videoReelParamsSchema, type VideoReelParams } from "@shared/reel"; -import { storage } from "../../storage"; -import { ffmpegService, type ReelRenderOptions } from "../ffmpeg"; -import { resolveInternalUrl } from "../minio"; -import type { JobContext } from "./queue"; -import { resolveGeminiApiKey, resolveLogoPath, resolveMusicUrl, resolveQwenTarget } from "./assets"; -import { describePublishFailure, publishReelToPages, storeRenderedVideo } from "./publish"; -import { REMOTION_TEMP_DIR, tempFileUrl, toDataUrl } from "./remotionAssets"; -import { renderReelComposition } from "./remotionRenderer"; - -export type ReelRenderer = "remotion" | "ffmpeg"; - -export function configuredRenderer(): ReelRenderer { - return process.env.REEL_RENDERER === "ffmpeg" ? "ffmpeg" : "remotion"; -} - -export async function runVideoReelJob({ job, progress }: JobContext) { - if (!job.postId) throw new Error("Job vidéo sans post associé"); - const postId = job.postId; - const params = videoReelParamsSchema.parse(job.params); - - await progress(5, "prepare"); - const media = await storage.getMediaById(params.videoMediaId); - if (!media || media.type !== "video") { - throw new Error("Vidéo source introuvable ou invalide"); - } - - const [musicUrl, logoPath, geminiApiKey, qwen] = await Promise.all([ - resolveMusicUrl(params.musicTrackId, params.musicUrl), - resolveLogoPath(), - resolveGeminiApiKey(params.ttsEngine), - resolveQwenTarget(params.ttsEngine), - ]); - - const options: ReelRenderOptions = { - text: params.srtCues ? undefined : params.overlayText, - srtCues: params.srtCues, - musicUrl, - ttsEnabled: params.ttsEnabled, - ttsVoice: params.ttsVoice, - ttsEngine: params.ttsEngine, - ttsStyle: params.ttsStyle, - geminiApiKey, - qwen, - fontSize: params.fontSize, - musicVolume: params.musicVolume, - drawText: params.drawText, - stabilize: params.stabilize, - storeName: params.storeName, - enableEndingEffect: params.enableEndingEffect, - showLogo: params.showLogo, - }; - - const renderer = configuredRenderer(); - const startedAt = Date.now(); - const videoBuffer = - renderer === "remotion" - ? await renderWithRemotion(job.id, resolveInternalUrl(media.originalUrl), options, params, logoPath, progress) - : await renderWithFfmpeg(resolveInternalUrl(media.originalUrl), options, logoPath, progress); - console.log(`⏱️ [Reels] Rendu ${renderer} : ${((Date.now() - startedAt) / 1000).toFixed(1)} s`); - - await progress(88, "store"); - const processedMedia = await storeRenderedVideo(job.userId, videoBuffer, `reel-${Date.now()}.mp4`); - await storage.updatePostMedia(postId, [processedMedia.id]); - - await progress(93, "publish"); - const results = await publishReelToPages({ - postId, - pageIds: params.pageIds, - videoBuffer, - description: params.description || params.overlayText || "", - scheduledFor: params.scheduledFor, - alsoStory: params.alsoStory, - }); - - if (!results.some((r) => r.success)) { - throw new Error(describePublishFailure(results)); - } - - return { mediaId: processedMedia.id, renderer, results }; -} - -async function renderWithFfmpeg( - videoUrl: string, - options: ReelRenderOptions, - logoPath: string | undefined, - progress: JobContext["progress"], -): Promise { - await progress(15, "render"); - // Le logo sert au coin de la vidéo et/ou à l'effet de fin : inutile de l'envoyer si aucun des deux - const logoUsed = Boolean(logoPath) && (options.showLogo !== false || options.enableEndingEffect !== false); - const rendered = await ffmpegService.renderReel(videoUrl, { - ...options, - watermarkUrl: logoUsed && logoPath ? resolveInternalUrl(logoPath) : undefined, - }); - return rendered.video; -} - -async function renderWithRemotion( - jobId: string, - videoUrl: string, - options: ReelRenderOptions, - params: VideoReelParams, - logoPath: string | undefined, - progress: JobContext["progress"], -): Promise { - const workdir = path.join(REMOTION_TEMP_DIR, `reel-${jobId}`); - try { - // 1. Image recadrée à la durée finale, piste son finale, mots minutés - await progress(10, "voice"); - const prepared = await ffmpegService.prepareReel(videoUrl, { ...options, hasLogo: Boolean(logoPath) }, workdir); - - // 2. Composition : sous-titres animés, logo, effet de fin - await progress(40, "render"); - const outputLocation = path.join(workdir, "reel.mp4"); - let lastReported = 40; - await renderReelComposition({ - compositionId: "ReelVideo", - outputLocation, - inputProps: { - videoUrl: tempFileUrl(prepared.videoPath), - videoDuration: prepared.totalDuration, - totalDuration: prepared.totalDuration, - words: prepared.words, - captionStyle: params.captionStyle, - logoUrl: logoPath ? await toDataUrl(logoPath) : undefined, - showWatermark: params.showLogo, - storeName: params.enableEndingEffect ? params.storeName : undefined, - logoStart: prepared.logoStart, - fadeStart: prepared.fadeStart, - fadeDuration: prepared.fadeDuration, - mixedAudioUrl: prepared.audioPath ? tempFileUrl(prepared.audioPath) : undefined, - }, - onProgress: (ratio) => { - const pct = 40 + Math.floor(ratio * 45); - if (pct >= lastReported + 5) { - lastReported = pct; - progress(pct, "render").catch(() => { /* non bloquant */ }); - } - }, - }); - return await fs.promises.readFile(outputLocation); - } finally { - await fs.promises.rm(workdir, { recursive: true, force: true }).catch(() => { /* déjà absent */ }); - } -} diff --git a/server/services/scheduler.ts b/server/services/scheduler.ts deleted file mode 100644 index 73342ad..0000000 --- a/server/services/scheduler.ts +++ /dev/null @@ -1,182 +0,0 @@ -import cron, { ScheduledTask } from "node-cron"; -import { storage } from "../storage"; -import { facebookService } from "./facebook"; -import { tiktokService } from "./tiktok"; -import { minioService as storageService, readMediaBuffer } from "./minio"; - -export class SchedulerService { - private task: ScheduledTask | null = null; - - start() { - // Run every minute to check for pending posts - this.task = cron.schedule("* * * * *", async () => { - await this.processPendingPosts(); - }); - - console.log("Scheduler service started"); - } - - stop() { - if (this.task) { - this.task.stop(); - console.log("Scheduler service stopped"); - } - } - - private async processPendingPosts() { - try { - const pendingPosts = await storage.getPendingScheduledPosts(); - - for (const scheduledPost of pendingPosts) { - try { - // Reject legacy "both" posts - they should have been split at creation - // New posts are automatically split in /api/posts - if (scheduledPost.postType === 'both') { - const errorMsg = 'Legacy postType "both" is no longer supported. Deleting this scheduled entry. Please recreate this post - it will automatically be split into separate feed and story posts.'; - console.error(`Rejecting and deleting legacy "both" post ${scheduledPost.id}: ${errorMsg}`); - - // Delete the legacy post entirely (don't mark as published - that's misleading) - await storage.deleteScheduledPost(scheduledPost.id); - - // Update parent post status to draft so user can reschedule - await storage.updatePost(scheduledPost.postId, { - status: "draft", - }); - - continue; - } - - await this.publishPost(scheduledPost); - } catch (error) { - console.error(`Error publishing post ${scheduledPost.id}:`, error); - await storage.updateScheduledPost(scheduledPost.id, { - error: error instanceof Error ? error.message : "Unknown error", - }); - } - } - } catch (error) { - console.error("Error processing pending posts:", error); - } - } - - private async publishPost(scheduledPost: any) { - const post = await storage.getPost(scheduledPost.postId); - const page = await storage.getSocialPage(scheduledPost.pageId); - - if (!post || !page) { - throw new Error("Post or page not found"); - } - - console.log(`Publishing post ${post.id} to ${page.platform} page ${page.pageName}`); - - // Get all media for the post (for multi-photo carousels) - const postMediaList = await storage.getPostMedia(post.id); - const mediaList: any[] = []; - for (const pm of postMediaList) { - const media = await storage.getMediaById(pm.mediaId); - if (media) { - mediaList.push(media); - } - } - - // Validate that story posts have media - // Note: "both" should never reach here since they're split at creation - if (scheduledPost.postType === 'story' && mediaList.length === 0) { - const errorMsg = 'Les stories nécessitent un média. Ce post ne peut pas être publié.'; - console.error(`Deleting invalid story post ${scheduledPost.id} without media: ${errorMsg}`); - - // Delete the invalid scheduled post (don't mark as published - that's misleading) - await storage.deleteScheduledPost(scheduledPost.id); - - // Set post to draft so user can fix and reschedule - await storage.updatePost(post.id, { - status: "draft", - }); - return; - } - - // Publish to Facebook/Instagram/TikTok - if (page.platform === 'facebook') { - const externalPostId = await facebookService.publishPost(post, page, scheduledPost.postType, mediaList); - - // Update the scheduled post as published (clear any previous error) - await storage.updateScheduledPost(scheduledPost.id, { - publishedAt: new Date(), - externalPostId, - error: null, - }); - } else if (page.platform === 'tiktok') { - const publishId = await this.publishToTiktok(post, page, mediaList); - - // TikTok publie de façon asynchrone : on marque l'envoi comme effectué pour - // ne pas republier à la minute suivante, et le poller de statut renseignera - // l'identifiant définitif du post. - await storage.updateScheduledPost(scheduledPost.id, { - publishedAt: new Date(), - publishId, - publishStatus: 'PROCESSING_UPLOAD', - error: null, - }); - } else if (page.platform === 'instagram') { - // Instagram publishing not yet implemented - console.warn(`Instagram publishing not yet implemented for post ${post.id}`); - await storage.updateScheduledPost(scheduledPost.id, { - error: 'Instagram publishing not yet implemented', - }); - await storage.updatePost(post.id, { - status: "failed", - }); - return; - } else { - throw new Error(`Unsupported platform: ${page.platform}`); - } - - // Update post status - await storage.updatePost(post.id, { - status: "published", - }); - - // Delete local video files only when no other unpublished scheduled posts reference them - for (const mediaItem of mediaList) { - if (mediaItem.type === 'video' && mediaItem.cloudinaryPublicId) { - try { - const remainingScheduled = await storage.getScheduledPostsByPost(post.id); - const hasPending = remainingScheduled.some(sp => !sp.publishedAt); - if (!hasPending) { - await storageService.deleteMedia(mediaItem.cloudinaryPublicId, mediaItem.userId, 'video'); - console.log(`[Scheduler] Deleted local video file for media ${mediaItem.id}`); - } else { - console.log(`[Scheduler] Kept local video file for media ${mediaItem.id} (pending scheduled posts still exist)`); - } - } catch (err) { - console.warn(`[Scheduler] Failed to delete local video file for media ${mediaItem.id}:`, err); - } - } - } - - console.log(`Successfully published post ${post.id} to ${page.platform} page ${page.pageName}`); - } - - /** - * Envoie la vidéo d'un post planifié sur un compte TikTok. - * TikTok ne sait pas programmer une publication : c'est notre planificateur qui - * déclenche l'envoi à l'heure voulue. - * - * @returns le publish_id à suivre pour connaître le résultat final - */ - private async publishToTiktok(post: any, page: any, mediaList: any[]): Promise { - const videoMedia = mediaList.find(m => m.type === 'video'); - - if (!videoMedia) { - throw new Error( - `Le compte TikTok "${page.pageName}" ne peut recevoir que des vidéos : ce post n'en contient pas.` - ); - } - - const videoBuffer = await readMediaBuffer(videoMedia.originalUrl); - - return await tiktokService.publishVideoFromBuffer(page, videoBuffer, post.content); - } -} - -export const schedulerService = new SchedulerService(); diff --git a/server/services/thumbnail.ts b/server/services/thumbnail.ts deleted file mode 100644 index 445fe71..0000000 --- a/server/services/thumbnail.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Génération de vignettes vidéo. - * - * Les vidéos sources sont supprimées du disque une fois publiées (le - * planificateur libère la place, la purge quotidienne fait le reste), alors que - * la ligne `media` survit pour l'historique. Sans vignette persistée, l'interface - * tente alors de lire un fichier disparu : d'où les « Video thumbnail generation - * failed » et les tuiles vides. - * - * On extrait donc une image au moment de la création du média, et elle reste - * disponible après la disparition de la vidéo. - */ - -import fs from 'fs'; -import path from 'path'; -import { execFile } from 'child_process'; -import { promisify } from 'util'; -import { minioService } from './minio'; - -const execFileAsync = promisify(execFile); - -const TEMP_DIR = path.join(process.cwd(), 'uploads', 'temp'); - -/** - * Extrait une image d'une vidéo avec FFmpeg. - * - * @param videoPath chemin du fichier vidéo - * @param outputPath chemin de l'image à écrire - * @param seekTime instant de la capture, en secondes - * @returns true si l'image a bien été écrite - */ -export async function generateVideoThumbnail( - videoPath: string, - outputPath: string, - seekTime: number = 1 -): Promise { - try { - // Arguments séparés : aucun passage par le shell, les chemins ne sont jamais interprétés - await execFileAsync('ffmpeg', [ - '-y', '-ss', String(seekTime), '-i', videoPath, '-vframes', '1', '-q:v', '2', outputPath, - ]); - return fs.existsSync(outputPath); - } catch (error) { - console.warn('⚠️ Failed to generate video thumbnail:', error); - return false; - } -} - -/** - * Produit la vignette d'une vidéo et la range dans le stockage public. - * - * Tolérante à l'échec : une vignette manquante dégrade l'affichage mais ne doit - * jamais faire échouer une publication. - * - * @param source chemin du fichier vidéo, ou son contenu en mémoire - * @returns l'URL publique de la vignette, ou null si l'extraction a échoué - */ -export async function createVideoThumbnail(source: string | Buffer): Promise { - const stamp = `${Date.now()}-${Math.round(Math.random() * 1e9)}`; - const outputPath = path.join(TEMP_DIR, `thumb-${stamp}.jpg`); - - // Une vidéo reçue en mémoire doit d'abord être posée sur le disque pour FFmpeg - let videoPath: string; - let tempVideoPath: string | null = null; - - try { - if (!fs.existsSync(TEMP_DIR)) { - fs.mkdirSync(TEMP_DIR, { recursive: true }); - } - - if (Buffer.isBuffer(source)) { - tempVideoPath = path.join(TEMP_DIR, `thumb-src-${stamp}.mp4`); - await fs.promises.writeFile(tempVideoPath, source); - videoPath = tempVideoPath; - } else { - videoPath = source; - if (!fs.existsSync(videoPath)) { - console.warn(`⚠️ [Thumbnail] Vidéo introuvable pour la vignette : ${videoPath}`); - return null; - } - } - - const ok = await generateVideoThumbnail(videoPath, outputPath); - if (!ok) return null; - - const buffer = await fs.promises.readFile(outputPath); - const { url } = await minioService.uploadThumbnail(buffer, `thumb-${stamp}.jpg`); - - console.log(`🖼️ [Thumbnail] Vignette générée : ${url}`); - return url; - } catch (error) { - console.warn('⚠️ [Thumbnail] Génération de la vignette impossible :', error); - return null; - } finally { - for (const file of [outputPath, tempVideoPath]) { - if (file) { - await fs.promises.unlink(file).catch(() => { /* déjà absent */ }); - } - } - } -} diff --git a/server/services/tiktok.ts b/server/services/tiktok.ts deleted file mode 100644 index 246b656..0000000 --- a/server/services/tiktok.ts +++ /dev/null @@ -1,574 +0,0 @@ -/** - * Service de publication TikTok (Content Posting API v2). - * - * Différences structurantes avec Facebook, qui expliquent la forme de ce service : - * - Pas de token collable à la main : chaque compte magasin passe par OAuth. - * L'access token ne vit que 24h et doit être renouvelé avec le refresh token - * (valable 1 an) avant chaque publication. - * - La publication est asynchrone : l'upload rend un `publish_id`, et l'identifiant - * définitif du post n'est connu qu'en interrogeant /status/fetch/ ensuite. - * - `creator_info/query` doit être appelé avant chaque publication pour connaître - * les niveaux de confidentialité réellement autorisés sur le compte. - * - * Note : tant que l'application n'a pas passé l'audit TikTok, le seul niveau de - * confidentialité disponible est SELF_ONLY (vidéo visible du seul propriétaire du - * compte). Le code s'adapte automatiquement à ce que le compte autorise. - */ - -import type { Request } from 'express'; -import { storage } from '../storage'; -import { resolvePublicBaseUrl } from '../utils/public_url'; -import type { SocialPage } from '@shared/schema'; - -const AUTH_BASE_URL = 'https://www.tiktok.com/v2/auth/authorize/'; -const API_BASE_URL = 'https://open.tiktokapis.com/v2'; - -/** Scopes demandés à l'autorisation. `video.publish` nécessite l'audit TikTok. */ -const DEFAULT_SCOPES = 'user.info.basic,video.publish'; - -/** TikTok impose des chunks entre 5 Mo et 64 Mo. */ -const MAX_SINGLE_CHUNK_SIZE = 64 * 1024 * 1024; -const CHUNK_SIZE = 32 * 1024 * 1024; - -/** Longueur maximale de la légende d'une vidéo TikTok. */ -const MAX_TITLE_LENGTH = 2200; - -/** Renouvelle le token si sa fin de validité est à moins de 5 minutes. */ -const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000; - -export interface TiktokCreatorInfo { - creatorAvatarUrl?: string; - creatorUsername?: string; - creatorNickname?: string; - privacyLevelOptions: string[]; - commentDisabled: boolean; - duetDisabled: boolean; - stitchDisabled: boolean; - maxVideoPostDurationSec?: number; -} - -export interface TiktokPublishOptions { - privacyLevel?: string; - disableComment?: boolean; - disableDuet?: boolean; - disableStitch?: boolean; -} - -export interface TiktokPublishStatus { - status: string; - failReason?: string; - postId?: string; -} - -interface TiktokTokenResponse { - access_token: string; - expires_in: number; - open_id: string; - refresh_token: string; - refresh_expires_in: number; - scope: string; - token_type: string; -} - -export class TiktokService { - /** - * Identifiants de l'application développeur TikTok (globaux à l'instance). - * La base prime sur les variables d'environnement. - */ - private async getCredentials(): Promise<{ clientKey: string; clientSecret: string }> { - const config = await storage.getTiktokConfig(); - - const clientKey = config?.clientKey || process.env.TIKTOK_CLIENT_KEY; - const clientSecret = config?.clientSecret || process.env.TIKTOK_CLIENT_SECRET; - - if (!clientKey || !clientSecret) { - throw new Error( - "TikTok n'est pas configuré. Renseignez le client key et le client secret " + - "de votre application TikTok dans les paramètres." - ); - } - - return { clientKey, clientSecret }; - } - - async isConfigured(): Promise { - try { - await this.getCredentials(); - return true; - } catch { - return false; - } - } - - /** - * URI de redirection OAuth. `req` sert de repli quand `APP_URL` n'est pas - * défini : le domaine public est alors lu sur la requête plutôt que remplacé - * par `http://localhost:5555`. - */ - getRedirectUri(req?: Request): string { - if (process.env.TIKTOK_REDIRECT_URI) { - return process.env.TIKTOK_REDIRECT_URI; - } - return `${resolvePublicBaseUrl(req)}/api/tiktok/callback`; - } - - /** - * URL d'autorisation à ouvrir pour connecter le compte TikTok d'un magasin. - * Chaque magasin répète ce flux : un compte = une ligne dans social_pages. - */ - async buildAuthorizationUrl(state: string, req?: Request): Promise { - const { clientKey } = await this.getCredentials(); - - const params = new URLSearchParams({ - client_key: clientKey, - scope: process.env.TIKTOK_SCOPES || DEFAULT_SCOPES, - response_type: 'code', - redirect_uri: this.getRedirectUri(req), - state, - }); - - return `${AUTH_BASE_URL}?${params.toString()}`; - } - - /** Échange le code d'autorisation contre un couple access/refresh token. */ - async exchangeCodeForToken(code: string, req?: Request): Promise { - const { clientKey, clientSecret } = await this.getCredentials(); - - return await this.requestToken({ - client_key: clientKey, - client_secret: clientSecret, - // TikTok renvoie le code URL-encodé dans la query string - code: decodeURIComponent(code), - grant_type: 'authorization_code', - redirect_uri: this.getRedirectUri(req), - }); - } - - /** Renouvelle un access token expiré. TikTok rend aussi un nouveau refresh token. */ - async refreshAccessToken(refreshToken: string): Promise { - const { clientKey, clientSecret } = await this.getCredentials(); - - return await this.requestToken({ - client_key: clientKey, - client_secret: clientSecret, - grant_type: 'refresh_token', - refresh_token: refreshToken, - }); - } - - private async requestToken(body: Record): Promise { - const response = await fetch(`${API_BASE_URL}/oauth/token/`, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Cache-Control': 'no-cache', - }, - body: new URLSearchParams(body).toString(), - }); - - const raw = await response.text(); - let data: any; - try { - data = JSON.parse(raw); - } catch { - throw new Error(`Réponse TikTok illisible (${response.status}): ${raw.slice(0, 200)}`); - } - - // L'endpoint OAuth signale les erreurs avec `error` + `error_description` - if (!response.ok || data.error) { - const description = data.error_description || data.error || raw.slice(0, 200); - throw new Error(`Erreur OAuth TikTok: ${description}`); - } - - if (!data.access_token) { - throw new Error("Réponse TikTok inattendue : aucun access token reçu"); - } - - return data as TiktokTokenResponse; - } - - /** - * Récupère un access token valide pour une page, en le renouvelant si besoin. - * Le nouveau couple de tokens est persisté immédiatement : le refresh token - * TikTok est à usage unique, le perdre déconnecterait le compte. - */ - async getValidAccessToken(page: SocialPage): Promise { - if (page.platform !== 'tiktok') { - throw new Error('Ce service ne gère que les comptes TikTok'); - } - - const expiresAt = page.tokenExpiresAt ? new Date(page.tokenExpiresAt).getTime() : 0; - const stillValid = expiresAt - TOKEN_REFRESH_MARGIN_MS > Date.now(); - - if (stillValid && page.accessToken) { - return page.accessToken; - } - - if (!page.refreshToken) { - throw new Error( - `Le compte TikTok "${page.pageName}" doit être reconnecté (aucun refresh token enregistré).` - ); - } - - console.log(`🔄 [TikTok] Renouvellement du token pour ${page.pageName}`); - - let tokens: TiktokTokenResponse; - try { - tokens = await this.refreshAccessToken(page.refreshToken); - } catch (error) { - await storage.updateSocialPage(page.id, { - tokenStatus: 'expired', - lastTokenCheck: new Date(), - }); - throw new Error( - `Impossible de renouveler le token TikTok de "${page.pageName}". ` + - `Le compte doit être reconnecté. (${error instanceof Error ? error.message : 'erreur inconnue'})` - ); - } - - await this.persistTokens(page.id, tokens); - - return tokens.access_token; - } - - /** Enregistre un couple de tokens fraîchement obtenu sur une page. */ - async persistTokens(pageId: string, tokens: TiktokTokenResponse): Promise { - await storage.updateSocialPage(pageId, { - accessToken: tokens.access_token, - tokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000), - refreshToken: tokens.refresh_token, - refreshTokenExpiresAt: new Date(Date.now() + tokens.refresh_expires_in * 1000), - scopes: tokens.scope, - tokenStatus: 'valid', - lastTokenCheck: new Date(), - }); - } - - /** Profil public du compte, utilisé pour nommer la page à la connexion. */ - async getUserInfo(accessToken: string): Promise<{ - openId: string; - displayName: string; - avatarUrl?: string; - }> { - const params = new URLSearchParams({ - fields: 'open_id,display_name,avatar_url', - }); - - const response = await fetch(`${API_BASE_URL}/user/info/?${params.toString()}`, { - method: 'GET', - headers: { Authorization: `Bearer ${accessToken}` }, - }); - - const data = await this.parseApiResponse(response, 'récupération du profil'); - const user = data.user || {}; - - return { - openId: user.open_id, - displayName: user.display_name || 'Compte TikTok', - avatarUrl: user.avatar_url, - }; - } - - /** - * Options réellement autorisées sur le compte (confidentialité, commentaires, - * duo, stitch, durée max). TikTok impose cet appel avant chaque publication. - */ - async getCreatorInfo(page: SocialPage): Promise { - return await this.queryCreatorInfo(await this.getValidAccessToken(page)); - } - - /** - * Variante prenant un token déjà résolu : le refresh token TikTok tourne à chaque - * renouvellement, donc une même opération ne doit déclencher qu'un seul refresh. - */ - private async queryCreatorInfo(accessToken: string): Promise { - const response = await fetch(`${API_BASE_URL}/post/publish/creator_info/query/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json; charset=UTF-8', - }, - }); - - const data = await this.parseApiResponse(response, 'récupération des options du compte'); - - return { - creatorAvatarUrl: data.creator_avatar_url, - creatorUsername: data.creator_username, - creatorNickname: data.creator_nickname, - privacyLevelOptions: data.privacy_level_options || [], - commentDisabled: !!data.comment_disabled, - duetDisabled: !!data.duet_disabled, - stitchDisabled: !!data.stitch_disabled, - maxVideoPostDurationSec: data.max_video_post_duration_sec, - }; - } - - /** - * Publie une vidéo sur un compte TikTok en trois temps : - * 1. init — déclare la taille et le découpage, récupère publish_id + upload_url - * 2. upload — envoie les données binaires par chunks - * 3. (plus tard) status/fetch — le poller récupère l'identifiant du post - * - * On envoie le fichier (FILE_UPLOAD) plutôt qu'une URL (PULL_FROM_URL) : cette - * seconde option exigerait de faire vérifier le domaine de l'instance chez TikTok - * et que les médias soient publiquement accessibles. - * - * @returns le publish_id à suivre pour connaître le résultat final - */ - async publishVideoFromBuffer( - page: SocialPage, - videoBuffer: Buffer, - caption?: string, - options: TiktokPublishOptions = {} - ): Promise { - if (page.platform !== 'tiktok') { - throw new Error('Ce service ne gère que les comptes TikTok'); - } - - // Un seul renouvellement de token pour toute la publication - const accessToken = await this.getValidAccessToken(page); - const creatorInfo = await this.queryCreatorInfo(accessToken); - const privacyLevel = this.resolvePrivacyLevel(creatorInfo, options.privacyLevel, page.pageName); - - const videoSize = videoBuffer.length; - const { chunkSize, totalChunkCount } = this.computeChunking(videoSize); - - console.log('🎬 [TikTok] Publication en cours:', { - compte: page.pageName, - taille: `${(videoSize / 1024 / 1024).toFixed(2)} Mo`, - chunks: totalChunkCount, - confidentialite: privacyLevel, - }); - - // ── Étape 1 : init ──────────────────────────────────────────────────────── - const initBody = { - post_info: { - title: (caption || '').slice(0, MAX_TITLE_LENGTH), - privacy_level: privacyLevel, - // Une option désactivée côté compte doit obligatoirement être déclarée désactivée - disable_comment: creatorInfo.commentDisabled || !!options.disableComment, - disable_duet: creatorInfo.duetDisabled || !!options.disableDuet, - disable_stitch: creatorInfo.stitchDisabled || !!options.disableStitch, - }, - source_info: { - source: 'FILE_UPLOAD', - video_size: videoSize, - chunk_size: chunkSize, - total_chunk_count: totalChunkCount, - }, - }; - - const initResponse = await fetch(`${API_BASE_URL}/post/publish/video/init/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json; charset=UTF-8', - }, - body: JSON.stringify(initBody), - }); - - const initData = await this.parseApiResponse(initResponse, 'initialisation de la publication'); - const publishId: string = initData.publish_id; - const uploadUrl: string = initData.upload_url; - - if (!publishId || !uploadUrl) { - throw new Error("TikTok n'a pas renvoyé d'identifiant de publication"); - } - - console.log(`📤 [TikTok] Upload initialisé: publish_id=${publishId}`); - - // ── Étape 2 : upload des chunks ─────────────────────────────────────────── - for (let index = 0; index < totalChunkCount; index++) { - const start = index * chunkSize; - // Le dernier chunk absorbe le reliquat - const end = index === totalChunkCount - 1 ? videoSize - 1 : start + chunkSize - 1; - const chunk = videoBuffer.subarray(start, end + 1); - - const uploadResponse = await fetch(uploadUrl, { - method: 'PUT', - headers: { - 'Content-Type': 'video/mp4', - 'Content-Length': chunk.length.toString(), - 'Content-Range': `bytes ${start}-${end}/${videoSize}`, - }, - body: new Blob([chunk], { type: 'video/mp4' }), - }); - - if (!uploadResponse.ok) { - const errorText = await uploadResponse.text(); - throw new Error( - `Échec de l'envoi du chunk ${index + 1}/${totalChunkCount} vers TikTok ` + - `(${uploadResponse.status}): ${errorText.slice(0, 200)}` - ); - } - - console.log(`📤 [TikTok] Chunk ${index + 1}/${totalChunkCount} envoyé`); - } - - console.log(`✅ [TikTok] Vidéo transmise pour ${page.pageName}, traitement en cours côté TikTok`); - - return publishId; - } - - /** Interroge TikTok sur l'avancement d'une publication. */ - async getPublishStatus(page: SocialPage, publishId: string): Promise { - const accessToken = await this.getValidAccessToken(page); - - const response = await fetch(`${API_BASE_URL}/post/publish/status/fetch/`, { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json; charset=UTF-8', - }, - body: JSON.stringify({ publish_id: publishId }), - }); - - const data = await this.parseApiResponse(response, 'suivi de la publication'); - - // Le nom du champ comporte bien une faute de frappe côté API TikTok - const postIds: string[] = data.publicaly_available_post_id || data.publicly_available_post_id || []; - - return { - status: data.status, - failReason: data.fail_reason, - postId: postIds.length > 0 ? postIds[0] : undefined, - }; - } - - /** - * Met à jour les publications TikTok encore en cours de traitement. - * Appelé périodiquement : c'est ce qui renseigne l'identifiant définitif du post - * (ou l'erreur) une fois que TikTok a fini d'encoder la vidéo. - */ - async syncPendingPublications(): Promise { - const pending = await storage.getScheduledPostsAwaitingPublishStatus(); - - if (pending.length === 0) { - return; - } - - console.log(`🔎 [TikTok] Suivi de ${pending.length} publication(s) en cours...`); - - for (const scheduledPost of pending) { - try { - const page = await storage.getSocialPage(scheduledPost.pageId); - if (!page || page.platform !== 'tiktok' || !scheduledPost.publishId) { - continue; - } - - const status = await this.getPublishStatus(page, scheduledPost.publishId); - - if (status.status === 'PUBLISH_COMPLETE') { - await storage.updateScheduledPost(scheduledPost.id, { - publishStatus: status.status, - // À défaut d'ID public (compte privé), on garde le publish_id comme référence - externalPostId: status.postId || scheduledPost.publishId, - error: null, - }); - console.log(`✅ [TikTok] Publication terminée sur ${page.pageName}`); - continue; - } - - if (status.status === 'FAILED') { - await storage.updateScheduledPost(scheduledPost.id, { - publishStatus: 'FAILED', - error: `Publication TikTok échouée : ${status.failReason || 'raison inconnue'}`, - }); - console.error(`❌ [TikTok] Publication échouée sur ${page.pageName}: ${status.failReason}`); - continue; - } - - // Filet de sécurité : au-delà de 24h, on cesse d'interroger l'API - const sentAt = scheduledPost.publishedAt ? new Date(scheduledPost.publishedAt).getTime() : 0; - if (sentAt && Date.now() - sentAt > 24 * 60 * 60 * 1000) { - await storage.updateScheduledPost(scheduledPost.id, { - publishStatus: 'FAILED', - error: `Statut TikTok toujours "${status.status}" après 24h, suivi abandonné.`, - }); - console.error(`❌ [TikTok] Suivi abandonné pour ${page.pageName} (statut ${status.status})`); - continue; - } - - await storage.updateScheduledPost(scheduledPost.id, { publishStatus: status.status }); - } catch (error) { - console.error( - `⚠️ [TikTok] Impossible de récupérer le statut de la publication ${scheduledPost.id}:`, - error instanceof Error ? error.message : error - ); - } - } - } - - /** - * Choisit un niveau de confidentialité réellement autorisé sur le compte. - * Sans audit TikTok validé, seul SELF_ONLY est proposé : on s'y rabat plutôt - * que d'échouer, en le signalant dans les logs. - */ - private resolvePrivacyLevel( - creatorInfo: TiktokCreatorInfo, - requested: string | undefined, - pageName: string - ): string { - const options = creatorInfo.privacyLevelOptions; - - if (options.length === 0) { - throw new Error( - `Le compte TikTok "${pageName}" ne propose aucun niveau de confidentialité. ` + - `Vérifiez que le compte est bien autorisé et que l'application dispose du scope video.publish.` - ); - } - - const wanted = requested || 'PUBLIC_TO_EVERYONE'; - if (options.includes(wanted)) { - return wanted; - } - - const fallback = options.includes('SELF_ONLY') ? 'SELF_ONLY' : options[0]; - console.warn( - `⚠️ [TikTok] "${wanted}" indisponible sur ${pageName} (options: ${options.join(', ')}). ` + - `Publication en ${fallback}. Tant que l'application TikTok n'est pas auditée, ` + - `les vidéos restent privées.` - ); - return fallback; - } - - /** - * Découpage du fichier : un seul chunk jusqu'à 64 Mo, sinon des chunks de 32 Mo, - * le dernier absorbant le reliquat (règle imposée par TikTok). - */ - private computeChunking(videoSize: number): { chunkSize: number; totalChunkCount: number } { - if (videoSize <= MAX_SINGLE_CHUNK_SIZE) { - return { chunkSize: videoSize, totalChunkCount: 1 }; - } - return { - chunkSize: CHUNK_SIZE, - totalChunkCount: Math.floor(videoSize / CHUNK_SIZE), - }; - } - - /** - * Les endpoints v2 répondent HTTP 200 avec `error.code === 'ok'` en cas de succès : - * il ne suffit pas de regarder le statut HTTP. - */ - private async parseApiResponse(response: Response, context: string): Promise { - const raw = await response.text(); - - let payload: any; - try { - payload = JSON.parse(raw); - } catch { - throw new Error(`Réponse TikTok illisible lors de la ${context} (${response.status}): ${raw.slice(0, 200)}`); - } - - const errorCode = payload?.error?.code; - if (!response.ok || (errorCode && errorCode !== 'ok')) { - const message = payload?.error?.message || raw.slice(0, 200); - throw new Error(`Erreur TikTok lors de la ${context}: ${message} (code: ${errorCode || response.status})`); - } - - return payload.data || {}; - } -} - -export const tiktokService = new TiktokService(); diff --git a/server/services/token_manager.ts b/server/services/token_manager.ts deleted file mode 100644 index 82dd90a..0000000 --- a/server/services/token_manager.ts +++ /dev/null @@ -1,358 +0,0 @@ -// crypto removed, using shared encryption utility -import { db } from '../db'; -import { socialPages } from '../../shared/schema'; -import { eq } from 'drizzle-orm'; -import { GraphAPIClient } from '../utils/graph_client'; -import type { SocialPage } from '../../shared/schema'; - -import { encrypt, decrypt } from '../utils/encryption'; -import { facebookOAuthService, FacebookAuthError } from './facebook_oauth'; - -/** - * Un jeton dont l'échéance est plus proche que cette fenêtre est renouvelé - * sans attendre : il reste largement le temps de réessayer les jours suivants - * si Facebook est indisponible au moment du contrôle. - */ -const RENEWAL_WINDOW_MS = 15 * 24 * 60 * 60 * 1000; - -/** En deçà de cette marge, un jeton non renouvelable est signalé « expiring ». */ -const WARNING_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; - -export interface TokenCheckResult { - pageId: string; - pageName: string; - status: 'valid' | 'expiring' | 'expired' | 'error'; - /** Vrai quand ce passage a effectivement réécrit un nouveau jeton. */ - renewed: boolean; - error?: string; -} - -/** - * Service responsible for managing Facebook Page Token lifecycle - * - Encryption at rest - * - Automated refreshing of expiring tokens - */ -export class TokenManager { - - /** - * Encrypts a text using shared encryption utility - */ - static encrypt(text: string): string { - return encrypt(text); - } - - /** - * Decrypts a text using shared encryption utility - */ - static decrypt(text: string): string { - return decrypt(text); - } - - /** - * Checks all active pages for expiring tokens and refreshes them if needed. - */ - static async checkAndRefreshTokens(): Promise { - console.log('[TokenManager] Starting daily token check...'); - const results: TokenCheckResult[] = []; - - try { - // Fetch active pages - const pages = await db.query.socialPages.findMany({ - where: eq(socialPages.isActive, "true") - }); - - for (const page of pages) { - try { - results.push(await this.processPageToken(page)); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`[TokenManager] Error processing page ${page.pageName} (${page.id}):`, error); - await this.persistStatus(page.id, 'error', message); - results.push({ - pageId: page.id, - pageName: page.pageName, - status: 'error', - renewed: false, - error: message, - }); - } - } - - console.log('[TokenManager] Finished token check.'); - } catch (e) { - console.error('[TokenManager] Critical error in checkAndRefreshTokens:', e); - } - - return results; - } - - /** Contrôle (et renouvellement) d'une seule page, déclenché depuis l'UI. */ - static async checkPage(pageId: string): Promise { - const [page] = await db.select().from(socialPages).where(eq(socialPages.id, pageId)); - if (!page) { - throw new Error('Page introuvable'); - } - return this.processPageToken(page); - } - - private static async processPageToken(page: SocialPage): Promise { - // Les comptes TikTok ont leur propre cycle de vie : l'access token ne dure - // que 24h et se renouvelle avec le refresh token, pas via la Graph API. - if (page.platform === 'tiktok') { - return await this.processTiktokToken(page); - } - - console.log(`[TokenManager] Checking page: ${page.pageName}`); - - const accessToken = this.readToken(page.accessToken); - - // Sans application Facebook configurée, on ne peut ni interroger - // /debug_token ni régénérer quoi que ce soit : on se rabat sur le - // contrôle historique (le jeton répond-il encore ?). - if (!(await facebookOAuthService.isConfigured())) { - return this.legacyCheck(page, accessToken); - } - - let info; - try { - info = await facebookOAuthService.inspectToken(accessToken); - } catch (error) { - // Facebook injoignable : on ne dégrade pas le statut sur un incident - // réseau, sinon toute l'UI passerait au rouge le temps d'une panne. - const message = error instanceof Error ? error.message : String(error); - console.warn(`[TokenManager] Inspection impossible pour ${page.pageName}: ${message}`); - return this.legacyCheck(page, accessToken); - } - - const expiresAt = info.expiresAt; - const expiringSoon = - expiresAt !== null && expiresAt.getTime() - Date.now() < RENEWAL_WINDOW_MS; - - if (info.isValid && !expiringSoon) { - // Le jeton de page tient : on en profite pour prolonger le jeton - // utilisateur s'il approche de son échéance, car c'est lui qui - // permettra le prochain renouvellement. - await this.extendUserTokenIfNeeded(page); - await this.persistStatus(page.id, 'valid', null, { tokenExpiresAt: expiresAt }); - return { pageId: page.id, pageName: page.pageName, status: 'valid', renewed: false }; - } - - const reason = info.isValid - ? `Le jeton expire le ${expiresAt?.toLocaleDateString('fr-FR')}` - : info.error || 'Le jeton a été invalidé par Facebook'; - console.warn(`[TokenManager] ${page.pageName}: ${reason} — tentative de renouvellement`); - - // Renouvellement automatique : c'est tout l'intérêt d'avoir conservé le - // jeton utilisateur longue durée au moment de la connexion OAuth. - try { - const renewed = await this.renewFromUserToken(page); - if (renewed) { - console.log(`[TokenManager] Jeton renouvelé automatiquement pour ${page.pageName}`); - return { pageId: page.id, pageName: page.pageName, status: 'valid', renewed: true }; - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`[TokenManager] Renouvellement impossible pour ${page.pageName}: ${message}`); - await this.persistStatus(page.id, info.isValid ? 'expiring' : 'expired', message, { - tokenExpiresAt: expiresAt, - }); - return { - pageId: page.id, - pageName: page.pageName, - status: info.isValid ? 'expiring' : 'expired', - renewed: false, - error: message, - }; - } - - // Pas de jeton utilisateur en base : la page a été ajoutée à la main, - // seule une reconnexion peut la remettre d'aplomb. - const manual = - `${reason}. Reconnectez la page via « Connecter des pages Facebook » ` + - 'pour activer le renouvellement automatique.'; - const status = info.isValid ? 'expiring' : 'expired'; - await this.persistStatus(page.id, status, manual, { tokenExpiresAt: expiresAt }); - return { pageId: page.id, pageName: page.pageName, status, renewed: false, error: manual }; - } - - /** - * Régénère le jeton de page à partir du jeton utilisateur longue durée. - * Renvoie false quand la page n'a pas de jeton utilisateur enregistré. - */ - private static async renewFromUserToken(page: SocialPage): Promise { - if (!page.userAccessToken) return false; - - const storedUserToken = this.readToken(page.userAccessToken); - - // On prolonge d'abord le jeton utilisateur : le ré-échanger repart pour - // ~60 jours, ce qui repousse d'autant la prochaine reconnexion manuelle. - // Un échec ici n'est pas bloquant tant que le jeton actuel fonctionne. - let userToken = storedUserToken; - let userTokenExpiresAt = page.userTokenExpiresAt ?? null; - try { - const extended = await facebookOAuthService.extendUserToken(storedUserToken); - userToken = extended.accessToken; - userTokenExpiresAt = extended.expiresAt; - } catch (error) { - console.warn( - `[TokenManager] Prolongation du jeton utilisateur impossible pour ${page.pageName}:`, - error instanceof Error ? error.message : error - ); - } - - const targets = await facebookOAuthService.listPages(userToken); - // Une page Instagram est publiée avec le jeton de la page Facebook à - // laquelle son compte professionnel est rattaché. - const match = targets.find( - (target) => target.id === page.pageId || target.instagram?.id === page.pageId - ); - - if (!match) { - throw new FacebookAuthError( - `La page « ${page.pageName} » n'est plus administrée par le compte Facebook connecté. ` + - 'Reconnectez-la avec un compte administrateur de cette page.' - ); - } - - // Un jeton de page dérivé d'un jeton utilisateur longue durée n'expire - // pas : `expiresAt` reste donc généralement nul. - let pageTokenExpiresAt: Date | null = null; - try { - pageTokenExpiresAt = (await facebookOAuthService.inspectToken(match.accessToken)).expiresAt; - } catch { - // L'inspection est un confort : le jeton fraîchement émis est bon. - } - - await db.update(socialPages) - .set({ - accessToken: match.accessToken, - tokenExpiresAt: pageTokenExpiresAt, - userAccessToken: encrypt(userToken), - userTokenExpiresAt, - followersCount: match.followersCount || page.followersCount, - tokenStatus: 'valid', - tokenError: null, - lastTokenCheck: new Date(), - }) - .where(eq(socialPages.id, page.id)); - - return true; - } - - /** - * Prolonge le jeton utilisateur quand son échéance approche, même si le - * jeton de page se porte bien : c'est ce qui évite de perdre la capacité de - * renouveler le jour où le jeton de page est révoqué. - */ - private static async extendUserTokenIfNeeded(page: SocialPage): Promise { - if (!page.userAccessToken) return; - - const expiresAt = page.userTokenExpiresAt ? new Date(page.userTokenExpiresAt).getTime() : null; - if (expiresAt !== null && expiresAt - Date.now() > RENEWAL_WINDOW_MS) return; - - try { - const extended = await facebookOAuthService.extendUserToken(this.readToken(page.userAccessToken)); - await db.update(socialPages) - .set({ - userAccessToken: encrypt(extended.accessToken), - userTokenExpiresAt: extended.expiresAt, - }) - .where(eq(socialPages.id, page.id)); - console.log(`[TokenManager] Jeton utilisateur prolongé pour ${page.pageName}`); - } catch (error) { - console.warn( - `[TokenManager] Impossible de prolonger le jeton utilisateur de ${page.pageName}:`, - error instanceof Error ? error.message : error - ); - } - } - - /** - * Contrôle historique, utilisé quand l'application Facebook n'est pas - * configurée : on sait seulement si le jeton répond encore. - */ - private static async legacyCheck(page: SocialPage, accessToken: string): Promise { - const isValid = await this.verifyToken(accessToken); - - if (!isValid) { - const error = - "Le jeton n'est plus accepté par Facebook. Configurez l'application Facebook " + - 'dans les paramètres pour activer le renouvellement automatique, ou saisissez un nouveau jeton.'; - await this.persistStatus(page.id, 'expired', error); - return { pageId: page.id, pageName: page.pageName, status: 'expired', renewed: false, error }; - } - - // Le jeton répond : reste l'échéance déclarée à l'ajout de la page. - const declared = page.tokenExpiresAt ? new Date(page.tokenExpiresAt).getTime() : null; - const status = declared !== null && declared - Date.now() < WARNING_WINDOW_MS ? 'expiring' : 'valid'; - await this.persistStatus(page.id, status, null); - return { pageId: page.id, pageName: page.pageName, status, renewed: false }; - } - - /** - * Vérifie qu'un compte TikTok est toujours utilisable. - * On ne teste pas l'access token (il est renouvelé à la demande avant chaque - * publication) mais le refresh token, dont l'expiration déconnecterait le compte. - */ - private static async processTiktokToken(page: SocialPage): Promise { - console.log(`[TokenManager] Checking TikTok account: ${page.pageName}`); - - const refreshExpiresAt = page.refreshTokenExpiresAt - ? new Date(page.refreshTokenExpiresAt).getTime() - : 0; - - let status: "valid" | "expiring" | "expired" = "valid"; - let error: string | null = null; - - if (!page.refreshToken || (refreshExpiresAt && refreshExpiresAt <= Date.now())) { - status = "expired"; - error = "L'autorisation TikTok a expiré : reconnectez le compte."; - console.warn(`[TokenManager] TikTok account ${page.pageName} must be reconnected`); - } else if (refreshExpiresAt && refreshExpiresAt - Date.now() < 30 * 24 * 60 * 60 * 1000) { - // Moins de 30 jours de validité restante - status = "expiring"; - } - - await this.persistStatus(page.id, status, error); - return { pageId: page.id, pageName: page.pageName, status, renewed: false, error: error ?? undefined }; - } - - private static async persistStatus( - pageId: string, - status: 'valid' | 'expiring' | 'expired' | 'error', - error: string | null, - extra: { tokenExpiresAt?: Date | null } = {} - ): Promise { - await db.update(socialPages) - .set({ - tokenStatus: status, - tokenError: error, - lastTokenCheck: new Date(), - ...(extra.tokenExpiresAt !== undefined ? { tokenExpiresAt: extra.tokenExpiresAt } : {}), - }) - .where(eq(socialPages.id, pageId)); - } - - /** - * Les jetons ont pu être enregistrés en clair (saisie manuelle) ou chiffrés - * (flux OAuth) : `decrypt` renvoie le texte tel quel s'il n'est pas chiffré. - */ - private static readToken(token: string): string { - try { - return this.decrypt(token); - } catch { - return token; - } - } - - private static async verifyToken(token: string): Promise { - try { - // Simple validation check: Get 'me' (page profile) - await GraphAPIClient.get('me', { accessToken: token }); - return true; - } catch (error) { - console.error('[TokenManager] Token verification failed:', error); - return false; - } - } -} diff --git a/server/services/ttsSync.test.ts b/server/services/ttsSync.test.ts deleted file mode 100644 index 143e7b1..0000000 --- a/server/services/ttsSync.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { estimateVoiceTiming } from "./ttsSync"; - -describe("estimateVoiceTiming", () => { - it("ignore hashtags et liens dans le décompte", () => { - expect(estimateVoiceTiming("Venez vite #promo https://x.fr").wordCount).toBe(2); - }); - - it("prévient quand la voix sera trop longue", () => { - const timing = estimateVoiceTiming("mot ".repeat(200)); - expect(timing.isHealthy).toBe(false); - expect(timing.warnings[0]).toMatch(/Texte long/); - }); -}); diff --git a/server/services/ttsSync.ts b/server/services/ttsSync.ts deleted file mode 100644 index 33a85a5..0000000 --- a/server/services/ttsSync.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Estimation de la durée de lecture d'un texte, affichée pendant la saisie. - * - * Auparavant, chaque pause de frappe générait une voix complète pour la - * mesurer (un appel Gemini facturé à chaque fois). Le minutage réel des mots - * vient désormais de la voix au moment du rendu ; ici, une estimation suffit. - */ - -/** Débit moyen d'une voix de synthèse française, en mots par seconde. */ -const WORDS_PER_SECOND = 2.6; -/** Au-delà, la voix dépasse la durée confortable d'un Reel. */ -const MAX_COMFORTABLE_SECONDS = 45; - -export interface SyncTiming { - wordDuration: number; - audioDuration: number; - wordCount: number; - punctuationPause: number; - isHealthy: boolean; - warnings: string[]; -} - -export function estimateVoiceTiming(text: string): SyncTiming { - const words = text - .replace(/#[\wÀ-ÿ]+/g, '') - .replace(/https?:\/\/\S+/g, '') - .split(/\s+/) - .filter((w) => /[A-Za-z0-9À-ÿ]/.test(w)); - const pauses = (text.match(/[.!?;:]/g) ?? []).length; - const punctuationPause = 0.35; - const audioDuration = words.length / WORDS_PER_SECOND + pauses * punctuationPause; - - const warnings: string[] = []; - if (audioDuration > MAX_COMFORTABLE_SECONDS) { - warnings.push(`Texte long : environ ${Math.round(audioDuration)} s de voix. Visez moins de ${MAX_COMFORTABLE_SECONDS} s.`); - } - if (words.length > 0 && words.length < 4) { - warnings.push('Texte très court : la voix ne durera que quelques secondes.'); - } - - return { - wordDuration: words.length ? audioDuration / words.length : 0, - audioDuration, - wordCount: words.length, - punctuationPause, - isHealthy: warnings.length === 0, - warnings, - }; -} diff --git a/server/services/twemoji-parser.d.ts b/server/services/twemoji-parser.d.ts deleted file mode 100644 index 4d8821b..0000000 --- a/server/services/twemoji-parser.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -declare module 'twemoji-parser' { - export interface EmojiEntity { - text: string; - url: string; - indices: [number, number]; - type: string; - } - - export function parse(text: string, options?: { buildUrl?: (codePoint: string, assetType: string) => string }): EmojiEntity[]; - export function toCodePoints(unicodeSurrogates: string): string[]; -} diff --git a/server/src/main.rs b/server/src/main.rs index c551b82..300237a 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,3 +1,5 @@ +// Quelques aides (accès aux données, réponses des services) sont gardées +// complètes même si toutes ne sont pas encore appelées. #![allow(dead_code)] //! SocialFlow — serveur HTTP en Rust. //! diff --git a/server/storage.ts b/server/storage.ts deleted file mode 100644 index eab12a1..0000000 --- a/server/storage.ts +++ /dev/null @@ -1,831 +0,0 @@ -import { - users, - socialPages, - media, - posts, - reelJobs, - postMedia, - scheduledPosts, - aiGenerations, - cloudinaryConfig, - openrouterConfig, - userPagePermissions, - type User, - type InsertUser, - type SocialPage, - type InsertSocialPage, - type Media, - type InsertMedia, - type Post, - type InsertPost, - type PostMedia, - type ScheduledPost, - type InsertScheduledPost, - type AiGeneration, - type InsertAiGeneration, - type CloudinaryConfig, - type InsertCloudinaryConfig, - type OpenrouterConfig, - type InsertOpenrouterConfig, - type UserPagePermission, - type InsertUserPagePermission, - musicFavorites, - type MusicFavorite, - type InsertMusicFavorite, - audioTracks, - type AudioTrack, - type InsertAudioTrack, - appConfig, - type AppConfig, - tiktokConfig, - type TiktokConfig, - type InsertTiktokConfig, - facebookConfig, - type FacebookConfig, - type InsertFacebookConfig, -} from "@shared/schema"; -import { db } from "./db"; -import { eq, and, or, gte, lte, desc, asc, isNull, isNotNull, inArray, notInArray, getTableColumns, sql, type SQL } from "drizzle-orm"; -import { encrypt, decrypt, isEncrypted } from "./utils/encryption"; - -/** - * Statuts de publication asynchrone (TikTok) qui ne bougeront plus : inutile de - * continuer à interroger l'API une fois l'un d'eux atteint. - */ -export const TERMINAL_PUBLISH_STATUSES: string[] = [ - 'PUBLISH_COMPLETE', - 'FAILED', - // Vidéo déposée dans la boîte de réception du créateur : elle attend une action - // manuelle dans TikTok et n'évoluera plus côté API. - 'SEND_TO_USER_INBOX', -]; - -/** - * Déchiffre les tokens d'une page avant de la rendre au reste de l'application. - * Les services (Facebook, TikTok) manipulent toujours des tokens en clair. - */ -function decryptPageTokens(page: SocialPage): SocialPage { - return { - ...page, - accessToken: decrypt(page.accessToken), - refreshToken: page.refreshToken ? decrypt(page.refreshToken) : page.refreshToken, - }; -} - -/** Bornes temporelles utilisées pour les comparaisons du tableau de bord. */ -export interface DashboardStatsRange { - yesterdayStart: Date; - yesterdayEnd: Date; - lastMonth: Date; -} - -export interface DashboardStats { - scheduledPosts: number; - scheduledPostsLastMonth: number; - connectedPages: number; - aiTextsGenerated: number; - aiTextsYesterday: number; - mediaStored: number; -} - -/** - * Colonnes renvoyées pour une publication planifiée. - * - * On ne sélectionne pas la ligne `social_pages` entière : elle porte les jetons - * d'accès Facebook et TikTok, qui partaient jusqu'ici dans chaque réponse HTTP - * — y compris vers le navigateur, qui n'en fait rien. - */ -const SCHEDULED_POST_COLUMNS = { - ...getTableColumns(scheduledPosts), - post: { - id: posts.id, - userId: posts.userId, - content: posts.content, - status: posts.status, - scheduledFor: posts.scheduledFor, - aiGenerated: posts.aiGenerated, - generationStatus: posts.generationStatus, - generationProgress: posts.generationProgress, - createdAt: posts.createdAt, - }, - page: { - id: socialPages.id, - pageId: socialPages.pageId, - pageName: socialPages.pageName, - platform: socialPages.platform, - avatarUrl: socialPages.avatarUrl, - }, -}; - -export interface IStorage { - // Users - getUser(id: string): Promise; - getUserByUsername(username: string): Promise; - getAllUsers(): Promise; - getDashboardStats(userId: string, options: DashboardStatsRange): Promise; - createUser(user: InsertUser): Promise; - updateUser(id: string, user: Partial): Promise; - deleteUser(id: string): Promise; - - // Social Pages - getSocialPages(userId: string): Promise; - getAllSocialPages(): Promise; - getSocialPage(id: string): Promise; - createSocialPage(page: InsertSocialPage): Promise; - updateSocialPage(id: string, page: Partial): Promise; - deleteSocialPage(id: string): Promise; - - // Media - getMedia(userId: string, options?: { limit?: number; offset?: number }): Promise; - getMediaByIds(ids: string[]): Promise; - getMediaCount(userId: string): Promise; - getMediaById(id: string): Promise; - createMedia(media: InsertMedia): Promise; - deleteMedia(id: string): Promise; - getPostMedia(postId: string): Promise; - - // Posts - getPosts(userId: string): Promise; - getPost(id: string): Promise; - getPostWithMedia(id: string): Promise<{ post: Post; media: Media[] } | undefined>; - getOngoingReelPosts(userId: string): Promise<(Post & { generationStep: string | null })[]>; - getRenderingReelJobs(): Promise<{ id: string; postId: string | null; createdAt: Date; params: unknown; content: string | null }[]>; - createPost(post: InsertPost): Promise; - updatePost(id: string, post: Partial): Promise; - updatePostGenerationStatus(id: string, status: string, progress: number, error?: string): Promise; - deletePost(id: string): Promise; - updatePostMedia(postId: string, mediaIds: string[]): Promise; - - // Scheduled Posts - getScheduledPosts(userId: string, startDate?: Date, endDate?: Date): Promise; - getScheduledPostsByPages(pageIds: string[], startDate?: Date, endDate?: Date): Promise; - getScheduledPost(id: string): Promise; - getScheduledPostsByPost(postId: string): Promise; - createScheduledPost(scheduledPost: InsertScheduledPost): Promise; - updateScheduledPost(id: string, scheduledPost: Partial): Promise; - deleteScheduledPost(id: string): Promise; - getPendingScheduledPosts(): Promise; - getScheduledPostsAwaitingPublishStatus(): Promise; - - // AI Generations - getAiGenerations(userId: string): Promise; - createAiGeneration(generation: InsertAiGeneration): Promise; - - // TikTok Config - getTiktokConfig(): Promise; - upsertTiktokConfig(config: Partial): Promise; - getFacebookConfig(): Promise; - upsertFacebookConfig(config: Partial): Promise; - - // Cloudinary Config - getCloudinaryConfig(): Promise; - createCloudinaryConfig(config: InsertCloudinaryConfig): Promise; - updateCloudinaryConfig(config: Partial): Promise; - - // OpenRouter Config - getOpenrouterConfig(userId: string): Promise; - getAnyOpenrouterConfig(): Promise; - createOpenrouterConfig(config: InsertOpenrouterConfig): Promise; - updateOpenrouterConfig(userId: string, config: Partial): Promise; - - // User Page Permissions - getUserPagePermissions(userId: string): Promise; - getPagePermissions(pageId: string): Promise; - createPagePermission(permission: InsertUserPagePermission): Promise; - deletePagePermission(userId: string, pageId: string): Promise; - deleteAllUserPagePermissions(userId: string): Promise; - getUserAccessiblePages(userId: string): Promise; - - // Music Favorites - getMusicFavorites(userId: string): Promise; - addMusicFavorite(favorite: InsertMusicFavorite): Promise; - removeMusicFavorite(userId: string, trackId: string): Promise; - isMusicFavorite(userId: string, trackId: string): Promise; - - // Audio Tracks - getAudioTracks(): Promise; - // Audio Tracks - getAudioTrack(id: string): Promise; - createAudioTrack(track: InsertAudioTrack): Promise; - deleteAudioTrack(id: string): Promise; - - // App Config - getAppConfig(): Promise; - upsertAppConfig(data: Partial>): Promise; -} - -export class DatabaseStorage implements IStorage { - // Users - async getUser(id: string): Promise { - const [user] = await db.select().from(users).where(eq(users.id, id)); - return user || undefined; - } - - async getUserByUsername(username: string): Promise { - const [user] = await db.select().from(users).where(eq(users.username, username)); - return user || undefined; - } - - async getAllUsers(): Promise { - return await db.select().from(users); - } - - async createUser(insertUser: InsertUser): Promise { - const [user] = await db.insert(users).values(insertUser).returning(); - return user; - } - - async updateUser(id: string, updateData: Partial): Promise { - const [updated] = await db.update(users).set(updateData).where(eq(users.id, id)).returning(); - return updated; - } - - async deleteUser(id: string): Promise { - await db.delete(users).where(eq(users.id, id)); - } - - /** - * Compteurs du tableau de bord. - * - * Comptés en SQL : les charger en mémoire revenait à lire quatre tables - * entières — tous les posts, toutes les pages, tous les médias, toutes les - * générations — pour n'en garder que le nombre de lignes. - */ - async getDashboardStats(userId: string, range: DashboardStatsRange): Promise { - const [postCounts, generationCounts, pageCount, mediaCount] = await Promise.all([ - db - .select({ - scheduled: sql`count(*) filter (where ${posts.status} = 'scheduled')`, - scheduledLastMonth: sql`count(*) filter (where ${posts.status} = 'scheduled' and ${posts.createdAt} < ${range.lastMonth})`, - }) - .from(posts) - .where(eq(posts.userId, userId)), - db - .select({ - total: sql`count(*)`, - yesterday: sql`count(*) filter (where ${aiGenerations.createdAt} >= ${range.yesterdayStart} and ${aiGenerations.createdAt} < ${range.yesterdayEnd})`, - }) - .from(aiGenerations) - .where(eq(aiGenerations.userId, userId)), - db.select({ total: sql`count(*)` }).from(socialPages).where(eq(socialPages.userId, userId)), - db.select({ total: sql`count(*)` }).from(media).where(eq(media.userId, userId)), - ]); - - return { - scheduledPosts: Number(postCounts[0]?.scheduled ?? 0), - scheduledPostsLastMonth: Number(postCounts[0]?.scheduledLastMonth ?? 0), - connectedPages: Number(pageCount[0]?.total ?? 0), - aiTextsGenerated: Number(generationCounts[0]?.total ?? 0), - aiTextsYesterday: Number(generationCounts[0]?.yesterday ?? 0), - mediaStored: Number(mediaCount[0]?.total ?? 0), - }; - } - - // Social Pages - async getSocialPages(userId: string): Promise { - const pages = await db.select().from(socialPages).where(eq(socialPages.userId, userId)); - return pages.map(page => decryptPageTokens(page)); - } - - /** - * Toutes les pages de l'instance, pour les vues d'administration. - * Remplace un `getAllUsers()` suivi d'une requête par utilisateur. - */ - async getAllSocialPages(): Promise { - const pages = await db.select().from(socialPages); - return pages.map(page => decryptPageTokens(page)); - } - - async getSocialPage(id: string): Promise { - const [page] = await db.select().from(socialPages).where(eq(socialPages.id, id)); - return page ? decryptPageTokens(page) : undefined; - } - - async createSocialPage(page: InsertSocialPage): Promise { - // Chiffrer les tokens avant stockage - const encryptedPage = { - ...page, - accessToken: encrypt(page.accessToken), - ...(page.refreshToken ? { refreshToken: encrypt(page.refreshToken) } : {}), - }; - const [newPage] = await db.insert(socialPages).values(encryptedPage).returning(); - return decryptPageTokens(newPage); - } - - async updateSocialPage(id: string, page: Partial): Promise { - // Chiffrer les tokens présents dans la mise à jour - const updateData: Partial = { ...page }; - if (page.accessToken) { - updateData.accessToken = encrypt(page.accessToken); - } - if (page.refreshToken) { - updateData.refreshToken = encrypt(page.refreshToken); - } - const [updated] = await db.update(socialPages).set(updateData).where(eq(socialPages.id, id)).returning(); - return decryptPageTokens(updated); - } - - async deleteSocialPage(id: string): Promise { - await db.delete(socialPages).where(eq(socialPages.id, id)); - } - - // Media - /** - * Médias d'un utilisateur, du plus récent au plus ancien. - * `limit` sert aux écrans qui n'en affichent qu'une poignée : sans lui, la - * médiathèque entière transitait pour en montrer cinq. - */ - async getMedia(userId: string, options: { limit?: number; offset?: number } = {}): Promise { - let query = db - .select() - .from(media) - .where(eq(media.userId, userId)) - .orderBy(desc(media.createdAt)) - .$dynamic(); - - if (options.limit !== undefined) query = query.limit(options.limit); - if (options.offset !== undefined) query = query.offset(options.offset); - - return await query; - } - - async getMediaByIds(ids: string[]): Promise { - if (ids.length === 0) return []; - return await db.select().from(media).where(inArray(media.id, ids)); - } - - /** Total de médias, pour que la pagination sache où elle s'arrête. */ - async getMediaCount(userId: string): Promise { - const [row] = await db - .select({ total: sql`count(*)` }) - .from(media) - .where(eq(media.userId, userId)); - return Number(row?.total ?? 0); - } - - async getMediaById(id: string): Promise { - const [mediaItem] = await db.select().from(media).where(eq(media.id, id)); - return mediaItem || undefined; - } - - async createMedia(mediaItem: InsertMedia): Promise { - const [newMedia] = await db.insert(media).values(mediaItem).returning(); - return newMedia; - } - - async deleteMedia(id: string): Promise { - await db.delete(media).where(eq(media.id, id)); - } - - async getPostMedia(postId: string): Promise { - return await db.select().from(postMedia).where(eq(postMedia.postId, postId)).orderBy(asc(postMedia.displayOrder)); - } - - // Posts - async getPosts(userId: string): Promise { - return await db.select().from(posts).where(eq(posts.userId, userId)).orderBy(desc(posts.createdAt)); - } - - async getPost(id: string): Promise { - const [post] = await db.select().from(posts).where(eq(posts.id, id)); - return post || undefined; - } - - async getPostWithMedia(id: string): Promise<{ post: Post; media: Media[] } | undefined> { - const post = await this.getPost(id); - if (!post) return undefined; - - const postMediaLinks = await this.getPostMedia(id); - - // Une seule requête pour tous les médias, puis remise dans l'ordre - // d'affichage : une requête par média rendait le coût proportionnel au - // nombre de visuels du post. - const byId = new Map( - (await this.getMediaByIds(postMediaLinks.map(link => link.mediaId))).map(item => [item.id, item]) - ); - const mediaItems = postMediaLinks - .map(link => byId.get(link.mediaId)) - .filter((item): item is Media => item !== undefined); - - return { post, media: mediaItems }; - } - - async createPost(post: InsertPost): Promise { - const [newPost] = await db.insert(posts).values(post).returning(); - return newPost; - } - - async updatePost(id: string, post: Partial): Promise { - const [updated] = await db.update(posts).set({ ...post, updatedAt: new Date() }).where(eq(posts.id, id)).returning(); - return updated; - } - - async deletePost(id: string): Promise { - await db.delete(posts).where(eq(posts.id, id)); - } - - async updatePostGenerationStatus( - id: string, - status: string, - progress: number, - error?: string - ): Promise { - const updateData: Record = { - generationStatus: status, - generationProgress: progress, - updatedAt: new Date(), - }; - if (error !== undefined) { - updateData.generationError = error; - } - const [updated] = await db - .update(posts) - .set(updateData) - .where(eq(posts.id, id)) - .returning(); - return updated; - } - - async updatePostMedia(postId: string, mediaIds: string[]): Promise { - await db.delete(postMedia).where(eq(postMedia.postId, postId)); - - if (mediaIds.length > 0) { - const postMediaEntries = mediaIds.map((mediaId, index) => ({ - postId, - mediaId, - displayOrder: index, - })); - await db.insert(postMedia).values(postMediaEntries); - } - } - - // Scheduled Posts - async getScheduledPosts(userId: string, startDate?: Date, endDate?: Date): Promise { - return this.queryScheduledPosts(eq(posts.userId, userId), startDate, endDate); - } - - async getScheduledPostsByPages(pageIds: string[], startDate?: Date, endDate?: Date): Promise { - if (pageIds.length === 0) { - return []; - } - return this.queryScheduledPosts(inArray(scheduledPosts.pageId, pageIds), startDate, endDate); - } - - /** - * Publications planifiées, avec le post et la page auxquels elles se rapportent. - * - * La période est filtrée en SQL : la charger entière pour la filtrer en - * mémoire revenait à lire tout l'historique à chaque affichage du calendrier. - */ - private async queryScheduledPosts( - scope: SQL, - startDate?: Date, - endDate?: Date - ): Promise { - const conditions: SQL[] = [scope]; - if (startDate) conditions.push(gte(scheduledPosts.scheduledAt, startDate)); - if (endDate) conditions.push(lte(scheduledPosts.scheduledAt, endDate)); - - const rows = await db - .select(SCHEDULED_POST_COLUMNS) - .from(scheduledPosts) - .innerJoin(posts, eq(scheduledPosts.postId, posts.id)) - .leftJoin(socialPages, eq(scheduledPosts.pageId, socialPages.id)) - .where(conditions.length === 1 ? conditions[0] : and(...conditions)) - .orderBy(desc(scheduledPosts.scheduledAt)); - - // La jointure externe renvoie un objet aux champs nuls quand la page a été - // supprimée ; les appelants attendent `page: null` dans ce cas. - return rows.map(row => ({ ...row, page: row.page?.id ? row.page : null })); - } - - async getScheduledPost(id: string): Promise { - const [scheduledPost] = await db.select().from(scheduledPosts).where(eq(scheduledPosts.id, id)); - return scheduledPost || undefined; - } - - async getScheduledPostsByPost(postId: string): Promise { - return await db.select().from(scheduledPosts).where(eq(scheduledPosts.postId, postId)); - } - - async createScheduledPost(scheduledPost: InsertScheduledPost): Promise { - const [newScheduledPost] = await db.insert(scheduledPosts).values(scheduledPost).returning(); - return newScheduledPost; - } - - async updateScheduledPost(id: string, scheduledPost: Partial): Promise { - const [updated] = await db.update(scheduledPosts).set(scheduledPost).where(eq(scheduledPosts.id, id)).returning(); - return updated; - } - - async deleteScheduledPost(id: string): Promise { - await db.delete(scheduledPosts).where(eq(scheduledPosts.id, id)); - } - - async getPendingScheduledPosts(): Promise { - const now = new Date(); - const results = await db - .select() - .from(scheduledPosts) - .where( - and( - isNull(scheduledPosts.publishedAt), - lte(scheduledPosts.scheduledAt, now) - ) - ); - return results; - } - - /** - * Publications déjà envoyées à TikTok dont le statut final n'est pas encore - * connu (l'API répond de façon asynchrone via un publish_id). - */ - async getScheduledPostsAwaitingPublishStatus(): Promise { - return await db - .select() - .from(scheduledPosts) - .where( - and( - isNotNull(scheduledPosts.publishId), - notInArray(scheduledPosts.publishStatus, TERMINAL_PUBLISH_STATUSES) - ) - ); - } - - /** - * Reels dont la génération est en cours, plus ceux qui ont échoué dans les - * dernières 24 h (pour que l'utilisateur en voie la cause), avec l'étape - * courante du rendu. Filtré en SQL : la route est interrogée toutes les - * trois secondes pendant un rendu. - */ - async getOngoingReelPosts(userId: string): Promise<(Post & { generationStep: string | null })[]> { - const since = new Date(Date.now() - 24 * 60 * 60 * 1000); - const rows = await db - .select() - .from(posts) - .where( - and( - eq(posts.userId, userId), - or( - inArray(posts.generationStatus, ['processing', 'pending']), - and(eq(posts.generationStatus, 'failed'), gte(posts.updatedAt, since), isNotNull(posts.generationError)), - ), - ) - ) - .orderBy(desc(posts.createdAt)); - if (rows.length === 0) return []; - - const jobs = await db - .select({ postId: reelJobs.postId, step: reelJobs.step }) - .from(reelJobs) - .where(inArray(reelJobs.postId, rows.map((p) => p.id))); - const stepByPost = new Map(jobs.map((j) => [j.postId, j.step])); - return rows.map((post) => ({ ...post, generationStep: stepByPost.get(post.id) ?? null })); - } - - /** - * Reels vidéo en file ou en montage : leurs publications planifiées - * n'existent qu'à la fin du rendu, l'agenda les lit donc ici. - */ - async getRenderingReelJobs(): Promise<{ id: string; postId: string | null; createdAt: Date; params: unknown; content: string | null }[]> { - return await db - .select({ - id: reelJobs.id, - postId: reelJobs.postId, - createdAt: reelJobs.createdAt, - params: reelJobs.params, - content: posts.content, - }) - .from(reelJobs) - .leftJoin(posts, eq(reelJobs.postId, posts.id)) - .where(and(eq(reelJobs.kind, 'video'), inArray(reelJobs.status, ['pending', 'processing']))); - } - - // AI Generations - async getAiGenerations(userId: string): Promise { - return await db.select().from(aiGenerations).where(eq(aiGenerations.userId, userId)).orderBy(desc(aiGenerations.createdAt)); - } - - async createAiGeneration(generation: InsertAiGeneration): Promise { - const [newGeneration] = await db.insert(aiGenerations).values(generation).returning(); - return newGeneration; - } - - // Cloudinary Config - async getCloudinaryConfig(): Promise { - const [config] = await db.select().from(cloudinaryConfig).limit(1); - return config || undefined; - } - - async createCloudinaryConfig(config: InsertCloudinaryConfig): Promise { - const [newConfig] = await db.insert(cloudinaryConfig).values(config).returning(); - return newConfig; - } - - async updateCloudinaryConfig(config: Partial): Promise { - const existingConfig = await this.getCloudinaryConfig(); - if (!existingConfig) throw new Error("Cloudinary config not initialized"); - - const [updated] = await db.update(cloudinaryConfig) - .set({ ...config, updatedAt: new Date() }) - .where(eq(cloudinaryConfig.id, existingConfig.id)) - .returning(); - return updated; - } - - // OpenRouter Config - async getOpenrouterConfig(userId: string): Promise { - const [config] = await db.select().from(openrouterConfig).where(eq(openrouterConfig.userId, userId)); - return config || undefined; - } - - async getAnyOpenrouterConfig(): Promise { - const [config] = await db.select().from(openrouterConfig).orderBy(desc(openrouterConfig.updatedAt)).limit(1); - return config || undefined; - } - - async createOpenrouterConfig(config: InsertOpenrouterConfig): Promise { - const [newConfig] = await db.insert(openrouterConfig).values(config).returning(); - return newConfig; - } - - async updateOpenrouterConfig(userId: string, config: Partial): Promise { - const [updated] = await db.update(openrouterConfig) - .set({ ...config, updatedAt: new Date() }) - .where(eq(openrouterConfig.userId, userId)) - .returning(); - return updated; - } - - // User Page Permissions - async getUserPagePermissions(userId: string): Promise { - return await db.select().from(userPagePermissions).where(eq(userPagePermissions.userId, userId)); - } - - async getPagePermissions(pageId: string): Promise { - return await db.select().from(userPagePermissions).where(eq(userPagePermissions.pageId, pageId)); - } - - async createPagePermission(permission: InsertUserPagePermission): Promise { - const [newPermission] = await db.insert(userPagePermissions).values(permission).returning(); - return newPermission; - } - - async deletePagePermission(userId: string, pageId: string): Promise { - await db.delete(userPagePermissions).where( - and( - eq(userPagePermissions.userId, userId), - eq(userPagePermissions.pageId, pageId) - ) - ); - } - - async deleteAllUserPagePermissions(userId: string): Promise { - await db.delete(userPagePermissions).where(eq(userPagePermissions.userId, userId)); - } - - async getUserAccessiblePages(userId: string): Promise { - const permissions = await db - .select() - .from(userPagePermissions) - .innerJoin(socialPages, eq(userPagePermissions.pageId, socialPages.id)) - .where(eq(userPagePermissions.userId, userId)); - - return permissions.map(p => p.social_pages); - } - - // Music Favorites - async getMusicFavorites(userId: string): Promise { - return db.select().from(musicFavorites) - .where(eq(musicFavorites.userId, userId)) - .orderBy(desc(musicFavorites.createdAt)); - } - - async addMusicFavorite(favorite: InsertMusicFavorite): Promise { - const [created] = await db.insert(musicFavorites).values(favorite).returning(); - return created; - } - - async removeMusicFavorite(userId: string, trackId: string): Promise { - await db.delete(musicFavorites).where( - and( - eq(musicFavorites.userId, userId), - eq(musicFavorites.trackId, trackId) - ) - ); - } - - async isMusicFavorite(userId: string, trackId: string): Promise { - const [fav] = await db.select().from(musicFavorites).where( - and( - eq(musicFavorites.userId, userId), - eq(musicFavorites.trackId, trackId) - ) - ); - return !!fav; - } - - // Audio Tracks - async getAudioTracks(): Promise { - return db.select().from(audioTracks).orderBy(desc(audioTracks.createdAt)); - } - - async getAudioTrack(id: string): Promise { - const [track] = await db.select().from(audioTracks).where(eq(audioTracks.id, id)); - return track || undefined; - } - - async createAudioTrack(track: InsertAudioTrack): Promise { - const [newTrack] = await db.insert(audioTracks).values(track).returning(); - return newTrack; - } - - async deleteAudioTrack(id: string): Promise { - await db.delete(audioTracks).where(eq(audioTracks.id, id)); - } - - // App Config - async getAppConfig(): Promise { - const [config] = await db.select().from(appConfig).limit(1); - return config || undefined; - } - - // Facebook Config (application développeur, globale à l'instance) - async getFacebookConfig(): Promise { - const [config] = await db.select().from(facebookConfig).limit(1); - if (!config) return undefined; - return { ...config, appSecret: decrypt(config.appSecret) }; - } - - async upsertFacebookConfig(data: Partial): Promise { - const values: Partial = { ...data }; - if (data.appSecret) { - values.appSecret = encrypt(data.appSecret); - } - - const [existing] = await db.select().from(facebookConfig).limit(1); - if (existing) { - const [updated] = await db.update(facebookConfig) - .set({ ...values, updatedAt: new Date() }) - .where(eq(facebookConfig.id, existing.id)) - .returning(); - return { ...updated, appSecret: decrypt(updated.appSecret) }; - } - - if (!values.appId || !values.appSecret) { - throw new Error("L'App ID et l'App Secret Facebook sont requis"); - } - - const [created] = await db.insert(facebookConfig) - .values({ appId: values.appId, appSecret: values.appSecret }) - .returning(); - return { ...created, appSecret: decrypt(created.appSecret) }; - } - - // TikTok Config - async getTiktokConfig(): Promise { - const [config] = await db.select().from(tiktokConfig).limit(1); - if (!config) return undefined; - return { ...config, clientSecret: decrypt(config.clientSecret) }; - } - - async upsertTiktokConfig(data: Partial): Promise { - const values: Partial = { ...data }; - if (data.clientSecret) { - values.clientSecret = encrypt(data.clientSecret); - } - - const [existing] = await db.select().from(tiktokConfig).limit(1); - if (existing) { - const [updated] = await db.update(tiktokConfig) - .set({ ...values, updatedAt: new Date() }) - .where(eq(tiktokConfig.id, existing.id)) - .returning(); - return { ...updated, clientSecret: decrypt(updated.clientSecret) }; - } - - if (!values.clientKey || !values.clientSecret) { - throw new Error("Le client key et le client secret TikTok sont requis"); - } - - const [created] = await db.insert(tiktokConfig) - .values({ clientKey: values.clientKey, clientSecret: values.clientSecret }) - .returning(); - return { ...created, clientSecret: decrypt(created.clientSecret) }; - } - - async upsertAppConfig(data: Partial>): Promise { - const existing = await this.getAppConfig(); - if (existing) { - const [updated] = await db.update(appConfig) - .set({ ...data, updatedAt: new Date() }) - .where(eq(appConfig.id, existing.id)) - .returning(); - return updated; - } - const [created] = await db.insert(appConfig).values(data).returning(); - return created; - } -} - -export const storage = new DatabaseStorage(); diff --git a/server/utils/encryption.ts b/server/utils/encryption.ts deleted file mode 100644 index c44a86a..0000000 --- a/server/utils/encryption.ts +++ /dev/null @@ -1,152 +0,0 @@ -import crypto from 'crypto'; -import fs from 'fs'; -import path from 'path'; - -const ALGORITHM = 'aes-256-gcm'; -const IV_LENGTH = 16; - -// Chemin du fichier de clé - utilise le volume Docker si disponible, sinon le répertoire courant -const KEY_DIR = fs.existsSync('/app/.encryption-key-data') - ? '/app/.encryption-key-data' - : process.cwd(); -const KEY_FILE_PATH = path.join(KEY_DIR, '.encryption-key'); - -// Cache de la clé pour éviter de lire le fichier à chaque appel -let cachedKey: Buffer | null = null; -let keyInitialized = false; - -/** - * Génère une clé de chiffrement aléatoire et la sauvegarde dans un fichier. - * @returns La clé générée - */ -function generateAndSaveKey(): Buffer { - const randomKey = crypto.randomBytes(32).toString('hex'); - try { - fs.writeFileSync(KEY_FILE_PATH, randomKey, { mode: 0o600 }); // Permissions restrictives - console.log('🔐 Nouvelle clé de chiffrement générée et sauvegardée automatiquement'); - } catch (error) { - console.warn('⚠️ Impossible de sauvegarder la clé de chiffrement dans un fichier. Elle sera régénérée au prochain redémarrage.'); - } - return crypto.scryptSync(randomKey, 'socialflow-salt', 32); -} - -/** - * Charge la clé depuis le fichier persistant si elle existe. - * @returns La clé ou null si non trouvée - */ -function loadKeyFromFile(): Buffer | null { - try { - if (fs.existsSync(KEY_FILE_PATH)) { - const savedKey = fs.readFileSync(KEY_FILE_PATH, 'utf8').trim(); - if (savedKey) { - console.log('🔐 Clé de chiffrement chargée depuis le fichier persistant'); - return crypto.scryptSync(savedKey, 'socialflow-salt', 32); - } - } - } catch (error) { - console.warn('⚠️ Impossible de lire le fichier de clé de chiffrement'); - } - return null; -} - -/** - * Récupère la clé de chiffrement. - * Priorité: 1) Variable d'environnement, 2) Fichier persistant, 3) Génération auto - */ -function getEncryptionKey(): Buffer { - // Retourner la clé en cache si déjà initialisée - if (keyInitialized && cachedKey) { - return cachedKey; - } - - // 1. Priorité à la variable d'environnement - const envKey = process.env.ENCRYPTION_KEY; - if (envKey) { - cachedKey = crypto.scryptSync(envKey, 'socialflow-salt', 32); - keyInitialized = true; - return cachedKey; - } - - // 2. Essayer de charger depuis le fichier persistant - const fileKey = loadKeyFromFile(); - if (fileKey) { - cachedKey = fileKey; - keyInitialized = true; - return cachedKey; - } - - // 3. Générer automatiquement une nouvelle clé et la sauvegarder - cachedKey = generateAndSaveKey(); - keyInitialized = true; - return cachedKey; -} - -/** - * Chiffre une chaîne de texte avec AES-256-GCM. - * @param text - Le texte à chiffrer - * @returns Le texte chiffré au format: iv:authTag:encrypted (hex) - */ -export function encrypt(text: string): string { - const key = getEncryptionKey(); - const iv = crypto.randomBytes(IV_LENGTH); - const cipher = crypto.createCipheriv(ALGORITHM, key, iv); - - let encrypted = cipher.update(text, 'utf8', 'hex'); - encrypted += cipher.final('hex'); - const authTag = cipher.getAuthTag(); - - // Format: iv:authTag:encrypted - return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`; -} - -/** - * Déchiffre une chaîne chiffrée avec AES-256-GCM. - * @param encryptedText - Le texte chiffré au format iv:authTag:encrypted - * @returns Le texte déchiffré - */ -export function decrypt(encryptedText: string): string { - // Si le texte ne contient pas le format attendu, retourner tel quel - // (pour la rétrocompatibilité avec les tokens non chiffrés) - if (!encryptedText.includes(':')) { - return encryptedText; - } - - const key = getEncryptionKey(); - const parts = encryptedText.split(':'); - - if (parts.length !== 3) { - // Format invalide, retourner tel quel (rétrocompatibilité) - return encryptedText; - } - - const [ivHex, authTagHex, encrypted] = parts; - - try { - const iv = Buffer.from(ivHex, 'hex'); - const authTag = Buffer.from(authTagHex, 'hex'); - const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); - decipher.setAuthTag(authTag); - - let decrypted = decipher.update(encrypted, 'hex', 'utf8'); - decrypted += decipher.final('utf8'); - - return decrypted; - } catch (error) { - // Si le déchiffrement échoue, retourner tel quel (rétrocompatibilité) - console.warn('⚠️ Échec du déchiffrement, token probablement non chiffré'); - return encryptedText; - } -} - -/** - * Vérifie si un texte est déjà chiffré (format iv:authTag:encrypted). - * @param text - Le texte à vérifier - * @returns true si le texte semble être chiffré - */ -export function isEncrypted(text: string): boolean { - if (!text.includes(':')) return false; - const parts = text.split(':'); - if (parts.length !== 3) return false; - // Vérifier que les parties ressemblent à du hex - return parts.every(part => /^[a-f0-9]+$/i.test(part)); -} diff --git a/server/utils/graph_client.ts b/server/utils/graph_client.ts deleted file mode 100644 index 7bbc047..0000000 --- a/server/utils/graph_client.ts +++ /dev/null @@ -1,94 +0,0 @@ - - -export class FacebookApiError extends Error { - constructor( - public message: string, - public status: number, - public code: number, - public fbTraceId: string - ) { - super(message); - this.name = 'FacebookApiError'; - } -} - -interface GraphRequestConfig { - params?: Record; - accessToken?: string; -} - -const GRAPH_API_VERSION = 'v19.0'; -const BASE_URL = `https://graph.facebook.com/${GRAPH_API_VERSION}`; - -export class GraphAPIClient { - - static async get(endpoint: string, config: GraphRequestConfig): Promise { - const url = this.buildUrl(endpoint, config); - console.log(`[GraphAPI] GET ${endpoint}`); - - // In node-fetch v2 (which is typically used in CJS/TS mixes unless v3 ESM), basic fetch - // If project uses v3, imports might differ. Assuming standard fetch is available or polyfilled. - // socialflow package.json has "@types/node-fetch": "^2.6.13" but also "node-fetch" isn't explicitly in dependencies? - // Wait, package.json has "node-fetch" missing in dependencies list! - // But it has "@types/node-fetch". - // "canvas" is there. "express" is there. - // I need to check if fetch is globally available (Node 18+) or if I need to use `axios` or add `node-fetch`. - // Package.json has: "type": "module". Node 20.16.11 is used in parsing. - // Node 18+ has native fetch. I should use `global.fetch` or just `fetch`. - // Explicit import `import fetch from 'node-fetch'` might fail if not installed. - // I will use native fetch. - - // Remove import above if using native. - - const response = await fetch(url.toString()); - return this.handleResponse(response); - } - - static async post(endpoint: string, body: any, config: GraphRequestConfig): Promise { - const url = this.buildUrl(endpoint, config); - console.log(`[GraphAPI] POST ${endpoint}`); - - const response = await fetch(url.toString(), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); - - return this.handleResponse(response); - } - - private static buildUrl(endpoint: string, config: GraphRequestConfig): URL { - const url = new URL(`${BASE_URL}${endpoint.startsWith('/') ? endpoint : '/' + endpoint}`); - - if (config.accessToken) { - url.searchParams.append('access_token', config.accessToken); - } - - if (config.params) { - Object.entries(config.params).forEach(([key, value]) => { - url.searchParams.append(key, String(value)); - }); - } - - return url; - } - - private static async handleResponse(response: Response): Promise { - const data: any = await response.json(); - - if (!response.ok) { - // Parse FB Error structure - const error = data.error || {}; - throw new FacebookApiError( - error.message || 'Unknown Facebook API Error', - response.status, - error.code || 0, - error.fbtrace_id || '' - ); - } - - return data as T; - } -} diff --git a/server/utils/public_url.ts b/server/utils/public_url.ts deleted file mode 100644 index c721e8b..0000000 --- a/server/utils/public_url.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * URL publique de l'application. - * - * Les flux OAuth (Facebook, TikTok) exigent une URI de redirection *exacte* : - * celle envoyée au moment de l'autorisation doit être identique à celle - * envoyée lors de l'échange du code, et déclarée telle quelle chez le - * fournisseur. Se rabattre en silence sur `http://localhost:5555` quand - * `APP_URL` n'est pas défini produit une URI que Facebook refuse — et - * l'erreur n'apparaît qu'au moment de connecter une page. - * - * On préfère donc, à défaut d'`APP_URL`, reconstruire l'URL à partir de la - * requête entrante : derrière Nginx, les en-têtes `X-Forwarded-Proto` et - * `X-Forwarded-Host` portent le domaine public réel (`trust proxy` est activé - * dans `server/index.ts`). Le repli localhost ne sert plus qu'au développement - * local, hors requête HTTP. - */ - -import type { Request } from 'express'; - -const DEV_FALLBACK = 'http://localhost:5555'; - -/** Premier élément d'un en-tête potentiellement chaîné (« a, b, c »). */ -function firstHeaderValue(value: string | undefined): string | undefined { - const first = value?.split(',')[0]?.trim(); - return first || undefined; -} - -/** - * Base publique sans slash final (« https://exemple.fr »). - * `APP_URL` prime toujours ; sinon la requête fait foi. - */ -export function resolvePublicBaseUrl(req?: Request): string { - const configured = process.env.APP_URL?.trim(); - if (configured) { - return configured.replace(/\/$/, ''); - } - - if (req) { - const host = firstHeaderValue(req.get('x-forwarded-host')) || req.get('host'); - if (host) { - const protocol = firstHeaderValue(req.get('x-forwarded-proto')) || req.protocol || 'http'; - return `${protocol}://${host}`; - } - } - - return DEV_FALLBACK; -} diff --git a/server/vite.ts b/server/vite.ts deleted file mode 100644 index 9338c14..0000000 --- a/server/vite.ts +++ /dev/null @@ -1,85 +0,0 @@ -import express, { type Express } from "express"; -import fs from "fs"; -import path from "path"; -import { createServer as createViteServer, createLogger } from "vite"; -import { type Server } from "http"; -import viteConfig from "../vite.config"; -import { nanoid } from "nanoid"; - -const viteLogger = createLogger(); - -export function log(message: string, source = "express") { - const formattedTime = new Date().toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - second: "2-digit", - hour12: true, - }); - - console.log(`${formattedTime} [${source}] ${message}`); -} - -export async function setupVite(app: Express, server: Server) { - const serverOptions = { - middlewareMode: true, - hmr: { server }, - allowedHosts: true as const, - }; - - const vite = await createViteServer({ - ...viteConfig, - configFile: false, - customLogger: { - ...viteLogger, - error: (msg, options) => { - viteLogger.error(msg, options); - process.exit(1); - }, - }, - server: serverOptions, - appType: "custom", - }); - - app.use(vite.middlewares); - app.use("*", async (req, res, next) => { - const url = req.originalUrl; - - try { - const clientTemplate = path.resolve( - import.meta.dirname, - "..", - "client", - "index.html", - ); - - // always reload the index.html file from disk incase it changes - let template = await fs.promises.readFile(clientTemplate, "utf-8"); - template = template.replace( - `src="/src/main.tsx"`, - `src="/src/main.tsx?v=${nanoid()}"`, - ); - const page = await vite.transformIndexHtml(url, template); - res.status(200).set({ "Content-Type": "text/html" }).end(page); - } catch (e) { - vite.ssrFixStacktrace(e as Error); - next(e); - } - }); -} - -export function serveStatic(app: Express) { - const distPath = path.resolve(import.meta.dirname, "public"); - - if (!fs.existsSync(distPath)) { - throw new Error( - `Could not find the build directory: ${distPath}, make sure to build the client first`, - ); - } - - app.use(express.static(distPath)); - - // fall through to index.html if the file doesn't exist - app.use("*", (_req, res) => { - res.sendFile(path.resolve(distPath, "index.html")); - }); -}