mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-18 04:36:32 +02:00
Retrait de l'ancien serveur Node/TypeScript
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013qMexTfVRaegbPAgsagCAJ
This commit is contained in:
54 files changed
+2
-13174
No files matched your search
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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)
|
||||
@@ -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
|
||||
--
|
||||
-- ============================================================
|
||||
@@ -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;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1768914136854,
|
||||
"tag": "0000_spicy_cargill",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 * * *).');
|
||||
}
|
||||
@@ -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 });
|
||||
-241
@@ -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<string, any> | 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);
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
-2120
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
@@ -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<string, string> = {
|
||||
"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: <EXTERNAL_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<string, string | undefined>;
|
||||
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<string, {
|
||||
id: string;
|
||||
content: string;
|
||||
status: string;
|
||||
scheduledFor: Date | null;
|
||||
createdAt: Date | null;
|
||||
media: Array<{ id: string; url: string; type: string }>;
|
||||
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<typeof item> => 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 };
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
@@ -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, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function renderPage(title: string, body: string): string {
|
||||
return `<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body {
|
||||
max-width: 46rem;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.25rem 4rem;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
line-height: 1.65;
|
||||
color: #1f2328;
|
||||
background: #ffffff;
|
||||
}
|
||||
h1 { font-size: 1.75rem; margin-bottom: 0.25rem; }
|
||||
h2 { font-size: 1.15rem; margin-top: 2.25rem; }
|
||||
.updated { color: #6b7280; font-size: 0.875rem; margin-top: 0; }
|
||||
ul { padding-left: 1.25rem; }
|
||||
li { margin-bottom: 0.35rem; }
|
||||
code { background: rgba(127, 127, 127, 0.15); padding: 0.1rem 0.3rem; border-radius: 0.25rem; font-size: 0.9em; }
|
||||
footer { margin-top: 3rem; padding-top: 1.25rem; border-top: 1px solid rgba(127,127,127,0.3); color: #6b7280; font-size: 0.875rem; }
|
||||
a { color: #2563eb; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { color: #e6e6e6; background: #0f1115; }
|
||||
.updated, footer { color: #9ca3af; }
|
||||
a { color: #7aa7ff; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${body}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function renderFooter(): string {
|
||||
const company = getCompanyInfo();
|
||||
return `<footer>
|
||||
<p>
|
||||
${escapeHtml(company.name)} – enseigne ${escapeHtml(company.tradeName)}<br>
|
||||
${escapeHtml(company.legalForm)}<br>
|
||||
${escapeHtml(company.address)}<br>
|
||||
${escapeHtml(company.rcs)}<br>
|
||||
Contact : ${escapeHtml(company.email)}
|
||||
</p>
|
||||
<p><a href="/terms">Conditions d'utilisation</a> · <a href="/privacy">Politique de confidentialité</a></p>
|
||||
</footer>`;
|
||||
}
|
||||
|
||||
legalRouter.get('/terms', (_req: Request, res: Response) => {
|
||||
const company = getCompanyInfo();
|
||||
|
||||
const body = `
|
||||
<h1>Conditions d'utilisation</h1>
|
||||
<p class="updated">Dernière mise à jour : 11 août 2026</p>
|
||||
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>1. Accès au service</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>2. Comptes de réseaux sociaux connectés</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>3. Contenus publiés</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>4. Disponibilité</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>5. Responsabilité</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>6. Modification des conditions</h2>
|
||||
<p>
|
||||
Ces conditions peuvent être modifiées. La date de dernière mise à jour figure en tête de page.
|
||||
</p>
|
||||
|
||||
<h2>7. Contact</h2>
|
||||
<p>Pour toute question relative à ces conditions : ${escapeHtml(company.email)}</p>
|
||||
|
||||
${renderFooter()}`;
|
||||
|
||||
res.type('html').send(renderPage("Conditions d'utilisation", body));
|
||||
});
|
||||
|
||||
legalRouter.get('/privacy', (_req: Request, res: Response) => {
|
||||
const company = getCompanyInfo();
|
||||
|
||||
const body = `
|
||||
<h1>Politique de confidentialité</h1>
|
||||
<p class="updated">Dernière mise à jour : 11 août 2026</p>
|
||||
|
||||
<p>
|
||||
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)}.
|
||||
</p>
|
||||
|
||||
<h2>1. Données de nos utilisateurs internes</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>2. Données issues des comptes de réseaux sociaux connectés</h2>
|
||||
<p>
|
||||
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 <code>user.info.basic</code> :
|
||||
</p>
|
||||
<ul>
|
||||
<li>l'identifiant technique du compte (<code>open_id</code>) ;</li>
|
||||
<li>le nom d'affichage du compte ;</li>
|
||||
<li>l'image de profil du compte ;</li>
|
||||
<li>un jeton d'accès et un jeton de renouvellement, qui nous permettent de publier en son nom.</li>
|
||||
</ul>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>3. Finalités</h2>
|
||||
<p>
|
||||
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é.
|
||||
</p>
|
||||
|
||||
<h2>4. Données que nous transmettons aux plateformes</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>5. Conservation et sécurité</h2>
|
||||
<ul>
|
||||
<li>Les jetons d'accès et de renouvellement sont chiffrés au repos (AES-256-GCM).</li>
|
||||
<li>
|
||||
Ils sont conservés tant que le compte reste connecté à l'application, et supprimés
|
||||
immédiatement lorsque le compte en est retiré.
|
||||
</li>
|
||||
<li>Les médias sources sont supprimés automatiquement après leur publication et leur péremption.</li>
|
||||
<li>L'accès à l'application est restreint aux personnes autorisées et protégé par mot de passe.</li>
|
||||
</ul>
|
||||
|
||||
<h2>6. Partage avec des tiers</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>7. Retirer une autorisation</h2>
|
||||
<p>
|
||||
Le titulaire d'un compte connecté peut retirer son autorisation à tout moment :
|
||||
</p>
|
||||
<ul>
|
||||
<li>en demandant la déconnexion du compte depuis l'application, ce qui supprime les jetons ;</li>
|
||||
<li>
|
||||
ou directement dans TikTok, via <em>Profil › Paramètres et confidentialité ›
|
||||
Sécurité et autorisations › Gérer les autorisations des applications</em>.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>8. Vos droits</h2>
|
||||
<p>
|
||||
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)}.
|
||||
</p>
|
||||
|
||||
<h2>9. Modification de cette politique</h2>
|
||||
<p>
|
||||
Cette politique peut être mise à jour. La date de dernière mise à jour figure en tête de page.
|
||||
</p>
|
||||
|
||||
${renderFooter()}`;
|
||||
|
||||
res.type('html').send(renderPage('Politique de confidentialité', body));
|
||||
});
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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<any>(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<any>(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<number> => {
|
||||
try {
|
||||
const result = await GraphAPIClient.get<any>(
|
||||
`${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<void> {
|
||||
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.`);
|
||||
}
|
||||
}
|
||||
@@ -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<any>((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<void> {
|
||||
// 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<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<any>((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<any>((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<void> {
|
||||
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();
|
||||
@@ -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<string, Buffer>();
|
||||
|
||||
/**
|
||||
* 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<Buffer> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
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<string[]> {
|
||||
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<string[]> {
|
||||
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();
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
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<Buffer> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
// 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<string> {
|
||||
// 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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
// 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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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();
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<string> {
|
||||
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<FacebookLongLivedToken> {
|
||||
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<FacebookLongLivedToken> {
|
||||
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<FacebookPageTarget[]> {
|
||||
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<FacebookTokenInfo> {
|
||||
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<T>(
|
||||
endpoint: string,
|
||||
params: Record<string, string>,
|
||||
context: string
|
||||
): Promise<T> {
|
||||
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();
|
||||
@@ -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<Response> {
|
||||
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<ReelRenderResult> {
|
||||
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<PreparedReel> {
|
||||
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<void> {
|
||||
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<Uint8Array>),
|
||||
fs.createWriteStream(target),
|
||||
);
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
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<VoiceCatalog> {
|
||||
const headers: Record<string, string> = {};
|
||||
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<VoicePreview> {
|
||||
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();
|
||||
@@ -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<MusicTrack[]> {
|
||||
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<MusicTrack | null> {
|
||||
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<MusicTrack[]> {
|
||||
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<boolean> {
|
||||
try {
|
||||
const tracks = await this.getPopularTracks(1);
|
||||
return tracks.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const freeSoundService = new FreeSoundService();
|
||||
@@ -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<ProcessedImages> {
|
||||
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<string> {
|
||||
const uniqueFilename = `${randomUUID()}-${filename}`;
|
||||
const filePath = path.join(this.uploadsDir, uniqueFilename);
|
||||
await fs.writeFile(filePath, buffer);
|
||||
return `/uploads/${uniqueFilename}`;
|
||||
}
|
||||
|
||||
async deleteFile(filePath: string): Promise<void> {
|
||||
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<Buffer> {
|
||||
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();
|
||||
@@ -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<string, string> = {
|
||||
'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<MusicTrack[]> {
|
||||
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<MusicTrack | null> {
|
||||
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<MusicTrack[]> {
|
||||
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<boolean> {
|
||||
try {
|
||||
const tracks = await this.getPopularTracks(1);
|
||||
return tracks.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const jamendoService = new JamendoService();
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<Buffer> {
|
||||
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<void> {
|
||||
const filePath = path.join(UPLOADS_BASE, publicId);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
async uploadStoryImageWithText(buffer: Buffer, originalFileName: string): Promise<string> {
|
||||
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<void> {
|
||||
const filePath = path.join(UPLOADS_BASE, publicId);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const minioService = new LocalStorageService();
|
||||
@@ -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<GeneratedText[]> {
|
||||
// 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<any[]> {
|
||||
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();
|
||||
@@ -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<string | undefined> {
|
||||
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<string | undefined> {
|
||||
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<string | undefined> {
|
||||
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<QwenTarget | undefined> {
|
||||
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<string | undefined> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
registerReelJobHandler("video", runVideoReelJob);
|
||||
return startReelWorker();
|
||||
}
|
||||
@@ -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<Media> {
|
||||
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<PagePublishResult[]> {
|
||||
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<string | undefined> {
|
||||
// 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}` : ""}`;
|
||||
}
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
export type JobHandler = (ctx: JobContext) => Promise<unknown>;
|
||||
|
||||
const handlers = new Map<ReelJobKind, JobHandler>();
|
||||
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<ReelJob> {
|
||||
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<ReelJob | undefined> {
|
||||
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<number> {
|
||||
const [row] = await db
|
||||
.select({ count: sql<number>`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<void> {
|
||||
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<void> {
|
||||
for (;;) {
|
||||
const job = await claimNextJob();
|
||||
if (!job) return;
|
||||
await runJob(job);
|
||||
}
|
||||
}
|
||||
|
||||
async function claimNextJob(): Promise<ReelJob | undefined> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const patch: Partial<typeof posts.$inferInsert> = {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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`);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
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<string> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<string> | null = null;
|
||||
|
||||
/** Bundle des compositions, construit une fois par processus. */
|
||||
export function getRemotionBundle(): Promise<string> {
|
||||
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<string, unknown>;
|
||||
outputLocation: string;
|
||||
onProgress?: (ratio: number) => void;
|
||||
}): Promise<void> {
|
||||
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`,
|
||||
);
|
||||
}
|
||||
@@ -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<Buffer> {
|
||||
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<Buffer> {
|
||||
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 */ });
|
||||
}
|
||||
}
|
||||
@@ -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<string> {
|
||||
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();
|
||||
@@ -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<boolean> {
|
||||
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<string | null> {
|
||||
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 */ });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<boolean> {
|
||||
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<string> {
|
||||
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<TiktokTokenResponse> {
|
||||
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<TiktokTokenResponse> {
|
||||
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<string, string>): Promise<TiktokTokenResponse> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<TiktokCreatorInfo> {
|
||||
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<TiktokCreatorInfo> {
|
||||
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<string> {
|
||||
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<TiktokPublishStatus> {
|
||||
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<void> {
|
||||
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<any> {
|
||||
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();
|
||||
@@ -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<TokenCheckResult[]> {
|
||||
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<TokenCheckResult> {
|
||||
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<TokenCheckResult> {
|
||||
// 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<boolean> {
|
||||
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<void> {
|
||||
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<TokenCheckResult> {
|
||||
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<TokenCheckResult> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Vendored
-11
@@ -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[];
|
||||
}
|
||||
@@ -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.
|
||||
//!
|
||||
|
||||
@@ -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<User | undefined>;
|
||||
getUserByUsername(username: string): Promise<User | undefined>;
|
||||
getAllUsers(): Promise<User[]>;
|
||||
getDashboardStats(userId: string, options: DashboardStatsRange): Promise<DashboardStats>;
|
||||
createUser(user: InsertUser): Promise<User>;
|
||||
updateUser(id: string, user: Partial<InsertUser>): Promise<User>;
|
||||
deleteUser(id: string): Promise<void>;
|
||||
|
||||
// Social Pages
|
||||
getSocialPages(userId: string): Promise<SocialPage[]>;
|
||||
getAllSocialPages(): Promise<SocialPage[]>;
|
||||
getSocialPage(id: string): Promise<SocialPage | undefined>;
|
||||
createSocialPage(page: InsertSocialPage): Promise<SocialPage>;
|
||||
updateSocialPage(id: string, page: Partial<SocialPage>): Promise<SocialPage>;
|
||||
deleteSocialPage(id: string): Promise<void>;
|
||||
|
||||
// Media
|
||||
getMedia(userId: string, options?: { limit?: number; offset?: number }): Promise<Media[]>;
|
||||
getMediaByIds(ids: string[]): Promise<Media[]>;
|
||||
getMediaCount(userId: string): Promise<number>;
|
||||
getMediaById(id: string): Promise<Media | undefined>;
|
||||
createMedia(media: InsertMedia): Promise<Media>;
|
||||
deleteMedia(id: string): Promise<void>;
|
||||
getPostMedia(postId: string): Promise<PostMedia[]>;
|
||||
|
||||
// Posts
|
||||
getPosts(userId: string): Promise<Post[]>;
|
||||
getPost(id: string): Promise<Post | undefined>;
|
||||
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<Post>;
|
||||
updatePost(id: string, post: Partial<InsertPost>): Promise<Post>;
|
||||
updatePostGenerationStatus(id: string, status: string, progress: number, error?: string): Promise<Post>;
|
||||
deletePost(id: string): Promise<void>;
|
||||
updatePostMedia(postId: string, mediaIds: string[]): Promise<void>;
|
||||
|
||||
// Scheduled Posts
|
||||
getScheduledPosts(userId: string, startDate?: Date, endDate?: Date): Promise<ScheduledPost[]>;
|
||||
getScheduledPostsByPages(pageIds: string[], startDate?: Date, endDate?: Date): Promise<ScheduledPost[]>;
|
||||
getScheduledPost(id: string): Promise<ScheduledPost | undefined>;
|
||||
getScheduledPostsByPost(postId: string): Promise<ScheduledPost[]>;
|
||||
createScheduledPost(scheduledPost: InsertScheduledPost): Promise<ScheduledPost>;
|
||||
updateScheduledPost(id: string, scheduledPost: Partial<ScheduledPost>): Promise<ScheduledPost>;
|
||||
deleteScheduledPost(id: string): Promise<void>;
|
||||
getPendingScheduledPosts(): Promise<ScheduledPost[]>;
|
||||
getScheduledPostsAwaitingPublishStatus(): Promise<ScheduledPost[]>;
|
||||
|
||||
// AI Generations
|
||||
getAiGenerations(userId: string): Promise<AiGeneration[]>;
|
||||
createAiGeneration(generation: InsertAiGeneration): Promise<AiGeneration>;
|
||||
|
||||
// TikTok Config
|
||||
getTiktokConfig(): Promise<TiktokConfig | undefined>;
|
||||
upsertTiktokConfig(config: Partial<InsertTiktokConfig>): Promise<TiktokConfig>;
|
||||
getFacebookConfig(): Promise<FacebookConfig | undefined>;
|
||||
upsertFacebookConfig(config: Partial<InsertFacebookConfig>): Promise<FacebookConfig>;
|
||||
|
||||
// Cloudinary Config
|
||||
getCloudinaryConfig(): Promise<CloudinaryConfig | undefined>;
|
||||
createCloudinaryConfig(config: InsertCloudinaryConfig): Promise<CloudinaryConfig>;
|
||||
updateCloudinaryConfig(config: Partial<InsertCloudinaryConfig>): Promise<CloudinaryConfig>;
|
||||
|
||||
// OpenRouter Config
|
||||
getOpenrouterConfig(userId: string): Promise<OpenrouterConfig | undefined>;
|
||||
getAnyOpenrouterConfig(): Promise<OpenrouterConfig | undefined>;
|
||||
createOpenrouterConfig(config: InsertOpenrouterConfig): Promise<OpenrouterConfig>;
|
||||
updateOpenrouterConfig(userId: string, config: Partial<InsertOpenrouterConfig>): Promise<OpenrouterConfig>;
|
||||
|
||||
// User Page Permissions
|
||||
getUserPagePermissions(userId: string): Promise<UserPagePermission[]>;
|
||||
getPagePermissions(pageId: string): Promise<UserPagePermission[]>;
|
||||
createPagePermission(permission: InsertUserPagePermission): Promise<UserPagePermission>;
|
||||
deletePagePermission(userId: string, pageId: string): Promise<void>;
|
||||
deleteAllUserPagePermissions(userId: string): Promise<void>;
|
||||
getUserAccessiblePages(userId: string): Promise<SocialPage[]>;
|
||||
|
||||
// Music Favorites
|
||||
getMusicFavorites(userId: string): Promise<MusicFavorite[]>;
|
||||
addMusicFavorite(favorite: InsertMusicFavorite): Promise<MusicFavorite>;
|
||||
removeMusicFavorite(userId: string, trackId: string): Promise<void>;
|
||||
isMusicFavorite(userId: string, trackId: string): Promise<boolean>;
|
||||
|
||||
// Audio Tracks
|
||||
getAudioTracks(): Promise<AudioTrack[]>;
|
||||
// Audio Tracks
|
||||
getAudioTrack(id: string): Promise<AudioTrack | undefined>;
|
||||
createAudioTrack(track: InsertAudioTrack): Promise<AudioTrack>;
|
||||
deleteAudioTrack(id: string): Promise<void>;
|
||||
|
||||
// App Config
|
||||
getAppConfig(): Promise<AppConfig | undefined>;
|
||||
upsertAppConfig(data: Partial<Pick<AppConfig, 'externalApiKey' | 'geminiApiKey' | 'qwenTtsUrl' | 'qwenTtsApiKey'>>): Promise<AppConfig>;
|
||||
}
|
||||
|
||||
export class DatabaseStorage implements IStorage {
|
||||
// Users
|
||||
async getUser(id: string): Promise<User | undefined> {
|
||||
const [user] = await db.select().from(users).where(eq(users.id, id));
|
||||
return user || undefined;
|
||||
}
|
||||
|
||||
async getUserByUsername(username: string): Promise<User | undefined> {
|
||||
const [user] = await db.select().from(users).where(eq(users.username, username));
|
||||
return user || undefined;
|
||||
}
|
||||
|
||||
async getAllUsers(): Promise<User[]> {
|
||||
return await db.select().from(users);
|
||||
}
|
||||
|
||||
async createUser(insertUser: InsertUser): Promise<User> {
|
||||
const [user] = await db.insert(users).values(insertUser).returning();
|
||||
return user;
|
||||
}
|
||||
|
||||
async updateUser(id: string, updateData: Partial<InsertUser>): Promise<User> {
|
||||
const [updated] = await db.update(users).set(updateData).where(eq(users.id, id)).returning();
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deleteUser(id: string): Promise<void> {
|
||||
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<DashboardStats> {
|
||||
const [postCounts, generationCounts, pageCount, mediaCount] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
scheduled: sql<string>`count(*) filter (where ${posts.status} = 'scheduled')`,
|
||||
scheduledLastMonth: sql<string>`count(*) filter (where ${posts.status} = 'scheduled' and ${posts.createdAt} < ${range.lastMonth})`,
|
||||
})
|
||||
.from(posts)
|
||||
.where(eq(posts.userId, userId)),
|
||||
db
|
||||
.select({
|
||||
total: sql<string>`count(*)`,
|
||||
yesterday: sql<string>`count(*) filter (where ${aiGenerations.createdAt} >= ${range.yesterdayStart} and ${aiGenerations.createdAt} < ${range.yesterdayEnd})`,
|
||||
})
|
||||
.from(aiGenerations)
|
||||
.where(eq(aiGenerations.userId, userId)),
|
||||
db.select({ total: sql<string>`count(*)` }).from(socialPages).where(eq(socialPages.userId, userId)),
|
||||
db.select({ total: sql<string>`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<SocialPage[]> {
|
||||
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<SocialPage[]> {
|
||||
const pages = await db.select().from(socialPages);
|
||||
return pages.map(page => decryptPageTokens(page));
|
||||
}
|
||||
|
||||
async getSocialPage(id: string): Promise<SocialPage | undefined> {
|
||||
const [page] = await db.select().from(socialPages).where(eq(socialPages.id, id));
|
||||
return page ? decryptPageTokens(page) : undefined;
|
||||
}
|
||||
|
||||
async createSocialPage(page: InsertSocialPage): Promise<SocialPage> {
|
||||
// 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<SocialPage>): Promise<SocialPage> {
|
||||
// Chiffrer les tokens présents dans la mise à jour
|
||||
const updateData: Partial<SocialPage> = { ...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<void> {
|
||||
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<Media[]> {
|
||||
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<Media[]> {
|
||||
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<number> {
|
||||
const [row] = await db
|
||||
.select({ total: sql<string>`count(*)` })
|
||||
.from(media)
|
||||
.where(eq(media.userId, userId));
|
||||
return Number(row?.total ?? 0);
|
||||
}
|
||||
|
||||
async getMediaById(id: string): Promise<Media | undefined> {
|
||||
const [mediaItem] = await db.select().from(media).where(eq(media.id, id));
|
||||
return mediaItem || undefined;
|
||||
}
|
||||
|
||||
async createMedia(mediaItem: InsertMedia): Promise<Media> {
|
||||
const [newMedia] = await db.insert(media).values(mediaItem).returning();
|
||||
return newMedia;
|
||||
}
|
||||
|
||||
async deleteMedia(id: string): Promise<void> {
|
||||
await db.delete(media).where(eq(media.id, id));
|
||||
}
|
||||
|
||||
async getPostMedia(postId: string): Promise<PostMedia[]> {
|
||||
return await db.select().from(postMedia).where(eq(postMedia.postId, postId)).orderBy(asc(postMedia.displayOrder));
|
||||
}
|
||||
|
||||
// Posts
|
||||
async getPosts(userId: string): Promise<Post[]> {
|
||||
return await db.select().from(posts).where(eq(posts.userId, userId)).orderBy(desc(posts.createdAt));
|
||||
}
|
||||
|
||||
async getPost(id: string): Promise<Post | undefined> {
|
||||
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<Post> {
|
||||
const [newPost] = await db.insert(posts).values(post).returning();
|
||||
return newPost;
|
||||
}
|
||||
|
||||
async updatePost(id: string, post: Partial<InsertPost>): Promise<Post> {
|
||||
const [updated] = await db.update(posts).set({ ...post, updatedAt: new Date() }).where(eq(posts.id, id)).returning();
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deletePost(id: string): Promise<void> {
|
||||
await db.delete(posts).where(eq(posts.id, id));
|
||||
}
|
||||
|
||||
async updatePostGenerationStatus(
|
||||
id: string,
|
||||
status: string,
|
||||
progress: number,
|
||||
error?: string
|
||||
): Promise<Post> {
|
||||
const updateData: Record<string, unknown> = {
|
||||
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<void> {
|
||||
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<any[]> {
|
||||
return this.queryScheduledPosts(eq(posts.userId, userId), startDate, endDate);
|
||||
}
|
||||
|
||||
async getScheduledPostsByPages(pageIds: string[], startDate?: Date, endDate?: Date): Promise<any[]> {
|
||||
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<any[]> {
|
||||
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<ScheduledPost | undefined> {
|
||||
const [scheduledPost] = await db.select().from(scheduledPosts).where(eq(scheduledPosts.id, id));
|
||||
return scheduledPost || undefined;
|
||||
}
|
||||
|
||||
async getScheduledPostsByPost(postId: string): Promise<ScheduledPost[]> {
|
||||
return await db.select().from(scheduledPosts).where(eq(scheduledPosts.postId, postId));
|
||||
}
|
||||
|
||||
async createScheduledPost(scheduledPost: InsertScheduledPost): Promise<ScheduledPost> {
|
||||
const [newScheduledPost] = await db.insert(scheduledPosts).values(scheduledPost).returning();
|
||||
return newScheduledPost;
|
||||
}
|
||||
|
||||
async updateScheduledPost(id: string, scheduledPost: Partial<ScheduledPost>): Promise<ScheduledPost> {
|
||||
const [updated] = await db.update(scheduledPosts).set(scheduledPost).where(eq(scheduledPosts.id, id)).returning();
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deleteScheduledPost(id: string): Promise<void> {
|
||||
await db.delete(scheduledPosts).where(eq(scheduledPosts.id, id));
|
||||
}
|
||||
|
||||
async getPendingScheduledPosts(): Promise<ScheduledPost[]> {
|
||||
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<ScheduledPost[]> {
|
||||
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<AiGeneration[]> {
|
||||
return await db.select().from(aiGenerations).where(eq(aiGenerations.userId, userId)).orderBy(desc(aiGenerations.createdAt));
|
||||
}
|
||||
|
||||
async createAiGeneration(generation: InsertAiGeneration): Promise<AiGeneration> {
|
||||
const [newGeneration] = await db.insert(aiGenerations).values(generation).returning();
|
||||
return newGeneration;
|
||||
}
|
||||
|
||||
// Cloudinary Config
|
||||
async getCloudinaryConfig(): Promise<CloudinaryConfig | undefined> {
|
||||
const [config] = await db.select().from(cloudinaryConfig).limit(1);
|
||||
return config || undefined;
|
||||
}
|
||||
|
||||
async createCloudinaryConfig(config: InsertCloudinaryConfig): Promise<CloudinaryConfig> {
|
||||
const [newConfig] = await db.insert(cloudinaryConfig).values(config).returning();
|
||||
return newConfig;
|
||||
}
|
||||
|
||||
async updateCloudinaryConfig(config: Partial<InsertCloudinaryConfig>): Promise<CloudinaryConfig> {
|
||||
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<OpenrouterConfig | undefined> {
|
||||
const [config] = await db.select().from(openrouterConfig).where(eq(openrouterConfig.userId, userId));
|
||||
return config || undefined;
|
||||
}
|
||||
|
||||
async getAnyOpenrouterConfig(): Promise<OpenrouterConfig | undefined> {
|
||||
const [config] = await db.select().from(openrouterConfig).orderBy(desc(openrouterConfig.updatedAt)).limit(1);
|
||||
return config || undefined;
|
||||
}
|
||||
|
||||
async createOpenrouterConfig(config: InsertOpenrouterConfig): Promise<OpenrouterConfig> {
|
||||
const [newConfig] = await db.insert(openrouterConfig).values(config).returning();
|
||||
return newConfig;
|
||||
}
|
||||
|
||||
async updateOpenrouterConfig(userId: string, config: Partial<InsertOpenrouterConfig>): Promise<OpenrouterConfig> {
|
||||
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<UserPagePermission[]> {
|
||||
return await db.select().from(userPagePermissions).where(eq(userPagePermissions.userId, userId));
|
||||
}
|
||||
|
||||
async getPagePermissions(pageId: string): Promise<UserPagePermission[]> {
|
||||
return await db.select().from(userPagePermissions).where(eq(userPagePermissions.pageId, pageId));
|
||||
}
|
||||
|
||||
async createPagePermission(permission: InsertUserPagePermission): Promise<UserPagePermission> {
|
||||
const [newPermission] = await db.insert(userPagePermissions).values(permission).returning();
|
||||
return newPermission;
|
||||
}
|
||||
|
||||
async deletePagePermission(userId: string, pageId: string): Promise<void> {
|
||||
await db.delete(userPagePermissions).where(
|
||||
and(
|
||||
eq(userPagePermissions.userId, userId),
|
||||
eq(userPagePermissions.pageId, pageId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async deleteAllUserPagePermissions(userId: string): Promise<void> {
|
||||
await db.delete(userPagePermissions).where(eq(userPagePermissions.userId, userId));
|
||||
}
|
||||
|
||||
async getUserAccessiblePages(userId: string): Promise<SocialPage[]> {
|
||||
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<MusicFavorite[]> {
|
||||
return db.select().from(musicFavorites)
|
||||
.where(eq(musicFavorites.userId, userId))
|
||||
.orderBy(desc(musicFavorites.createdAt));
|
||||
}
|
||||
|
||||
async addMusicFavorite(favorite: InsertMusicFavorite): Promise<MusicFavorite> {
|
||||
const [created] = await db.insert(musicFavorites).values(favorite).returning();
|
||||
return created;
|
||||
}
|
||||
|
||||
async removeMusicFavorite(userId: string, trackId: string): Promise<void> {
|
||||
await db.delete(musicFavorites).where(
|
||||
and(
|
||||
eq(musicFavorites.userId, userId),
|
||||
eq(musicFavorites.trackId, trackId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async isMusicFavorite(userId: string, trackId: string): Promise<boolean> {
|
||||
const [fav] = await db.select().from(musicFavorites).where(
|
||||
and(
|
||||
eq(musicFavorites.userId, userId),
|
||||
eq(musicFavorites.trackId, trackId)
|
||||
)
|
||||
);
|
||||
return !!fav;
|
||||
}
|
||||
|
||||
// Audio Tracks
|
||||
async getAudioTracks(): Promise<AudioTrack[]> {
|
||||
return db.select().from(audioTracks).orderBy(desc(audioTracks.createdAt));
|
||||
}
|
||||
|
||||
async getAudioTrack(id: string): Promise<AudioTrack | undefined> {
|
||||
const [track] = await db.select().from(audioTracks).where(eq(audioTracks.id, id));
|
||||
return track || undefined;
|
||||
}
|
||||
|
||||
async createAudioTrack(track: InsertAudioTrack): Promise<AudioTrack> {
|
||||
const [newTrack] = await db.insert(audioTracks).values(track).returning();
|
||||
return newTrack;
|
||||
}
|
||||
|
||||
async deleteAudioTrack(id: string): Promise<void> {
|
||||
await db.delete(audioTracks).where(eq(audioTracks.id, id));
|
||||
}
|
||||
|
||||
// App Config
|
||||
async getAppConfig(): Promise<AppConfig | undefined> {
|
||||
const [config] = await db.select().from(appConfig).limit(1);
|
||||
return config || undefined;
|
||||
}
|
||||
|
||||
// Facebook Config (application développeur, globale à l'instance)
|
||||
async getFacebookConfig(): Promise<FacebookConfig | undefined> {
|
||||
const [config] = await db.select().from(facebookConfig).limit(1);
|
||||
if (!config) return undefined;
|
||||
return { ...config, appSecret: decrypt(config.appSecret) };
|
||||
}
|
||||
|
||||
async upsertFacebookConfig(data: Partial<InsertFacebookConfig>): Promise<FacebookConfig> {
|
||||
const values: Partial<FacebookConfig> = { ...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<TiktokConfig | undefined> {
|
||||
const [config] = await db.select().from(tiktokConfig).limit(1);
|
||||
if (!config) return undefined;
|
||||
return { ...config, clientSecret: decrypt(config.clientSecret) };
|
||||
}
|
||||
|
||||
async upsertTiktokConfig(data: Partial<InsertTiktokConfig>): Promise<TiktokConfig> {
|
||||
const values: Partial<TiktokConfig> = { ...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<Pick<AppConfig, 'externalApiKey' | 'geminiApiKey' | 'qwenTtsUrl' | 'qwenTtsApiKey'>>): Promise<AppConfig> {
|
||||
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();
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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<string, string | number | boolean>;
|
||||
accessToken?: string;
|
||||
}
|
||||
|
||||
const GRAPH_API_VERSION = 'v19.0';
|
||||
const BASE_URL = `https://graph.facebook.com/${GRAPH_API_VERSION}`;
|
||||
|
||||
export class GraphAPIClient {
|
||||
|
||||
static async get<T>(endpoint: string, config: GraphRequestConfig): Promise<T> {
|
||||
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<T>(response);
|
||||
}
|
||||
|
||||
static async post<T>(endpoint: string, body: any, config: GraphRequestConfig): Promise<T> {
|
||||
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<T>(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<T>(response: Response): Promise<T> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"));
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user