Compare commits

...
Author SHA1 Message Date
Claude fcbc6b706f fix(docker): construire les images au lieu de tenter de les télécharger
Portainer échouait en « Pull and redeploy » : `pull access denied for
sociaflow-app, repository does not exist`. Les services `app` et
`ffmpeg-api` n'ayant que `build:`, Compose leur dérivait un nom d'image
depuis le nom de la stack et Docker allait la chercher sur Docker Hub,
où elle n'existe évidemment pas.

- `pull_policy: build` : l'image est reconstruite, jamais téléchargée
- `image:` explicite, pour que le nom ne dépende plus du nom donné à la
  stack dans Portainer
- DOCKER.md : procédure Portainer et diagnostic de cette erreur

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Db2EazEcvnqgTTeg8oGTif
2026-09-11 18:08:48 +00:00
Claude 3af7f9418b fix(oauth): déduire l'URI de redirection du domaine public réel
Sans APP_URL, Facebook et TikTok recevaient
http://localhost:5555/api/{facebook,tiktok}/callback : une URI que les
fournisseurs refusent, donc aucune page connectable derrière un reverse
proxy. Le repli localhost n'est plus utilisé que hors requête HTTP ; en
service, la base publique est reconstruite depuis X-Forwarded-Proto /
X-Forwarded-Host (trust proxy est déjà activé).

- `resolvePublicBaseUrl(req)` centralise la résolution (APP_URL prioritaire)
- l'URI de redirection est déduite de la requête au démarrage du flux, à
  l'échange du code et dans l'affichage des paramètres : les trois restent
  identiques, comme l'exige OAuth
- docker-compose ne force plus APP_URL à localhost, qui rendait l'auto
  détection inopérante
- cookie de session en `secure: 'auto'` à défaut d'APP_URL, pour ne pas
  émettre un cookie non sécurisé derrière un proxy HTTPS

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Db2EazEcvnqgTTeg8oGTif
2026-09-11 18:01:28 +00:00
Claude 94e961d0f1 perf(api): alléger les requêtes du calendrier, des stats et de la médiathèque
Trois défauts se cumulaient : des réponses bien plus larges que nécessaire,
aucun index en base, et des boucles de requêtes là où une seule suffisait.

Réponses
- /api/scheduled-posts renvoyait la ligne `social_pages` entière, jetons
  d'accès Facebook et TikTok compris, jusque dans le navigateur. Seules les
  colonnes réellement affichées sont désormais sélectionnées.
- Le filtre de dates est poussé en SQL. Il était appliqué en JavaScript après
  avoir chargé tout l'historique — que le client ne bornait jamais. Le
  calendrier demande maintenant les six semaines affichées, le sélecteur de
  date les créneaux à venir, le bloc « publications récentes » ses 90 jours.
- /api/media accepte `limit` et `offset` (sans eux, réponse inchangée : des
  écrans doivent retrouver un média par son identifiant). La médiathèque
  pagine côté serveur au lieu de tout charger pour afficher cinq vignettes, et
  l'écran de création ne demande que les douze médias qu'il propose. Le total
  voyage dans l'en-tête X-Total-Count pour garder le compteur juste.

Index
- server/migrate.ts est le seul script exécuté au démarrage et n'en créait
  aucun ; ceux de migration-docker.sql ne servaient à rien, ce fichier n'étant
  monté nulle part. Postgres n'indexant pas les clés étrangères, le calendrier,
  l'historique et le scheduler parcouraient les tables entières. S'y ajoutent
  deux index partiels pour les boucles de fond : publications en attente et
  suivi asynchrone TikTok.

Requêtes
- /api/stats lisait quatre tables entières pour en compter les lignes : c'est
  désormais quatre COUNT filtrés.
- Les vues d'administration listaient les utilisateurs puis interrogeaient les
  pages de chacun ; une seule requête les remplace.
- getPostWithMedia et l'API externe chargeaient les médias un par un.
- /api/reels/ongoing chargeait tous les posts de l'utilisateur pour n'en garder
  que ceux en cours de génération, toutes les trois secondes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qns6Gcpd95bCHWUxySh4Ew
2026-09-10 06:39:16 +00:00
Claude 25bf0981bd perf(client): rafraîchir les données sans recharger la page
`staleTime: Infinity` combiné à `refetchOnWindowFocus: false` empêchait toute
revalidation : une donnée chargée à la première visite restait affichée pour
toute la session. Or le serveur agit de son côté — le scheduler publie chaque
minute, les jetons se renouvellent en tâche de fond — sans que le navigateur en
soit averti. D'où la nécessité de recharger la page pour voir quoi que ce soit.

Les compteurs du tableau de bord étaient le cas le plus visible : /api/stats et
/api/posts n'étaient invalidés par aucune mutation de l'application, et ne
pouvaient donc plus bouger du tout.

- Péremption courte (30 s) et rafraîchissement au retour sur l'onglet ou sur
  reconnexion, en remplacement du cache éternel.
- /api/stats et /api/posts, qui dérivent de tout le reste, sont invalidés depuis
  le MutationCache après chaque mutation réussie : aucune mutation ne peut
  raisonnablement se souvenir de le faire, et aucune ne le faisait. Les
  mutations d'authentification en sont exclues, la session étant déjà détruite
  quand la déconnexion aboutit.
- Les trois sondages à 3 s (historique desktop et mobile, reels en cours)
  devenaient inutiles hors traitement en cours. Ils ne s'emballent plus que
  lorsqu'il y a effectivement quelque chose à suivre — une publication dont
  l'heure est passée, une génération en route — et retombent à 30 s sinon. La
  page Historique rechargeait tout l'historique 20 fois par minute.
- La liste des modèles OpenRouter, servie par un appel sortant, garde une
  péremption d'une heure pour ne pas être redemandée à chaque retour d'onglet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qns6Gcpd95bCHWUxySh4Ew
2026-09-10 06:22:21 +00:00
Claude d7f002a64f feat(facebook): renouvellement automatique des jetons de page
Un jeton de page collé à la main meurt au bout de 60 jours — ou du jour au
lendemain si le mot de passe change, si le compte perd son rôle d'admin ou si
Meta révoque l'autorisation — et on ne l'apprenait qu'à la première publication
en échec : le contrôle quotidien existant se contentait de marquer la page
« expired ».

On reprend la chaîne recommandée par Meta, comme dans PleinR : autorisation
OAuth, échange du code contre un jeton utilisateur longue durée (~60 j), puis
dérivation des jetons de page — qui n'expirent pas. Le jeton utilisateur est
conservé chiffré sur chaque page : c'est lui qui permet au TokenManager de
régénérer un jeton révoqué sans intervention humaine, et de se prolonger
lui-même avant échéance.

- facebook_config : App ID / App Secret de l'application, saisis dans les
  paramètres (chiffrés en base) ou par variables d'environnement.
- /api/facebook : configuration, connexion OAuth, callback qui crée ou met à
  jour les pages, contrôle d'une page à la demande, contrôle global.
- TokenManager : /debug_token pour connaître l'état réel du jeton, puis
  renouvellement automatique 15 jours avant l'échéance ou dès révocation ;
  repli sur le contrôle historique tant que l'application n'est pas configurée
  ou si Facebook est injoignable, pour ne pas passer l'UI au rouge sur une
  panne réseau. Le cron passe à deux fois par jour.
- Les comptes Instagram professionnels rattachés à une page reçoivent le même
  traitement, puisqu'ils publient avec le jeton de cette page.
- L'UI montre l'état constaté (jeton valide, bientôt expiré, révoqué), l'erreur
  à corriger et un bouton de contrôle immédiat ; un bandeau signale les pages à
  reconnecter.
- /api/pages ne renvoie plus aucun jeton au navigateur, seulement un indicateur
  de renouvellement automatique.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qns6Gcpd95bCHWUxySh4Ew
2026-09-10 06:11:43 +00:00
LogiFlow 55e502aa8d Merge pull request #110 from R0m1k3/claude/tiktok-store-account-management-m558za
Claude/tiktok store account management m558za
2026-08-12 10:02:11 +02:00
Claude ac8b494f24 fix: vignettes vidéo persistantes et remise à zéro des erreurs de types
Deux corrections demandées ensemble.

1. Vignettes vidéo

Le planificateur supprime la vidéo locale une fois publiée, alors que la
ligne `media` survit pour l'historique. L'interface tentait donc de lire
un fichier disparu pour en extraire une image : d'où les avertissements
« Video thumbnail generation failed » et les tuiles vides.

Une vignette JPEG est désormais extraite à la création du reel, dans les
deux flux de génération, et stockée à part : elle reste disponible quand
la vidéo ne l'est plus. La purge quotidienne la nettoie en même temps que
la ligne qu'elle illustre. L'extraction est tolérante à l'échec — une
vignette manquante dégrade l'affichage mais ne fait jamais échouer une
publication — et le composant retombe sur son icône de remplacement pour
les médias créés avant ce changement. La fonction d'extraction, jusque-là
privée à la route Remotion, devient un service partagé.

2. Erreurs de compilation préexistantes (27 → 0)

- remotion-video.tsx (bureau et mobile) : les composants Select étaient
  utilisés sans être importés, ce qui faisait planter le sélecteur de voix
  à l'exécution.
- external.ts : fileSize et aiGenerated étaient transmis dans un type que
  le schéma ne déclare pas, et ne fonctionnaient que par conversion
  implicite de PostgreSQL.
- reels.ts : l'interface MusicTrack, référencée mais jamais déclarée dans
  ce module.
- remotion.ts : chromiumOptions.args retiré. Remotion ne lit jamais cette
  clé, les drapeaux Chrome qu'elle contenait n'atteignaient donc pas le
  navigateur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uUPZ1GNpEkYB1dpXHYPek
2026-08-12 08:00:48 +00:00
Claude c69fe4e462 fix(ia): afficher la cause réelle d'un échec de génération de texte
La route /api/ai/generate connaissait la cause exacte de l'échec
(configuration absente, clé invalide, crédits épuisés, modèle inconnu)
mais la remplaçait par un « Failed to generate text » en 500, et
l'interface affichait de son côté un message figé. Sans accès aux logs
du serveur, l'utilisateur n'avait donc aucun moyen de savoir quoi
corriger.

Le motif renvoyé par OpenRouter est désormais extrait, tronqué et
propagé jusqu'au bandeau d'erreur, avec un code HTTP qui distingue un
problème de configuration (400) d'un refus du service tiers (502).
La clé API n'apparaît à aucun moment dans ces messages.

Même traitement pour /api/reels/generate-text, qui souffrait du même
masquage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uUPZ1GNpEkYB1dpXHYPek
2026-08-12 07:49:23 +00:00
Claude 0570088722 feat(tiktok): servir le fichier de vérification du domaine
TikTok exige de prouver la propriété du domaine ("URL properties") avant
d'autoriser la Content Posting API, en servant un fichier à la racine du
site.

Le dossier public-root/ est exposé publiquement à la racine du domaine et
monté avant le catch-all du frontend. Y déposer un fichier suffit : pas de
modification de code ni de reconstruction du client, ce qui couvre aussi
les prochaines vérifications (autre domaine, Google, Meta).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uUPZ1GNpEkYB1dpXHYPek
2026-08-11 16:27:26 +00:00
Claude 3e00a3ca67 feat(legal): préremplir les mentions légales de la société exploitante
Renseigne l'identité de FROUARD DISTRIBUTION (enseigne LA FOIR'FOUILLE)
comme valeur par défaut des pages /terms et /privacy : forme juridique,
capital, siège et numéro RCS. Les relecteurs TikTok ouvrent ces pages
pendant l'audit et refusent les mentions incomplètes.

Chaque champ reste surchargeable par variable d'environnement si l'outil
venait à être exploité par une autre société du groupe.

L'adresse de contact reste à fournir via LEGAL_CONTACT_EMAIL : elle ne
figure pas au registre, et le RGPD impose un point de contact pour
l'exercice des droits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uUPZ1GNpEkYB1dpXHYPek
2026-08-11 16:19:16 +00:00
Claude d0f63c68e4 feat(legal): servir les pages /terms et /privacy exigées par TikTok
L'audit de l'application développeur TikTok impose une URL publique pour
les conditions d'utilisation et pour la politique de confidentialité, et
les relecteurs les ouvrent réellement.

