mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-23 06:58:12 +02:00
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
This commit is contained in:
27 files changed
+1679
-183
No files matched your search
@@ -38,6 +38,24 @@ MINIO_PUBLIC_URL=https://media.example.com
|
|||||||
# 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
|
||||||
|
# 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
|
# API EXTERNE
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ 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";
|
||||||
@@ -261,7 +261,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 +340,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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -162,10 +163,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" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -119,9 +119,12 @@ 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');
|
||||||
|
|
||||||
const { data: allMedia = [] } = useQuery<Media[]>({
|
const { data: allMedia = [] } = useQuery<Media[]>({
|
||||||
queryKey: ['/api/media'],
|
queryKey: ['/api/media'],
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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, Calendar, 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";
|
||||||
@@ -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 (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full min-h-[48px]"
|
||||||
|
onClick={handleClick}
|
||||||
|
data-testid="button-connect-tiktok"
|
||||||
|
>
|
||||||
|
<SiTiktok className="w-5 h-5 mr-2" />
|
||||||
|
Connecter un compte TikTok
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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<SocialPage | null>(null);
|
||||||
const { toast } = useToast();
|
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<SocialPage[]>({
|
const { data: pages = [], isLoading } = useQuery<SocialPage[]>({
|
||||||
queryKey: ['/api/pages'],
|
queryKey: ['/api/pages'],
|
||||||
});
|
});
|
||||||
@@ -119,10 +181,11 @@ 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>
|
||||||
|
|
||||||
|
<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 +244,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" />
|
||||||
@@ -202,20 +269,21 @@ export default function PagesManagementMobile() {
|
|||||||
|
|
||||||
{/* Token expiration status */}
|
{/* Token expiration status */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const expirationStatus = getTokenExpirationStatus(
|
const expiresAt = getConnectionExpiry(page);
|
||||||
page.tokenExpiresAt ? new Date(page.tokenExpiresAt).toISOString() : null
|
const expirationStatus = getTokenExpirationStatus(expiresAt);
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex items-center gap-2 text-xs px-3 py-3 rounded-lg ${expirationStatus.bgColor}`}>
|
<div className={`flex items-center gap-2 text-xs px-3 py-3 rounded-lg ${expirationStatus.bgColor}`}>
|
||||||
<Calendar className="w-4 h-4 flex-shrink-0" />
|
<Calendar className="w-4 h-4 flex-shrink-0" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-medium">Expiration du token</div>
|
<div className="font-medium">
|
||||||
|
{page.platform === 'tiktok' ? 'Expiration de la connexion' : 'Expiration du token'}
|
||||||
|
</div>
|
||||||
<div className={`${expirationStatus.color} font-semibold`}>
|
<div className={`${expirationStatus.color} font-semibold`}>
|
||||||
{expirationStatus.message}
|
{expirationStatus.message}
|
||||||
</div>
|
</div>
|
||||||
{page.tokenExpiresAt && (
|
{expiresAt && (
|
||||||
<div className="text-muted-foreground mt-0.5">
|
<div className="text-muted-foreground mt-0.5">
|
||||||
{format(new Date(page.tokenExpiresAt), "d MMMM yyyy", { locale: fr })}
|
{format(new Date(expiresAt), "d MMMM yyyy", { locale: fr })}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -227,15 +295,27 @@ export default function PagesManagementMobile() {
|
|||||||
})()}
|
})()}
|
||||||
|
|
||||||
<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]"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ 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 } 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,6 +69,23 @@ 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({
|
||||||
@@ -430,31 +448,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 +507,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}
|
||||||
|
|||||||
@@ -116,9 +116,12 @@ 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');
|
||||||
|
|
||||||
const { data: allMedia = [] } = useQuery<Media[]>({
|
const { data: allMedia = [] } = useQuery<Media[]>({
|
||||||
queryKey: ['/api/media'],
|
queryKey: ['/api/media'],
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
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 { 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'],
|
||||||
@@ -918,42 +945,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>
|
||||||
|
|||||||
+95
-17
@@ -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, Calendar, 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";
|
||||||
@@ -66,12 +67,37 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
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<SocialPage | null>(null);
|
||||||
const { toast } = useToast();
|
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<SocialPage[]>({
|
const { data: pages = [], isLoading } = useQuery<SocialPage[]>({
|
||||||
queryKey: ['/api/pages'],
|
queryKey: ['/api/pages'],
|
||||||
});
|
});
|
||||||
@@ -119,10 +145,13 @@ 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">
|
||||||
|
<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>
|
||||||
|
|
||||||
@@ -194,6 +223,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" />
|
||||||
@@ -215,20 +248,21 @@ export default function PagesManagement() {
|
|||||||
|
|
||||||
{/* Token expiration status */}
|
{/* Token expiration status */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const expirationStatus = getTokenExpirationStatus(
|
const expiresAt = getConnectionExpiry(page);
|
||||||
page.tokenExpiresAt ? new Date(page.tokenExpiresAt).toISOString() : null
|
const expirationStatus = getTokenExpirationStatus(expiresAt);
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex items-center gap-2 text-xs px-3 py-2 rounded-lg ${expirationStatus.bgColor}`}>
|
<div className={`flex items-center gap-2 text-xs px-3 py-2 rounded-lg ${expirationStatus.bgColor}`}>
|
||||||
<Calendar className="w-4 h-4" />
|
<Calendar className="w-4 h-4" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="font-medium">Expiration du token</div>
|
<div className="font-medium">
|
||||||
|
{page.platform === 'tiktok' ? 'Expiration de la connexion' : 'Expiration du token'}
|
||||||
|
</div>
|
||||||
<div className={`${expirationStatus.color} font-semibold`}>
|
<div className={`${expirationStatus.color} font-semibold`}>
|
||||||
{expirationStatus.message}
|
{expirationStatus.message}
|
||||||
</div>
|
</div>
|
||||||
{page.tokenExpiresAt && (
|
{expiresAt && (
|
||||||
<div className="text-muted-foreground mt-0.5">
|
<div className="text-muted-foreground mt-0.5">
|
||||||
{format(new Date(page.tokenExpiresAt), "d MMMM yyyy", { locale: fr })}
|
{format(new Date(expiresAt), "d MMMM yyyy", { locale: fr })}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -240,14 +274,27 @@ export default function PagesManagement() {
|
|||||||
})()}
|
})()}
|
||||||
|
|
||||||
<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' ? (
|
||||||
variant="ghost"
|
// Un token TikTok ne se saisit pas : on repasse par l'autorisation
|
||||||
size="icon"
|
<Button
|
||||||
onClick={() => setEditingPage(page)}
|
variant="ghost"
|
||||||
data-testid={`button-edit-page-${page.id}`}
|
size="sm"
|
||||||
>
|
onClick={() => { window.location.href = '/api/tiktok/connect'; }}
|
||||||
<Edit className="w-4 h-4" />
|
data-testid={`button-reconnect-page-${page.id}`}
|
||||||
</Button>
|
>
|
||||||
|
<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 +316,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('');
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Switch } from "@/components/ui/switch";
|
|||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
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 +68,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;
|
||||||
|
|
||||||
@@ -376,30 +393,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 +456,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}
|
||||||
|
|||||||
@@ -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,8 @@ 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 [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 }>({
|
||||||
@@ -56,6 +59,15 @@ export default function Settings() {
|
|||||||
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: externalApiConfig } = useQuery({
|
const { data: externalApiConfig } = useQuery({
|
||||||
queryKey: ['/api/settings/external-api'],
|
queryKey: ['/api/settings/external-api'],
|
||||||
enabled: isAdmin,
|
enabled: isAdmin,
|
||||||
@@ -92,6 +104,13 @@ 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(() => {
|
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 +283,29 @@ export default function Settings() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 +802,71 @@ 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">
|
||||||
|
<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">
|
||||||
|
|||||||
@@ -2,8 +2,19 @@ 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() {
|
||||||
|
// Toutes les 2 minutes — suivi des publications TikTok (API asynchrone)
|
||||||
|
cron.schedule('*/2 * * * *', async () => {
|
||||||
|
try {
|
||||||
|
await tiktokService.syncPendingPublications();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Cron] TikTok publish status sync failed:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log('[Cron] TikTok publish status job scheduled (*/2 * * * *).');
|
||||||
|
|
||||||
// Run every day at midnight (00:00) — Token health check
|
// Run every day at midnight (00:00) — Token health check
|
||||||
cron.schedule('0 0 * * *', async () => {
|
cron.schedule('0 0 * * *', async () => {
|
||||||
console.log('[Cron] Running daily token check...');
|
console.log('[Cron] Running daily token check...');
|
||||||
|
|||||||
@@ -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,33 @@ export async function migrate() {
|
|||||||
ADD COLUMN IF NOT EXISTS "gemini_api_key" text;
|
ADD COLUMN IF NOT EXISTS "gemini_api_key" 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;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
`);
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
+14
-1
@@ -16,6 +16,7 @@ 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 { insertAudioTrackSchema } from "@shared/schema";
|
import { insertAudioTrackSchema } from "@shared/schema";
|
||||||
import * as musicMetadata from "music-metadata";
|
import * as musicMetadata from "music-metadata";
|
||||||
|
|
||||||
@@ -458,6 +459,9 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
// Analytics Routes
|
// Analytics Routes
|
||||||
app.use("/api/analytics", requireAuth, analyticsRouter);
|
app.use("/api/analytics", requireAuth, analyticsRouter);
|
||||||
|
|
||||||
|
// TikTok Routes (OAuth multi-comptes) — avant le routeur Reels monté sur /api
|
||||||
|
app.use("/api/tiktok", requireAuth, tiktokRouter);
|
||||||
|
|
||||||
// Reels & Music Routes
|
// 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);
|
||||||
@@ -1565,6 +1569,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);
|
||||||
@@ -1598,7 +1610,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 = {
|
||||||
|
|||||||
+39
-15
@@ -7,6 +7,7 @@ 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 { minioService as cloudinaryService, buildMinioUrl, resolveInternalUrl } from '../services/minio';
|
import { minioService as cloudinaryService, buildMinioUrl, resolveInternalUrl } from '../services/minio';
|
||||||
import { openRouterService } from '../services/openrouter';
|
import { openRouterService } from '../services/openrouter';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
@@ -642,8 +643,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 +661,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' });
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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 * as musicMetadata from "music-metadata";
|
import * as musicMetadata from "music-metadata";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
@@ -427,7 +428,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 +441,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" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
});
|
||||||
|
} 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(),
|
||||||
|
});
|
||||||
|
} 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);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,568 @@
|
|||||||
|
/**
|
||||||
|
* 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 { storage } from '../storage';
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getRedirectUri(): string {
|
||||||
|
if (process.env.TIKTOK_REDIRECT_URI) {
|
||||||
|
return process.env.TIKTOK_REDIRECT_URI;
|
||||||
|
}
|
||||||
|
const base = (process.env.APP_URL || 'http://localhost:5555').replace(/\/$/, '');
|
||||||
|
return `${base}/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): 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(),
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${AUTH_BASE_URL}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Échange le code d'autorisation contre un couple access/refresh token. */
|
||||||
|
async exchangeCodeForToken(code: string): 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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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();
|
||||||
@@ -60,6 +60,12 @@ export class TokenManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static async processPageToken(page: any) {
|
private static async processPageToken(page: any) {
|
||||||
|
// Les comptes TikTok ont leur propre cycle de vie : l'access token ne dure
|
||||||
|
// que 24h et se renouvelle avec le refresh token, pas via la Graph API.
|
||||||
|
if (page.platform === 'tiktok') {
|
||||||
|
return await this.processTiktokToken(page);
|
||||||
|
}
|
||||||
|
|
||||||
let accessToken = page.accessToken;
|
let accessToken = page.accessToken;
|
||||||
|
|
||||||
// Attempt to decrypt. If fails, assume it's plain text (migration phase)
|
// Attempt to decrypt. If fails, assume it's plain text (migration phase)
|
||||||
@@ -93,6 +99,36 @@ export class TokenManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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: any) {
|
||||||
|
console.log(`[TokenManager] Checking TikTok account: ${page.pageName}`);
|
||||||
|
|
||||||
|
const refreshExpiresAt = page.refreshTokenExpiresAt
|
||||||
|
? new Date(page.refreshTokenExpiresAt).getTime()
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
let status: "valid" | "expiring" | "expired" = "valid";
|
||||||
|
|
||||||
|
if (!page.refreshToken || (refreshExpiresAt && refreshExpiresAt <= Date.now())) {
|
||||||
|
status = "expired";
|
||||||
|
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 db.update(socialPages)
|
||||||
|
.set({
|
||||||
|
tokenStatus: status,
|
||||||
|
lastTokenCheck: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(socialPages.id, page.id));
|
||||||
|
}
|
||||||
|
|
||||||
private static async verifyToken(token: string): Promise<boolean> {
|
private static async verifyToken(token: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
// Simple validation check: Get 'me' (page profile)
|
// Simple validation check: Get 'me' (page profile)
|
||||||
|
|||||||
+96
-23
@@ -36,11 +36,38 @@ import {
|
|||||||
type InsertAudioTrack,
|
type InsertAudioTrack,
|
||||||
appConfig,
|
appConfig,
|
||||||
type AppConfig,
|
type AppConfig,
|
||||||
|
tiktokConfig,
|
||||||
|
type TiktokConfig,
|
||||||
|
type InsertTiktokConfig,
|
||||||
} 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 } 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface IStorage {
|
export interface IStorage {
|
||||||
// Users
|
// Users
|
||||||
getUser(id: string): Promise<User | undefined>;
|
getUser(id: string): Promise<User | undefined>;
|
||||||
@@ -85,11 +112,16 @@ 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>;
|
||||||
|
|
||||||
// Cloudinary Config
|
// Cloudinary Config
|
||||||
getCloudinaryConfig(): Promise<CloudinaryConfig | undefined>;
|
getCloudinaryConfig(): Promise<CloudinaryConfig | undefined>;
|
||||||
createCloudinaryConfig(config: InsertCloudinaryConfig): Promise<CloudinaryConfig>;
|
createCloudinaryConfig(config: InsertCloudinaryConfig): Promise<CloudinaryConfig>;
|
||||||
@@ -160,43 +192,36 @@ export class DatabaseStorage implements IStorage {
|
|||||||
// 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)
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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> {
|
||||||
@@ -422,6 +447,22 @@ 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)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 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 +612,38 @@ export class DatabaseStorage implements IStorage {
|
|||||||
return config || undefined;
|
return config || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
|||||||
+41
-1
@@ -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,13 @@ 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"),
|
||||||
|
|
||||||
|
// 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(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,6 +141,16 @@ 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.
|
||||||
|
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" }),
|
||||||
@@ -180,6 +198,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 +429,19 @@ export const updateCloudinaryConfigSchema = insertCloudinaryConfigSchema.partial
|
|||||||
apiSecret: true,
|
apiSecret: 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 +501,8 @@ export type InsertAudioTrack = z.infer<typeof insertAudioTrackSchema>;
|
|||||||
|
|
||||||
export type AppConfig = typeof appConfig.$inferSelect;
|
export type AppConfig = typeof appConfig.$inferSelect;
|
||||||
|
|
||||||
|
export type TiktokConfig = typeof tiktokConfig.$inferSelect;
|
||||||
|
export type InsertTiktokConfig = z.infer<typeof insertTiktokConfigSchema>;
|
||||||
|
export type UpdateTiktokConfig = z.infer<typeof updateTiktokConfigSchema>;
|
||||||
|
|
||||||
|
|
||||||
Reference in new issue
Block a user