From b95cd23ebcb8fe686ddf3b5f6557ee7e369687a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 14:29:38 +0000 Subject: [PATCH 1/6] feat(tiktok): publier les reels sur les comptes TikTok des magasins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_018uUPZ1GNpEkYB1dpXHYPek --- .env.example | 18 + client/src/components/calendar-list-view.tsx | 4 +- client/src/components/calendar-view.tsx | 6 +- client/src/components/managed-pages.tsx | 8 +- client/src/components/recent-publications.tsx | 7 +- client/src/pages/mobile/new-post.tsx | 5 +- client/src/pages/mobile/new-reel.tsx | 57 +- client/src/pages/mobile/pages.tsx | 114 +++- client/src/pages/mobile/remotion-video.tsx | 65 +- client/src/pages/new-post.tsx | 5 +- client/src/pages/new-reel.tsx | 77 ++- client/src/pages/pages.tsx | 112 +++- client/src/pages/remotion-video.tsx | 62 +- client/src/pages/settings.tsx | 107 ++++ server/cron.ts | 11 + server/migrate.ts | 30 + server/routes.ts | 15 +- server/routes/reels.ts | 54 +- server/routes/remotion.ts | 29 +- server/routes/tiktok.ts | 226 +++++++ server/services/analytics.ts | 6 +- server/services/minio.ts | 21 + server/services/scheduler.ts | 58 +- server/services/tiktok.ts | 568 ++++++++++++++++++ server/services/token_manager.ts | 36 ++ server/storage.ts | 119 +++- shared/schema.ts | 42 +- 27 files changed, 1679 insertions(+), 183 deletions(-) create mode 100644 server/routes/tiktok.ts create mode 100644 server/services/tiktok.ts diff --git a/.env.example b/.env.example index cfca62a..a200c81 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,24 @@ MINIO_PUBLIC_URL=https://media.example.com # INSTAGRAM_APP_ID= # 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 +# 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= + # ========================================== # API EXTERNE # ========================================== diff --git a/client/src/components/calendar-list-view.tsx b/client/src/components/calendar-list-view.tsx index 9cb2fad..18e450e 100644 --- a/client/src/components/calendar-list-view.tsx +++ b/client/src/components/calendar-list-view.tsx @@ -1,7 +1,7 @@ import { format, isSameDay, parseISO } from "date-fns"; import { fr } from "date-fns/locale"; 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 { useState } from "react"; @@ -159,7 +159,7 @@ export default function CalendarListView({ scheduledPosts, onEditPost, onDeleteP const isPublished = !!post.publishedAt; const pageName = post.page?.pageName || 'Page inconnue'; const platform = post.page?.platform || 'facebook'; - const PlatformIcon = platform === 'instagram' ? SiInstagram : SiFacebook; + const PlatformIcon = platform === 'instagram' ? SiInstagram : platform === 'tiktok' ? SiTiktok : SiFacebook; return (
{page.platform === "facebook" ? ( + ) : page.platform === "tiktok" ? ( + ) : ( )} @@ -65,7 +67,7 @@ export default function ManagedPages() {

{page.pageName}

- {page.platform === "facebook" ? "Facebook Page" : "Instagram Business"} + {page.platform === "facebook" ? "Facebook Page" : page.platform === "tiktok" ? "Compte TikTok" : "Instagram Business"}

diff --git a/client/src/components/recent-publications.tsx b/client/src/components/recent-publications.tsx index db2e699..6b915ad 100644 --- a/client/src/components/recent-publications.tsx +++ b/client/src/components/recent-publications.tsx @@ -1,6 +1,7 @@ import { useQuery } from "@tanstack/react-query"; 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 { SiTiktok } from "react-icons/si"; import { format } from "date-fns"; import { fr } from "date-fns/locale"; import type { ScheduledPost, SocialPage, Post, Media } from "@shared/schema"; @@ -162,10 +163,14 @@ export default function RecentPublications() { {/* Platform icon */}
{scheduledPost.page?.platform === 'facebook' ? ( + ) : scheduledPost.page?.platform === 'tiktok' ? ( + ) : ( )} diff --git a/client/src/pages/mobile/new-post.tsx b/client/src/pages/mobile/new-post.tsx index d463e79..985d2a2 100644 --- a/client/src/pages/mobile/new-post.tsx +++ b/client/src/pages/mobile/new-post.tsx @@ -119,9 +119,12 @@ export default function NewPostMobile() { const [postType, setPostType] = useState<'feed' | 'story' | 'both'>('feed'); const [previewModalOpen, setPreviewModalOpen] = useState(false); - const { data: pages = [] } = useQuery({ + // 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({ queryKey: ['/api/pages'], }); + const pages = allPages.filter(p => p.platform !== 'tiktok'); const { data: allMedia = [] } = useQuery({ queryKey: ['/api/media'], diff --git a/client/src/pages/mobile/new-reel.tsx b/client/src/pages/mobile/new-reel.tsx index ba86893..20e39c2 100644 --- a/client/src/pages/mobile/new-reel.tsx +++ b/client/src/pages/mobile/new-reel.tsx @@ -18,6 +18,7 @@ import { Slider } from "@/components/ui/slider"; import { useToast } from "@/hooks/use-toast"; import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient"; import type { SocialPage, Media } from "@shared/schema"; +import { SiFacebook, SiTiktok } from "react-icons/si"; import { DateTimePicker } from "@/components/datetime-picker"; import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; import { Checkbox } from "@/components/ui/checkbox"; @@ -114,6 +115,24 @@ export default function MobileNewReel() { 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) => ( +
+ { + if (checked) setSelectedPages([...selectedPages, page.id]); + else setSelectedPages(selectedPages.filter(id => id !== page.id)); + }} + /> + +
+ ); + const { data: allMedia = [] } = useQuery({ queryKey: ['/api/media'], }); @@ -585,20 +604,32 @@ export default function MobileNewReel() { Diffusion - - {pages.filter(p => p.platform === 'facebook').map((page) => ( -
- { - if (checked) setSelectedPages([...selectedPages, page.id]); - else setSelectedPages(selectedPages.filter(id => id !== page.id)); - }} - /> - + + {facebookPages.length === 0 && tiktokAccounts.length === 0 && ( +

+ Aucune page Facebook ni compte TikTok connecté. +

+ )} + + {facebookPages.length > 0 && ( +
+
+ + Pages Facebook +
+ {facebookPages.map(renderTargetCheckbox)}
- ))} + )} + + {tiktokAccounts.length > 0 && ( +
+
+ + Comptes TikTok +
+ {tiktokAccounts.map(renderTargetCheckbox)} +
+ )}
diff --git a/client/src/pages/mobile/pages.tsx b/client/src/pages/mobile/pages.tsx index b3072b0..9bcc59e 100644 --- a/client/src/pages/mobile/pages.tsx +++ b/client/src/pages/mobile/pages.tsx @@ -1,6 +1,7 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useQuery, useMutation } from "@tanstack/react-query"; import { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code, Calendar, AlertTriangle } from "lucide-react"; +import { SiTiktok } from "react-icons/si"; import Sidebar from "@/components/sidebar"; import TopBar from "@/components/topbar"; import { Button } from "@/components/ui/button"; @@ -66,12 +67,73 @@ function getTokenExpirationStatus(expiresAt: string | null | undefined) { } } +/** + * Un compte TikTok n'a pas de "token de page" à surveiller : ce qui compte est la + * validité de l'autorisation, portée par le refresh token (1 an). + */ +function getConnectionExpiry(page: SocialPage): string | null { + const date = page.platform === 'tiktok' ? page.refreshTokenExpiresAt : page.tokenExpiresAt; + return date ? new Date(date).toISOString() : null; +} + +/** + * 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 ( + + ); +} + export default function PagesManagementMobile() { const [sidebarOpen, setSidebarOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false); const [editingPage, setEditingPage] = useState(null); const { toast } = useToast(); + // Retour du flux d'autorisation TikTok (/api/tiktok/callback redirige ici) + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const result = params.get('tiktok'); + if (!result) return; + + toast({ + title: result === 'success' ? 'Compte TikTok connecté' : 'Connexion TikTok 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({ queryKey: ['/api/pages'], }); @@ -119,10 +181,11 @@ export default function PagesManagementMobile() {

Pages gérées

- Connectez et gérez vos pages Facebook et Instagram + Connectez et gérez vos pages Facebook, Instagram et vos comptes TikTok

+ !open && setEditingPage(null)} /> @@ -181,6 +244,10 @@ export default function PagesManagementMobile() {
+ ) : page.platform === 'tiktok' ? ( +
+ +
) : (
@@ -202,20 +269,21 @@ export default function PagesManagementMobile() { {/* Token expiration status */} {(() => { - const expirationStatus = getTokenExpirationStatus( - page.tokenExpiresAt ? new Date(page.tokenExpiresAt).toISOString() : null - ); + const expiresAt = getConnectionExpiry(page); + const expirationStatus = getTokenExpirationStatus(expiresAt); return (
-
Expiration du token
+
+ {page.platform === 'tiktok' ? 'Expiration de la connexion' : 'Expiration du token'} +
{expirationStatus.message}
- {page.tokenExpiresAt && ( + {expiresAt && (
- {format(new Date(page.tokenExpiresAt), "d MMMM yyyy", { locale: fr })} + {format(new Date(expiresAt), "d MMMM yyyy", { locale: fr })}
)}
@@ -227,15 +295,27 @@ export default function PagesManagementMobile() { })()}
- + {page.platform === 'tiktok' ? ( + + ) : ( + + )}