Les pages sont rendues en HTML côté serveur, sans authentification, pour
rester lisibles par un robot qui n'exécute pas le JavaScript du client.
La politique décrit précisément ce que l'intégration fait : données reçues
de TikTok (open_id, nom d'affichage, avatar, jetons), finalité de
publication, chiffrement des jetons au repos, suppression à la
déconnexion et marche à suivre pour retirer l'autorisation.

Les mentions de l'exploitant se configurent via LEGAL_COMPANY_NAME,
LEGAL_COMPANY_ADDRESS et LEGAL_CONTACT_EMAIL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uUPZ1GNpEkYB1dpXHYPek
2026-08-11 16:14:13 +00:00
Claude b95cd23ebc feat(tiktok): publier les reels sur les comptes TikTok des magasins
Ajoute la gestion multi-comptes TikTok et permet d'envoyer la même vidéo
sur plusieurs pages Facebook ET plusieurs comptes TikTok en une seule
publication.

Un compte TikTok vit dans social_pages comme une page Facebook : il hérite
donc des permissions par utilisateur, de la planification et du calendrier.

Trois particularités de l'API TikTok structurent l'implémentation :
- pas de jeton saisi à la main : chaque compte passe par OAuth, et
  l'access token (24h) est renouvelé via le refresh token (1 an) avant
  chaque publication ;
- la publication est asynchrone : l'upload rend un publish_id, et un
  poller récupère l'identifiant définitif du post ;
- creator_info doit être interrogé avant chaque envoi pour ne demander
  qu'un niveau de confidentialité réellement autorisé sur le compte.

Serveur :
- service TikTok (OAuth, creator_info, upload FILE_UPLOAD par chunks,
  suivi de statut) et routes de connexion/configuration
- schéma : platform 'tiktok', refresh token et scopes sur social_pages,
  publish_id/publish_status sur scheduled_posts, table tiktok_config
- routage par plateforme dans les deux flux de reels et le planificateur
- cron de suivi des publications, token manager et analytics adaptés

Client :
- connexion et reconnexion des comptes TikTok depuis « Pages gérées »
- sélection combinée pages Facebook / comptes TikTok à la publication
- configuration de l'application TikTok dans les paramètres (admin)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uUPZ1GNpEkYB1dpXHYPek
2026-08-11 14:29:38 +00:00
51 changed files with 4217 additions and 571 deletions

No files matched your search

+62 -1
View File
@@ -11,6 +11,11 @@ DATABASE_URL=postgresql://socialflow:changeme@postgres:4523/socialflow
# Configuration de l'application # Configuration de l'application
PORT=5555 PORT=5555
NODE_ENV=production NODE_ENV=production
# URL publique de l'application, telle que vue par le navigateur.
# En production, mettez le vrai domaine (ex. https://socialflow.exemple.fr) :
# les URI de redirection OAuth (Facebook/TikTok) en découlent.
# Laissée vide derrière un reverse proxy, elle est déduite des en-têtes
# X-Forwarded-Proto / X-Forwarded-Host.
APP_URL=http://localhost:5555 APP_URL=http://localhost:5555
# Clé secrète pour les sessions (CHANGEZ CETTE VALEUR) # Clé secrète pour les sessions (CHANGEZ CETTE VALEUR)
@@ -30,14 +35,70 @@ OPENROUTER_API_KEY=your-openrouter-api-key-here
MINIO_ENDPOINT=http://minio:9000 MINIO_ENDPOINT=http://minio:9000
MINIO_PUBLIC_URL=https://media.example.com MINIO_PUBLIC_URL=https://media.example.com
# Optionnel: Configuration Facebook API # ==========================================
# FACEBOOK (connexion des pages et renouvellement des jetons)
# ==========================================
# Identifiants de votre application sur https://developers.facebook.com
# Ils peuvent aussi être saisis dans l'application (Paramètres → Facebook),
# ce qui est prioritaire sur ces variables.
# Sans eux, les jetons de page doivent être collés et remplacés à la main.
# FACEBOOK_APP_ID= # FACEBOOK_APP_ID=
# FACEBOOK_APP_SECRET= # FACEBOOK_APP_SECRET=
#
# URI de redirection OAuth à déclarer chez Facebook.
# Par défaut : ${APP_URL}/api/facebook/callback, ou le domaine vu par le
# reverse proxy quand APP_URL n'est pas défini.
# FACEBOOK_REDIRECT_URI=
#
# Permissions demandées à l'autorisation. Défaut : pages_show_list,
# pages_manage_posts, pages_read_engagement, pages_manage_engagement,
# read_insights, business_management, instagram_basic, instagram_content_publish
# FACEBOOK_SCOPES=
#
# Version de la Graph API utilisée pour l'OAuth (défaut : v19.0).
# FACEBOOK_GRAPH_VERSION=
# Optionnel: Configuration Instagram API # Optionnel: Configuration Instagram API
# INSTAGRAM_APP_ID= # INSTAGRAM_APP_ID=
# INSTAGRAM_APP_SECRET= # INSTAGRAM_APP_SECRET=
# ==========================================
# TIKTOK (publication des reels)
# ==========================================
# Identifiants de votre application sur https://developers.tiktok.com
# Ils peuvent aussi être saisis dans l'application (Paramètres → TikTok),
# ce qui est prioritaire sur ces variables.
# TIKTOK_CLIENT_KEY=
# TIKTOK_CLIENT_SECRET=
#
# URL de redirection OAuth à déclarer chez TikTok.
# Par défaut : ${APP_URL}/api/tiktok/callback, ou le domaine vu par le
# reverse proxy quand APP_URL n'est pas défini.
# TIKTOK_REDIRECT_URI=
#
# Scopes demandés à l'autorisation (défaut : user.info.basic,video.publish).
# "video.publish" nécessite que l'application ait passé l'audit TikTok ;
# sans audit, les vidéos publiées restent privées.
# TIKTOK_SCOPES=
# ==========================================
# MENTIONS LÉGALES
# ==========================================
# Affichées sur les pages publiques /terms et /privacy, dont TikTok exige
# les URLs pour l'audit de l'application développeur.
#
# L'identité de la société exploitante est déjà la valeur par défaut : seule
# l'adresse de contact doit obligatoirement être renseignée ici, le RGPD
# imposant un point de contact pour l'exercice des droits.
LEGAL_CONTACT_EMAIL=
# Surcharges facultatives (utiles si l'outil est exploité par une autre société)
# LEGAL_COMPANY_NAME=
# LEGAL_TRADE_NAME=
# LEGAL_COMPANY_LEGAL_FORM=
# LEGAL_COMPANY_RCS=
# LEGAL_COMPANY_ADDRESS=
# ========================================== # ==========================================
# API EXTERNE # API EXTERNE
# ========================================== # ==========================================
+38
View File
@@ -136,6 +136,44 @@ docker-compose down
docker-compose down -v docker-compose down -v
``` ```
## 🔗 URL publique et connexions OAuth (Facebook / TikTok)
Les boutons « Connecter une page » construisent une URI de redirection à partir
de l'URL publique de l'application. Renseignez donc `APP_URL` avec le domaine
réel :
```bash
APP_URL=https://socialflow.exemple.fr
```
Puis déclarez l'URI correspondante chez le fournisseur :
- Facebook : *Connexion Facebook → Paramètres → URI de redirection OAuth valides*
→ `https://socialflow.exemple.fr/api/facebook/callback`
- TikTok : *Login Kit → Redirect URI* → `https://socialflow.exemple.fr/api/tiktok/callback`
Si `APP_URL` est absent, l'URL est déduite des en-têtes `X-Forwarded-Proto` et
`X-Forwarded-Host` envoyés par Nginx — vérifiez que le proxy les transmet
(voir la configuration Nginx ci-dessus). Un `APP_URL` réglé sur
`http://localhost:5555` en production produit une URI que Facebook refuse.
## 🧩 Déploiement via Portainer (stack Git)
La stack se construit depuis les sources : `app` et `ffmpeg-api` ont une section
`build:`, leurs images n'existent sur aucun registre.
- **« Pull and redeploy » échoue** avec `pull access denied for <stack>-app,
repository does not exist` : Docker cherche sur Docker Hub une image qui est
construite localement. Le `pull_policy: build` du `docker-compose.yml` évite
cette tentative ; si votre version de Portainer déclenche quand même le pull,
décochez **« Re-pull image »** dans la mise à jour de la stack.
- Pour reconstruire après une mise à jour du dépôt : *Stack → Editor →
Update the stack*, en laissant Docker rebâtir les images (ou
`docker compose up -d --build` en ligne de commande).
- Le nom des images est figé (`socialflow-app:latest`,
`socialflow-ffmpeg-api:latest`) : il ne dépend donc plus du nom donné à la
stack dans Portainer.
## 🔒 Sécurité en production ## 🔒 Sécurité en production
1. **Variables d'environnement** : Ne commitez JAMAIS le fichier `.env` 1. **Variables d'environnement** : Ne commitez JAMAIS le fichier `.env`
+3
View File
@@ -38,6 +38,9 @@ export default function AiChat() {
// Fetch available models from OpenRouter // Fetch available models from OpenRouter
const { data: modelsData, isLoading: modelsLoading } = useQuery<{ data: OpenRouterModel[] }>({ const { data: modelsData, isLoading: modelsLoading } = useQuery<{ data: OpenRouterModel[] }>({
queryKey: ['/api/openrouter/models'], queryKey: ['/api/openrouter/models'],
// Liste servie par un appel sortant vers OpenRouter : elle ne bouge pas d'une
// heure à l'autre, inutile de la redemander à chaque retour sur l'onglet.
staleTime: 60 * 60 * 1000,
}); });
const availableModels = modelsData?.data || []; const availableModels = modelsData?.data || [];
+2 -2
View File
@@ -1,7 +1,7 @@
import { format, isSameDay, parseISO } from "date-fns"; import { format, isSameDay, parseISO } from "date-fns";
import { fr } from "date-fns/locale"; import { fr } from "date-fns/locale";
import { Edit, Trash2, ChevronDown, ChevronUp, Eye, Image, Smartphone } from "lucide-react"; import { Edit, Trash2, ChevronDown, ChevronUp, Eye, Image, Smartphone } from "lucide-react";
import { SiFacebook, SiInstagram } from "react-icons/si"; import { SiFacebook, SiInstagram, SiTiktok } from "react-icons/si";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useState } from "react"; import { useState } from "react";
@@ -159,7 +159,7 @@ export default function CalendarListView({ scheduledPosts, onEditPost, onDeleteP
const isPublished = !!post.publishedAt; const isPublished = !!post.publishedAt;
const pageName = post.page?.pageName || 'Page inconnue'; const pageName = post.page?.pageName || 'Page inconnue';
const platform = post.page?.platform || 'facebook'; const platform = post.page?.platform || 'facebook';
const PlatformIcon = platform === 'instagram' ? SiInstagram : SiFacebook; const PlatformIcon = platform === 'instagram' ? SiInstagram : platform === 'tiktok' ? SiTiktok : SiFacebook;
return ( return (
<div <div
+25 -4
View File
@@ -6,13 +6,31 @@ import { useQuery, useMutation } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, queryClient } from "@/lib/queryClient";
import type { ScheduledPost } from "@shared/schema"; import type { ScheduledPost } from "@shared/schema";
import { SiFacebook, SiInstagram } from "react-icons/si"; import { SiFacebook, SiInstagram, SiTiktok } from "react-icons/si";
import EditScheduledPostDialog from "./edit-scheduled-post-dialog"; import EditScheduledPostDialog from "./edit-scheduled-post-dialog";
import CalendarListView from "./calendar-list-view"; import CalendarListView from "./calendar-list-view";
import { useMediaQuery } from "@/hooks/use-media-query"; import { useMediaQuery } from "@/hooks/use-media-query";
import { PreviewModal } from "@/components/preview-modal"; import { PreviewModal } from "@/components/preview-modal";
import type { Media } from "@shared/schema"; import type { Media } from "@shared/schema";
/**
* Bornes de la grille affichée : 42 cases à partir du lundi qui précède le 1er
* du mois. Demander cette fenêtre plutôt que tout l'historique évite de charger
* des années de publications pour en afficher six semaines.
*/
function visibleRange(currentDate: Date): { startDate: string; endDate: string } {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const firstDay = new Date(year, month, 1);
const startingDayOfWeek = firstDay.getDay() === 0 ? 6 : firstDay.getDay() - 1;
const start = new Date(year, month, 1 - startingDayOfWeek);
const end = new Date(year, month, 1 - startingDayOfWeek + 42);
end.setMilliseconds(-1); // fin de la 42e journée
return { startDate: start.toISOString(), endDate: end.toISOString() };
}
export default function CalendarView() { export default function CalendarView() {
const [currentDate, setCurrentDate] = useState(new Date()); const [currentDate, setCurrentDate] = useState(new Date());
const [editDialogOpen, setEditDialogOpen] = useState(false); const [editDialogOpen, setEditDialogOpen] = useState(false);
@@ -22,8 +40,11 @@ export default function CalendarView() {
const { toast } = useToast(); const { toast } = useToast();
const isDesktop = useMediaQuery("(min-width: 768px)"); const isDesktop = useMediaQuery("(min-width: 768px)");
// La fenêtre fait partie de la clé : changer de mois redéclenche la requête.
const range = visibleRange(currentDate);
const { data: scheduledPosts = [] } = useQuery<ScheduledPost[]>({ const { data: scheduledPosts = [] } = useQuery<ScheduledPost[]>({
queryKey: ["/api/scheduled-posts"], queryKey: ["/api/scheduled-posts", range],
refetchInterval: 30000, // Auto-refresh every 30 seconds refetchInterval: 30000, // Auto-refresh every 30 seconds
}); });
@@ -261,7 +282,7 @@ export default function CalendarView() {
const isPublished = !!post.publishedAt; const isPublished = !!post.publishedAt;
const pageName = post.page?.pageName || 'Page inconnue'; const pageName = post.page?.pageName || 'Page inconnue';
const platform = post.page?.platform || 'facebook'; const platform = post.page?.platform || 'facebook';
const PlatformIcon = platform === 'instagram' ? SiInstagram : SiFacebook; const PlatformIcon = platform === 'instagram' ? SiInstagram : platform === 'tiktok' ? SiTiktok : SiFacebook;
return ( return (
<div <div
@@ -340,7 +361,7 @@ export default function CalendarView() {
const isPublished = !!post.publishedAt; const isPublished = !!post.publishedAt;
const pageName = post.page?.pageName || 'Page inconnue'; const pageName = post.page?.pageName || 'Page inconnue';
const platform = post.page?.platform || 'facebook'; const platform = post.page?.platform || 'facebook';
const PlatformIcon = platform === 'instagram' ? SiInstagram : SiFacebook; const PlatformIcon = platform === 'instagram' ? SiInstagram : platform === 'tiktok' ? SiTiktok : SiFacebook;
return ( return (
<div <div
@@ -169,6 +169,7 @@ export default function EditScheduledPostDialog({ open, onOpenChange, scheduledP
<MediaThumbnail <MediaThumbnail
src={media.instagramFeedUrl || media.originalUrl} src={media.instagramFeedUrl || media.originalUrl}
alt="Selected" alt="Selected"
thumbnailUrl={media.thumbnailUrl ?? undefined}
type={media.type === 'video' ? 'video' : 'image'} type={media.type === 'video' ? 'video' : 'image'}
className="w-full h-24 object-cover rounded-lg border-2 border-primary" className="w-full h-24 object-cover rounded-lg border-2 border-primary"
/> />
@@ -203,6 +204,7 @@ export default function EditScheduledPostDialog({ open, onOpenChange, scheduledP
<MediaThumbnail <MediaThumbnail
src={media.instagramFeedUrl || media.originalUrl} src={media.instagramFeedUrl || media.originalUrl}
alt={media.fileName} alt={media.fileName}
thumbnailUrl={media.thumbnailUrl ?? undefined}
type={media.type === 'video' ? 'video' : 'image'} type={media.type === 'video' ? 'video' : 'image'}
className={`w-full h-20 object-cover rounded-lg border-2 transition-all ${ className={`w-full h-20 object-cover rounded-lg border-2 transition-all ${
isSelected ? 'border-primary' : 'border-border hover:border-primary/50' isSelected ? 'border-primary' : 'border-border hover:border-primary/50'
@@ -0,0 +1,203 @@
/**
* Suivi et renouvellement des jetons d'accès.
*
* Un jeton de page Facebook n'affiche pas toujours de date d'expiration : celui
* qui dérive d'une connexion OAuth est permanent, et c'est sa révocation — pas
* son échéance — qui casse la publication. L'UI montre donc l'état réel remonté
* par le contrôle serveur, pas seulement un décompte de jours.
*/
import { useMutation, useQuery } from "@tanstack/react-query";
import { AlertTriangle, Calendar, CheckCircle2, Facebook, RefreshCw, ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, getErrorMessage, queryClient } from "@/lib/queryClient";
import type { ClientSocialPage } from "@shared/schema";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
type TokenStatus = 'valid' | 'expiring' | 'expired' | 'error';
const STATUS_STYLES: Record<TokenStatus, { label: string; color: string; bgColor: string }> = {
valid: { label: 'Jeton valide', color: 'text-green-600', bgColor: 'bg-green-100' },
expiring: { label: 'Jeton bientôt expiré', color: 'text-orange-600', bgColor: 'bg-orange-100' },
expired: { label: 'Jeton expiré', color: 'text-red-600', bgColor: 'bg-red-100' },
error: { label: 'Contrôle en échec', color: 'text-red-600', bgColor: 'bg-red-100' },
};
function statusOf(page: ClientSocialPage): TokenStatus {
return (page.tokenStatus as TokenStatus | null) ?? 'valid';
}
/**
* Lance l'autorisation Facebook. La connexion se fait par navigation complète
* du navigateur (et non en fetch) puisqu'elle passe par le site de Facebook.
*/
export function ConnectFacebookButton({ className }: { className?: string }) {
const { toast } = useToast();
const { data: config } = useQuery<{ configured: boolean }>({
queryKey: ['/api/facebook/config'],
});
const handleClick = () => {
if (config && !config.configured) {
toast({
title: "Facebook n'est pas configuré",
description:
"Renseignez l'App ID et l'App Secret de votre application Facebook dans les paramètres.",
variant: 'destructive',
});
return;
}
window.location.href = '/api/facebook/connect';
};
return (
<Button
variant="outline"
className={className}
onClick={handleClick}
data-testid="button-connect-facebook"
>
<Facebook className="w-4 h-4 mr-2" />
Connecter des pages Facebook
</Button>
);
}
/** Relance le contrôle serveur, qui renouvelle le jeton s'il le peut. */
export function RefreshTokenButton({ page }: { page: ClientSocialPage }) {
const { toast } = useToast();
const refreshMutation = useMutation({
mutationFn: async () => {
const response = await apiRequest('POST', `/api/facebook/pages/${page.id}/refresh`);
return (await response.json()) as { status: TokenStatus; renewed: boolean; error?: string };
},
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['/api/pages'] });
toast({
title: result.renewed
? 'Jeton renouvelé'
: result.status === 'valid'
? 'Jeton valide'
: STATUS_STYLES[result.status].label,
description:
result.error ||
(result.renewed
? `Un nouveau jeton a été généré pour ${page.pageName}.`
: `Aucun renouvellement nécessaire pour ${page.pageName}.`),
variant: result.status === 'valid' ? undefined : 'destructive',
});
},
onError: (error: unknown) => {
toast({
title: 'Contrôle impossible',
description: getErrorMessage(error, 'Facebook est injoignable, réessayez plus tard.'),
variant: 'destructive',
});
},
});
return (
<Button
variant="ghost"
size="sm"
onClick={() => refreshMutation.mutate()}
disabled={refreshMutation.isPending}
data-testid={`button-refresh-token-${page.id}`}
>
<RefreshCw className={`w-4 h-4 mr-2 ${refreshMutation.isPending ? 'animate-spin' : ''}`} />
{refreshMutation.isPending ? 'Contrôle…' : 'Vérifier le jeton'}
</Button>
);
}
/**
* Bloc d'état du jeton : ce que le dernier contrôle a constaté, l'échéance
* quand il y en a une, et l'erreur à corriger le cas échéant.
*/
export function TokenHealthPanel({ page }: { page: ClientSocialPage }) {
const status = statusOf(page);
const style = STATUS_STYLES[status];
const isTiktok = page.platform === 'tiktok';
// Pour TikTok, c'est l'autorisation (refresh token) qui porte l'échéance.
const expiresAt = isTiktok ? page.refreshTokenExpiresAt : page.tokenExpiresAt;
return (
<div className={`text-xs px-3 py-2 rounded-lg space-y-1 ${style.bgColor}`}>
<div className="flex items-center gap-2">
{status === 'valid' ? (
<CheckCircle2 className="w-4 h-4 text-green-600 shrink-0" />
) : (
<AlertTriangle className="w-4 h-4 text-red-600 shrink-0" />
)}
<span className={`font-semibold ${style.color}`}>{style.label}</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Calendar className="w-3.5 h-3.5 shrink-0" />
{expiresAt ? (
<span>
{isTiktok ? 'Connexion valable jusqu\'au' : 'Expire le'}{' '}
{format(new Date(expiresAt), 'd MMMM yyyy', { locale: fr })}
</span>
) : page.autoRenew ? (
<span>Sans expiration</span>
) : (
<span>Échéance inconnue</span>
)}
</div>
{page.autoRenew && (
<div className="flex items-center gap-2 text-muted-foreground">
<ShieldCheck className="w-3.5 h-3.5 shrink-0" />
<span>Renouvellement automatique actif</span>
</div>
)}
{page.tokenError && (
<p className={`${style.color} leading-relaxed`} data-testid={`text-token-error-${page.id}`}>
{page.tokenError}
</p>
)}
{page.lastTokenCheck && (
<p className="text-muted-foreground">
Dernier contrôle : {format(new Date(page.lastTokenCheck), 'd MMM yyyy à HH:mm', { locale: fr })}
</p>
)}
</div>
);
}
/** Bandeau global : les pages qui réclament une reconnexion manuelle. */
export function TokenAlertBanner({ pages }: { pages: ClientSocialPage[] }) {
const failing = pages.filter((page) => {
const status = statusOf(page);
return status === 'expired' || status === 'error' || status === 'expiring';
});
if (failing.length === 0) return null;
return (
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/30 rounded-xl flex gap-3">
<AlertTriangle className="w-5 h-5 text-red-500 shrink-0 mt-0.5" />
<div className="text-sm space-y-1">
<p className="font-semibold text-red-600 dark:text-red-400">
{failing.length === 1
? 'Une page nécessite votre attention'
: `${failing.length} pages nécessitent votre attention`}
</p>
{failing.map((page) => (
<p key={page.id} className="text-muted-foreground">
<strong>{page.pageName}</strong> — {STATUS_STYLES[statusOf(page)].label.toLowerCase()}
{page.tokenError ? ` : ${page.tokenError}` : '.'}
</p>
))}
</div>
</div>
);
}
+5 -3
View File
@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Users } from "lucide-react"; import { Users } from "lucide-react";
import { SiFacebook, SiInstagram } from "react-icons/si"; import { SiFacebook, SiInstagram, SiTiktok } from "react-icons/si";
export default function ManagedPages() { export default function ManagedPages() {
const { data: pages, isLoading } = useQuery({ const { data: pages, isLoading } = useQuery({
@@ -48,10 +48,12 @@ export default function ManagedPages() {
<div className="flex items-start justify-between mb-5"> <div className="flex items-start justify-between mb-5">
<div className={` <div className={`
w-14 h-14 rounded-xl flex items-center justify-center shadow-md w-14 h-14 rounded-xl flex items-center justify-center shadow-md
${page.platform === "facebook" ? "bg-[#1877F2]/10" : "bg-[#E4405F]/10"} ${page.platform === "facebook" ? "bg-[#1877F2]/10" : page.platform === "tiktok" ? "bg-foreground/10" : "bg-[#E4405F]/10"}
`}> `}>
{page.platform === "facebook" ? ( {page.platform === "facebook" ? (
<SiFacebook className="text-[#1877F2] text-2xl" /> <SiFacebook className="text-[#1877F2] text-2xl" />
) : page.platform === "tiktok" ? (
<SiTiktok className="text-foreground text-2xl" />
) : ( ) : (
<SiInstagram className="text-[#E4405F] text-2xl" /> <SiInstagram className="text-[#E4405F] text-2xl" />
)} )}
@@ -65,7 +67,7 @@ export default function ManagedPages() {
</div> </div>
<h4 className="font-semibold text-foreground text-lg mb-2">{page.pageName}</h4> <h4 className="font-semibold text-foreground text-lg mb-2">{page.pageName}</h4>
<p className="text-sm text-muted-foreground mb-4 font-medium"> <p className="text-sm text-muted-foreground mb-4 font-medium">
{page.platform === "facebook" ? "Facebook Page" : "Instagram Business"} {page.platform === "facebook" ? "Facebook Page" : page.platform === "tiktok" ? "Compte TikTok" : "Instagram Business"}
</p> </p>
<div className="flex items-center justify-between pt-4 border-t border-border/50"> <div className="flex items-center justify-between pt-4 border-t border-border/50">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
+4 -1
View File
@@ -61,7 +61,10 @@ export function MediaThumbnail({ src, alt, type, className = "w-full h-full obje
}; };
const handleError = () => { const handleError = () => {
console.warn('Video thumbnail generation failed for:', src); // Cas courant et attendu : la vidéo a été supprimée du disque après
// publication. Les médias créés depuis embarquent une vignette persistée,
// qui évite d'en arriver là.
console.warn('Aperçu vidéo indisponible (fichier absent ou illisible):', src);
setError(true); setError(true);
video.remove(); video.remove();
}; };
+43 -10
View File
@@ -2,25 +2,57 @@ import { useState, useCallback, useRef, useEffect } from "react";
import { useDropzone } from "react-dropzone"; import { useDropzone } from "react-dropzone";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Dialog, DialogContent } from "@/components/ui/dialog";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useInfiniteQuery, useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
import { MediaThumbnail } from "@/components/media-thumbnail"; import { MediaThumbnail } from "@/components/media-thumbnail";
import { CloudUpload, Image as ImageIcon, Video, X, Upload, Loader2, ZoomIn, Camera } from "lucide-react"; import { CloudUpload, Image as ImageIcon, Video, X, Upload, Loader2, ZoomIn, Camera } from "lucide-react";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { SiFacebook, SiInstagram } from "react-icons/si"; import { SiFacebook, SiInstagram } from "react-icons/si";
/** Nombre de vignettes chargées par page. */
const MEDIA_PAGE_SIZE = 15;
export default function MediaUpload() { export default function MediaUpload() {
const { toast } = useToast(); const { toast } = useToast();
const [selectedFile, setSelectedFile] = useState<any>(null); const [selectedFile, setSelectedFile] = useState<any>(null);
const [visibleCount, setVisibleCount] = useState(5);
const [zoomImage, setZoomImage] = useState<string | null>(null); const [zoomImage, setZoomImage] = useState<string | null>(null);
const loadMoreRef = useRef<HTMLDivElement>(null); const loadMoreRef = useRef<HTMLDivElement>(null);
const cameraInputRef = useRef<HTMLInputElement>(null); const cameraInputRef = useRef<HTMLInputElement>(null);
const { data: mediaList } = useQuery({ // Pagination côté serveur : la médiathèque entière transitait pour n'afficher
queryKey: ["/api/media"], // que les premières vignettes. La clé reste préfixée par « /api/media » afin
// que les invalidations existantes continuent de la rafraîchir.
const {
data: mediaPages,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ["/api/media", "pages"],
initialPageParam: 0,
queryFn: async ({ pageParam }) => {
const response = await fetch(`/api/media?limit=${MEDIA_PAGE_SIZE}&offset=${pageParam}`, {
credentials: "include",
});
if (!response.ok) {
// Même traitement que les autres requêtes : une session expirée renvoie
// vers l'écran de connexion plutôt que sur une médiathèque vide.
if (response.status === 401) handleUnauthorized("/api/media");
throw new Error("Impossible de charger la médiathèque");
}
const items = (await response.json()) as any[];
const total = Number(response.headers.get("X-Total-Count"));
return { items, total: Number.isFinite(total) ? total : items.length };
},
getNextPageParam: (lastPage, allPages) => {
const loaded = allPages.reduce((count, page) => count + page.items.length, 0);
return loaded < lastPage.total ? loaded : undefined;
},
}); });
const mediaList = mediaPages?.pages.flatMap(page => page.items) ?? [];
const totalMedia = mediaPages?.pages[0]?.total ?? 0;
const [uploadingCount, setUploadingCount] = useState(0); const [uploadingCount, setUploadingCount] = useState(0);
const uploadMutation = useMutation({ const uploadMutation = useMutation({
@@ -163,8 +195,8 @@ export default function MediaUpload() {
useEffect(() => { useEffect(() => {
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
if (entries[0].isIntersecting && mediaList && visibleCount < (mediaList as any[]).length) { if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
setVisibleCount(prev => Math.min(prev + 5, (mediaList as any[]).length)); fetchNextPage();
} }
}, },
{ threshold: 0.1 } { threshold: 0.1 }
@@ -179,7 +211,7 @@ export default function MediaUpload() {
observer.unobserve(loadMoreRef.current); observer.unobserve(loadMoreRef.current);
} }
}; };
}, [mediaList, visibleCount]); }, [hasNextPage, isFetchingNextPage, fetchNextPage]);
return ( return (
<div className="bg-card rounded-2xl border border-border/50 overflow-hidden shadow-lg"> <div className="bg-card rounded-2xl border border-border/50 overflow-hidden shadow-lg">
@@ -268,13 +300,13 @@ export default function MediaUpload() {
<h4 className="text-sm font-semibold text-foreground flex items-center justify-between"> <h4 className="text-sm font-semibold text-foreground flex items-center justify-between">
<span>Fichiers téléchargés</span> <span>Fichiers téléchargés</span>
<span className="text-xs font-medium text-muted-foreground bg-muted/30 px-3 py-1 rounded-full"> <span className="text-xs font-medium text-muted-foreground bg-muted/30 px-3 py-1 rounded-full">
{(mediaList as any[])?.length || 0} média(s) {totalMedia} média(s)
</span> </span>
</h4> </h4>
<div className="max-h-[500px] overflow-y-auto"> <div className="max-h-[500px] overflow-y-auto">
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
{(mediaList as any[])?.slice(0, visibleCount).map((media: any) => ( {mediaList.map((media: any) => (
<div <div
key={media.id} key={media.id}
onClick={() => setSelectedFile(media)} onClick={() => setSelectedFile(media)}
@@ -284,6 +316,7 @@ export default function MediaUpload() {
<MediaThumbnail <MediaThumbnail
src={media.facebookFeedUrl || media.originalUrl} src={media.facebookFeedUrl || media.originalUrl}
alt={media.fileName} alt={media.fileName}
thumbnailUrl={media.thumbnailUrl ?? undefined}
type={media.type === 'video' ? 'video' : 'image'} type={media.type === 'video' ? 'video' : 'image'}
/> />
<div className="absolute top-2 right-2 flex gap-2"> <div className="absolute top-2 right-2 flex gap-2">
@@ -315,7 +348,7 @@ export default function MediaUpload() {
</div> </div>
{/* Élément sentinelle pour le scroll infini */} {/* Élément sentinelle pour le scroll infini */}
{(mediaList && visibleCount < (mediaList as any[]).length) ? ( {hasNextPage ? (
<div ref={loadMoreRef} className="flex justify-center py-4 mt-3"> <div ref={loadMoreRef} className="flex justify-center py-4 mt-3">
<Loader2 className="w-5 h-5 text-primary animate-spin" /> <Loader2 className="w-5 h-5 text-primary animate-spin" />
</div> </div>
+4 -1
View File
@@ -24,7 +24,10 @@ interface OngoingReelsProps {
export default function OngoingReels({ compact = false }: OngoingReelsProps) { export default function OngoingReels({ compact = false }: OngoingReelsProps) {
const { data: ongoingPosts = [] } = useQuery<Post[]>({ const { data: ongoingPosts = [] } = useQuery<Post[]>({
queryKey: ["/api/reels/ongoing"], queryKey: ["/api/reels/ongoing"],
refetchInterval: 3000, // Poll every 3 seconds // Cette route ne renvoie que les générations en cours : tant qu'elle est
// vide il n'y a pas de barre de progression à animer. On garde une veille
// lente, qui suffit à détecter une génération lancée depuis un autre écran.
refetchInterval: (query) => ((query.state.data?.length ?? 0) > 0 ? 3000 : 30000),
}); });
// Nothing to show — render nothing (not even a card) // Nothing to show — render nothing (not even a card)
+22 -2
View File
@@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Facebook, Instagram, Clock, CheckCircle2, XCircle, Eye, Image as ImageIcon, Smartphone, Clapperboard } from "lucide-react"; import { Facebook, Instagram, Clock, CheckCircle2, XCircle, Eye, Image as ImageIcon, Smartphone, Clapperboard } from "lucide-react";
import { SiTiktok } from "react-icons/si";
import { format } from "date-fns"; import { format } from "date-fns";
import { fr } from "date-fns/locale"; import { fr } from "date-fns/locale";
import type { ScheduledPost, SocialPage, Post, Media } from "@shared/schema"; import type { ScheduledPost, SocialPage, Post, Media } from "@shared/schema";
@@ -28,6 +29,16 @@ import { Trash2 } from "lucide-react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient"; import { apiRequest } from "@/lib/queryClient";
/** Profondeur d'historique du bloc « Publications récentes ». */
const RECENT_WINDOW_DAYS = 90;
/** Minuit, pour que la clé de requête reste stable d'un rendu à l'autre. */
function startOfDay(date: Date): Date {
const copy = new Date(date);
copy.setHours(0, 0, 0, 0);
return copy;
}
export default function RecentPublications() { export default function RecentPublications() {
const [previewModalOpen, setPreviewModalOpen] = useState(false); const [previewModalOpen, setPreviewModalOpen] = useState(false);
const [previewData, setPreviewData] = useState<{ postText: string; mediaIds: string[]; allMedia: Media[] }>({ postText: '', mediaIds: [], allMedia: [] }); const [previewData, setPreviewData] = useState<{ postText: string; mediaIds: string[]; allMedia: Media[] }>({ postText: '', mediaIds: [], allMedia: [] });
@@ -36,8 +47,13 @@ export default function RecentPublications() {
const { toast } = useToast(); const { toast } = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Ce bloc n'affiche que les dix dernières publications : inutile de faire
// remonter tout l'historique pour cela.
const since = new Date();
since.setDate(since.getDate() - RECENT_WINDOW_DAYS);
const { data: scheduledPosts = [], isLoading } = useQuery<ScheduledPostWithRelations[]>({ const { data: scheduledPosts = [], isLoading } = useQuery<ScheduledPostWithRelations[]>({
queryKey: ['/api/scheduled-posts'], queryKey: ['/api/scheduled-posts', { startDate: startOfDay(since).toISOString() }],
}); });
// Filter and sort attempted posts (scheduled in the past) // Filter and sort attempted posts (scheduled in the past)
@@ -162,10 +178,14 @@ export default function RecentPublications() {
{/* Platform icon */} {/* Platform icon */}
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${scheduledPost.page?.platform === 'facebook' <div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${scheduledPost.page?.platform === 'facebook'
? 'bg-blue-500' ? 'bg-blue-500'
: 'bg-gradient-to-br from-purple-500 to-pink-500' : scheduledPost.page?.platform === 'tiktok'
? 'bg-black'
: 'bg-gradient-to-br from-purple-500 to-pink-500'
}`}> }`}>
{scheduledPost.page?.platform === 'facebook' ? ( {scheduledPost.page?.platform === 'facebook' ? (
<Facebook className="w-5 h-5 text-white" /> <Facebook className="w-5 h-5 text-white" />
) : scheduledPost.page?.platform === 'tiktok' ? (
<SiTiktok className="w-5 h-5 text-white" />
) : ( ) : (
<Instagram className="w-5 h-5 text-white" /> <Instagram className="w-5 h-5 text-white" />
)} )}
+2 -1
View File
@@ -1,7 +1,7 @@
import { Home, PlusCircle, Calendar, Images, Users, Bot, Clock, Settings, Database, UserCog, LogOut, ChevronLeft, ChevronRight, ChevronDown, Wand2, BarChart3, Video, Music, Shield } from "lucide-react"; import { Home, PlusCircle, Calendar, Images, Users, Bot, Clock, Settings, Database, UserCog, LogOut, ChevronLeft, ChevronRight, ChevronDown, Wand2, BarChart3, Video, Music, Shield } from "lucide-react";
import { Link, useLocation } from "wouter"; import { Link, useLocation } from "wouter";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, AUTH_MUTATION, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from "@/components/ui/avatar";
@@ -52,6 +52,7 @@ export default function Sidebar({ onLinkClick }: SidebarProps = {}) {
}; };
const logoutMutation = useMutation({ const logoutMutation = useMutation({
mutationKey: [AUTH_MUTATION, "logout"],
mutationFn: async () => { mutationFn: async () => {
const res = await apiRequest("POST", "/api/auth/logout", {}); const res = await apiRequest("POST", "/api/auth/logout", {});
return await res.json(); return await res.json();
+84 -5
View File
@@ -1,4 +1,4 @@
import { QueryClient, QueryFunction } from "@tanstack/react-query"; import { MutationCache, QueryClient, QueryFunction } from "@tanstack/react-query";
// Les routes d'authentification elles-mêmes ne doivent jamais déclencher // Les routes d'authentification elles-mêmes ne doivent jamais déclencher
// de redirection automatique (ex: mauvais mot de passe sur /login). // de redirection automatique (ex: mauvais mot de passe sur /login).
@@ -20,6 +20,29 @@ export function handleUnauthorized(url: string) {
window.location.href = "/login"; window.location.href = "/login";
} }
/**
* Extrait un message lisible d'une erreur d'API.
* apiRequest lève des erreurs de la forme `500: {"error":"…"}` : sans ce
* décodage, l'interface ne peut afficher qu'un message générique alors que le
* serveur a renvoyé la cause exacte.
*/
export function getErrorMessage(error: unknown, fallback: string): string {
if (!(error instanceof Error) || !error.message) return fallback;
const withoutStatus = error.message.replace(/^\d{3}:\s*/, '').trim();
if (!withoutStatus) return fallback;
try {
const parsed = JSON.parse(withoutStatus);
const message = parsed?.error || parsed?.message;
if (typeof message === 'string' && message.trim()) return message.trim();
} catch {
// Corps non-JSON : on affiche le texte tel quel
}
return withoutStatus.startsWith('<') ? fallback : withoutStatus;
}
async function throwIfResNotOk(res: Response, url: string) { async function throwIfResNotOk(res: Response, url: string) {
if (!res.ok) { if (!res.ok) {
if (res.status === 401) { if (res.status === 401) {
@@ -46,13 +69,40 @@ export async function apiRequest(
return res; return res;
} }
/**
* Construit l'URL d'une requête à partir de sa clé.
*
* Les segments texte forment le chemin (`['/api/posts', id]`). Un objet en
* dernière position porte les paramètres de requête : c'est ce qui permet de
* demander une fenêtre de dates plutôt que tout l'historique
* (`['/api/scheduled-posts', { startDate, endDate }]`).
*/
export function urlFromQueryKey(queryKey: readonly unknown[]): string {
const segments = [...queryKey];
const last = segments[segments.length - 1];
if (last !== null && typeof last === "object") {
segments.pop();
const params = new URLSearchParams();
for (const [key, value] of Object.entries(last as Record<string, unknown>)) {
if (value !== undefined && value !== null && value !== "") {
params.set(key, String(value));
}
}
const query = params.toString();
return query ? `${segments.join("/")}?${query}` : segments.join("/");
}
return segments.join("/");
}
type UnauthorizedBehavior = "returnNull" | "throw"; type UnauthorizedBehavior = "returnNull" | "throw";
export const getQueryFn: <T>(options: { export const getQueryFn: <T>(options: {
on401: UnauthorizedBehavior; on401: UnauthorizedBehavior;
}) => QueryFunction<T> = }) => QueryFunction<T> =
({ on401: unauthorizedBehavior }) => ({ on401: unauthorizedBehavior }) =>
async ({ queryKey }) => { async ({ queryKey }) => {
const url = queryKey.join("/") as string; const url = urlFromQueryKey(queryKey);
const res = await fetch(url, { const res = await fetch(url, {
credentials: "include", credentials: "include",
}); });
@@ -65,13 +115,42 @@ export const getQueryFn: <T>(options: {
return await res.json(); return await res.json();
}; };
/**
* Marque les mutations d'authentification, exclues de l'invalidation globale
* ci-dessous. À passer en `mutationKey` : `[AUTH_MUTATION, 'logout']`.
*/
export const AUTH_MUTATION = "auth";
export const queryClient = new QueryClient({ export const queryClient = new QueryClient({
// Les compteurs du tableau de bord et la liste des posts dérivent de tout le
// reste : publier, supprimer un média, générer un texte… Aucune mutation ne
// peut raisonnablement se souvenir de les invalider, et aucune ne le faisait :
// ils restaient figés jusqu'au rechargement de la page. On les invalide donc
// ici, une bonne fois, après chaque mutation réussie.
mutationCache: new MutationCache({
onSuccess: (_data, _variables, _context, mutation) => {
// Sauf pour l'authentification : au moment où la déconnexion réussit, la
// session est déjà détruite. Relancer des requêtes ici ne produirait que
// des 401 et une redirection brutale, avant même que le cache soit vidé.
if (mutation.options.mutationKey?.[0] === AUTH_MUTATION) return;
queryClient.invalidateQueries({ queryKey: ["/api/stats"] });
queryClient.invalidateQueries({ queryKey: ["/api/posts"] });
},
}),
defaultOptions: { defaultOptions: {
queries: { queries: {
queryFn: getQueryFn({ on401: "throw" }), queryFn: getQueryFn({ on401: "throw" }),
refetchInterval: false, // Le serveur agit de son côté — le scheduler publie toutes les minutes, le
refetchOnWindowFocus: false, // renouvellement des jetons tourne en tâche de fond — sans que le
staleTime: Infinity, // navigateur en soit averti. Avec `staleTime: Infinity`, un écran restait
// sur le cache de sa première visite pour toute la session : d'où la
// nécessité de recharger la page pour voir quoi que ce soit. Une
// péremption courte, plus un rafraîchissement au retour sur l'onglet,
// suffit à garder l'affichage juste sans marteler l'API.
staleTime: 30_000,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
retry: false, retry: false,
}, },
mutations: { mutations: {
+14 -1
View File
@@ -17,6 +17,15 @@ type ScheduledPostWithRelations = ScheduledPost & {
page?: SocialPage; page?: SocialPage;
}; };
/** Publication dont l'heure est passée mais que le scheduler n'a pas encore traitée. */
function isAwaitingPublication(scheduledPost: ScheduledPostWithRelations): boolean {
return (
!!scheduledPost.scheduledAt &&
new Date(scheduledPost.scheduledAt) <= new Date() &&
!scheduledPost.publishedAt
);
}
export default function History() { export default function History() {
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [previewModalOpen, setPreviewModalOpen] = useState(false); const [previewModalOpen, setPreviewModalOpen] = useState(false);
@@ -25,7 +34,11 @@ export default function History() {
const { data: scheduledPosts = [], isLoading } = useQuery<ScheduledPostWithRelations[]>({ const { data: scheduledPosts = [], isLoading } = useQuery<ScheduledPostWithRelations[]>({
queryKey: ['/api/scheduled-posts'], queryKey: ['/api/scheduled-posts'],
refetchInterval: 3000, // Le scheduler publie en tâche de fond : on ne suit à la seconde que ce qui
// est réellement en attente. Le reste du temps, une veille lente suffit —
// sans quoi la page entière était rechargée 20 fois par minute pour rien.
refetchInterval: (query) =>
(query.state.data ?? []).some(isAwaitingPublication) ? 3000 : 30000,
}); });
// Filter attempted posts (scheduled in the past) // Filter attempted posts (scheduled in the past)
+13 -1
View File
@@ -19,13 +19,25 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
/** Post dont le statut peut changer d'un instant à l'autre. */
function isChanging(post: any): boolean {
if (post?.generationStatus === 'pending' || post?.generationStatus === 'processing') {
return true;
}
return post?.status === 'scheduled' && !!post?.scheduledFor && new Date(post.scheduledFor) <= new Date();
}
export default function MobileHistory() { export default function MobileHistory() {
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
// Fetch posts history with polling to see status updates // Fetch posts history with polling to see status updates
const { data: posts } = useQuery<any[]>({ const { data: posts } = useQuery<any[]>({
queryKey: ['/api/posts'], queryKey: ['/api/posts'],
refetchInterval: 3000, // Poll every 3 seconds // Un post planifié pour la semaine prochaine ne bougera pas d'ici une
// seconde : on ne suit de près que ce dont le statut peut basculer
// maintenant (génération en cours, ou heure de publication dépassée).
refetchInterval: (query) =>
(query.state.data ?? []).some(isChanging) ? 3000 : 30000,
}); });
const getStatusIcon = (status: string) => { const getStatusIcon = (status: string) => {
+26 -7
View File
@@ -32,7 +32,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Dialog, DialogContent } from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
import type { SocialPage, Media, ScheduledPost } from "@shared/schema"; import type { SocialPage, Media, ScheduledPost } from "@shared/schema";
import { PreviewModal } from "@/components/preview-modal"; import { PreviewModal } from "@/components/preview-modal";
import { DateTimePicker } from "@/components/datetime-picker"; import { DateTimePicker } from "@/components/datetime-picker";
@@ -81,6 +81,7 @@ function SortableMediaItem({
<MediaThumbnail <MediaThumbnail
src={media.facebookFeedUrl || media.originalUrl} src={media.facebookFeedUrl || media.originalUrl}
alt={media.fileName} alt={media.fileName}
thumbnailUrl={media.thumbnailUrl ?? undefined}
type={isVideo ? 'video' : 'image'} type={isVideo ? 'video' : 'image'}
className="absolute inset-0 w-full h-full object-cover" className="absolute inset-0 w-full h-full object-cover"
/> />
@@ -103,6 +104,16 @@ function SortableMediaItem({
); );
} }
/** Nombre de médias récents proposés à la sélection. */
const RECENT_MEDIA_COUNT = 12;
/** Minuit, pour que la clé de requête reste stable d'un rendu à l'autre. */
function startOfToday(): Date {
const date = new Date();
date.setHours(0, 0, 0, 0);
return date;
}
export default function NewPostMobile() { export default function NewPostMobile() {
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [, navigate] = useLocation(); const [, navigate] = useLocation();
@@ -119,12 +130,17 @@ export default function NewPostMobile() {
const [postType, setPostType] = useState<'feed' | 'story' | 'both'>('feed'); const [postType, setPostType] = useState<'feed' | 'story' | 'both'>('feed');
const [previewModalOpen, setPreviewModalOpen] = useState(false); const [previewModalOpen, setPreviewModalOpen] = useState(false);
const { data: pages = [] } = useQuery<SocialPage[]>({ // Ce composeur gère les publications feed/story : TikTok n'accepte que des
// vidéos et se pilote depuis la création de reel.
const { data: allPages = [] } = useQuery<SocialPage[]>({
queryKey: ['/api/pages'], queryKey: ['/api/pages'],
}); });
const pages = allPages.filter(p => p.platform !== 'tiktok');
// Seuls les douze derniers médias sont proposés ici : autant ne demander
// que ceux-là plutôt que toute la médiathèque.
const { data: allMedia = [] } = useQuery<Media[]>({ const { data: allMedia = [] } = useQuery<Media[]>({
queryKey: ['/api/media'], queryKey: ['/api/media', { limit: RECENT_MEDIA_COUNT }],
}); });
const mediaList = useMemo(() => { const mediaList = useMemo(() => {
@@ -138,11 +154,13 @@ export default function NewPostMobile() {
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0; const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return dateB - dateA; return dateB - dateA;
}) })
.slice(0, 12); .slice(0, RECENT_MEDIA_COUNT);
}, [allMedia, recentUploads]); }, [allMedia, recentUploads]);
// Ne sert qu'à griser les créneaux déjà pris dans le sélecteur de date : le
// passé n'a aucune influence sur une planification à venir.
const { data: scheduledPosts = [] } = useQuery<ScheduledPost[]>({ const { data: scheduledPosts = [] } = useQuery<ScheduledPost[]>({
queryKey: ['/api/scheduled-posts'], queryKey: ['/api/scheduled-posts', { startDate: startOfToday().toISOString() }],
}); });
const uploadMutation = useMutation({ const uploadMutation = useMutation({
@@ -227,10 +245,10 @@ export default function NewPostMobile() {
}); });
queryClient.invalidateQueries({ queryKey: ['/api/ai/generations'] }); queryClient.invalidateQueries({ queryKey: ['/api/ai/generations'] });
}, },
onError: () => { onError: (error: unknown) => {
toast({ toast({
title: "Erreur", title: "Erreur",
description: "Impossible de générer le texte", description: getErrorMessage(error, "Impossible de générer le texte"),
variant: "destructive", variant: "destructive",
}); });
}, },
@@ -532,6 +550,7 @@ export default function NewPostMobile() {
<MediaThumbnail <MediaThumbnail
src={media.facebookFeedUrl || media.originalUrl} src={media.facebookFeedUrl || media.originalUrl}
alt={media.fileName} alt={media.fileName}
thumbnailUrl={media.thumbnailUrl ?? undefined}
type={isVideo ? 'video' : 'image'} type={isVideo ? 'video' : 'image'}
className="absolute inset-0 w-full h-full object-cover" className="absolute inset-0 w-full h-full object-cover"
/> />
+44 -13
View File
@@ -18,6 +18,7 @@ import { Slider } from "@/components/ui/slider";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
import type { SocialPage, Media } from "@shared/schema"; import type { SocialPage, Media } from "@shared/schema";
import { SiFacebook, SiTiktok } from "react-icons/si";
import { DateTimePicker } from "@/components/datetime-picker"; import { DateTimePicker } from "@/components/datetime-picker";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
@@ -114,6 +115,24 @@ export default function MobileNewReel() {
queryKey: ['/api/pages'], queryKey: ['/api/pages'],
}); });
// Destinations acceptant une vidéo verticale
const facebookPages = pages.filter(p => p.platform === 'facebook');
const tiktokAccounts = pages.filter(p => p.platform === 'tiktok');
const renderTargetCheckbox = (page: SocialPage) => (
<div key={page.id} className="flex items-center space-x-3 bg-secondary/20 p-3 rounded-lg">
<Checkbox
id={page.id}
checked={selectedPages.includes(page.id)}
onCheckedChange={(checked) => {
if (checked) setSelectedPages([...selectedPages, page.id]);
else setSelectedPages(selectedPages.filter(id => id !== page.id));
}}
/>
<label htmlFor={page.id} className="font-medium flex-1">{page.pageName}</label>
</div>
);
const { data: allMedia = [] } = useQuery<Media[]>({ const { data: allMedia = [] } = useQuery<Media[]>({
queryKey: ['/api/media'], queryKey: ['/api/media'],
}); });
@@ -585,20 +604,32 @@ export default function MobileNewReel() {
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-base">Diffusion</CardTitle> <CardTitle className="text-base">Diffusion</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-4">
{pages.filter(p => p.platform === 'facebook').map((page) => ( {facebookPages.length === 0 && tiktokAccounts.length === 0 && (
<div key={page.id} className="flex items-center space-x-3 bg-secondary/20 p-3 rounded-lg"> <p className="text-sm text-muted-foreground">
<Checkbox Aucune page Facebook ni compte TikTok connecté.
id={page.id} </p>
checked={selectedPages.includes(page.id)} )}
onCheckedChange={(checked) => {
if (checked) setSelectedPages([...selectedPages, page.id]); {facebookPages.length > 0 && (
else setSelectedPages(selectedPages.filter(id => id !== page.id)); <div className="space-y-3">
}} <div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
/> <SiFacebook className="w-4 h-4 text-[#1877F2]" />
<label htmlFor={page.id} className="font-medium flex-1">{page.pageName}</label> Pages Facebook
</div>
{facebookPages.map(renderTargetCheckbox)}
</div> </div>
))} )}
{tiktokAccounts.length > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<SiTiktok className="w-4 h-4" />
Comptes TikTok
</div>
{tiktokAccounts.map(renderTargetCheckbox)}
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
+103 -90
View File
@@ -1,6 +1,7 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code, Calendar, AlertTriangle } from "lucide-react"; import { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code } from "lucide-react";
import { SiTiktok } from "react-icons/si";
import Sidebar from "@/components/sidebar"; import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar"; import TopBar from "@/components/topbar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -11,68 +12,77 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, queryClient } from "@/lib/queryClient";
import type { SocialPage } from "@shared/schema"; import type { ClientSocialPage } from "@shared/schema";
import { format } from "date-fns"; import {
import { fr } from "date-fns/locale"; ConnectFacebookButton,
RefreshTokenButton,
TokenAlertBanner,
TokenHealthPanel,
} from "@/components/facebook-token-status";
function getTokenExpirationStatus(expiresAt: string | null | undefined) { /**
if (!expiresAt) { * Lance l'autorisation TikTok. La connexion se fait par navigation complète du
return { * navigateur (et non en fetch) puisqu'elle passe par le site de TikTok.
status: 'unknown' as const, */
color: 'text-gray-500', function ConnectTiktokButton() {
bgColor: 'bg-gray-100', const { toast } = useToast();
daysLeft: null,
message: 'Date inconnue'
};
}
const now = new Date(); const { data: config } = useQuery<{ configured: boolean }>({
const expiration = new Date(expiresAt); queryKey: ['/api/tiktok/config'],
const diffTime = expiration.getTime() - now.getTime(); });
const daysLeft = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (daysLeft < 0) { const handleClick = () => {
return { if (config && !config.configured) {
status: 'expired' as const, toast({
color: 'text-red-600', title: "TikTok n'est pas configuré",
bgColor: 'bg-red-100', description: "Renseignez le client key et le client secret de votre application TikTok dans les paramètres.",
daysLeft, variant: "destructive",
message: 'Expiré' });
}; return;
} else if (daysLeft <= 7) { }
return { window.location.href = '/api/tiktok/connect';
status: 'urgent' as const, };
color: 'text-red-600',
bgColor: 'bg-red-100', return (
daysLeft, <Button
message: `${daysLeft} jour${daysLeft > 1 ? 's' : ''} restant${daysLeft > 1 ? 's' : ''}` variant="outline"
}; className="w-full min-h-[48px]"
} else if (daysLeft <= 15) { onClick={handleClick}
return { data-testid="button-connect-tiktok"
status: 'warning' as const, >
color: 'text-orange-600', <SiTiktok className="w-5 h-5 mr-2" />
bgColor: 'bg-orange-100', Connecter un compte TikTok
daysLeft, </Button>
message: `${daysLeft} jours restants` );
};
} else {
return {
status: 'good' as const,
color: 'text-green-600',
bgColor: 'bg-green-100',
daysLeft,
message: `${daysLeft} jours restants`
};
}
} }
export default function PagesManagementMobile() { export default function PagesManagementMobile() {
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingPage, setEditingPage] = useState<SocialPage | null>(null); const [editingPage, setEditingPage] = useState<ClientSocialPage | null>(null);
const { toast } = useToast(); const { toast } = useToast();
const { data: pages = [], isLoading } = useQuery<SocialPage[]>({ // Retour des flux d'autorisation (/api/tiktok/callback et /api/facebook/callback
// redirigent ici avec le résultat en paramètre)
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const network = params.get('tiktok') ? 'tiktok' : params.get('facebook') ? 'facebook' : null;
if (!network) return;
const result = params.get(network);
const label = network === 'tiktok' ? 'TikTok' : 'Facebook';
toast({
title: result === 'success' ? `${label} connecté` : `Connexion ${label} impossible`,
description: params.get('message') || undefined,
variant: result === 'success' ? undefined : 'destructive',
});
queryClient.invalidateQueries({ queryKey: ['/api/pages'] });
window.history.replaceState({}, '', window.location.pathname);
}, [toast]);
const { data: pages = [], isLoading } = useQuery<ClientSocialPage[]>({
queryKey: ['/api/pages'], queryKey: ['/api/pages'],
}); });
@@ -119,10 +129,14 @@ export default function PagesManagementMobile() {
<div className="space-y-2"> <div className="space-y-2">
<h1 className="text-2xl font-bold text-foreground">Pages gérées</h1> <h1 className="text-2xl font-bold text-foreground">Pages gérées</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Connectez et gérez vos pages Facebook et Instagram Connectez et gérez vos pages Facebook, Instagram et vos comptes TikTok
</p> </p>
</div> </div>
<TokenAlertBanner pages={pages} />
<ConnectFacebookButton className="w-full min-h-[48px]" />
<ConnectTiktokButton />
<AddPageDialog open={dialogOpen} onOpenChange={setDialogOpen} /> <AddPageDialog open={dialogOpen} onOpenChange={setDialogOpen} />
<EditPageDialog page={editingPage} onOpenChange={(open) => !open && setEditingPage(null)} /> <EditPageDialog page={editingPage} onOpenChange={(open) => !open && setEditingPage(null)} />
@@ -181,6 +195,10 @@ export default function PagesManagementMobile() {
<div className="w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0"> <div className="w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
<Facebook className="w-6 h-6 text-white" /> <Facebook className="w-6 h-6 text-white" />
</div> </div>
) : page.platform === 'tiktok' ? (
<div className="w-12 h-12 bg-black rounded-lg flex items-center justify-center flex-shrink-0">
<SiTiktok className="w-6 h-6 text-white" />
</div>
) : ( ) : (
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-500 rounded-lg flex items-center justify-center flex-shrink-0"> <div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-500 rounded-lg flex items-center justify-center flex-shrink-0">
<Instagram className="w-6 h-6 text-white" /> <Instagram className="w-6 h-6 text-white" />
@@ -200,42 +218,37 @@ export default function PagesManagementMobile() {
ID: {page.pageId} ID: {page.pageId}
</div> </div>
{/* Token expiration status */} {/* État du jeton, tel que constaté par le dernier contrôle serveur */}
{(() => { <TokenHealthPanel page={page} />
const expirationStatus = getTokenExpirationStatus(
page.tokenExpiresAt ? new Date(page.tokenExpiresAt).toISOString() : null {page.platform !== 'tiktok' && (
); <div className="flex">
return ( <RefreshTokenButton page={page} />
<div className={`flex items-center gap-2 text-xs px-3 py-3 rounded-lg ${expirationStatus.bgColor}`}> </div>
<Calendar className="w-4 h-4 flex-shrink-0" /> )}
<div className="flex-1 min-w-0">
<div className="font-medium">Expiration du token</div>
<div className={`${expirationStatus.color} font-semibold`}>
{expirationStatus.message}
</div>
{page.tokenExpiresAt && (
<div className="text-muted-foreground mt-0.5">
{format(new Date(page.tokenExpiresAt), "d MMMM yyyy", { locale: fr })}
</div>
)}
</div>
{(expirationStatus.status === 'expired' || expirationStatus.status === 'urgent') && (
<AlertTriangle className="w-4 h-4 text-red-600 flex-shrink-0" />
)}
</div>
);
})()}
<div className="flex items-center gap-2 pt-2"> <div className="flex items-center gap-2 pt-2">
<Button {page.platform === 'tiktok' ? (
variant="outline" <Button
className="flex-1 min-h-[44px]" variant="outline"
onClick={() => setEditingPage(page)} className="flex-1 min-h-[44px]"
data-testid={`button-edit-page-${page.id}`} onClick={() => { window.location.href = '/api/tiktok/connect'; }}
> data-testid={`button-reconnect-page-${page.id}`}
<Edit className="w-4 h-4 mr-2" /> >
Modifier <RefreshCw className="w-4 h-4 mr-2" />
</Button> Reconnecter
</Button>
) : (
<Button
variant="outline"
className="flex-1 min-h-[44px]"
onClick={() => setEditingPage(page)}
data-testid={`button-edit-page-${page.id}`}
>
<Edit className="w-4 h-4 mr-2" />
Modifier
</Button>
)}
<Button <Button
variant="destructive" variant="destructive"
className="flex-1 min-h-[44px]" className="flex-1 min-h-[44px]"
@@ -417,7 +430,7 @@ function AddPageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o
); );
} }
function EditPageDialog({ page, onOpenChange }: { page: SocialPage | null; onOpenChange: (open: boolean) => void }) { function EditPageDialog({ page, onOpenChange }: { page: ClientSocialPage | null; onOpenChange: (open: boolean) => void }) {
const [accessToken, setAccessToken] = useState(''); const [accessToken, setAccessToken] = useState('');
const { toast } = useToast(); const { toast } = useToast();
+47 -23
View File
@@ -11,10 +11,12 @@ import { MediaThumbnail } from "@/components/media-thumbnail";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import Sidebar from "@/components/sidebar"; import Sidebar from "@/components/sidebar";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { DateTimePicker } from "@/components/datetime-picker"; import { DateTimePicker } from "@/components/datetime-picker";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
import type { Media, SocialPage } from "@shared/schema"; import type { Media, SocialPage } from "@shared/schema";
import { SiFacebook, SiTiktok } from "react-icons/si";
interface AudioTrack { id: string; title: string; fileName: string; url: string; duration: number; } interface AudioTrack { id: string; title: string; fileName: string; url: string; duration: number; }
@@ -68,12 +70,29 @@ export default function MobileRemotionVideoPage() {
const { data: socialPages = [] } = useQuery<SocialPage[]>({ queryKey: ['/api/pages'] }); const { data: socialPages = [] } = useQuery<SocialPage[]>({ queryKey: ['/api/pages'] });
const facebookPages = socialPages.filter(p => p.platform === 'facebook'); const facebookPages = socialPages.filter(p => p.platform === 'facebook');
const tiktokAccounts = socialPages.filter(p => p.platform === 'tiktok');
const renderTargetCheckbox = (page: SocialPage) => (
<div key={page.id} className="flex items-center gap-3">
<Checkbox
id={`page-${page.id}`}
checked={selectedPageIds.includes(page.id)}
onCheckedChange={checked =>
setSelectedPageIds(prev => checked ? [...prev, page.id] : prev.filter(id => id !== page.id))
}
/>
<label htmlFor={`page-${page.id}`} className="text-sm font-medium cursor-pointer">
{page.pageName}
</label>
</div>
);
const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20); const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20);
const generateTextMutation = useMutation({ const generateTextMutation = useMutation({
mutationFn: async (text: string) => (await apiRequest('POST', '/api/reels/generate-text', { productInfo: text })).json(), mutationFn: async (text: string) => (await apiRequest('POST', '/api/reels/generate-text', { productInfo: text })).json(),
onSuccess: (data: any) => { setGeneratedVariants(data.variants || []); toast({ title: "Textes générés" }); }, onSuccess: (data: any) => { setGeneratedVariants(data.variants || []); toast({ title: "Textes générés" }); },
onError: () => toast({ title: "Erreur IA", variant: "destructive" }), onError: (error: unknown) => toast({ title: "Erreur IA", description: getErrorMessage(error, "Impossible de générer le texte"), variant: "destructive" }),
}); });
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -430,31 +449,36 @@ export default function MobileRemotionVideoPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* 4. Page Facebook */} {/* 4. Destinations */}
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2"><Send className="w-4 h-4" /> 4. Page Facebook</CardTitle> <CardTitle className="text-base flex items-center gap-2"><Send className="w-4 h-4" /> 4. Destinations</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
{facebookPages.length === 0 ? ( {facebookPages.length === 0 && tiktokAccounts.length === 0 ? (
<p className="text-xs text-muted-foreground">Aucune page Facebook connectée.</p> <p className="text-xs text-muted-foreground">Aucune page Facebook ni compte TikTok connecté.</p>
) : ( ) : (
facebookPages.map(page => ( <>
<div key={page.id} className="flex items-center gap-3"> {facebookPages.length > 0 && (
<Checkbox <div className="space-y-2">
id={`page-${page.id}`} <div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
checked={selectedPageIds.includes(page.id)} <SiFacebook className="w-4 h-4 text-[#1877F2]" />
onCheckedChange={checked => Pages Facebook
setSelectedPageIds(prev => </div>
checked ? [...prev, page.id] : prev.filter(id => id !== page.id) {facebookPages.map(renderTargetCheckbox)}
) </div>
} )}
/>
<label htmlFor={`page-${page.id}`} className="text-sm font-medium cursor-pointer"> {tiktokAccounts.length > 0 && (
{page.pageName} <div className="space-y-2 pt-2">
</label> <div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
</div> <SiTiktok className="w-4 h-4" />
)) Comptes TikTok
</div>
{tiktokAccounts.map(renderTargetCheckbox)}
</div>
)}
</>
)} )}
</CardContent> </CardContent>
</Card> </Card>
@@ -484,7 +508,7 @@ export default function MobileRemotionVideoPage() {
<p className="text-xs text-muted-foreground">Sélectionnez une page ci-dessus.</p> <p className="text-xs text-muted-foreground">Sélectionnez une page ci-dessus.</p>
) : ( ) : (
<p className="text-xs text-primary font-medium"> <p className="text-xs text-primary font-medium">
Pages : {facebookPages.filter(p => selectedPageIds.includes(p.id)).map(p => p.pageName).join(', ')} Destinations : {[...facebookPages, ...tiktokAccounts].filter(p => selectedPageIds.includes(p.id)).map(p => p.pageName).join(', ')}
</p> </p>
)} )}
<Textarea rows={2} placeholder="Description (optionnel)" value={publishDescription} <Textarea rows={2} placeholder="Description (optionnel)" value={publishDescription}
+3
View File
@@ -47,6 +47,9 @@ export default function SettingsMobile() {
const { data: openrouterModels, isLoading: modelsLoading } = useQuery({ const { data: openrouterModels, isLoading: modelsLoading } = useQuery({
queryKey: ['/api/openrouter/models'], queryKey: ['/api/openrouter/models'],
// Liste servie par un appel sortant vers OpenRouter : elle ne bouge pas d'une
// heure à l'autre, inutile de la redemander à chaque retour sur l'onglet.
staleTime: 60 * 60 * 1000,
}); });
useEffect(() => { useEffect(() => {
+26 -7
View File
@@ -31,7 +31,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Dialog, DialogContent } from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
import { MediaThumbnail } from "@/components/media-thumbnail"; import { MediaThumbnail } from "@/components/media-thumbnail";
import type { SocialPage, Media, ScheduledPost } from "@shared/schema"; import type { SocialPage, Media, ScheduledPost } from "@shared/schema";
import { PreviewModal } from "@/components/preview-modal"; import { PreviewModal } from "@/components/preview-modal";
@@ -80,6 +80,7 @@ function SortableMediaItem({
<MediaThumbnail <MediaThumbnail
src={media.facebookFeedUrl || media.originalUrl} src={media.facebookFeedUrl || media.originalUrl}
alt={media.fileName} alt={media.fileName}
thumbnailUrl={media.thumbnailUrl ?? undefined}
type={isVideo ? 'video' : 'image'} type={isVideo ? 'video' : 'image'}
/> />
</button> </button>
@@ -101,6 +102,16 @@ function SortableMediaItem({
); );
} }
/** Nombre de médias récents proposés à la sélection. */
const RECENT_MEDIA_COUNT = 12;
/** Minuit, pour que la clé de requête reste stable d'un rendu à l'autre. */
function startOfToday(): Date {
const date = new Date();
date.setHours(0, 0, 0, 0);
return date;
}
export default function NewPost() { export default function NewPost() {
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [, navigate] = useLocation(); const [, navigate] = useLocation();
@@ -116,12 +127,17 @@ export default function NewPost() {
const [postType, setPostType] = useState<'feed' | 'story' | 'both'>('feed'); const [postType, setPostType] = useState<'feed' | 'story' | 'both'>('feed');
const [previewModalOpen, setPreviewModalOpen] = useState(false); const [previewModalOpen, setPreviewModalOpen] = useState(false);
const { data: pages = [] } = useQuery<SocialPage[]>({ // Ce composeur gère les publications feed/story : TikTok n'accepte que des
// vidéos et se pilote depuis la création de reel.
const { data: allPages = [] } = useQuery<SocialPage[]>({
queryKey: ['/api/pages'], queryKey: ['/api/pages'],
}); });
const pages = allPages.filter(p => p.platform !== 'tiktok');
// Seuls les douze derniers médias sont proposés ici : autant ne demander
// que ceux-là plutôt que toute la médiathèque.
const { data: allMedia = [] } = useQuery<Media[]>({ const { data: allMedia = [] } = useQuery<Media[]>({
queryKey: ['/api/media'], queryKey: ['/api/media', { limit: RECENT_MEDIA_COUNT }],
}); });
// Afficher seulement les 12 derniers médias triés par date décroissante // Afficher seulement les 12 derniers médias triés par date décroissante
@@ -134,11 +150,13 @@ export default function NewPost() {
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0; const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return dateB - dateA; // Tri décroissant (plus récent en premier) return dateB - dateA; // Tri décroissant (plus récent en premier)
}) })
.slice(0, 12); .slice(0, RECENT_MEDIA_COUNT);
}, [allMedia]); }, [allMedia]);
// Ne sert qu'à griser les créneaux déjà pris dans le sélecteur de date : le
// passé n'a aucune influence sur une planification à venir.
const { data: scheduledPosts = [] } = useQuery<ScheduledPost[]>({ const { data: scheduledPosts = [] } = useQuery<ScheduledPost[]>({
queryKey: ['/api/scheduled-posts'], queryKey: ['/api/scheduled-posts', { startDate: startOfToday().toISOString() }],
}); });
const uploadMutation = useMutation({ const uploadMutation = useMutation({
@@ -223,10 +241,10 @@ export default function NewPost() {
}); });
queryClient.invalidateQueries({ queryKey: ['/api/ai/generations'] }); queryClient.invalidateQueries({ queryKey: ['/api/ai/generations'] });
}, },
onError: () => { onError: (error: unknown) => {
toast({ toast({
title: "Erreur", title: "Erreur",
description: "Impossible de générer le texte", description: getErrorMessage(error, "Impossible de générer le texte"),
variant: "destructive", variant: "destructive",
}); });
}, },
@@ -540,6 +558,7 @@ export default function NewPost() {
<MediaThumbnail <MediaThumbnail
src={media.facebookFeedUrl || media.originalUrl} src={media.facebookFeedUrl || media.originalUrl}
alt={media.fileName} alt={media.fileName}
thumbnailUrl={media.thumbnailUrl ?? undefined}
type={isVideo ? 'video' : 'image'} type={isVideo ? 'video' : 'image'}
/> />
{isSelected && ( {isSelected && (
+56 -28
View File
@@ -25,8 +25,9 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
import type { SocialPage, Media } from "@shared/schema"; import type { SocialPage, Media } from "@shared/schema";
import { SiFacebook, SiTiktok } from "react-icons/si";
import { MediaThumbnail } from "@/components/media-thumbnail"; import { MediaThumbnail } from "@/components/media-thumbnail";
import { DateTimePicker } from "@/components/datetime-picker"; import { DateTimePicker } from "@/components/datetime-picker";
@@ -133,6 +134,32 @@ export default function NewReel() {
queryKey: ['/api/pages'], queryKey: ['/api/pages'],
}); });
// Destinations acceptant une vidéo verticale
const facebookPages = pages.filter(p => p.platform === 'facebook');
const tiktokAccounts = pages.filter(p => p.platform === 'tiktok');
const renderTargetCheckbox = (page: SocialPage) => (
<div key={page.id} className="flex items-center space-x-2">
<Checkbox
id={`page-${page.id}`}
checked={selectedPages.includes(page.id)}
onCheckedChange={(checked) => {
if (checked) {
setSelectedPages([...selectedPages, page.id]);
} else {
setSelectedPages(selectedPages.filter(id => id !== page.id));
}
}}
/>
<label
htmlFor={`page-${page.id}`}
className="text-sm font-medium leading-none flex-1"
>
{page.pageName}
</label>
</div>
);
// Récupérer les vidéos disponibles // Récupérer les vidéos disponibles
const { data: allMedia = [] } = useQuery<Media[]>({ const { data: allMedia = [] } = useQuery<Media[]>({
queryKey: ['/api/media'], queryKey: ['/api/media'],
@@ -208,10 +235,10 @@ export default function NewReel() {
description: `${variants.length} variations créées`, description: `${variants.length} variations créées`,
}); });
}, },
onError: () => { onError: (error: unknown) => {
toast({ toast({
title: "Erreur", title: "Erreur",
description: "Impossible de générer le texte", description: getErrorMessage(error, "Impossible de générer le texte"),
variant: "destructive", variant: "destructive",
}); });
}, },
@@ -541,6 +568,7 @@ export default function NewReel() {
<MediaThumbnail <MediaThumbnail
src={media.originalUrl} src={media.originalUrl}
alt={media.fileName} alt={media.fileName}
thumbnailUrl={media.thumbnailUrl ?? undefined}
type="video" type="video"
/> />
{selectedVideoId === media.id && ( {selectedVideoId === media.id && (
@@ -918,42 +946,42 @@ export default function NewReel() {
<> <>
<Card className="rounded-2xl border-border/50 shadow-lg"> <Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader> <CardHeader>
<CardTitle>Pages cibles</CardTitle> <CardTitle>Destinations</CardTitle>
<CardDescription>Sélectionnez les pages où publier</CardDescription> <CardDescription>
La même vidéo peut partir sur plusieurs pages Facebook et plusieurs comptes TikTok
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{pages.filter(p => p.platform === 'facebook').length === 0 ? ( {facebookPages.length === 0 && tiktokAccounts.length === 0 ? (
<div className="text-center py-8"> <div className="text-center py-8">
<p className="text-muted-foreground mb-2"> <p className="text-muted-foreground mb-2">
Aucune page Facebook connectée Aucune page Facebook ni compte TikTok connecté
</p> </p>
<Button variant="link" onClick={() => navigate('/pages')}> <Button variant="link" onClick={() => navigate('/pages')}>
Ajouter des pages Ajouter des pages
</Button> </Button>
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-5">
{pages.filter(p => p.platform === 'facebook').map((page) => ( {facebookPages.length > 0 && (
<div key={page.id} className="flex items-center space-x-2"> <div className="space-y-3">
<Checkbox <div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
id={`page-${page.id}`} <SiFacebook className="w-4 h-4 text-[#1877F2]" />
checked={selectedPages.includes(page.id)} Pages Facebook
onCheckedChange={(checked) => { </div>
if (checked) { {facebookPages.map(renderTargetCheckbox)}
setSelectedPages([...selectedPages, page.id]);
} else {
setSelectedPages(selectedPages.filter(id => id !== page.id));
}
}}
/>
<label
htmlFor={`page-${page.id}`}
className="text-sm font-medium leading-none flex-1"
>
{page.pageName}
</label>
</div> </div>
))} )}
{tiktokAccounts.length > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<SiTiktok className="w-4 h-4" />
Comptes TikTok
</div>
{tiktokAccounts.map(renderTargetCheckbox)}
</div>
)}
</div> </div>
)} )}
</CardContent> </CardContent>
+107 -99
View File
@@ -1,6 +1,7 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code, Calendar, AlertTriangle } from "lucide-react"; import { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code, AlertTriangle } from "lucide-react";
import { SiTiktok } from "react-icons/si";
import Sidebar from "@/components/sidebar"; import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar"; import TopBar from "@/components/topbar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -11,68 +12,41 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, queryClient } from "@/lib/queryClient";
import type { SocialPage } from "@shared/schema"; import type { ClientSocialPage } from "@shared/schema";
import { format } from "date-fns"; import {
import { fr } from "date-fns/locale"; ConnectFacebookButton,
RefreshTokenButton,
function getTokenExpirationStatus(expiresAt: string | null | undefined) { TokenAlertBanner,
if (!expiresAt) { TokenHealthPanel,
return { } from "@/components/facebook-token-status";
status: 'unknown' as const,
color: 'text-gray-500',
bgColor: 'bg-gray-100',
daysLeft: null,
message: 'Date inconnue'
};
}
const now = new Date();
const expiration = new Date(expiresAt);
const diffTime = expiration.getTime() - now.getTime();
const daysLeft = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (daysLeft < 0) {
return {
status: 'expired' as const,
color: 'text-red-600',
bgColor: 'bg-red-100',
daysLeft,
message: 'Expiré'
};
} else if (daysLeft <= 7) {
return {
status: 'urgent' as const,
color: 'text-red-600',
bgColor: 'bg-red-100',
daysLeft,
message: `${daysLeft} jour${daysLeft > 1 ? 's' : ''} restant${daysLeft > 1 ? 's' : ''}`
};
} else if (daysLeft <= 15) {
return {
status: 'warning' as const,
color: 'text-orange-600',
bgColor: 'bg-orange-100',
daysLeft,
message: `${daysLeft} jours restants`
};
} else {
return {
status: 'good' as const,
color: 'text-green-600',
bgColor: 'bg-green-100',
daysLeft,
message: `${daysLeft} jours restants`
};
}
}
export default function PagesManagement() { export default function PagesManagement() {
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingPage, setEditingPage] = useState<SocialPage | null>(null); const [editingPage, setEditingPage] = useState<ClientSocialPage | null>(null);
const { toast } = useToast(); const { toast } = useToast();
const { data: pages = [], isLoading } = useQuery<SocialPage[]>({ // Retour des flux d'autorisation (/api/tiktok/callback et /api/facebook/callback
// redirigent ici avec le résultat en paramètre)
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const network = params.get('tiktok') ? 'tiktok' : params.get('facebook') ? 'facebook' : null;
if (!network) return;
const result = params.get(network);
const label = network === 'tiktok' ? 'TikTok' : 'Facebook';
toast({
title: result === 'success' ? `${label} connecté` : `Connexion ${label} impossible`,
description: params.get('message') || undefined,
variant: result === 'success' ? undefined : 'destructive',
});
queryClient.invalidateQueries({ queryKey: ['/api/pages'] });
window.history.replaceState({}, '', window.location.pathname);
}, [toast]);
const { data: pages = [], isLoading } = useQuery<ClientSocialPage[]>({
queryKey: ['/api/pages'], queryKey: ['/api/pages'],
}); });
@@ -119,25 +93,33 @@ export default function PagesManagement() {
<div> <div>
<h1 className="text-3xl font-bold text-foreground">Pages gérées</h1> <h1 className="text-3xl font-bold text-foreground">Pages gérées</h1>
<p className="text-muted-foreground mt-2"> <p className="text-muted-foreground mt-2">
Connectez et gérez vos pages Facebook et Instagram Connectez et gérez vos pages Facebook, Instagram et vos comptes TikTok
</p> </p>
</div> </div>
<AddPageDialog open={dialogOpen} onOpenChange={setDialogOpen} /> <div className="flex items-center gap-3">
<ConnectFacebookButton />
<ConnectTiktokButton />
<AddPageDialog open={dialogOpen} onOpenChange={setDialogOpen} />
</div>
<EditPageDialog page={editingPage} onOpenChange={(open) => !open && setEditingPage(null)} /> <EditPageDialog page={editingPage} onOpenChange={(open) => !open && setEditingPage(null)} />
</div> </div>
<TokenAlertBanner pages={pages} />
{/* Permissions required notice */} {/* Permissions required notice */}
<div className="mb-6 p-4 bg-amber-500/10 border border-amber-500/30 rounded-xl flex gap-3"> <div className="mb-6 p-4 bg-amber-500/10 border border-amber-500/30 rounded-xl flex gap-3">
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" /> <AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
<div className="text-sm"> <div className="text-sm">
<p className="font-semibold text-amber-600 dark:text-amber-400 mb-1">Permissions Facebook requises pour la publication vidéo</p> <p className="font-semibold text-amber-600 dark:text-amber-400 mb-1">Connectez vos pages plutôt que de coller un jeton</p>
<p className="text-muted-foreground mb-2"> <p className="text-muted-foreground mb-2">
Le token de page doit inclure : <code className="bg-muted px-1 rounded text-xs">pages_manage_posts</code>{" "} « Connecter des pages Facebook » récupère les jetons de page via l'autorisation Facebook :
<code className="bg-muted px-1 rounded text-xs">pages_read_engagement</code>{" "} ils n'expirent pas et SocialFlow les régénère seul en cas de révocation. Un jeton collé à la
<code className="bg-muted px-1 rounded text-xs">publish_video</code> main expire au bout de 60 jours et devra être remplacé manuellement.
</p> </p>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
Dans Graph API Explorer → Sélectionnez votre app → Ajoutez ces 3 permissions → Générez un User Token → Échangez via <code className="bg-muted px-1 rounded">/me/accounts</code> pour obtenir le Page Token. Le jeton doit couvrir : <code className="bg-muted px-1 rounded text-xs">pages_manage_posts</code>{" "}
<code className="bg-muted px-1 rounded text-xs">pages_read_engagement</code>{" "}
<code className="bg-muted px-1 rounded text-xs">publish_video</code>
</p> </p>
</div> </div>
</div> </div>
@@ -194,6 +176,10 @@ export default function PagesManagement() {
<div className="w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center"> <div className="w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center">
<Facebook className="w-6 h-6 text-white" /> <Facebook className="w-6 h-6 text-white" />
</div> </div>
) : page.platform === 'tiktok' ? (
<div className="w-12 h-12 bg-black rounded-lg flex items-center justify-center">
<SiTiktok className="w-6 h-6 text-white" />
</div>
) : ( ) : (
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-500 rounded-lg flex items-center justify-center"> <div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-500 rounded-lg flex items-center justify-center">
<Instagram className="w-6 h-6 text-white" /> <Instagram className="w-6 h-6 text-white" />
@@ -213,41 +199,32 @@ export default function PagesManagement() {
ID: {page.pageId} ID: {page.pageId}
</div> </div>
{/* Token expiration status */} {/* État du jeton, tel que constaté par le dernier contrôle serveur */}
{(() => { <TokenHealthPanel page={page} />
const expirationStatus = getTokenExpirationStatus(
page.tokenExpiresAt ? new Date(page.tokenExpiresAt).toISOString() : null
);
return (
<div className={`flex items-center gap-2 text-xs px-3 py-2 rounded-lg ${expirationStatus.bgColor}`}>
<Calendar className="w-4 h-4" />
<div className="flex-1">
<div className="font-medium">Expiration du token</div>
<div className={`${expirationStatus.color} font-semibold`}>
{expirationStatus.message}
</div>
{page.tokenExpiresAt && (
<div className="text-muted-foreground mt-0.5">
{format(new Date(page.tokenExpiresAt), "d MMMM yyyy", { locale: fr })}
</div>
)}
</div>
{(expirationStatus.status === 'expired' || expirationStatus.status === 'urgent') && (
<AlertTriangle className="w-4 h-4 text-red-600" />
)}
</div>
);
})()}
<div className="flex items-center justify-end gap-2 pt-2"> <div className="flex items-center justify-end gap-2 pt-2">
<Button {page.platform !== 'tiktok' && <RefreshTokenButton page={page} />}
variant="ghost" {page.platform === 'tiktok' ? (
size="icon" // Un token TikTok ne se saisit pas : on repasse par l'autorisation
onClick={() => setEditingPage(page)} <Button
data-testid={`button-edit-page-${page.id}`} variant="ghost"
> size="sm"
<Edit className="w-4 h-4" /> onClick={() => { window.location.href = '/api/tiktok/connect'; }}
</Button> data-testid={`button-reconnect-page-${page.id}`}
>
<RefreshCw className="w-4 h-4 mr-2" />
Reconnecter
</Button>
) : (
<Button
variant="ghost"
size="icon"
onClick={() => setEditingPage(page)}
data-testid={`button-edit-page-${page.id}`}
>
<Edit className="w-4 h-4" />
</Button>
)}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -269,6 +246,37 @@ export default function PagesManagement() {
); );
} }
/**
* Lance l'autorisation TikTok. La connexion se fait par navigation complète du
* navigateur (et non en fetch) puisqu'elle passe par le site de TikTok.
*/
function ConnectTiktokButton() {
const { toast } = useToast();
const { data: config } = useQuery<{ configured: boolean }>({
queryKey: ['/api/tiktok/config'],
});
const handleClick = () => {
if (config && !config.configured) {
toast({
title: "TikTok n'est pas configuré",
description: "Renseignez le client key et le client secret de votre application TikTok dans les paramètres.",
variant: "destructive",
});
return;
}
window.location.href = '/api/tiktok/connect';
};
return (
<Button variant="outline" onClick={handleClick} data-testid="button-connect-tiktok">
<SiTiktok className="w-4 h-4 mr-2" />
Connecter un compte TikTok
</Button>
);
}
function AddPageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { function AddPageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
const [platform, setPlatform] = useState<'facebook' | 'instagram'>('facebook'); const [platform, setPlatform] = useState<'facebook' | 'instagram'>('facebook');
const [pageName, setPageName] = useState(''); const [pageName, setPageName] = useState('');
@@ -417,7 +425,7 @@ function AddPageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o
); );
} }
function EditPageDialog({ page, onOpenChange }: { page: SocialPage | null; onOpenChange: (open: boolean) => void }) { function EditPageDialog({ page, onOpenChange }: { page: ClientSocialPage | null; onOpenChange: (open: boolean) => void }) {
const [accessToken, setAccessToken] = useState(''); const [accessToken, setAccessToken] = useState('');
const { toast } = useToast(); const { toast } = useToast();
+46 -21
View File
@@ -8,12 +8,14 @@ import { Textarea } from "@/components/ui/textarea";
import { UploadCloud, Video, Loader2, Check, Sparkles, Mic, Volume2, Music, Play, Pause, Send } from "lucide-react"; import { UploadCloud, Video, Loader2, Check, Sparkles, Mic, Volume2, Music, Play, Pause, Send } from "lucide-react";
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { DateTimePicker } from "@/components/datetime-picker"; import { DateTimePicker } from "@/components/datetime-picker";
import type { Media, SocialPage } from "@shared/schema"; import type { Media, SocialPage } from "@shared/schema";
import { SiFacebook, SiTiktok } from "react-icons/si";
interface AudioTrack { interface AudioTrack {
@@ -67,6 +69,22 @@ export default function RemotionVideoPage() {
const { data: socialPages = [] } = useQuery<SocialPage[]>({ queryKey: ['/api/pages'] }); const { data: socialPages = [] } = useQuery<SocialPage[]>({ queryKey: ['/api/pages'] });
const facebookPages = socialPages.filter(p => p.platform === 'facebook'); const facebookPages = socialPages.filter(p => p.platform === 'facebook');
const tiktokAccounts = socialPages.filter(p => p.platform === 'tiktok');
const renderTargetCheckbox = (page: SocialPage) => (
<div key={page.id} className="flex items-center gap-3">
<Checkbox
id={`pre-${page.id}`}
checked={selectedPageIds.includes(page.id)}
onCheckedChange={checked =>
setSelectedPageIds(prev => checked ? [...prev, page.id] : prev.filter(id => id !== page.id))
}
/>
<label htmlFor={`pre-${page.id}`} className="text-sm font-medium cursor-pointer">
{page.pageName}
</label>
</div>
);
const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20); const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20);
const totalSelected = images.length + selectedLibraryImages.length; const totalSelected = images.length + selectedLibraryImages.length;
@@ -76,7 +94,7 @@ export default function RemotionVideoPage() {
setGeneratedVariants(data.variants || []); setGeneratedVariants(data.variants || []);
toast({ title: "Textes générés", description: "Cliquez pour appliquer." }); toast({ title: "Textes générés", description: "Cliquez pour appliquer." });
}, },
onError: () => toast({ title: "Erreur IA", variant: "destructive" }), onError: (error: unknown) => toast({ title: "Erreur IA", description: getErrorMessage(error, "Impossible de générer le texte"), variant: "destructive" }),
}); });
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -376,30 +394,37 @@ export default function RemotionVideoPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* 4. Page Facebook */} {/* 4. Destinations */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"><Send className="w-4 h-4" /> 4. Page Facebook</CardTitle> <CardTitle className="flex items-center gap-2"><Send className="w-4 h-4" /> 4. Page Facebook</CardTitle>
<CardDescription>Sélectionnez la page cible (son nom apparaîtra en fin de vidéo).</CardDescription> <CardDescription>Pages Facebook et comptes TikTok cibles (le nom de la première destination apparaîtra en fin de vidéo).</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
{facebookPages.length === 0 ? ( {facebookPages.length === 0 && tiktokAccounts.length === 0 ? (
<p className="text-sm text-muted-foreground">Aucune page Facebook connectée.</p> <p className="text-sm text-muted-foreground">Aucune page Facebook ni compte TikTok connecté.</p>
) : ( ) : (
facebookPages.map(page => ( <>
<div key={page.id} className="flex items-center gap-3"> {facebookPages.length > 0 && (
<Checkbox <div className="space-y-2">
id={`pre-${page.id}`} <div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
checked={selectedPageIds.includes(page.id)} <SiFacebook className="w-4 h-4 text-[#1877F2]" />
onCheckedChange={checked => Pages Facebook
setSelectedPageIds(prev => checked ? [...prev, page.id] : prev.filter(id => id !== page.id)) </div>
} {facebookPages.map(renderTargetCheckbox)}
/> </div>
<label htmlFor={`pre-${page.id}`} className="text-sm font-medium cursor-pointer"> )}
{page.pageName}
</label> {tiktokAccounts.length > 0 && (
</div> <div className="space-y-2 pt-2">
)) <div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<SiTiktok className="w-4 h-4" />
Comptes TikTok
</div>
{tiktokAccounts.map(renderTargetCheckbox)}
</div>
)}
</>
)} )}
</CardContent> </CardContent>
</Card> </Card>
@@ -432,7 +457,7 @@ export default function RemotionVideoPage() {
<p className="text-sm text-muted-foreground">Sélectionnez une page ci-dessus avant de générer.</p> <p className="text-sm text-muted-foreground">Sélectionnez une page ci-dessus avant de générer.</p>
) : ( ) : (
<p className="text-sm text-primary font-medium"> <p className="text-sm text-primary font-medium">
Pages : {facebookPages.filter(p => selectedPageIds.includes(p.id)).map(p => p.pageName).join(', ')} Destinations : {[...facebookPages, ...tiktokAccounts].filter(p => selectedPageIds.includes(p.id)).map(p => p.pageName).join(', ')}
</p> </p>
)} )}
<Textarea rows={2} placeholder="Description (optionnel)" value={publishDescription} <Textarea rows={2} placeholder="Description (optionnel)" value={publishDescription}
+230 -1
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Settings as SettingsIcon, Bell, Key, Shield, Cloud, Brain, Image, Upload, X, Video, Plug, Mic } from "lucide-react"; import { Settings as SettingsIcon, Bell, Key, Shield, Cloud, Brain, Image, Upload, X, Video, Plug, Mic, Facebook } from "lucide-react";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import Sidebar from "@/components/sidebar"; import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar"; import TopBar from "@/components/topbar";
@@ -12,6 +12,7 @@ import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, queryClient } from "@/lib/queryClient";
import { ModelCombobox } from "@/components/model-combobox"; import { ModelCombobox } from "@/components/model-combobox";
import { SiTiktok } from "react-icons/si";
export default function Settings() { export default function Settings() {
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
@@ -31,6 +32,10 @@ export default function Settings() {
const [ffmpegApiKey, setFfmpegApiKey] = useState(""); const [ffmpegApiKey, setFfmpegApiKey] = useState("");
const [externalApiKey, setExternalApiKey] = useState(""); const [externalApiKey, setExternalApiKey] = useState("");
const [geminiApiKey, setGeminiApiKey] = useState(""); const [geminiApiKey, setGeminiApiKey] = useState("");
const [facebookAppId, setFacebookAppId] = useState("");
const [facebookAppSecret, setFacebookAppSecret] = useState("");
const [tiktokClientKey, setTiktokClientKey] = useState("");
const [tiktokClientSecret, setTiktokClientSecret] = useState("");
const { toast } = useToast(); const { toast } = useToast();
const { data: session } = useQuery<{ id: string; username: string; role: string }>({ const { data: session } = useQuery<{ id: string; username: string; role: string }>({
@@ -50,12 +55,34 @@ export default function Settings() {
const { data: openrouterModels, isLoading: modelsLoading } = useQuery({ const { data: openrouterModels, isLoading: modelsLoading } = useQuery({
queryKey: ['/api/openrouter/models'], queryKey: ['/api/openrouter/models'],
// Liste servie par un appel sortant vers OpenRouter : elle ne bouge pas d'une
// heure à l'autre, inutile de la redemander à chaque retour sur l'onglet.
staleTime: 60 * 60 * 1000,
}); });
const { data: ffmpegConfig } = useQuery({ const { data: ffmpegConfig } = useQuery({
queryKey: ['/api/ffmpeg/config'], queryKey: ['/api/ffmpeg/config'],
}); });
const { data: tiktokConfig } = useQuery<{
configured: boolean;
clientKey: string;
hasClientSecret: boolean;
redirectUri: string;
}>({
queryKey: ['/api/tiktok/config'],
});
const { data: facebookConfig } = useQuery<{
configured: boolean;
appId: string;
hasAppSecret: boolean;
redirectUri: string;
scopes: string[];
}>({
queryKey: ['/api/facebook/config'],
});
const { data: externalApiConfig } = useQuery({ const { data: externalApiConfig } = useQuery({
queryKey: ['/api/settings/external-api'], queryKey: ['/api/settings/external-api'],
enabled: isAdmin, enabled: isAdmin,
@@ -92,6 +119,20 @@ export default function Settings() {
} }
}, [ffmpegConfig]); }, [ffmpegConfig]);
useEffect(() => {
if (tiktokConfig) {
// Le client secret n'est jamais renvoyé par l'API : il reste vide
setTiktokClientKey(tiktokConfig.clientKey || "");
}
}, [tiktokConfig]);
useEffect(() => {
if (facebookConfig) {
// L'App Secret n'est jamais renvoyé par l'API : il reste vide
setFacebookAppId(facebookConfig.appId || "");
}
}, [facebookConfig]);
useEffect(() => { useEffect(() => {
if (geminiConfig) { if (geminiConfig) {
// Don't pre-fill - user must enter key manually // Don't pre-fill - user must enter key manually
@@ -264,6 +305,55 @@ export default function Settings() {
}, },
}); });
const saveFacebookMutation = useMutation({
mutationFn: () => {
const payload: any = { appId: facebookAppId.trim() };
// Le secret n'est envoyé que s'il est saisi : sinon on conserve l'existant
if (facebookAppSecret.trim() !== "") {
payload.appSecret = facebookAppSecret.trim();
}
return apiRequest('PUT', '/api/facebook/config', payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/facebook/config'] });
setFacebookAppSecret("");
toast({
title: "Configuration Facebook enregistrée",
description: "Les jetons de page se renouvellent désormais automatiquement",
});
},
onError: async (error: any) => {
toast({
title: "Erreur",
description: error?.message || "Impossible d'enregistrer la configuration Facebook",
variant: "destructive",
});
},
});
const saveTiktokMutation = useMutation({
mutationFn: () => {
const payload: any = { clientKey: tiktokClientKey.trim() };
// Le secret n'est envoyé que s'il est saisi : sinon on conserve l'existant
if (tiktokClientSecret.trim() !== "") {
payload.clientSecret = tiktokClientSecret.trim();
}
return apiRequest('PUT', '/api/tiktok/config', payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/tiktok/config'] });
setTiktokClientSecret("");
toast({ title: "Configuration TikTok enregistrée", description: "Vous pouvez maintenant connecter les comptes TikTok de vos magasins" });
},
onError: async (error: any) => {
toast({
title: "Erreur",
description: error?.message || "Impossible d'enregistrer la configuration TikTok",
variant: "destructive",
});
},
});
const deleteExternalApiMutation = useMutation({ const deleteExternalApiMutation = useMutation({
mutationFn: () => apiRequest('DELETE', '/api/settings/external-api', {}), mutationFn: () => apiRequest('DELETE', '/api/settings/external-api', {}),
onSuccess: () => { onSuccess: () => {
@@ -760,6 +850,145 @@ export default function Settings() {
</Card> </Card>
)} )}
{isAdmin && (
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-6">
<CardTitle className="flex items-center gap-2">
<Facebook className="w-5 h-5" />
Facebook
</CardTitle>
<CardDescription>
Identifiants de votre application Facebook (developers.facebook.com). Ils
permettent de connecter vos pages en un clic et de renouveler leurs jetons
automatiquement, sans jamais recoller un token à la main.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{facebookConfig?.configured && (
<div className="rounded-lg bg-muted p-3 flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
<Key className="w-4 h-4" />
<span>Application Facebook configurée</span>
</div>
)}
<div className="space-y-2">
<Label htmlFor="facebookAppId">App ID</Label>
<Input
id="facebookAppId"
value={facebookAppId}
onChange={(e) => setFacebookAppId(e.target.value)}
placeholder="1234567890123456"
/>
</div>
<div className="space-y-2">
<Label htmlFor="facebookAppSecret">
{facebookConfig?.hasAppSecret ? "Nouvel App Secret (laisser vide pour conserver l'actuel)" : "App Secret"}
</Label>
<Input
id="facebookAppSecret"
type="password"
value={facebookAppSecret}
onChange={(e) => setFacebookAppSecret(e.target.value)}
placeholder="••••••••••••••••"
/>
</div>
<div className="space-y-2">
<Label>URI de redirection à déclarer chez Facebook</Label>
<code className="block text-xs bg-muted px-3 py-2 rounded break-all">
{facebookConfig?.redirectUri || `${window.location.origin}/api/facebook/callback`}
</code>
<p className="text-xs text-muted-foreground">
À ajouter dans « Connexion Facebook › Paramètres › URI de redirection OAuth valides ».
</p>
</div>
{facebookConfig?.scopes?.length ? (
<div className="space-y-2">
<Label>Permissions demandées</Label>
<p className="text-xs text-muted-foreground break-all">
{facebookConfig.scopes.join(', ')}
</p>
</div>
) : null}
<Button
onClick={() => saveFacebookMutation.mutate()}
disabled={saveFacebookMutation.isPending || !facebookAppId.trim()}
className="w-full"
>
{saveFacebookMutation.isPending ? "Enregistrement..." : "Enregistrer"}
</Button>
</CardContent>
</Card>
)}
{isAdmin && (
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-6">
<CardTitle className="flex items-center gap-2">
<SiTiktok className="w-5 h-5" />
TikTok
</CardTitle>
<CardDescription>
Identifiants de votre application TikTok (developers.tiktok.com). Ils servent à
connecter les comptes TikTok de chaque magasin depuis la page « Pages gérées ».
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{tiktokConfig?.configured && (
<div className="rounded-lg bg-muted p-3 flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
<Key className="w-4 h-4" />
<span>Application TikTok configurée</span>
</div>
)}
<div className="space-y-2">
<Label htmlFor="tiktokClientKey">Client key</Label>
<Input
id="tiktokClientKey"
value={tiktokClientKey}
onChange={(e) => setTiktokClientKey(e.target.value)}
placeholder="awxxxxxxxxxxxxxx"
/>
</div>
<div className="space-y-2">
<Label htmlFor="tiktokClientSecret">
{tiktokConfig?.hasClientSecret ? "Nouveau client secret (laisser vide pour conserver l'actuel)" : "Client secret"}
</Label>
<Input
id="tiktokClientSecret"
type="password"
value={tiktokClientSecret}
onChange={(e) => setTiktokClientSecret(e.target.value)}
placeholder="••••••••••••••••"
/>
</div>
<div className="space-y-2">
<Label>URL de redirection à déclarer chez TikTok</Label>
<code className="block text-xs bg-muted px-3 py-2 rounded break-all">
{tiktokConfig?.redirectUri || `${window.location.origin}/api/tiktok/callback`}
</code>
<p className="text-xs text-muted-foreground">
Tant que l'application n'a pas passé l'audit TikTok, les vidéos publiées
restent privées (visibles du seul propriétaire du compte).
</p>
</div>
<Button
onClick={() => saveTiktokMutation.mutate()}
disabled={saveTiktokMutation.isPending || !tiktokClientKey.trim()}
className="w-full"
>
{saveTiktokMutation.isPending ? "Enregistrement..." : "Enregistrer"}
</Button>
</CardContent>
</Card>
)}
<Card className="rounded-2xl border-border/50 shadow-lg"> <Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-6"> <CardHeader className="p-6">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
+11 -1
View File
@@ -29,6 +29,11 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
# Image construite sur place : nom figé pour ne pas dépendre du nom de la
# stack, et `pull_policy: build` pour que Docker la reconstruise au lieu
# d'aller la chercher sur Docker Hub (où elle n'existe pas).
image: socialflow-app:latest
pull_policy: build
container_name: socialflow-app container_name: socialflow-app
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -38,7 +43,10 @@ services:
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY} OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
GEMINI_API_KEY: ${GEMINI_API_KEY} GEMINI_API_KEY: ${GEMINI_API_KEY}
SESSION_SECRET: ${SESSION_SECRET:-your-secret-key-change-me} SESSION_SECRET: ${SESSION_SECRET:-your-secret-key-change-me}
APP_URL: ${APP_URL:-http://localhost:5555} # URL publique (ex. https://socialflow.exemple.fr). Laissée vide, elle est
# déduite des en-têtes du reverse proxy — ne jamais forcer localhost ici,
# les URI de redirection OAuth (Facebook/TikTok) en dépendent.
APP_URL: ${APP_URL:-}
# URL interne Docker pour que ffmpeg-api puisse télécharger les fichiers audio # URL interne Docker pour que ffmpeg-api puisse télécharger les fichiers audio
INTERNAL_APP_URL: http://socialflow-app:5555 INTERNAL_APP_URL: http://socialflow-app:5555
# Configuration FFmpeg Service # Configuration FFmpeg Service
@@ -63,6 +71,8 @@ services:
build: build:
context: ./ffmpeg-service context: ./ffmpeg-service
dockerfile: Dockerfile dockerfile: Dockerfile
image: socialflow-ffmpeg-api:latest
pull_policy: build
container_name: socialflow-ffmpeg container_name: socialflow-ffmpeg
restart: unless-stopped restart: unless-stopped
environment: environment:
+11
View File
@@ -0,0 +1,11 @@
# Fichiers servis à la racine du domaine
Le contenu de ce dossier est exposé publiquement, sans authentification, à la
racine du domaine (`https://<domaine>/<nom-du-fichier>`).
Il sert aux fichiers de vérification de propriété du domaine réclamés par les
plateformes : TikTok (`tiktok<clé>.txt`, requis par « URL properties » avant de
pouvoir utiliser la Content Posting API), Google Search Console, Meta, etc.
Pour en ajouter un, il suffit de déposer le fichier ici — aucune modification de
code n'est nécessaire, et le fichier est servi sans reconstruire le frontend.
@@ -0,0 +1 @@
tiktok-developers-site-verification=Hjd5evuj6TppdUaAJmwmDg34f1fs0qMs
+21 -5
View File
@@ -2,14 +2,30 @@ import cron from 'node-cron';
import { TokenManager } from './services/token_manager'; import { TokenManager } from './services/token_manager';
import { AnalyticsService } from './services/analytics'; import { AnalyticsService } from './services/analytics';
import { purgeOldMedia } from './services/media-purge'; import { purgeOldMedia } from './services/media-purge';
import { tiktokService } from './services/tiktok';
export function startTokenCron() { export function startTokenCron() {
// Run every day at midnight (00:00) — Token health check // Toutes les 2 minutes — suivi des publications TikTok (API asynchrone)
cron.schedule('0 0 * * *', async () => { cron.schedule('*/2 * * * *', async () => {
console.log('[Cron] Running daily token check...'); try {
await TokenManager.checkAndRefreshTokens(); await tiktokService.syncPendingPublications();
} catch (error) {
console.error('[Cron] TikTok publish status sync failed:', error);
}
}); });
console.log('[Cron] Token refresh job scheduled (0 0 * * *).'); 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 // Run twice daily (08:00 and 20:00) — Analytics sync for all pages
cron.schedule('0 8,20 * * *', async () => { cron.schedule('0 8,20 * * *', async () => {
+4 -2
View File
@@ -106,8 +106,10 @@ if (!process.env.SESSION_SECRET) {
// Générer un secret aléatoire pour le dev si non défini // 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'); const sessionSecret = process.env.SESSION_SECRET || crypto.randomBytes(32).toString('hex');
// Déterminer si on utilise HTTPS basé sur APP_URL // HTTPS déduit d'APP_URL ; à défaut, 'auto' laisse express-session décider par
const isHttps = process.env.APP_URL?.startsWith('https://') || false; // 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 // Configuration du store de session pour production
const sessionStore = process.env.NODE_ENV === 'production' && process.env.DATABASE_URL const sessionStore = process.env.NODE_ENV === 'production' && process.env.DATABASE_URL
+102
View File
@@ -17,6 +17,9 @@ export async function migrate() {
// Add 'reel' to post_type enum if not exists // Add 'reel' to post_type enum if not exists
await client.query(`ALTER TYPE "post_type" ADD VALUE IF NOT EXISTS 'reel';`); 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 // 2. Create Tables if not exist
// post_analytics // post_analytics
@@ -158,6 +161,105 @@ export async function migrate() {
ADD COLUMN IF NOT EXISTS "gemini_api_key" text; ADD COLUMN IF NOT EXISTS "gemini_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;
`);
// 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_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."); console.log("[Migration] Safe migration completed.");
} catch (error) { } catch (error) {
console.error("[Migration] Error during migration:", error); console.error("[Migration] Error during migration:", error);
+98 -43
View File
@@ -1,14 +1,15 @@
import type { Express, Request, Response, NextFunction } from "express"; import express, { type Express, type Request, type Response, type NextFunction } from "express";
import { createServer, type Server } from "http"; import { createServer, type Server } from "http";
import fs from "fs"; import fs from "fs";
import os from "os"; import os from "os";
import path from "path";
import { storage } from "./storage"; import { storage } from "./storage";
import { db, pool } from "./db"; import { db, pool } from "./db";
import multer from "multer"; import multer from "multer";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import passport from "./auth"; import passport from "./auth";
import { z } from "zod"; import { z } from "zod";
import { openRouterService } from "./services/openrouter"; import { openRouterService, describeGenerationError } from "./services/openrouter";
import { minioService as cloudinaryService, buildMinioUrl } from "./services/minio"; import { minioService as cloudinaryService, buildMinioUrl } from "./services/minio";
import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema, insertCloudinaryConfigSchema, updateCloudinaryConfigSchema, insertOpenrouterConfigSchema, updateOpenrouterConfigSchema, insertUserSchema, postMedia, type SocialPage } from "@shared/schema"; import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema, insertCloudinaryConfigSchema, updateCloudinaryConfigSchema, insertOpenrouterConfigSchema, updateOpenrouterConfigSchema, insertUserSchema, postMedia, type SocialPage } from "@shared/schema";
import type { User, InsertUser, ScheduledPost } from "@shared/schema"; import type { User, InsertUser, ScheduledPost } from "@shared/schema";
@@ -16,6 +17,9 @@ import { analyticsRouter } from "./routes/analytics";
import { reelsRouter } from "./routes/reels"; import { reelsRouter } from "./routes/reels";
import { remotionRouter } from "./routes/remotion"; import { remotionRouter } from "./routes/remotion";
import { externalRouter } from "./routes/external"; import { externalRouter } from "./routes/external";
import { tiktokRouter } from "./routes/tiktok";
import { facebookRouter } from "./routes/facebook";
import { legalRouter } from "./routes/legal";
import { insertAudioTrackSchema } from "@shared/schema"; import { insertAudioTrackSchema } from "@shared/schema";
import * as musicMetadata from "music-metadata"; import * as musicMetadata from "music-metadata";
@@ -107,6 +111,20 @@ function requireAdmin(req: Request, res: Response, next: NextFunction) {
next(); next();
} }
/** Convertit un paramètre de requête en entier positif, ou `undefined`. */
function parsePositiveInt(value: unknown): number | undefined {
if (typeof value !== "string" || value === "") return undefined;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
}
/** Convertit un paramètre de requête en date, ou `undefined` s'il est inutilisable. */
function parseDateParam(value: unknown): Date | undefined {
if (typeof value !== "string" || value === "") return undefined;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? undefined : date;
}
export async function registerRoutes(app: Express): Promise<Server> { export async function registerRoutes(app: Express): Promise<Server> {
@@ -458,6 +476,22 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Analytics Routes // Analytics Routes
app.use("/api/analytics", requireAuth, analyticsRouter); app.use("/api/analytics", requireAuth, analyticsRouter);
// Fichiers servis à la racine du domaine, sans authentification : vérification
// de propriété du domaine par TikTok ("URL properties"), Google, Meta…
// Monté ici pour être servi avant le catch-all du frontend, et sans dépendre
// d'une reconstruction du client.
app.use(express.static(path.resolve(import.meta.dirname, "..", "public-root"), {
dotfiles: "ignore",
index: false,
}));
// Pages légales publiques (exigées par TikTok pour l'audit de l'application)
app.use(legalRouter);
// TikTok Routes (OAuth multi-comptes) — avant le routeur Reels monté sur /api
app.use("/api/tiktok", requireAuth, tiktokRouter);
app.use("/api/facebook", requireAuth, facebookRouter);
// Reels & Music Routes // Reels & Music Routes
app.use("/api", requireAuth, reelsRouter); app.use("/api", requireAuth, reelsRouter);
app.use("/api/remotion", requireAuth, remotionRouter); app.use("/api/remotion", requireAuth, remotionRouter);
@@ -524,39 +558,28 @@ export async function registerRoutes(app: Express): Promise<Server> {
const user = req.user as User; const user = req.user as User;
const userId = user.id; const userId = user.id;
const posts = await storage.getPosts(userId);
const pages = await storage.getSocialPages(userId);
const media = await storage.getMedia(userId);
const aiGenerations = await storage.getAiGenerations(userId);
// Statistiques actuelles
const scheduledPosts = posts.filter(p => p.status === "scheduled").length;
const currentAiTexts = aiGenerations.length;
const currentMedia = media.length;
// Calculer les statistiques de la période précédente (hier pour les textes IA, mois dernier pour les posts) // Calculer les statistiques de la période précédente (hier pour les textes IA, mois dernier pour les posts)
const now = new Date(); const now = new Date();
const yesterday = new Date(now); const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1); yesterday.setDate(yesterday.getDate() - 1);
yesterday.setHours(0, 0, 0, 0); yesterday.setHours(0, 0, 0, 0);
const today = new Date(yesterday);
today.setDate(today.getDate() + 1);
const lastMonth = new Date(now); const lastMonth = new Date(now);
lastMonth.setMonth(lastMonth.getMonth() - 1); lastMonth.setMonth(lastMonth.getMonth() - 1);
// Textes IA générés hier const stats = await storage.getDashboardStats(userId, {
const aiTextsYesterday = aiGenerations.filter(gen => { yesterdayStart: yesterday,
if (!gen.createdAt) return false; yesterdayEnd: today,
const genDate = new Date(gen.createdAt); lastMonth,
genDate.setHours(0, 0, 0, 0); });
return genDate.getTime() === yesterday.getTime();
}).length;
// Posts planifiés le mois dernier const scheduledPosts = stats.scheduledPosts;
const scheduledPostsLastMonth = posts.filter(p => { const scheduledPostsLastMonth = stats.scheduledPostsLastMonth;
if (!p.createdAt) return false; const currentAiTexts = stats.aiTextsGenerated;
const createdDate = new Date(p.createdAt); const aiTextsYesterday = stats.aiTextsYesterday;
return p.status === "scheduled" && createdDate < lastMonth;
}).length;
// Calculer les variations en pourcentage // Calculer les variations en pourcentage
const aiTextChange = aiTextsYesterday > 0 const aiTextChange = aiTextsYesterday > 0
@@ -571,11 +594,11 @@ export async function registerRoutes(app: Express): Promise<Server> {
scheduledPosts, scheduledPosts,
scheduledPostsChange: scheduledPostsChange > 0 ? `+${scheduledPostsChange}%` : `${scheduledPostsChange}%`, scheduledPostsChange: scheduledPostsChange > 0 ? `+${scheduledPostsChange}%` : `${scheduledPostsChange}%`,
scheduledPostsTrending: scheduledPostsChange >= 0 ? "up" : "down", scheduledPostsTrending: scheduledPostsChange >= 0 ? "up" : "down",
connectedPages: pages.length, connectedPages: stats.connectedPages,
aiTextsGenerated: currentAiTexts, aiTextsGenerated: currentAiTexts,
aiTextsChange: aiTextChange > 0 ? `+${aiTextChange}%` : `${aiTextChange}%`, aiTextsChange: aiTextChange > 0 ? `+${aiTextChange}%` : `${aiTextChange}%`,
aiTextsTrending: aiTextChange >= 0 ? "up" : "down", aiTextsTrending: aiTextChange >= 0 ? "up" : "down",
mediaStored: media.length, mediaStored: stats.mediaStored,
}); });
} catch (error) { } catch (error) {
console.error("Error fetching stats:", error); console.error("Error fetching stats:", error);
@@ -613,7 +636,8 @@ export async function registerRoutes(app: Express): Promise<Server> {
res.json({ variants: generatedTexts }); res.json({ variants: generatedTexts });
} catch (error) { } catch (error) {
console.error("Error generating text:", error); console.error("Error generating text:", error);
res.status(500).json({ error: "Failed to generate text" }); const { status, message } = describeGenerationError(error);
res.status(status).json({ error: message });
} }
}); });
@@ -702,7 +726,19 @@ export async function registerRoutes(app: Express): Promise<Server> {
try { try {
const user = req.user as User; const user = req.user as User;
const userId = user.id; const userId = user.id;
const media = await storage.getMedia(userId);
// Sans paramètre, la médiathèque entière est renvoyée comme avant : les
// écrans qui doivent retrouver un média par son identifiant en dépendent.
const limit = parsePositiveInt(req.query.limit);
const offset = parsePositiveInt(req.query.offset);
const media = await storage.getMedia(userId, { limit, offset });
// Le total permet à l'appelant paginé de savoir s'il reste des pages —
// et à la médiathèque d'afficher un compteur juste sans tout charger.
if (limit !== undefined) {
res.setHeader("X-Total-Count", String(await storage.getMediaCount(userId)));
}
res.json(media); res.json(media);
} catch (error) { } catch (error) {
console.error("Error fetching media:", error); console.error("Error fetching media:", error);
@@ -1394,16 +1430,15 @@ export async function registerRoutes(app: Express): Promise<Server> {
const userId = user.id; const userId = user.id;
const { startDate, endDate } = req.query; const { startDate, endDate } = req.query;
const start = startDate ? new Date(startDate as string) : undefined; // Une date illisible est ignorée plutôt que transmise telle quelle à SQL.
const end = endDate ? new Date(endDate as string) : undefined; const start = parseDateParam(startDate);
const end = parseDateParam(endDate);
let scheduledPosts; let scheduledPosts;
if (user.role === 'admin') { if (user.role === 'admin') {
// Admin voit tous les posts programmés - on récupère toutes les pages // Admin voit tous les posts programmés - on récupère toutes les pages
const allPages = await storage.getAllUsers().then(users => const allPages = await storage.getAllSocialPages();
Promise.all(users.map(u => storage.getSocialPages(u.id)))
).then(pagesArrays => pagesArrays.flat());
const allPageIds = allPages.map(p => p.id); const allPageIds = allPages.map(p => p.id);
if (allPageIds.length > 0) { if (allPageIds.length > 0) {
@@ -1534,6 +1569,19 @@ export async function registerRoutes(app: Express): Promise<Server> {
}); });
// Social pages // Social pages
/**
* Vue client d'une page : sans jetons, avec un indicateur de renouvellement
* automatique (vrai quand un jeton utilisateur longue durée est enregistré).
*/
function toClientPage(page: SocialPage) {
const { accessToken, userAccessToken, refreshToken, ...safe } = page;
return {
...safe,
autoRenew: page.platform === 'tiktok' ? !!refreshToken : !!userAccessToken,
};
}
app.get("/api/pages", requireAuth, async (req, res) => { app.get("/api/pages", requireAuth, async (req, res) => {
try { try {
const user = req.user as User; const user = req.user as User;
@@ -1543,17 +1591,15 @@ export async function registerRoutes(app: Express): Promise<Server> {
if (user.role === 'admin') { if (user.role === 'admin') {
// Les admins voient toutes les pages de tous les utilisateurs // Les admins voient toutes les pages de tous les utilisateurs
const allUsers = await storage.getAllUsers(); pages = await storage.getAllSocialPages();
const allPages = await Promise.all(
allUsers.map(u => storage.getSocialPages(u.id))
);
pages = allPages.flat();
} else { } else {
// Les utilisateurs normaux voient uniquement les pages auxquelles ils ont accès // Les utilisateurs normaux voient uniquement les pages auxquelles ils ont accès
pages = await storage.getUserAccessiblePages(userId); pages = await storage.getUserAccessiblePages(userId);
} }
res.json(pages); // Les jetons n'ont rien à faire dans le navigateur : on ne renvoie que
// l'information dont l'UI a besoin, dont la capacité à se renouveler seule.
res.json(pages.map(toClientPage));
} catch (error) { } catch (error) {
console.error("Error fetching pages:", error); console.error("Error fetching pages:", error);
res.status(500).json({ error: "Failed to fetch pages" }); res.status(500).json({ error: "Failed to fetch pages" });
@@ -1565,6 +1611,14 @@ export async function registerRoutes(app: Express): Promise<Server> {
const user = req.user as User; const user = req.user as User;
const userId = user.id; const userId = user.id;
// TikTok n'autorise pas la saisie manuelle d'un token : le compte doit
// passer par le flux OAuth (/api/tiktok/connect).
if (req.body?.platform === 'tiktok') {
return res.status(400).json({
error: "Un compte TikTok se connecte via l'autorisation TikTok, pas par saisie manuelle d'un jeton.",
});
}
// Calculate token expiration date (60 days from now) // Calculate token expiration date (60 days from now)
const tokenExpiresAt = new Date(); const tokenExpiresAt = new Date();
tokenExpiresAt.setDate(tokenExpiresAt.getDate() + 60); tokenExpiresAt.setDate(tokenExpiresAt.getDate() + 60);
@@ -1575,7 +1629,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
tokenExpiresAt tokenExpiresAt
}); });
const page = await storage.createSocialPage(pageData); const page = await storage.createSocialPage(pageData);
res.json(page); res.json(toClientPage(page));
} catch (error) { } catch (error) {
console.error("Error creating page:", error); console.error("Error creating page:", error);
res.status(500).json({ error: "Failed to create page" }); res.status(500).json({ error: "Failed to create page" });
@@ -1598,7 +1652,8 @@ export async function registerRoutes(app: Express): Promise<Server> {
// If accessToken is being updated, recalculate expiration date (60 days from now) // If accessToken is being updated, recalculate expiration date (60 days from now)
// AND reset the status to valid so the UI updates immediately // AND reset the status to valid so the UI updates immediately
if (parsedData.accessToken) { // (les tokens TikTok ont leur propre cycle de vie, géré par le service OAuth)
if (parsedData.accessToken && existingPage.platform !== 'tiktok') {
const tokenExpiresAt = new Date(); const tokenExpiresAt = new Date();
tokenExpiresAt.setDate(tokenExpiresAt.getDate() + 60); tokenExpiresAt.setDate(tokenExpiresAt.getDate() + 60);
pageData = { pageData = {
@@ -1610,7 +1665,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
} }
const updatedPage = await storage.updateSocialPage(pageId, pageData); const updatedPage = await storage.updateSocialPage(pageId, pageData);
res.json(updatedPage); res.json(toClientPage(updatedPage));
} catch (error) { } catch (error) {
console.error("Error updating page:", error); console.error("Error updating page:", error);
res.status(500).json({ error: "Failed to update page" }); res.status(500).json({ error: "Failed to update page" });
+17 -18
View File
@@ -137,7 +137,7 @@ router.post("/publish", async (req, res) => {
instagramFeedUrl: uploaded.instagramFeedUrl, instagramFeedUrl: uploaded.instagramFeedUrl,
instagramStoryUrl: uploaded.instagramStoryUrl, instagramStoryUrl: uploaded.instagramStoryUrl,
fileName, fileName,
fileSize: String(buffer.length), fileSize: buffer.length,
}); });
} }
@@ -149,7 +149,7 @@ router.post("/publish", async (req, res) => {
content, content,
status: scheduledFor ? "scheduled" : "scheduled", status: scheduledFor ? "scheduled" : "scheduled",
scheduledFor: scheduledFor ?? new Date(), scheduledFor: scheduledFor ?? new Date(),
aiGenerated: false, aiGenerated: "false",
}); });
// Lier le média si présent // Lier le média si présent
@@ -270,9 +270,7 @@ router.get("/posts", async (req, res) => {
} }
pageIds = [pageId]; pageIds = [pageId];
} else { } else {
const users = await storage.getAllUsers(); pageIds = (await storage.getAllSocialPages()).map(p => p.id);
const allPages = await Promise.all(users.map(u => storage.getSocialPages(u.id)));
pageIds = allPages.flat().map(p => p.id);
} }
if (pageIds.length === 0) { if (pageIds.length === 0) {
@@ -312,19 +310,20 @@ router.get("/posts", async (req, res) => {
if (post.status !== postStatus && postStatus !== "all") continue; if (post.status !== postStatus && postStatus !== "all") continue;
if (!postMap.has(post.id)) { if (!postMap.has(post.id)) {
// Récupérer les médias du post // 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 postMediaLinks = await storage.getPostMedia(post.id);
const mediaItems: Array<{ id: string; url: string; type: string }> = []; const byId = new Map(
for (const link of postMediaLinks) { (await storage.getMediaByIds(postMediaLinks.map(link => link.mediaId)))
const mediaItem = await storage.getMediaById(link.mediaId); .map(item => [item.id, item])
if (mediaItem) { );
mediaItems.push({ const mediaItems = postMediaLinks
id: mediaItem.id, .map(link => byId.get(link.mediaId))
url: mediaItem.originalUrl, .filter((item): item is NonNullable<typeof item> => item !== undefined)
type: mediaItem.type, .map(item => ({
}); id: item.id,
} url: item.originalUrl,
} type: item.type,
}));
postMap.set(post.id, { postMap.set(post.id, {
id: post.id, id: post.id,
@@ -415,7 +414,7 @@ router.patch("/posts/:id", async (req, res) => {
instagramFeedUrl: uploaded.instagramFeedUrl, instagramFeedUrl: uploaded.instagramFeedUrl,
instagramStoryUrl: uploaded.instagramStoryUrl, instagramStoryUrl: uploaded.instagramStoryUrl,
fileName, fileName,
fileSize: String(buffer.length), fileSize: buffer.length,
}); });
// Remplacer les médias existants par le nouveau // Remplacer les médias existants par le nouveau
+300
View File
@@ -0,0 +1,300 @@
/**
* 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' });
}
});
+269
View File
@@ -0,0 +1,269 @@
/**
* 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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)} &ndash; 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> &middot; <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 &rsaquo; Paramètres et confidentialité &rsaquo;
Sécurité et autorisations &rsaquo; 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));
});
+62 -21
View File
@@ -7,13 +7,28 @@ import type { User, Media, SocialPage } from '@shared/schema';
import { storage } from '../storage'; import { storage } from '../storage';
import { ffmpegService } from '../services/ffmpeg'; import { ffmpegService } from '../services/ffmpeg';
import { facebookService } from '../services/facebook'; import { facebookService } from '../services/facebook';
import { tiktokService } from '../services/tiktok';
import { createVideoThumbnail } from '../services/thumbnail';
import { minioService as cloudinaryService, buildMinioUrl, resolveInternalUrl } from '../services/minio'; import { minioService as cloudinaryService, buildMinioUrl, resolveInternalUrl } from '../services/minio';
import { openRouterService } from '../services/openrouter'; import { openRouterService, describeGenerationError } from '../services/openrouter';
import { db } from '../db'; import { db } from '../db';
import { cloudinaryConfig } from '@shared/schema'; import { cloudinaryConfig } from '@shared/schema';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { ttsSyncService } from '../services/ttsSync'; import { ttsSyncService } 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(); export const reelsRouter = Router();
// ============================================ // ============================================
@@ -253,7 +268,8 @@ reelsRouter.post('/reels/generate-text', async (req: Request, res: Response) =>
res.json({ variants: generatedTexts }); res.json({ variants: generatedTexts });
} catch (error) { } catch (error) {
console.error('❌ Error generating Reel text:', error); console.error('❌ Error generating Reel text:', error);
res.status(500).json({ error: 'Erreur lors de la génération du texte' }); const { status, message } = describeGenerationError(error);
res.status(status).json({ error: message });
} }
}); });
@@ -614,6 +630,10 @@ async function processReelBackground(
console.log('✅ [Background] Uploaded:', cloudinaryResult.originalUrl); console.log('✅ [Background] Uploaded:', cloudinaryResult.originalUrl);
// 5. Créer l'enregistrement Media pour la vidéo traitée // 5. Créer l'enregistrement Media pour la vidéo traitée
// La vignette est extraite maintenant : la vidéo sera supprimée du disque
// après publication, mais l'historique doit rester illustré.
const thumbnailUrl = await createVideoThumbnail(videoBuffer);
const processedMedia = await storage.createMedia({ const processedMedia = await storage.createMedia({
userId: userId, userId: userId,
type: 'video', type: 'video',
@@ -622,6 +642,7 @@ async function processReelBackground(
facebookFeedUrl: cloudinaryResult.facebookFeedUrl || null, facebookFeedUrl: cloudinaryResult.facebookFeedUrl || null,
instagramFeedUrl: cloudinaryResult.instagramFeedUrl || null, instagramFeedUrl: cloudinaryResult.instagramFeedUrl || null,
instagramStoryUrl: cloudinaryResult.instagramStoryUrl || null, instagramStoryUrl: cloudinaryResult.instagramStoryUrl || null,
thumbnailUrl,
fileName: `reel-processed-${Date.now()}.mp4`, fileName: `reel-processed-${Date.now()}.mp4`,
fileSize: videoBuffer.length, fileSize: videoBuffer.length,
}); });
@@ -642,8 +663,12 @@ async function processReelBackground(
continue; continue;
} }
if (page.platform !== 'facebook') { if (page.platform !== 'facebook' && page.platform !== 'tiktok') {
results.push({ pageId, success: false, error: 'Seules les pages Facebook sont supportées' }); results.push({
pageId,
success: false,
error: `Plateforme non supportée pour les reels : ${page.platform}`,
});
continue; continue;
} }
@@ -656,23 +681,42 @@ async function processReelBackground(
}); });
if (!scheduledFor) { if (!scheduledFor) {
// Publication immédiate // Publication immédiate — la même vidéo part sur chaque destination
console.log(`🚀 [Background] Publishing to Page ${page.pageName}...`); console.log(`🚀 [Background] Publishing to ${page.platform} ${page.pageName}...`);
const finalDescription = description || overlayText || ''; const finalDescription = description || overlayText || '';
const reelId = await facebookService.publishReel( if (page.platform === 'facebook') {
page, const reelId = await facebookService.publishReel(
cloudinaryResult.originalUrl, page,
finalDescription cloudinaryResult.originalUrl,
); finalDescription
);
// Mise à jour succès // Mise à jour succès
await storage.updateScheduledPost(scheduledPost.id, { await storage.updateScheduledPost(scheduledPost.id, {
publishedAt: new Date(), publishedAt: new Date(),
externalPostId: reelId, externalPostId: reelId,
}); });
results.push({ pageId, success: true, reelId }); results.push({ pageId, success: true, reelId });
} else {
const publishId = await tiktokService.publishVideoFromBuffer(
page,
videoBuffer,
finalDescription
);
// TikTok finalise la publication de son côté : on marque l'envoi
// comme effectué pour ne pas republier, et le poller de statut
// renseignera l'identifiant définitif du post.
await storage.updateScheduledPost(scheduledPost.id, {
publishedAt: new Date(),
publishId,
publishStatus: 'PROCESSING_UPLOAD',
});
results.push({ pageId, success: true, reelId: publishId });
}
} else { } else {
// Planifié // Planifié
results.push({ pageId, success: true, reelId: 'scheduled' }); results.push({ pageId, success: true, reelId: 'scheduled' });
@@ -854,10 +898,7 @@ reelsRouter.get('/reels/config', async (req: Request, res: Response) => {
reelsRouter.get('/reels/ongoing', async (req: Request, res: Response) => { reelsRouter.get('/reels/ongoing', async (req: Request, res: Response) => {
try { try {
const user = req.user as User; const user = req.user as User;
const allPosts = await storage.getPosts(user.id); const ongoing = await storage.getOngoingReelPosts(user.id);
const ongoing = allPosts.filter(
(p) => p.generationStatus === 'processing' || p.generationStatus === 'pending'
);
res.json(ongoing); res.json(ongoing);
} catch (error) { } catch (error) {
console.error('❌ Error fetching ongoing reels:', error); console.error('❌ Error fetching ongoing reels:', error);
+31 -27
View File
@@ -2,34 +2,16 @@ import { Router } from "express";
import multer from "multer"; import multer from "multer";
import path from "path"; import path from "path";
import fs from "fs"; import fs from "fs";
import { exec } from "child_process";
import { promisify } from "util";
import { bundle } from "@remotion/bundler"; import { bundle } from "@remotion/bundler";
import { renderMedia, selectComposition } from "@remotion/renderer"; import { renderMedia, selectComposition } from "@remotion/renderer";
import { ffmpegService } from "../services/ffmpeg"; import { ffmpegService } from "../services/ffmpeg";
import { storage as dbStorage } from "../storage"; import { storage as dbStorage } from "../storage";
import { minioService as cloudinaryService, buildMinioUrl } from "../services/minio"; import { minioService as cloudinaryService, buildMinioUrl } from "../services/minio";
import { facebookService } from "../services/facebook"; import { facebookService } from "../services/facebook";
import { tiktokService } from "../services/tiktok";
import { generateVideoThumbnail, createVideoThumbnail } from "../services/thumbnail";
import * as musicMetadata from "music-metadata"; import * as musicMetadata from "music-metadata";
const execAsync = promisify(exec);
/**
* Generate a thumbnail from a video file using FFmpeg
* @param videoPath Path to the video file
* @param outputPath Path to save the thumbnail
* @param seekTime Time in seconds to extract the frame (default: 1s)
*/
async function generateVideoThumbnail(videoPath: string, outputPath: string, seekTime: number = 1): Promise<boolean> {
try {
const cmd = `ffmpeg -y -ss ${seekTime} -i "${videoPath}" -vframes 1 -q:v 2 "${outputPath}"`;
await execAsync(cmd);
return fs.existsSync(outputPath);
} catch (error) {
console.warn('⚠️ Failed to generate video thumbnail:', error);
return false;
}
}
export const remotionRouter = Router(); export const remotionRouter = Router();
@@ -325,10 +307,12 @@ remotionRouter.post("/render", upload.fields([{ name: "images", maxCount: 4 }, {
codec: "h264", codec: "h264",
outputLocation, outputLocation,
inputProps, inputProps,
// Ne pas y remettre "args" : Remotion ne lit jamais cette clé (elle est
// absente de ChromiumOptions et de son code), les drapeaux passés là
// n'atteignaient donc jamais le navigateur.
chromiumOptions: { chromiumOptions: {
disableWebSecurity: true, disableWebSecurity: true,
ignoreCertificateErrors: true, ignoreCertificateErrors: true,
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu"],
}, },
concurrency: 1, // Limit concurrency to prevent Docker memory exhaustion concurrency: 1, // Limit concurrency to prevent Docker memory exhaustion
}); });
@@ -407,6 +391,9 @@ remotionRouter.post("/publish", async (req, res) => {
}); });
// Create a Media record for the processed video // Create a Media record for the processed video
// Vignette extraite avant que la vidéo ne soit purgée du disque
const thumbnailUrl = await createVideoThumbnail(localPath);
const mediaRecord = await dbStorage.createMedia({ const mediaRecord = await dbStorage.createMedia({
userId: user.id, userId: user.id,
type: "video", type: "video",
@@ -415,6 +402,7 @@ remotionRouter.post("/publish", async (req, res) => {
facebookFeedUrl: null, facebookFeedUrl: null,
instagramFeedUrl: null, instagramFeedUrl: null,
instagramStoryUrl: null, instagramStoryUrl: null,
thumbnailUrl,
fileName: filename, fileName: filename,
fileSize: fs.statSync(localPath).size, fileSize: fs.statSync(localPath).size,
}); });
@@ -427,7 +415,10 @@ remotionRouter.post("/publish", async (req, res) => {
try { try {
const page = await dbStorage.getSocialPage(pageId); const page = await dbStorage.getSocialPage(pageId);
if (!page) { results.push({ pageId, success: false, error: "Page introuvable" }); continue; } if (!page) { results.push({ pageId, success: false, error: "Page introuvable" }); continue; }
if (page.platform !== "facebook") { results.push({ pageId, success: false, error: "Seules les pages Facebook sont supportées" }); 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 dbStorage.createScheduledPost({ const scheduledPost = await dbStorage.createScheduledPost({
postId: post.id, postId: post.id,
@@ -437,12 +428,25 @@ remotionRouter.post("/publish", async (req, res) => {
}); });
if (!scheduledFor) { if (!scheduledFor) {
console.log(`🚀 Publishing to ${page.pageName} (direct binary upload)...`); console.log(`🚀 Publishing to ${page.platform} ${page.pageName} (direct binary upload)...`);
// Upload video bytes directly — Facebook cannot access local URLs // Upload video bytes directly — les APIs ne peuvent pas lire une URL locale
const videoBuffer = await fs.promises.readFile(localPath); const videoBuffer = await fs.promises.readFile(localPath);
const reelId = await facebookService.publishVideoFromBuffer(page, videoBuffer, description || "");
await dbStorage.updateScheduledPost(scheduledPost.id, { publishedAt: new Date(), externalPostId: reelId }); if (page.platform === "facebook") {
results.push({ pageId, success: true, reelId }); const reelId = await facebookService.publishVideoFromBuffer(page, videoBuffer, description || "");
await dbStorage.updateScheduledPost(scheduledPost.id, { publishedAt: new Date(), externalPostId: reelId });
results.push({ pageId, success: true, reelId });
} else {
// TikTok finalise la publication de son côté : le poller de statut
// renseignera l'identifiant définitif du post.
const publishId = await tiktokService.publishVideoFromBuffer(page, videoBuffer, description || "");
await dbStorage.updateScheduledPost(scheduledPost.id, {
publishedAt: new Date(),
publishId,
publishStatus: "PROCESSING_UPLOAD",
});
results.push({ pageId, success: true, reelId: publishId });
}
} else { } else {
results.push({ pageId, success: true, reelId: "scheduled" }); results.push({ pageId, success: true, reelId: "scheduled" });
} }
+226
View File
@@ -0,0 +1,226 @@
/**
* 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',
});
}
});
+5 -1
View File
@@ -48,6 +48,8 @@ export class AnalyticsService {
for (const instance of publishedInstances) { for (const instance of publishedInstances) {
if (!instance.externalPostId || !instance.page) continue; 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 { try {
// We need the page access token // We need the page access token
@@ -278,7 +280,9 @@ export class AnalyticsService {
*/ */
static async syncAllPages(): Promise<void> { static async syncAllPages(): Promise<void> {
console.log('[AnalyticsService] Starting sync for all pages...'); console.log('[AnalyticsService] Starting sync for all pages...');
const allPages = await db.query.socialPages.findMany(); // 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) { for (const page of allPages) {
try { try {
+295
View File
@@ -0,0 +1,295 @@
/**
* 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();
+6
View File
@@ -43,6 +43,12 @@ export async function purgeOldMedia() {
if (item.cloudinaryPublicId) { if (item.cloudinaryPublicId) {
await storageService.deleteMedia(item.cloudinaryPublicId, item.userId, item.type === 'video' ? 'video' : 'image'); 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)); await db.delete(media).where(eq(media.id, item.id));
successCount++; successCount++;
} catch (err) { } catch (err) {
+36
View File
@@ -40,6 +40,27 @@ export function resolveInternalUrl(url: string): string {
return `${base}${url}`; 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 { class LocalStorageService {
async uploadMedia( async uploadMedia(
file: Buffer | string, file: Buffer | string,
@@ -85,6 +106,21 @@ class LocalStorageService {
return buildMinioUrl('', objectKey); 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 }> { async uploadLogo(buffer: Buffer, fileName: string): Promise<{ publicId: string; url: string }> {
const dir = path.join(UPLOADS_BASE, 'logos'); const dir = path.join(UPLOADS_BASE, 'logos');
ensureDir(dir); ensureDir(dir);
+50 -1
View File
@@ -15,6 +15,49 @@ interface GeneratedText {
import crypto from 'crypto'; import crypto from 'crypto';
import { storage } from '../storage'; 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 { export class OpenRouterService {
private baseUrl = "https://openrouter.ai/api/v1/chat/completions"; private baseUrl = "https://openrouter.ai/api/v1/chat/completions";
@@ -64,7 +107,13 @@ export class OpenRouterService {
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
console.error('OpenRouter API Error Response:', errorText); console.error('OpenRouter API Error Response:', errorText);
throw new Error(`OpenRouter API error: ${response.status} ${response.statusText}`); // 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(); const data = await response.json();
+45 -13
View File
@@ -1,7 +1,8 @@
import cron, { ScheduledTask } from "node-cron"; import cron, { ScheduledTask } from "node-cron";
import { storage } from "../storage"; import { storage } from "../storage";
import { facebookService } from "./facebook"; import { facebookService } from "./facebook";
import { minioService as storageService } from "./minio"; import { tiktokService } from "./tiktok";
import { minioService as storageService, readMediaBuffer } from "./minio";
export class SchedulerService { export class SchedulerService {
private task: ScheduledTask | null = null; private task: ScheduledTask | null = null;
@@ -94,11 +95,28 @@ export class SchedulerService {
return; return;
} }
// Publish to Facebook/Instagram // Publish to Facebook/Instagram/TikTok
let externalPostId: string;
if (page.platform === 'facebook') { if (page.platform === 'facebook') {
externalPostId = await facebookService.publishPost(post, page, scheduledPost.postType, mediaList); 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') { } else if (page.platform === 'instagram') {
// Instagram publishing not yet implemented // Instagram publishing not yet implemented
console.warn(`Instagram publishing not yet implemented for post ${post.id}`); console.warn(`Instagram publishing not yet implemented for post ${post.id}`);
@@ -113,13 +131,6 @@ export class SchedulerService {
throw new Error(`Unsupported platform: ${page.platform}`); throw new Error(`Unsupported platform: ${page.platform}`);
} }
// Update the scheduled post as published (clear any previous error)
await storage.updateScheduledPost(scheduledPost.id, {
publishedAt: new Date(),
externalPostId,
error: null,
});
// Update post status // Update post status
await storage.updatePost(post.id, { await storage.updatePost(post.id, {
status: "published", status: "published",
@@ -143,7 +154,28 @@ export class SchedulerService {
} }
} }
console.log(`Successfully published post ${post.id} with external ID: ${externalPostId}`); 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);
} }
} }
+99
View File
@@ -0,0 +1,99 @@
/**
* 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 { exec } from 'child_process';
import { promisify } from 'util';
import { minioService } from './minio';
const execAsync = promisify(exec);
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 {
const cmd = `ffmpeg -y -ss ${seekTime} -i "${videoPath}" -vframes 1 -q:v 2 "${outputPath}"`;
await execAsync(cmd);
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 */ });
}
}
}
}
+574
View File
@@ -0,0 +1,574 @@
/**
* 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();
+286 -34
View File
@@ -1,10 +1,31 @@
// crypto removed, using shared encryption utility // crypto removed, using shared encryption utility
import { db } from '../db'; import { db } from '../db';
import { socialPages, tokenStatusEnum } from '../../shared/schema'; import { socialPages } from '../../shared/schema';
import { eq, and, lte } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { GraphAPIClient } from '../utils/graph_client'; import { GraphAPIClient } from '../utils/graph_client';
import type { SocialPage } from '../../shared/schema';
import { encrypt, decrypt } from '../utils/encryption'; 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 * Service responsible for managing Facebook Page Token lifecycle
@@ -30,8 +51,9 @@ export class TokenManager {
/** /**
* Checks all active pages for expiring tokens and refreshes them if needed. * Checks all active pages for expiring tokens and refreshes them if needed.
*/ */
static async checkAndRefreshTokens() { static async checkAndRefreshTokens(): Promise<TokenCheckResult[]> {
console.log('[TokenManager] Starting daily token check...'); console.log('[TokenManager] Starting daily token check...');
const results: TokenCheckResult[] = [];
try { try {
// Fetch active pages // Fetch active pages
@@ -41,15 +63,18 @@ export class TokenManager {
for (const page of pages) { for (const page of pages) {
try { try {
await this.processPageToken(page); results.push(await this.processPageToken(page));
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[TokenManager] Error processing page ${page.pageName} (${page.id}):`, error); console.error(`[TokenManager] Error processing page ${page.pageName} (${page.id}):`, error);
await db.update(socialPages) await this.persistStatus(page.id, 'error', message);
.set({ results.push({
tokenStatus: "error", pageId: page.id,
lastTokenCheck: new Date() pageName: page.pageName,
}) status: 'error',
.where(eq(socialPages.id, page.id)); renewed: false,
error: message,
});
} }
} }
@@ -57,39 +82,266 @@ export class TokenManager {
} catch (e) { } catch (e) {
console.error('[TokenManager] Critical error in checkAndRefreshTokens:', e); console.error('[TokenManager] Critical error in checkAndRefreshTokens:', e);
} }
return results;
} }
private static async processPageToken(page: any) { /** Contrôle (et renouvellement) d'une seule page, déclenché depuis l'UI. */
let accessToken = page.accessToken; 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);
}
// Attempt to decrypt. If fails, assume it's plain text (migration phase) private static async processPageToken(page: SocialPage): Promise<TokenCheckResult> {
try { // Les comptes TikTok ont leur propre cycle de vie : l'access token ne dure
if (accessToken.includes(':')) { // que 24h et se renouvelle avec le refresh token, pas via la Graph API.
accessToken = this.decrypt(accessToken); if (page.platform === 'tiktok') {
} return await this.processTiktokToken(page);
} catch (e) {
// Not encrypted or format error, proceed with original
} }
console.log(`[TokenManager] Checking page: ${page.pageName}`); 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); const isValid = await this.verifyToken(accessToken);
if (isValid) { if (!isValid) {
await db.update(socialPages) const error =
.set({ "Le jeton n'est plus accepté par Facebook. Configurez l'application Facebook " +
tokenStatus: "valid", 'dans les paramètres pour activer le renouvellement automatique, ou saisissez un nouveau jeton.';
lastTokenCheck: new Date() await this.persistStatus(page.id, 'expired', error);
}) return { pageId: page.id, pageName: page.pageName, status: 'expired', renewed: false, error };
.where(eq(socialPages.id, page.id)); }
} else {
await db.update(socialPages) // Le jeton répond : reste l'échéance déclarée à l'ajout de la page.
.set({ const declared = page.tokenExpiresAt ? new Date(page.tokenExpiresAt).getTime() : null;
tokenStatus: "expired", const status = declared !== null && declared - Date.now() < WARNING_WINDOW_MS ? 'expiring' : 'valid';
lastTokenCheck: new Date() await this.persistStatus(page.id, status, null);
}) return { pageId: page.id, pageName: page.pageName, status, renewed: false };
.where(eq(socialPages.id, page.id)); }
console.warn(`[TokenManager] Token expired for ${page.pageName}`);
/**
* 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;
} }
} }
+313 -83
View File
@@ -36,29 +36,108 @@ import {
type InsertAudioTrack, type InsertAudioTrack,
appConfig, appConfig,
type AppConfig, type AppConfig,
tiktokConfig,
type TiktokConfig,
type InsertTiktokConfig,
facebookConfig,
type FacebookConfig,
type InsertFacebookConfig,
} from "@shared/schema"; } from "@shared/schema";
import { db } from "./db"; import { db } from "./db";
import { eq, and, gte, lte, desc, asc, isNull, inArray } from "drizzle-orm"; import { eq, and, gte, lte, desc, asc, isNull, isNotNull, inArray, notInArray, getTableColumns, sql, type SQL } from "drizzle-orm";
import { encrypt, decrypt, isEncrypted } from "./utils/encryption"; 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 { export interface IStorage {
// Users // Users
getUser(id: string): Promise<User | undefined>; getUser(id: string): Promise<User | undefined>;
getUserByUsername(username: string): Promise<User | undefined>; getUserByUsername(username: string): Promise<User | undefined>;
getAllUsers(): Promise<User[]>; getAllUsers(): Promise<User[]>;
getDashboardStats(userId: string, options: DashboardStatsRange): Promise<DashboardStats>;
createUser(user: InsertUser): Promise<User>; createUser(user: InsertUser): Promise<User>;
updateUser(id: string, user: Partial<InsertUser>): Promise<User>; updateUser(id: string, user: Partial<InsertUser>): Promise<User>;
deleteUser(id: string): Promise<void>; deleteUser(id: string): Promise<void>;
// Social Pages // Social Pages
getSocialPages(userId: string): Promise<SocialPage[]>; getSocialPages(userId: string): Promise<SocialPage[]>;
getAllSocialPages(): Promise<SocialPage[]>;
getSocialPage(id: string): Promise<SocialPage | undefined>; getSocialPage(id: string): Promise<SocialPage | undefined>;
createSocialPage(page: InsertSocialPage): Promise<SocialPage>; createSocialPage(page: InsertSocialPage): Promise<SocialPage>;
updateSocialPage(id: string, page: Partial<SocialPage>): Promise<SocialPage>; updateSocialPage(id: string, page: Partial<SocialPage>): Promise<SocialPage>;
deleteSocialPage(id: string): Promise<void>; deleteSocialPage(id: string): Promise<void>;
// Media // Media
getMedia(userId: string): Promise<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>; getMediaById(id: string): Promise<Media | undefined>;
createMedia(media: InsertMedia): Promise<Media>; createMedia(media: InsertMedia): Promise<Media>;
deleteMedia(id: string): Promise<void>; deleteMedia(id: string): Promise<void>;
@@ -68,6 +147,7 @@ export interface IStorage {
getPosts(userId: string): Promise<Post[]>; getPosts(userId: string): Promise<Post[]>;
getPost(id: string): Promise<Post | undefined>; getPost(id: string): Promise<Post | undefined>;
getPostWithMedia(id: string): Promise<{ post: Post; media: Media[] } | undefined>; getPostWithMedia(id: string): Promise<{ post: Post; media: Media[] } | undefined>;
getOngoingReelPosts(userId: string): Promise<Post[]>;
createPost(post: InsertPost): Promise<Post>; createPost(post: InsertPost): Promise<Post>;
updatePost(id: string, post: Partial<InsertPost>): Promise<Post>; updatePost(id: string, post: Partial<InsertPost>): Promise<Post>;
updatePostGenerationStatus(id: string, status: string, progress: number, error?: string): Promise<Post>; updatePostGenerationStatus(id: string, status: string, progress: number, error?: string): Promise<Post>;
@@ -85,11 +165,18 @@ export interface IStorage {
updateScheduledPost(id: string, scheduledPost: Partial<ScheduledPost>): Promise<ScheduledPost>; updateScheduledPost(id: string, scheduledPost: Partial<ScheduledPost>): Promise<ScheduledPost>;
deleteScheduledPost(id: string): Promise<void>; deleteScheduledPost(id: string): Promise<void>;
getPendingScheduledPosts(): Promise<ScheduledPost[]>; getPendingScheduledPosts(): Promise<ScheduledPost[]>;
getScheduledPostsAwaitingPublishStatus(): Promise<ScheduledPost[]>;
// AI Generations // AI Generations
getAiGenerations(userId: string): Promise<AiGeneration[]>; getAiGenerations(userId: string): Promise<AiGeneration[]>;
createAiGeneration(generation: InsertAiGeneration): 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 // Cloudinary Config
getCloudinaryConfig(): Promise<CloudinaryConfig | undefined>; getCloudinaryConfig(): Promise<CloudinaryConfig | undefined>;
createCloudinaryConfig(config: InsertCloudinaryConfig): Promise<CloudinaryConfig>; createCloudinaryConfig(config: InsertCloudinaryConfig): Promise<CloudinaryConfig>;
@@ -157,46 +244,85 @@ export class DatabaseStorage implements IStorage {
await db.delete(users).where(eq(users.id, id)); 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 // Social Pages
async getSocialPages(userId: string): Promise<SocialPage[]> { async getSocialPages(userId: string): Promise<SocialPage[]> {
const pages = await db.select().from(socialPages).where(eq(socialPages.userId, userId)); const pages = await db.select().from(socialPages).where(eq(socialPages.userId, userId));
// Déchiffrer les tokens pour chaque page return pages.map(page => decryptPageTokens(page));
return pages.map(page => ({ }
...page,
accessToken: decrypt(page.accessToken) /**
})); * 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> { async getSocialPage(id: string): Promise<SocialPage | undefined> {
const [page] = await db.select().from(socialPages).where(eq(socialPages.id, id)); const [page] = await db.select().from(socialPages).where(eq(socialPages.id, id));
if (page) { return page ? decryptPageTokens(page) : undefined;
// Déchiffrer le token
page.accessToken = decrypt(page.accessToken);
}
return page || undefined;
} }
async createSocialPage(page: InsertSocialPage): Promise<SocialPage> { async createSocialPage(page: InsertSocialPage): Promise<SocialPage> {
// Chiffrer le token avant stockage // Chiffrer les tokens avant stockage
const encryptedPage = { const encryptedPage = {
...page, ...page,
accessToken: encrypt(page.accessToken) accessToken: encrypt(page.accessToken),
...(page.refreshToken ? { refreshToken: encrypt(page.refreshToken) } : {}),
}; };
const [newPage] = await db.insert(socialPages).values(encryptedPage).returning(); const [newPage] = await db.insert(socialPages).values(encryptedPage).returning();
// Retourner avec le token déchiffré return decryptPageTokens(newPage);
newPage.accessToken = decrypt(newPage.accessToken);
return newPage;
} }
async updateSocialPage(id: string, page: Partial<SocialPage>): Promise<SocialPage> { async updateSocialPage(id: string, page: Partial<SocialPage>): Promise<SocialPage> {
// Chiffrer le token si présent dans la mise à jour // Chiffrer les tokens présents dans la mise à jour
const updateData = page.accessToken const updateData: Partial<SocialPage> = { ...page };
? { ...page, accessToken: encrypt(page.accessToken) } if (page.accessToken) {
: page; 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(); const [updated] = await db.update(socialPages).set(updateData).where(eq(socialPages.id, id)).returning();
// Retourner avec le token déchiffré return decryptPageTokens(updated);
updated.accessToken = decrypt(updated.accessToken);
return updated;
} }
async deleteSocialPage(id: string): Promise<void> { async deleteSocialPage(id: string): Promise<void> {
@@ -204,8 +330,37 @@ export class DatabaseStorage implements IStorage {
} }
// Media // Media
async getMedia(userId: string): Promise<Media[]> { /**
return await db.select().from(media).where(eq(media.userId, userId)).orderBy(desc(media.createdAt)); * 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> { async getMediaById(id: string): Promise<Media | undefined> {
@@ -241,14 +396,16 @@ export class DatabaseStorage implements IStorage {
if (!post) return undefined; if (!post) return undefined;
const postMediaLinks = await this.getPostMedia(id); const postMediaLinks = await this.getPostMedia(id);
const mediaItems: Media[] = [];
for (const link of postMediaLinks) { // Une seule requête pour tous les médias, puis remise dans l'ordre
const mediaItem = await this.getMediaById(link.mediaId); // d'affichage : une requête par média rendait le coût proportionnel au
if (mediaItem) { // nombre de visuels du post.
mediaItems.push(mediaItem); 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 }; return { post, media: mediaItems };
} }
@@ -322,67 +479,42 @@ export class DatabaseStorage implements IStorage {
// Scheduled Posts // Scheduled Posts
async getScheduledPosts(userId: string, startDate?: Date, endDate?: Date): Promise<any[]> { async getScheduledPosts(userId: string, startDate?: Date, endDate?: Date): Promise<any[]> {
let query = db return this.queryScheduledPosts(eq(posts.userId, userId), startDate, endDate);
.select()
.from(scheduledPosts)
.innerJoin(posts, eq(scheduledPosts.postId, posts.id))
.leftJoin(socialPages, eq(scheduledPosts.pageId, socialPages.id))
.where(eq(posts.userId, userId));
if (startDate && endDate) {
const results = await query;
return results
.filter(r => {
const scheduledAt = new Date(r.scheduled_posts.scheduledAt);
return scheduledAt >= startDate && scheduledAt <= endDate;
})
.map(r => ({
...r.scheduled_posts,
post: r.posts,
page: r.social_pages,
}));
}
const results = await query;
return results.map(r => ({
...r.scheduled_posts,
post: r.posts,
page: r.social_pages,
}));
} }
async getScheduledPostsByPages(pageIds: string[], startDate?: Date, endDate?: Date): Promise<any[]> { async getScheduledPostsByPages(pageIds: string[], startDate?: Date, endDate?: Date): Promise<any[]> {
if (pageIds.length === 0) { if (pageIds.length === 0) {
return []; return [];
} }
return this.queryScheduledPosts(inArray(scheduledPosts.pageId, pageIds), startDate, endDate);
}
let query = db /**
.select() * 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) .from(scheduledPosts)
.innerJoin(posts, eq(scheduledPosts.postId, posts.id)) .innerJoin(posts, eq(scheduledPosts.postId, posts.id))
.leftJoin(socialPages, eq(scheduledPosts.pageId, socialPages.id)) .leftJoin(socialPages, eq(scheduledPosts.pageId, socialPages.id))
.where(inArray(scheduledPosts.pageId, pageIds)); .where(conditions.length === 1 ? conditions[0] : and(...conditions))
.orderBy(desc(scheduledPosts.scheduledAt));
if (startDate && endDate) { // La jointure externe renvoie un objet aux champs nuls quand la page a été
const results = await query; // supprimée ; les appelants attendent `page: null` dans ce cas.
return results return rows.map(row => ({ ...row, page: row.page?.id ? row.page : null }));
.filter(r => {
const scheduledAt = new Date(r.scheduled_posts.scheduledAt);
return scheduledAt >= startDate && scheduledAt <= endDate;
})
.map(r => ({
...r.scheduled_posts,
post: r.posts,
page: r.social_pages,
}));
}
const results = await query;
return results.map(r => ({
...r.scheduled_posts,
post: r.posts,
page: r.social_pages,
}));
} }
async getScheduledPost(id: string): Promise<ScheduledPost | undefined> { async getScheduledPost(id: string): Promise<ScheduledPost | undefined> {
@@ -422,6 +554,40 @@ export class DatabaseStorage implements IStorage {
return results; 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. Filtré en SQL : la route chargeait
* tous les posts de l'utilisateur pour n'en garder qu'une poignée, toutes les
* trois secondes.
*/
async getOngoingReelPosts(userId: string): Promise<Post[]> {
return await db
.select()
.from(posts)
.where(
and(
eq(posts.userId, userId),
inArray(posts.generationStatus, ['processing', 'pending'])
)
)
.orderBy(desc(posts.createdAt));
}
// AI Generations // AI Generations
async getAiGenerations(userId: string): Promise<AiGeneration[]> { async getAiGenerations(userId: string): Promise<AiGeneration[]> {
return await db.select().from(aiGenerations).where(eq(aiGenerations.userId, userId)).orderBy(desc(aiGenerations.createdAt)); return await db.select().from(aiGenerations).where(eq(aiGenerations.userId, userId)).orderBy(desc(aiGenerations.createdAt));
@@ -571,6 +737,70 @@ export class DatabaseStorage implements IStorage {
return config || undefined; 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'>>): Promise<AppConfig> { async upsertAppConfig(data: Partial<Pick<AppConfig, 'externalApiKey' | 'geminiApiKey'>>): Promise<AppConfig> {
const existing = await this.getAppConfig(); const existing = await this.getAppConfig();
if (existing) { if (existing) {
+47
View File
@@ -0,0 +1,47 @@
/**
* 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;
}
+94 -1
View File
@@ -4,7 +4,7 @@ import { relations } from "drizzle-orm";
import { createInsertSchema } from "drizzle-zod"; import { createInsertSchema } from "drizzle-zod";
import { z } from "zod"; import { z } from "zod";
export const platformEnum = pgEnum("platform", ["facebook", "instagram"]); export const platformEnum = pgEnum("platform", ["facebook", "instagram", "tiktok"]);
export const postTypeEnum = pgEnum("post_type", ["feed", "story", "both", "reel"]); export const postTypeEnum = pgEnum("post_type", ["feed", "story", "both", "reel"]);
export const postStatusEnum = pgEnum("post_status", ["draft", "scheduled", "published", "failed"]); export const postStatusEnum = pgEnum("post_status", ["draft", "scheduled", "published", "failed"]);
export const mediaTypeEnum = pgEnum("media_type", ["image", "video"]); export const mediaTypeEnum = pgEnum("media_type", ["image", "video"]);
@@ -26,6 +26,7 @@ export const socialPages = pgTable("social_pages", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`), id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
platform: platformEnum("platform").notNull(), platform: platformEnum("platform").notNull(),
// Identifiant natif de la destination : page_id (Facebook) ou open_id (TikTok)
pageId: text("page_id").notNull(), pageId: text("page_id").notNull(),
pageName: text("page_name").notNull(), pageName: text("page_name").notNull(),
accessToken: text("access_token").notNull(), accessToken: text("access_token").notNull(),
@@ -37,6 +38,22 @@ export const socialPages = pgTable("social_pages", {
tokenStatus: tokenStatusEnum("token_status").default("valid"), tokenStatus: tokenStatusEnum("token_status").default("valid"),
lastTokenCheck: timestamp("last_token_check"), lastTokenCheck: timestamp("last_token_check"),
// Jeton utilisateur longue durée (~60 j) obtenu via OAuth Facebook, chiffré au
// repos. C'est lui qui permet de régénérer un token de page sans intervention
// humaine : sans lui, un token révoqué impose une reconnexion manuelle.
userAccessToken: text("user_access_token"),
userTokenExpiresAt: timestamp("user_token_expires_at"),
// Dernière erreur rencontrée lors du contrôle du token, affichée dans l'UI
// pour que l'utilisateur sache quoi faire (reconnecter, réautoriser…).
tokenError: text("token_error"),
// OAuth TikTok : l'access token n'est valable que 24h, il doit être renouvelé
// avec le refresh token (valable 1 an) avant chaque publication.
refreshToken: text("refresh_token"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scopes: text("scopes"),
avatarUrl: text("avatar_url"),
createdAt: timestamp("created_at").defaultNow(), createdAt: timestamp("created_at").defaultNow(),
}); });
@@ -85,6 +102,7 @@ export const insertSocialPageSchema = createInsertSchema(socialPages).omit({
createdAt: true, createdAt: true,
tokenStatus: true, // Managed by System tokenStatus: true, // Managed by System
lastTokenCheck: true, // Managed by System lastTokenCheck: true, // Managed by System
tokenError: true, // Managed by System
}); });
export const insertPostAnalyticsSchema = createInsertSchema(postAnalytics).omit({ export const insertPostAnalyticsSchema = createInsertSchema(postAnalytics).omit({
@@ -133,6 +151,30 @@ export const openrouterConfig = pgTable("openrouter_config", {
updatedAt: timestamp("updated_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(),
}); });
// Application développeur TikTok (globale) : un seul client_key pour toute
// l'instance, chaque magasin autorise ensuite son propre compte via OAuth.
/**
* Application développeur Facebook (globale à l'instance). Elle rend possible le
* renouvellement automatique des tokens de page : sans app id / app secret, on
* ne peut ni échanger un code OAuth, ni prolonger un jeton, ni interroger
* /debug_token.
*/
export const facebookConfig = pgTable("facebook_config", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
appId: text("app_id").notNull(),
appSecret: text("app_secret").notNull(),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
export const tiktokConfig = pgTable("tiktok_config", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
clientKey: text("client_key").notNull(),
clientSecret: text("client_secret").notNull(),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
export const media = pgTable("media", { export const media = pgTable("media", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`), id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
@@ -143,6 +185,9 @@ export const media = pgTable("media", {
facebookFeedUrl: text("facebook_feed_url"), facebookFeedUrl: text("facebook_feed_url"),
instagramFeedUrl: text("instagram_feed_url"), instagramFeedUrl: text("instagram_feed_url"),
instagramStoryUrl: text("instagram_story_url"), instagramStoryUrl: text("instagram_story_url"),
// Image extraite d'une vidéo, conservée après la suppression du fichier source
// pour que l'historique reste illustré.
thumbnailUrl: text("thumbnail_url"),
fileName: text("file_name").notNull(), fileName: text("file_name").notNull(),
fileSize: integer("file_size").notNull(), fileSize: integer("file_size").notNull(),
createdAt: timestamp("created_at").defaultNow(), createdAt: timestamp("created_at").defaultNow(),
@@ -180,6 +225,11 @@ export const scheduledPosts = pgTable("scheduled_posts", {
publishedAt: timestamp("published_at"), publishedAt: timestamp("published_at"),
externalPostId: text("external_post_id"), externalPostId: text("external_post_id"),
error: text("error"), error: text("error"),
// TikTok publie de façon asynchrone : l'upload rend un publish_id, et le
// post_id définitif n'est connu qu'après interrogation du statut.
publishId: text("publish_id"),
publishStatus: text("publish_status"),
}); });
export const aiGenerations = pgTable("ai_generations", { export const aiGenerations = pgTable("ai_generations", {
@@ -406,6 +456,32 @@ export const updateCloudinaryConfigSchema = insertCloudinaryConfigSchema.partial
apiSecret: true, apiSecret: true,
}); });
export const insertFacebookConfigSchema = createInsertSchema(facebookConfig).omit({
id: true,
createdAt: true,
updatedAt: true,
}).extend({
appId: z.string().trim().min(1, "L'App ID ne peut pas être vide"),
appSecret: z.string().trim().min(1, "L'App Secret ne peut pas être vide"),
});
export const updateFacebookConfigSchema = insertFacebookConfigSchema.partial({
appSecret: true,
});
export const insertTiktokConfigSchema = createInsertSchema(tiktokConfig).omit({
id: true,
createdAt: true,
updatedAt: true,
}).extend({
clientKey: z.string().trim().min(1, "Le client key ne peut pas être vide"),
clientSecret: z.string().trim().min(1, "Le client secret ne peut pas être vide"),
});
export const updateTiktokConfigSchema = insertTiktokConfigSchema.partial({
clientSecret: true,
});
export const insertUserPagePermissionSchema = createInsertSchema(userPagePermissions).omit({ export const insertUserPagePermissionSchema = createInsertSchema(userPagePermissions).omit({
id: true, id: true,
createdAt: true, createdAt: true,
@@ -465,4 +541,21 @@ export type InsertAudioTrack = z.infer<typeof insertAudioTrackSchema>;
export type AppConfig = typeof appConfig.$inferSelect; export type AppConfig = typeof appConfig.$inferSelect;
/**
* Page telle qu'elle est renvoyée au navigateur : les jetons restent au serveur,
* `autoRenew` dit simplement si la page sait se renouveler toute seule.
*/
export type ClientSocialPage = Omit<
SocialPage,
'accessToken' | 'userAccessToken' | 'refreshToken'
> & { autoRenew: boolean };
export type FacebookConfig = typeof facebookConfig.$inferSelect;
export type InsertFacebookConfig = z.infer<typeof insertFacebookConfigSchema>;
export type UpdateFacebookConfig = z.infer<typeof updateFacebookConfigSchema>;
export type TiktokConfig = typeof tiktokConfig.$inferSelect;
export type InsertTiktokConfig = z.infer<typeof insertTiktokConfigSchema>;
export type UpdateTiktokConfig = z.infer<typeof updateTiktokConfigSchema>;