Merge pull request #110 from R0m1k3/claude/tiktok-store-account-management-m558za

Claude/tiktok store account management m558za
This commit is contained in:
LogiFlow authored and GitHub committed 2026-08-12 10:02:11 +02:00
commit 55e502aa8d
38 files changed
+2258 -227

No files matched your search

+36
View File
@@ -38,6 +38,42 @@ MINIO_PUBLIC_URL=https://media.example.com
# INSTAGRAM_APP_ID=
# INSTAGRAM_APP_SECRET=
# ==========================================
# TIKTOK (publication des reels)
# ==========================================
# Identifiants de votre application sur https://developers.tiktok.com
# Ils peuvent aussi être saisis dans l'application (Paramètres → TikTok),
# ce qui est prioritaire sur ces variables.
# TIKTOK_CLIENT_KEY=
# TIKTOK_CLIENT_SECRET=
#
# URL de redirection OAuth à déclarer chez TikTok.
# Par défaut : ${APP_URL}/api/tiktok/callback
# TIKTOK_REDIRECT_URI=
#
# Scopes demandés à l'autorisation (défaut : user.info.basic,video.publish).
# "video.publish" nécessite que l'application ait passé l'audit TikTok ;
# sans audit, les vidéos publiées restent privées.
# TIKTOK_SCOPES=
# ==========================================
# MENTIONS LÉGALES
# ==========================================
# Affichées sur les pages publiques /terms et /privacy, dont TikTok exige
# les URLs pour l'audit de l'application développeur.
#
# L'identité de la société exploitante est déjà la valeur par défaut : seule
# l'adresse de contact doit obligatoirement être renseignée ici, le RGPD
# imposant un point de contact pour l'exercice des droits.
LEGAL_CONTACT_EMAIL=
# Surcharges facultatives (utiles si l'outil est exploité par une autre société)
# LEGAL_COMPANY_NAME=
# LEGAL_TRADE_NAME=
# LEGAL_COMPANY_LEGAL_FORM=
# LEGAL_COMPANY_RCS=
# LEGAL_COMPANY_ADDRESS=
# ==========================================
# API EXTERNE
# ==========================================
+2 -2
View File
@@ -1,7 +1,7 @@
import { format, isSameDay, parseISO } from "date-fns";
import { fr } from "date-fns/locale";
import { Edit, Trash2, ChevronDown, ChevronUp, Eye, Image, Smartphone } from "lucide-react";
import { SiFacebook, SiInstagram } from "react-icons/si";
import { SiFacebook, SiInstagram, SiTiktok } from "react-icons/si";
import { Button } from "@/components/ui/button";
import { useState } from "react";
@@ -159,7 +159,7 @@ export default function CalendarListView({ scheduledPosts, onEditPost, onDeleteP
const isPublished = !!post.publishedAt;
const pageName = post.page?.pageName || 'Page inconnue';
const platform = post.page?.platform || 'facebook';
const PlatformIcon = platform === 'instagram' ? SiInstagram : SiFacebook;
const PlatformIcon = platform === 'instagram' ? SiInstagram : platform === 'tiktok' ? SiTiktok : SiFacebook;
return (
<div
+3 -3
View File
@@ -6,7 +6,7 @@ import { useQuery, useMutation } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
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 CalendarListView from "./calendar-list-view";
import { useMediaQuery } from "@/hooks/use-media-query";
@@ -261,7 +261,7 @@ export default function CalendarView() {
const isPublished = !!post.publishedAt;
const pageName = post.page?.pageName || 'Page inconnue';
const platform = post.page?.platform || 'facebook';
const PlatformIcon = platform === 'instagram' ? SiInstagram : SiFacebook;
const PlatformIcon = platform === 'instagram' ? SiInstagram : platform === 'tiktok' ? SiTiktok : SiFacebook;
return (
<div
@@ -340,7 +340,7 @@ export default function CalendarView() {
const isPublished = !!post.publishedAt;
const pageName = post.page?.pageName || 'Page inconnue';
const platform = post.page?.platform || 'facebook';
const PlatformIcon = platform === 'instagram' ? SiInstagram : SiFacebook;
const PlatformIcon = platform === 'instagram' ? SiInstagram : platform === 'tiktok' ? SiTiktok : SiFacebook;
return (
<div
@@ -169,6 +169,7 @@ export default function EditScheduledPostDialog({ open, onOpenChange, scheduledP
<MediaThumbnail
src={media.instagramFeedUrl || media.originalUrl}
alt="Selected"
thumbnailUrl={media.thumbnailUrl ?? undefined}
type={media.type === 'video' ? 'video' : 'image'}
className="w-full h-24 object-cover rounded-lg border-2 border-primary"
/>
@@ -203,6 +204,7 @@ export default function EditScheduledPostDialog({ open, onOpenChange, scheduledP
<MediaThumbnail
src={media.instagramFeedUrl || media.originalUrl}
alt={media.fileName}
thumbnailUrl={media.thumbnailUrl ?? undefined}
type={media.type === 'video' ? 'video' : 'image'}
className={`w-full h-20 object-cover rounded-lg border-2 transition-all ${
isSelected ? 'border-primary' : 'border-border hover:border-primary/50'
+5 -3
View File
@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { Users } from "lucide-react";
import { SiFacebook, SiInstagram } from "react-icons/si";
import { SiFacebook, SiInstagram, SiTiktok } from "react-icons/si";
export default function ManagedPages() {
const { data: pages, isLoading } = useQuery({
@@ -48,10 +48,12 @@ export default function ManagedPages() {
<div className="flex items-start justify-between mb-5">
<div className={`
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" ? (
<SiFacebook className="text-[#1877F2] text-2xl" />
) : page.platform === "tiktok" ? (
<SiTiktok className="text-foreground text-2xl" />
) : (
<SiInstagram className="text-[#E4405F] text-2xl" />
)}
@@ -65,7 +67,7 @@ export default function ManagedPages() {
</div>
<h4 className="font-semibold text-foreground text-lg mb-2">{page.pageName}</h4>
<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>
<div className="flex items-center justify-between pt-4 border-t border-border/50">
<div className="flex items-center gap-2">
+4 -1
View File
@@ -61,7 +61,10 @@ export function MediaThumbnail({ src, alt, type, className = "w-full h-full obje
};
const handleError = () => {
console.warn('Video thumbnail generation failed for:', src);
// Cas courant et attendu : la vidéo a été supprimée du disque après
// publication. Les médias créés depuis embarquent une vignette persistée,
// qui évite d'en arriver là.
console.warn('Aperçu vidéo indisponible (fichier absent ou illisible):', src);
setError(true);
video.remove();
};
+1
View File
@@ -284,6 +284,7 @@ export default function MediaUpload() {
<MediaThumbnail
src={media.facebookFeedUrl || media.originalUrl}
alt={media.fileName}
thumbnailUrl={media.thumbnailUrl ?? undefined}
type={media.type === 'video' ? 'video' : 'image'}
/>
<div className="absolute top-2 right-2 flex gap-2">
@@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Facebook, Instagram, Clock, CheckCircle2, XCircle, Eye, Image as ImageIcon, Smartphone, Clapperboard } from "lucide-react";
import { SiTiktok } from "react-icons/si";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import type { ScheduledPost, SocialPage, Post, Media } from "@shared/schema";
@@ -162,10 +163,14 @@ export default function RecentPublications() {
{/* Platform icon */}
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${scheduledPost.page?.platform === 'facebook'
? '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' ? (
<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" />
)}
+23
View File
@@ -20,6 +20,29 @@ export function handleUnauthorized(url: string) {
window.location.href = "/login";
}
/**
* Extrait un message lisible d'une erreur d'API.
* apiRequest lève des erreurs de la forme `500: {"error":"…"}` : sans ce
* décodage, l'interface ne peut afficher qu'un message générique alors que le
* serveur a renvoyé la cause exacte.
*/
export function getErrorMessage(error: unknown, fallback: string): string {
if (!(error instanceof Error) || !error.message) return fallback;
const withoutStatus = error.message.replace(/^\d{3}:\s*/, '').trim();
if (!withoutStatus) return fallback;
try {
const parsed = JSON.parse(withoutStatus);
const message = parsed?.error || parsed?.message;
if (typeof message === 'string' && message.trim()) return message.trim();
} catch {
// Corps non-JSON : on affiche le texte tel quel
}
return withoutStatus.startsWith('<') ? fallback : withoutStatus;
}
async function throwIfResNotOk(res: Response, url: string) {
if (!res.ok) {
if (res.status === 401) {
+9 -4
View File
@@ -32,7 +32,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
import type { SocialPage, Media, ScheduledPost } from "@shared/schema";
import { PreviewModal } from "@/components/preview-modal";
import { DateTimePicker } from "@/components/datetime-picker";
@@ -81,6 +81,7 @@ function SortableMediaItem({
<MediaThumbnail
src={media.facebookFeedUrl || media.originalUrl}
alt={media.fileName}
thumbnailUrl={media.thumbnailUrl ?? undefined}
type={isVideo ? 'video' : 'image'}
className="absolute inset-0 w-full h-full object-cover"
/>
@@ -119,9 +120,12 @@ export default function NewPostMobile() {
const [postType, setPostType] = useState<'feed' | 'story' | 'both'>('feed');
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'],
});
const pages = allPages.filter(p => p.platform !== 'tiktok');
const { data: allMedia = [] } = useQuery<Media[]>({
queryKey: ['/api/media'],
@@ -227,10 +231,10 @@ export default function NewPostMobile() {
});
queryClient.invalidateQueries({ queryKey: ['/api/ai/generations'] });
},
onError: () => {
onError: (error: unknown) => {
toast({
title: "Erreur",
description: "Impossible de générer le texte",
description: getErrorMessage(error, "Impossible de générer le texte"),
variant: "destructive",
});
},
@@ -532,6 +536,7 @@ export default function NewPostMobile() {
<MediaThumbnail
src={media.facebookFeedUrl || media.originalUrl}
alt={media.fileName}
thumbnailUrl={media.thumbnailUrl ?? undefined}
type={isVideo ? 'video' : 'image'}
className="absolute inset-0 w-full h-full object-cover"
/>
+44 -13
View File
@@ -18,6 +18,7 @@ import { Slider } from "@/components/ui/slider";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
import type { SocialPage, Media } from "@shared/schema";
import { SiFacebook, SiTiktok } from "react-icons/si";
import { DateTimePicker } from "@/components/datetime-picker";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { Checkbox } from "@/components/ui/checkbox";
@@ -114,6 +115,24 @@ export default function MobileNewReel() {
queryKey: ['/api/pages'],
});
// Destinations acceptant une vidéo verticale
const facebookPages = pages.filter(p => p.platform === 'facebook');
const tiktokAccounts = pages.filter(p => p.platform === 'tiktok');
const renderTargetCheckbox = (page: SocialPage) => (
<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[]>({
queryKey: ['/api/media'],
});
@@ -585,20 +604,32 @@ export default function MobileNewReel() {
<CardHeader className="pb-3">
<CardTitle className="text-base">Diffusion</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{pages.filter(p => p.platform === 'facebook').map((page) => (
<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>
<CardContent className="space-y-4">
{facebookPages.length === 0 && tiktokAccounts.length === 0 && (
<p className="text-sm text-muted-foreground">
Aucune page Facebook ni compte TikTok connecté.
</p>
)}
{facebookPages.length > 0 && (
<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]" />
Pages Facebook
</div>
{facebookPages.map(renderTargetCheckbox)}
</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>
</Card>
+97 -17
View File
@@ -1,6 +1,7 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code, Calendar, AlertTriangle } from "lucide-react";
import { SiTiktok } from "react-icons/si";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { Button } from "@/components/ui/button";
@@ -66,12 +67,73 @@ function getTokenExpirationStatus(expiresAt: string | null | undefined) {
}
}
/**
* Un compte TikTok n'a pas de "token de page" à surveiller : ce qui compte est la
* validité de l'autorisation, portée par le refresh token (1 an).
*/
function getConnectionExpiry(page: SocialPage): string | null {
const date = page.platform === 'tiktok' ? page.refreshTokenExpiresAt : page.tokenExpiresAt;
return date ? new Date(date).toISOString() : null;
}
/**
* Lance l'autorisation TikTok. La connexion se fait par navigation complète du
* navigateur (et non en fetch) puisqu'elle passe par le site de TikTok.
*/
function ConnectTiktokButton() {
const { toast } = useToast();
const { data: config } = useQuery<{ configured: boolean }>({
queryKey: ['/api/tiktok/config'],
});
const handleClick = () => {
if (config && !config.configured) {
toast({
title: "TikTok n'est pas configuré",
description: "Renseignez le client key et le client secret de votre application TikTok dans les paramètres.",
variant: "destructive",
});
return;
}
window.location.href = '/api/tiktok/connect';
};
return (
<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() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingPage, setEditingPage] = useState<SocialPage | null>(null);
const { toast } = useToast();
// Retour du flux d'autorisation TikTok (/api/tiktok/callback redirige ici)
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const result = params.get('tiktok');
if (!result) return;
toast({
title: result === 'success' ? 'Compte TikTok connecté' : 'Connexion TikTok impossible',
description: params.get('message') || undefined,
variant: result === 'success' ? undefined : 'destructive',
});
queryClient.invalidateQueries({ queryKey: ['/api/pages'] });
window.history.replaceState({}, '', window.location.pathname);
}, [toast]);
const { data: pages = [], isLoading } = useQuery<SocialPage[]>({
queryKey: ['/api/pages'],
});
@@ -119,10 +181,11 @@ export default function PagesManagementMobile() {
<div className="space-y-2">
<h1 className="text-2xl font-bold text-foreground">Pages gérées</h1>
<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>
</div>
<ConnectTiktokButton />
<AddPageDialog open={dialogOpen} onOpenChange={setDialogOpen} />
<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">
<Facebook className="w-6 h-6 text-white" />
</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">
<Instagram className="w-6 h-6 text-white" />
@@ -202,20 +269,21 @@ export default function PagesManagementMobile() {
{/* Token expiration status */}
{(() => {
const expirationStatus = getTokenExpirationStatus(
page.tokenExpiresAt ? new Date(page.tokenExpiresAt).toISOString() : null
);
const expiresAt = getConnectionExpiry(page);
const expirationStatus = getTokenExpirationStatus(expiresAt);
return (
<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" />
<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`}>
{expirationStatus.message}
</div>
{page.tokenExpiresAt && (
{expiresAt && (
<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>
@@ -227,15 +295,27 @@ export default function PagesManagementMobile() {
})()}
<div className="flex items-center gap-2 pt-2">
<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>
{page.platform === 'tiktok' ? (
<Button
variant="outline"
className="flex-1 min-h-[44px]"
onClick={() => { window.location.href = '/api/tiktok/connect'; }}
data-testid={`button-reconnect-page-${page.id}`}
>
<RefreshCw className="w-4 h-4 mr-2" />
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
variant="destructive"
className="flex-1 min-h-[44px]"
+47 -23
View File
@@ -11,10 +11,12 @@ import { MediaThumbnail } from "@/components/media-thumbnail";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import Sidebar from "@/components/sidebar";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { DateTimePicker } from "@/components/datetime-picker";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
import type { Media, SocialPage } from "@shared/schema";
import { SiFacebook, SiTiktok } from "react-icons/si";
interface AudioTrack { id: string; title: string; fileName: string; url: string; duration: number; }
@@ -68,12 +70,29 @@ export default function MobileRemotionVideoPage() {
const { data: socialPages = [] } = useQuery<SocialPage[]>({ queryKey: ['/api/pages'] });
const 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 generateTextMutation = useMutation({
mutationFn: async (text: string) => (await apiRequest('POST', '/api/reels/generate-text', { productInfo: text })).json(),
onSuccess: (data: any) => { setGeneratedVariants(data.variants || []); toast({ title: "Textes générés" }); },
onError: () => toast({ title: "Erreur IA", variant: "destructive" }),
onError: (error: unknown) => toast({ title: "Erreur IA", description: getErrorMessage(error, "Impossible de générer le texte"), variant: "destructive" }),
});
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -430,31 +449,36 @@ export default function MobileRemotionVideoPage() {
</CardContent>
</Card>
{/* 4. Page Facebook */}
{/* 4. Destinations */}
<Card>
<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>
<CardContent className="space-y-2">
{facebookPages.length === 0 ? (
<p className="text-xs text-muted-foreground">Aucune page Facebook connectée.</p>
{facebookPages.length === 0 && tiktokAccounts.length === 0 ? (
<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">
<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>
))
<>
{facebookPages.length > 0 && (
<div className="space-y-2">
<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]" />
Pages Facebook
</div>
{facebookPages.map(renderTargetCheckbox)}
</div>
)}
{tiktokAccounts.length > 0 && (
<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>
</Card>
@@ -484,7 +508,7 @@ export default function MobileRemotionVideoPage() {
<p className="text-xs text-muted-foreground">Sélectionnez une page ci-dessus.</p>
) : (
<p className="text-xs text-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>
)}
<Textarea rows={2} placeholder="Description (optionnel)" value={publishDescription}
+9 -4
View File
@@ -31,7 +31,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
import { MediaThumbnail } from "@/components/media-thumbnail";
import type { SocialPage, Media, ScheduledPost } from "@shared/schema";
import { PreviewModal } from "@/components/preview-modal";
@@ -80,6 +80,7 @@ function SortableMediaItem({
<MediaThumbnail
src={media.facebookFeedUrl || media.originalUrl}
alt={media.fileName}
thumbnailUrl={media.thumbnailUrl ?? undefined}
type={isVideo ? 'video' : 'image'}
/>
</button>
@@ -116,9 +117,12 @@ export default function NewPost() {
const [postType, setPostType] = useState<'feed' | 'story' | 'both'>('feed');
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'],
});
const pages = allPages.filter(p => p.platform !== 'tiktok');
const { data: allMedia = [] } = useQuery<Media[]>({
queryKey: ['/api/media'],
@@ -223,10 +227,10 @@ export default function NewPost() {
});
queryClient.invalidateQueries({ queryKey: ['/api/ai/generations'] });
},
onError: () => {
onError: (error: unknown) => {
toast({
title: "Erreur",
description: "Impossible de générer le texte",
description: getErrorMessage(error, "Impossible de générer le texte"),
variant: "destructive",
});
},
@@ -540,6 +544,7 @@ export default function NewPost() {
<MediaThumbnail
src={media.facebookFeedUrl || media.originalUrl}
alt={media.fileName}
thumbnailUrl={media.thumbnailUrl ?? undefined}
type={isVideo ? 'video' : 'image'}
/>
{isSelected && (
+56 -28
View File
@@ -25,8 +25,9 @@ import {
SelectValue,
} from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
import type { SocialPage, Media } from "@shared/schema";
import { SiFacebook, SiTiktok } from "react-icons/si";
import { MediaThumbnail } from "@/components/media-thumbnail";
import { DateTimePicker } from "@/components/datetime-picker";
@@ -133,6 +134,32 @@ export default function NewReel() {
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
const { data: allMedia = [] } = useQuery<Media[]>({
queryKey: ['/api/media'],
@@ -208,10 +235,10 @@ export default function NewReel() {
description: `${variants.length} variations créées`,
});
},
onError: () => {
onError: (error: unknown) => {
toast({
title: "Erreur",
description: "Impossible de générer le texte",
description: getErrorMessage(error, "Impossible de générer le texte"),
variant: "destructive",
});
},
@@ -541,6 +568,7 @@ export default function NewReel() {
<MediaThumbnail
src={media.originalUrl}
alt={media.fileName}
thumbnailUrl={media.thumbnailUrl ?? undefined}
type="video"
/>
{selectedVideoId === media.id && (
@@ -918,42 +946,42 @@ export default function NewReel() {
<>
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader>
<CardTitle>Pages cibles</CardTitle>
<CardDescription>Sélectionnez les pages où publier</CardDescription>
<CardTitle>Destinations</CardTitle>
<CardDescription>
La même vidéo peut partir sur plusieurs pages Facebook et plusieurs comptes TikTok
</CardDescription>
</CardHeader>
<CardContent>
{pages.filter(p => p.platform === 'facebook').length === 0 ? (
{facebookPages.length === 0 && tiktokAccounts.length === 0 ? (
<div className="text-center py-8">
<p className="text-muted-foreground mb-2">
Aucune page Facebook connectée
Aucune page Facebook ni compte TikTok connecté
</p>
<Button variant="link" onClick={() => navigate('/pages')}>
Ajouter des pages
</Button>
</div>
) : (
<div className="space-y-3">
{pages.filter(p => p.platform === 'facebook').map((page) => (
<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 className="space-y-5">
{facebookPages.length > 0 && (
<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]" />
Pages Facebook
</div>
{facebookPages.map(renderTargetCheckbox)}
</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>
)}
</CardContent>
+95 -17
View File
@@ -1,6 +1,7 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code, Calendar, AlertTriangle } from "lucide-react";
import { SiTiktok } from "react-icons/si";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { Button } from "@/components/ui/button";
@@ -66,12 +67,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() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingPage, setEditingPage] = useState<SocialPage | null>(null);
const { toast } = useToast();
// Retour du flux d'autorisation TikTok (/api/tiktok/callback redirige ici)
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const result = params.get('tiktok');
if (!result) return;
toast({
title: result === 'success' ? 'Compte TikTok connecté' : 'Connexion TikTok impossible',
description: params.get('message') || undefined,
variant: result === 'success' ? undefined : 'destructive',
});
queryClient.invalidateQueries({ queryKey: ['/api/pages'] });
window.history.replaceState({}, '', window.location.pathname);
}, [toast]);
const { data: pages = [], isLoading } = useQuery<SocialPage[]>({
queryKey: ['/api/pages'],
});
@@ -119,10 +145,13 @@ export default function PagesManagement() {
<div>
<h1 className="text-3xl font-bold text-foreground">Pages gérées</h1>
<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>
</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)} />
</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">
<Facebook className="w-6 h-6 text-white" />
</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">
<Instagram className="w-6 h-6 text-white" />
@@ -215,20 +248,21 @@ export default function PagesManagement() {
{/* Token expiration status */}
{(() => {
const expirationStatus = getTokenExpirationStatus(
page.tokenExpiresAt ? new Date(page.tokenExpiresAt).toISOString() : null
);
const expiresAt = getConnectionExpiry(page);
const expirationStatus = getTokenExpirationStatus(expiresAt);
return (
<div className={`flex items-center gap-2 text-xs px-3 py-2 rounded-lg ${expirationStatus.bgColor}`}>
<Calendar className="w-4 h-4" />
<div className="flex-1">
<div className="font-medium">Expiration du token</div>
<div className="font-medium">
{page.platform === 'tiktok' ? 'Expiration de la connexion' : 'Expiration du token'}
</div>
<div className={`${expirationStatus.color} font-semibold`}>
{expirationStatus.message}
</div>
{page.tokenExpiresAt && (
{expiresAt && (
<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>
@@ -240,14 +274,27 @@ export default function PagesManagement() {
})()}
<div className="flex items-center justify-end gap-2 pt-2">
<Button
variant="ghost"
size="icon"
onClick={() => setEditingPage(page)}
data-testid={`button-edit-page-${page.id}`}
>
<Edit className="w-4 h-4" />
</Button>
{page.platform === 'tiktok' ? (
// Un token TikTok ne se saisit pas : on repasse par l'autorisation
<Button
variant="ghost"
size="sm"
onClick={() => { window.location.href = '/api/tiktok/connect'; }}
data-testid={`button-reconnect-page-${page.id}`}
>
<RefreshCw className="w-4 h-4 mr-2" />
Reconnecter
</Button>
) : (
<Button
variant="ghost"
size="icon"
onClick={() => setEditingPage(page)}
data-testid={`button-edit-page-${page.id}`}
>
<Edit className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
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 }) {
const [platform, setPlatform] = useState<'facebook' | 'instagram'>('facebook');
const [pageName, setPageName] = useState('');
+46 -21
View File
@@ -8,12 +8,14 @@ import { Textarea } from "@/components/ui/textarea";
import { UploadCloud, Video, Loader2, Check, Sparkles, Mic, Volume2, Music, Play, Pause, Send } from "lucide-react";
import { Slider } from "@/components/ui/slider";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { DateTimePicker } from "@/components/datetime-picker";
import type { Media, SocialPage } from "@shared/schema";
import { SiFacebook, SiTiktok } from "react-icons/si";
interface AudioTrack {
@@ -67,6 +69,22 @@ export default function RemotionVideoPage() {
const { data: socialPages = [] } = useQuery<SocialPage[]>({ queryKey: ['/api/pages'] });
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 totalSelected = images.length + selectedLibraryImages.length;
@@ -76,7 +94,7 @@ export default function RemotionVideoPage() {
setGeneratedVariants(data.variants || []);
toast({ title: "Textes générés", description: "Cliquez pour appliquer." });
},
onError: () => toast({ title: "Erreur IA", variant: "destructive" }),
onError: (error: unknown) => toast({ title: "Erreur IA", description: getErrorMessage(error, "Impossible de générer le texte"), variant: "destructive" }),
});
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -376,30 +394,37 @@ export default function RemotionVideoPage() {
</CardContent>
</Card>
{/* 4. Page Facebook */}
{/* 4. Destinations */}
<Card>
<CardHeader>
<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>
<CardContent className="space-y-2">
{facebookPages.length === 0 ? (
<p className="text-sm text-muted-foreground">Aucune page Facebook connectée.</p>
{facebookPages.length === 0 && tiktokAccounts.length === 0 ? (
<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">
<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>
))
<>
{facebookPages.length > 0 && (
<div className="space-y-2">
<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]" />
Pages Facebook
</div>
{facebookPages.map(renderTargetCheckbox)}
</div>
)}
{tiktokAccounts.length > 0 && (
<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>
</Card>
@@ -432,7 +457,7 @@ export default function RemotionVideoPage() {
<p className="text-sm text-muted-foreground">Sélectionnez une page ci-dessus avant de générer.</p>
) : (
<p className="text-sm text-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>
)}
<Textarea rows={2} placeholder="Description (optionnel)" value={publishDescription}
+107
View File
@@ -12,6 +12,7 @@ import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { ModelCombobox } from "@/components/model-combobox";
import { SiTiktok } from "react-icons/si";
export default function Settings() {
const [sidebarOpen, setSidebarOpen] = useState(false);
@@ -31,6 +32,8 @@ export default function Settings() {
const [ffmpegApiKey, setFfmpegApiKey] = useState("");
const [externalApiKey, setExternalApiKey] = useState("");
const [geminiApiKey, setGeminiApiKey] = useState("");
const [tiktokClientKey, setTiktokClientKey] = useState("");
const [tiktokClientSecret, setTiktokClientSecret] = useState("");
const { toast } = useToast();
const { data: session } = useQuery<{ id: string; username: string; role: string }>({
@@ -56,6 +59,15 @@ export default function Settings() {
queryKey: ['/api/ffmpeg/config'],
});
const { data: tiktokConfig } = useQuery<{
configured: boolean;
clientKey: string;
hasClientSecret: boolean;
redirectUri: string;
}>({
queryKey: ['/api/tiktok/config'],
});
const { data: externalApiConfig } = useQuery({
queryKey: ['/api/settings/external-api'],
enabled: isAdmin,
@@ -92,6 +104,13 @@ export default function Settings() {
}
}, [ffmpegConfig]);
useEffect(() => {
if (tiktokConfig) {
// Le client secret n'est jamais renvoyé par l'API : il reste vide
setTiktokClientKey(tiktokConfig.clientKey || "");
}
}, [tiktokConfig]);
useEffect(() => {
if (geminiConfig) {
// 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({
mutationFn: () => apiRequest('DELETE', '/api/settings/external-api', {}),
onSuccess: () => {
@@ -760,6 +802,71 @@ export default function Settings() {
</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">
<CardHeader className="p-6">
<CardTitle className="flex items-center gap-2">
+11
View File
@@ -0,0 +1,11 @@
# Fichiers servis à la racine du domaine
Le contenu de ce dossier est exposé publiquement, sans authentification, à la
racine du domaine (`https://<domaine>/<nom-du-fichier>`).
Il sert aux fichiers de vérification de propriété du domaine réclamés par les
plateformes : TikTok (`tiktok<clé>.txt`, requis par « URL properties » avant de
pouvoir utiliser la Content Posting API), Google Search Console, Meta, etc.
Pour en ajouter un, il suffit de déposer le fichier ici — aucune modification de
code n'est nécessaire, et le fichier est servi sans reconstruire le frontend.
@@ -0,0 +1 @@
tiktok-developers-site-verification=Hjd5evuj6TppdUaAJmwmDg34f1fs0qMs
+11
View File
@@ -2,8 +2,19 @@ import cron from 'node-cron';
import { TokenManager } from './services/token_manager';
import { AnalyticsService } from './services/analytics';
import { purgeOldMedia } from './services/media-purge';
import { tiktokService } from './services/tiktok';
export function startTokenCron() {
// Toutes les 2 minutes — suivi des publications TikTok (API asynchrone)
cron.schedule('*/2 * * * *', async () => {
try {
await tiktokService.syncPendingPublications();
} catch (error) {
console.error('[Cron] TikTok publish status sync failed:', error);
}
});
console.log('[Cron] TikTok publish status job scheduled (*/2 * * * *).');
// Run every day at midnight (00:00) — Token health check
cron.schedule('0 0 * * *', async () => {
console.log('[Cron] Running daily token check...');
+36
View File
@@ -17,6 +17,9 @@ export async function migrate() {
// Add 'reel' to post_type enum if not exists
await client.query(`ALTER TYPE "post_type" ADD VALUE IF NOT EXISTS 'reel';`);
// Add 'tiktok' to platform enum if not exists
await client.query(`ALTER TYPE "platform" ADD VALUE IF NOT EXISTS 'tiktok';`);
// 2. Create Tables if not exist
// post_analytics
@@ -158,6 +161,39 @@ export async function migrate() {
ADD COLUMN IF NOT EXISTS "gemini_api_key" text;
`);
// media.thumbnail_url (vignette conservée après suppression de la vidéo)
await client.query(`
ALTER TABLE "media"
ADD COLUMN IF NOT EXISTS "thumbnail_url" text;
`);
// tiktok_config (application développeur TikTok, globale)
await client.query(`
CREATE TABLE IF NOT EXISTS "tiktok_config" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"client_key" text NOT NULL,
"client_secret" text NOT NULL,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
);
`);
// social_pages : champs OAuth TikTok
await client.query(`
ALTER TABLE "social_pages"
ADD COLUMN IF NOT EXISTS "refresh_token" text,
ADD COLUMN IF NOT EXISTS "refresh_token_expires_at" timestamp,
ADD COLUMN IF NOT EXISTS "scopes" text,
ADD COLUMN IF NOT EXISTS "avatar_url" text;
`);
// 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.");
} catch (error) {
console.error("[Migration] Error during migration:", error);
+32 -4
View File
@@ -1,14 +1,15 @@
import type { Express, Request, Response, NextFunction } from "express";
import express, { type Express, type Request, type Response, type NextFunction } from "express";
import { createServer, type Server } from "http";
import fs from "fs";
import os from "os";
import path from "path";
import { storage } from "./storage";
import { db, pool } from "./db";
import multer from "multer";
import bcrypt from "bcrypt";
import passport from "./auth";
import { z } from "zod";
import { openRouterService } from "./services/openrouter";
import { openRouterService, describeGenerationError } from "./services/openrouter";
import { minioService as cloudinaryService, buildMinioUrl } from "./services/minio";
import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema, insertCloudinaryConfigSchema, updateCloudinaryConfigSchema, insertOpenrouterConfigSchema, updateOpenrouterConfigSchema, insertUserSchema, postMedia, type SocialPage } from "@shared/schema";
import type { User, InsertUser, ScheduledPost } from "@shared/schema";
@@ -16,6 +17,8 @@ import { analyticsRouter } from "./routes/analytics";
import { reelsRouter } from "./routes/reels";
import { remotionRouter } from "./routes/remotion";
import { externalRouter } from "./routes/external";
import { tiktokRouter } from "./routes/tiktok";
import { legalRouter } from "./routes/legal";
import { insertAudioTrackSchema } from "@shared/schema";
import * as musicMetadata from "music-metadata";
@@ -458,6 +461,21 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Analytics Routes
app.use("/api/analytics", requireAuth, analyticsRouter);
// Fichiers servis à la racine du domaine, sans authentification : vérification
// de propriété du domaine par TikTok ("URL properties"), Google, Meta…
// Monté ici pour être servi avant le catch-all du frontend, et sans dépendre
// d'une reconstruction du client.
app.use(express.static(path.resolve(import.meta.dirname, "..", "public-root"), {
dotfiles: "ignore",
index: false,
}));
// Pages légales publiques (exigées par TikTok pour l'audit de l'application)
app.use(legalRouter);
// TikTok Routes (OAuth multi-comptes) — avant le routeur Reels monté sur /api
app.use("/api/tiktok", requireAuth, tiktokRouter);
// Reels & Music Routes
app.use("/api", requireAuth, reelsRouter);
app.use("/api/remotion", requireAuth, remotionRouter);
@@ -613,7 +631,8 @@ export async function registerRoutes(app: Express): Promise<Server> {
res.json({ variants: generatedTexts });
} catch (error) {
console.error("Error generating text:", error);
res.status(500).json({ error: "Failed to generate text" });
const { status, message } = describeGenerationError(error);
res.status(status).json({ error: message });
}
});
@@ -1565,6 +1584,14 @@ export async function registerRoutes(app: Express): Promise<Server> {
const user = req.user as User;
const userId = user.id;
// TikTok n'autorise pas la saisie manuelle d'un token : le compte doit
// passer par le flux OAuth (/api/tiktok/connect).
if (req.body?.platform === 'tiktok') {
return res.status(400).json({
error: "Un compte TikTok se connecte via l'autorisation TikTok, pas par saisie manuelle d'un jeton.",
});
}
// Calculate token expiration date (60 days from now)
const tokenExpiresAt = new Date();
tokenExpiresAt.setDate(tokenExpiresAt.getDate() + 60);
@@ -1598,7 +1625,8 @@ export async function registerRoutes(app: Express): Promise<Server> {
// If accessToken is being updated, recalculate expiration date (60 days from now)
// 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();
tokenExpiresAt.setDate(tokenExpiresAt.getDate() + 60);
pageData = {
+3 -3
View File
@@ -137,7 +137,7 @@ router.post("/publish", async (req, res) => {
instagramFeedUrl: uploaded.instagramFeedUrl,
instagramStoryUrl: uploaded.instagramStoryUrl,
fileName,
fileSize: String(buffer.length),
fileSize: buffer.length,
});
}
@@ -149,7 +149,7 @@ router.post("/publish", async (req, res) => {
content,
status: scheduledFor ? "scheduled" : "scheduled",
scheduledFor: scheduledFor ?? new Date(),
aiGenerated: false,
aiGenerated: "false",
});
// Lier le média si présent
@@ -415,7 +415,7 @@ router.patch("/posts/:id", async (req, res) => {
instagramFeedUrl: uploaded.instagramFeedUrl,
instagramStoryUrl: uploaded.instagramStoryUrl,
fileName,
fileSize: String(buffer.length),
fileSize: buffer.length,
});
// Remplacer les médias existants par le nouveau
+269
View File
@@ -0,0 +1,269 @@
/**
* Pages légales publiques (conditions d'utilisation et politique de confidentialité).
*
* TikTok exige une URL publique pour chacune lors de la création de l'application
* développeur, et les relecteurs les ouvrent réellement pendant l'audit. Elles sont
* servies en HTML depuis le serveur, sans authentification, pour rester lisibles
* même par un robot qui n'exécute pas le JavaScript du client.
*
* Les mentions de l'exploitant sont préremplies avec l'identité de la société
* exploitante et restent surchargeables par variables d'environnement :
* LEGAL_COMPANY_NAME, LEGAL_TRADE_NAME, LEGAL_COMPANY_LEGAL_FORM,
* LEGAL_COMPANY_RCS, LEGAL_COMPANY_ADDRESS, LEGAL_CONTACT_EMAIL
*/
import { Router, Request, Response } from 'express';
export const legalRouter = Router();
function getCompanyInfo() {
return {
name: process.env.LEGAL_COMPANY_NAME || 'FROUARD DISTRIBUTION',
tradeName: process.env.LEGAL_TRADE_NAME || "LA FOIR'FOUILLE",
legalForm: process.env.LEGAL_COMPANY_LEGAL_FORM
|| 'Société par actions simplifiée au capital de 50 000 €',
rcs: process.env.LEGAL_COMPANY_RCS || '478 693 518 R.C.S. Nancy',
address: process.env.LEGAL_COMPANY_ADDRESS || '13 rue du Saule Gaillard, 54390 Frouard',
// Aucune adresse de contact ne figure au registre : elle doit être fournie,
// le RGPD imposant un point de contact pour l'exercice des droits.
email: process.env.LEGAL_CONTACT_EMAIL || '[Adresse e-mail de contact à renseigner]',
};
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function renderPage(title: string, body: string): string {
return `<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)}</title>
<style>
:root { color-scheme: light dark; }
body {
max-width: 46rem;
margin: 0 auto;
padding: 2.5rem 1.25rem 4rem;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.65;
color: #1f2328;
background: #ffffff;
}
h1 { font-size: 1.75rem; margin-bottom: 0.25rem; }
h2 { font-size: 1.15rem; margin-top: 2.25rem; }
.updated { color: #6b7280; font-size: 0.875rem; margin-top: 0; }
ul { padding-left: 1.25rem; }
li { margin-bottom: 0.35rem; }
code { background: rgba(127, 127, 127, 0.15); padding: 0.1rem 0.3rem; border-radius: 0.25rem; font-size: 0.9em; }
footer { margin-top: 3rem; padding-top: 1.25rem; border-top: 1px solid rgba(127,127,127,0.3); color: #6b7280; font-size: 0.875rem; }
a { color: #2563eb; }
@media (prefers-color-scheme: dark) {
body { color: #e6e6e6; background: #0f1115; }
.updated, footer { color: #9ca3af; }
a { color: #7aa7ff; }
}
</style>
</head>
<body>
${body}
</body>
</html>`;
}
function renderFooter(): string {
const company = getCompanyInfo();
return `<footer>
<p>
${escapeHtml(company.name)} &ndash; enseigne ${escapeHtml(company.tradeName)}<br>
${escapeHtml(company.legalForm)}<br>
${escapeHtml(company.address)}<br>
${escapeHtml(company.rcs)}<br>
Contact : ${escapeHtml(company.email)}
</p>
<p><a href="/terms">Conditions d'utilisation</a> &middot; <a href="/privacy">Politique de confidentialité</a></p>
</footer>`;
}
legalRouter.get('/terms', (_req: Request, res: Response) => {
const company = getCompanyInfo();
const body = `
<h1>Conditions d'utilisation</h1>
<p class="updated">Dernière mise à jour : 11 août 2026</p>
<p>
Cette application est un outil interne de gestion des réseaux sociaux exploité par
${escapeHtml(company.name)}, ${escapeHtml(company.legalForm)}, dont le siège est situé
${escapeHtml(company.address)}, immatriculée au ${escapeHtml(company.rcs)}, qui exploite le
magasin sous l'enseigne ${escapeHtml(company.tradeName)} (« nous »). Elle permet à nos équipes
de créer des vidéos et des publications promotionnelles, puis de les diffuser sur les comptes
et pages officiels de nos magasins.
</p>
<h2>1. Accès au service</h2>
<p>
L'accès est réservé aux personnes que nous avons expressément autorisées, au moyen d'un compte
nominatif. Chaque utilisateur est responsable de la confidentialité de ses identifiants et des
actions effectuées depuis son compte. Nous pouvons suspendre ou retirer un accès à tout moment.
</p>
<h2>2. Comptes de réseaux sociaux connectés</h2>
<p>
L'application publie sur des comptes Facebook et TikTok qui nous appartiennent ou que nous
sommes habilités à administrer. En connectant un compte, l'utilisateur déclare disposer des
droits nécessaires pour le faire. Un compte peut être déconnecté à tout moment depuis
l'application, ou par le retrait de l'autorisation directement dans les réglages de la
plateforme concernée.
</p>
<h2>3. Contenus publiés</h2>
<p>
Les utilisateurs restent responsables des contenus qu'ils créent et diffusent. Ces contenus
doivent respecter la loi applicable ainsi que les règles des plateformes de destination,
notamment les Conditions d'utilisation et les Règles communautaires de TikTok et de Meta.
Sont notamment proscrits les contenus dont les utilisateurs ne détiennent pas les droits,
y compris pour les images et les musiques utilisées.
</p>
<h2>4. Disponibilité</h2>
<p>
Le service est fourni « en l'état ». Il dépend de services tiers (plateformes de publication,
hébergement, traitement vidéo) dont les interruptions ou modifications peuvent affecter son
fonctionnement. Nous ne garantissons ni une disponibilité continue, ni la publication effective
d'un contenu par une plateforme tierce, celle-ci pouvant la refuser selon ses propres règles.
</p>
<h2>5. Responsabilité</h2>
<p>
Dans les limites permises par la loi, notre responsabilité ne saurait être engagée au titre des
dommages indirects résultant de l'utilisation du service, notamment une perte de contenu, une
publication manquée ou la suspension d'un compte par une plateforme tierce.
</p>
<h2>6. Modification des conditions</h2>
<p>
Ces conditions peuvent être modifiées. La date de dernière mise à jour figure en tête de page.
</p>
<h2>7. Contact</h2>
<p>Pour toute question relative à ces conditions : ${escapeHtml(company.email)}</p>
${renderFooter()}`;
res.type('html').send(renderPage("Conditions d'utilisation", body));
});
legalRouter.get('/privacy', (_req: Request, res: Response) => {
const company = getCompanyInfo();
const body = `
<h1>Politique de confidentialité</h1>
<p class="updated">Dernière mise à jour : 11 août 2026</p>
<p>
Cette politique décrit les données traitées par notre outil interne de gestion des réseaux
sociaux. Le responsable de traitement est ${escapeHtml(company.name)},
${escapeHtml(company.legalForm)}, dont le siège est situé ${escapeHtml(company.address)},
immatriculée au ${escapeHtml(company.rcs)}, exploitant le magasin sous l'enseigne
${escapeHtml(company.tradeName)}.
</p>
<h2>1. Données de nos utilisateurs internes</h2>
<p>
Pour chaque personne autorisée à utiliser l'outil, nous conservons un identifiant de connexion,
un mot de passe stocké sous forme chiffrée, un rôle et la liste des comptes auxquels elle a
accès. Ces données servent uniquement à l'authentification et à la gestion des droits.
</p>
<h2>2. Données issues des comptes de réseaux sociaux connectés</h2>
<p>
Lorsqu'un compte TikTok est connecté, nous recevons de TikTok, avec l'autorisation explicite du
titulaire du compte et dans le cadre du périmètre <code>user.info.basic</code> :
</p>
<ul>
<li>l'identifiant technique du compte (<code>open_id</code>) ;</li>
<li>le nom d'affichage du compte ;</li>
<li>l'image de profil du compte ;</li>
<li>un jeton d'accès et un jeton de renouvellement, qui nous permettent de publier en son nom.</li>
</ul>
<p>
Nous procédons de manière équivalente pour les pages Facebook connectées. Nous ne collectons ni
la liste des abonnés, ni les messages privés, ni les données de navigation des visiteurs de ces
comptes.
</p>
<h2>3. Finalités</h2>
<p>
Ces données sont utilisées exclusivement pour identifier le compte de destination dans
l'interface et y publier les vidéos et publications créées par nos équipes, immédiatement ou à
l'heure programmée. Elles ne servent à aucune autre finalité.
</p>
<h2>4. Données que nous transmettons aux plateformes</h2>
<p>
Lors d'une publication, nous transmettons à la plateforme concernée le fichier vidéo ou image,
le texte de la publication, ainsi que les paramètres de diffusion associés (niveau de
confidentialité, autorisation des commentaires). Ces contenus sont ensuite régis par la
politique de confidentialité de la plateforme concernée.
</p>
<h2>5. Conservation et sécurité</h2>
<ul>
<li>Les jetons d'accès et de renouvellement sont chiffrés au repos (AES-256-GCM).</li>
<li>
Ils sont conservés tant que le compte reste connecté à l'application, et supprimés
immédiatement lorsque le compte en est retiré.
</li>
<li>Les médias sources sont supprimés automatiquement après leur publication et leur péremption.</li>
<li>L'accès à l'application est restreint aux personnes autorisées et protégé par mot de passe.</li>
</ul>
<h2>6. Partage avec des tiers</h2>
<p>
Nous ne vendons ni ne louons ces données, et ne les transmettons à aucun tiers à des fins
publicitaires ou de profilage. Elles ne sont communiquées qu'aux plateformes de destination
(TikTok, Meta) dans la stricte mesure nécessaire à la publication demandée, ainsi qu'à nos
prestataires techniques d'hébergement et de traitement vidéo agissant sur nos instructions.
</p>
<h2>7. Retirer une autorisation</h2>
<p>
Le titulaire d'un compte connecté peut retirer son autorisation à tout moment :
</p>
<ul>
<li>en demandant la déconnexion du compte depuis l'application, ce qui supprime les jetons ;</li>
<li>
ou directement dans TikTok, via <em>Profil &rsaquo; Paramètres et confidentialité &rsaquo;
Sécurité et autorisations &rsaquo; Gérer les autorisations des applications</em>.
</li>
</ul>
<p>
Le retrait de l'autorisation empêche toute publication ultérieure. Il n'entraîne pas la
suppression des contenus déjà publiés sur la plateforme, qui doivent être supprimés depuis
celle-ci.
</p>
<h2>8. Vos droits</h2>
<p>
Conformément au Règlement général sur la protection des données, vous disposez d'un droit
d'accès, de rectification, d'effacement, de limitation et de portabilité des données vous
concernant, ainsi que du droit d'introduire une réclamation auprès de la CNIL. Pour exercer ces
droits, écrivez à ${escapeHtml(company.email)}.
</p>
<h2>9. Modification de cette politique</h2>
<p>
Cette politique peut être mise à jour. La date de dernière mise à jour figure en tête de page.
</p>
${renderFooter()}`;
res.type('html').send(renderPage('Politique de confidentialité', body));
});
+61 -17
View File
@@ -7,13 +7,28 @@ import type { User, Media, SocialPage } from '@shared/schema';
import { storage } from '../storage';
import { ffmpegService } from '../services/ffmpeg';
import { facebookService } from '../services/facebook';
import { tiktokService } from '../services/tiktok';
import { createVideoThumbnail } from '../services/thumbnail';
import { minioService as cloudinaryService, buildMinioUrl, resolveInternalUrl } from '../services/minio';
import { openRouterService } from '../services/openrouter';
import { openRouterService, describeGenerationError } from '../services/openrouter';
import { db } from '../db';
import { cloudinaryConfig } from '@shared/schema';
import { eq } from 'drizzle-orm';
import { ttsSyncService } from '../services/ttsSync';
/** Piste musicale telle qu'attendue par le client. */
interface MusicTrack {
id: string;
title: string;
artist: string;
albumName: string;
duration: number;
previewUrl: string;
downloadUrl: string;
imageUrl: string;
license: string;
}
export const reelsRouter = Router();
// ============================================
@@ -253,7 +268,8 @@ reelsRouter.post('/reels/generate-text', async (req: Request, res: Response) =>
res.json({ variants: generatedTexts });
} catch (error) {
console.error('❌ Error generating Reel text:', error);
res.status(500).json({ error: 'Erreur lors de la génération du texte' });
const { status, message } = describeGenerationError(error);
res.status(status).json({ error: message });
}
});
@@ -614,6 +630,10 @@ async function processReelBackground(
console.log('✅ [Background] Uploaded:', cloudinaryResult.originalUrl);
// 5. Créer l'enregistrement Media pour la vidéo traitée
// La vignette est extraite maintenant : la vidéo sera supprimée du disque
// après publication, mais l'historique doit rester illustré.
const thumbnailUrl = await createVideoThumbnail(videoBuffer);
const processedMedia = await storage.createMedia({
userId: userId,
type: 'video',
@@ -622,6 +642,7 @@ async function processReelBackground(
facebookFeedUrl: cloudinaryResult.facebookFeedUrl || null,
instagramFeedUrl: cloudinaryResult.instagramFeedUrl || null,
instagramStoryUrl: cloudinaryResult.instagramStoryUrl || null,
thumbnailUrl,
fileName: `reel-processed-${Date.now()}.mp4`,
fileSize: videoBuffer.length,
});
@@ -642,8 +663,12 @@ async function processReelBackground(
continue;
}
if (page.platform !== 'facebook') {
results.push({ pageId, success: false, error: 'Seules les pages Facebook sont supportées' });
if (page.platform !== 'facebook' && page.platform !== 'tiktok') {
results.push({
pageId,
success: false,
error: `Plateforme non supportée pour les reels : ${page.platform}`,
});
continue;
}
@@ -656,23 +681,42 @@ async function processReelBackground(
});
if (!scheduledFor) {
// Publication immédiate
console.log(`🚀 [Background] Publishing to Page ${page.pageName}...`);
// Publication immédiate — la même vidéo part sur chaque destination
console.log(`🚀 [Background] Publishing to ${page.platform} ${page.pageName}...`);
const finalDescription = description || overlayText || '';
const reelId = await facebookService.publishReel(
page,
cloudinaryResult.originalUrl,
finalDescription
);
if (page.platform === 'facebook') {
const reelId = await facebookService.publishReel(
page,
cloudinaryResult.originalUrl,
finalDescription
);
// Mise à jour succès
await storage.updateScheduledPost(scheduledPost.id, {
publishedAt: new Date(),
externalPostId: reelId,
});
// Mise à jour succès
await storage.updateScheduledPost(scheduledPost.id, {
publishedAt: new Date(),
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 {
// Planifié
results.push({ pageId, success: true, reelId: 'scheduled' });
+31 -27
View File
@@ -2,34 +2,16 @@ import { Router } from "express";
import multer from "multer";
import path from "path";
import fs from "fs";
import { exec } from "child_process";
import { promisify } from "util";
import { bundle } from "@remotion/bundler";
import { renderMedia, selectComposition } from "@remotion/renderer";
import { ffmpegService } from "../services/ffmpeg";
import { storage as dbStorage } from "../storage";
import { minioService as cloudinaryService, buildMinioUrl } from "../services/minio";
import { facebookService } from "../services/facebook";
import { tiktokService } from "../services/tiktok";
import { generateVideoThumbnail, createVideoThumbnail } from "../services/thumbnail";
import * as musicMetadata from "music-metadata";
const execAsync = promisify(exec);
/**
* Generate a thumbnail from a video file using FFmpeg
* @param videoPath Path to the video file
* @param outputPath Path to save the thumbnail
* @param seekTime Time in seconds to extract the frame (default: 1s)
*/
async function generateVideoThumbnail(videoPath: string, outputPath: string, seekTime: number = 1): Promise<boolean> {
try {
const cmd = `ffmpeg -y -ss ${seekTime} -i "${videoPath}" -vframes 1 -q:v 2 "${outputPath}"`;
await execAsync(cmd);
return fs.existsSync(outputPath);
} catch (error) {
console.warn('⚠️ Failed to generate video thumbnail:', error);
return false;
}
}
export const remotionRouter = Router();
@@ -325,10 +307,12 @@ remotionRouter.post("/render", upload.fields([{ name: "images", maxCount: 4 }, {
codec: "h264",
outputLocation,
inputProps,
// Ne pas y remettre "args" : Remotion ne lit jamais cette clé (elle est
// absente de ChromiumOptions et de son code), les drapeaux passés là
// n'atteignaient donc jamais le navigateur.
chromiumOptions: {
disableWebSecurity: true,
ignoreCertificateErrors: true,
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu"],
},
concurrency: 1, // Limit concurrency to prevent Docker memory exhaustion
});
@@ -407,6 +391,9 @@ remotionRouter.post("/publish", async (req, res) => {
});
// Create a Media record for the processed video
// Vignette extraite avant que la vidéo ne soit purgée du disque
const thumbnailUrl = await createVideoThumbnail(localPath);
const mediaRecord = await dbStorage.createMedia({
userId: user.id,
type: "video",
@@ -415,6 +402,7 @@ remotionRouter.post("/publish", async (req, res) => {
facebookFeedUrl: null,
instagramFeedUrl: null,
instagramStoryUrl: null,
thumbnailUrl,
fileName: filename,
fileSize: fs.statSync(localPath).size,
});
@@ -427,7 +415,10 @@ remotionRouter.post("/publish", async (req, res) => {
try {
const page = await dbStorage.getSocialPage(pageId);
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({
postId: post.id,
@@ -437,12 +428,25 @@ remotionRouter.post("/publish", async (req, res) => {
});
if (!scheduledFor) {
console.log(`🚀 Publishing to ${page.pageName} (direct binary upload)...`);
// Upload video bytes directly — Facebook cannot access local URLs
console.log(`🚀 Publishing to ${page.platform} ${page.pageName} (direct binary upload)...`);
// Upload video bytes directly — les APIs ne peuvent pas lire une URL locale
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 });
results.push({ pageId, success: true, reelId });
if (page.platform === "facebook") {
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 {
results.push({ pageId, success: true, reelId: "scheduled" });
}
+226
View File
@@ -0,0 +1,226 @@
/**
* Routes de gestion des comptes TikTok.
*
* Un compte TikTok de magasin se connecte en OAuth (TikTok n'autorise pas la
* saisie manuelle d'un token), puis vit dans `social_pages` comme une page
* Facebook : mêmes permissions, même planification, même calendrier.
*/
import crypto from 'crypto';
import { Router, Request, Response } from 'express';
import type { User } from '@shared/schema';
import { storage } from '../storage';
import { tiktokService } from '../services/tiktok';
import { insertTiktokConfigSchema, updateTiktokConfigSchema } from '@shared/schema';
export const tiktokRouter = Router();
/** Page vers laquelle on renvoie le navigateur à la fin du flux OAuth. */
const RETURN_PATH = '/pages';
function isAdmin(req: Request): boolean {
const user = req.user as User | undefined;
return user?.role === 'admin';
}
function buildReturnUrl(status: 'success' | 'error', message: string): string {
const params = new URLSearchParams({ tiktok: status, message });
return `${RETURN_PATH}?${params.toString()}`;
}
/**
* État de la configuration TikTok.
* Le client secret n'est jamais renvoyé au client.
*/
tiktokRouter.get('/config', async (req: Request, res: Response) => {
try {
const config = await storage.getTiktokConfig();
const configured = await tiktokService.isConfigured();
// Tout le monde a besoin de savoir si la connexion est possible ; seuls les
// admins voient les détails de l'application développeur.
if (!isAdmin(req)) {
return res.json({ configured });
}
res.json({
configured,
clientKey: config?.clientKey || (process.env.TIKTOK_CLIENT_KEY ? '(défini par variable d\'environnement)' : ''),
hasClientSecret: !!(config?.clientSecret || process.env.TIKTOK_CLIENT_SECRET),
redirectUri: tiktokService.getRedirectUri(),
});
} 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',
});
}
});
+5 -1
View File
@@ -48,6 +48,8 @@ export class AnalyticsService {
for (const instance of publishedInstances) {
if (!instance.externalPostId || !instance.page) continue;
// Les métriques TikTok ne sont pas exposées par la Graph API
if (instance.page.platform === 'tiktok') continue;
try {
// We need the page access token
@@ -278,7 +280,9 @@ export class AnalyticsService {
*/
static async syncAllPages(): Promise<void> {
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) {
try {
+6
View File
@@ -43,6 +43,12 @@ export async function purgeOldMedia() {
if (item.cloudinaryPublicId) {
await storageService.deleteMedia(item.cloudinaryPublicId, item.userId, item.type === 'video' ? 'video' : 'image');
}
// La vignette n'a plus de raison d'être une fois la ligne supprimée
if (item.thumbnailUrl?.startsWith('/uploads/')) {
await storageService
.deleteMedia(item.thumbnailUrl.replace('/uploads/', ''), item.userId, 'image')
.catch(err => console.warn(`[Media Purge] Vignette non supprimée pour ${item.id}:`, err));
}
await db.delete(media).where(eq(media.id, item.id));
successCount++;
} catch (err) {
+36
View File
@@ -40,6 +40,27 @@ export function resolveInternalUrl(url: string): string {
return `${base}${url}`;
}
/**
* Charge un média en mémoire, que son URL soit locale (/uploads/...) ou distante.
* Nécessaire pour les APIs qui veulent recevoir le fichier plutôt qu'une URL
* (upload TikTok, Reels Facebook).
*/
export async function readMediaBuffer(url: string): Promise<Buffer> {
if (url.startsWith('/')) {
const localPath = path.join(process.cwd(), url);
if (fs.existsSync(localPath)) {
return await fs.promises.readFile(localPath);
}
}
const absoluteUrl = resolvePublicUrl(url);
const response = await fetch(absoluteUrl);
if (!response.ok) {
throw new Error(`Impossible de récupérer le média depuis ${absoluteUrl} (${response.status})`);
}
return Buffer.from(await response.arrayBuffer());
}
class LocalStorageService {
async uploadMedia(
file: Buffer | string,
@@ -85,6 +106,21 @@ class LocalStorageService {
return buildMinioUrl('', objectKey);
}
/**
* Range une vignette vidéo. Elle survit volontairement à la suppression de la
* vidéo source, pour que l'historique reste illustré.
*/
async uploadThumbnail(buffer: Buffer, fileName: string): Promise<{ publicId: string; url: string }> {
const dir = path.join(UPLOADS_BASE, 'thumbnails');
ensureDir(dir);
const safeName = path.basename(fileName).replace(/[^a-zA-Z0-9._-]/g, '-');
const objectKey = `thumbnails/${safeName}`;
fs.writeFileSync(path.join(UPLOADS_BASE, objectKey), buffer);
return { publicId: objectKey, url: buildMinioUrl('', objectKey) };
}
async uploadLogo(buffer: Buffer, fileName: string): Promise<{ publicId: string; url: string }> {
const dir = path.join(UPLOADS_BASE, 'logos');
ensureDir(dir);
+50 -1
View File
@@ -15,6 +15,49 @@ interface GeneratedText {
import crypto from 'crypto';
import { storage } from '../storage';
/**
* Extrait le message d'erreur d'une réponse OpenRouter, qu'elle soit au format
* JSON ({ error: { message } }) ou en texte brut. Tronqué pour éviter de
* déverser une page HTML entière dans l'interface.
*/
function extractUpstreamMessage(body: string): string {
const trimmed = (body || '').trim();
if (!trimmed) return 'aucun détail fourni';
try {
const parsed = JSON.parse(trimmed);
const message = parsed?.error?.message || parsed?.message || parsed?.error;
if (typeof message === 'string' && message.trim()) {
return message.trim().slice(0, 300);
}
} catch {
// Réponse non-JSON (page d'erreur HTML d'un proxy, par exemple)
}
return trimmed.replace(/\s+/g, ' ').slice(0, 300);
}
/**
* Traduit une erreur de génération en réponse HTTP exploitable côté interface.
* Sans cela, l'utilisateur ne voit qu'un 500 générique alors que la cause
* (configuration absente, clé invalide, crédits épuisés) est connue du serveur.
*/
export function describeGenerationError(error: unknown): { status: number; message: string } {
const message = error instanceof Error ? error.message : 'Erreur inconnue lors de la génération';
// Problème de configuration : l'administrateur peut le corriger lui-même
if (/Configuration OpenRouter non trouvée|Clé API OpenRouter manquante/.test(message)) {
return { status: 400, message };
}
// Refus du service tiers
if (/OpenRouter a refusé la requête|Réponse invalide de l'API OpenRouter/.test(message)) {
return { status: 502, message };
}
return { status: 500, message };
}
export class OpenRouterService {
private baseUrl = "https://openrouter.ai/api/v1/chat/completions";
@@ -64,7 +107,13 @@ export class OpenRouterService {
if (!response.ok) {
const errorText = await response.text();
console.error('OpenRouter API Error Response:', errorText);
throw new Error(`OpenRouter API error: ${response.status} ${response.statusText}`);
// Le motif renvoyé par OpenRouter (clé invalide, crédits épuisés, modèle
// inconnu…) est la seule information exploitable : on le fait remonter
// jusqu'à l'utilisateur, qui n'a pas accès aux logs du serveur.
throw new Error(
`OpenRouter a refusé la requête (${response.status} ${response.statusText}) ` +
`avec le modèle "${modelToUse}" : ${extractUpstreamMessage(errorText)}`
);
}
const data = await response.json();
+45 -13
View File
@@ -1,7 +1,8 @@
import cron, { ScheduledTask } from "node-cron";
import { storage } from "../storage";
import { facebookService } from "./facebook";
import { minioService as storageService } from "./minio";
import { tiktokService } from "./tiktok";
import { minioService as storageService, readMediaBuffer } from "./minio";
export class SchedulerService {
private task: ScheduledTask | null = null;
@@ -94,11 +95,28 @@ export class SchedulerService {
return;
}
// Publish to Facebook/Instagram
let externalPostId: string;
// Publish to Facebook/Instagram/TikTok
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') {
// Instagram publishing not yet implemented
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}`);
}
// Update the scheduled post as published (clear any previous error)
await storage.updateScheduledPost(scheduledPost.id, {
publishedAt: new Date(),
externalPostId,
error: null,
});
// Update post status
await storage.updatePost(post.id, {
status: "published",
@@ -143,7 +154,28 @@ export class SchedulerService {
}
}
console.log(`Successfully published post ${post.id} with external ID: ${externalPostId}`);
console.log(`Successfully published post ${post.id} to ${page.platform} page ${page.pageName}`);
}
/**
* Envoie la vidéo d'un post planifié sur un compte TikTok.
* TikTok ne sait pas programmer une publication : c'est notre planificateur qui
* déclenche l'envoi à l'heure voulue.
*
* @returns le publish_id à suivre pour connaître le résultat final
*/
private async publishToTiktok(post: any, page: any, mediaList: any[]): Promise<string> {
const videoMedia = mediaList.find(m => m.type === 'video');
if (!videoMedia) {
throw new Error(
`Le compte TikTok "${page.pageName}" ne peut recevoir que des vidéos : ce post n'en contient pas.`
);
}
const videoBuffer = await readMediaBuffer(videoMedia.originalUrl);
return await tiktokService.publishVideoFromBuffer(page, videoBuffer, post.content);
}
}
+99
View File
@@ -0,0 +1,99 @@
/**
* Génération de vignettes vidéo.
*
* Les vidéos sources sont supprimées du disque une fois publiées (le
* planificateur libère la place, la purge quotidienne fait le reste), alors que
* la ligne `media` survit pour l'historique. Sans vignette persistée, l'interface
* tente alors de lire un fichier disparu : d'où les « Video thumbnail generation
* failed » et les tuiles vides.
*
* On extrait donc une image au moment de la création du média, et elle reste
* disponible après la disparition de la vidéo.
*/
import fs from 'fs';
import path from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
import { minioService } from './minio';
const execAsync = promisify(exec);
const TEMP_DIR = path.join(process.cwd(), 'uploads', 'temp');
/**
* Extrait une image d'une vidéo avec FFmpeg.
*
* @param videoPath chemin du fichier vidéo
* @param outputPath chemin de l'image à écrire
* @param seekTime instant de la capture, en secondes
* @returns true si l'image a bien été écrite
*/
export async function generateVideoThumbnail(
videoPath: string,
outputPath: string,
seekTime: number = 1
): Promise<boolean> {
try {
const cmd = `ffmpeg -y -ss ${seekTime} -i "${videoPath}" -vframes 1 -q:v 2 "${outputPath}"`;
await execAsync(cmd);
return fs.existsSync(outputPath);
} catch (error) {
console.warn('⚠️ Failed to generate video thumbnail:', error);
return false;
}
}
/**
* Produit la vignette d'une vidéo et la range dans le stockage public.
*
* Tolérante à l'échec : une vignette manquante dégrade l'affichage mais ne doit
* jamais faire échouer une publication.
*
* @param source chemin du fichier vidéo, ou son contenu en mémoire
* @returns l'URL publique de la vignette, ou null si l'extraction a échoué
*/
export async function createVideoThumbnail(source: string | Buffer): Promise<string | null> {
const stamp = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
const outputPath = path.join(TEMP_DIR, `thumb-${stamp}.jpg`);
// Une vidéo reçue en mémoire doit d'abord être posée sur le disque pour FFmpeg
let videoPath: string;
let tempVideoPath: string | null = null;
try {
if (!fs.existsSync(TEMP_DIR)) {
fs.mkdirSync(TEMP_DIR, { recursive: true });
}
if (Buffer.isBuffer(source)) {
tempVideoPath = path.join(TEMP_DIR, `thumb-src-${stamp}.mp4`);
await fs.promises.writeFile(tempVideoPath, source);
videoPath = tempVideoPath;
} else {
videoPath = source;
if (!fs.existsSync(videoPath)) {
console.warn(`⚠️ [Thumbnail] Vidéo introuvable pour la vignette : ${videoPath}`);
return null;
}
}
const ok = await generateVideoThumbnail(videoPath, outputPath);
if (!ok) return null;
const buffer = await fs.promises.readFile(outputPath);
const { url } = await minioService.uploadThumbnail(buffer, `thumb-${stamp}.jpg`);
console.log(`🖼️ [Thumbnail] Vignette générée : ${url}`);
return url;
} catch (error) {
console.warn('⚠️ [Thumbnail] Génération de la vignette impossible :', error);
return null;
} finally {
for (const file of [outputPath, tempVideoPath]) {
if (file) {
await fs.promises.unlink(file).catch(() => { /* déjà absent */ });
}
}
}
}
+568
View File
@@ -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();
+36
View File
@@ -60,6 +60,12 @@ export class TokenManager {
}
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;
// 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> {
try {
// Simple validation check: Get 'me' (page profile)
+96 -23
View File
@@ -36,11 +36,38 @@ import {
type InsertAudioTrack,
appConfig,
type AppConfig,
tiktokConfig,
type TiktokConfig,
type InsertTiktokConfig,
} from "@shared/schema";
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";
/**
* 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 {
// Users
getUser(id: string): Promise<User | undefined>;
@@ -85,11 +112,16 @@ export interface IStorage {
updateScheduledPost(id: string, scheduledPost: Partial<ScheduledPost>): Promise<ScheduledPost>;
deleteScheduledPost(id: string): Promise<void>;
getPendingScheduledPosts(): Promise<ScheduledPost[]>;
getScheduledPostsAwaitingPublishStatus(): Promise<ScheduledPost[]>;
// AI Generations
getAiGenerations(userId: string): Promise<AiGeneration[]>;
createAiGeneration(generation: InsertAiGeneration): Promise<AiGeneration>;
// TikTok Config
getTiktokConfig(): Promise<TiktokConfig | undefined>;
upsertTiktokConfig(config: Partial<InsertTiktokConfig>): Promise<TiktokConfig>;
// Cloudinary Config
getCloudinaryConfig(): Promise<CloudinaryConfig | undefined>;
createCloudinaryConfig(config: InsertCloudinaryConfig): Promise<CloudinaryConfig>;
@@ -160,43 +192,36 @@ export class DatabaseStorage implements IStorage {
// Social Pages
async getSocialPages(userId: string): Promise<SocialPage[]> {
const pages = await db.select().from(socialPages).where(eq(socialPages.userId, userId));
// Déchiffrer les tokens pour chaque page
return pages.map(page => ({
...page,
accessToken: decrypt(page.accessToken)
}));
return pages.map(page => decryptPageTokens(page));
}
async getSocialPage(id: string): Promise<SocialPage | undefined> {
const [page] = await db.select().from(socialPages).where(eq(socialPages.id, id));
if (page) {
// Déchiffrer le token
page.accessToken = decrypt(page.accessToken);
}
return page || undefined;
return page ? decryptPageTokens(page) : undefined;
}
async createSocialPage(page: InsertSocialPage): Promise<SocialPage> {
// Chiffrer le token avant stockage
// Chiffrer les tokens avant stockage
const encryptedPage = {
...page,
accessToken: encrypt(page.accessToken)
accessToken: encrypt(page.accessToken),
...(page.refreshToken ? { refreshToken: encrypt(page.refreshToken) } : {}),
};
const [newPage] = await db.insert(socialPages).values(encryptedPage).returning();
// Retourner avec le token déchiffré
newPage.accessToken = decrypt(newPage.accessToken);
return newPage;
return decryptPageTokens(newPage);
}
async updateSocialPage(id: string, page: Partial<SocialPage>): Promise<SocialPage> {
// Chiffrer le token si présent dans la mise à jour
const updateData = page.accessToken
? { ...page, accessToken: encrypt(page.accessToken) }
: page;
// Chiffrer les tokens présents dans la mise à jour
const updateData: Partial<SocialPage> = { ...page };
if (page.accessToken) {
updateData.accessToken = encrypt(page.accessToken);
}
if (page.refreshToken) {
updateData.refreshToken = encrypt(page.refreshToken);
}
const [updated] = await db.update(socialPages).set(updateData).where(eq(socialPages.id, id)).returning();
// Retourner avec le token déchiffré
updated.accessToken = decrypt(updated.accessToken);
return updated;
return decryptPageTokens(updated);
}
async deleteSocialPage(id: string): Promise<void> {
@@ -422,6 +447,22 @@ export class DatabaseStorage implements IStorage {
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
async getAiGenerations(userId: string): Promise<AiGeneration[]> {
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;
}
// 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> {
const existing = await this.getAppConfig();
if (existing) {
+44 -1
View File
@@ -4,7 +4,7 @@ import { relations } from "drizzle-orm";
import { createInsertSchema } from "drizzle-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 postStatusEnum = pgEnum("post_status", ["draft", "scheduled", "published", "failed"]);
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()`),
userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
platform: platformEnum("platform").notNull(),
// Identifiant natif de la destination : page_id (Facebook) ou open_id (TikTok)
pageId: text("page_id").notNull(),
pageName: text("page_name").notNull(),
accessToken: text("access_token").notNull(),
@@ -37,6 +38,13 @@ export const socialPages = pgTable("social_pages", {
tokenStatus: tokenStatusEnum("token_status").default("valid"),
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(),
});
@@ -133,6 +141,16 @@ export const openrouterConfig = pgTable("openrouter_config", {
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", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
@@ -143,6 +161,9 @@ export const media = pgTable("media", {
facebookFeedUrl: text("facebook_feed_url"),
instagramFeedUrl: text("instagram_feed_url"),
instagramStoryUrl: text("instagram_story_url"),
// Image extraite d'une vidéo, conservée après la suppression du fichier source
// pour que l'historique reste illustré.
thumbnailUrl: text("thumbnail_url"),
fileName: text("file_name").notNull(),
fileSize: integer("file_size").notNull(),
createdAt: timestamp("created_at").defaultNow(),
@@ -180,6 +201,11 @@ export const scheduledPosts = pgTable("scheduled_posts", {
publishedAt: timestamp("published_at"),
externalPostId: text("external_post_id"),
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", {
@@ -406,6 +432,19 @@ export const updateCloudinaryConfigSchema = insertCloudinaryConfigSchema.partial
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({
id: true,
createdAt: true,
@@ -465,4 +504,8 @@ export type InsertAudioTrack = z.infer<typeof insertAudioTrackSchema>;
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>;