mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-23 23:18:12 +02:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcbc6b706f | ||
|
|
3af7f9418b | ||
|
|
94e961d0f1 | ||
|
|
25bf0981bd | ||
|
|
d7f002a64f | ||
|
|
55e502aa8d | ||
|
|
ac8b494f24 | ||
|
|
c69fe4e462 | ||
|
|
0570088722 | ||
|
|
3e00a3ca67 | ||
|
|
d0f63c68e4 | ||
|
|
b95cd23ebc |
No files matched your search
+62
-1
@@ -11,6 +11,11 @@ DATABASE_URL=postgresql://socialflow:changeme@postgres:4523/socialflow
|
|||||||
# Configuration de l'application
|
# Configuration de l'application
|
||||||
PORT=5555
|
PORT=5555
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
|
# URL publique de l'application, telle que vue par le navigateur.
|
||||||
|
# En production, mettez le vrai domaine (ex. https://socialflow.exemple.fr) :
|
||||||
|
# les URI de redirection OAuth (Facebook/TikTok) en découlent.
|
||||||
|
# Laissée vide derrière un reverse proxy, elle est déduite des en-têtes
|
||||||
|
# X-Forwarded-Proto / X-Forwarded-Host.
|
||||||
APP_URL=http://localhost:5555
|
APP_URL=http://localhost:5555
|
||||||
|
|
||||||
# Clé secrète pour les sessions (CHANGEZ CETTE VALEUR)
|
# Clé secrète pour les sessions (CHANGEZ CETTE VALEUR)
|
||||||
@@ -30,14 +35,70 @@ OPENROUTER_API_KEY=your-openrouter-api-key-here
|
|||||||
MINIO_ENDPOINT=http://minio:9000
|
MINIO_ENDPOINT=http://minio:9000
|
||||||
MINIO_PUBLIC_URL=https://media.example.com
|
MINIO_PUBLIC_URL=https://media.example.com
|
||||||
|
|
||||||
# Optionnel: Configuration Facebook API
|
# ==========================================
|
||||||
|
# FACEBOOK (connexion des pages et renouvellement des jetons)
|
||||||
|
# ==========================================
|
||||||
|
# Identifiants de votre application sur https://developers.facebook.com
|
||||||
|
# Ils peuvent aussi être saisis dans l'application (Paramètres → Facebook),
|
||||||
|
# ce qui est prioritaire sur ces variables.
|
||||||
|
# Sans eux, les jetons de page doivent être collés et remplacés à la main.
|
||||||
# FACEBOOK_APP_ID=
|
# FACEBOOK_APP_ID=
|
||||||
# FACEBOOK_APP_SECRET=
|
# FACEBOOK_APP_SECRET=
|
||||||
|
#
|
||||||
|
# URI de redirection OAuth à déclarer chez Facebook.
|
||||||
|
# Par défaut : ${APP_URL}/api/facebook/callback, ou le domaine vu par le
|
||||||
|
# reverse proxy quand APP_URL n'est pas défini.
|
||||||
|
# FACEBOOK_REDIRECT_URI=
|
||||||
|
#
|
||||||
|
# Permissions demandées à l'autorisation. Défaut : pages_show_list,
|
||||||
|
# pages_manage_posts, pages_read_engagement, pages_manage_engagement,
|
||||||
|
# read_insights, business_management, instagram_basic, instagram_content_publish
|
||||||
|
# FACEBOOK_SCOPES=
|
||||||
|
#
|
||||||
|
# Version de la Graph API utilisée pour l'OAuth (défaut : v19.0).
|
||||||
|
# FACEBOOK_GRAPH_VERSION=
|
||||||
|
|
||||||
# Optionnel: Configuration Instagram API
|
# Optionnel: Configuration Instagram API
|
||||||
# INSTAGRAM_APP_ID=
|
# INSTAGRAM_APP_ID=
|
||||||
# INSTAGRAM_APP_SECRET=
|
# INSTAGRAM_APP_SECRET=
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# TIKTOK (publication des reels)
|
||||||
|
# ==========================================
|
||||||
|
# Identifiants de votre application sur https://developers.tiktok.com
|
||||||
|
# Ils peuvent aussi être saisis dans l'application (Paramètres → TikTok),
|
||||||
|
# ce qui est prioritaire sur ces variables.
|
||||||
|
# TIKTOK_CLIENT_KEY=
|
||||||
|
# TIKTOK_CLIENT_SECRET=
|
||||||
|
#
|
||||||
|
# URL de redirection OAuth à déclarer chez TikTok.
|
||||||
|
# Par défaut : ${APP_URL}/api/tiktok/callback, ou le domaine vu par le
|
||||||
|
# reverse proxy quand APP_URL n'est pas défini.
|
||||||
|
# TIKTOK_REDIRECT_URI=
|
||||||
|
#
|
||||||
|
# Scopes demandés à l'autorisation (défaut : user.info.basic,video.publish).
|
||||||
|
# "video.publish" nécessite que l'application ait passé l'audit TikTok ;
|
||||||
|
# sans audit, les vidéos publiées restent privées.
|
||||||
|
# TIKTOK_SCOPES=
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# MENTIONS LÉGALES
|
||||||
|
# ==========================================
|
||||||
|
# Affichées sur les pages publiques /terms et /privacy, dont TikTok exige
|
||||||
|
# les URLs pour l'audit de l'application développeur.
|
||||||
|
#
|
||||||
|
# L'identité de la société exploitante est déjà la valeur par défaut : seule
|
||||||
|
# l'adresse de contact doit obligatoirement être renseignée ici, le RGPD
|
||||||
|
# imposant un point de contact pour l'exercice des droits.
|
||||||
|
LEGAL_CONTACT_EMAIL=
|
||||||
|
|
||||||
|
# Surcharges facultatives (utiles si l'outil est exploité par une autre société)
|
||||||
|
# LEGAL_COMPANY_NAME=
|
||||||
|
# LEGAL_TRADE_NAME=
|
||||||
|
# LEGAL_COMPANY_LEGAL_FORM=
|
||||||
|
# LEGAL_COMPANY_RCS=
|
||||||
|
# LEGAL_COMPANY_ADDRESS=
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# API EXTERNE
|
# API EXTERNE
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|||||||
@@ -136,6 +136,44 @@ docker-compose down
|
|||||||
docker-compose down -v
|
docker-compose down -v
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 🔗 URL publique et connexions OAuth (Facebook / TikTok)
|
||||||
|
|
||||||
|
Les boutons « Connecter une page » construisent une URI de redirection à partir
|
||||||
|
de l'URL publique de l'application. Renseignez donc `APP_URL` avec le domaine
|
||||||
|
réel :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APP_URL=https://socialflow.exemple.fr
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis déclarez l'URI correspondante chez le fournisseur :
|
||||||
|
|
||||||
|
- Facebook : *Connexion Facebook → Paramètres → URI de redirection OAuth valides*
|
||||||
|
→ `https://socialflow.exemple.fr/api/facebook/callback`
|
||||||
|
- TikTok : *Login Kit → Redirect URI* → `https://socialflow.exemple.fr/api/tiktok/callback`
|
||||||
|
|
||||||
|
Si `APP_URL` est absent, l'URL est déduite des en-têtes `X-Forwarded-Proto` et
|
||||||
|
`X-Forwarded-Host` envoyés par Nginx — vérifiez que le proxy les transmet
|
||||||
|
(voir la configuration Nginx ci-dessus). Un `APP_URL` réglé sur
|
||||||
|
`http://localhost:5555` en production produit une URI que Facebook refuse.
|
||||||
|
|
||||||
|
## 🧩 Déploiement via Portainer (stack Git)
|
||||||
|
|
||||||
|
La stack se construit depuis les sources : `app` et `ffmpeg-api` ont une section
|
||||||
|
`build:`, leurs images n'existent sur aucun registre.
|
||||||
|
|
||||||
|
- **« Pull and redeploy » échoue** avec `pull access denied for <stack>-app,
|
||||||
|
repository does not exist` : Docker cherche sur Docker Hub une image qui est
|
||||||
|
construite localement. Le `pull_policy: build` du `docker-compose.yml` évite
|
||||||
|
cette tentative ; si votre version de Portainer déclenche quand même le pull,
|
||||||
|
décochez **« Re-pull image »** dans la mise à jour de la stack.
|
||||||
|
- Pour reconstruire après une mise à jour du dépôt : *Stack → Editor →
|
||||||
|
Update the stack*, en laissant Docker rebâtir les images (ou
|
||||||
|
`docker compose up -d --build` en ligne de commande).
|
||||||
|
- Le nom des images est figé (`socialflow-app:latest`,
|
||||||
|
`socialflow-ffmpeg-api:latest`) : il ne dépend donc plus du nom donné à la
|
||||||
|
stack dans Portainer.
|
||||||
|
|
||||||
## 🔒 Sécurité en production
|
## 🔒 Sécurité en production
|
||||||
|
|
||||||
1. **Variables d'environnement** : Ne commitez JAMAIS le fichier `.env`
|
1. **Variables d'environnement** : Ne commitez JAMAIS le fichier `.env`
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ export default function AiChat() {
|
|||||||
// Fetch available models from OpenRouter
|
// Fetch available models from OpenRouter
|
||||||
const { data: modelsData, isLoading: modelsLoading } = useQuery<{ data: OpenRouterModel[] }>({
|
const { data: modelsData, isLoading: modelsLoading } = useQuery<{ data: OpenRouterModel[] }>({
|
||||||
queryKey: ['/api/openrouter/models'],
|
queryKey: ['/api/openrouter/models'],
|
||||||
|
// Liste servie par un appel sortant vers OpenRouter : elle ne bouge pas d'une
|
||||||
|
// heure à l'autre, inutile de la redemander à chaque retour sur l'onglet.
|
||||||
|
staleTime: 60 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const availableModels = modelsData?.data || [];
|
const availableModels = modelsData?.data || [];
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { format, isSameDay, parseISO } from "date-fns";
|
import { format, isSameDay, parseISO } from "date-fns";
|
||||||
import { fr } from "date-fns/locale";
|
import { fr } from "date-fns/locale";
|
||||||
import { Edit, Trash2, ChevronDown, ChevronUp, Eye, Image, Smartphone } from "lucide-react";
|
import { Edit, Trash2, ChevronDown, ChevronUp, Eye, Image, Smartphone } from "lucide-react";
|
||||||
import { SiFacebook, SiInstagram } from "react-icons/si";
|
import { SiFacebook, SiInstagram, SiTiktok } from "react-icons/si";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ export default function CalendarListView({ scheduledPosts, onEditPost, onDeleteP
|
|||||||
const isPublished = !!post.publishedAt;
|
const isPublished = !!post.publishedAt;
|
||||||
const pageName = post.page?.pageName || 'Page inconnue';
|
const pageName = post.page?.pageName || 'Page inconnue';
|
||||||
const platform = post.page?.platform || 'facebook';
|
const platform = post.page?.platform || 'facebook';
|
||||||
const PlatformIcon = platform === 'instagram' ? SiInstagram : SiFacebook;
|
const PlatformIcon = platform === 'instagram' ? SiInstagram : platform === 'tiktok' ? SiTiktok : SiFacebook;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -6,13 +6,31 @@ import { useQuery, useMutation } from "@tanstack/react-query";
|
|||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
import type { ScheduledPost } from "@shared/schema";
|
import type { ScheduledPost } from "@shared/schema";
|
||||||
import { SiFacebook, SiInstagram } from "react-icons/si";
|
import { SiFacebook, SiInstagram, SiTiktok } from "react-icons/si";
|
||||||
import EditScheduledPostDialog from "./edit-scheduled-post-dialog";
|
import EditScheduledPostDialog from "./edit-scheduled-post-dialog";
|
||||||
import CalendarListView from "./calendar-list-view";
|
import CalendarListView from "./calendar-list-view";
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
import { PreviewModal } from "@/components/preview-modal";
|
import { PreviewModal } from "@/components/preview-modal";
|
||||||
import type { Media } from "@shared/schema";
|
import type { Media } from "@shared/schema";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bornes de la grille affichée : 42 cases à partir du lundi qui précède le 1er
|
||||||
|
* du mois. Demander cette fenêtre plutôt que tout l'historique évite de charger
|
||||||
|
* des années de publications pour en afficher six semaines.
|
||||||
|
*/
|
||||||
|
function visibleRange(currentDate: Date): { startDate: string; endDate: string } {
|
||||||
|
const year = currentDate.getFullYear();
|
||||||
|
const month = currentDate.getMonth();
|
||||||
|
const firstDay = new Date(year, month, 1);
|
||||||
|
const startingDayOfWeek = firstDay.getDay() === 0 ? 6 : firstDay.getDay() - 1;
|
||||||
|
|
||||||
|
const start = new Date(year, month, 1 - startingDayOfWeek);
|
||||||
|
const end = new Date(year, month, 1 - startingDayOfWeek + 42);
|
||||||
|
end.setMilliseconds(-1); // fin de la 42e journée
|
||||||
|
|
||||||
|
return { startDate: start.toISOString(), endDate: end.toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
export default function CalendarView() {
|
export default function CalendarView() {
|
||||||
const [currentDate, setCurrentDate] = useState(new Date());
|
const [currentDate, setCurrentDate] = useState(new Date());
|
||||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
@@ -22,8 +40,11 @@ export default function CalendarView() {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||||
|
|
||||||
|
// La fenêtre fait partie de la clé : changer de mois redéclenche la requête.
|
||||||
|
const range = visibleRange(currentDate);
|
||||||
|
|
||||||
const { data: scheduledPosts = [] } = useQuery<ScheduledPost[]>({
|
const { data: scheduledPosts = [] } = useQuery<ScheduledPost[]>({
|
||||||
queryKey: ["/api/scheduled-posts"],
|
queryKey: ["/api/scheduled-posts", range],
|
||||||
refetchInterval: 30000, // Auto-refresh every 30 seconds
|
refetchInterval: 30000, // Auto-refresh every 30 seconds
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -261,7 +282,7 @@ export default function CalendarView() {
|
|||||||
const isPublished = !!post.publishedAt;
|
const isPublished = !!post.publishedAt;
|
||||||
const pageName = post.page?.pageName || 'Page inconnue';
|
const pageName = post.page?.pageName || 'Page inconnue';
|
||||||
const platform = post.page?.platform || 'facebook';
|
const platform = post.page?.platform || 'facebook';
|
||||||
const PlatformIcon = platform === 'instagram' ? SiInstagram : SiFacebook;
|
const PlatformIcon = platform === 'instagram' ? SiInstagram : platform === 'tiktok' ? SiTiktok : SiFacebook;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -340,7 +361,7 @@ export default function CalendarView() {
|
|||||||
const isPublished = !!post.publishedAt;
|
const isPublished = !!post.publishedAt;
|
||||||
const pageName = post.page?.pageName || 'Page inconnue';
|
const pageName = post.page?.pageName || 'Page inconnue';
|
||||||
const platform = post.page?.platform || 'facebook';
|
const platform = post.page?.platform || 'facebook';
|
||||||
const PlatformIcon = platform === 'instagram' ? SiInstagram : SiFacebook;
|
const PlatformIcon = platform === 'instagram' ? SiInstagram : platform === 'tiktok' ? SiTiktok : SiFacebook;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ export default function EditScheduledPostDialog({ open, onOpenChange, scheduledP
|
|||||||
<MediaThumbnail
|
<MediaThumbnail
|
||||||
src={media.instagramFeedUrl || media.originalUrl}
|
src={media.instagramFeedUrl || media.originalUrl}
|
||||||
alt="Selected"
|
alt="Selected"
|
||||||
|
thumbnailUrl={media.thumbnailUrl ?? undefined}
|
||||||
type={media.type === 'video' ? 'video' : 'image'}
|
type={media.type === 'video' ? 'video' : 'image'}
|
||||||
className="w-full h-24 object-cover rounded-lg border-2 border-primary"
|
className="w-full h-24 object-cover rounded-lg border-2 border-primary"
|
||||||
/>
|
/>
|
||||||
@@ -203,6 +204,7 @@ export default function EditScheduledPostDialog({ open, onOpenChange, scheduledP
|
|||||||
<MediaThumbnail
|
<MediaThumbnail
|
||||||
src={media.instagramFeedUrl || media.originalUrl}
|
src={media.instagramFeedUrl || media.originalUrl}
|
||||||
alt={media.fileName}
|
alt={media.fileName}
|
||||||
|
thumbnailUrl={media.thumbnailUrl ?? undefined}
|
||||||
type={media.type === 'video' ? 'video' : 'image'}
|
type={media.type === 'video' ? 'video' : 'image'}
|
||||||
className={`w-full h-20 object-cover rounded-lg border-2 transition-all ${
|
className={`w-full h-20 object-cover rounded-lg border-2 transition-all ${
|
||||||
isSelected ? 'border-primary' : 'border-border hover:border-primary/50'
|
isSelected ? 'border-primary' : 'border-border hover:border-primary/50'
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Suivi et renouvellement des jetons d'accès.
|
||||||
|
*
|
||||||
|
* Un jeton de page Facebook n'affiche pas toujours de date d'expiration : celui
|
||||||
|
* qui dérive d'une connexion OAuth est permanent, et c'est sa révocation — pas
|
||||||
|
* son échéance — qui casse la publication. L'UI montre donc l'état réel remonté
|
||||||
|
* par le contrôle serveur, pas seulement un décompte de jours.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { AlertTriangle, Calendar, CheckCircle2, Facebook, RefreshCw, ShieldCheck } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { apiRequest, getErrorMessage, queryClient } from "@/lib/queryClient";
|
||||||
|
import type { ClientSocialPage } from "@shared/schema";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { fr } from "date-fns/locale";
|
||||||
|
|
||||||
|
type TokenStatus = 'valid' | 'expiring' | 'expired' | 'error';
|
||||||
|
|
||||||
|
const STATUS_STYLES: Record<TokenStatus, { label: string; color: string; bgColor: string }> = {
|
||||||
|
valid: { label: 'Jeton valide', color: 'text-green-600', bgColor: 'bg-green-100' },
|
||||||
|
expiring: { label: 'Jeton bientôt expiré', color: 'text-orange-600', bgColor: 'bg-orange-100' },
|
||||||
|
expired: { label: 'Jeton expiré', color: 'text-red-600', bgColor: 'bg-red-100' },
|
||||||
|
error: { label: 'Contrôle en échec', color: 'text-red-600', bgColor: 'bg-red-100' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function statusOf(page: ClientSocialPage): TokenStatus {
|
||||||
|
return (page.tokenStatus as TokenStatus | null) ?? 'valid';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lance l'autorisation Facebook. La connexion se fait par navigation complète
|
||||||
|
* du navigateur (et non en fetch) puisqu'elle passe par le site de Facebook.
|
||||||
|
*/
|
||||||
|
export function ConnectFacebookButton({ className }: { className?: string }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { data: config } = useQuery<{ configured: boolean }>({
|
||||||
|
queryKey: ['/api/facebook/config'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (config && !config.configured) {
|
||||||
|
toast({
|
||||||
|
title: "Facebook n'est pas configuré",
|
||||||
|
description:
|
||||||
|
"Renseignez l'App ID et l'App Secret de votre application Facebook dans les paramètres.",
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = '/api/facebook/connect';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={className}
|
||||||
|
onClick={handleClick}
|
||||||
|
data-testid="button-connect-facebook"
|
||||||
|
>
|
||||||
|
<Facebook className="w-4 h-4 mr-2" />
|
||||||
|
Connecter des pages Facebook
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Relance le contrôle serveur, qui renouvelle le jeton s'il le peut. */
|
||||||
|
export function RefreshTokenButton({ page }: { page: ClientSocialPage }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const refreshMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const response = await apiRequest('POST', `/api/facebook/pages/${page.id}/refresh`);
|
||||||
|
return (await response.json()) as { status: TokenStatus; renewed: boolean; error?: string };
|
||||||
|
},
|
||||||
|
onSuccess: (result) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['/api/pages'] });
|
||||||
|
toast({
|
||||||
|
title: result.renewed
|
||||||
|
? 'Jeton renouvelé'
|
||||||
|
: result.status === 'valid'
|
||||||
|
? 'Jeton valide'
|
||||||
|
: STATUS_STYLES[result.status].label,
|
||||||
|
description:
|
||||||
|
result.error ||
|
||||||
|
(result.renewed
|
||||||
|
? `Un nouveau jeton a été généré pour ${page.pageName}.`
|
||||||
|
: `Aucun renouvellement nécessaire pour ${page.pageName}.`),
|
||||||
|
variant: result.status === 'valid' ? undefined : 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
toast({
|
||||||
|
title: 'Contrôle impossible',
|
||||||
|
description: getErrorMessage(error, 'Facebook est injoignable, réessayez plus tard.'),
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => refreshMutation.mutate()}
|
||||||
|
disabled={refreshMutation.isPending}
|
||||||
|
data-testid={`button-refresh-token-${page.id}`}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 mr-2 ${refreshMutation.isPending ? 'animate-spin' : ''}`} />
|
||||||
|
{refreshMutation.isPending ? 'Contrôle…' : 'Vérifier le jeton'}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bloc d'état du jeton : ce que le dernier contrôle a constaté, l'échéance
|
||||||
|
* quand il y en a une, et l'erreur à corriger le cas échéant.
|
||||||
|
*/
|
||||||
|
export function TokenHealthPanel({ page }: { page: ClientSocialPage }) {
|
||||||
|
const status = statusOf(page);
|
||||||
|
const style = STATUS_STYLES[status];
|
||||||
|
const isTiktok = page.platform === 'tiktok';
|
||||||
|
|
||||||
|
// Pour TikTok, c'est l'autorisation (refresh token) qui porte l'échéance.
|
||||||
|
const expiresAt = isTiktok ? page.refreshTokenExpiresAt : page.tokenExpiresAt;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`text-xs px-3 py-2 rounded-lg space-y-1 ${style.bgColor}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{status === 'valid' ? (
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-green-600 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<AlertTriangle className="w-4 h-4 text-red-600 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className={`font-semibold ${style.color}`}>{style.label}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Calendar className="w-3.5 h-3.5 shrink-0" />
|
||||||
|
{expiresAt ? (
|
||||||
|
<span>
|
||||||
|
{isTiktok ? 'Connexion valable jusqu\'au' : 'Expire le'}{' '}
|
||||||
|
{format(new Date(expiresAt), 'd MMMM yyyy', { locale: fr })}
|
||||||
|
</span>
|
||||||
|
) : page.autoRenew ? (
|
||||||
|
<span>Sans expiration</span>
|
||||||
|
) : (
|
||||||
|
<span>Échéance inconnue</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{page.autoRenew && (
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<ShieldCheck className="w-3.5 h-3.5 shrink-0" />
|
||||||
|
<span>Renouvellement automatique actif</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{page.tokenError && (
|
||||||
|
<p className={`${style.color} leading-relaxed`} data-testid={`text-token-error-${page.id}`}>
|
||||||
|
{page.tokenError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{page.lastTokenCheck && (
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Dernier contrôle : {format(new Date(page.lastTokenCheck), 'd MMM yyyy à HH:mm', { locale: fr })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bandeau global : les pages qui réclament une reconnexion manuelle. */
|
||||||
|
export function TokenAlertBanner({ pages }: { pages: ClientSocialPage[] }) {
|
||||||
|
const failing = pages.filter((page) => {
|
||||||
|
const status = statusOf(page);
|
||||||
|
return status === 'expired' || status === 'error' || status === 'expiring';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (failing.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/30 rounded-xl flex gap-3">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-red-500 shrink-0 mt-0.5" />
|
||||||
|
<div className="text-sm space-y-1">
|
||||||
|
<p className="font-semibold text-red-600 dark:text-red-400">
|
||||||
|
{failing.length === 1
|
||||||
|
? 'Une page nécessite votre attention'
|
||||||
|
: `${failing.length} pages nécessitent votre attention`}
|
||||||
|
</p>
|
||||||
|
{failing.map((page) => (
|
||||||
|
<p key={page.id} className="text-muted-foreground">
|
||||||
|
<strong>{page.pageName}</strong> — {STATUS_STYLES[statusOf(page)].label.toLowerCase()}
|
||||||
|
{page.tokenError ? ` : ${page.tokenError}` : '.'}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Users } from "lucide-react";
|
import { Users } from "lucide-react";
|
||||||
import { SiFacebook, SiInstagram } from "react-icons/si";
|
import { SiFacebook, SiInstagram, SiTiktok } from "react-icons/si";
|
||||||
|
|
||||||
export default function ManagedPages() {
|
export default function ManagedPages() {
|
||||||
const { data: pages, isLoading } = useQuery({
|
const { data: pages, isLoading } = useQuery({
|
||||||
@@ -48,10 +48,12 @@ export default function ManagedPages() {
|
|||||||
<div className="flex items-start justify-between mb-5">
|
<div className="flex items-start justify-between mb-5">
|
||||||
<div className={`
|
<div className={`
|
||||||
w-14 h-14 rounded-xl flex items-center justify-center shadow-md
|
w-14 h-14 rounded-xl flex items-center justify-center shadow-md
|
||||||
${page.platform === "facebook" ? "bg-[#1877F2]/10" : "bg-[#E4405F]/10"}
|
${page.platform === "facebook" ? "bg-[#1877F2]/10" : page.platform === "tiktok" ? "bg-foreground/10" : "bg-[#E4405F]/10"}
|
||||||
`}>
|
`}>
|
||||||
{page.platform === "facebook" ? (
|
{page.platform === "facebook" ? (
|
||||||
<SiFacebook className="text-[#1877F2] text-2xl" />
|
<SiFacebook className="text-[#1877F2] text-2xl" />
|
||||||
|
) : page.platform === "tiktok" ? (
|
||||||
|
<SiTiktok className="text-foreground text-2xl" />
|
||||||
) : (
|
) : (
|
||||||
<SiInstagram className="text-[#E4405F] text-2xl" />
|
<SiInstagram className="text-[#E4405F] text-2xl" />
|
||||||
)}
|
)}
|
||||||
@@ -65,7 +67,7 @@ export default function ManagedPages() {
|
|||||||
</div>
|
</div>
|
||||||
<h4 className="font-semibold text-foreground text-lg mb-2">{page.pageName}</h4>
|
<h4 className="font-semibold text-foreground text-lg mb-2">{page.pageName}</h4>
|
||||||
<p className="text-sm text-muted-foreground mb-4 font-medium">
|
<p className="text-sm text-muted-foreground mb-4 font-medium">
|
||||||
{page.platform === "facebook" ? "Facebook Page" : "Instagram Business"}
|
{page.platform === "facebook" ? "Facebook Page" : page.platform === "tiktok" ? "Compte TikTok" : "Instagram Business"}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center justify-between pt-4 border-t border-border/50">
|
<div className="flex items-center justify-between pt-4 border-t border-border/50">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -61,7 +61,10 @@ export function MediaThumbnail({ src, alt, type, className = "w-full h-full obje
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleError = () => {
|
const handleError = () => {
|
||||||
console.warn('Video thumbnail generation failed for:', src);
|
// Cas courant et attendu : la vidéo a été supprimée du disque après
|
||||||
|
// publication. Les médias créés depuis embarquent une vignette persistée,
|
||||||
|
// qui évite d'en arriver là.
|
||||||
|
console.warn('Aperçu vidéo indisponible (fichier absent ou illisible):', src);
|
||||||
setError(true);
|
setError(true);
|
||||||
video.remove();
|
video.remove();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,25 +2,57 @@ import { useState, useCallback, useRef, useEffect } from "react";
|
|||||||
import { useDropzone } from "react-dropzone";
|
import { useDropzone } from "react-dropzone";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useMutation } from "@tanstack/react-query";
|
||||||
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
|
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
|
||||||
import { MediaThumbnail } from "@/components/media-thumbnail";
|
import { MediaThumbnail } from "@/components/media-thumbnail";
|
||||||
import { CloudUpload, Image as ImageIcon, Video, X, Upload, Loader2, ZoomIn, Camera } from "lucide-react";
|
import { CloudUpload, Image as ImageIcon, Video, X, Upload, Loader2, ZoomIn, Camera } from "lucide-react";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { SiFacebook, SiInstagram } from "react-icons/si";
|
import { SiFacebook, SiInstagram } from "react-icons/si";
|
||||||
|
|
||||||
|
/** Nombre de vignettes chargées par page. */
|
||||||
|
const MEDIA_PAGE_SIZE = 15;
|
||||||
|
|
||||||
export default function MediaUpload() {
|
export default function MediaUpload() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [selectedFile, setSelectedFile] = useState<any>(null);
|
const [selectedFile, setSelectedFile] = useState<any>(null);
|
||||||
const [visibleCount, setVisibleCount] = useState(5);
|
|
||||||
const [zoomImage, setZoomImage] = useState<string | null>(null);
|
const [zoomImage, setZoomImage] = useState<string | null>(null);
|
||||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||||
const cameraInputRef = useRef<HTMLInputElement>(null);
|
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const { data: mediaList } = useQuery({
|
// Pagination côté serveur : la médiathèque entière transitait pour n'afficher
|
||||||
queryKey: ["/api/media"],
|
// que les premières vignettes. La clé reste préfixée par « /api/media » afin
|
||||||
|
// que les invalidations existantes continuent de la rafraîchir.
|
||||||
|
const {
|
||||||
|
data: mediaPages,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
} = useInfiniteQuery({
|
||||||
|
queryKey: ["/api/media", "pages"],
|
||||||
|
initialPageParam: 0,
|
||||||
|
queryFn: async ({ pageParam }) => {
|
||||||
|
const response = await fetch(`/api/media?limit=${MEDIA_PAGE_SIZE}&offset=${pageParam}`, {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
// Même traitement que les autres requêtes : une session expirée renvoie
|
||||||
|
// vers l'écran de connexion plutôt que sur une médiathèque vide.
|
||||||
|
if (response.status === 401) handleUnauthorized("/api/media");
|
||||||
|
throw new Error("Impossible de charger la médiathèque");
|
||||||
|
}
|
||||||
|
const items = (await response.json()) as any[];
|
||||||
|
const total = Number(response.headers.get("X-Total-Count"));
|
||||||
|
return { items, total: Number.isFinite(total) ? total : items.length };
|
||||||
|
},
|
||||||
|
getNextPageParam: (lastPage, allPages) => {
|
||||||
|
const loaded = allPages.reduce((count, page) => count + page.items.length, 0);
|
||||||
|
return loaded < lastPage.total ? loaded : undefined;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mediaList = mediaPages?.pages.flatMap(page => page.items) ?? [];
|
||||||
|
const totalMedia = mediaPages?.pages[0]?.total ?? 0;
|
||||||
|
|
||||||
const [uploadingCount, setUploadingCount] = useState(0);
|
const [uploadingCount, setUploadingCount] = useState(0);
|
||||||
|
|
||||||
const uploadMutation = useMutation({
|
const uploadMutation = useMutation({
|
||||||
@@ -163,8 +195,8 @@ export default function MediaUpload() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
if (entries[0].isIntersecting && mediaList && visibleCount < (mediaList as any[]).length) {
|
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||||
setVisibleCount(prev => Math.min(prev + 5, (mediaList as any[]).length));
|
fetchNextPage();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ threshold: 0.1 }
|
{ threshold: 0.1 }
|
||||||
@@ -179,7 +211,7 @@ export default function MediaUpload() {
|
|||||||
observer.unobserve(loadMoreRef.current);
|
observer.unobserve(loadMoreRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [mediaList, visibleCount]);
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-card rounded-2xl border border-border/50 overflow-hidden shadow-lg">
|
<div className="bg-card rounded-2xl border border-border/50 overflow-hidden shadow-lg">
|
||||||
@@ -268,13 +300,13 @@ export default function MediaUpload() {
|
|||||||
<h4 className="text-sm font-semibold text-foreground flex items-center justify-between">
|
<h4 className="text-sm font-semibold text-foreground flex items-center justify-between">
|
||||||
<span>Fichiers téléchargés</span>
|
<span>Fichiers téléchargés</span>
|
||||||
<span className="text-xs font-medium text-muted-foreground bg-muted/30 px-3 py-1 rounded-full">
|
<span className="text-xs font-medium text-muted-foreground bg-muted/30 px-3 py-1 rounded-full">
|
||||||
{(mediaList as any[])?.length || 0} média(s)
|
{totalMedia} média(s)
|
||||||
</span>
|
</span>
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
<div className="max-h-[500px] overflow-y-auto">
|
<div className="max-h-[500px] overflow-y-auto">
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
{(mediaList as any[])?.slice(0, visibleCount).map((media: any) => (
|
{mediaList.map((media: any) => (
|
||||||
<div
|
<div
|
||||||
key={media.id}
|
key={media.id}
|
||||||
onClick={() => setSelectedFile(media)}
|
onClick={() => setSelectedFile(media)}
|
||||||
@@ -284,6 +316,7 @@ export default function MediaUpload() {
|
|||||||
<MediaThumbnail
|
<MediaThumbnail
|
||||||
src={media.facebookFeedUrl || media.originalUrl}
|
src={media.facebookFeedUrl || media.originalUrl}
|
||||||
alt={media.fileName}
|
alt={media.fileName}
|
||||||
|
thumbnailUrl={media.thumbnailUrl ?? undefined}
|
||||||
type={media.type === 'video' ? 'video' : 'image'}
|
type={media.type === 'video' ? 'video' : 'image'}
|
||||||
/>
|
/>
|
||||||
<div className="absolute top-2 right-2 flex gap-2">
|
<div className="absolute top-2 right-2 flex gap-2">
|
||||||
@@ -315,7 +348,7 @@ export default function MediaUpload() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Élément sentinelle pour le scroll infini */}
|
{/* Élément sentinelle pour le scroll infini */}
|
||||||
{(mediaList && visibleCount < (mediaList as any[]).length) ? (
|
{hasNextPage ? (
|
||||||
<div ref={loadMoreRef} className="flex justify-center py-4 mt-3">
|
<div ref={loadMoreRef} className="flex justify-center py-4 mt-3">
|
||||||
<Loader2 className="w-5 h-5 text-primary animate-spin" />
|
<Loader2 className="w-5 h-5 text-primary animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ interface OngoingReelsProps {
|
|||||||
export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
||||||
const { data: ongoingPosts = [] } = useQuery<Post[]>({
|
const { data: ongoingPosts = [] } = useQuery<Post[]>({
|
||||||
queryKey: ["/api/reels/ongoing"],
|
queryKey: ["/api/reels/ongoing"],
|
||||||
refetchInterval: 3000, // Poll every 3 seconds
|
// Cette route ne renvoie que les générations en cours : tant qu'elle est
|
||||||
|
// vide il n'y a pas de barre de progression à animer. On garde une veille
|
||||||
|
// lente, qui suffit à détecter une génération lancée depuis un autre écran.
|
||||||
|
refetchInterval: (query) => ((query.state.data?.length ?? 0) > 0 ? 3000 : 30000),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Nothing to show — render nothing (not even a card)
|
// Nothing to show — render nothing (not even a card)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Facebook, Instagram, Clock, CheckCircle2, XCircle, Eye, Image as ImageIcon, Smartphone, Clapperboard } from "lucide-react";
|
import { Facebook, Instagram, Clock, CheckCircle2, XCircle, Eye, Image as ImageIcon, Smartphone, Clapperboard } from "lucide-react";
|
||||||
|
import { SiTiktok } from "react-icons/si";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { fr } from "date-fns/locale";
|
import { fr } from "date-fns/locale";
|
||||||
import type { ScheduledPost, SocialPage, Post, Media } from "@shared/schema";
|
import type { ScheduledPost, SocialPage, Post, Media } from "@shared/schema";
|
||||||
@@ -28,6 +29,16 @@ import { Trash2 } from "lucide-react";
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { apiRequest } from "@/lib/queryClient";
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
|
|
||||||
|
/** Profondeur d'historique du bloc « Publications récentes ». */
|
||||||
|
const RECENT_WINDOW_DAYS = 90;
|
||||||
|
|
||||||
|
/** Minuit, pour que la clé de requête reste stable d'un rendu à l'autre. */
|
||||||
|
function startOfDay(date: Date): Date {
|
||||||
|
const copy = new Date(date);
|
||||||
|
copy.setHours(0, 0, 0, 0);
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
export default function RecentPublications() {
|
export default function RecentPublications() {
|
||||||
const [previewModalOpen, setPreviewModalOpen] = useState(false);
|
const [previewModalOpen, setPreviewModalOpen] = useState(false);
|
||||||
const [previewData, setPreviewData] = useState<{ postText: string; mediaIds: string[]; allMedia: Media[] }>({ postText: '', mediaIds: [], allMedia: [] });
|
const [previewData, setPreviewData] = useState<{ postText: string; mediaIds: string[]; allMedia: Media[] }>({ postText: '', mediaIds: [], allMedia: [] });
|
||||||
@@ -36,8 +47,13 @@ export default function RecentPublications() {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Ce bloc n'affiche que les dix dernières publications : inutile de faire
|
||||||
|
// remonter tout l'historique pour cela.
|
||||||
|
const since = new Date();
|
||||||
|
since.setDate(since.getDate() - RECENT_WINDOW_DAYS);
|
||||||
|
|
||||||
const { data: scheduledPosts = [], isLoading } = useQuery<ScheduledPostWithRelations[]>({
|
const { data: scheduledPosts = [], isLoading } = useQuery<ScheduledPostWithRelations[]>({
|
||||||
queryKey: ['/api/scheduled-posts'],
|
queryKey: ['/api/scheduled-posts', { startDate: startOfDay(since).toISOString() }],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter and sort attempted posts (scheduled in the past)
|
// Filter and sort attempted posts (scheduled in the past)
|
||||||
@@ -162,10 +178,14 @@ export default function RecentPublications() {
|
|||||||
{/* Platform icon */}
|
{/* Platform icon */}
|
||||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${scheduledPost.page?.platform === 'facebook'
|
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${scheduledPost.page?.platform === 'facebook'
|
||||||
? 'bg-blue-500'
|
? 'bg-blue-500'
|
||||||
: 'bg-gradient-to-br from-purple-500 to-pink-500'
|
: scheduledPost.page?.platform === 'tiktok'
|
||||||
|
? 'bg-black'
|
||||||
|
: 'bg-gradient-to-br from-purple-500 to-pink-500'
|
||||||
}`}>
|
}`}>
|
||||||
{scheduledPost.page?.platform === 'facebook' ? (
|
{scheduledPost.page?.platform === 'facebook' ? (
|
||||||
<Facebook className="w-5 h-5 text-white" />
|
<Facebook className="w-5 h-5 text-white" />
|
||||||
|
) : scheduledPost.page?.platform === 'tiktok' ? (
|
||||||
|
<SiTiktok className="w-5 h-5 text-white" />
|
||||||
) : (
|
) : (
|
||||||
<Instagram className="w-5 h-5 text-white" />
|
<Instagram className="w-5 h-5 text-white" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Home, PlusCircle, Calendar, Images, Users, Bot, Clock, Settings, Database, UserCog, LogOut, ChevronLeft, ChevronRight, ChevronDown, Wand2, BarChart3, Video, Music, Shield } from "lucide-react";
|
import { Home, PlusCircle, Calendar, Images, Users, Bot, Clock, Settings, Database, UserCog, LogOut, ChevronLeft, ChevronRight, ChevronDown, Wand2, BarChart3, Video, Music, Shield } from "lucide-react";
|
||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
import { apiRequest, AUTH_MUTATION, queryClient } from "@/lib/queryClient";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
@@ -52,6 +52,7 @@ export default function Sidebar({ onLinkClick }: SidebarProps = {}) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const logoutMutation = useMutation({
|
const logoutMutation = useMutation({
|
||||||
|
mutationKey: [AUTH_MUTATION, "logout"],
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const res = await apiRequest("POST", "/api/auth/logout", {});
|
const res = await apiRequest("POST", "/api/auth/logout", {});
|
||||||
return await res.json();
|
return await res.json();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { QueryClient, QueryFunction } from "@tanstack/react-query";
|
import { MutationCache, QueryClient, QueryFunction } from "@tanstack/react-query";
|
||||||
|
|
||||||
// Les routes d'authentification elles-mêmes ne doivent jamais déclencher
|
// Les routes d'authentification elles-mêmes ne doivent jamais déclencher
|
||||||
// de redirection automatique (ex: mauvais mot de passe sur /login).
|
// de redirection automatique (ex: mauvais mot de passe sur /login).
|
||||||
@@ -20,6 +20,29 @@ export function handleUnauthorized(url: string) {
|
|||||||
window.location.href = "/login";
|
window.location.href = "/login";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrait un message lisible d'une erreur d'API.
|
||||||
|
* apiRequest lève des erreurs de la forme `500: {"error":"…"}` : sans ce
|
||||||
|
* décodage, l'interface ne peut afficher qu'un message générique alors que le
|
||||||
|
* serveur a renvoyé la cause exacte.
|
||||||
|
*/
|
||||||
|
export function getErrorMessage(error: unknown, fallback: string): string {
|
||||||
|
if (!(error instanceof Error) || !error.message) return fallback;
|
||||||
|
|
||||||
|
const withoutStatus = error.message.replace(/^\d{3}:\s*/, '').trim();
|
||||||
|
if (!withoutStatus) return fallback;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(withoutStatus);
|
||||||
|
const message = parsed?.error || parsed?.message;
|
||||||
|
if (typeof message === 'string' && message.trim()) return message.trim();
|
||||||
|
} catch {
|
||||||
|
// Corps non-JSON : on affiche le texte tel quel
|
||||||
|
}
|
||||||
|
|
||||||
|
return withoutStatus.startsWith('<') ? fallback : withoutStatus;
|
||||||
|
}
|
||||||
|
|
||||||
async function throwIfResNotOk(res: Response, url: string) {
|
async function throwIfResNotOk(res: Response, url: string) {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
@@ -46,13 +69,40 @@ export async function apiRequest(
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit l'URL d'une requête à partir de sa clé.
|
||||||
|
*
|
||||||
|
* Les segments texte forment le chemin (`['/api/posts', id]`). Un objet en
|
||||||
|
* dernière position porte les paramètres de requête : c'est ce qui permet de
|
||||||
|
* demander une fenêtre de dates plutôt que tout l'historique
|
||||||
|
* (`['/api/scheduled-posts', { startDate, endDate }]`).
|
||||||
|
*/
|
||||||
|
export function urlFromQueryKey(queryKey: readonly unknown[]): string {
|
||||||
|
const segments = [...queryKey];
|
||||||
|
const last = segments[segments.length - 1];
|
||||||
|
|
||||||
|
if (last !== null && typeof last === "object") {
|
||||||
|
segments.pop();
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(last as Record<string, unknown>)) {
|
||||||
|
if (value !== undefined && value !== null && value !== "") {
|
||||||
|
params.set(key, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const query = params.toString();
|
||||||
|
return query ? `${segments.join("/")}?${query}` : segments.join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments.join("/");
|
||||||
|
}
|
||||||
|
|
||||||
type UnauthorizedBehavior = "returnNull" | "throw";
|
type UnauthorizedBehavior = "returnNull" | "throw";
|
||||||
export const getQueryFn: <T>(options: {
|
export const getQueryFn: <T>(options: {
|
||||||
on401: UnauthorizedBehavior;
|
on401: UnauthorizedBehavior;
|
||||||
}) => QueryFunction<T> =
|
}) => QueryFunction<T> =
|
||||||
({ on401: unauthorizedBehavior }) =>
|
({ on401: unauthorizedBehavior }) =>
|
||||||
async ({ queryKey }) => {
|
async ({ queryKey }) => {
|
||||||
const url = queryKey.join("/") as string;
|
const url = urlFromQueryKey(queryKey);
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
});
|
});
|
||||||
@@ -65,13 +115,42 @@ export const getQueryFn: <T>(options: {
|
|||||||
return await res.json();
|
return await res.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marque les mutations d'authentification, exclues de l'invalidation globale
|
||||||
|
* ci-dessous. À passer en `mutationKey` : `[AUTH_MUTATION, 'logout']`.
|
||||||
|
*/
|
||||||
|
export const AUTH_MUTATION = "auth";
|
||||||
|
|
||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
|
// Les compteurs du tableau de bord et la liste des posts dérivent de tout le
|
||||||
|
// reste : publier, supprimer un média, générer un texte… Aucune mutation ne
|
||||||
|
// peut raisonnablement se souvenir de les invalider, et aucune ne le faisait :
|
||||||
|
// ils restaient figés jusqu'au rechargement de la page. On les invalide donc
|
||||||
|
// ici, une bonne fois, après chaque mutation réussie.
|
||||||
|
mutationCache: new MutationCache({
|
||||||
|
onSuccess: (_data, _variables, _context, mutation) => {
|
||||||
|
// Sauf pour l'authentification : au moment où la déconnexion réussit, la
|
||||||
|
// session est déjà détruite. Relancer des requêtes ici ne produirait que
|
||||||
|
// des 401 et une redirection brutale, avant même que le cache soit vidé.
|
||||||
|
if (mutation.options.mutationKey?.[0] === AUTH_MUTATION) return;
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["/api/stats"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["/api/posts"] });
|
||||||
|
},
|
||||||
|
}),
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
queryFn: getQueryFn({ on401: "throw" }),
|
queryFn: getQueryFn({ on401: "throw" }),
|
||||||
refetchInterval: false,
|
// Le serveur agit de son côté — le scheduler publie toutes les minutes, le
|
||||||
refetchOnWindowFocus: false,
|
// renouvellement des jetons tourne en tâche de fond — sans que le
|
||||||
staleTime: Infinity,
|
// navigateur en soit averti. Avec `staleTime: Infinity`, un écran restait
|
||||||
|
// sur le cache de sa première visite pour toute la session : d'où la
|
||||||
|
// nécessité de recharger la page pour voir quoi que ce soit. Une
|
||||||
|
// péremption courte, plus un rafraîchissement au retour sur l'onglet,
|
||||||
|
// suffit à garder l'affichage juste sans marteler l'API.
|
||||||
|
staleTime: 30_000,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
refetchOnReconnect: true,
|
||||||
retry: false,
|
retry: false,
|
||||||
},
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
|
|||||||
@@ -17,6 +17,15 @@ type ScheduledPostWithRelations = ScheduledPost & {
|
|||||||
page?: SocialPage;
|
page?: SocialPage;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Publication dont l'heure est passée mais que le scheduler n'a pas encore traitée. */
|
||||||
|
function isAwaitingPublication(scheduledPost: ScheduledPostWithRelations): boolean {
|
||||||
|
return (
|
||||||
|
!!scheduledPost.scheduledAt &&
|
||||||
|
new Date(scheduledPost.scheduledAt) <= new Date() &&
|
||||||
|
!scheduledPost.publishedAt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function History() {
|
export default function History() {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [previewModalOpen, setPreviewModalOpen] = useState(false);
|
const [previewModalOpen, setPreviewModalOpen] = useState(false);
|
||||||
@@ -25,7 +34,11 @@ export default function History() {
|
|||||||
|
|
||||||
const { data: scheduledPosts = [], isLoading } = useQuery<ScheduledPostWithRelations[]>({
|
const { data: scheduledPosts = [], isLoading } = useQuery<ScheduledPostWithRelations[]>({
|
||||||
queryKey: ['/api/scheduled-posts'],
|
queryKey: ['/api/scheduled-posts'],
|
||||||
refetchInterval: 3000,
|
// Le scheduler publie en tâche de fond : on ne suit à la seconde que ce qui
|
||||||
|
// est réellement en attente. Le reste du temps, une veille lente suffit —
|
||||||
|
// sans quoi la page entière était rechargée 20 fois par minute pour rien.
|
||||||
|
refetchInterval: (query) =>
|
||||||
|
(query.state.data ?? []).some(isAwaitingPublication) ? 3000 : 30000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter attempted posts (scheduled in the past)
|
// Filter attempted posts (scheduled in the past)
|
||||||
|
|||||||
@@ -19,13 +19,25 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
|
/** Post dont le statut peut changer d'un instant à l'autre. */
|
||||||
|
function isChanging(post: any): boolean {
|
||||||
|
if (post?.generationStatus === 'pending' || post?.generationStatus === 'processing') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return post?.status === 'scheduled' && !!post?.scheduledFor && new Date(post.scheduledFor) <= new Date();
|
||||||
|
}
|
||||||
|
|
||||||
export default function MobileHistory() {
|
export default function MobileHistory() {
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
||||||
// Fetch posts history with polling to see status updates
|
// Fetch posts history with polling to see status updates
|
||||||
const { data: posts } = useQuery<any[]>({
|
const { data: posts } = useQuery<any[]>({
|
||||||
queryKey: ['/api/posts'],
|
queryKey: ['/api/posts'],
|
||||||
refetchInterval: 3000, // Poll every 3 seconds
|
// Un post planifié pour la semaine prochaine ne bougera pas d'ici une
|
||||||
|
// seconde : on ne suit de près que ce dont le statut peut basculer
|
||||||
|
// maintenant (génération en cours, ou heure de publication dépassée).
|
||||||
|
refetchInterval: (query) =>
|
||||||
|
(query.state.data ?? []).some(isChanging) ? 3000 : 30000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const getStatusIcon = (status: string) => {
|
const getStatusIcon = (status: string) => {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import { Checkbox } from "@/components/ui/checkbox";
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
|
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
|
||||||
import type { SocialPage, Media, ScheduledPost } from "@shared/schema";
|
import type { SocialPage, Media, ScheduledPost } from "@shared/schema";
|
||||||
import { PreviewModal } from "@/components/preview-modal";
|
import { PreviewModal } from "@/components/preview-modal";
|
||||||
import { DateTimePicker } from "@/components/datetime-picker";
|
import { DateTimePicker } from "@/components/datetime-picker";
|
||||||
@@ -81,6 +81,7 @@ function SortableMediaItem({
|
|||||||
<MediaThumbnail
|
<MediaThumbnail
|
||||||
src={media.facebookFeedUrl || media.originalUrl}
|
src={media.facebookFeedUrl || media.originalUrl}
|
||||||
alt={media.fileName}
|
alt={media.fileName}
|
||||||
|
thumbnailUrl={media.thumbnailUrl ?? undefined}
|
||||||
type={isVideo ? 'video' : 'image'}
|
type={isVideo ? 'video' : 'image'}
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
@@ -103,6 +104,16 @@ function SortableMediaItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Nombre de médias récents proposés à la sélection. */
|
||||||
|
const RECENT_MEDIA_COUNT = 12;
|
||||||
|
|
||||||
|
/** Minuit, pour que la clé de requête reste stable d'un rendu à l'autre. */
|
||||||
|
function startOfToday(): Date {
|
||||||
|
const date = new Date();
|
||||||
|
date.setHours(0, 0, 0, 0);
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
export default function NewPostMobile() {
|
export default function NewPostMobile() {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [, navigate] = useLocation();
|
const [, navigate] = useLocation();
|
||||||
@@ -119,12 +130,17 @@ export default function NewPostMobile() {
|
|||||||
const [postType, setPostType] = useState<'feed' | 'story' | 'both'>('feed');
|
const [postType, setPostType] = useState<'feed' | 'story' | 'both'>('feed');
|
||||||
const [previewModalOpen, setPreviewModalOpen] = useState(false);
|
const [previewModalOpen, setPreviewModalOpen] = useState(false);
|
||||||
|
|
||||||
const { data: pages = [] } = useQuery<SocialPage[]>({
|
// Ce composeur gère les publications feed/story : TikTok n'accepte que des
|
||||||
|
// vidéos et se pilote depuis la création de reel.
|
||||||
|
const { data: allPages = [] } = useQuery<SocialPage[]>({
|
||||||
queryKey: ['/api/pages'],
|
queryKey: ['/api/pages'],
|
||||||
});
|
});
|
||||||
|
const pages = allPages.filter(p => p.platform !== 'tiktok');
|
||||||
|
|
||||||
|
// Seuls les douze derniers médias sont proposés ici : autant ne demander
|
||||||
|
// que ceux-là plutôt que toute la médiathèque.
|
||||||
const { data: allMedia = [] } = useQuery<Media[]>({
|
const { data: allMedia = [] } = useQuery<Media[]>({
|
||||||
queryKey: ['/api/media'],
|
queryKey: ['/api/media', { limit: RECENT_MEDIA_COUNT }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const mediaList = useMemo(() => {
|
const mediaList = useMemo(() => {
|
||||||
@@ -138,11 +154,13 @@ export default function NewPostMobile() {
|
|||||||
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||||||
return dateB - dateA;
|
return dateB - dateA;
|
||||||
})
|
})
|
||||||
.slice(0, 12);
|
.slice(0, RECENT_MEDIA_COUNT);
|
||||||
}, [allMedia, recentUploads]);
|
}, [allMedia, recentUploads]);
|
||||||
|
|
||||||
|
// Ne sert qu'à griser les créneaux déjà pris dans le sélecteur de date : le
|
||||||
|
// passé n'a aucune influence sur une planification à venir.
|
||||||
const { data: scheduledPosts = [] } = useQuery<ScheduledPost[]>({
|
const { data: scheduledPosts = [] } = useQuery<ScheduledPost[]>({
|
||||||
queryKey: ['/api/scheduled-posts'],
|
queryKey: ['/api/scheduled-posts', { startDate: startOfToday().toISOString() }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const uploadMutation = useMutation({
|
const uploadMutation = useMutation({
|
||||||
@@ -227,10 +245,10 @@ export default function NewPostMobile() {
|
|||||||
});
|
});
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/ai/generations'] });
|
queryClient.invalidateQueries({ queryKey: ['/api/ai/generations'] });
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: (error: unknown) => {
|
||||||
toast({
|
toast({
|
||||||
title: "Erreur",
|
title: "Erreur",
|
||||||
description: "Impossible de générer le texte",
|
description: getErrorMessage(error, "Impossible de générer le texte"),
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -532,6 +550,7 @@ export default function NewPostMobile() {
|
|||||||
<MediaThumbnail
|
<MediaThumbnail
|
||||||
src={media.facebookFeedUrl || media.originalUrl}
|
src={media.facebookFeedUrl || media.originalUrl}
|
||||||
alt={media.fileName}
|
alt={media.fileName}
|
||||||
|
thumbnailUrl={media.thumbnailUrl ?? undefined}
|
||||||
type={isVideo ? 'video' : 'image'}
|
type={isVideo ? 'video' : 'image'}
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { Slider } from "@/components/ui/slider";
|
|||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
|
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
|
||||||
import type { SocialPage, Media } from "@shared/schema";
|
import type { SocialPage, Media } from "@shared/schema";
|
||||||
|
import { SiFacebook, SiTiktok } from "react-icons/si";
|
||||||
import { DateTimePicker } from "@/components/datetime-picker";
|
import { DateTimePicker } from "@/components/datetime-picker";
|
||||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
@@ -114,6 +115,24 @@ export default function MobileNewReel() {
|
|||||||
queryKey: ['/api/pages'],
|
queryKey: ['/api/pages'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Destinations acceptant une vidéo verticale
|
||||||
|
const facebookPages = pages.filter(p => p.platform === 'facebook');
|
||||||
|
const tiktokAccounts = pages.filter(p => p.platform === 'tiktok');
|
||||||
|
|
||||||
|
const renderTargetCheckbox = (page: SocialPage) => (
|
||||||
|
<div key={page.id} className="flex items-center space-x-3 bg-secondary/20 p-3 rounded-lg">
|
||||||
|
<Checkbox
|
||||||
|
id={page.id}
|
||||||
|
checked={selectedPages.includes(page.id)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (checked) setSelectedPages([...selectedPages, page.id]);
|
||||||
|
else setSelectedPages(selectedPages.filter(id => id !== page.id));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label htmlFor={page.id} className="font-medium flex-1">{page.pageName}</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const { data: allMedia = [] } = useQuery<Media[]>({
|
const { data: allMedia = [] } = useQuery<Media[]>({
|
||||||
queryKey: ['/api/media'],
|
queryKey: ['/api/media'],
|
||||||
});
|
});
|
||||||
@@ -585,20 +604,32 @@ export default function MobileNewReel() {
|
|||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-base">Diffusion</CardTitle>
|
<CardTitle className="text-base">Diffusion</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-4">
|
||||||
{pages.filter(p => p.platform === 'facebook').map((page) => (
|
{facebookPages.length === 0 && tiktokAccounts.length === 0 && (
|
||||||
<div key={page.id} className="flex items-center space-x-3 bg-secondary/20 p-3 rounded-lg">
|
<p className="text-sm text-muted-foreground">
|
||||||
<Checkbox
|
Aucune page Facebook ni compte TikTok connecté.
|
||||||
id={page.id}
|
</p>
|
||||||
checked={selectedPages.includes(page.id)}
|
)}
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
if (checked) setSelectedPages([...selectedPages, page.id]);
|
{facebookPages.length > 0 && (
|
||||||
else setSelectedPages(selectedPages.filter(id => id !== page.id));
|
<div className="space-y-3">
|
||||||
}}
|
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
/>
|
<SiFacebook className="w-4 h-4 text-[#1877F2]" />
|
||||||
<label htmlFor={page.id} className="font-medium flex-1">{page.pageName}</label>
|
Pages Facebook
|
||||||
|
</div>
|
||||||
|
{facebookPages.map(renderTargetCheckbox)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
|
||||||
|
{tiktokAccounts.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
<SiTiktok className="w-4 h-4" />
|
||||||
|
Comptes TikTok
|
||||||
|
</div>
|
||||||
|
{tiktokAccounts.map(renderTargetCheckbox)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code, Calendar, AlertTriangle } from "lucide-react";
|
import { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code } from "lucide-react";
|
||||||
|
import { SiTiktok } from "react-icons/si";
|
||||||
import Sidebar from "@/components/sidebar";
|
import Sidebar from "@/components/sidebar";
|
||||||
import TopBar from "@/components/topbar";
|
import TopBar from "@/components/topbar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -11,68 +12,77 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
import type { SocialPage } from "@shared/schema";
|
import type { ClientSocialPage } from "@shared/schema";
|
||||||
import { format } from "date-fns";
|
import {
|
||||||
import { fr } from "date-fns/locale";
|
ConnectFacebookButton,
|
||||||
|
RefreshTokenButton,
|
||||||
|
TokenAlertBanner,
|
||||||
|
TokenHealthPanel,
|
||||||
|
} from "@/components/facebook-token-status";
|
||||||
|
|
||||||
function getTokenExpirationStatus(expiresAt: string | null | undefined) {
|
/**
|
||||||
if (!expiresAt) {
|
* Lance l'autorisation TikTok. La connexion se fait par navigation complète du
|
||||||
return {
|
* navigateur (et non en fetch) puisqu'elle passe par le site de TikTok.
|
||||||
status: 'unknown' as const,
|
*/
|
||||||
color: 'text-gray-500',
|
function ConnectTiktokButton() {
|
||||||
bgColor: 'bg-gray-100',
|
const { toast } = useToast();
|
||||||
daysLeft: null,
|
|
||||||
message: 'Date inconnue'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date();
|
const { data: config } = useQuery<{ configured: boolean }>({
|
||||||
const expiration = new Date(expiresAt);
|
queryKey: ['/api/tiktok/config'],
|
||||||
const diffTime = expiration.getTime() - now.getTime();
|
});
|
||||||
const daysLeft = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
if (daysLeft < 0) {
|
const handleClick = () => {
|
||||||
return {
|
if (config && !config.configured) {
|
||||||
status: 'expired' as const,
|
toast({
|
||||||
color: 'text-red-600',
|
title: "TikTok n'est pas configuré",
|
||||||
bgColor: 'bg-red-100',
|
description: "Renseignez le client key et le client secret de votre application TikTok dans les paramètres.",
|
||||||
daysLeft,
|
variant: "destructive",
|
||||||
message: 'Expiré'
|
});
|
||||||
};
|
return;
|
||||||
} else if (daysLeft <= 7) {
|
}
|
||||||
return {
|
window.location.href = '/api/tiktok/connect';
|
||||||
status: 'urgent' as const,
|
};
|
||||||
color: 'text-red-600',
|
|
||||||
bgColor: 'bg-red-100',
|
return (
|
||||||
daysLeft,
|
<Button
|
||||||
message: `${daysLeft} jour${daysLeft > 1 ? 's' : ''} restant${daysLeft > 1 ? 's' : ''}`
|
variant="outline"
|
||||||
};
|
className="w-full min-h-[48px]"
|
||||||
} else if (daysLeft <= 15) {
|
onClick={handleClick}
|
||||||
return {
|
data-testid="button-connect-tiktok"
|
||||||
status: 'warning' as const,
|
>
|
||||||
color: 'text-orange-600',
|
<SiTiktok className="w-5 h-5 mr-2" />
|
||||||
bgColor: 'bg-orange-100',
|
Connecter un compte TikTok
|
||||||
daysLeft,
|
</Button>
|
||||||
message: `${daysLeft} jours restants`
|
);
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
status: 'good' as const,
|
|
||||||
color: 'text-green-600',
|
|
||||||
bgColor: 'bg-green-100',
|
|
||||||
daysLeft,
|
|
||||||
message: `${daysLeft} jours restants`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PagesManagementMobile() {
|
export default function PagesManagementMobile() {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [editingPage, setEditingPage] = useState<SocialPage | null>(null);
|
const [editingPage, setEditingPage] = useState<ClientSocialPage | null>(null);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { data: pages = [], isLoading } = useQuery<SocialPage[]>({
|
// Retour des flux d'autorisation (/api/tiktok/callback et /api/facebook/callback
|
||||||
|
// redirigent ici avec le résultat en paramètre)
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const network = params.get('tiktok') ? 'tiktok' : params.get('facebook') ? 'facebook' : null;
|
||||||
|
if (!network) return;
|
||||||
|
|
||||||
|
const result = params.get(network);
|
||||||
|
const label = network === 'tiktok' ? 'TikTok' : 'Facebook';
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: result === 'success' ? `${label} connecté` : `Connexion ${label} impossible`,
|
||||||
|
description: params.get('message') || undefined,
|
||||||
|
variant: result === 'success' ? undefined : 'destructive',
|
||||||
|
});
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['/api/pages'] });
|
||||||
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
|
const { data: pages = [], isLoading } = useQuery<ClientSocialPage[]>({
|
||||||
queryKey: ['/api/pages'],
|
queryKey: ['/api/pages'],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -119,10 +129,14 @@ export default function PagesManagementMobile() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h1 className="text-2xl font-bold text-foreground">Pages gérées</h1>
|
<h1 className="text-2xl font-bold text-foreground">Pages gérées</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Connectez et gérez vos pages Facebook et Instagram
|
Connectez et gérez vos pages Facebook, Instagram et vos comptes TikTok
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TokenAlertBanner pages={pages} />
|
||||||
|
|
||||||
|
<ConnectFacebookButton className="w-full min-h-[48px]" />
|
||||||
|
<ConnectTiktokButton />
|
||||||
<AddPageDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
<AddPageDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
||||||
<EditPageDialog page={editingPage} onOpenChange={(open) => !open && setEditingPage(null)} />
|
<EditPageDialog page={editingPage} onOpenChange={(open) => !open && setEditingPage(null)} />
|
||||||
|
|
||||||
@@ -181,6 +195,10 @@ export default function PagesManagementMobile() {
|
|||||||
<div className="w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
<div className="w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
<Facebook className="w-6 h-6 text-white" />
|
<Facebook className="w-6 h-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
|
) : page.platform === 'tiktok' ? (
|
||||||
|
<div className="w-12 h-12 bg-black rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
|
<SiTiktok className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-500 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
<Instagram className="w-6 h-6 text-white" />
|
<Instagram className="w-6 h-6 text-white" />
|
||||||
@@ -200,42 +218,37 @@ export default function PagesManagementMobile() {
|
|||||||
ID: {page.pageId}
|
ID: {page.pageId}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Token expiration status */}
|
{/* État du jeton, tel que constaté par le dernier contrôle serveur */}
|
||||||
{(() => {
|
<TokenHealthPanel page={page} />
|
||||||
const expirationStatus = getTokenExpirationStatus(
|
|
||||||
page.tokenExpiresAt ? new Date(page.tokenExpiresAt).toISOString() : null
|
{page.platform !== 'tiktok' && (
|
||||||
);
|
<div className="flex">
|
||||||
return (
|
<RefreshTokenButton page={page} />
|
||||||
<div className={`flex items-center gap-2 text-xs px-3 py-3 rounded-lg ${expirationStatus.bgColor}`}>
|
</div>
|
||||||
<Calendar className="w-4 h-4 flex-shrink-0" />
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="font-medium">Expiration du token</div>
|
|
||||||
<div className={`${expirationStatus.color} font-semibold`}>
|
|
||||||
{expirationStatus.message}
|
|
||||||
</div>
|
|
||||||
{page.tokenExpiresAt && (
|
|
||||||
<div className="text-muted-foreground mt-0.5">
|
|
||||||
{format(new Date(page.tokenExpiresAt), "d MMMM yyyy", { locale: fr })}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{(expirationStatus.status === 'expired' || expirationStatus.status === 'urgent') && (
|
|
||||||
<AlertTriangle className="w-4 h-4 text-red-600 flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 pt-2">
|
<div className="flex items-center gap-2 pt-2">
|
||||||
<Button
|
{page.platform === 'tiktok' ? (
|
||||||
variant="outline"
|
<Button
|
||||||
className="flex-1 min-h-[44px]"
|
variant="outline"
|
||||||
onClick={() => setEditingPage(page)}
|
className="flex-1 min-h-[44px]"
|
||||||
data-testid={`button-edit-page-${page.id}`}
|
onClick={() => { window.location.href = '/api/tiktok/connect'; }}
|
||||||
>
|
data-testid={`button-reconnect-page-${page.id}`}
|
||||||
<Edit className="w-4 h-4 mr-2" />
|
>
|
||||||
Modifier
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
</Button>
|
Reconnecter
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1 min-h-[44px]"
|
||||||
|
onClick={() => setEditingPage(page)}
|
||||||
|
data-testid={`button-edit-page-${page.id}`}
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4 mr-2" />
|
||||||
|
Modifier
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="flex-1 min-h-[44px]"
|
className="flex-1 min-h-[44px]"
|
||||||
@@ -417,7 +430,7 @@ function AddPageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EditPageDialog({ page, onOpenChange }: { page: SocialPage | null; onOpenChange: (open: boolean) => void }) {
|
function EditPageDialog({ page, onOpenChange }: { page: ClientSocialPage | null; onOpenChange: (open: boolean) => void }) {
|
||||||
const [accessToken, setAccessToken] = useState('');
|
const [accessToken, setAccessToken] = useState('');
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ import { MediaThumbnail } from "@/components/media-thumbnail";
|
|||||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||||
import Sidebar from "@/components/sidebar";
|
import Sidebar from "@/components/sidebar";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { DateTimePicker } from "@/components/datetime-picker";
|
import { DateTimePicker } from "@/components/datetime-picker";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
|
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
|
||||||
import type { Media, SocialPage } from "@shared/schema";
|
import type { Media, SocialPage } from "@shared/schema";
|
||||||
|
import { SiFacebook, SiTiktok } from "react-icons/si";
|
||||||
|
|
||||||
interface AudioTrack { id: string; title: string; fileName: string; url: string; duration: number; }
|
interface AudioTrack { id: string; title: string; fileName: string; url: string; duration: number; }
|
||||||
|
|
||||||
@@ -68,12 +70,29 @@ export default function MobileRemotionVideoPage() {
|
|||||||
const { data: socialPages = [] } = useQuery<SocialPage[]>({ queryKey: ['/api/pages'] });
|
const { data: socialPages = [] } = useQuery<SocialPage[]>({ queryKey: ['/api/pages'] });
|
||||||
|
|
||||||
const facebookPages = socialPages.filter(p => p.platform === 'facebook');
|
const facebookPages = socialPages.filter(p => p.platform === 'facebook');
|
||||||
|
const tiktokAccounts = socialPages.filter(p => p.platform === 'tiktok');
|
||||||
|
|
||||||
|
const renderTargetCheckbox = (page: SocialPage) => (
|
||||||
|
<div key={page.id} className="flex items-center gap-3">
|
||||||
|
<Checkbox
|
||||||
|
id={`page-${page.id}`}
|
||||||
|
checked={selectedPageIds.includes(page.id)}
|
||||||
|
onCheckedChange={checked =>
|
||||||
|
setSelectedPageIds(prev => checked ? [...prev, page.id] : prev.filter(id => id !== page.id))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label htmlFor={`page-${page.id}`} className="text-sm font-medium cursor-pointer">
|
||||||
|
{page.pageName}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20);
|
const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20);
|
||||||
|
|
||||||
const generateTextMutation = useMutation({
|
const generateTextMutation = useMutation({
|
||||||
mutationFn: async (text: string) => (await apiRequest('POST', '/api/reels/generate-text', { productInfo: text })).json(),
|
mutationFn: async (text: string) => (await apiRequest('POST', '/api/reels/generate-text', { productInfo: text })).json(),
|
||||||
onSuccess: (data: any) => { setGeneratedVariants(data.variants || []); toast({ title: "Textes générés" }); },
|
onSuccess: (data: any) => { setGeneratedVariants(data.variants || []); toast({ title: "Textes générés" }); },
|
||||||
onError: () => toast({ title: "Erreur IA", variant: "destructive" }),
|
onError: (error: unknown) => toast({ title: "Erreur IA", description: getErrorMessage(error, "Impossible de générer le texte"), variant: "destructive" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -430,31 +449,36 @@ export default function MobileRemotionVideoPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 4. Page Facebook */}
|
{/* 4. Destinations */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="text-base flex items-center gap-2"><Send className="w-4 h-4" /> 4. Page Facebook</CardTitle>
|
<CardTitle className="text-base flex items-center gap-2"><Send className="w-4 h-4" /> 4. Destinations</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
{facebookPages.length === 0 ? (
|
{facebookPages.length === 0 && tiktokAccounts.length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground">Aucune page Facebook connectée.</p>
|
<p className="text-xs text-muted-foreground">Aucune page Facebook ni compte TikTok connecté.</p>
|
||||||
) : (
|
) : (
|
||||||
facebookPages.map(page => (
|
<>
|
||||||
<div key={page.id} className="flex items-center gap-3">
|
{facebookPages.length > 0 && (
|
||||||
<Checkbox
|
<div className="space-y-2">
|
||||||
id={`page-${page.id}`}
|
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
checked={selectedPageIds.includes(page.id)}
|
<SiFacebook className="w-4 h-4 text-[#1877F2]" />
|
||||||
onCheckedChange={checked =>
|
Pages Facebook
|
||||||
setSelectedPageIds(prev =>
|
</div>
|
||||||
checked ? [...prev, page.id] : prev.filter(id => id !== page.id)
|
{facebookPages.map(renderTargetCheckbox)}
|
||||||
)
|
</div>
|
||||||
}
|
)}
|
||||||
/>
|
|
||||||
<label htmlFor={`page-${page.id}`} className="text-sm font-medium cursor-pointer">
|
{tiktokAccounts.length > 0 && (
|
||||||
{page.pageName}
|
<div className="space-y-2 pt-2">
|
||||||
</label>
|
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
</div>
|
<SiTiktok className="w-4 h-4" />
|
||||||
))
|
Comptes TikTok
|
||||||
|
</div>
|
||||||
|
{tiktokAccounts.map(renderTargetCheckbox)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -484,7 +508,7 @@ export default function MobileRemotionVideoPage() {
|
|||||||
<p className="text-xs text-muted-foreground">Sélectionnez une page ci-dessus.</p>
|
<p className="text-xs text-muted-foreground">Sélectionnez une page ci-dessus.</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-primary font-medium">
|
<p className="text-xs text-primary font-medium">
|
||||||
Pages : {facebookPages.filter(p => selectedPageIds.includes(p.id)).map(p => p.pageName).join(', ')}
|
Destinations : {[...facebookPages, ...tiktokAccounts].filter(p => selectedPageIds.includes(p.id)).map(p => p.pageName).join(', ')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<Textarea rows={2} placeholder="Description (optionnel)" value={publishDescription}
|
<Textarea rows={2} placeholder="Description (optionnel)" value={publishDescription}
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ export default function SettingsMobile() {
|
|||||||
|
|
||||||
const { data: openrouterModels, isLoading: modelsLoading } = useQuery({
|
const { data: openrouterModels, isLoading: modelsLoading } = useQuery({
|
||||||
queryKey: ['/api/openrouter/models'],
|
queryKey: ['/api/openrouter/models'],
|
||||||
|
// Liste servie par un appel sortant vers OpenRouter : elle ne bouge pas d'une
|
||||||
|
// heure à l'autre, inutile de la redemander à chaque retour sur l'onglet.
|
||||||
|
staleTime: 60 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import { Checkbox } from "@/components/ui/checkbox";
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
|
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
|
||||||
import { MediaThumbnail } from "@/components/media-thumbnail";
|
import { MediaThumbnail } from "@/components/media-thumbnail";
|
||||||
import type { SocialPage, Media, ScheduledPost } from "@shared/schema";
|
import type { SocialPage, Media, ScheduledPost } from "@shared/schema";
|
||||||
import { PreviewModal } from "@/components/preview-modal";
|
import { PreviewModal } from "@/components/preview-modal";
|
||||||
@@ -80,6 +80,7 @@ function SortableMediaItem({
|
|||||||
<MediaThumbnail
|
<MediaThumbnail
|
||||||
src={media.facebookFeedUrl || media.originalUrl}
|
src={media.facebookFeedUrl || media.originalUrl}
|
||||||
alt={media.fileName}
|
alt={media.fileName}
|
||||||
|
thumbnailUrl={media.thumbnailUrl ?? undefined}
|
||||||
type={isVideo ? 'video' : 'image'}
|
type={isVideo ? 'video' : 'image'}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
@@ -101,6 +102,16 @@ function SortableMediaItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Nombre de médias récents proposés à la sélection. */
|
||||||
|
const RECENT_MEDIA_COUNT = 12;
|
||||||
|
|
||||||
|
/** Minuit, pour que la clé de requête reste stable d'un rendu à l'autre. */
|
||||||
|
function startOfToday(): Date {
|
||||||
|
const date = new Date();
|
||||||
|
date.setHours(0, 0, 0, 0);
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
export default function NewPost() {
|
export default function NewPost() {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [, navigate] = useLocation();
|
const [, navigate] = useLocation();
|
||||||
@@ -116,12 +127,17 @@ export default function NewPost() {
|
|||||||
const [postType, setPostType] = useState<'feed' | 'story' | 'both'>('feed');
|
const [postType, setPostType] = useState<'feed' | 'story' | 'both'>('feed');
|
||||||
const [previewModalOpen, setPreviewModalOpen] = useState(false);
|
const [previewModalOpen, setPreviewModalOpen] = useState(false);
|
||||||
|
|
||||||
const { data: pages = [] } = useQuery<SocialPage[]>({
|
// Ce composeur gère les publications feed/story : TikTok n'accepte que des
|
||||||
|
// vidéos et se pilote depuis la création de reel.
|
||||||
|
const { data: allPages = [] } = useQuery<SocialPage[]>({
|
||||||
queryKey: ['/api/pages'],
|
queryKey: ['/api/pages'],
|
||||||
});
|
});
|
||||||
|
const pages = allPages.filter(p => p.platform !== 'tiktok');
|
||||||
|
|
||||||
|
// Seuls les douze derniers médias sont proposés ici : autant ne demander
|
||||||
|
// que ceux-là plutôt que toute la médiathèque.
|
||||||
const { data: allMedia = [] } = useQuery<Media[]>({
|
const { data: allMedia = [] } = useQuery<Media[]>({
|
||||||
queryKey: ['/api/media'],
|
queryKey: ['/api/media', { limit: RECENT_MEDIA_COUNT }],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Afficher seulement les 12 derniers médias triés par date décroissante
|
// Afficher seulement les 12 derniers médias triés par date décroissante
|
||||||
@@ -134,11 +150,13 @@ export default function NewPost() {
|
|||||||
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||||||
return dateB - dateA; // Tri décroissant (plus récent en premier)
|
return dateB - dateA; // Tri décroissant (plus récent en premier)
|
||||||
})
|
})
|
||||||
.slice(0, 12);
|
.slice(0, RECENT_MEDIA_COUNT);
|
||||||
}, [allMedia]);
|
}, [allMedia]);
|
||||||
|
|
||||||
|
// Ne sert qu'à griser les créneaux déjà pris dans le sélecteur de date : le
|
||||||
|
// passé n'a aucune influence sur une planification à venir.
|
||||||
const { data: scheduledPosts = [] } = useQuery<ScheduledPost[]>({
|
const { data: scheduledPosts = [] } = useQuery<ScheduledPost[]>({
|
||||||
queryKey: ['/api/scheduled-posts'],
|
queryKey: ['/api/scheduled-posts', { startDate: startOfToday().toISOString() }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const uploadMutation = useMutation({
|
const uploadMutation = useMutation({
|
||||||
@@ -223,10 +241,10 @@ export default function NewPost() {
|
|||||||
});
|
});
|
||||||
queryClient.invalidateQueries({ queryKey: ['/api/ai/generations'] });
|
queryClient.invalidateQueries({ queryKey: ['/api/ai/generations'] });
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: (error: unknown) => {
|
||||||
toast({
|
toast({
|
||||||
title: "Erreur",
|
title: "Erreur",
|
||||||
description: "Impossible de générer le texte",
|
description: getErrorMessage(error, "Impossible de générer le texte"),
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -540,6 +558,7 @@ export default function NewPost() {
|
|||||||
<MediaThumbnail
|
<MediaThumbnail
|
||||||
src={media.facebookFeedUrl || media.originalUrl}
|
src={media.facebookFeedUrl || media.originalUrl}
|
||||||
alt={media.fileName}
|
alt={media.fileName}
|
||||||
|
thumbnailUrl={media.thumbnailUrl ?? undefined}
|
||||||
type={isVideo ? 'video' : 'image'}
|
type={isVideo ? 'video' : 'image'}
|
||||||
/>
|
/>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
|
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
|
||||||
import type { SocialPage, Media } from "@shared/schema";
|
import type { SocialPage, Media } from "@shared/schema";
|
||||||
|
import { SiFacebook, SiTiktok } from "react-icons/si";
|
||||||
import { MediaThumbnail } from "@/components/media-thumbnail";
|
import { MediaThumbnail } from "@/components/media-thumbnail";
|
||||||
import { DateTimePicker } from "@/components/datetime-picker";
|
import { DateTimePicker } from "@/components/datetime-picker";
|
||||||
|
|
||||||
@@ -133,6 +134,32 @@ export default function NewReel() {
|
|||||||
queryKey: ['/api/pages'],
|
queryKey: ['/api/pages'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Destinations acceptant une vidéo verticale
|
||||||
|
const facebookPages = pages.filter(p => p.platform === 'facebook');
|
||||||
|
const tiktokAccounts = pages.filter(p => p.platform === 'tiktok');
|
||||||
|
|
||||||
|
const renderTargetCheckbox = (page: SocialPage) => (
|
||||||
|
<div key={page.id} className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`page-${page.id}`}
|
||||||
|
checked={selectedPages.includes(page.id)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (checked) {
|
||||||
|
setSelectedPages([...selectedPages, page.id]);
|
||||||
|
} else {
|
||||||
|
setSelectedPages(selectedPages.filter(id => id !== page.id));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`page-${page.id}`}
|
||||||
|
className="text-sm font-medium leading-none flex-1"
|
||||||
|
>
|
||||||
|
{page.pageName}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
// Récupérer les vidéos disponibles
|
// Récupérer les vidéos disponibles
|
||||||
const { data: allMedia = [] } = useQuery<Media[]>({
|
const { data: allMedia = [] } = useQuery<Media[]>({
|
||||||
queryKey: ['/api/media'],
|
queryKey: ['/api/media'],
|
||||||
@@ -208,10 +235,10 @@ export default function NewReel() {
|
|||||||
description: `${variants.length} variations créées`,
|
description: `${variants.length} variations créées`,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: (error: unknown) => {
|
||||||
toast({
|
toast({
|
||||||
title: "Erreur",
|
title: "Erreur",
|
||||||
description: "Impossible de générer le texte",
|
description: getErrorMessage(error, "Impossible de générer le texte"),
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -541,6 +568,7 @@ export default function NewReel() {
|
|||||||
<MediaThumbnail
|
<MediaThumbnail
|
||||||
src={media.originalUrl}
|
src={media.originalUrl}
|
||||||
alt={media.fileName}
|
alt={media.fileName}
|
||||||
|
thumbnailUrl={media.thumbnailUrl ?? undefined}
|
||||||
type="video"
|
type="video"
|
||||||
/>
|
/>
|
||||||
{selectedVideoId === media.id && (
|
{selectedVideoId === media.id && (
|
||||||
@@ -918,42 +946,42 @@ export default function NewReel() {
|
|||||||
<>
|
<>
|
||||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Pages cibles</CardTitle>
|
<CardTitle>Destinations</CardTitle>
|
||||||
<CardDescription>Sélectionnez les pages où publier</CardDescription>
|
<CardDescription>
|
||||||
|
La même vidéo peut partir sur plusieurs pages Facebook et plusieurs comptes TikTok
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{pages.filter(p => p.platform === 'facebook').length === 0 ? (
|
{facebookPages.length === 0 && tiktokAccounts.length === 0 ? (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<p className="text-muted-foreground mb-2">
|
<p className="text-muted-foreground mb-2">
|
||||||
Aucune page Facebook connectée
|
Aucune page Facebook ni compte TikTok connecté
|
||||||
</p>
|
</p>
|
||||||
<Button variant="link" onClick={() => navigate('/pages')}>
|
<Button variant="link" onClick={() => navigate('/pages')}>
|
||||||
Ajouter des pages
|
Ajouter des pages
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-5">
|
||||||
{pages.filter(p => p.platform === 'facebook').map((page) => (
|
{facebookPages.length > 0 && (
|
||||||
<div key={page.id} className="flex items-center space-x-2">
|
<div className="space-y-3">
|
||||||
<Checkbox
|
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
id={`page-${page.id}`}
|
<SiFacebook className="w-4 h-4 text-[#1877F2]" />
|
||||||
checked={selectedPages.includes(page.id)}
|
Pages Facebook
|
||||||
onCheckedChange={(checked) => {
|
</div>
|
||||||
if (checked) {
|
{facebookPages.map(renderTargetCheckbox)}
|
||||||
setSelectedPages([...selectedPages, page.id]);
|
|
||||||
} else {
|
|
||||||
setSelectedPages(selectedPages.filter(id => id !== page.id));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor={`page-${page.id}`}
|
|
||||||
className="text-sm font-medium leading-none flex-1"
|
|
||||||
>
|
|
||||||
{page.pageName}
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
|
||||||
|
{tiktokAccounts.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
<SiTiktok className="w-4 h-4" />
|
||||||
|
Comptes TikTok
|
||||||
|
</div>
|
||||||
|
{tiktokAccounts.map(renderTargetCheckbox)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
+107
-99
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code, Calendar, AlertTriangle } from "lucide-react";
|
import { Plus, Facebook, Instagram, Trash2, RefreshCw, Edit, Bug, Code, AlertTriangle } from "lucide-react";
|
||||||
|
import { SiTiktok } from "react-icons/si";
|
||||||
import Sidebar from "@/components/sidebar";
|
import Sidebar from "@/components/sidebar";
|
||||||
import TopBar from "@/components/topbar";
|
import TopBar from "@/components/topbar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -11,68 +12,41 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
import type { SocialPage } from "@shared/schema";
|
import type { ClientSocialPage } from "@shared/schema";
|
||||||
import { format } from "date-fns";
|
import {
|
||||||
import { fr } from "date-fns/locale";
|
ConnectFacebookButton,
|
||||||
|
RefreshTokenButton,
|
||||||
function getTokenExpirationStatus(expiresAt: string | null | undefined) {
|
TokenAlertBanner,
|
||||||
if (!expiresAt) {
|
TokenHealthPanel,
|
||||||
return {
|
} from "@/components/facebook-token-status";
|
||||||
status: 'unknown' as const,
|
|
||||||
color: 'text-gray-500',
|
|
||||||
bgColor: 'bg-gray-100',
|
|
||||||
daysLeft: null,
|
|
||||||
message: 'Date inconnue'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const expiration = new Date(expiresAt);
|
|
||||||
const diffTime = expiration.getTime() - now.getTime();
|
|
||||||
const daysLeft = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
if (daysLeft < 0) {
|
|
||||||
return {
|
|
||||||
status: 'expired' as const,
|
|
||||||
color: 'text-red-600',
|
|
||||||
bgColor: 'bg-red-100',
|
|
||||||
daysLeft,
|
|
||||||
message: 'Expiré'
|
|
||||||
};
|
|
||||||
} else if (daysLeft <= 7) {
|
|
||||||
return {
|
|
||||||
status: 'urgent' as const,
|
|
||||||
color: 'text-red-600',
|
|
||||||
bgColor: 'bg-red-100',
|
|
||||||
daysLeft,
|
|
||||||
message: `${daysLeft} jour${daysLeft > 1 ? 's' : ''} restant${daysLeft > 1 ? 's' : ''}`
|
|
||||||
};
|
|
||||||
} else if (daysLeft <= 15) {
|
|
||||||
return {
|
|
||||||
status: 'warning' as const,
|
|
||||||
color: 'text-orange-600',
|
|
||||||
bgColor: 'bg-orange-100',
|
|
||||||
daysLeft,
|
|
||||||
message: `${daysLeft} jours restants`
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
status: 'good' as const,
|
|
||||||
color: 'text-green-600',
|
|
||||||
bgColor: 'bg-green-100',
|
|
||||||
daysLeft,
|
|
||||||
message: `${daysLeft} jours restants`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PagesManagement() {
|
export default function PagesManagement() {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [editingPage, setEditingPage] = useState<SocialPage | null>(null);
|
const [editingPage, setEditingPage] = useState<ClientSocialPage | null>(null);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { data: pages = [], isLoading } = useQuery<SocialPage[]>({
|
// Retour des flux d'autorisation (/api/tiktok/callback et /api/facebook/callback
|
||||||
|
// redirigent ici avec le résultat en paramètre)
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const network = params.get('tiktok') ? 'tiktok' : params.get('facebook') ? 'facebook' : null;
|
||||||
|
if (!network) return;
|
||||||
|
|
||||||
|
const result = params.get(network);
|
||||||
|
const label = network === 'tiktok' ? 'TikTok' : 'Facebook';
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: result === 'success' ? `${label} connecté` : `Connexion ${label} impossible`,
|
||||||
|
description: params.get('message') || undefined,
|
||||||
|
variant: result === 'success' ? undefined : 'destructive',
|
||||||
|
});
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['/api/pages'] });
|
||||||
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
|
const { data: pages = [], isLoading } = useQuery<ClientSocialPage[]>({
|
||||||
queryKey: ['/api/pages'],
|
queryKey: ['/api/pages'],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -119,25 +93,33 @@ export default function PagesManagement() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-foreground">Pages gérées</h1>
|
<h1 className="text-3xl font-bold text-foreground">Pages gérées</h1>
|
||||||
<p className="text-muted-foreground mt-2">
|
<p className="text-muted-foreground mt-2">
|
||||||
Connectez et gérez vos pages Facebook et Instagram
|
Connectez et gérez vos pages Facebook, Instagram et vos comptes TikTok
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<AddPageDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
<div className="flex items-center gap-3">
|
||||||
|
<ConnectFacebookButton />
|
||||||
|
<ConnectTiktokButton />
|
||||||
|
<AddPageDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
||||||
|
</div>
|
||||||
<EditPageDialog page={editingPage} onOpenChange={(open) => !open && setEditingPage(null)} />
|
<EditPageDialog page={editingPage} onOpenChange={(open) => !open && setEditingPage(null)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TokenAlertBanner pages={pages} />
|
||||||
|
|
||||||
{/* Permissions required notice */}
|
{/* Permissions required notice */}
|
||||||
<div className="mb-6 p-4 bg-amber-500/10 border border-amber-500/30 rounded-xl flex gap-3">
|
<div className="mb-6 p-4 bg-amber-500/10 border border-amber-500/30 rounded-xl flex gap-3">
|
||||||
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
|
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<p className="font-semibold text-amber-600 dark:text-amber-400 mb-1">Permissions Facebook requises pour la publication vidéo</p>
|
<p className="font-semibold text-amber-600 dark:text-amber-400 mb-1">Connectez vos pages plutôt que de coller un jeton</p>
|
||||||
<p className="text-muted-foreground mb-2">
|
<p className="text-muted-foreground mb-2">
|
||||||
Le token de page doit inclure : <code className="bg-muted px-1 rounded text-xs">pages_manage_posts</code>{" "}
|
« Connecter des pages Facebook » récupère les jetons de page via l'autorisation Facebook :
|
||||||
<code className="bg-muted px-1 rounded text-xs">pages_read_engagement</code>{" "}
|
ils n'expirent pas et SocialFlow les régénère seul en cas de révocation. Un jeton collé à la
|
||||||
<code className="bg-muted px-1 rounded text-xs">publish_video</code>
|
main expire au bout de 60 jours et devra être remplacé manuellement.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
Dans Graph API Explorer → Sélectionnez votre app → Ajoutez ces 3 permissions → Générez un User Token → Échangez via <code className="bg-muted px-1 rounded">/me/accounts</code> pour obtenir le Page Token.
|
Le jeton doit couvrir : <code className="bg-muted px-1 rounded text-xs">pages_manage_posts</code>{" "}
|
||||||
|
<code className="bg-muted px-1 rounded text-xs">pages_read_engagement</code>{" "}
|
||||||
|
<code className="bg-muted px-1 rounded text-xs">publish_video</code>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -194,6 +176,10 @@ export default function PagesManagement() {
|
|||||||
<div className="w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center">
|
<div className="w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||||
<Facebook className="w-6 h-6 text-white" />
|
<Facebook className="w-6 h-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
|
) : page.platform === 'tiktok' ? (
|
||||||
|
<div className="w-12 h-12 bg-black rounded-lg flex items-center justify-center">
|
||||||
|
<SiTiktok className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-500 rounded-lg flex items-center justify-center">
|
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-500 rounded-lg flex items-center justify-center">
|
||||||
<Instagram className="w-6 h-6 text-white" />
|
<Instagram className="w-6 h-6 text-white" />
|
||||||
@@ -213,41 +199,32 @@ export default function PagesManagement() {
|
|||||||
ID: {page.pageId}
|
ID: {page.pageId}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Token expiration status */}
|
{/* État du jeton, tel que constaté par le dernier contrôle serveur */}
|
||||||
{(() => {
|
<TokenHealthPanel page={page} />
|
||||||
const expirationStatus = getTokenExpirationStatus(
|
|
||||||
page.tokenExpiresAt ? new Date(page.tokenExpiresAt).toISOString() : null
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div className={`flex items-center gap-2 text-xs px-3 py-2 rounded-lg ${expirationStatus.bgColor}`}>
|
|
||||||
<Calendar className="w-4 h-4" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="font-medium">Expiration du token</div>
|
|
||||||
<div className={`${expirationStatus.color} font-semibold`}>
|
|
||||||
{expirationStatus.message}
|
|
||||||
</div>
|
|
||||||
{page.tokenExpiresAt && (
|
|
||||||
<div className="text-muted-foreground mt-0.5">
|
|
||||||
{format(new Date(page.tokenExpiresAt), "d MMMM yyyy", { locale: fr })}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{(expirationStatus.status === 'expired' || expirationStatus.status === 'urgent') && (
|
|
||||||
<AlertTriangle className="w-4 h-4 text-red-600" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2 pt-2">
|
<div className="flex items-center justify-end gap-2 pt-2">
|
||||||
<Button
|
{page.platform !== 'tiktok' && <RefreshTokenButton page={page} />}
|
||||||
variant="ghost"
|
{page.platform === 'tiktok' ? (
|
||||||
size="icon"
|
// Un token TikTok ne se saisit pas : on repasse par l'autorisation
|
||||||
onClick={() => setEditingPage(page)}
|
<Button
|
||||||
data-testid={`button-edit-page-${page.id}`}
|
variant="ghost"
|
||||||
>
|
size="sm"
|
||||||
<Edit className="w-4 h-4" />
|
onClick={() => { window.location.href = '/api/tiktok/connect'; }}
|
||||||
</Button>
|
data-testid={`button-reconnect-page-${page.id}`}
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
Reconnecter
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setEditingPage(page)}
|
||||||
|
data-testid={`button-edit-page-${page.id}`}
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -269,6 +246,37 @@ export default function PagesManagement() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lance l'autorisation TikTok. La connexion se fait par navigation complète du
|
||||||
|
* navigateur (et non en fetch) puisqu'elle passe par le site de TikTok.
|
||||||
|
*/
|
||||||
|
function ConnectTiktokButton() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { data: config } = useQuery<{ configured: boolean }>({
|
||||||
|
queryKey: ['/api/tiktok/config'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (config && !config.configured) {
|
||||||
|
toast({
|
||||||
|
title: "TikTok n'est pas configuré",
|
||||||
|
description: "Renseignez le client key et le client secret de votre application TikTok dans les paramètres.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = '/api/tiktok/connect';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button variant="outline" onClick={handleClick} data-testid="button-connect-tiktok">
|
||||||
|
<SiTiktok className="w-4 h-4 mr-2" />
|
||||||
|
Connecter un compte TikTok
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AddPageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
function AddPageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||||
const [platform, setPlatform] = useState<'facebook' | 'instagram'>('facebook');
|
const [platform, setPlatform] = useState<'facebook' | 'instagram'>('facebook');
|
||||||
const [pageName, setPageName] = useState('');
|
const [pageName, setPageName] = useState('');
|
||||||
@@ -417,7 +425,7 @@ function AddPageDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EditPageDialog({ page, onOpenChange }: { page: SocialPage | null; onOpenChange: (open: boolean) => void }) {
|
function EditPageDialog({ page, onOpenChange }: { page: ClientSocialPage | null; onOpenChange: (open: boolean) => void }) {
|
||||||
const [accessToken, setAccessToken] = useState('');
|
const [accessToken, setAccessToken] = useState('');
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { UploadCloud, Video, Loader2, Check, Sparkles, Mic, Volume2, Music, Play, Pause, Send } from "lucide-react";
|
import { UploadCloud, Video, Loader2, Check, Sparkles, Mic, Volume2, Music, Play, Pause, Send } from "lucide-react";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
|
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { DateTimePicker } from "@/components/datetime-picker";
|
import { DateTimePicker } from "@/components/datetime-picker";
|
||||||
import type { Media, SocialPage } from "@shared/schema";
|
import type { Media, SocialPage } from "@shared/schema";
|
||||||
|
import { SiFacebook, SiTiktok } from "react-icons/si";
|
||||||
|
|
||||||
|
|
||||||
interface AudioTrack {
|
interface AudioTrack {
|
||||||
@@ -67,6 +69,22 @@ export default function RemotionVideoPage() {
|
|||||||
const { data: socialPages = [] } = useQuery<SocialPage[]>({ queryKey: ['/api/pages'] });
|
const { data: socialPages = [] } = useQuery<SocialPage[]>({ queryKey: ['/api/pages'] });
|
||||||
|
|
||||||
const facebookPages = socialPages.filter(p => p.platform === 'facebook');
|
const facebookPages = socialPages.filter(p => p.platform === 'facebook');
|
||||||
|
const tiktokAccounts = socialPages.filter(p => p.platform === 'tiktok');
|
||||||
|
|
||||||
|
const renderTargetCheckbox = (page: SocialPage) => (
|
||||||
|
<div key={page.id} className="flex items-center gap-3">
|
||||||
|
<Checkbox
|
||||||
|
id={`pre-${page.id}`}
|
||||||
|
checked={selectedPageIds.includes(page.id)}
|
||||||
|
onCheckedChange={checked =>
|
||||||
|
setSelectedPageIds(prev => checked ? [...prev, page.id] : prev.filter(id => id !== page.id))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label htmlFor={`pre-${page.id}`} className="text-sm font-medium cursor-pointer">
|
||||||
|
{page.pageName}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20);
|
const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20);
|
||||||
const totalSelected = images.length + selectedLibraryImages.length;
|
const totalSelected = images.length + selectedLibraryImages.length;
|
||||||
|
|
||||||
@@ -76,7 +94,7 @@ export default function RemotionVideoPage() {
|
|||||||
setGeneratedVariants(data.variants || []);
|
setGeneratedVariants(data.variants || []);
|
||||||
toast({ title: "Textes générés", description: "Cliquez pour appliquer." });
|
toast({ title: "Textes générés", description: "Cliquez pour appliquer." });
|
||||||
},
|
},
|
||||||
onError: () => toast({ title: "Erreur IA", variant: "destructive" }),
|
onError: (error: unknown) => toast({ title: "Erreur IA", description: getErrorMessage(error, "Impossible de générer le texte"), variant: "destructive" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -376,30 +394,37 @@ export default function RemotionVideoPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 4. Page Facebook */}
|
{/* 4. Destinations */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2"><Send className="w-4 h-4" /> 4. Page Facebook</CardTitle>
|
<CardTitle className="flex items-center gap-2"><Send className="w-4 h-4" /> 4. Page Facebook</CardTitle>
|
||||||
<CardDescription>Sélectionnez la page cible (son nom apparaîtra en fin de vidéo).</CardDescription>
|
<CardDescription>Pages Facebook et comptes TikTok cibles (le nom de la première destination apparaîtra en fin de vidéo).</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
{facebookPages.length === 0 ? (
|
{facebookPages.length === 0 && tiktokAccounts.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">Aucune page Facebook connectée.</p>
|
<p className="text-sm text-muted-foreground">Aucune page Facebook ni compte TikTok connecté.</p>
|
||||||
) : (
|
) : (
|
||||||
facebookPages.map(page => (
|
<>
|
||||||
<div key={page.id} className="flex items-center gap-3">
|
{facebookPages.length > 0 && (
|
||||||
<Checkbox
|
<div className="space-y-2">
|
||||||
id={`pre-${page.id}`}
|
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
checked={selectedPageIds.includes(page.id)}
|
<SiFacebook className="w-4 h-4 text-[#1877F2]" />
|
||||||
onCheckedChange={checked =>
|
Pages Facebook
|
||||||
setSelectedPageIds(prev => checked ? [...prev, page.id] : prev.filter(id => id !== page.id))
|
</div>
|
||||||
}
|
{facebookPages.map(renderTargetCheckbox)}
|
||||||
/>
|
</div>
|
||||||
<label htmlFor={`pre-${page.id}`} className="text-sm font-medium cursor-pointer">
|
)}
|
||||||
{page.pageName}
|
|
||||||
</label>
|
{tiktokAccounts.length > 0 && (
|
||||||
</div>
|
<div className="space-y-2 pt-2">
|
||||||
))
|
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
<SiTiktok className="w-4 h-4" />
|
||||||
|
Comptes TikTok
|
||||||
|
</div>
|
||||||
|
{tiktokAccounts.map(renderTargetCheckbox)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -432,7 +457,7 @@ export default function RemotionVideoPage() {
|
|||||||
<p className="text-sm text-muted-foreground">Sélectionnez une page ci-dessus avant de générer.</p>
|
<p className="text-sm text-muted-foreground">Sélectionnez une page ci-dessus avant de générer.</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-primary font-medium">
|
<p className="text-sm text-primary font-medium">
|
||||||
Pages : {facebookPages.filter(p => selectedPageIds.includes(p.id)).map(p => p.pageName).join(', ')}
|
Destinations : {[...facebookPages, ...tiktokAccounts].filter(p => selectedPageIds.includes(p.id)).map(p => p.pageName).join(', ')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<Textarea rows={2} placeholder="Description (optionnel)" value={publishDescription}
|
<Textarea rows={2} placeholder="Description (optionnel)" value={publishDescription}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Settings as SettingsIcon, Bell, Key, Shield, Cloud, Brain, Image, Upload, X, Video, Plug, Mic } from "lucide-react";
|
import { Settings as SettingsIcon, Bell, Key, Shield, Cloud, Brain, Image, Upload, X, Video, Plug, Mic, Facebook } from "lucide-react";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import Sidebar from "@/components/sidebar";
|
import Sidebar from "@/components/sidebar";
|
||||||
import TopBar from "@/components/topbar";
|
import TopBar from "@/components/topbar";
|
||||||
@@ -12,6 +12,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
import { ModelCombobox } from "@/components/model-combobox";
|
import { ModelCombobox } from "@/components/model-combobox";
|
||||||
|
import { SiTiktok } from "react-icons/si";
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
@@ -31,6 +32,10 @@ export default function Settings() {
|
|||||||
const [ffmpegApiKey, setFfmpegApiKey] = useState("");
|
const [ffmpegApiKey, setFfmpegApiKey] = useState("");
|
||||||
const [externalApiKey, setExternalApiKey] = useState("");
|
const [externalApiKey, setExternalApiKey] = useState("");
|
||||||
const [geminiApiKey, setGeminiApiKey] = useState("");
|
const [geminiApiKey, setGeminiApiKey] = useState("");
|
||||||
|
const [facebookAppId, setFacebookAppId] = useState("");
|
||||||
|
const [facebookAppSecret, setFacebookAppSecret] = useState("");
|
||||||
|
const [tiktokClientKey, setTiktokClientKey] = useState("");
|
||||||
|
const [tiktokClientSecret, setTiktokClientSecret] = useState("");
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { data: session } = useQuery<{ id: string; username: string; role: string }>({
|
const { data: session } = useQuery<{ id: string; username: string; role: string }>({
|
||||||
@@ -50,12 +55,34 @@ export default function Settings() {
|
|||||||
|
|
||||||
const { data: openrouterModels, isLoading: modelsLoading } = useQuery({
|
const { data: openrouterModels, isLoading: modelsLoading } = useQuery({
|
||||||
queryKey: ['/api/openrouter/models'],
|
queryKey: ['/api/openrouter/models'],
|
||||||
|
// Liste servie par un appel sortant vers OpenRouter : elle ne bouge pas d'une
|
||||||
|
// heure à l'autre, inutile de la redemander à chaque retour sur l'onglet.
|
||||||
|
staleTime: 60 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: ffmpegConfig } = useQuery({
|
const { data: ffmpegConfig } = useQuery({
|
||||||
queryKey: ['/api/ffmpeg/config'],
|
queryKey: ['/api/ffmpeg/config'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: tiktokConfig } = useQuery<{
|
||||||
|
configured: boolean;
|
||||||
|
clientKey: string;
|
||||||
|
hasClientSecret: boolean;
|
||||||
|
redirectUri: string;
|
||||||
|
}>({
|
||||||
|
queryKey: ['/api/tiktok/config'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: facebookConfig } = useQuery<{
|
||||||
|
configured: boolean;
|
||||||
|
appId: string;
|
||||||
|
hasAppSecret: boolean;
|
||||||
|
redirectUri: string;
|
||||||
|
scopes: string[];
|
||||||
|
}>({
|
||||||
|
queryKey: ['/api/facebook/config'],
|
||||||
|
});
|
||||||
|
|
||||||
const { data: externalApiConfig } = useQuery({
|
const { data: externalApiConfig } = useQuery({
|
||||||
queryKey: ['/api/settings/external-api'],
|
queryKey: ['/api/settings/external-api'],
|
||||||
enabled: isAdmin,
|
enabled: isAdmin,
|
||||||
@@ -92,6 +119,20 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
}, [ffmpegConfig]);
|
}, [ffmpegConfig]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tiktokConfig) {
|
||||||
|
// Le client secret n'est jamais renvoyé par l'API : il reste vide
|
||||||
|
setTiktokClientKey(tiktokConfig.clientKey || "");
|
||||||
|
}
|
||||||
|
}, [tiktokConfig]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (facebookConfig) {
|
||||||
|
// L'App Secret n'est jamais renvoyé par l'API : il reste vide
|
||||||
|
setFacebookAppId(facebookConfig.appId || "");
|
||||||
|
}
|
||||||
|
}, [facebookConfig]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (geminiConfig) {
|
if (geminiConfig) {
|
||||||
// Don't pre-fill - user must enter key manually
|
// Don't pre-fill - user must enter key manually
|
||||||
@@ -264,6 +305,55 @@ export default function Settings() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const saveFacebookMutation = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const payload: any = { appId: facebookAppId.trim() };
|
||||||
|
// Le secret n'est envoyé que s'il est saisi : sinon on conserve l'existant
|
||||||
|
if (facebookAppSecret.trim() !== "") {
|
||||||
|
payload.appSecret = facebookAppSecret.trim();
|
||||||
|
}
|
||||||
|
return apiRequest('PUT', '/api/facebook/config', payload);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['/api/facebook/config'] });
|
||||||
|
setFacebookAppSecret("");
|
||||||
|
toast({
|
||||||
|
title: "Configuration Facebook enregistrée",
|
||||||
|
description: "Les jetons de page se renouvellent désormais automatiquement",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: async (error: any) => {
|
||||||
|
toast({
|
||||||
|
title: "Erreur",
|
||||||
|
description: error?.message || "Impossible d'enregistrer la configuration Facebook",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveTiktokMutation = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const payload: any = { clientKey: tiktokClientKey.trim() };
|
||||||
|
// Le secret n'est envoyé que s'il est saisi : sinon on conserve l'existant
|
||||||
|
if (tiktokClientSecret.trim() !== "") {
|
||||||
|
payload.clientSecret = tiktokClientSecret.trim();
|
||||||
|
}
|
||||||
|
return apiRequest('PUT', '/api/tiktok/config', payload);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['/api/tiktok/config'] });
|
||||||
|
setTiktokClientSecret("");
|
||||||
|
toast({ title: "Configuration TikTok enregistrée", description: "Vous pouvez maintenant connecter les comptes TikTok de vos magasins" });
|
||||||
|
},
|
||||||
|
onError: async (error: any) => {
|
||||||
|
toast({
|
||||||
|
title: "Erreur",
|
||||||
|
description: error?.message || "Impossible d'enregistrer la configuration TikTok",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const deleteExternalApiMutation = useMutation({
|
const deleteExternalApiMutation = useMutation({
|
||||||
mutationFn: () => apiRequest('DELETE', '/api/settings/external-api', {}),
|
mutationFn: () => apiRequest('DELETE', '/api/settings/external-api', {}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -760,6 +850,145 @@ export default function Settings() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||||
|
<CardHeader className="p-6">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Facebook className="w-5 h-5" />
|
||||||
|
Facebook
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Identifiants de votre application Facebook (developers.facebook.com). Ils
|
||||||
|
permettent de connecter vos pages en un clic et de renouveler leurs jetons
|
||||||
|
automatiquement, sans jamais recoller un token à la main.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{facebookConfig?.configured && (
|
||||||
|
<div className="rounded-lg bg-muted p-3 flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
||||||
|
<Key className="w-4 h-4" />
|
||||||
|
<span>Application Facebook configurée</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="facebookAppId">App ID</Label>
|
||||||
|
<Input
|
||||||
|
id="facebookAppId"
|
||||||
|
value={facebookAppId}
|
||||||
|
onChange={(e) => setFacebookAppId(e.target.value)}
|
||||||
|
placeholder="1234567890123456"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="facebookAppSecret">
|
||||||
|
{facebookConfig?.hasAppSecret ? "Nouvel App Secret (laisser vide pour conserver l'actuel)" : "App Secret"}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="facebookAppSecret"
|
||||||
|
type="password"
|
||||||
|
value={facebookAppSecret}
|
||||||
|
onChange={(e) => setFacebookAppSecret(e.target.value)}
|
||||||
|
placeholder="••••••••••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>URI de redirection à déclarer chez Facebook</Label>
|
||||||
|
<code className="block text-xs bg-muted px-3 py-2 rounded break-all">
|
||||||
|
{facebookConfig?.redirectUri || `${window.location.origin}/api/facebook/callback`}
|
||||||
|
</code>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
À ajouter dans « Connexion Facebook › Paramètres › URI de redirection OAuth valides ».
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{facebookConfig?.scopes?.length ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Permissions demandées</Label>
|
||||||
|
<p className="text-xs text-muted-foreground break-all">
|
||||||
|
{facebookConfig.scopes.join(', ')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => saveFacebookMutation.mutate()}
|
||||||
|
disabled={saveFacebookMutation.isPending || !facebookAppId.trim()}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{saveFacebookMutation.isPending ? "Enregistrement..." : "Enregistrer"}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||||
|
<CardHeader className="p-6">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<SiTiktok className="w-5 h-5" />
|
||||||
|
TikTok
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Identifiants de votre application TikTok (developers.tiktok.com). Ils servent à
|
||||||
|
connecter les comptes TikTok de chaque magasin depuis la page « Pages gérées ».
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{tiktokConfig?.configured && (
|
||||||
|
<div className="rounded-lg bg-muted p-3 flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
||||||
|
<Key className="w-4 h-4" />
|
||||||
|
<span>Application TikTok configurée</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="tiktokClientKey">Client key</Label>
|
||||||
|
<Input
|
||||||
|
id="tiktokClientKey"
|
||||||
|
value={tiktokClientKey}
|
||||||
|
onChange={(e) => setTiktokClientKey(e.target.value)}
|
||||||
|
placeholder="awxxxxxxxxxxxxxx"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="tiktokClientSecret">
|
||||||
|
{tiktokConfig?.hasClientSecret ? "Nouveau client secret (laisser vide pour conserver l'actuel)" : "Client secret"}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="tiktokClientSecret"
|
||||||
|
type="password"
|
||||||
|
value={tiktokClientSecret}
|
||||||
|
onChange={(e) => setTiktokClientSecret(e.target.value)}
|
||||||
|
placeholder="••••••••••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>URL de redirection à déclarer chez TikTok</Label>
|
||||||
|
<code className="block text-xs bg-muted px-3 py-2 rounded break-all">
|
||||||
|
{tiktokConfig?.redirectUri || `${window.location.origin}/api/tiktok/callback`}
|
||||||
|
</code>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Tant que l'application n'a pas passé l'audit TikTok, les vidéos publiées
|
||||||
|
restent privées (visibles du seul propriétaire du compte).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => saveTiktokMutation.mutate()}
|
||||||
|
disabled={saveTiktokMutation.isPending || !tiktokClientKey.trim()}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{saveTiktokMutation.isPending ? "Enregistrement..." : "Enregistrer"}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||||
<CardHeader className="p-6">
|
<CardHeader className="p-6">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
|||||||
+11
-1
@@ -29,6 +29,11 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
# Image construite sur place : nom figé pour ne pas dépendre du nom de la
|
||||||
|
# stack, et `pull_policy: build` pour que Docker la reconstruise au lieu
|
||||||
|
# d'aller la chercher sur Docker Hub (où elle n'existe pas).
|
||||||
|
image: socialflow-app:latest
|
||||||
|
pull_policy: build
|
||||||
container_name: socialflow-app
|
container_name: socialflow-app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
@@ -38,7 +43,10 @@ services:
|
|||||||
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
|
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
|
||||||
GEMINI_API_KEY: ${GEMINI_API_KEY}
|
GEMINI_API_KEY: ${GEMINI_API_KEY}
|
||||||
SESSION_SECRET: ${SESSION_SECRET:-your-secret-key-change-me}
|
SESSION_SECRET: ${SESSION_SECRET:-your-secret-key-change-me}
|
||||||
APP_URL: ${APP_URL:-http://localhost:5555}
|
# URL publique (ex. https://socialflow.exemple.fr). Laissée vide, elle est
|
||||||
|
# déduite des en-têtes du reverse proxy — ne jamais forcer localhost ici,
|
||||||
|
# les URI de redirection OAuth (Facebook/TikTok) en dépendent.
|
||||||
|
APP_URL: ${APP_URL:-}
|
||||||
# URL interne Docker pour que ffmpeg-api puisse télécharger les fichiers audio
|
# URL interne Docker pour que ffmpeg-api puisse télécharger les fichiers audio
|
||||||
INTERNAL_APP_URL: http://socialflow-app:5555
|
INTERNAL_APP_URL: http://socialflow-app:5555
|
||||||
# Configuration FFmpeg Service
|
# Configuration FFmpeg Service
|
||||||
@@ -63,6 +71,8 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./ffmpeg-service
|
context: ./ffmpeg-service
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
image: socialflow-ffmpeg-api:latest
|
||||||
|
pull_policy: build
|
||||||
container_name: socialflow-ffmpeg
|
container_name: socialflow-ffmpeg
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Fichiers servis à la racine du domaine
|
||||||
|
|
||||||
|
Le contenu de ce dossier est exposé publiquement, sans authentification, à la
|
||||||
|
racine du domaine (`https://<domaine>/<nom-du-fichier>`).
|
||||||
|
|
||||||
|
Il sert aux fichiers de vérification de propriété du domaine réclamés par les
|
||||||
|
plateformes : TikTok (`tiktok<clé>.txt`, requis par « URL properties » avant de
|
||||||
|
pouvoir utiliser la Content Posting API), Google Search Console, Meta, etc.
|
||||||
|
|
||||||
|
Pour en ajouter un, il suffit de déposer le fichier ici — aucune modification de
|
||||||
|
code n'est nécessaire, et le fichier est servi sans reconstruire le frontend.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
tiktok-developers-site-verification=Hjd5evuj6TppdUaAJmwmDg34f1fs0qMs
|
||||||
+21
-5
@@ -2,14 +2,30 @@ import cron from 'node-cron';
|
|||||||
import { TokenManager } from './services/token_manager';
|
import { TokenManager } from './services/token_manager';
|
||||||
import { AnalyticsService } from './services/analytics';
|
import { AnalyticsService } from './services/analytics';
|
||||||
import { purgeOldMedia } from './services/media-purge';
|
import { purgeOldMedia } from './services/media-purge';
|
||||||
|
import { tiktokService } from './services/tiktok';
|
||||||
|
|
||||||
export function startTokenCron() {
|
export function startTokenCron() {
|
||||||
// Run every day at midnight (00:00) — Token health check
|
// Toutes les 2 minutes — suivi des publications TikTok (API asynchrone)
|
||||||
cron.schedule('0 0 * * *', async () => {
|
cron.schedule('*/2 * * * *', async () => {
|
||||||
console.log('[Cron] Running daily token check...');
|
try {
|
||||||
await TokenManager.checkAndRefreshTokens();
|
await tiktokService.syncPendingPublications();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Cron] TikTok publish status sync failed:', error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
console.log('[Cron] Token refresh job scheduled (0 0 * * *).');
|
console.log('[Cron] TikTok publish status job scheduled (*/2 * * * *).');
|
||||||
|
|
||||||
|
// Deux fois par jour (00:00 et 12:00) — contrôle et renouvellement des jetons.
|
||||||
|
// Un jeton révoqué par Meta ne prévient pas : passer deux fois par jour
|
||||||
|
// réduit d'autant la fenêtre pendant laquelle une publication échouerait.
|
||||||
|
cron.schedule('0 0,12 * * *', async () => {
|
||||||
|
console.log('[Cron] Running token check...');
|
||||||
|
const results = await TokenManager.checkAndRefreshTokens();
|
||||||
|
const renewed = results.filter(r => r.renewed).length;
|
||||||
|
const failing = results.filter(r => r.status === 'expired' || r.status === 'error').length;
|
||||||
|
console.log(`[Cron] Token check done: ${results.length} contrôlés, ${renewed} renouvelés, ${failing} en échec.`);
|
||||||
|
});
|
||||||
|
console.log('[Cron] Token refresh job scheduled (0 0,12 * * *).');
|
||||||
|
|
||||||
// Run twice daily (08:00 and 20:00) — Analytics sync for all pages
|
// Run twice daily (08:00 and 20:00) — Analytics sync for all pages
|
||||||
cron.schedule('0 8,20 * * *', async () => {
|
cron.schedule('0 8,20 * * *', async () => {
|
||||||
|
|||||||
+4
-2
@@ -106,8 +106,10 @@ if (!process.env.SESSION_SECRET) {
|
|||||||
// Générer un secret aléatoire pour le dev si non défini
|
// Générer un secret aléatoire pour le dev si non défini
|
||||||
const sessionSecret = process.env.SESSION_SECRET || crypto.randomBytes(32).toString('hex');
|
const sessionSecret = process.env.SESSION_SECRET || crypto.randomBytes(32).toString('hex');
|
||||||
|
|
||||||
// Déterminer si on utilise HTTPS basé sur APP_URL
|
// HTTPS déduit d'APP_URL ; à défaut, 'auto' laisse express-session décider par
|
||||||
const isHttps = process.env.APP_URL?.startsWith('https://') || false;
|
// requête à partir de X-Forwarded-Proto (cf. `trust proxy` plus haut), ce qui
|
||||||
|
// évite un cookie non sécurisé derrière un reverse proxy HTTPS sans APP_URL.
|
||||||
|
const isHttps: boolean | 'auto' = process.env.APP_URL?.startsWith('https://') || 'auto';
|
||||||
|
|
||||||
// Configuration du store de session pour production
|
// Configuration du store de session pour production
|
||||||
const sessionStore = process.env.NODE_ENV === 'production' && process.env.DATABASE_URL
|
const sessionStore = process.env.NODE_ENV === 'production' && process.env.DATABASE_URL
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export async function migrate() {
|
|||||||
// Add 'reel' to post_type enum if not exists
|
// Add 'reel' to post_type enum if not exists
|
||||||
await client.query(`ALTER TYPE "post_type" ADD VALUE IF NOT EXISTS 'reel';`);
|
await client.query(`ALTER TYPE "post_type" ADD VALUE IF NOT EXISTS 'reel';`);
|
||||||
|
|
||||||
|
// Add 'tiktok' to platform enum if not exists
|
||||||
|
await client.query(`ALTER TYPE "platform" ADD VALUE IF NOT EXISTS 'tiktok';`);
|
||||||
|
|
||||||
// 2. Create Tables if not exist
|
// 2. Create Tables if not exist
|
||||||
|
|
||||||
// post_analytics
|
// post_analytics
|
||||||
@@ -158,6 +161,105 @@ export async function migrate() {
|
|||||||
ADD COLUMN IF NOT EXISTS "gemini_api_key" text;
|
ADD COLUMN IF NOT EXISTS "gemini_api_key" text;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// media.thumbnail_url (vignette conservée après suppression de la vidéo)
|
||||||
|
await client.query(`
|
||||||
|
ALTER TABLE "media"
|
||||||
|
ADD COLUMN IF NOT EXISTS "thumbnail_url" text;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// tiktok_config (application développeur TikTok, globale)
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "tiktok_config" (
|
||||||
|
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"client_key" text NOT NULL,
|
||||||
|
"client_secret" text NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now(),
|
||||||
|
"updated_at" timestamp DEFAULT now()
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// social_pages : champs OAuth TikTok
|
||||||
|
await client.query(`
|
||||||
|
ALTER TABLE "social_pages"
|
||||||
|
ADD COLUMN IF NOT EXISTS "refresh_token" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "refresh_token_expires_at" timestamp,
|
||||||
|
ADD COLUMN IF NOT EXISTS "scopes" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "avatar_url" text;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// facebook_config (application développeur Facebook, globale)
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "facebook_config" (
|
||||||
|
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"app_id" text NOT NULL,
|
||||||
|
"app_secret" text NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now(),
|
||||||
|
"updated_at" timestamp DEFAULT now()
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// social_pages : renouvellement automatique des tokens Facebook
|
||||||
|
await client.query(`
|
||||||
|
ALTER TABLE "social_pages"
|
||||||
|
ADD COLUMN IF NOT EXISTS "user_access_token" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "user_token_expires_at" timestamp,
|
||||||
|
ADD COLUMN IF NOT EXISTS "token_error" text;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// scheduled_posts : suivi de la publication asynchrone TikTok
|
||||||
|
await client.query(`
|
||||||
|
ALTER TABLE "scheduled_posts"
|
||||||
|
ADD COLUMN IF NOT EXISTS "publish_id" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "publish_status" text;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 4. Index de performance
|
||||||
|
//
|
||||||
|
// Postgres n'indexe pas les clés étrangères tout seul : sans ces index, le
|
||||||
|
// calendrier, l'historique et le scheduler faisaient des parcours complets
|
||||||
|
// de table à chaque appel. Ils existaient dans migration-docker.sql, qui
|
||||||
|
// n'est monté nulle part — ce fichier-ci est le seul exécuté au démarrage.
|
||||||
|
//
|
||||||
|
// Chaque index est créé isolément : une table absente sur une vieille
|
||||||
|
// installation ne doit pas empêcher la création des suivants.
|
||||||
|
const indexes: [string, string][] = [
|
||||||
|
// Filtres par propriétaire
|
||||||
|
["idx_posts_user_id", `CREATE INDEX IF NOT EXISTS "idx_posts_user_id" ON "posts" ("user_id")`],
|
||||||
|
["idx_media_user_id", `CREATE INDEX IF NOT EXISTS "idx_media_user_id" ON "media" ("user_id")`],
|
||||||
|
["idx_social_pages_user_id", `CREATE INDEX IF NOT EXISTS "idx_social_pages_user_id" ON "social_pages" ("user_id")`],
|
||||||
|
["idx_ai_generations_user_id", `CREATE INDEX IF NOT EXISTS "idx_ai_generations_user_id" ON "ai_generations" ("user_id")`],
|
||||||
|
|
||||||
|
// Jointures
|
||||||
|
["idx_scheduled_posts_post_id", `CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_post_id" ON "scheduled_posts" ("post_id")`],
|
||||||
|
["idx_post_media_post_id", `CREATE INDEX IF NOT EXISTS "idx_post_media_post_id" ON "post_media" ("post_id")`],
|
||||||
|
["idx_post_media_media_id", `CREATE INDEX IF NOT EXISTS "idx_post_media_media_id" ON "post_media" ("media_id")`],
|
||||||
|
["idx_post_analytics_post_id", `CREATE INDEX IF NOT EXISTS "idx_post_analytics_post_id" ON "post_analytics" ("post_id")`],
|
||||||
|
["idx_page_analytics_history_page_date", `CREATE INDEX IF NOT EXISTS "idx_page_analytics_history_page_date" ON "page_analytics_history" ("page_id", "date")`],
|
||||||
|
["idx_user_page_permissions_user_id", `CREATE INDEX IF NOT EXISTS "idx_user_page_permissions_user_id" ON "user_page_permissions" ("user_id")`],
|
||||||
|
["idx_user_page_permissions_page_id", `CREATE INDEX IF NOT EXISTS "idx_user_page_permissions_page_id" ON "user_page_permissions" ("page_id")`],
|
||||||
|
|
||||||
|
// Calendrier et historique : « les publications de ces pages, sur cette période »
|
||||||
|
["idx_scheduled_posts_page_scheduled_at", `CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_page_scheduled_at" ON "scheduled_posts" ("page_id", "scheduled_at")`],
|
||||||
|
|
||||||
|
// Scheduler (toutes les minutes) : seules les publications non encore
|
||||||
|
// traitées comptent, d'où un index partiel qui reste minuscule.
|
||||||
|
["idx_scheduled_posts_pending", `CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_pending" ON "scheduled_posts" ("scheduled_at") WHERE "published_at" IS NULL`],
|
||||||
|
|
||||||
|
// Suivi asynchrone TikTok (toutes les deux minutes)
|
||||||
|
["idx_scheduled_posts_publish_id", `CREATE INDEX IF NOT EXISTS "idx_scheduled_posts_publish_id" ON "scheduled_posts" ("publish_id") WHERE "publish_id" IS NOT NULL`],
|
||||||
|
|
||||||
|
// Reels en cours de génération
|
||||||
|
["idx_posts_generation_status", `CREATE INDEX IF NOT EXISTS "idx_posts_generation_status" ON "posts" ("generation_status") WHERE "generation_status" IS NOT NULL`],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [name, statement] of indexes) {
|
||||||
|
try {
|
||||||
|
await client.query(statement);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[Migration] Index ${name} non créé:`, error instanceof Error ? error.message : error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log("[Migration] Safe migration completed.");
|
console.log("[Migration] Safe migration completed.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Migration] Error during migration:", error);
|
console.error("[Migration] Error during migration:", error);
|
||||||
|
|||||||
+98
-43
@@ -1,14 +1,15 @@
|
|||||||
import type { Express, Request, Response, NextFunction } from "express";
|
import express, { type Express, type Request, type Response, type NextFunction } from "express";
|
||||||
import { createServer, type Server } from "http";
|
import { createServer, type Server } from "http";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import os from "os";
|
import os from "os";
|
||||||
|
import path from "path";
|
||||||
import { storage } from "./storage";
|
import { storage } from "./storage";
|
||||||
import { db, pool } from "./db";
|
import { db, pool } from "./db";
|
||||||
import multer from "multer";
|
import multer from "multer";
|
||||||
import bcrypt from "bcrypt";
|
import bcrypt from "bcrypt";
|
||||||
import passport from "./auth";
|
import passport from "./auth";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { openRouterService } from "./services/openrouter";
|
import { openRouterService, describeGenerationError } from "./services/openrouter";
|
||||||
import { minioService as cloudinaryService, buildMinioUrl } from "./services/minio";
|
import { minioService as cloudinaryService, buildMinioUrl } from "./services/minio";
|
||||||
import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema, insertCloudinaryConfigSchema, updateCloudinaryConfigSchema, insertOpenrouterConfigSchema, updateOpenrouterConfigSchema, insertUserSchema, postMedia, type SocialPage } from "@shared/schema";
|
import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema, insertCloudinaryConfigSchema, updateCloudinaryConfigSchema, insertOpenrouterConfigSchema, updateOpenrouterConfigSchema, insertUserSchema, postMedia, type SocialPage } from "@shared/schema";
|
||||||
import type { User, InsertUser, ScheduledPost } from "@shared/schema";
|
import type { User, InsertUser, ScheduledPost } from "@shared/schema";
|
||||||
@@ -16,6 +17,9 @@ import { analyticsRouter } from "./routes/analytics";
|
|||||||
import { reelsRouter } from "./routes/reels";
|
import { reelsRouter } from "./routes/reels";
|
||||||
import { remotionRouter } from "./routes/remotion";
|
import { remotionRouter } from "./routes/remotion";
|
||||||
import { externalRouter } from "./routes/external";
|
import { externalRouter } from "./routes/external";
|
||||||
|
import { tiktokRouter } from "./routes/tiktok";
|
||||||
|
import { facebookRouter } from "./routes/facebook";
|
||||||
|
import { legalRouter } from "./routes/legal";
|
||||||
import { insertAudioTrackSchema } from "@shared/schema";
|
import { insertAudioTrackSchema } from "@shared/schema";
|
||||||
import * as musicMetadata from "music-metadata";
|
import * as musicMetadata from "music-metadata";
|
||||||
|
|
||||||
@@ -107,6 +111,20 @@ function requireAdmin(req: Request, res: Response, next: NextFunction) {
|
|||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Convertit un paramètre de requête en entier positif, ou `undefined`. */
|
||||||
|
function parsePositiveInt(value: unknown): number | undefined {
|
||||||
|
if (typeof value !== "string" || value === "") return undefined;
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convertit un paramètre de requête en date, ou `undefined` s'il est inutilisable. */
|
||||||
|
function parseDateParam(value: unknown): Date | undefined {
|
||||||
|
if (typeof value !== "string" || value === "") return undefined;
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? undefined : date;
|
||||||
|
}
|
||||||
|
|
||||||
export async function registerRoutes(app: Express): Promise<Server> {
|
export async function registerRoutes(app: Express): Promise<Server> {
|
||||||
|
|
||||||
|
|
||||||
@@ -458,6 +476,22 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
// Analytics Routes
|
// Analytics Routes
|
||||||
app.use("/api/analytics", requireAuth, analyticsRouter);
|
app.use("/api/analytics", requireAuth, analyticsRouter);
|
||||||
|
|
||||||
|
// Fichiers servis à la racine du domaine, sans authentification : vérification
|
||||||
|
// de propriété du domaine par TikTok ("URL properties"), Google, Meta…
|
||||||
|
// Monté ici pour être servi avant le catch-all du frontend, et sans dépendre
|
||||||
|
// d'une reconstruction du client.
|
||||||
|
app.use(express.static(path.resolve(import.meta.dirname, "..", "public-root"), {
|
||||||
|
dotfiles: "ignore",
|
||||||
|
index: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Pages légales publiques (exigées par TikTok pour l'audit de l'application)
|
||||||
|
app.use(legalRouter);
|
||||||
|
|
||||||
|
// TikTok Routes (OAuth multi-comptes) — avant le routeur Reels monté sur /api
|
||||||
|
app.use("/api/tiktok", requireAuth, tiktokRouter);
|
||||||
|
app.use("/api/facebook", requireAuth, facebookRouter);
|
||||||
|
|
||||||
// Reels & Music Routes
|
// Reels & Music Routes
|
||||||
app.use("/api", requireAuth, reelsRouter);
|
app.use("/api", requireAuth, reelsRouter);
|
||||||
app.use("/api/remotion", requireAuth, remotionRouter);
|
app.use("/api/remotion", requireAuth, remotionRouter);
|
||||||
@@ -524,39 +558,28 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
const user = req.user as User;
|
const user = req.user as User;
|
||||||
const userId = user.id;
|
const userId = user.id;
|
||||||
|
|
||||||
const posts = await storage.getPosts(userId);
|
|
||||||
const pages = await storage.getSocialPages(userId);
|
|
||||||
const media = await storage.getMedia(userId);
|
|
||||||
const aiGenerations = await storage.getAiGenerations(userId);
|
|
||||||
|
|
||||||
// Statistiques actuelles
|
|
||||||
const scheduledPosts = posts.filter(p => p.status === "scheduled").length;
|
|
||||||
const currentAiTexts = aiGenerations.length;
|
|
||||||
const currentMedia = media.length;
|
|
||||||
|
|
||||||
// Calculer les statistiques de la période précédente (hier pour les textes IA, mois dernier pour les posts)
|
// Calculer les statistiques de la période précédente (hier pour les textes IA, mois dernier pour les posts)
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const yesterday = new Date(now);
|
const yesterday = new Date(now);
|
||||||
yesterday.setDate(yesterday.getDate() - 1);
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
yesterday.setHours(0, 0, 0, 0);
|
yesterday.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const today = new Date(yesterday);
|
||||||
|
today.setDate(today.getDate() + 1);
|
||||||
|
|
||||||
const lastMonth = new Date(now);
|
const lastMonth = new Date(now);
|
||||||
lastMonth.setMonth(lastMonth.getMonth() - 1);
|
lastMonth.setMonth(lastMonth.getMonth() - 1);
|
||||||
|
|
||||||
// Textes IA générés hier
|
const stats = await storage.getDashboardStats(userId, {
|
||||||
const aiTextsYesterday = aiGenerations.filter(gen => {
|
yesterdayStart: yesterday,
|
||||||
if (!gen.createdAt) return false;
|
yesterdayEnd: today,
|
||||||
const genDate = new Date(gen.createdAt);
|
lastMonth,
|
||||||
genDate.setHours(0, 0, 0, 0);
|
});
|
||||||
return genDate.getTime() === yesterday.getTime();
|
|
||||||
}).length;
|
|
||||||
|
|
||||||
// Posts planifiés le mois dernier
|
const scheduledPosts = stats.scheduledPosts;
|
||||||
const scheduledPostsLastMonth = posts.filter(p => {
|
const scheduledPostsLastMonth = stats.scheduledPostsLastMonth;
|
||||||
if (!p.createdAt) return false;
|
const currentAiTexts = stats.aiTextsGenerated;
|
||||||
const createdDate = new Date(p.createdAt);
|
const aiTextsYesterday = stats.aiTextsYesterday;
|
||||||
return p.status === "scheduled" && createdDate < lastMonth;
|
|
||||||
}).length;
|
|
||||||
|
|
||||||
// Calculer les variations en pourcentage
|
// Calculer les variations en pourcentage
|
||||||
const aiTextChange = aiTextsYesterday > 0
|
const aiTextChange = aiTextsYesterday > 0
|
||||||
@@ -571,11 +594,11 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
scheduledPosts,
|
scheduledPosts,
|
||||||
scheduledPostsChange: scheduledPostsChange > 0 ? `+${scheduledPostsChange}%` : `${scheduledPostsChange}%`,
|
scheduledPostsChange: scheduledPostsChange > 0 ? `+${scheduledPostsChange}%` : `${scheduledPostsChange}%`,
|
||||||
scheduledPostsTrending: scheduledPostsChange >= 0 ? "up" : "down",
|
scheduledPostsTrending: scheduledPostsChange >= 0 ? "up" : "down",
|
||||||
connectedPages: pages.length,
|
connectedPages: stats.connectedPages,
|
||||||
aiTextsGenerated: currentAiTexts,
|
aiTextsGenerated: currentAiTexts,
|
||||||
aiTextsChange: aiTextChange > 0 ? `+${aiTextChange}%` : `${aiTextChange}%`,
|
aiTextsChange: aiTextChange > 0 ? `+${aiTextChange}%` : `${aiTextChange}%`,
|
||||||
aiTextsTrending: aiTextChange >= 0 ? "up" : "down",
|
aiTextsTrending: aiTextChange >= 0 ? "up" : "down",
|
||||||
mediaStored: media.length,
|
mediaStored: stats.mediaStored,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching stats:", error);
|
console.error("Error fetching stats:", error);
|
||||||
@@ -613,7 +636,8 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
res.json({ variants: generatedTexts });
|
res.json({ variants: generatedTexts });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error generating text:", error);
|
console.error("Error generating text:", error);
|
||||||
res.status(500).json({ error: "Failed to generate text" });
|
const { status, message } = describeGenerationError(error);
|
||||||
|
res.status(status).json({ error: message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -702,7 +726,19 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
try {
|
try {
|
||||||
const user = req.user as User;
|
const user = req.user as User;
|
||||||
const userId = user.id;
|
const userId = user.id;
|
||||||
const media = await storage.getMedia(userId);
|
|
||||||
|
// Sans paramètre, la médiathèque entière est renvoyée comme avant : les
|
||||||
|
// écrans qui doivent retrouver un média par son identifiant en dépendent.
|
||||||
|
const limit = parsePositiveInt(req.query.limit);
|
||||||
|
const offset = parsePositiveInt(req.query.offset);
|
||||||
|
const media = await storage.getMedia(userId, { limit, offset });
|
||||||
|
|
||||||
|
// Le total permet à l'appelant paginé de savoir s'il reste des pages —
|
||||||
|
// et à la médiathèque d'afficher un compteur juste sans tout charger.
|
||||||
|
if (limit !== undefined) {
|
||||||
|
res.setHeader("X-Total-Count", String(await storage.getMediaCount(userId)));
|
||||||
|
}
|
||||||
|
|
||||||
res.json(media);
|
res.json(media);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching media:", error);
|
console.error("Error fetching media:", error);
|
||||||
@@ -1394,16 +1430,15 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
const userId = user.id;
|
const userId = user.id;
|
||||||
const { startDate, endDate } = req.query;
|
const { startDate, endDate } = req.query;
|
||||||
|
|
||||||
const start = startDate ? new Date(startDate as string) : undefined;
|
// Une date illisible est ignorée plutôt que transmise telle quelle à SQL.
|
||||||
const end = endDate ? new Date(endDate as string) : undefined;
|
const start = parseDateParam(startDate);
|
||||||
|
const end = parseDateParam(endDate);
|
||||||
|
|
||||||
let scheduledPosts;
|
let scheduledPosts;
|
||||||
|
|
||||||
if (user.role === 'admin') {
|
if (user.role === 'admin') {
|
||||||
// Admin voit tous les posts programmés - on récupère toutes les pages
|
// Admin voit tous les posts programmés - on récupère toutes les pages
|
||||||
const allPages = await storage.getAllUsers().then(users =>
|
const allPages = await storage.getAllSocialPages();
|
||||||
Promise.all(users.map(u => storage.getSocialPages(u.id)))
|
|
||||||
).then(pagesArrays => pagesArrays.flat());
|
|
||||||
const allPageIds = allPages.map(p => p.id);
|
const allPageIds = allPages.map(p => p.id);
|
||||||
|
|
||||||
if (allPageIds.length > 0) {
|
if (allPageIds.length > 0) {
|
||||||
@@ -1534,6 +1569,19 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Social pages
|
// Social pages
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vue client d'une page : sans jetons, avec un indicateur de renouvellement
|
||||||
|
* automatique (vrai quand un jeton utilisateur longue durée est enregistré).
|
||||||
|
*/
|
||||||
|
function toClientPage(page: SocialPage) {
|
||||||
|
const { accessToken, userAccessToken, refreshToken, ...safe } = page;
|
||||||
|
return {
|
||||||
|
...safe,
|
||||||
|
autoRenew: page.platform === 'tiktok' ? !!refreshToken : !!userAccessToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
app.get("/api/pages", requireAuth, async (req, res) => {
|
app.get("/api/pages", requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const user = req.user as User;
|
const user = req.user as User;
|
||||||
@@ -1543,17 +1591,15 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
|
|
||||||
if (user.role === 'admin') {
|
if (user.role === 'admin') {
|
||||||
// Les admins voient toutes les pages de tous les utilisateurs
|
// Les admins voient toutes les pages de tous les utilisateurs
|
||||||
const allUsers = await storage.getAllUsers();
|
pages = await storage.getAllSocialPages();
|
||||||
const allPages = await Promise.all(
|
|
||||||
allUsers.map(u => storage.getSocialPages(u.id))
|
|
||||||
);
|
|
||||||
pages = allPages.flat();
|
|
||||||
} else {
|
} else {
|
||||||
// Les utilisateurs normaux voient uniquement les pages auxquelles ils ont accès
|
// Les utilisateurs normaux voient uniquement les pages auxquelles ils ont accès
|
||||||
pages = await storage.getUserAccessiblePages(userId);
|
pages = await storage.getUserAccessiblePages(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(pages);
|
// Les jetons n'ont rien à faire dans le navigateur : on ne renvoie que
|
||||||
|
// l'information dont l'UI a besoin, dont la capacité à se renouveler seule.
|
||||||
|
res.json(pages.map(toClientPage));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching pages:", error);
|
console.error("Error fetching pages:", error);
|
||||||
res.status(500).json({ error: "Failed to fetch pages" });
|
res.status(500).json({ error: "Failed to fetch pages" });
|
||||||
@@ -1565,6 +1611,14 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
const user = req.user as User;
|
const user = req.user as User;
|
||||||
const userId = user.id;
|
const userId = user.id;
|
||||||
|
|
||||||
|
// TikTok n'autorise pas la saisie manuelle d'un token : le compte doit
|
||||||
|
// passer par le flux OAuth (/api/tiktok/connect).
|
||||||
|
if (req.body?.platform === 'tiktok') {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: "Un compte TikTok se connecte via l'autorisation TikTok, pas par saisie manuelle d'un jeton.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate token expiration date (60 days from now)
|
// Calculate token expiration date (60 days from now)
|
||||||
const tokenExpiresAt = new Date();
|
const tokenExpiresAt = new Date();
|
||||||
tokenExpiresAt.setDate(tokenExpiresAt.getDate() + 60);
|
tokenExpiresAt.setDate(tokenExpiresAt.getDate() + 60);
|
||||||
@@ -1575,7 +1629,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
tokenExpiresAt
|
tokenExpiresAt
|
||||||
});
|
});
|
||||||
const page = await storage.createSocialPage(pageData);
|
const page = await storage.createSocialPage(pageData);
|
||||||
res.json(page);
|
res.json(toClientPage(page));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating page:", error);
|
console.error("Error creating page:", error);
|
||||||
res.status(500).json({ error: "Failed to create page" });
|
res.status(500).json({ error: "Failed to create page" });
|
||||||
@@ -1598,7 +1652,8 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
|
|
||||||
// If accessToken is being updated, recalculate expiration date (60 days from now)
|
// If accessToken is being updated, recalculate expiration date (60 days from now)
|
||||||
// AND reset the status to valid so the UI updates immediately
|
// AND reset the status to valid so the UI updates immediately
|
||||||
if (parsedData.accessToken) {
|
// (les tokens TikTok ont leur propre cycle de vie, géré par le service OAuth)
|
||||||
|
if (parsedData.accessToken && existingPage.platform !== 'tiktok') {
|
||||||
const tokenExpiresAt = new Date();
|
const tokenExpiresAt = new Date();
|
||||||
tokenExpiresAt.setDate(tokenExpiresAt.getDate() + 60);
|
tokenExpiresAt.setDate(tokenExpiresAt.getDate() + 60);
|
||||||
pageData = {
|
pageData = {
|
||||||
@@ -1610,7 +1665,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updatedPage = await storage.updateSocialPage(pageId, pageData);
|
const updatedPage = await storage.updateSocialPage(pageId, pageData);
|
||||||
res.json(updatedPage);
|
res.json(toClientPage(updatedPage));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating page:", error);
|
console.error("Error updating page:", error);
|
||||||
res.status(500).json({ error: "Failed to update page" });
|
res.status(500).json({ error: "Failed to update page" });
|
||||||
|
|||||||
+17
-18
@@ -137,7 +137,7 @@ router.post("/publish", async (req, res) => {
|
|||||||
instagramFeedUrl: uploaded.instagramFeedUrl,
|
instagramFeedUrl: uploaded.instagramFeedUrl,
|
||||||
instagramStoryUrl: uploaded.instagramStoryUrl,
|
instagramStoryUrl: uploaded.instagramStoryUrl,
|
||||||
fileName,
|
fileName,
|
||||||
fileSize: String(buffer.length),
|
fileSize: buffer.length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ router.post("/publish", async (req, res) => {
|
|||||||
content,
|
content,
|
||||||
status: scheduledFor ? "scheduled" : "scheduled",
|
status: scheduledFor ? "scheduled" : "scheduled",
|
||||||
scheduledFor: scheduledFor ?? new Date(),
|
scheduledFor: scheduledFor ?? new Date(),
|
||||||
aiGenerated: false,
|
aiGenerated: "false",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Lier le média si présent
|
// Lier le média si présent
|
||||||
@@ -270,9 +270,7 @@ router.get("/posts", async (req, res) => {
|
|||||||
}
|
}
|
||||||
pageIds = [pageId];
|
pageIds = [pageId];
|
||||||
} else {
|
} else {
|
||||||
const users = await storage.getAllUsers();
|
pageIds = (await storage.getAllSocialPages()).map(p => p.id);
|
||||||
const allPages = await Promise.all(users.map(u => storage.getSocialPages(u.id)));
|
|
||||||
pageIds = allPages.flat().map(p => p.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageIds.length === 0) {
|
if (pageIds.length === 0) {
|
||||||
@@ -312,19 +310,20 @@ router.get("/posts", async (req, res) => {
|
|||||||
if (post.status !== postStatus && postStatus !== "all") continue;
|
if (post.status !== postStatus && postStatus !== "all") continue;
|
||||||
|
|
||||||
if (!postMap.has(post.id)) {
|
if (!postMap.has(post.id)) {
|
||||||
// Récupérer les médias du post
|
// Récupérer les médias du post, en une requête plutôt qu'une par média
|
||||||
const postMediaLinks = await storage.getPostMedia(post.id);
|
const postMediaLinks = await storage.getPostMedia(post.id);
|
||||||
const mediaItems: Array<{ id: string; url: string; type: string }> = [];
|
const byId = new Map(
|
||||||
for (const link of postMediaLinks) {
|
(await storage.getMediaByIds(postMediaLinks.map(link => link.mediaId)))
|
||||||
const mediaItem = await storage.getMediaById(link.mediaId);
|
.map(item => [item.id, item])
|
||||||
if (mediaItem) {
|
);
|
||||||
mediaItems.push({
|
const mediaItems = postMediaLinks
|
||||||
id: mediaItem.id,
|
.map(link => byId.get(link.mediaId))
|
||||||
url: mediaItem.originalUrl,
|
.filter((item): item is NonNullable<typeof item> => item !== undefined)
|
||||||
type: mediaItem.type,
|
.map(item => ({
|
||||||
});
|
id: item.id,
|
||||||
}
|
url: item.originalUrl,
|
||||||
}
|
type: item.type,
|
||||||
|
}));
|
||||||
|
|
||||||
postMap.set(post.id, {
|
postMap.set(post.id, {
|
||||||
id: post.id,
|
id: post.id,
|
||||||
@@ -415,7 +414,7 @@ router.patch("/posts/:id", async (req, res) => {
|
|||||||
instagramFeedUrl: uploaded.instagramFeedUrl,
|
instagramFeedUrl: uploaded.instagramFeedUrl,
|
||||||
instagramStoryUrl: uploaded.instagramStoryUrl,
|
instagramStoryUrl: uploaded.instagramStoryUrl,
|
||||||
fileName,
|
fileName,
|
||||||
fileSize: String(buffer.length),
|
fileSize: buffer.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remplacer les médias existants par le nouveau
|
// Remplacer les médias existants par le nouveau
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
/**
|
||||||
|
* Connexion et renouvellement des pages Facebook / Instagram.
|
||||||
|
*
|
||||||
|
* Une page connectée ici embarque le jeton utilisateur longue durée qui a servi
|
||||||
|
* à l'obtenir : c'est ce qui permet au `TokenManager` de régénérer son jeton de
|
||||||
|
* page tout seul, au lieu d'exiger un copier-coller depuis le Graph Explorer
|
||||||
|
* tous les deux mois.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import type { User } from '@shared/schema';
|
||||||
|
import { storage } from '../storage';
|
||||||
|
import { facebookOAuthService } from '../services/facebook_oauth';
|
||||||
|
import { TokenManager } from '../services/token_manager';
|
||||||
|
import { encrypt } from '../utils/encryption';
|
||||||
|
import { insertFacebookConfigSchema, updateFacebookConfigSchema } from '@shared/schema';
|
||||||
|
|
||||||
|
export const facebookRouter = Router();
|
||||||
|
|
||||||
|
/** Page vers laquelle on renvoie le navigateur à la fin du flux OAuth. */
|
||||||
|
const RETURN_PATH = '/pages';
|
||||||
|
|
||||||
|
function isAdmin(req: Request): boolean {
|
||||||
|
const user = req.user as User | undefined;
|
||||||
|
return user?.role === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReturnUrl(status: 'success' | 'error', message: string): string {
|
||||||
|
const params = new URLSearchParams({ facebook: status, message });
|
||||||
|
return `${RETURN_PATH}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* État de la configuration Facebook.
|
||||||
|
* L'App Secret n'est jamais renvoyé au client.
|
||||||
|
*/
|
||||||
|
facebookRouter.get('/config', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const config = await storage.getFacebookConfig();
|
||||||
|
const configured = await facebookOAuthService.isConfigured();
|
||||||
|
|
||||||
|
// Tout le monde a besoin de savoir si la connexion est possible ; seuls les
|
||||||
|
// admins voient les détails de l'application développeur.
|
||||||
|
if (!isAdmin(req)) {
|
||||||
|
return res.json({ configured });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
configured,
|
||||||
|
appId: config?.appId || (process.env.FACEBOOK_APP_ID ? "(défini par variable d'environnement)" : ''),
|
||||||
|
hasAppSecret: !!(config?.appSecret || process.env.FACEBOOK_APP_SECRET),
|
||||||
|
redirectUri: facebookOAuthService.getRedirectUri(req),
|
||||||
|
scopes: facebookOAuthService.getScopes().split(','),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [Facebook] Erreur lecture configuration:', error);
|
||||||
|
res.status(500).json({ error: 'Impossible de lire la configuration Facebook' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Enregistre les identifiants de l'application Facebook (admin). */
|
||||||
|
facebookRouter.put('/config', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!isAdmin(req)) {
|
||||||
|
return res.status(403).json({ error: 'Accès refusé. Réservé aux administrateurs.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await storage.getFacebookConfig();
|
||||||
|
// Le secret peut être omis lors d'une mise à jour : on garde l'existant
|
||||||
|
const schema = existing ? updateFacebookConfigSchema : insertFacebookConfigSchema;
|
||||||
|
const data = schema.parse(req.body);
|
||||||
|
|
||||||
|
const config = await storage.upsertFacebookConfig(data);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
appId: config.appId,
|
||||||
|
redirectUri: facebookOAuthService.getRedirectUri(req),
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ [Facebook] Erreur enregistrement configuration:', error);
|
||||||
|
res.status(400).json({
|
||||||
|
error: error?.errors?.[0]?.message || error?.message || 'Configuration Facebook invalide',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Démarre la connexion des pages Facebook.
|
||||||
|
* À appeler par navigation complète du navigateur (window.location), pas en fetch.
|
||||||
|
*/
|
||||||
|
facebookRouter.get('/connect', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!(await facebookOAuthService.isConfigured())) {
|
||||||
|
return res.redirect(
|
||||||
|
buildReturnUrl('error', "Facebook n'est pas configuré. Renseignez d'abord l'App ID et l'App Secret.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protection CSRF : l'état est vérifié au retour
|
||||||
|
const state = crypto.randomBytes(16).toString('hex');
|
||||||
|
(req.session as any).facebookOAuthState = state;
|
||||||
|
|
||||||
|
const authUrl = await facebookOAuthService.buildAuthorizationUrl(state, req);
|
||||||
|
|
||||||
|
// La session doit être écrite avant la redirection, sinon l'état est perdu
|
||||||
|
req.session.save((err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('❌ [Facebook] Impossible de sauvegarder la session OAuth:', err);
|
||||||
|
return res.redirect(buildReturnUrl('error', 'Erreur de session, réessayez.'));
|
||||||
|
}
|
||||||
|
res.redirect(authUrl);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [Facebook] Erreur démarrage OAuth:', error);
|
||||||
|
res.redirect(
|
||||||
|
buildReturnUrl('error', error instanceof Error ? error.message : 'Erreur de connexion Facebook')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retour d'autorisation Facebook : échange le code contre un jeton utilisateur
|
||||||
|
* longue durée, puis crée ou met à jour chaque page administrée.
|
||||||
|
*/
|
||||||
|
facebookRouter.get('/callback', async (req: Request, res: Response) => {
|
||||||
|
const user = req.user as User;
|
||||||
|
const { code, state, error: oauthError, error_description: oauthErrorDescription } = req.query;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (oauthError) {
|
||||||
|
const description = (oauthErrorDescription as string) || (oauthError as string);
|
||||||
|
return res.redirect(buildReturnUrl('error', `Autorisation Facebook refusée : ${description}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedState = (req.session as any)?.facebookOAuthState;
|
||||||
|
delete (req.session as any)?.facebookOAuthState;
|
||||||
|
|
||||||
|
if (!state || !expectedState || state !== expectedState) {
|
||||||
|
return res.redirect(buildReturnUrl('error', 'Requête de connexion Facebook invalide (état non reconnu).'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code || typeof code !== 'string') {
|
||||||
|
return res.redirect(buildReturnUrl('error', "Aucun code d'autorisation reçu de Facebook."));
|
||||||
|
}
|
||||||
|
|
||||||
|
const userToken = await facebookOAuthService.exchangeCodeForUserToken(code, req);
|
||||||
|
const targets = await facebookOAuthService.listPages(userToken.accessToken);
|
||||||
|
|
||||||
|
if (targets.length === 0) {
|
||||||
|
return res.redirect(
|
||||||
|
buildReturnUrl(
|
||||||
|
'error',
|
||||||
|
"Aucune page Facebook administrée par ce compte. Connectez-vous avec un compte administrateur de la page."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le jeton utilisateur est la clé du renouvellement automatique : il est
|
||||||
|
// conservé chiffré, sur chaque page issue de cette autorisation.
|
||||||
|
const encryptedUserToken = encrypt(userToken.accessToken);
|
||||||
|
const existingPages = await storage.getSocialPages(user.id);
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
let updated = 0;
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
const existing = existingPages.find(
|
||||||
|
(page) => page.platform === 'facebook' && page.pageId === target.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await storage.updateSocialPage(existing.id, {
|
||||||
|
pageName: target.name,
|
||||||
|
accessToken: target.accessToken,
|
||||||
|
// Un jeton de page dérivé d'un jeton utilisateur longue durée n'expire pas.
|
||||||
|
tokenExpiresAt: null,
|
||||||
|
userAccessToken: encryptedUserToken,
|
||||||
|
userTokenExpiresAt: userToken.expiresAt,
|
||||||
|
followersCount: target.followersCount,
|
||||||
|
tokenStatus: 'valid',
|
||||||
|
tokenError: null,
|
||||||
|
lastTokenCheck: new Date(),
|
||||||
|
isActive: 'true',
|
||||||
|
});
|
||||||
|
updated++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = await storage.createSocialPage({
|
||||||
|
userId: user.id,
|
||||||
|
platform: 'facebook',
|
||||||
|
pageId: target.id,
|
||||||
|
pageName: target.name,
|
||||||
|
accessToken: target.accessToken,
|
||||||
|
tokenExpiresAt: null,
|
||||||
|
userAccessToken: encryptedUserToken,
|
||||||
|
userTokenExpiresAt: userToken.expiresAt,
|
||||||
|
followersCount: target.followersCount,
|
||||||
|
isActive: 'true',
|
||||||
|
});
|
||||||
|
created++;
|
||||||
|
|
||||||
|
// Les non-admins ne voient que les pages qui leur sont explicitement
|
||||||
|
// attribuées : sans cette permission, ils ne verraient pas leur propre page.
|
||||||
|
if (user.role !== 'admin') {
|
||||||
|
try {
|
||||||
|
await storage.createPagePermission({ userId: user.id, pageId: page.id });
|
||||||
|
} catch (permissionError) {
|
||||||
|
console.error('⚠️ [Facebook] Impossible de créer la permission de page:', permissionError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un compte Instagram professionnel publie avec le jeton de la page Facebook
|
||||||
|
// à laquelle il est rattaché : on le rafraîchit dans la foulée, sans quoi il
|
||||||
|
// resterait sur un jeton saisi à la main, condamné à expirer.
|
||||||
|
for (const page of existingPages) {
|
||||||
|
if (page.platform !== 'instagram') continue;
|
||||||
|
const linked = targets.find((target) => target.instagram?.id === page.pageId);
|
||||||
|
if (!linked) continue;
|
||||||
|
|
||||||
|
await storage.updateSocialPage(page.id, {
|
||||||
|
accessToken: linked.accessToken,
|
||||||
|
tokenExpiresAt: null,
|
||||||
|
userAccessToken: encryptedUserToken,
|
||||||
|
userTokenExpiresAt: userToken.expiresAt,
|
||||||
|
tokenStatus: 'valid',
|
||||||
|
tokenError: null,
|
||||||
|
lastTokenCheck: new Date(),
|
||||||
|
});
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = [
|
||||||
|
created > 0 ? `${created} page${created > 1 ? 's' : ''} connectée${created > 1 ? 's' : ''}` : null,
|
||||||
|
updated > 0 ? `${updated} page${updated > 1 ? 's' : ''} mise${updated > 1 ? 's' : ''} à jour` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
console.log(`✅ [Facebook] ${summary} pour ${user.username}`);
|
||||||
|
res.redirect(
|
||||||
|
buildReturnUrl('success', `${summary}. Les jetons se renouvellent désormais automatiquement.`)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [Facebook] Erreur callback OAuth:', error);
|
||||||
|
res.redirect(
|
||||||
|
buildReturnUrl('error', error instanceof Error ? error.message : 'Connexion Facebook impossible')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Contrôle (et renouvelle si besoin) le jeton d'une page à la demande. */
|
||||||
|
facebookRouter.post('/pages/:id/refresh', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const user = req.user as User;
|
||||||
|
const page = await storage.getSocialPage(req.params.id);
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
return res.status(404).json({ error: 'Page non trouvée' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role !== 'admin' && page.userId !== user.id) {
|
||||||
|
const accessible = await storage.getUserAccessiblePages(user.id);
|
||||||
|
if (!accessible.some((p) => p.id === page.id)) {
|
||||||
|
return res.status(403).json({ error: 'Accès refusé à cette page' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await TokenManager.checkPage(page.id);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [Facebook] Erreur renouvellement du jeton:', error);
|
||||||
|
res.status(502).json({
|
||||||
|
error: error instanceof Error ? error.message : 'Impossible de contrôler le jeton',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Relance le contrôle de toutes les pages (admin). */
|
||||||
|
facebookRouter.post('/tokens/check', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!isAdmin(req)) {
|
||||||
|
return res.status(403).json({ error: 'Accès refusé. Réservé aux administrateurs.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await TokenManager.checkAndRefreshTokens();
|
||||||
|
res.json({
|
||||||
|
checked: results.length,
|
||||||
|
renewed: results.filter((r) => r.renewed).length,
|
||||||
|
failing: results.filter((r) => r.status === 'expired' || r.status === 'error').length,
|
||||||
|
results,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [Facebook] Erreur contrôle global des jetons:', error);
|
||||||
|
res.status(500).json({ error: 'Contrôle des jetons impossible' });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPage(title: string, body: string): string {
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>${escapeHtml(title)}</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light dark; }
|
||||||
|
body {
|
||||||
|
max-width: 46rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2.5rem 1.25rem 4rem;
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: #1f2328;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.75rem; margin-bottom: 0.25rem; }
|
||||||
|
h2 { font-size: 1.15rem; margin-top: 2.25rem; }
|
||||||
|
.updated { color: #6b7280; font-size: 0.875rem; margin-top: 0; }
|
||||||
|
ul { padding-left: 1.25rem; }
|
||||||
|
li { margin-bottom: 0.35rem; }
|
||||||
|
code { background: rgba(127, 127, 127, 0.15); padding: 0.1rem 0.3rem; border-radius: 0.25rem; font-size: 0.9em; }
|
||||||
|
footer { margin-top: 3rem; padding-top: 1.25rem; border-top: 1px solid rgba(127,127,127,0.3); color: #6b7280; font-size: 0.875rem; }
|
||||||
|
a { color: #2563eb; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body { color: #e6e6e6; background: #0f1115; }
|
||||||
|
.updated, footer { color: #9ca3af; }
|
||||||
|
a { color: #7aa7ff; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${body}
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFooter(): string {
|
||||||
|
const company = getCompanyInfo();
|
||||||
|
return `<footer>
|
||||||
|
<p>
|
||||||
|
${escapeHtml(company.name)} – enseigne ${escapeHtml(company.tradeName)}<br>
|
||||||
|
${escapeHtml(company.legalForm)}<br>
|
||||||
|
${escapeHtml(company.address)}<br>
|
||||||
|
${escapeHtml(company.rcs)}<br>
|
||||||
|
Contact : ${escapeHtml(company.email)}
|
||||||
|
</p>
|
||||||
|
<p><a href="/terms">Conditions d'utilisation</a> · <a href="/privacy">Politique de confidentialité</a></p>
|
||||||
|
</footer>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
legalRouter.get('/terms', (_req: Request, res: Response) => {
|
||||||
|
const company = getCompanyInfo();
|
||||||
|
|
||||||
|
const body = `
|
||||||
|
<h1>Conditions d'utilisation</h1>
|
||||||
|
<p class="updated">Dernière mise à jour : 11 août 2026</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Cette application est un outil interne de gestion des réseaux sociaux exploité par
|
||||||
|
${escapeHtml(company.name)}, ${escapeHtml(company.legalForm)}, dont le siège est situé
|
||||||
|
${escapeHtml(company.address)}, immatriculée au ${escapeHtml(company.rcs)}, qui exploite le
|
||||||
|
magasin sous l'enseigne ${escapeHtml(company.tradeName)} (« nous »). Elle permet à nos équipes
|
||||||
|
de créer des vidéos et des publications promotionnelles, puis de les diffuser sur les comptes
|
||||||
|
et pages officiels de nos magasins.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>1. Accès au service</h2>
|
||||||
|
<p>
|
||||||
|
L'accès est réservé aux personnes que nous avons expressément autorisées, au moyen d'un compte
|
||||||
|
nominatif. Chaque utilisateur est responsable de la confidentialité de ses identifiants et des
|
||||||
|
actions effectuées depuis son compte. Nous pouvons suspendre ou retirer un accès à tout moment.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>2. Comptes de réseaux sociaux connectés</h2>
|
||||||
|
<p>
|
||||||
|
L'application publie sur des comptes Facebook et TikTok qui nous appartiennent ou que nous
|
||||||
|
sommes habilités à administrer. En connectant un compte, l'utilisateur déclare disposer des
|
||||||
|
droits nécessaires pour le faire. Un compte peut être déconnecté à tout moment depuis
|
||||||
|
l'application, ou par le retrait de l'autorisation directement dans les réglages de la
|
||||||
|
plateforme concernée.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>3. Contenus publiés</h2>
|
||||||
|
<p>
|
||||||
|
Les utilisateurs restent responsables des contenus qu'ils créent et diffusent. Ces contenus
|
||||||
|
doivent respecter la loi applicable ainsi que les règles des plateformes de destination,
|
||||||
|
notamment les Conditions d'utilisation et les Règles communautaires de TikTok et de Meta.
|
||||||
|
Sont notamment proscrits les contenus dont les utilisateurs ne détiennent pas les droits,
|
||||||
|
y compris pour les images et les musiques utilisées.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>4. Disponibilité</h2>
|
||||||
|
<p>
|
||||||
|
Le service est fourni « en l'état ». Il dépend de services tiers (plateformes de publication,
|
||||||
|
hébergement, traitement vidéo) dont les interruptions ou modifications peuvent affecter son
|
||||||
|
fonctionnement. Nous ne garantissons ni une disponibilité continue, ni la publication effective
|
||||||
|
d'un contenu par une plateforme tierce, celle-ci pouvant la refuser selon ses propres règles.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>5. Responsabilité</h2>
|
||||||
|
<p>
|
||||||
|
Dans les limites permises par la loi, notre responsabilité ne saurait être engagée au titre des
|
||||||
|
dommages indirects résultant de l'utilisation du service, notamment une perte de contenu, une
|
||||||
|
publication manquée ou la suspension d'un compte par une plateforme tierce.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>6. Modification des conditions</h2>
|
||||||
|
<p>
|
||||||
|
Ces conditions peuvent être modifiées. La date de dernière mise à jour figure en tête de page.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>7. Contact</h2>
|
||||||
|
<p>Pour toute question relative à ces conditions : ${escapeHtml(company.email)}</p>
|
||||||
|
|
||||||
|
${renderFooter()}`;
|
||||||
|
|
||||||
|
res.type('html').send(renderPage("Conditions d'utilisation", body));
|
||||||
|
});
|
||||||
|
|
||||||
|
legalRouter.get('/privacy', (_req: Request, res: Response) => {
|
||||||
|
const company = getCompanyInfo();
|
||||||
|
|
||||||
|
const body = `
|
||||||
|
<h1>Politique de confidentialité</h1>
|
||||||
|
<p class="updated">Dernière mise à jour : 11 août 2026</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Cette politique décrit les données traitées par notre outil interne de gestion des réseaux
|
||||||
|
sociaux. Le responsable de traitement est ${escapeHtml(company.name)},
|
||||||
|
${escapeHtml(company.legalForm)}, dont le siège est situé ${escapeHtml(company.address)},
|
||||||
|
immatriculée au ${escapeHtml(company.rcs)}, exploitant le magasin sous l'enseigne
|
||||||
|
${escapeHtml(company.tradeName)}.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>1. Données de nos utilisateurs internes</h2>
|
||||||
|
<p>
|
||||||
|
Pour chaque personne autorisée à utiliser l'outil, nous conservons un identifiant de connexion,
|
||||||
|
un mot de passe stocké sous forme chiffrée, un rôle et la liste des comptes auxquels elle a
|
||||||
|
accès. Ces données servent uniquement à l'authentification et à la gestion des droits.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>2. Données issues des comptes de réseaux sociaux connectés</h2>
|
||||||
|
<p>
|
||||||
|
Lorsqu'un compte TikTok est connecté, nous recevons de TikTok, avec l'autorisation explicite du
|
||||||
|
titulaire du compte et dans le cadre du périmètre <code>user.info.basic</code> :
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>l'identifiant technique du compte (<code>open_id</code>) ;</li>
|
||||||
|
<li>le nom d'affichage du compte ;</li>
|
||||||
|
<li>l'image de profil du compte ;</li>
|
||||||
|
<li>un jeton d'accès et un jeton de renouvellement, qui nous permettent de publier en son nom.</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Nous procédons de manière équivalente pour les pages Facebook connectées. Nous ne collectons ni
|
||||||
|
la liste des abonnés, ni les messages privés, ni les données de navigation des visiteurs de ces
|
||||||
|
comptes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>3. Finalités</h2>
|
||||||
|
<p>
|
||||||
|
Ces données sont utilisées exclusivement pour identifier le compte de destination dans
|
||||||
|
l'interface et y publier les vidéos et publications créées par nos équipes, immédiatement ou à
|
||||||
|
l'heure programmée. Elles ne servent à aucune autre finalité.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>4. Données que nous transmettons aux plateformes</h2>
|
||||||
|
<p>
|
||||||
|
Lors d'une publication, nous transmettons à la plateforme concernée le fichier vidéo ou image,
|
||||||
|
le texte de la publication, ainsi que les paramètres de diffusion associés (niveau de
|
||||||
|
confidentialité, autorisation des commentaires). Ces contenus sont ensuite régis par la
|
||||||
|
politique de confidentialité de la plateforme concernée.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>5. Conservation et sécurité</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Les jetons d'accès et de renouvellement sont chiffrés au repos (AES-256-GCM).</li>
|
||||||
|
<li>
|
||||||
|
Ils sont conservés tant que le compte reste connecté à l'application, et supprimés
|
||||||
|
immédiatement lorsque le compte en est retiré.
|
||||||
|
</li>
|
||||||
|
<li>Les médias sources sont supprimés automatiquement après leur publication et leur péremption.</li>
|
||||||
|
<li>L'accès à l'application est restreint aux personnes autorisées et protégé par mot de passe.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>6. Partage avec des tiers</h2>
|
||||||
|
<p>
|
||||||
|
Nous ne vendons ni ne louons ces données, et ne les transmettons à aucun tiers à des fins
|
||||||
|
publicitaires ou de profilage. Elles ne sont communiquées qu'aux plateformes de destination
|
||||||
|
(TikTok, Meta) dans la stricte mesure nécessaire à la publication demandée, ainsi qu'à nos
|
||||||
|
prestataires techniques d'hébergement et de traitement vidéo agissant sur nos instructions.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>7. Retirer une autorisation</h2>
|
||||||
|
<p>
|
||||||
|
Le titulaire d'un compte connecté peut retirer son autorisation à tout moment :
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>en demandant la déconnexion du compte depuis l'application, ce qui supprime les jetons ;</li>
|
||||||
|
<li>
|
||||||
|
ou directement dans TikTok, via <em>Profil › Paramètres et confidentialité ›
|
||||||
|
Sécurité et autorisations › Gérer les autorisations des applications</em>.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Le retrait de l'autorisation empêche toute publication ultérieure. Il n'entraîne pas la
|
||||||
|
suppression des contenus déjà publiés sur la plateforme, qui doivent être supprimés depuis
|
||||||
|
celle-ci.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>8. Vos droits</h2>
|
||||||
|
<p>
|
||||||
|
Conformément au Règlement général sur la protection des données, vous disposez d'un droit
|
||||||
|
d'accès, de rectification, d'effacement, de limitation et de portabilité des données vous
|
||||||
|
concernant, ainsi que du droit d'introduire une réclamation auprès de la CNIL. Pour exercer ces
|
||||||
|
droits, écrivez à ${escapeHtml(company.email)}.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>9. Modification de cette politique</h2>
|
||||||
|
<p>
|
||||||
|
Cette politique peut être mise à jour. La date de dernière mise à jour figure en tête de page.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
${renderFooter()}`;
|
||||||
|
|
||||||
|
res.type('html').send(renderPage('Politique de confidentialité', body));
|
||||||
|
});
|
||||||
+62
-21
@@ -7,13 +7,28 @@ import type { User, Media, SocialPage } from '@shared/schema';
|
|||||||
import { storage } from '../storage';
|
import { storage } from '../storage';
|
||||||
import { ffmpegService } from '../services/ffmpeg';
|
import { ffmpegService } from '../services/ffmpeg';
|
||||||
import { facebookService } from '../services/facebook';
|
import { facebookService } from '../services/facebook';
|
||||||
|
import { tiktokService } from '../services/tiktok';
|
||||||
|
import { createVideoThumbnail } from '../services/thumbnail';
|
||||||
import { minioService as cloudinaryService, buildMinioUrl, resolveInternalUrl } from '../services/minio';
|
import { minioService as cloudinaryService, buildMinioUrl, resolveInternalUrl } from '../services/minio';
|
||||||
import { openRouterService } from '../services/openrouter';
|
import { openRouterService, describeGenerationError } from '../services/openrouter';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { cloudinaryConfig } from '@shared/schema';
|
import { cloudinaryConfig } from '@shared/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
import { ttsSyncService } from '../services/ttsSync';
|
import { ttsSyncService } from '../services/ttsSync';
|
||||||
|
/** Piste musicale telle qu'attendue par le client. */
|
||||||
|
interface MusicTrack {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
albumName: string;
|
||||||
|
duration: number;
|
||||||
|
previewUrl: string;
|
||||||
|
downloadUrl: string;
|
||||||
|
imageUrl: string;
|
||||||
|
license: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const reelsRouter = Router();
|
export const reelsRouter = Router();
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -253,7 +268,8 @@ reelsRouter.post('/reels/generate-text', async (req: Request, res: Response) =>
|
|||||||
res.json({ variants: generatedTexts });
|
res.json({ variants: generatedTexts });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error generating Reel text:', error);
|
console.error('❌ Error generating Reel text:', error);
|
||||||
res.status(500).json({ error: 'Erreur lors de la génération du texte' });
|
const { status, message } = describeGenerationError(error);
|
||||||
|
res.status(status).json({ error: message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -614,6 +630,10 @@ async function processReelBackground(
|
|||||||
console.log('✅ [Background] Uploaded:', cloudinaryResult.originalUrl);
|
console.log('✅ [Background] Uploaded:', cloudinaryResult.originalUrl);
|
||||||
|
|
||||||
// 5. Créer l'enregistrement Media pour la vidéo traitée
|
// 5. Créer l'enregistrement Media pour la vidéo traitée
|
||||||
|
// La vignette est extraite maintenant : la vidéo sera supprimée du disque
|
||||||
|
// après publication, mais l'historique doit rester illustré.
|
||||||
|
const thumbnailUrl = await createVideoThumbnail(videoBuffer);
|
||||||
|
|
||||||
const processedMedia = await storage.createMedia({
|
const processedMedia = await storage.createMedia({
|
||||||
userId: userId,
|
userId: userId,
|
||||||
type: 'video',
|
type: 'video',
|
||||||
@@ -622,6 +642,7 @@ async function processReelBackground(
|
|||||||
facebookFeedUrl: cloudinaryResult.facebookFeedUrl || null,
|
facebookFeedUrl: cloudinaryResult.facebookFeedUrl || null,
|
||||||
instagramFeedUrl: cloudinaryResult.instagramFeedUrl || null,
|
instagramFeedUrl: cloudinaryResult.instagramFeedUrl || null,
|
||||||
instagramStoryUrl: cloudinaryResult.instagramStoryUrl || null,
|
instagramStoryUrl: cloudinaryResult.instagramStoryUrl || null,
|
||||||
|
thumbnailUrl,
|
||||||
fileName: `reel-processed-${Date.now()}.mp4`,
|
fileName: `reel-processed-${Date.now()}.mp4`,
|
||||||
fileSize: videoBuffer.length,
|
fileSize: videoBuffer.length,
|
||||||
});
|
});
|
||||||
@@ -642,8 +663,12 @@ async function processReelBackground(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (page.platform !== 'facebook') {
|
if (page.platform !== 'facebook' && page.platform !== 'tiktok') {
|
||||||
results.push({ pageId, success: false, error: 'Seules les pages Facebook sont supportées' });
|
results.push({
|
||||||
|
pageId,
|
||||||
|
success: false,
|
||||||
|
error: `Plateforme non supportée pour les reels : ${page.platform}`,
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -656,23 +681,42 @@ async function processReelBackground(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!scheduledFor) {
|
if (!scheduledFor) {
|
||||||
// Publication immédiate
|
// Publication immédiate — la même vidéo part sur chaque destination
|
||||||
console.log(`🚀 [Background] Publishing to Page ${page.pageName}...`);
|
console.log(`🚀 [Background] Publishing to ${page.platform} ${page.pageName}...`);
|
||||||
const finalDescription = description || overlayText || '';
|
const finalDescription = description || overlayText || '';
|
||||||
|
|
||||||
const reelId = await facebookService.publishReel(
|
if (page.platform === 'facebook') {
|
||||||
page,
|
const reelId = await facebookService.publishReel(
|
||||||
cloudinaryResult.originalUrl,
|
page,
|
||||||
finalDescription
|
cloudinaryResult.originalUrl,
|
||||||
);
|
finalDescription
|
||||||
|
);
|
||||||
|
|
||||||
// Mise à jour succès
|
// Mise à jour succès
|
||||||
await storage.updateScheduledPost(scheduledPost.id, {
|
await storage.updateScheduledPost(scheduledPost.id, {
|
||||||
publishedAt: new Date(),
|
publishedAt: new Date(),
|
||||||
externalPostId: reelId,
|
externalPostId: reelId,
|
||||||
});
|
});
|
||||||
|
|
||||||
results.push({ pageId, success: true, reelId });
|
results.push({ pageId, success: true, reelId });
|
||||||
|
} else {
|
||||||
|
const publishId = await tiktokService.publishVideoFromBuffer(
|
||||||
|
page,
|
||||||
|
videoBuffer,
|
||||||
|
finalDescription
|
||||||
|
);
|
||||||
|
|
||||||
|
// TikTok finalise la publication de son côté : on marque l'envoi
|
||||||
|
// comme effectué pour ne pas republier, et le poller de statut
|
||||||
|
// renseignera l'identifiant définitif du post.
|
||||||
|
await storage.updateScheduledPost(scheduledPost.id, {
|
||||||
|
publishedAt: new Date(),
|
||||||
|
publishId,
|
||||||
|
publishStatus: 'PROCESSING_UPLOAD',
|
||||||
|
});
|
||||||
|
|
||||||
|
results.push({ pageId, success: true, reelId: publishId });
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Planifié
|
// Planifié
|
||||||
results.push({ pageId, success: true, reelId: 'scheduled' });
|
results.push({ pageId, success: true, reelId: 'scheduled' });
|
||||||
@@ -854,10 +898,7 @@ reelsRouter.get('/reels/config', async (req: Request, res: Response) => {
|
|||||||
reelsRouter.get('/reels/ongoing', async (req: Request, res: Response) => {
|
reelsRouter.get('/reels/ongoing', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const user = req.user as User;
|
const user = req.user as User;
|
||||||
const allPosts = await storage.getPosts(user.id);
|
const ongoing = await storage.getOngoingReelPosts(user.id);
|
||||||
const ongoing = allPosts.filter(
|
|
||||||
(p) => p.generationStatus === 'processing' || p.generationStatus === 'pending'
|
|
||||||
);
|
|
||||||
res.json(ongoing);
|
res.json(ongoing);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error fetching ongoing reels:', error);
|
console.error('❌ Error fetching ongoing reels:', error);
|
||||||
|
|||||||
+31
-27
@@ -2,34 +2,16 @@ import { Router } from "express";
|
|||||||
import multer from "multer";
|
import multer from "multer";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import { exec } from "child_process";
|
|
||||||
import { promisify } from "util";
|
|
||||||
import { bundle } from "@remotion/bundler";
|
import { bundle } from "@remotion/bundler";
|
||||||
import { renderMedia, selectComposition } from "@remotion/renderer";
|
import { renderMedia, selectComposition } from "@remotion/renderer";
|
||||||
import { ffmpegService } from "../services/ffmpeg";
|
import { ffmpegService } from "../services/ffmpeg";
|
||||||
import { storage as dbStorage } from "../storage";
|
import { storage as dbStorage } from "../storage";
|
||||||
import { minioService as cloudinaryService, buildMinioUrl } from "../services/minio";
|
import { minioService as cloudinaryService, buildMinioUrl } from "../services/minio";
|
||||||
import { facebookService } from "../services/facebook";
|
import { facebookService } from "../services/facebook";
|
||||||
|
import { tiktokService } from "../services/tiktok";
|
||||||
|
import { generateVideoThumbnail, createVideoThumbnail } from "../services/thumbnail";
|
||||||
import * as musicMetadata from "music-metadata";
|
import * as musicMetadata from "music-metadata";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a thumbnail from a video file using FFmpeg
|
|
||||||
* @param videoPath Path to the video file
|
|
||||||
* @param outputPath Path to save the thumbnail
|
|
||||||
* @param seekTime Time in seconds to extract the frame (default: 1s)
|
|
||||||
*/
|
|
||||||
async function generateVideoThumbnail(videoPath: string, outputPath: string, seekTime: number = 1): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const cmd = `ffmpeg -y -ss ${seekTime} -i "${videoPath}" -vframes 1 -q:v 2 "${outputPath}"`;
|
|
||||||
await execAsync(cmd);
|
|
||||||
return fs.existsSync(outputPath);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('⚠️ Failed to generate video thumbnail:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const remotionRouter = Router();
|
export const remotionRouter = Router();
|
||||||
|
|
||||||
@@ -325,10 +307,12 @@ remotionRouter.post("/render", upload.fields([{ name: "images", maxCount: 4 }, {
|
|||||||
codec: "h264",
|
codec: "h264",
|
||||||
outputLocation,
|
outputLocation,
|
||||||
inputProps,
|
inputProps,
|
||||||
|
// Ne pas y remettre "args" : Remotion ne lit jamais cette clé (elle est
|
||||||
|
// absente de ChromiumOptions et de son code), les drapeaux passés là
|
||||||
|
// n'atteignaient donc jamais le navigateur.
|
||||||
chromiumOptions: {
|
chromiumOptions: {
|
||||||
disableWebSecurity: true,
|
disableWebSecurity: true,
|
||||||
ignoreCertificateErrors: true,
|
ignoreCertificateErrors: true,
|
||||||
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu"],
|
|
||||||
},
|
},
|
||||||
concurrency: 1, // Limit concurrency to prevent Docker memory exhaustion
|
concurrency: 1, // Limit concurrency to prevent Docker memory exhaustion
|
||||||
});
|
});
|
||||||
@@ -407,6 +391,9 @@ remotionRouter.post("/publish", async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create a Media record for the processed video
|
// Create a Media record for the processed video
|
||||||
|
// Vignette extraite avant que la vidéo ne soit purgée du disque
|
||||||
|
const thumbnailUrl = await createVideoThumbnail(localPath);
|
||||||
|
|
||||||
const mediaRecord = await dbStorage.createMedia({
|
const mediaRecord = await dbStorage.createMedia({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
type: "video",
|
type: "video",
|
||||||
@@ -415,6 +402,7 @@ remotionRouter.post("/publish", async (req, res) => {
|
|||||||
facebookFeedUrl: null,
|
facebookFeedUrl: null,
|
||||||
instagramFeedUrl: null,
|
instagramFeedUrl: null,
|
||||||
instagramStoryUrl: null,
|
instagramStoryUrl: null,
|
||||||
|
thumbnailUrl,
|
||||||
fileName: filename,
|
fileName: filename,
|
||||||
fileSize: fs.statSync(localPath).size,
|
fileSize: fs.statSync(localPath).size,
|
||||||
});
|
});
|
||||||
@@ -427,7 +415,10 @@ remotionRouter.post("/publish", async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const page = await dbStorage.getSocialPage(pageId);
|
const page = await dbStorage.getSocialPage(pageId);
|
||||||
if (!page) { results.push({ pageId, success: false, error: "Page introuvable" }); continue; }
|
if (!page) { results.push({ pageId, success: false, error: "Page introuvable" }); continue; }
|
||||||
if (page.platform !== "facebook") { results.push({ pageId, success: false, error: "Seules les pages Facebook sont supportées" }); continue; }
|
if (page.platform !== "facebook" && page.platform !== "tiktok") {
|
||||||
|
results.push({ pageId, success: false, error: `Plateforme non supportée pour les reels : ${page.platform}` });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const scheduledPost = await dbStorage.createScheduledPost({
|
const scheduledPost = await dbStorage.createScheduledPost({
|
||||||
postId: post.id,
|
postId: post.id,
|
||||||
@@ -437,12 +428,25 @@ remotionRouter.post("/publish", async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!scheduledFor) {
|
if (!scheduledFor) {
|
||||||
console.log(`🚀 Publishing to ${page.pageName} (direct binary upload)...`);
|
console.log(`🚀 Publishing to ${page.platform} ${page.pageName} (direct binary upload)...`);
|
||||||
// Upload video bytes directly — Facebook cannot access local URLs
|
// Upload video bytes directly — les APIs ne peuvent pas lire une URL locale
|
||||||
const videoBuffer = await fs.promises.readFile(localPath);
|
const videoBuffer = await fs.promises.readFile(localPath);
|
||||||
const reelId = await facebookService.publishVideoFromBuffer(page, videoBuffer, description || "");
|
|
||||||
await dbStorage.updateScheduledPost(scheduledPost.id, { publishedAt: new Date(), externalPostId: reelId });
|
if (page.platform === "facebook") {
|
||||||
results.push({ pageId, success: true, reelId });
|
const reelId = await facebookService.publishVideoFromBuffer(page, videoBuffer, description || "");
|
||||||
|
await dbStorage.updateScheduledPost(scheduledPost.id, { publishedAt: new Date(), externalPostId: reelId });
|
||||||
|
results.push({ pageId, success: true, reelId });
|
||||||
|
} else {
|
||||||
|
// TikTok finalise la publication de son côté : le poller de statut
|
||||||
|
// renseignera l'identifiant définitif du post.
|
||||||
|
const publishId = await tiktokService.publishVideoFromBuffer(page, videoBuffer, description || "");
|
||||||
|
await dbStorage.updateScheduledPost(scheduledPost.id, {
|
||||||
|
publishedAt: new Date(),
|
||||||
|
publishId,
|
||||||
|
publishStatus: "PROCESSING_UPLOAD",
|
||||||
|
});
|
||||||
|
results.push({ pageId, success: true, reelId: publishId });
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
results.push({ pageId, success: true, reelId: "scheduled" });
|
results.push({ pageId, success: true, reelId: "scheduled" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
/**
|
||||||
|
* Routes de gestion des comptes TikTok.
|
||||||
|
*
|
||||||
|
* Un compte TikTok de magasin se connecte en OAuth (TikTok n'autorise pas la
|
||||||
|
* saisie manuelle d'un token), puis vit dans `social_pages` comme une page
|
||||||
|
* Facebook : mêmes permissions, même planification, même calendrier.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import type { User } from '@shared/schema';
|
||||||
|
import { storage } from '../storage';
|
||||||
|
import { tiktokService } from '../services/tiktok';
|
||||||
|
import { insertTiktokConfigSchema, updateTiktokConfigSchema } from '@shared/schema';
|
||||||
|
|
||||||
|
export const tiktokRouter = Router();
|
||||||
|
|
||||||
|
/** Page vers laquelle on renvoie le navigateur à la fin du flux OAuth. */
|
||||||
|
const RETURN_PATH = '/pages';
|
||||||
|
|
||||||
|
function isAdmin(req: Request): boolean {
|
||||||
|
const user = req.user as User | undefined;
|
||||||
|
return user?.role === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReturnUrl(status: 'success' | 'error', message: string): string {
|
||||||
|
const params = new URLSearchParams({ tiktok: status, message });
|
||||||
|
return `${RETURN_PATH}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* État de la configuration TikTok.
|
||||||
|
* Le client secret n'est jamais renvoyé au client.
|
||||||
|
*/
|
||||||
|
tiktokRouter.get('/config', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const config = await storage.getTiktokConfig();
|
||||||
|
const configured = await tiktokService.isConfigured();
|
||||||
|
|
||||||
|
// Tout le monde a besoin de savoir si la connexion est possible ; seuls les
|
||||||
|
// admins voient les détails de l'application développeur.
|
||||||
|
if (!isAdmin(req)) {
|
||||||
|
return res.json({ configured });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
configured,
|
||||||
|
clientKey: config?.clientKey || (process.env.TIKTOK_CLIENT_KEY ? '(défini par variable d\'environnement)' : ''),
|
||||||
|
hasClientSecret: !!(config?.clientSecret || process.env.TIKTOK_CLIENT_SECRET),
|
||||||
|
redirectUri: tiktokService.getRedirectUri(req),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [TikTok] Erreur lecture configuration:', error);
|
||||||
|
res.status(500).json({ error: 'Impossible de lire la configuration TikTok' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Enregistre les identifiants de l'application développeur TikTok (admin). */
|
||||||
|
tiktokRouter.put('/config', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!isAdmin(req)) {
|
||||||
|
return res.status(403).json({ error: 'Accès refusé. Réservé aux administrateurs.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await storage.getTiktokConfig();
|
||||||
|
// Le secret peut être omis lors d'une mise à jour : on garde l'existant
|
||||||
|
const schema = existing ? updateTiktokConfigSchema : insertTiktokConfigSchema;
|
||||||
|
const data = schema.parse(req.body);
|
||||||
|
|
||||||
|
const config = await storage.upsertTiktokConfig(data);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
clientKey: config.clientKey,
|
||||||
|
redirectUri: tiktokService.getRedirectUri(req),
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ [TikTok] Erreur enregistrement configuration:', error);
|
||||||
|
res.status(400).json({
|
||||||
|
error: error?.errors?.[0]?.message || error?.message || 'Configuration TikTok invalide',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Démarre la connexion d'un compte TikTok.
|
||||||
|
* À appeler par navigation complète du navigateur (window.location), pas en fetch.
|
||||||
|
*/
|
||||||
|
tiktokRouter.get('/connect', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!(await tiktokService.isConfigured())) {
|
||||||
|
return res.redirect(
|
||||||
|
buildReturnUrl('error', "TikTok n'est pas configuré. Renseignez d'abord le client key et le client secret.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protection CSRF : l'état est vérifié au retour
|
||||||
|
const state = crypto.randomBytes(16).toString('hex');
|
||||||
|
(req.session as any).tiktokOAuthState = state;
|
||||||
|
|
||||||
|
const authUrl = await tiktokService.buildAuthorizationUrl(state, req);
|
||||||
|
|
||||||
|
// La session doit être écrite avant la redirection, sinon l'état est perdu
|
||||||
|
req.session.save((err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('❌ [TikTok] Impossible de sauvegarder la session OAuth:', err);
|
||||||
|
return res.redirect(buildReturnUrl('error', 'Erreur de session, réessayez.'));
|
||||||
|
}
|
||||||
|
res.redirect(authUrl);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [TikTok] Erreur démarrage OAuth:', error);
|
||||||
|
res.redirect(
|
||||||
|
buildReturnUrl('error', error instanceof Error ? error.message : 'Erreur de connexion TikTok')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retour d'autorisation TikTok : échange le code, puis crée ou met à jour le
|
||||||
|
* compte dans social_pages.
|
||||||
|
*/
|
||||||
|
tiktokRouter.get('/callback', async (req: Request, res: Response) => {
|
||||||
|
const user = req.user as User;
|
||||||
|
const { code, state, error: oauthError, error_description: oauthErrorDescription } = req.query;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (oauthError) {
|
||||||
|
const description = (oauthErrorDescription as string) || (oauthError as string);
|
||||||
|
return res.redirect(buildReturnUrl('error', `Autorisation TikTok refusée : ${description}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedState = (req.session as any)?.tiktokOAuthState;
|
||||||
|
delete (req.session as any)?.tiktokOAuthState;
|
||||||
|
|
||||||
|
if (!state || !expectedState || state !== expectedState) {
|
||||||
|
return res.redirect(buildReturnUrl('error', 'Requête de connexion TikTok invalide (état non reconnu).'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code || typeof code !== 'string') {
|
||||||
|
return res.redirect(buildReturnUrl('error', "Aucun code d'autorisation reçu de TikTok."));
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await tiktokService.exchangeCodeForToken(code, req);
|
||||||
|
const profile = await tiktokService.getUserInfo(tokens.access_token);
|
||||||
|
const openId = profile.openId || tokens.open_id;
|
||||||
|
|
||||||
|
// Un compte déjà connecté est mis à jour plutôt que dupliqué
|
||||||
|
const existingPages = await storage.getSocialPages(user.id);
|
||||||
|
const existing = existingPages.find(p => p.platform === 'tiktok' && p.pageId === openId);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await storage.updateSocialPage(existing.id, {
|
||||||
|
pageName: profile.displayName,
|
||||||
|
avatarUrl: profile.avatarUrl || null,
|
||||||
|
isActive: 'true',
|
||||||
|
});
|
||||||
|
await tiktokService.persistTokens(existing.id, tokens);
|
||||||
|
|
||||||
|
console.log(`🔁 [TikTok] Compte reconnecté: ${profile.displayName}`);
|
||||||
|
return res.redirect(buildReturnUrl('success', `Compte TikTok "${profile.displayName}" reconnecté.`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = await storage.createSocialPage({
|
||||||
|
userId: user.id,
|
||||||
|
platform: 'tiktok',
|
||||||
|
pageId: openId,
|
||||||
|
pageName: profile.displayName,
|
||||||
|
accessToken: tokens.access_token,
|
||||||
|
tokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000),
|
||||||
|
refreshToken: tokens.refresh_token,
|
||||||
|
refreshTokenExpiresAt: new Date(Date.now() + tokens.refresh_expires_in * 1000),
|
||||||
|
scopes: tokens.scope,
|
||||||
|
avatarUrl: profile.avatarUrl || null,
|
||||||
|
isActive: 'true',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Les non-admins ne voient que les pages qui leur sont explicitement
|
||||||
|
// attribuées : sans cette permission, ils ne verraient pas leur propre compte.
|
||||||
|
if (user.role !== 'admin') {
|
||||||
|
try {
|
||||||
|
await storage.createPagePermission({ userId: user.id, pageId: page.id });
|
||||||
|
} catch (permissionError) {
|
||||||
|
console.error('⚠️ [TikTok] Impossible de créer la permission de page:', permissionError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ [TikTok] Nouveau compte connecté: ${profile.displayName} (${openId})`);
|
||||||
|
res.redirect(buildReturnUrl('success', `Compte TikTok "${profile.displayName}" connecté.`));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [TikTok] Erreur callback OAuth:', error);
|
||||||
|
res.redirect(
|
||||||
|
buildReturnUrl('error', error instanceof Error ? error.message : 'Connexion TikTok impossible')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options réellement autorisées sur un compte (confidentialité, commentaires,
|
||||||
|
* duo, stitch, durée max). TikTok impose de les afficher avant publication.
|
||||||
|
*/
|
||||||
|
tiktokRouter.get('/accounts/:id/creator-info', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const user = req.user as User;
|
||||||
|
const page = await storage.getSocialPage(req.params.id);
|
||||||
|
|
||||||
|
if (!page || page.platform !== 'tiktok') {
|
||||||
|
return res.status(404).json({ error: 'Compte TikTok non trouvé' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role !== 'admin' && page.userId !== user.id) {
|
||||||
|
const accessible = await storage.getUserAccessiblePages(user.id);
|
||||||
|
if (!accessible.some(p => p.id === page.id)) {
|
||||||
|
return res.status(403).json({ error: 'Accès refusé à ce compte' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const creatorInfo = await tiktokService.getCreatorInfo(page);
|
||||||
|
res.json(creatorInfo);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [TikTok] Erreur récupération creator info:', error);
|
||||||
|
res.status(502).json({
|
||||||
|
error: error instanceof Error ? error.message : 'Impossible de contacter TikTok',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -48,6 +48,8 @@ export class AnalyticsService {
|
|||||||
|
|
||||||
for (const instance of publishedInstances) {
|
for (const instance of publishedInstances) {
|
||||||
if (!instance.externalPostId || !instance.page) continue;
|
if (!instance.externalPostId || !instance.page) continue;
|
||||||
|
// Les métriques TikTok ne sont pas exposées par la Graph API
|
||||||
|
if (instance.page.platform === 'tiktok') continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// We need the page access token
|
// We need the page access token
|
||||||
@@ -278,7 +280,9 @@ export class AnalyticsService {
|
|||||||
*/
|
*/
|
||||||
static async syncAllPages(): Promise<void> {
|
static async syncAllPages(): Promise<void> {
|
||||||
console.log('[AnalyticsService] Starting sync for all pages...');
|
console.log('[AnalyticsService] Starting sync for all pages...');
|
||||||
const allPages = await db.query.socialPages.findMany();
|
// Les statistiques passent par la Graph API : les comptes TikTok en sont exclus
|
||||||
|
const allPages = (await db.query.socialPages.findMany())
|
||||||
|
.filter(page => page.platform !== 'tiktok');
|
||||||
|
|
||||||
for (const page of allPages) {
|
for (const page of allPages) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
/**
|
||||||
|
* Renouvellement des tokens Facebook.
|
||||||
|
*
|
||||||
|
* Le problème que ce service résout : un token de page saisi à la main finit
|
||||||
|
* toujours par mourir (60 jours pour un token issu du Graph Explorer, ou du
|
||||||
|
* jour au lendemain si le mot de passe change, si l'utilisateur perd son rôle
|
||||||
|
* d'admin, ou si Meta révoque l'autorisation) — et on ne l'apprend qu'au
|
||||||
|
* moment où une publication échoue.
|
||||||
|
*
|
||||||
|
* La parade est celle recommandée par Meta :
|
||||||
|
* 1. l'utilisateur autorise l'application (OAuth) ;
|
||||||
|
* 2. le code est échangé contre un token utilisateur court ;
|
||||||
|
* 3. ce token court est échangé contre un token utilisateur longue durée
|
||||||
|
* (~60 jours) — c'est lui que l'on conserve, chiffré ;
|
||||||
|
* 4. les tokens de page dérivés d'un token utilisateur longue durée
|
||||||
|
* n'expirent pas.
|
||||||
|
*
|
||||||
|
* Conserver le token utilisateur permet de régénérer un token de page sans
|
||||||
|
* intervention humaine, et de prolonger le token utilisateur lui-même avant
|
||||||
|
* son échéance : tant que l'utilisateur ne retire pas son autorisation, la
|
||||||
|
* chaîne se renouvelle toute seule.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request } from 'express';
|
||||||
|
import { storage } from '../storage';
|
||||||
|
import { resolvePublicBaseUrl } from '../utils/public_url';
|
||||||
|
|
||||||
|
const GRAPH_VERSION = process.env.FACEBOOK_GRAPH_VERSION?.trim() || 'v19.0';
|
||||||
|
const GRAPH_URL = `https://graph.facebook.com/${GRAPH_VERSION}`;
|
||||||
|
|
||||||
|
/** Permissions nécessaires pour publier et lire les statistiques d'une page. */
|
||||||
|
const DEFAULT_SCOPES = [
|
||||||
|
'pages_show_list',
|
||||||
|
'pages_manage_posts',
|
||||||
|
'pages_read_engagement',
|
||||||
|
'pages_manage_engagement',
|
||||||
|
'read_insights',
|
||||||
|
'business_management',
|
||||||
|
'instagram_basic',
|
||||||
|
'instagram_content_publish',
|
||||||
|
].join(',');
|
||||||
|
|
||||||
|
export interface FacebookLongLivedToken {
|
||||||
|
accessToken: string;
|
||||||
|
/** Nul quand Facebook ne renvoie pas d'échéance (jeton sans expiration). */
|
||||||
|
expiresAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FacebookPageTarget {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
accessToken: string;
|
||||||
|
followersCount: number;
|
||||||
|
/** Compte Instagram professionnel rattaché à la page, s'il existe. */
|
||||||
|
instagram: { id: string; name: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Résultat de /debug_token : ce que Meta sait vraiment du jeton. */
|
||||||
|
export interface FacebookTokenInfo {
|
||||||
|
isValid: boolean;
|
||||||
|
/** Nul = n'expire pas (cas normal d'un token de page). */
|
||||||
|
expiresAt: Date | null;
|
||||||
|
scopes: string[];
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FacebookAuthError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'FacebookAuthError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FacebookOAuthService {
|
||||||
|
/**
|
||||||
|
* Identifiants de l'application Facebook (globaux à l'instance).
|
||||||
|
* La base prime sur les variables d'environnement.
|
||||||
|
*/
|
||||||
|
private async getCredentials(): Promise<{ appId: string; appSecret: string }> {
|
||||||
|
const config = await storage.getFacebookConfig();
|
||||||
|
|
||||||
|
const appId = config?.appId || process.env.FACEBOOK_APP_ID;
|
||||||
|
const appSecret = config?.appSecret || process.env.FACEBOOK_APP_SECRET;
|
||||||
|
|
||||||
|
if (!appId || !appSecret) {
|
||||||
|
throw new FacebookAuthError(
|
||||||
|
"Facebook n'est pas configuré. Renseignez l'App ID et l'App Secret de " +
|
||||||
|
'votre application Facebook dans les paramètres.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { appId, appSecret };
|
||||||
|
}
|
||||||
|
|
||||||
|
async isConfigured(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this.getCredentials();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URI de redirection OAuth. `req` permet de la déduire du domaine réellement
|
||||||
|
* utilisé quand `APP_URL` n'est pas défini : sans lui on renverrait
|
||||||
|
* `http://localhost:5555`, que Facebook rejette.
|
||||||
|
*/
|
||||||
|
getRedirectUri(req?: Request): string {
|
||||||
|
if (process.env.FACEBOOK_REDIRECT_URI) {
|
||||||
|
return process.env.FACEBOOK_REDIRECT_URI;
|
||||||
|
}
|
||||||
|
return `${resolvePublicBaseUrl(req)}/api/facebook/callback`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getScopes(): string {
|
||||||
|
return process.env.FACEBOOK_SCOPES || DEFAULT_SCOPES;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL d'autorisation à ouvrir pour connecter les pages d'un compte Facebook. */
|
||||||
|
async buildAuthorizationUrl(state: string, req?: Request): Promise<string> {
|
||||||
|
const { appId } = await this.getCredentials();
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: appId,
|
||||||
|
redirect_uri: this.getRedirectUri(req),
|
||||||
|
state,
|
||||||
|
scope: this.getScopes(),
|
||||||
|
response_type: 'code',
|
||||||
|
});
|
||||||
|
|
||||||
|
return `https://www.facebook.com/${GRAPH_VERSION}/dialog/oauth?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Échange le code d'autorisation contre un token utilisateur **longue durée**.
|
||||||
|
* L'étape intermédiaire (token court) n'est jamais conservée : seul le token
|
||||||
|
* longue durée rend les tokens de page permanents.
|
||||||
|
*/
|
||||||
|
async exchangeCodeForUserToken(code: string, req?: Request): Promise<FacebookLongLivedToken> {
|
||||||
|
const { appId, appSecret } = await this.getCredentials();
|
||||||
|
|
||||||
|
const shortLived = await this.graphGet<{ access_token?: string }>('/oauth/access_token', {
|
||||||
|
client_id: appId,
|
||||||
|
client_secret: appSecret,
|
||||||
|
redirect_uri: this.getRedirectUri(req),
|
||||||
|
code,
|
||||||
|
}, "échange du code d'autorisation");
|
||||||
|
|
||||||
|
if (!shortLived.access_token) {
|
||||||
|
throw new FacebookAuthError('Facebook n\'a pas renvoyé de jeton pour ce code d\'autorisation.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.extendUserToken(shortLived.access_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prolonge un token utilisateur. Appelé au retour d'OAuth (token court →
|
||||||
|
* ~60 jours) mais aussi périodiquement : ré-échanger un token longue durée
|
||||||
|
* encore valide repart pour 60 jours, ce qui évite la reconnexion manuelle.
|
||||||
|
*/
|
||||||
|
async extendUserToken(userToken: string): Promise<FacebookLongLivedToken> {
|
||||||
|
const { appId, appSecret } = await this.getCredentials();
|
||||||
|
|
||||||
|
const longLived = await this.graphGet<{ access_token?: string; expires_in?: number }>(
|
||||||
|
'/oauth/access_token',
|
||||||
|
{
|
||||||
|
grant_type: 'fb_exchange_token',
|
||||||
|
client_id: appId,
|
||||||
|
client_secret: appSecret,
|
||||||
|
fb_exchange_token: userToken,
|
||||||
|
},
|
||||||
|
'prolongation du jeton utilisateur'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!longLived.access_token) {
|
||||||
|
throw new FacebookAuthError("Facebook n'a pas renvoyé de jeton longue durée.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken: longLived.access_token,
|
||||||
|
// expires_in absent ou nul = jeton sans expiration connue.
|
||||||
|
expiresAt: longLived.expires_in
|
||||||
|
? new Date(Date.now() + longLived.expires_in * 1000)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pages administrées par le compte, avec leur token de page.
|
||||||
|
* Ces tokens sont permanents lorsqu'ils dérivent d'un token utilisateur
|
||||||
|
* longue durée : c'est le cœur du renouvellement automatique.
|
||||||
|
*/
|
||||||
|
async listPages(userToken: string): Promise<FacebookPageTarget[]> {
|
||||||
|
const response = await this.graphGet<{
|
||||||
|
data?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
access_token: string;
|
||||||
|
fan_count?: number;
|
||||||
|
followers_count?: number;
|
||||||
|
instagram_business_account?: { id: string; name?: string; username?: string };
|
||||||
|
}[];
|
||||||
|
}>(
|
||||||
|
'/me/accounts',
|
||||||
|
{
|
||||||
|
fields: 'id,name,access_token,fan_count,followers_count,instagram_business_account{id,name,username}',
|
||||||
|
limit: '200',
|
||||||
|
access_token: userToken,
|
||||||
|
},
|
||||||
|
'récupération des pages'
|
||||||
|
);
|
||||||
|
|
||||||
|
return (response.data ?? []).map((page) => ({
|
||||||
|
id: page.id,
|
||||||
|
name: page.name,
|
||||||
|
accessToken: page.access_token,
|
||||||
|
followersCount: page.followers_count ?? page.fan_count ?? 0,
|
||||||
|
instagram: page.instagram_business_account
|
||||||
|
? {
|
||||||
|
id: page.instagram_business_account.id,
|
||||||
|
name:
|
||||||
|
page.instagram_business_account.username ||
|
||||||
|
page.instagram_business_account.name ||
|
||||||
|
page.name,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interroge Meta sur l'état réel d'un jeton. Un simple appel /me ne dit pas
|
||||||
|
* quand le jeton expire : /debug_token le dit, ce qui permet de renouveler
|
||||||
|
* *avant* la panne plutôt qu'après.
|
||||||
|
*/
|
||||||
|
async inspectToken(token: string): Promise<FacebookTokenInfo> {
|
||||||
|
const { appId, appSecret } = await this.getCredentials();
|
||||||
|
|
||||||
|
const response = await this.graphGet<{
|
||||||
|
data?: {
|
||||||
|
is_valid?: boolean;
|
||||||
|
expires_at?: number;
|
||||||
|
scopes?: string[];
|
||||||
|
error?: { message?: string };
|
||||||
|
};
|
||||||
|
}>(
|
||||||
|
'/debug_token',
|
||||||
|
{
|
||||||
|
input_token: token,
|
||||||
|
// Jeton d'application : `app_id|app_secret`, accepté par /debug_token.
|
||||||
|
access_token: `${appId}|${appSecret}`,
|
||||||
|
},
|
||||||
|
'inspection du jeton'
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = response.data ?? {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: data.is_valid === true,
|
||||||
|
// `expires_at: 0` signifie « n'expire pas » chez Meta.
|
||||||
|
expiresAt: data.expires_at ? new Date(data.expires_at * 1000) : null,
|
||||||
|
scopes: data.scopes ?? [],
|
||||||
|
error: data.error?.message ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async graphGet<T>(
|
||||||
|
endpoint: string,
|
||||||
|
params: Record<string, string>,
|
||||||
|
context: string
|
||||||
|
): Promise<T> {
|
||||||
|
const url = new URL(`${GRAPH_URL}${endpoint}`);
|
||||||
|
Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value));
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url.toString());
|
||||||
|
} catch (error) {
|
||||||
|
throw new FacebookAuthError(
|
||||||
|
`Facebook injoignable (${context}) : ${error instanceof Error ? error.message : 'erreur réseau'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: any = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (!response.ok || payload?.error) {
|
||||||
|
const message = payload?.error?.message || `HTTP ${response.status}`;
|
||||||
|
throw new FacebookAuthError(`Facebook (${context}) : ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const facebookOAuthService = new FacebookOAuthService();
|
||||||
@@ -43,6 +43,12 @@ export async function purgeOldMedia() {
|
|||||||
if (item.cloudinaryPublicId) {
|
if (item.cloudinaryPublicId) {
|
||||||
await storageService.deleteMedia(item.cloudinaryPublicId, item.userId, item.type === 'video' ? 'video' : 'image');
|
await storageService.deleteMedia(item.cloudinaryPublicId, item.userId, item.type === 'video' ? 'video' : 'image');
|
||||||
}
|
}
|
||||||
|
// La vignette n'a plus de raison d'être une fois la ligne supprimée
|
||||||
|
if (item.thumbnailUrl?.startsWith('/uploads/')) {
|
||||||
|
await storageService
|
||||||
|
.deleteMedia(item.thumbnailUrl.replace('/uploads/', ''), item.userId, 'image')
|
||||||
|
.catch(err => console.warn(`[Media Purge] Vignette non supprimée pour ${item.id}:`, err));
|
||||||
|
}
|
||||||
await db.delete(media).where(eq(media.id, item.id));
|
await db.delete(media).where(eq(media.id, item.id));
|
||||||
successCount++;
|
successCount++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -40,6 +40,27 @@ export function resolveInternalUrl(url: string): string {
|
|||||||
return `${base}${url}`;
|
return `${base}${url}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge un média en mémoire, que son URL soit locale (/uploads/...) ou distante.
|
||||||
|
* Nécessaire pour les APIs qui veulent recevoir le fichier plutôt qu'une URL
|
||||||
|
* (upload TikTok, Reels Facebook).
|
||||||
|
*/
|
||||||
|
export async function readMediaBuffer(url: string): Promise<Buffer> {
|
||||||
|
if (url.startsWith('/')) {
|
||||||
|
const localPath = path.join(process.cwd(), url);
|
||||||
|
if (fs.existsSync(localPath)) {
|
||||||
|
return await fs.promises.readFile(localPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const absoluteUrl = resolvePublicUrl(url);
|
||||||
|
const response = await fetch(absoluteUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Impossible de récupérer le média depuis ${absoluteUrl} (${response.status})`);
|
||||||
|
}
|
||||||
|
return Buffer.from(await response.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
class LocalStorageService {
|
class LocalStorageService {
|
||||||
async uploadMedia(
|
async uploadMedia(
|
||||||
file: Buffer | string,
|
file: Buffer | string,
|
||||||
@@ -85,6 +106,21 @@ class LocalStorageService {
|
|||||||
return buildMinioUrl('', objectKey);
|
return buildMinioUrl('', objectKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Range une vignette vidéo. Elle survit volontairement à la suppression de la
|
||||||
|
* vidéo source, pour que l'historique reste illustré.
|
||||||
|
*/
|
||||||
|
async uploadThumbnail(buffer: Buffer, fileName: string): Promise<{ publicId: string; url: string }> {
|
||||||
|
const dir = path.join(UPLOADS_BASE, 'thumbnails');
|
||||||
|
ensureDir(dir);
|
||||||
|
|
||||||
|
const safeName = path.basename(fileName).replace(/[^a-zA-Z0-9._-]/g, '-');
|
||||||
|
const objectKey = `thumbnails/${safeName}`;
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(UPLOADS_BASE, objectKey), buffer);
|
||||||
|
return { publicId: objectKey, url: buildMinioUrl('', objectKey) };
|
||||||
|
}
|
||||||
|
|
||||||
async uploadLogo(buffer: Buffer, fileName: string): Promise<{ publicId: string; url: string }> {
|
async uploadLogo(buffer: Buffer, fileName: string): Promise<{ publicId: string; url: string }> {
|
||||||
const dir = path.join(UPLOADS_BASE, 'logos');
|
const dir = path.join(UPLOADS_BASE, 'logos');
|
||||||
ensureDir(dir);
|
ensureDir(dir);
|
||||||
|
|||||||
@@ -15,6 +15,49 @@ interface GeneratedText {
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { storage } from '../storage';
|
import { storage } from '../storage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrait le message d'erreur d'une réponse OpenRouter, qu'elle soit au format
|
||||||
|
* JSON ({ error: { message } }) ou en texte brut. Tronqué pour éviter de
|
||||||
|
* déverser une page HTML entière dans l'interface.
|
||||||
|
*/
|
||||||
|
function extractUpstreamMessage(body: string): string {
|
||||||
|
const trimmed = (body || '').trim();
|
||||||
|
if (!trimmed) return 'aucun détail fourni';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
const message = parsed?.error?.message || parsed?.message || parsed?.error;
|
||||||
|
if (typeof message === 'string' && message.trim()) {
|
||||||
|
return message.trim().slice(0, 300);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Réponse non-JSON (page d'erreur HTML d'un proxy, par exemple)
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed.replace(/\s+/g, ' ').slice(0, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traduit une erreur de génération en réponse HTTP exploitable côté interface.
|
||||||
|
* Sans cela, l'utilisateur ne voit qu'un 500 générique alors que la cause
|
||||||
|
* (configuration absente, clé invalide, crédits épuisés) est connue du serveur.
|
||||||
|
*/
|
||||||
|
export function describeGenerationError(error: unknown): { status: number; message: string } {
|
||||||
|
const message = error instanceof Error ? error.message : 'Erreur inconnue lors de la génération';
|
||||||
|
|
||||||
|
// Problème de configuration : l'administrateur peut le corriger lui-même
|
||||||
|
if (/Configuration OpenRouter non trouvée|Clé API OpenRouter manquante/.test(message)) {
|
||||||
|
return { status: 400, message };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refus du service tiers
|
||||||
|
if (/OpenRouter a refusé la requête|Réponse invalide de l'API OpenRouter/.test(message)) {
|
||||||
|
return { status: 502, message };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 500, message };
|
||||||
|
}
|
||||||
|
|
||||||
export class OpenRouterService {
|
export class OpenRouterService {
|
||||||
private baseUrl = "https://openrouter.ai/api/v1/chat/completions";
|
private baseUrl = "https://openrouter.ai/api/v1/chat/completions";
|
||||||
|
|
||||||
@@ -64,7 +107,13 @@ export class OpenRouterService {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
console.error('OpenRouter API Error Response:', errorText);
|
console.error('OpenRouter API Error Response:', errorText);
|
||||||
throw new Error(`OpenRouter API error: ${response.status} ${response.statusText}`);
|
// Le motif renvoyé par OpenRouter (clé invalide, crédits épuisés, modèle
|
||||||
|
// inconnu…) est la seule information exploitable : on le fait remonter
|
||||||
|
// jusqu'à l'utilisateur, qui n'a pas accès aux logs du serveur.
|
||||||
|
throw new Error(
|
||||||
|
`OpenRouter a refusé la requête (${response.status} ${response.statusText}) ` +
|
||||||
|
`avec le modèle "${modelToUse}" : ${extractUpstreamMessage(errorText)}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import cron, { ScheduledTask } from "node-cron";
|
import cron, { ScheduledTask } from "node-cron";
|
||||||
import { storage } from "../storage";
|
import { storage } from "../storage";
|
||||||
import { facebookService } from "./facebook";
|
import { facebookService } from "./facebook";
|
||||||
import { minioService as storageService } from "./minio";
|
import { tiktokService } from "./tiktok";
|
||||||
|
import { minioService as storageService, readMediaBuffer } from "./minio";
|
||||||
|
|
||||||
export class SchedulerService {
|
export class SchedulerService {
|
||||||
private task: ScheduledTask | null = null;
|
private task: ScheduledTask | null = null;
|
||||||
@@ -94,11 +95,28 @@ export class SchedulerService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish to Facebook/Instagram
|
// Publish to Facebook/Instagram/TikTok
|
||||||
let externalPostId: string;
|
|
||||||
|
|
||||||
if (page.platform === 'facebook') {
|
if (page.platform === 'facebook') {
|
||||||
externalPostId = await facebookService.publishPost(post, page, scheduledPost.postType, mediaList);
|
const externalPostId = await facebookService.publishPost(post, page, scheduledPost.postType, mediaList);
|
||||||
|
|
||||||
|
// Update the scheduled post as published (clear any previous error)
|
||||||
|
await storage.updateScheduledPost(scheduledPost.id, {
|
||||||
|
publishedAt: new Date(),
|
||||||
|
externalPostId,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
} else if (page.platform === 'tiktok') {
|
||||||
|
const publishId = await this.publishToTiktok(post, page, mediaList);
|
||||||
|
|
||||||
|
// TikTok publie de façon asynchrone : on marque l'envoi comme effectué pour
|
||||||
|
// ne pas republier à la minute suivante, et le poller de statut renseignera
|
||||||
|
// l'identifiant définitif du post.
|
||||||
|
await storage.updateScheduledPost(scheduledPost.id, {
|
||||||
|
publishedAt: new Date(),
|
||||||
|
publishId,
|
||||||
|
publishStatus: 'PROCESSING_UPLOAD',
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
} else if (page.platform === 'instagram') {
|
} else if (page.platform === 'instagram') {
|
||||||
// Instagram publishing not yet implemented
|
// Instagram publishing not yet implemented
|
||||||
console.warn(`Instagram publishing not yet implemented for post ${post.id}`);
|
console.warn(`Instagram publishing not yet implemented for post ${post.id}`);
|
||||||
@@ -113,13 +131,6 @@ export class SchedulerService {
|
|||||||
throw new Error(`Unsupported platform: ${page.platform}`);
|
throw new Error(`Unsupported platform: ${page.platform}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the scheduled post as published (clear any previous error)
|
|
||||||
await storage.updateScheduledPost(scheduledPost.id, {
|
|
||||||
publishedAt: new Date(),
|
|
||||||
externalPostId,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update post status
|
// Update post status
|
||||||
await storage.updatePost(post.id, {
|
await storage.updatePost(post.id, {
|
||||||
status: "published",
|
status: "published",
|
||||||
@@ -143,7 +154,28 @@ export class SchedulerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Successfully published post ${post.id} with external ID: ${externalPostId}`);
|
console.log(`Successfully published post ${post.id} to ${page.platform} page ${page.pageName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoie la vidéo d'un post planifié sur un compte TikTok.
|
||||||
|
* TikTok ne sait pas programmer une publication : c'est notre planificateur qui
|
||||||
|
* déclenche l'envoi à l'heure voulue.
|
||||||
|
*
|
||||||
|
* @returns le publish_id à suivre pour connaître le résultat final
|
||||||
|
*/
|
||||||
|
private async publishToTiktok(post: any, page: any, mediaList: any[]): Promise<string> {
|
||||||
|
const videoMedia = mediaList.find(m => m.type === 'video');
|
||||||
|
|
||||||
|
if (!videoMedia) {
|
||||||
|
throw new Error(
|
||||||
|
`Le compte TikTok "${page.pageName}" ne peut recevoir que des vidéos : ce post n'en contient pas.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoBuffer = await readMediaBuffer(videoMedia.originalUrl);
|
||||||
|
|
||||||
|
return await tiktokService.publishVideoFromBuffer(page, videoBuffer, post.content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,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 */ });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,574 @@
|
|||||||
|
/**
|
||||||
|
* Service de publication TikTok (Content Posting API v2).
|
||||||
|
*
|
||||||
|
* Différences structurantes avec Facebook, qui expliquent la forme de ce service :
|
||||||
|
* - Pas de token collable à la main : chaque compte magasin passe par OAuth.
|
||||||
|
* L'access token ne vit que 24h et doit être renouvelé avec le refresh token
|
||||||
|
* (valable 1 an) avant chaque publication.
|
||||||
|
* - La publication est asynchrone : l'upload rend un `publish_id`, et l'identifiant
|
||||||
|
* définitif du post n'est connu qu'en interrogeant /status/fetch/ ensuite.
|
||||||
|
* - `creator_info/query` doit être appelé avant chaque publication pour connaître
|
||||||
|
* les niveaux de confidentialité réellement autorisés sur le compte.
|
||||||
|
*
|
||||||
|
* Note : tant que l'application n'a pas passé l'audit TikTok, le seul niveau de
|
||||||
|
* confidentialité disponible est SELF_ONLY (vidéo visible du seul propriétaire du
|
||||||
|
* compte). Le code s'adapte automatiquement à ce que le compte autorise.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request } from 'express';
|
||||||
|
import { storage } from '../storage';
|
||||||
|
import { resolvePublicBaseUrl } from '../utils/public_url';
|
||||||
|
import type { SocialPage } from '@shared/schema';
|
||||||
|
|
||||||
|
const AUTH_BASE_URL = 'https://www.tiktok.com/v2/auth/authorize/';
|
||||||
|
const API_BASE_URL = 'https://open.tiktokapis.com/v2';
|
||||||
|
|
||||||
|
/** Scopes demandés à l'autorisation. `video.publish` nécessite l'audit TikTok. */
|
||||||
|
const DEFAULT_SCOPES = 'user.info.basic,video.publish';
|
||||||
|
|
||||||
|
/** TikTok impose des chunks entre 5 Mo et 64 Mo. */
|
||||||
|
const MAX_SINGLE_CHUNK_SIZE = 64 * 1024 * 1024;
|
||||||
|
const CHUNK_SIZE = 32 * 1024 * 1024;
|
||||||
|
|
||||||
|
/** Longueur maximale de la légende d'une vidéo TikTok. */
|
||||||
|
const MAX_TITLE_LENGTH = 2200;
|
||||||
|
|
||||||
|
/** Renouvelle le token si sa fin de validité est à moins de 5 minutes. */
|
||||||
|
const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
export interface TiktokCreatorInfo {
|
||||||
|
creatorAvatarUrl?: string;
|
||||||
|
creatorUsername?: string;
|
||||||
|
creatorNickname?: string;
|
||||||
|
privacyLevelOptions: string[];
|
||||||
|
commentDisabled: boolean;
|
||||||
|
duetDisabled: boolean;
|
||||||
|
stitchDisabled: boolean;
|
||||||
|
maxVideoPostDurationSec?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TiktokPublishOptions {
|
||||||
|
privacyLevel?: string;
|
||||||
|
disableComment?: boolean;
|
||||||
|
disableDuet?: boolean;
|
||||||
|
disableStitch?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TiktokPublishStatus {
|
||||||
|
status: string;
|
||||||
|
failReason?: string;
|
||||||
|
postId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TiktokTokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
expires_in: number;
|
||||||
|
open_id: string;
|
||||||
|
refresh_token: string;
|
||||||
|
refresh_expires_in: number;
|
||||||
|
scope: string;
|
||||||
|
token_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TiktokService {
|
||||||
|
/**
|
||||||
|
* Identifiants de l'application développeur TikTok (globaux à l'instance).
|
||||||
|
* La base prime sur les variables d'environnement.
|
||||||
|
*/
|
||||||
|
private async getCredentials(): Promise<{ clientKey: string; clientSecret: string }> {
|
||||||
|
const config = await storage.getTiktokConfig();
|
||||||
|
|
||||||
|
const clientKey = config?.clientKey || process.env.TIKTOK_CLIENT_KEY;
|
||||||
|
const clientSecret = config?.clientSecret || process.env.TIKTOK_CLIENT_SECRET;
|
||||||
|
|
||||||
|
if (!clientKey || !clientSecret) {
|
||||||
|
throw new Error(
|
||||||
|
"TikTok n'est pas configuré. Renseignez le client key et le client secret " +
|
||||||
|
"de votre application TikTok dans les paramètres."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { clientKey, clientSecret };
|
||||||
|
}
|
||||||
|
|
||||||
|
async isConfigured(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this.getCredentials();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URI de redirection OAuth. `req` sert de repli quand `APP_URL` n'est pas
|
||||||
|
* défini : le domaine public est alors lu sur la requête plutôt que remplacé
|
||||||
|
* par `http://localhost:5555`.
|
||||||
|
*/
|
||||||
|
getRedirectUri(req?: Request): string {
|
||||||
|
if (process.env.TIKTOK_REDIRECT_URI) {
|
||||||
|
return process.env.TIKTOK_REDIRECT_URI;
|
||||||
|
}
|
||||||
|
return `${resolvePublicBaseUrl(req)}/api/tiktok/callback`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL d'autorisation à ouvrir pour connecter le compte TikTok d'un magasin.
|
||||||
|
* Chaque magasin répète ce flux : un compte = une ligne dans social_pages.
|
||||||
|
*/
|
||||||
|
async buildAuthorizationUrl(state: string, req?: Request): Promise<string> {
|
||||||
|
const { clientKey } = await this.getCredentials();
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_key: clientKey,
|
||||||
|
scope: process.env.TIKTOK_SCOPES || DEFAULT_SCOPES,
|
||||||
|
response_type: 'code',
|
||||||
|
redirect_uri: this.getRedirectUri(req),
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${AUTH_BASE_URL}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Échange le code d'autorisation contre un couple access/refresh token. */
|
||||||
|
async exchangeCodeForToken(code: string, req?: Request): Promise<TiktokTokenResponse> {
|
||||||
|
const { clientKey, clientSecret } = await this.getCredentials();
|
||||||
|
|
||||||
|
return await this.requestToken({
|
||||||
|
client_key: clientKey,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
// TikTok renvoie le code URL-encodé dans la query string
|
||||||
|
code: decodeURIComponent(code),
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
redirect_uri: this.getRedirectUri(req),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renouvelle un access token expiré. TikTok rend aussi un nouveau refresh token. */
|
||||||
|
async refreshAccessToken(refreshToken: string): Promise<TiktokTokenResponse> {
|
||||||
|
const { clientKey, clientSecret } = await this.getCredentials();
|
||||||
|
|
||||||
|
return await this.requestToken({
|
||||||
|
client_key: clientKey,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestToken(body: Record<string, string>): Promise<TiktokTokenResponse> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/oauth/token/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
},
|
||||||
|
body: new URLSearchParams(body).toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const raw = await response.text();
|
||||||
|
let data: any;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Réponse TikTok illisible (${response.status}): ${raw.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// L'endpoint OAuth signale les erreurs avec `error` + `error_description`
|
||||||
|
if (!response.ok || data.error) {
|
||||||
|
const description = data.error_description || data.error || raw.slice(0, 200);
|
||||||
|
throw new Error(`Erreur OAuth TikTok: ${description}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.access_token) {
|
||||||
|
throw new Error("Réponse TikTok inattendue : aucun access token reçu");
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as TiktokTokenResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère un access token valide pour une page, en le renouvelant si besoin.
|
||||||
|
* Le nouveau couple de tokens est persisté immédiatement : le refresh token
|
||||||
|
* TikTok est à usage unique, le perdre déconnecterait le compte.
|
||||||
|
*/
|
||||||
|
async getValidAccessToken(page: SocialPage): Promise<string> {
|
||||||
|
if (page.platform !== 'tiktok') {
|
||||||
|
throw new Error('Ce service ne gère que les comptes TikTok');
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresAt = page.tokenExpiresAt ? new Date(page.tokenExpiresAt).getTime() : 0;
|
||||||
|
const stillValid = expiresAt - TOKEN_REFRESH_MARGIN_MS > Date.now();
|
||||||
|
|
||||||
|
if (stillValid && page.accessToken) {
|
||||||
|
return page.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!page.refreshToken) {
|
||||||
|
throw new Error(
|
||||||
|
`Le compte TikTok "${page.pageName}" doit être reconnecté (aucun refresh token enregistré).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔄 [TikTok] Renouvellement du token pour ${page.pageName}`);
|
||||||
|
|
||||||
|
let tokens: TiktokTokenResponse;
|
||||||
|
try {
|
||||||
|
tokens = await this.refreshAccessToken(page.refreshToken);
|
||||||
|
} catch (error) {
|
||||||
|
await storage.updateSocialPage(page.id, {
|
||||||
|
tokenStatus: 'expired',
|
||||||
|
lastTokenCheck: new Date(),
|
||||||
|
});
|
||||||
|
throw new Error(
|
||||||
|
`Impossible de renouveler le token TikTok de "${page.pageName}". ` +
|
||||||
|
`Le compte doit être reconnecté. (${error instanceof Error ? error.message : 'erreur inconnue'})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.persistTokens(page.id, tokens);
|
||||||
|
|
||||||
|
return tokens.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enregistre un couple de tokens fraîchement obtenu sur une page. */
|
||||||
|
async persistTokens(pageId: string, tokens: TiktokTokenResponse): Promise<void> {
|
||||||
|
await storage.updateSocialPage(pageId, {
|
||||||
|
accessToken: tokens.access_token,
|
||||||
|
tokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000),
|
||||||
|
refreshToken: tokens.refresh_token,
|
||||||
|
refreshTokenExpiresAt: new Date(Date.now() + tokens.refresh_expires_in * 1000),
|
||||||
|
scopes: tokens.scope,
|
||||||
|
tokenStatus: 'valid',
|
||||||
|
lastTokenCheck: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Profil public du compte, utilisé pour nommer la page à la connexion. */
|
||||||
|
async getUserInfo(accessToken: string): Promise<{
|
||||||
|
openId: string;
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
}> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
fields: 'open_id,display_name,avatar_url',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/user/info/?${params.toString()}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await this.parseApiResponse(response, 'récupération du profil');
|
||||||
|
const user = data.user || {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
openId: user.open_id,
|
||||||
|
displayName: user.display_name || 'Compte TikTok',
|
||||||
|
avatarUrl: user.avatar_url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options réellement autorisées sur le compte (confidentialité, commentaires,
|
||||||
|
* duo, stitch, durée max). TikTok impose cet appel avant chaque publication.
|
||||||
|
*/
|
||||||
|
async getCreatorInfo(page: SocialPage): Promise<TiktokCreatorInfo> {
|
||||||
|
return await this.queryCreatorInfo(await this.getValidAccessToken(page));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variante prenant un token déjà résolu : le refresh token TikTok tourne à chaque
|
||||||
|
* renouvellement, donc une même opération ne doit déclencher qu'un seul refresh.
|
||||||
|
*/
|
||||||
|
private async queryCreatorInfo(accessToken: string): Promise<TiktokCreatorInfo> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/post/publish/creator_info/query/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json; charset=UTF-8',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await this.parseApiResponse(response, 'récupération des options du compte');
|
||||||
|
|
||||||
|
return {
|
||||||
|
creatorAvatarUrl: data.creator_avatar_url,
|
||||||
|
creatorUsername: data.creator_username,
|
||||||
|
creatorNickname: data.creator_nickname,
|
||||||
|
privacyLevelOptions: data.privacy_level_options || [],
|
||||||
|
commentDisabled: !!data.comment_disabled,
|
||||||
|
duetDisabled: !!data.duet_disabled,
|
||||||
|
stitchDisabled: !!data.stitch_disabled,
|
||||||
|
maxVideoPostDurationSec: data.max_video_post_duration_sec,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publie une vidéo sur un compte TikTok en trois temps :
|
||||||
|
* 1. init — déclare la taille et le découpage, récupère publish_id + upload_url
|
||||||
|
* 2. upload — envoie les données binaires par chunks
|
||||||
|
* 3. (plus tard) status/fetch — le poller récupère l'identifiant du post
|
||||||
|
*
|
||||||
|
* On envoie le fichier (FILE_UPLOAD) plutôt qu'une URL (PULL_FROM_URL) : cette
|
||||||
|
* seconde option exigerait de faire vérifier le domaine de l'instance chez TikTok
|
||||||
|
* et que les médias soient publiquement accessibles.
|
||||||
|
*
|
||||||
|
* @returns le publish_id à suivre pour connaître le résultat final
|
||||||
|
*/
|
||||||
|
async publishVideoFromBuffer(
|
||||||
|
page: SocialPage,
|
||||||
|
videoBuffer: Buffer,
|
||||||
|
caption?: string,
|
||||||
|
options: TiktokPublishOptions = {}
|
||||||
|
): Promise<string> {
|
||||||
|
if (page.platform !== 'tiktok') {
|
||||||
|
throw new Error('Ce service ne gère que les comptes TikTok');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un seul renouvellement de token pour toute la publication
|
||||||
|
const accessToken = await this.getValidAccessToken(page);
|
||||||
|
const creatorInfo = await this.queryCreatorInfo(accessToken);
|
||||||
|
const privacyLevel = this.resolvePrivacyLevel(creatorInfo, options.privacyLevel, page.pageName);
|
||||||
|
|
||||||
|
const videoSize = videoBuffer.length;
|
||||||
|
const { chunkSize, totalChunkCount } = this.computeChunking(videoSize);
|
||||||
|
|
||||||
|
console.log('🎬 [TikTok] Publication en cours:', {
|
||||||
|
compte: page.pageName,
|
||||||
|
taille: `${(videoSize / 1024 / 1024).toFixed(2)} Mo`,
|
||||||
|
chunks: totalChunkCount,
|
||||||
|
confidentialite: privacyLevel,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Étape 1 : init ────────────────────────────────────────────────────────
|
||||||
|
const initBody = {
|
||||||
|
post_info: {
|
||||||
|
title: (caption || '').slice(0, MAX_TITLE_LENGTH),
|
||||||
|
privacy_level: privacyLevel,
|
||||||
|
// Une option désactivée côté compte doit obligatoirement être déclarée désactivée
|
||||||
|
disable_comment: creatorInfo.commentDisabled || !!options.disableComment,
|
||||||
|
disable_duet: creatorInfo.duetDisabled || !!options.disableDuet,
|
||||||
|
disable_stitch: creatorInfo.stitchDisabled || !!options.disableStitch,
|
||||||
|
},
|
||||||
|
source_info: {
|
||||||
|
source: 'FILE_UPLOAD',
|
||||||
|
video_size: videoSize,
|
||||||
|
chunk_size: chunkSize,
|
||||||
|
total_chunk_count: totalChunkCount,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const initResponse = await fetch(`${API_BASE_URL}/post/publish/video/init/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json; charset=UTF-8',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(initBody),
|
||||||
|
});
|
||||||
|
|
||||||
|
const initData = await this.parseApiResponse(initResponse, 'initialisation de la publication');
|
||||||
|
const publishId: string = initData.publish_id;
|
||||||
|
const uploadUrl: string = initData.upload_url;
|
||||||
|
|
||||||
|
if (!publishId || !uploadUrl) {
|
||||||
|
throw new Error("TikTok n'a pas renvoyé d'identifiant de publication");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📤 [TikTok] Upload initialisé: publish_id=${publishId}`);
|
||||||
|
|
||||||
|
// ── Étape 2 : upload des chunks ───────────────────────────────────────────
|
||||||
|
for (let index = 0; index < totalChunkCount; index++) {
|
||||||
|
const start = index * chunkSize;
|
||||||
|
// Le dernier chunk absorbe le reliquat
|
||||||
|
const end = index === totalChunkCount - 1 ? videoSize - 1 : start + chunkSize - 1;
|
||||||
|
const chunk = videoBuffer.subarray(start, end + 1);
|
||||||
|
|
||||||
|
const uploadResponse = await fetch(uploadUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'video/mp4',
|
||||||
|
'Content-Length': chunk.length.toString(),
|
||||||
|
'Content-Range': `bytes ${start}-${end}/${videoSize}`,
|
||||||
|
},
|
||||||
|
body: new Blob([chunk], { type: 'video/mp4' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!uploadResponse.ok) {
|
||||||
|
const errorText = await uploadResponse.text();
|
||||||
|
throw new Error(
|
||||||
|
`Échec de l'envoi du chunk ${index + 1}/${totalChunkCount} vers TikTok ` +
|
||||||
|
`(${uploadResponse.status}): ${errorText.slice(0, 200)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📤 [TikTok] Chunk ${index + 1}/${totalChunkCount} envoyé`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ [TikTok] Vidéo transmise pour ${page.pageName}, traitement en cours côté TikTok`);
|
||||||
|
|
||||||
|
return publishId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Interroge TikTok sur l'avancement d'une publication. */
|
||||||
|
async getPublishStatus(page: SocialPage, publishId: string): Promise<TiktokPublishStatus> {
|
||||||
|
const accessToken = await this.getValidAccessToken(page);
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/post/publish/status/fetch/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json; charset=UTF-8',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ publish_id: publishId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await this.parseApiResponse(response, 'suivi de la publication');
|
||||||
|
|
||||||
|
// Le nom du champ comporte bien une faute de frappe côté API TikTok
|
||||||
|
const postIds: string[] = data.publicaly_available_post_id || data.publicly_available_post_id || [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: data.status,
|
||||||
|
failReason: data.fail_reason,
|
||||||
|
postId: postIds.length > 0 ? postIds[0] : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met à jour les publications TikTok encore en cours de traitement.
|
||||||
|
* Appelé périodiquement : c'est ce qui renseigne l'identifiant définitif du post
|
||||||
|
* (ou l'erreur) une fois que TikTok a fini d'encoder la vidéo.
|
||||||
|
*/
|
||||||
|
async syncPendingPublications(): Promise<void> {
|
||||||
|
const pending = await storage.getScheduledPostsAwaitingPublishStatus();
|
||||||
|
|
||||||
|
if (pending.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔎 [TikTok] Suivi de ${pending.length} publication(s) en cours...`);
|
||||||
|
|
||||||
|
for (const scheduledPost of pending) {
|
||||||
|
try {
|
||||||
|
const page = await storage.getSocialPage(scheduledPost.pageId);
|
||||||
|
if (!page || page.platform !== 'tiktok' || !scheduledPost.publishId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = await this.getPublishStatus(page, scheduledPost.publishId);
|
||||||
|
|
||||||
|
if (status.status === 'PUBLISH_COMPLETE') {
|
||||||
|
await storage.updateScheduledPost(scheduledPost.id, {
|
||||||
|
publishStatus: status.status,
|
||||||
|
// À défaut d'ID public (compte privé), on garde le publish_id comme référence
|
||||||
|
externalPostId: status.postId || scheduledPost.publishId,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
console.log(`✅ [TikTok] Publication terminée sur ${page.pageName}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.status === 'FAILED') {
|
||||||
|
await storage.updateScheduledPost(scheduledPost.id, {
|
||||||
|
publishStatus: 'FAILED',
|
||||||
|
error: `Publication TikTok échouée : ${status.failReason || 'raison inconnue'}`,
|
||||||
|
});
|
||||||
|
console.error(`❌ [TikTok] Publication échouée sur ${page.pageName}: ${status.failReason}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filet de sécurité : au-delà de 24h, on cesse d'interroger l'API
|
||||||
|
const sentAt = scheduledPost.publishedAt ? new Date(scheduledPost.publishedAt).getTime() : 0;
|
||||||
|
if (sentAt && Date.now() - sentAt > 24 * 60 * 60 * 1000) {
|
||||||
|
await storage.updateScheduledPost(scheduledPost.id, {
|
||||||
|
publishStatus: 'FAILED',
|
||||||
|
error: `Statut TikTok toujours "${status.status}" après 24h, suivi abandonné.`,
|
||||||
|
});
|
||||||
|
console.error(`❌ [TikTok] Suivi abandonné pour ${page.pageName} (statut ${status.status})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.updateScheduledPost(scheduledPost.id, { publishStatus: status.status });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`⚠️ [TikTok] Impossible de récupérer le statut de la publication ${scheduledPost.id}:`,
|
||||||
|
error instanceof Error ? error.message : error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choisit un niveau de confidentialité réellement autorisé sur le compte.
|
||||||
|
* Sans audit TikTok validé, seul SELF_ONLY est proposé : on s'y rabat plutôt
|
||||||
|
* que d'échouer, en le signalant dans les logs.
|
||||||
|
*/
|
||||||
|
private resolvePrivacyLevel(
|
||||||
|
creatorInfo: TiktokCreatorInfo,
|
||||||
|
requested: string | undefined,
|
||||||
|
pageName: string
|
||||||
|
): string {
|
||||||
|
const options = creatorInfo.privacyLevelOptions;
|
||||||
|
|
||||||
|
if (options.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Le compte TikTok "${pageName}" ne propose aucun niveau de confidentialité. ` +
|
||||||
|
`Vérifiez que le compte est bien autorisé et que l'application dispose du scope video.publish.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wanted = requested || 'PUBLIC_TO_EVERYONE';
|
||||||
|
if (options.includes(wanted)) {
|
||||||
|
return wanted;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = options.includes('SELF_ONLY') ? 'SELF_ONLY' : options[0];
|
||||||
|
console.warn(
|
||||||
|
`⚠️ [TikTok] "${wanted}" indisponible sur ${pageName} (options: ${options.join(', ')}). ` +
|
||||||
|
`Publication en ${fallback}. Tant que l'application TikTok n'est pas auditée, ` +
|
||||||
|
`les vidéos restent privées.`
|
||||||
|
);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Découpage du fichier : un seul chunk jusqu'à 64 Mo, sinon des chunks de 32 Mo,
|
||||||
|
* le dernier absorbant le reliquat (règle imposée par TikTok).
|
||||||
|
*/
|
||||||
|
private computeChunking(videoSize: number): { chunkSize: number; totalChunkCount: number } {
|
||||||
|
if (videoSize <= MAX_SINGLE_CHUNK_SIZE) {
|
||||||
|
return { chunkSize: videoSize, totalChunkCount: 1 };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
chunkSize: CHUNK_SIZE,
|
||||||
|
totalChunkCount: Math.floor(videoSize / CHUNK_SIZE),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Les endpoints v2 répondent HTTP 200 avec `error.code === 'ok'` en cas de succès :
|
||||||
|
* il ne suffit pas de regarder le statut HTTP.
|
||||||
|
*/
|
||||||
|
private async parseApiResponse(response: Response, context: string): Promise<any> {
|
||||||
|
const raw = await response.text();
|
||||||
|
|
||||||
|
let payload: any;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Réponse TikTok illisible lors de la ${context} (${response.status}): ${raw.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorCode = payload?.error?.code;
|
||||||
|
if (!response.ok || (errorCode && errorCode !== 'ok')) {
|
||||||
|
const message = payload?.error?.message || raw.slice(0, 200);
|
||||||
|
throw new Error(`Erreur TikTok lors de la ${context}: ${message} (code: ${errorCode || response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload.data || {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tiktokService = new TiktokService();
|
||||||
@@ -1,10 +1,31 @@
|
|||||||
// crypto removed, using shared encryption utility
|
// crypto removed, using shared encryption utility
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { socialPages, tokenStatusEnum } from '../../shared/schema';
|
import { socialPages } from '../../shared/schema';
|
||||||
import { eq, and, lte } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { GraphAPIClient } from '../utils/graph_client';
|
import { GraphAPIClient } from '../utils/graph_client';
|
||||||
|
import type { SocialPage } from '../../shared/schema';
|
||||||
|
|
||||||
import { encrypt, decrypt } from '../utils/encryption';
|
import { encrypt, decrypt } from '../utils/encryption';
|
||||||
|
import { facebookOAuthService, FacebookAuthError } from './facebook_oauth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un jeton dont l'échéance est plus proche que cette fenêtre est renouvelé
|
||||||
|
* sans attendre : il reste largement le temps de réessayer les jours suivants
|
||||||
|
* si Facebook est indisponible au moment du contrôle.
|
||||||
|
*/
|
||||||
|
const RENEWAL_WINDOW_MS = 15 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** En deçà de cette marge, un jeton non renouvelable est signalé « expiring ». */
|
||||||
|
const WARNING_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export interface TokenCheckResult {
|
||||||
|
pageId: string;
|
||||||
|
pageName: string;
|
||||||
|
status: 'valid' | 'expiring' | 'expired' | 'error';
|
||||||
|
/** Vrai quand ce passage a effectivement réécrit un nouveau jeton. */
|
||||||
|
renewed: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service responsible for managing Facebook Page Token lifecycle
|
* Service responsible for managing Facebook Page Token lifecycle
|
||||||
@@ -30,8 +51,9 @@ export class TokenManager {
|
|||||||
/**
|
/**
|
||||||
* Checks all active pages for expiring tokens and refreshes them if needed.
|
* Checks all active pages for expiring tokens and refreshes them if needed.
|
||||||
*/
|
*/
|
||||||
static async checkAndRefreshTokens() {
|
static async checkAndRefreshTokens(): Promise<TokenCheckResult[]> {
|
||||||
console.log('[TokenManager] Starting daily token check...');
|
console.log('[TokenManager] Starting daily token check...');
|
||||||
|
const results: TokenCheckResult[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch active pages
|
// Fetch active pages
|
||||||
@@ -41,15 +63,18 @@ export class TokenManager {
|
|||||||
|
|
||||||
for (const page of pages) {
|
for (const page of pages) {
|
||||||
try {
|
try {
|
||||||
await this.processPageToken(page);
|
results.push(await this.processPageToken(page));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
console.error(`[TokenManager] Error processing page ${page.pageName} (${page.id}):`, error);
|
console.error(`[TokenManager] Error processing page ${page.pageName} (${page.id}):`, error);
|
||||||
await db.update(socialPages)
|
await this.persistStatus(page.id, 'error', message);
|
||||||
.set({
|
results.push({
|
||||||
tokenStatus: "error",
|
pageId: page.id,
|
||||||
lastTokenCheck: new Date()
|
pageName: page.pageName,
|
||||||
})
|
status: 'error',
|
||||||
.where(eq(socialPages.id, page.id));
|
renewed: false,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,39 +82,266 @@ export class TokenManager {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[TokenManager] Critical error in checkAndRefreshTokens:', e);
|
console.error('[TokenManager] Critical error in checkAndRefreshTokens:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async processPageToken(page: any) {
|
/** Contrôle (et renouvellement) d'une seule page, déclenché depuis l'UI. */
|
||||||
let accessToken = page.accessToken;
|
static async checkPage(pageId: string): Promise<TokenCheckResult> {
|
||||||
|
const [page] = await db.select().from(socialPages).where(eq(socialPages.id, pageId));
|
||||||
|
if (!page) {
|
||||||
|
throw new Error('Page introuvable');
|
||||||
|
}
|
||||||
|
return this.processPageToken(page);
|
||||||
|
}
|
||||||
|
|
||||||
// Attempt to decrypt. If fails, assume it's plain text (migration phase)
|
private static async processPageToken(page: SocialPage): Promise<TokenCheckResult> {
|
||||||
try {
|
// Les comptes TikTok ont leur propre cycle de vie : l'access token ne dure
|
||||||
if (accessToken.includes(':')) {
|
// que 24h et se renouvelle avec le refresh token, pas via la Graph API.
|
||||||
accessToken = this.decrypt(accessToken);
|
if (page.platform === 'tiktok') {
|
||||||
}
|
return await this.processTiktokToken(page);
|
||||||
} catch (e) {
|
|
||||||
// Not encrypted or format error, proceed with original
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[TokenManager] Checking page: ${page.pageName}`);
|
console.log(`[TokenManager] Checking page: ${page.pageName}`);
|
||||||
|
|
||||||
|
const accessToken = this.readToken(page.accessToken);
|
||||||
|
|
||||||
|
// Sans application Facebook configurée, on ne peut ni interroger
|
||||||
|
// /debug_token ni régénérer quoi que ce soit : on se rabat sur le
|
||||||
|
// contrôle historique (le jeton répond-il encore ?).
|
||||||
|
if (!(await facebookOAuthService.isConfigured())) {
|
||||||
|
return this.legacyCheck(page, accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
let info;
|
||||||
|
try {
|
||||||
|
info = await facebookOAuthService.inspectToken(accessToken);
|
||||||
|
} catch (error) {
|
||||||
|
// Facebook injoignable : on ne dégrade pas le statut sur un incident
|
||||||
|
// réseau, sinon toute l'UI passerait au rouge le temps d'une panne.
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.warn(`[TokenManager] Inspection impossible pour ${page.pageName}: ${message}`);
|
||||||
|
return this.legacyCheck(page, accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresAt = info.expiresAt;
|
||||||
|
const expiringSoon =
|
||||||
|
expiresAt !== null && expiresAt.getTime() - Date.now() < RENEWAL_WINDOW_MS;
|
||||||
|
|
||||||
|
if (info.isValid && !expiringSoon) {
|
||||||
|
// Le jeton de page tient : on en profite pour prolonger le jeton
|
||||||
|
// utilisateur s'il approche de son échéance, car c'est lui qui
|
||||||
|
// permettra le prochain renouvellement.
|
||||||
|
await this.extendUserTokenIfNeeded(page);
|
||||||
|
await this.persistStatus(page.id, 'valid', null, { tokenExpiresAt: expiresAt });
|
||||||
|
return { pageId: page.id, pageName: page.pageName, status: 'valid', renewed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = info.isValid
|
||||||
|
? `Le jeton expire le ${expiresAt?.toLocaleDateString('fr-FR')}`
|
||||||
|
: info.error || 'Le jeton a été invalidé par Facebook';
|
||||||
|
console.warn(`[TokenManager] ${page.pageName}: ${reason} — tentative de renouvellement`);
|
||||||
|
|
||||||
|
// Renouvellement automatique : c'est tout l'intérêt d'avoir conservé le
|
||||||
|
// jeton utilisateur longue durée au moment de la connexion OAuth.
|
||||||
|
try {
|
||||||
|
const renewed = await this.renewFromUserToken(page);
|
||||||
|
if (renewed) {
|
||||||
|
console.log(`[TokenManager] Jeton renouvelé automatiquement pour ${page.pageName}`);
|
||||||
|
return { pageId: page.id, pageName: page.pageName, status: 'valid', renewed: true };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(`[TokenManager] Renouvellement impossible pour ${page.pageName}: ${message}`);
|
||||||
|
await this.persistStatus(page.id, info.isValid ? 'expiring' : 'expired', message, {
|
||||||
|
tokenExpiresAt: expiresAt,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
pageId: page.id,
|
||||||
|
pageName: page.pageName,
|
||||||
|
status: info.isValid ? 'expiring' : 'expired',
|
||||||
|
renewed: false,
|
||||||
|
error: message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pas de jeton utilisateur en base : la page a été ajoutée à la main,
|
||||||
|
// seule une reconnexion peut la remettre d'aplomb.
|
||||||
|
const manual =
|
||||||
|
`${reason}. Reconnectez la page via « Connecter des pages Facebook » ` +
|
||||||
|
'pour activer le renouvellement automatique.';
|
||||||
|
const status = info.isValid ? 'expiring' : 'expired';
|
||||||
|
await this.persistStatus(page.id, status, manual, { tokenExpiresAt: expiresAt });
|
||||||
|
return { pageId: page.id, pageName: page.pageName, status, renewed: false, error: manual };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Régénère le jeton de page à partir du jeton utilisateur longue durée.
|
||||||
|
* Renvoie false quand la page n'a pas de jeton utilisateur enregistré.
|
||||||
|
*/
|
||||||
|
private static async renewFromUserToken(page: SocialPage): Promise<boolean> {
|
||||||
|
if (!page.userAccessToken) return false;
|
||||||
|
|
||||||
|
const storedUserToken = this.readToken(page.userAccessToken);
|
||||||
|
|
||||||
|
// On prolonge d'abord le jeton utilisateur : le ré-échanger repart pour
|
||||||
|
// ~60 jours, ce qui repousse d'autant la prochaine reconnexion manuelle.
|
||||||
|
// Un échec ici n'est pas bloquant tant que le jeton actuel fonctionne.
|
||||||
|
let userToken = storedUserToken;
|
||||||
|
let userTokenExpiresAt = page.userTokenExpiresAt ?? null;
|
||||||
|
try {
|
||||||
|
const extended = await facebookOAuthService.extendUserToken(storedUserToken);
|
||||||
|
userToken = extended.accessToken;
|
||||||
|
userTokenExpiresAt = extended.expiresAt;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`[TokenManager] Prolongation du jeton utilisateur impossible pour ${page.pageName}:`,
|
||||||
|
error instanceof Error ? error.message : error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = await facebookOAuthService.listPages(userToken);
|
||||||
|
// Une page Instagram est publiée avec le jeton de la page Facebook à
|
||||||
|
// laquelle son compte professionnel est rattaché.
|
||||||
|
const match = targets.find(
|
||||||
|
(target) => target.id === page.pageId || target.instagram?.id === page.pageId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
throw new FacebookAuthError(
|
||||||
|
`La page « ${page.pageName} » n'est plus administrée par le compte Facebook connecté. ` +
|
||||||
|
'Reconnectez-la avec un compte administrateur de cette page.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un jeton de page dérivé d'un jeton utilisateur longue durée n'expire
|
||||||
|
// pas : `expiresAt` reste donc généralement nul.
|
||||||
|
let pageTokenExpiresAt: Date | null = null;
|
||||||
|
try {
|
||||||
|
pageTokenExpiresAt = (await facebookOAuthService.inspectToken(match.accessToken)).expiresAt;
|
||||||
|
} catch {
|
||||||
|
// L'inspection est un confort : le jeton fraîchement émis est bon.
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(socialPages)
|
||||||
|
.set({
|
||||||
|
accessToken: match.accessToken,
|
||||||
|
tokenExpiresAt: pageTokenExpiresAt,
|
||||||
|
userAccessToken: encrypt(userToken),
|
||||||
|
userTokenExpiresAt,
|
||||||
|
followersCount: match.followersCount || page.followersCount,
|
||||||
|
tokenStatus: 'valid',
|
||||||
|
tokenError: null,
|
||||||
|
lastTokenCheck: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(socialPages.id, page.id));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prolonge le jeton utilisateur quand son échéance approche, même si le
|
||||||
|
* jeton de page se porte bien : c'est ce qui évite de perdre la capacité de
|
||||||
|
* renouveler le jour où le jeton de page est révoqué.
|
||||||
|
*/
|
||||||
|
private static async extendUserTokenIfNeeded(page: SocialPage): Promise<void> {
|
||||||
|
if (!page.userAccessToken) return;
|
||||||
|
|
||||||
|
const expiresAt = page.userTokenExpiresAt ? new Date(page.userTokenExpiresAt).getTime() : null;
|
||||||
|
if (expiresAt !== null && expiresAt - Date.now() > RENEWAL_WINDOW_MS) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const extended = await facebookOAuthService.extendUserToken(this.readToken(page.userAccessToken));
|
||||||
|
await db.update(socialPages)
|
||||||
|
.set({
|
||||||
|
userAccessToken: encrypt(extended.accessToken),
|
||||||
|
userTokenExpiresAt: extended.expiresAt,
|
||||||
|
})
|
||||||
|
.where(eq(socialPages.id, page.id));
|
||||||
|
console.log(`[TokenManager] Jeton utilisateur prolongé pour ${page.pageName}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`[TokenManager] Impossible de prolonger le jeton utilisateur de ${page.pageName}:`,
|
||||||
|
error instanceof Error ? error.message : error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrôle historique, utilisé quand l'application Facebook n'est pas
|
||||||
|
* configurée : on sait seulement si le jeton répond encore.
|
||||||
|
*/
|
||||||
|
private static async legacyCheck(page: SocialPage, accessToken: string): Promise<TokenCheckResult> {
|
||||||
const isValid = await this.verifyToken(accessToken);
|
const isValid = await this.verifyToken(accessToken);
|
||||||
|
|
||||||
if (isValid) {
|
if (!isValid) {
|
||||||
await db.update(socialPages)
|
const error =
|
||||||
.set({
|
"Le jeton n'est plus accepté par Facebook. Configurez l'application Facebook " +
|
||||||
tokenStatus: "valid",
|
'dans les paramètres pour activer le renouvellement automatique, ou saisissez un nouveau jeton.';
|
||||||
lastTokenCheck: new Date()
|
await this.persistStatus(page.id, 'expired', error);
|
||||||
})
|
return { pageId: page.id, pageName: page.pageName, status: 'expired', renewed: false, error };
|
||||||
.where(eq(socialPages.id, page.id));
|
}
|
||||||
} else {
|
|
||||||
await db.update(socialPages)
|
// Le jeton répond : reste l'échéance déclarée à l'ajout de la page.
|
||||||
.set({
|
const declared = page.tokenExpiresAt ? new Date(page.tokenExpiresAt).getTime() : null;
|
||||||
tokenStatus: "expired",
|
const status = declared !== null && declared - Date.now() < WARNING_WINDOW_MS ? 'expiring' : 'valid';
|
||||||
lastTokenCheck: new Date()
|
await this.persistStatus(page.id, status, null);
|
||||||
})
|
return { pageId: page.id, pageName: page.pageName, status, renewed: false };
|
||||||
.where(eq(socialPages.id, page.id));
|
}
|
||||||
console.warn(`[TokenManager] Token expired for ${page.pageName}`);
|
|
||||||
|
/**
|
||||||
|
* Vérifie qu'un compte TikTok est toujours utilisable.
|
||||||
|
* On ne teste pas l'access token (il est renouvelé à la demande avant chaque
|
||||||
|
* publication) mais le refresh token, dont l'expiration déconnecterait le compte.
|
||||||
|
*/
|
||||||
|
private static async processTiktokToken(page: SocialPage): Promise<TokenCheckResult> {
|
||||||
|
console.log(`[TokenManager] Checking TikTok account: ${page.pageName}`);
|
||||||
|
|
||||||
|
const refreshExpiresAt = page.refreshTokenExpiresAt
|
||||||
|
? new Date(page.refreshTokenExpiresAt).getTime()
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
let status: "valid" | "expiring" | "expired" = "valid";
|
||||||
|
let error: string | null = null;
|
||||||
|
|
||||||
|
if (!page.refreshToken || (refreshExpiresAt && refreshExpiresAt <= Date.now())) {
|
||||||
|
status = "expired";
|
||||||
|
error = "L'autorisation TikTok a expiré : reconnectez le compte.";
|
||||||
|
console.warn(`[TokenManager] TikTok account ${page.pageName} must be reconnected`);
|
||||||
|
} else if (refreshExpiresAt && refreshExpiresAt - Date.now() < 30 * 24 * 60 * 60 * 1000) {
|
||||||
|
// Moins de 30 jours de validité restante
|
||||||
|
status = "expiring";
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.persistStatus(page.id, status, error);
|
||||||
|
return { pageId: page.id, pageName: page.pageName, status, renewed: false, error: error ?? undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async persistStatus(
|
||||||
|
pageId: string,
|
||||||
|
status: 'valid' | 'expiring' | 'expired' | 'error',
|
||||||
|
error: string | null,
|
||||||
|
extra: { tokenExpiresAt?: Date | null } = {}
|
||||||
|
): Promise<void> {
|
||||||
|
await db.update(socialPages)
|
||||||
|
.set({
|
||||||
|
tokenStatus: status,
|
||||||
|
tokenError: error,
|
||||||
|
lastTokenCheck: new Date(),
|
||||||
|
...(extra.tokenExpiresAt !== undefined ? { tokenExpiresAt: extra.tokenExpiresAt } : {}),
|
||||||
|
})
|
||||||
|
.where(eq(socialPages.id, pageId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Les jetons ont pu être enregistrés en clair (saisie manuelle) ou chiffrés
|
||||||
|
* (flux OAuth) : `decrypt` renvoie le texte tel quel s'il n'est pas chiffré.
|
||||||
|
*/
|
||||||
|
private static readToken(token: string): string {
|
||||||
|
try {
|
||||||
|
return this.decrypt(token);
|
||||||
|
} catch {
|
||||||
|
return token;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+313
-83
@@ -36,29 +36,108 @@ import {
|
|||||||
type InsertAudioTrack,
|
type InsertAudioTrack,
|
||||||
appConfig,
|
appConfig,
|
||||||
type AppConfig,
|
type AppConfig,
|
||||||
|
tiktokConfig,
|
||||||
|
type TiktokConfig,
|
||||||
|
type InsertTiktokConfig,
|
||||||
|
facebookConfig,
|
||||||
|
type FacebookConfig,
|
||||||
|
type InsertFacebookConfig,
|
||||||
} from "@shared/schema";
|
} from "@shared/schema";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { eq, and, gte, lte, desc, asc, isNull, inArray } from "drizzle-orm";
|
import { eq, and, gte, lte, desc, asc, isNull, isNotNull, inArray, notInArray, getTableColumns, sql, type SQL } from "drizzle-orm";
|
||||||
import { encrypt, decrypt, isEncrypted } from "./utils/encryption";
|
import { encrypt, decrypt, isEncrypted } from "./utils/encryption";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statuts de publication asynchrone (TikTok) qui ne bougeront plus : inutile de
|
||||||
|
* continuer à interroger l'API une fois l'un d'eux atteint.
|
||||||
|
*/
|
||||||
|
export const TERMINAL_PUBLISH_STATUSES: string[] = [
|
||||||
|
'PUBLISH_COMPLETE',
|
||||||
|
'FAILED',
|
||||||
|
// Vidéo déposée dans la boîte de réception du créateur : elle attend une action
|
||||||
|
// manuelle dans TikTok et n'évoluera plus côté API.
|
||||||
|
'SEND_TO_USER_INBOX',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Déchiffre les tokens d'une page avant de la rendre au reste de l'application.
|
||||||
|
* Les services (Facebook, TikTok) manipulent toujours des tokens en clair.
|
||||||
|
*/
|
||||||
|
function decryptPageTokens(page: SocialPage): SocialPage {
|
||||||
|
return {
|
||||||
|
...page,
|
||||||
|
accessToken: decrypt(page.accessToken),
|
||||||
|
refreshToken: page.refreshToken ? decrypt(page.refreshToken) : page.refreshToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bornes temporelles utilisées pour les comparaisons du tableau de bord. */
|
||||||
|
export interface DashboardStatsRange {
|
||||||
|
yesterdayStart: Date;
|
||||||
|
yesterdayEnd: Date;
|
||||||
|
lastMonth: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardStats {
|
||||||
|
scheduledPosts: number;
|
||||||
|
scheduledPostsLastMonth: number;
|
||||||
|
connectedPages: number;
|
||||||
|
aiTextsGenerated: number;
|
||||||
|
aiTextsYesterday: number;
|
||||||
|
mediaStored: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Colonnes renvoyées pour une publication planifiée.
|
||||||
|
*
|
||||||
|
* On ne sélectionne pas la ligne `social_pages` entière : elle porte les jetons
|
||||||
|
* d'accès Facebook et TikTok, qui partaient jusqu'ici dans chaque réponse HTTP
|
||||||
|
* — y compris vers le navigateur, qui n'en fait rien.
|
||||||
|
*/
|
||||||
|
const SCHEDULED_POST_COLUMNS = {
|
||||||
|
...getTableColumns(scheduledPosts),
|
||||||
|
post: {
|
||||||
|
id: posts.id,
|
||||||
|
userId: posts.userId,
|
||||||
|
content: posts.content,
|
||||||
|
status: posts.status,
|
||||||
|
scheduledFor: posts.scheduledFor,
|
||||||
|
aiGenerated: posts.aiGenerated,
|
||||||
|
generationStatus: posts.generationStatus,
|
||||||
|
generationProgress: posts.generationProgress,
|
||||||
|
createdAt: posts.createdAt,
|
||||||
|
},
|
||||||
|
page: {
|
||||||
|
id: socialPages.id,
|
||||||
|
pageId: socialPages.pageId,
|
||||||
|
pageName: socialPages.pageName,
|
||||||
|
platform: socialPages.platform,
|
||||||
|
avatarUrl: socialPages.avatarUrl,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export interface IStorage {
|
export interface IStorage {
|
||||||
// Users
|
// Users
|
||||||
getUser(id: string): Promise<User | undefined>;
|
getUser(id: string): Promise<User | undefined>;
|
||||||
getUserByUsername(username: string): Promise<User | undefined>;
|
getUserByUsername(username: string): Promise<User | undefined>;
|
||||||
getAllUsers(): Promise<User[]>;
|
getAllUsers(): Promise<User[]>;
|
||||||
|
getDashboardStats(userId: string, options: DashboardStatsRange): Promise<DashboardStats>;
|
||||||
createUser(user: InsertUser): Promise<User>;
|
createUser(user: InsertUser): Promise<User>;
|
||||||
updateUser(id: string, user: Partial<InsertUser>): Promise<User>;
|
updateUser(id: string, user: Partial<InsertUser>): Promise<User>;
|
||||||
deleteUser(id: string): Promise<void>;
|
deleteUser(id: string): Promise<void>;
|
||||||
|
|
||||||
// Social Pages
|
// Social Pages
|
||||||
getSocialPages(userId: string): Promise<SocialPage[]>;
|
getSocialPages(userId: string): Promise<SocialPage[]>;
|
||||||
|
getAllSocialPages(): Promise<SocialPage[]>;
|
||||||
getSocialPage(id: string): Promise<SocialPage | undefined>;
|
getSocialPage(id: string): Promise<SocialPage | undefined>;
|
||||||
createSocialPage(page: InsertSocialPage): Promise<SocialPage>;
|
createSocialPage(page: InsertSocialPage): Promise<SocialPage>;
|
||||||
updateSocialPage(id: string, page: Partial<SocialPage>): Promise<SocialPage>;
|
updateSocialPage(id: string, page: Partial<SocialPage>): Promise<SocialPage>;
|
||||||
deleteSocialPage(id: string): Promise<void>;
|
deleteSocialPage(id: string): Promise<void>;
|
||||||
|
|
||||||
// Media
|
// Media
|
||||||
getMedia(userId: string): Promise<Media[]>;
|
getMedia(userId: string, options?: { limit?: number; offset?: number }): Promise<Media[]>;
|
||||||
|
getMediaByIds(ids: string[]): Promise<Media[]>;
|
||||||
|
getMediaCount(userId: string): Promise<number>;
|
||||||
getMediaById(id: string): Promise<Media | undefined>;
|
getMediaById(id: string): Promise<Media | undefined>;
|
||||||
createMedia(media: InsertMedia): Promise<Media>;
|
createMedia(media: InsertMedia): Promise<Media>;
|
||||||
deleteMedia(id: string): Promise<void>;
|
deleteMedia(id: string): Promise<void>;
|
||||||
@@ -68,6 +147,7 @@ export interface IStorage {
|
|||||||
getPosts(userId: string): Promise<Post[]>;
|
getPosts(userId: string): Promise<Post[]>;
|
||||||
getPost(id: string): Promise<Post | undefined>;
|
getPost(id: string): Promise<Post | undefined>;
|
||||||
getPostWithMedia(id: string): Promise<{ post: Post; media: Media[] } | undefined>;
|
getPostWithMedia(id: string): Promise<{ post: Post; media: Media[] } | undefined>;
|
||||||
|
getOngoingReelPosts(userId: string): Promise<Post[]>;
|
||||||
createPost(post: InsertPost): Promise<Post>;
|
createPost(post: InsertPost): Promise<Post>;
|
||||||
updatePost(id: string, post: Partial<InsertPost>): Promise<Post>;
|
updatePost(id: string, post: Partial<InsertPost>): Promise<Post>;
|
||||||
updatePostGenerationStatus(id: string, status: string, progress: number, error?: string): Promise<Post>;
|
updatePostGenerationStatus(id: string, status: string, progress: number, error?: string): Promise<Post>;
|
||||||
@@ -85,11 +165,18 @@ export interface IStorage {
|
|||||||
updateScheduledPost(id: string, scheduledPost: Partial<ScheduledPost>): Promise<ScheduledPost>;
|
updateScheduledPost(id: string, scheduledPost: Partial<ScheduledPost>): Promise<ScheduledPost>;
|
||||||
deleteScheduledPost(id: string): Promise<void>;
|
deleteScheduledPost(id: string): Promise<void>;
|
||||||
getPendingScheduledPosts(): Promise<ScheduledPost[]>;
|
getPendingScheduledPosts(): Promise<ScheduledPost[]>;
|
||||||
|
getScheduledPostsAwaitingPublishStatus(): Promise<ScheduledPost[]>;
|
||||||
|
|
||||||
// AI Generations
|
// AI Generations
|
||||||
getAiGenerations(userId: string): Promise<AiGeneration[]>;
|
getAiGenerations(userId: string): Promise<AiGeneration[]>;
|
||||||
createAiGeneration(generation: InsertAiGeneration): Promise<AiGeneration>;
|
createAiGeneration(generation: InsertAiGeneration): Promise<AiGeneration>;
|
||||||
|
|
||||||
|
// TikTok Config
|
||||||
|
getTiktokConfig(): Promise<TiktokConfig | undefined>;
|
||||||
|
upsertTiktokConfig(config: Partial<InsertTiktokConfig>): Promise<TiktokConfig>;
|
||||||
|
getFacebookConfig(): Promise<FacebookConfig | undefined>;
|
||||||
|
upsertFacebookConfig(config: Partial<InsertFacebookConfig>): Promise<FacebookConfig>;
|
||||||
|
|
||||||
// Cloudinary Config
|
// Cloudinary Config
|
||||||
getCloudinaryConfig(): Promise<CloudinaryConfig | undefined>;
|
getCloudinaryConfig(): Promise<CloudinaryConfig | undefined>;
|
||||||
createCloudinaryConfig(config: InsertCloudinaryConfig): Promise<CloudinaryConfig>;
|
createCloudinaryConfig(config: InsertCloudinaryConfig): Promise<CloudinaryConfig>;
|
||||||
@@ -157,46 +244,85 @@ export class DatabaseStorage implements IStorage {
|
|||||||
await db.delete(users).where(eq(users.id, id));
|
await db.delete(users).where(eq(users.id, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compteurs du tableau de bord.
|
||||||
|
*
|
||||||
|
* Comptés en SQL : les charger en mémoire revenait à lire quatre tables
|
||||||
|
* entières — tous les posts, toutes les pages, tous les médias, toutes les
|
||||||
|
* générations — pour n'en garder que le nombre de lignes.
|
||||||
|
*/
|
||||||
|
async getDashboardStats(userId: string, range: DashboardStatsRange): Promise<DashboardStats> {
|
||||||
|
const [postCounts, generationCounts, pageCount, mediaCount] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
scheduled: sql<string>`count(*) filter (where ${posts.status} = 'scheduled')`,
|
||||||
|
scheduledLastMonth: sql<string>`count(*) filter (where ${posts.status} = 'scheduled' and ${posts.createdAt} < ${range.lastMonth})`,
|
||||||
|
})
|
||||||
|
.from(posts)
|
||||||
|
.where(eq(posts.userId, userId)),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
total: sql<string>`count(*)`,
|
||||||
|
yesterday: sql<string>`count(*) filter (where ${aiGenerations.createdAt} >= ${range.yesterdayStart} and ${aiGenerations.createdAt} < ${range.yesterdayEnd})`,
|
||||||
|
})
|
||||||
|
.from(aiGenerations)
|
||||||
|
.where(eq(aiGenerations.userId, userId)),
|
||||||
|
db.select({ total: sql<string>`count(*)` }).from(socialPages).where(eq(socialPages.userId, userId)),
|
||||||
|
db.select({ total: sql<string>`count(*)` }).from(media).where(eq(media.userId, userId)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
scheduledPosts: Number(postCounts[0]?.scheduled ?? 0),
|
||||||
|
scheduledPostsLastMonth: Number(postCounts[0]?.scheduledLastMonth ?? 0),
|
||||||
|
connectedPages: Number(pageCount[0]?.total ?? 0),
|
||||||
|
aiTextsGenerated: Number(generationCounts[0]?.total ?? 0),
|
||||||
|
aiTextsYesterday: Number(generationCounts[0]?.yesterday ?? 0),
|
||||||
|
mediaStored: Number(mediaCount[0]?.total ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Social Pages
|
// Social Pages
|
||||||
async getSocialPages(userId: string): Promise<SocialPage[]> {
|
async getSocialPages(userId: string): Promise<SocialPage[]> {
|
||||||
const pages = await db.select().from(socialPages).where(eq(socialPages.userId, userId));
|
const pages = await db.select().from(socialPages).where(eq(socialPages.userId, userId));
|
||||||
// Déchiffrer les tokens pour chaque page
|
return pages.map(page => decryptPageTokens(page));
|
||||||
return pages.map(page => ({
|
}
|
||||||
...page,
|
|
||||||
accessToken: decrypt(page.accessToken)
|
/**
|
||||||
}));
|
* Toutes les pages de l'instance, pour les vues d'administration.
|
||||||
|
* Remplace un `getAllUsers()` suivi d'une requête par utilisateur.
|
||||||
|
*/
|
||||||
|
async getAllSocialPages(): Promise<SocialPage[]> {
|
||||||
|
const pages = await db.select().from(socialPages);
|
||||||
|
return pages.map(page => decryptPageTokens(page));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSocialPage(id: string): Promise<SocialPage | undefined> {
|
async getSocialPage(id: string): Promise<SocialPage | undefined> {
|
||||||
const [page] = await db.select().from(socialPages).where(eq(socialPages.id, id));
|
const [page] = await db.select().from(socialPages).where(eq(socialPages.id, id));
|
||||||
if (page) {
|
return page ? decryptPageTokens(page) : undefined;
|
||||||
// Déchiffrer le token
|
|
||||||
page.accessToken = decrypt(page.accessToken);
|
|
||||||
}
|
|
||||||
return page || undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createSocialPage(page: InsertSocialPage): Promise<SocialPage> {
|
async createSocialPage(page: InsertSocialPage): Promise<SocialPage> {
|
||||||
// Chiffrer le token avant stockage
|
// Chiffrer les tokens avant stockage
|
||||||
const encryptedPage = {
|
const encryptedPage = {
|
||||||
...page,
|
...page,
|
||||||
accessToken: encrypt(page.accessToken)
|
accessToken: encrypt(page.accessToken),
|
||||||
|
...(page.refreshToken ? { refreshToken: encrypt(page.refreshToken) } : {}),
|
||||||
};
|
};
|
||||||
const [newPage] = await db.insert(socialPages).values(encryptedPage).returning();
|
const [newPage] = await db.insert(socialPages).values(encryptedPage).returning();
|
||||||
// Retourner avec le token déchiffré
|
return decryptPageTokens(newPage);
|
||||||
newPage.accessToken = decrypt(newPage.accessToken);
|
|
||||||
return newPage;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateSocialPage(id: string, page: Partial<SocialPage>): Promise<SocialPage> {
|
async updateSocialPage(id: string, page: Partial<SocialPage>): Promise<SocialPage> {
|
||||||
// Chiffrer le token si présent dans la mise à jour
|
// Chiffrer les tokens présents dans la mise à jour
|
||||||
const updateData = page.accessToken
|
const updateData: Partial<SocialPage> = { ...page };
|
||||||
? { ...page, accessToken: encrypt(page.accessToken) }
|
if (page.accessToken) {
|
||||||
: page;
|
updateData.accessToken = encrypt(page.accessToken);
|
||||||
|
}
|
||||||
|
if (page.refreshToken) {
|
||||||
|
updateData.refreshToken = encrypt(page.refreshToken);
|
||||||
|
}
|
||||||
const [updated] = await db.update(socialPages).set(updateData).where(eq(socialPages.id, id)).returning();
|
const [updated] = await db.update(socialPages).set(updateData).where(eq(socialPages.id, id)).returning();
|
||||||
// Retourner avec le token déchiffré
|
return decryptPageTokens(updated);
|
||||||
updated.accessToken = decrypt(updated.accessToken);
|
|
||||||
return updated;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteSocialPage(id: string): Promise<void> {
|
async deleteSocialPage(id: string): Promise<void> {
|
||||||
@@ -204,8 +330,37 @@ export class DatabaseStorage implements IStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Media
|
// Media
|
||||||
async getMedia(userId: string): Promise<Media[]> {
|
/**
|
||||||
return await db.select().from(media).where(eq(media.userId, userId)).orderBy(desc(media.createdAt));
|
* Médias d'un utilisateur, du plus récent au plus ancien.
|
||||||
|
* `limit` sert aux écrans qui n'en affichent qu'une poignée : sans lui, la
|
||||||
|
* médiathèque entière transitait pour en montrer cinq.
|
||||||
|
*/
|
||||||
|
async getMedia(userId: string, options: { limit?: number; offset?: number } = {}): Promise<Media[]> {
|
||||||
|
let query = db
|
||||||
|
.select()
|
||||||
|
.from(media)
|
||||||
|
.where(eq(media.userId, userId))
|
||||||
|
.orderBy(desc(media.createdAt))
|
||||||
|
.$dynamic();
|
||||||
|
|
||||||
|
if (options.limit !== undefined) query = query.limit(options.limit);
|
||||||
|
if (options.offset !== undefined) query = query.offset(options.offset);
|
||||||
|
|
||||||
|
return await query;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMediaByIds(ids: string[]): Promise<Media[]> {
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
return await db.select().from(media).where(inArray(media.id, ids));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total de médias, pour que la pagination sache où elle s'arrête. */
|
||||||
|
async getMediaCount(userId: string): Promise<number> {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ total: sql<string>`count(*)` })
|
||||||
|
.from(media)
|
||||||
|
.where(eq(media.userId, userId));
|
||||||
|
return Number(row?.total ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMediaById(id: string): Promise<Media | undefined> {
|
async getMediaById(id: string): Promise<Media | undefined> {
|
||||||
@@ -241,14 +396,16 @@ export class DatabaseStorage implements IStorage {
|
|||||||
if (!post) return undefined;
|
if (!post) return undefined;
|
||||||
|
|
||||||
const postMediaLinks = await this.getPostMedia(id);
|
const postMediaLinks = await this.getPostMedia(id);
|
||||||
const mediaItems: Media[] = [];
|
|
||||||
|
|
||||||
for (const link of postMediaLinks) {
|
// Une seule requête pour tous les médias, puis remise dans l'ordre
|
||||||
const mediaItem = await this.getMediaById(link.mediaId);
|
// d'affichage : une requête par média rendait le coût proportionnel au
|
||||||
if (mediaItem) {
|
// nombre de visuels du post.
|
||||||
mediaItems.push(mediaItem);
|
const byId = new Map(
|
||||||
}
|
(await this.getMediaByIds(postMediaLinks.map(link => link.mediaId))).map(item => [item.id, item])
|
||||||
}
|
);
|
||||||
|
const mediaItems = postMediaLinks
|
||||||
|
.map(link => byId.get(link.mediaId))
|
||||||
|
.filter((item): item is Media => item !== undefined);
|
||||||
|
|
||||||
return { post, media: mediaItems };
|
return { post, media: mediaItems };
|
||||||
}
|
}
|
||||||
@@ -322,67 +479,42 @@ export class DatabaseStorage implements IStorage {
|
|||||||
|
|
||||||
// Scheduled Posts
|
// Scheduled Posts
|
||||||
async getScheduledPosts(userId: string, startDate?: Date, endDate?: Date): Promise<any[]> {
|
async getScheduledPosts(userId: string, startDate?: Date, endDate?: Date): Promise<any[]> {
|
||||||
let query = db
|
return this.queryScheduledPosts(eq(posts.userId, userId), startDate, endDate);
|
||||||
.select()
|
|
||||||
.from(scheduledPosts)
|
|
||||||
.innerJoin(posts, eq(scheduledPosts.postId, posts.id))
|
|
||||||
.leftJoin(socialPages, eq(scheduledPosts.pageId, socialPages.id))
|
|
||||||
.where(eq(posts.userId, userId));
|
|
||||||
|
|
||||||
if (startDate && endDate) {
|
|
||||||
const results = await query;
|
|
||||||
return results
|
|
||||||
.filter(r => {
|
|
||||||
const scheduledAt = new Date(r.scheduled_posts.scheduledAt);
|
|
||||||
return scheduledAt >= startDate && scheduledAt <= endDate;
|
|
||||||
})
|
|
||||||
.map(r => ({
|
|
||||||
...r.scheduled_posts,
|
|
||||||
post: r.posts,
|
|
||||||
page: r.social_pages,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await query;
|
|
||||||
return results.map(r => ({
|
|
||||||
...r.scheduled_posts,
|
|
||||||
post: r.posts,
|
|
||||||
page: r.social_pages,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getScheduledPostsByPages(pageIds: string[], startDate?: Date, endDate?: Date): Promise<any[]> {
|
async getScheduledPostsByPages(pageIds: string[], startDate?: Date, endDate?: Date): Promise<any[]> {
|
||||||
if (pageIds.length === 0) {
|
if (pageIds.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
return this.queryScheduledPosts(inArray(scheduledPosts.pageId, pageIds), startDate, endDate);
|
||||||
|
}
|
||||||
|
|
||||||
let query = db
|
/**
|
||||||
.select()
|
* Publications planifiées, avec le post et la page auxquels elles se rapportent.
|
||||||
|
*
|
||||||
|
* La période est filtrée en SQL : la charger entière pour la filtrer en
|
||||||
|
* mémoire revenait à lire tout l'historique à chaque affichage du calendrier.
|
||||||
|
*/
|
||||||
|
private async queryScheduledPosts(
|
||||||
|
scope: SQL,
|
||||||
|
startDate?: Date,
|
||||||
|
endDate?: Date
|
||||||
|
): Promise<any[]> {
|
||||||
|
const conditions: SQL[] = [scope];
|
||||||
|
if (startDate) conditions.push(gte(scheduledPosts.scheduledAt, startDate));
|
||||||
|
if (endDate) conditions.push(lte(scheduledPosts.scheduledAt, endDate));
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select(SCHEDULED_POST_COLUMNS)
|
||||||
.from(scheduledPosts)
|
.from(scheduledPosts)
|
||||||
.innerJoin(posts, eq(scheduledPosts.postId, posts.id))
|
.innerJoin(posts, eq(scheduledPosts.postId, posts.id))
|
||||||
.leftJoin(socialPages, eq(scheduledPosts.pageId, socialPages.id))
|
.leftJoin(socialPages, eq(scheduledPosts.pageId, socialPages.id))
|
||||||
.where(inArray(scheduledPosts.pageId, pageIds));
|
.where(conditions.length === 1 ? conditions[0] : and(...conditions))
|
||||||
|
.orderBy(desc(scheduledPosts.scheduledAt));
|
||||||
|
|
||||||
if (startDate && endDate) {
|
// La jointure externe renvoie un objet aux champs nuls quand la page a été
|
||||||
const results = await query;
|
// supprimée ; les appelants attendent `page: null` dans ce cas.
|
||||||
return results
|
return rows.map(row => ({ ...row, page: row.page?.id ? row.page : null }));
|
||||||
.filter(r => {
|
|
||||||
const scheduledAt = new Date(r.scheduled_posts.scheduledAt);
|
|
||||||
return scheduledAt >= startDate && scheduledAt <= endDate;
|
|
||||||
})
|
|
||||||
.map(r => ({
|
|
||||||
...r.scheduled_posts,
|
|
||||||
post: r.posts,
|
|
||||||
page: r.social_pages,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await query;
|
|
||||||
return results.map(r => ({
|
|
||||||
...r.scheduled_posts,
|
|
||||||
post: r.posts,
|
|
||||||
page: r.social_pages,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getScheduledPost(id: string): Promise<ScheduledPost | undefined> {
|
async getScheduledPost(id: string): Promise<ScheduledPost | undefined> {
|
||||||
@@ -422,6 +554,40 @@ export class DatabaseStorage implements IStorage {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publications déjà envoyées à TikTok dont le statut final n'est pas encore
|
||||||
|
* connu (l'API répond de façon asynchrone via un publish_id).
|
||||||
|
*/
|
||||||
|
async getScheduledPostsAwaitingPublishStatus(): Promise<ScheduledPost[]> {
|
||||||
|
return await db
|
||||||
|
.select()
|
||||||
|
.from(scheduledPosts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
isNotNull(scheduledPosts.publishId),
|
||||||
|
notInArray(scheduledPosts.publishStatus, TERMINAL_PUBLISH_STATUSES)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reels dont la génération est en cours. Filtré en SQL : la route chargeait
|
||||||
|
* tous les posts de l'utilisateur pour n'en garder qu'une poignée, toutes les
|
||||||
|
* trois secondes.
|
||||||
|
*/
|
||||||
|
async getOngoingReelPosts(userId: string): Promise<Post[]> {
|
||||||
|
return await db
|
||||||
|
.select()
|
||||||
|
.from(posts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(posts.userId, userId),
|
||||||
|
inArray(posts.generationStatus, ['processing', 'pending'])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(posts.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
// AI Generations
|
// AI Generations
|
||||||
async getAiGenerations(userId: string): Promise<AiGeneration[]> {
|
async getAiGenerations(userId: string): Promise<AiGeneration[]> {
|
||||||
return await db.select().from(aiGenerations).where(eq(aiGenerations.userId, userId)).orderBy(desc(aiGenerations.createdAt));
|
return await db.select().from(aiGenerations).where(eq(aiGenerations.userId, userId)).orderBy(desc(aiGenerations.createdAt));
|
||||||
@@ -571,6 +737,70 @@ export class DatabaseStorage implements IStorage {
|
|||||||
return config || undefined;
|
return config || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Facebook Config (application développeur, globale à l'instance)
|
||||||
|
async getFacebookConfig(): Promise<FacebookConfig | undefined> {
|
||||||
|
const [config] = await db.select().from(facebookConfig).limit(1);
|
||||||
|
if (!config) return undefined;
|
||||||
|
return { ...config, appSecret: decrypt(config.appSecret) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertFacebookConfig(data: Partial<InsertFacebookConfig>): Promise<FacebookConfig> {
|
||||||
|
const values: Partial<FacebookConfig> = { ...data };
|
||||||
|
if (data.appSecret) {
|
||||||
|
values.appSecret = encrypt(data.appSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existing] = await db.select().from(facebookConfig).limit(1);
|
||||||
|
if (existing) {
|
||||||
|
const [updated] = await db.update(facebookConfig)
|
||||||
|
.set({ ...values, updatedAt: new Date() })
|
||||||
|
.where(eq(facebookConfig.id, existing.id))
|
||||||
|
.returning();
|
||||||
|
return { ...updated, appSecret: decrypt(updated.appSecret) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!values.appId || !values.appSecret) {
|
||||||
|
throw new Error("L'App ID et l'App Secret Facebook sont requis");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [created] = await db.insert(facebookConfig)
|
||||||
|
.values({ appId: values.appId, appSecret: values.appSecret })
|
||||||
|
.returning();
|
||||||
|
return { ...created, appSecret: decrypt(created.appSecret) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// TikTok Config
|
||||||
|
async getTiktokConfig(): Promise<TiktokConfig | undefined> {
|
||||||
|
const [config] = await db.select().from(tiktokConfig).limit(1);
|
||||||
|
if (!config) return undefined;
|
||||||
|
return { ...config, clientSecret: decrypt(config.clientSecret) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertTiktokConfig(data: Partial<InsertTiktokConfig>): Promise<TiktokConfig> {
|
||||||
|
const values: Partial<TiktokConfig> = { ...data };
|
||||||
|
if (data.clientSecret) {
|
||||||
|
values.clientSecret = encrypt(data.clientSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existing] = await db.select().from(tiktokConfig).limit(1);
|
||||||
|
if (existing) {
|
||||||
|
const [updated] = await db.update(tiktokConfig)
|
||||||
|
.set({ ...values, updatedAt: new Date() })
|
||||||
|
.where(eq(tiktokConfig.id, existing.id))
|
||||||
|
.returning();
|
||||||
|
return { ...updated, clientSecret: decrypt(updated.clientSecret) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!values.clientKey || !values.clientSecret) {
|
||||||
|
throw new Error("Le client key et le client secret TikTok sont requis");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [created] = await db.insert(tiktokConfig)
|
||||||
|
.values({ clientKey: values.clientKey, clientSecret: values.clientSecret })
|
||||||
|
.returning();
|
||||||
|
return { ...created, clientSecret: decrypt(created.clientSecret) };
|
||||||
|
}
|
||||||
|
|
||||||
async upsertAppConfig(data: Partial<Pick<AppConfig, 'externalApiKey' | 'geminiApiKey'>>): Promise<AppConfig> {
|
async upsertAppConfig(data: Partial<Pick<AppConfig, 'externalApiKey' | 'geminiApiKey'>>): Promise<AppConfig> {
|
||||||
const existing = await this.getAppConfig();
|
const existing = await this.getAppConfig();
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* URL publique de l'application.
|
||||||
|
*
|
||||||
|
* Les flux OAuth (Facebook, TikTok) exigent une URI de redirection *exacte* :
|
||||||
|
* celle envoyée au moment de l'autorisation doit être identique à celle
|
||||||
|
* envoyée lors de l'échange du code, et déclarée telle quelle chez le
|
||||||
|
* fournisseur. Se rabattre en silence sur `http://localhost:5555` quand
|
||||||
|
* `APP_URL` n'est pas défini produit une URI que Facebook refuse — et
|
||||||
|
* l'erreur n'apparaît qu'au moment de connecter une page.
|
||||||
|
*
|
||||||
|
* On préfère donc, à défaut d'`APP_URL`, reconstruire l'URL à partir de la
|
||||||
|
* requête entrante : derrière Nginx, les en-têtes `X-Forwarded-Proto` et
|
||||||
|
* `X-Forwarded-Host` portent le domaine public réel (`trust proxy` est activé
|
||||||
|
* dans `server/index.ts`). Le repli localhost ne sert plus qu'au développement
|
||||||
|
* local, hors requête HTTP.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request } from 'express';
|
||||||
|
|
||||||
|
const DEV_FALLBACK = 'http://localhost:5555';
|
||||||
|
|
||||||
|
/** Premier élément d'un en-tête potentiellement chaîné (« a, b, c »). */
|
||||||
|
function firstHeaderValue(value: string | undefined): string | undefined {
|
||||||
|
const first = value?.split(',')[0]?.trim();
|
||||||
|
return first || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base publique sans slash final (« https://exemple.fr »).
|
||||||
|
* `APP_URL` prime toujours ; sinon la requête fait foi.
|
||||||
|
*/
|
||||||
|
export function resolvePublicBaseUrl(req?: Request): string {
|
||||||
|
const configured = process.env.APP_URL?.trim();
|
||||||
|
if (configured) {
|
||||||
|
return configured.replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req) {
|
||||||
|
const host = firstHeaderValue(req.get('x-forwarded-host')) || req.get('host');
|
||||||
|
if (host) {
|
||||||
|
const protocol = firstHeaderValue(req.get('x-forwarded-proto')) || req.protocol || 'http';
|
||||||
|
return `${protocol}://${host}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEV_FALLBACK;
|
||||||
|
}
|
||||||
+94
-1
@@ -4,7 +4,7 @@ import { relations } from "drizzle-orm";
|
|||||||
import { createInsertSchema } from "drizzle-zod";
|
import { createInsertSchema } from "drizzle-zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const platformEnum = pgEnum("platform", ["facebook", "instagram"]);
|
export const platformEnum = pgEnum("platform", ["facebook", "instagram", "tiktok"]);
|
||||||
export const postTypeEnum = pgEnum("post_type", ["feed", "story", "both", "reel"]);
|
export const postTypeEnum = pgEnum("post_type", ["feed", "story", "both", "reel"]);
|
||||||
export const postStatusEnum = pgEnum("post_status", ["draft", "scheduled", "published", "failed"]);
|
export const postStatusEnum = pgEnum("post_status", ["draft", "scheduled", "published", "failed"]);
|
||||||
export const mediaTypeEnum = pgEnum("media_type", ["image", "video"]);
|
export const mediaTypeEnum = pgEnum("media_type", ["image", "video"]);
|
||||||
@@ -26,6 +26,7 @@ export const socialPages = pgTable("social_pages", {
|
|||||||
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
platform: platformEnum("platform").notNull(),
|
platform: platformEnum("platform").notNull(),
|
||||||
|
// Identifiant natif de la destination : page_id (Facebook) ou open_id (TikTok)
|
||||||
pageId: text("page_id").notNull(),
|
pageId: text("page_id").notNull(),
|
||||||
pageName: text("page_name").notNull(),
|
pageName: text("page_name").notNull(),
|
||||||
accessToken: text("access_token").notNull(),
|
accessToken: text("access_token").notNull(),
|
||||||
@@ -37,6 +38,22 @@ export const socialPages = pgTable("social_pages", {
|
|||||||
tokenStatus: tokenStatusEnum("token_status").default("valid"),
|
tokenStatus: tokenStatusEnum("token_status").default("valid"),
|
||||||
lastTokenCheck: timestamp("last_token_check"),
|
lastTokenCheck: timestamp("last_token_check"),
|
||||||
|
|
||||||
|
// Jeton utilisateur longue durée (~60 j) obtenu via OAuth Facebook, chiffré au
|
||||||
|
// repos. C'est lui qui permet de régénérer un token de page sans intervention
|
||||||
|
// humaine : sans lui, un token révoqué impose une reconnexion manuelle.
|
||||||
|
userAccessToken: text("user_access_token"),
|
||||||
|
userTokenExpiresAt: timestamp("user_token_expires_at"),
|
||||||
|
// Dernière erreur rencontrée lors du contrôle du token, affichée dans l'UI
|
||||||
|
// pour que l'utilisateur sache quoi faire (reconnecter, réautoriser…).
|
||||||
|
tokenError: text("token_error"),
|
||||||
|
|
||||||
|
// OAuth TikTok : l'access token n'est valable que 24h, il doit être renouvelé
|
||||||
|
// avec le refresh token (valable 1 an) avant chaque publication.
|
||||||
|
refreshToken: text("refresh_token"),
|
||||||
|
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
||||||
|
scopes: text("scopes"),
|
||||||
|
avatarUrl: text("avatar_url"),
|
||||||
|
|
||||||
createdAt: timestamp("created_at").defaultNow(),
|
createdAt: timestamp("created_at").defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,6 +102,7 @@ export const insertSocialPageSchema = createInsertSchema(socialPages).omit({
|
|||||||
createdAt: true,
|
createdAt: true,
|
||||||
tokenStatus: true, // Managed by System
|
tokenStatus: true, // Managed by System
|
||||||
lastTokenCheck: true, // Managed by System
|
lastTokenCheck: true, // Managed by System
|
||||||
|
tokenError: true, // Managed by System
|
||||||
});
|
});
|
||||||
|
|
||||||
export const insertPostAnalyticsSchema = createInsertSchema(postAnalytics).omit({
|
export const insertPostAnalyticsSchema = createInsertSchema(postAnalytics).omit({
|
||||||
@@ -133,6 +151,30 @@ export const openrouterConfig = pgTable("openrouter_config", {
|
|||||||
updatedAt: timestamp("updated_at").defaultNow(),
|
updatedAt: timestamp("updated_at").defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Application développeur TikTok (globale) : un seul client_key pour toute
|
||||||
|
// l'instance, chaque magasin autorise ensuite son propre compte via OAuth.
|
||||||
|
/**
|
||||||
|
* Application développeur Facebook (globale à l'instance). Elle rend possible le
|
||||||
|
* renouvellement automatique des tokens de page : sans app id / app secret, on
|
||||||
|
* ne peut ni échanger un code OAuth, ni prolonger un jeton, ni interroger
|
||||||
|
* /debug_token.
|
||||||
|
*/
|
||||||
|
export const facebookConfig = pgTable("facebook_config", {
|
||||||
|
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
|
appId: text("app_id").notNull(),
|
||||||
|
appSecret: text("app_secret").notNull(),
|
||||||
|
createdAt: timestamp("created_at").defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at").defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tiktokConfig = pgTable("tiktok_config", {
|
||||||
|
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
|
clientKey: text("client_key").notNull(),
|
||||||
|
clientSecret: text("client_secret").notNull(),
|
||||||
|
createdAt: timestamp("created_at").defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at").defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
export const media = pgTable("media", {
|
export const media = pgTable("media", {
|
||||||
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
@@ -143,6 +185,9 @@ export const media = pgTable("media", {
|
|||||||
facebookFeedUrl: text("facebook_feed_url"),
|
facebookFeedUrl: text("facebook_feed_url"),
|
||||||
instagramFeedUrl: text("instagram_feed_url"),
|
instagramFeedUrl: text("instagram_feed_url"),
|
||||||
instagramStoryUrl: text("instagram_story_url"),
|
instagramStoryUrl: text("instagram_story_url"),
|
||||||
|
// Image extraite d'une vidéo, conservée après la suppression du fichier source
|
||||||
|
// pour que l'historique reste illustré.
|
||||||
|
thumbnailUrl: text("thumbnail_url"),
|
||||||
fileName: text("file_name").notNull(),
|
fileName: text("file_name").notNull(),
|
||||||
fileSize: integer("file_size").notNull(),
|
fileSize: integer("file_size").notNull(),
|
||||||
createdAt: timestamp("created_at").defaultNow(),
|
createdAt: timestamp("created_at").defaultNow(),
|
||||||
@@ -180,6 +225,11 @@ export const scheduledPosts = pgTable("scheduled_posts", {
|
|||||||
publishedAt: timestamp("published_at"),
|
publishedAt: timestamp("published_at"),
|
||||||
externalPostId: text("external_post_id"),
|
externalPostId: text("external_post_id"),
|
||||||
error: text("error"),
|
error: text("error"),
|
||||||
|
|
||||||
|
// TikTok publie de façon asynchrone : l'upload rend un publish_id, et le
|
||||||
|
// post_id définitif n'est connu qu'après interrogation du statut.
|
||||||
|
publishId: text("publish_id"),
|
||||||
|
publishStatus: text("publish_status"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const aiGenerations = pgTable("ai_generations", {
|
export const aiGenerations = pgTable("ai_generations", {
|
||||||
@@ -406,6 +456,32 @@ export const updateCloudinaryConfigSchema = insertCloudinaryConfigSchema.partial
|
|||||||
apiSecret: true,
|
apiSecret: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const insertFacebookConfigSchema = createInsertSchema(facebookConfig).omit({
|
||||||
|
id: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
}).extend({
|
||||||
|
appId: z.string().trim().min(1, "L'App ID ne peut pas être vide"),
|
||||||
|
appSecret: z.string().trim().min(1, "L'App Secret ne peut pas être vide"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateFacebookConfigSchema = insertFacebookConfigSchema.partial({
|
||||||
|
appSecret: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const insertTiktokConfigSchema = createInsertSchema(tiktokConfig).omit({
|
||||||
|
id: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
}).extend({
|
||||||
|
clientKey: z.string().trim().min(1, "Le client key ne peut pas être vide"),
|
||||||
|
clientSecret: z.string().trim().min(1, "Le client secret ne peut pas être vide"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateTiktokConfigSchema = insertTiktokConfigSchema.partial({
|
||||||
|
clientSecret: true,
|
||||||
|
});
|
||||||
|
|
||||||
export const insertUserPagePermissionSchema = createInsertSchema(userPagePermissions).omit({
|
export const insertUserPagePermissionSchema = createInsertSchema(userPagePermissions).omit({
|
||||||
id: true,
|
id: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
@@ -465,4 +541,21 @@ export type InsertAudioTrack = z.infer<typeof insertAudioTrackSchema>;
|
|||||||
|
|
||||||
export type AppConfig = typeof appConfig.$inferSelect;
|
export type AppConfig = typeof appConfig.$inferSelect;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page telle qu'elle est renvoyée au navigateur : les jetons restent au serveur,
|
||||||
|
* `autoRenew` dit simplement si la page sait se renouveler toute seule.
|
||||||
|
*/
|
||||||
|
export type ClientSocialPage = Omit<
|
||||||
|
SocialPage,
|
||||||
|
'accessToken' | 'userAccessToken' | 'refreshToken'
|
||||||
|
> & { autoRenew: boolean };
|
||||||
|
|
||||||
|
export type FacebookConfig = typeof facebookConfig.$inferSelect;
|
||||||
|
export type InsertFacebookConfig = z.infer<typeof insertFacebookConfigSchema>;
|
||||||
|
export type UpdateFacebookConfig = z.infer<typeof updateFacebookConfigSchema>;
|
||||||
|
|
||||||
|
export type TiktokConfig = typeof tiktokConfig.$inferSelect;
|
||||||
|
export type InsertTiktokConfig = z.infer<typeof insertTiktokConfigSchema>;
|
||||||
|
export type UpdateTiktokConfig = z.infer<typeof updateTiktokConfigSchema>;
|
||||||
|
|
||||||
|
|
||||||
Reference in new issue
Block a user