From 94e961d0f1c6dd7367c2e43350b269c045010e8e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 06:39:16 +0000 Subject: [PATCH] =?UTF-8?q?perf(api):=20all=C3=A9ger=20les=20requ=C3=AAtes?= =?UTF-8?q?=20du=20calendrier,=20des=20stats=20et=20de=20la=20m=C3=A9diath?= =?UTF-8?q?=C3=A8que?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois défauts se cumulaient : des réponses bien plus larges que nécessaire, aucun index en base, et des boucles de requêtes là où une seule suffisait. Réponses - /api/scheduled-posts renvoyait la ligne `social_pages` entière, jetons d'accès Facebook et TikTok compris, jusque dans le navigateur. Seules les colonnes réellement affichées sont désormais sélectionnées. - Le filtre de dates est poussé en SQL. Il était appliqué en JavaScript après avoir chargé tout l'historique — que le client ne bornait jamais. Le calendrier demande maintenant les six semaines affichées, le sélecteur de date les créneaux à venir, le bloc « publications récentes » ses 90 jours. - /api/media accepte `limit` et `offset` (sans eux, réponse inchangée : des écrans doivent retrouver un média par son identifiant). La médiathèque pagine côté serveur au lieu de tout charger pour afficher cinq vignettes, et l'écran de création ne demande que les douze médias qu'il propose. Le total voyage dans l'en-tête X-Total-Count pour garder le compteur juste. Index - server/migrate.ts est le seul script exécuté au démarrage et n'en créait aucun ; ceux de migration-docker.sql ne servaient à rien, ce fichier n'étant monté nulle part. Postgres n'indexant pas les clés étrangères, le calendrier, l'historique et le scheduler parcouraient les tables entières. S'y ajoutent deux index partiels pour les boucles de fond : publications en attente et suivi asynchrone TikTok. Requêtes - /api/stats lisait quatre tables entières pour en compter les lignes : c'est désormais quatre COUNT filtrés. - Les vues d'administration listaient les utilisateurs puis interrogeaient les pages de chacun ; une seule requête les remplace. - getPostWithMedia et l'API externe chargeaient les médias un par un. - /api/reels/ongoing chargeait tous les posts de l'utilisateur pour n'en garder que ceux en cours de génération, toutes les trois secondes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Qns6Gcpd95bCHWUxySh4Ew --- client/src/components/calendar-view.tsx | 23 +- client/src/components/media-upload.tsx | 52 +++- client/src/components/recent-publications.tsx | 17 +- client/src/lib/queryClient.ts | 29 ++- client/src/pages/mobile/new-post.tsx | 20 +- client/src/pages/new-post.tsx | 20 +- server/migrate.ts | 47 ++++ server/routes.ts | 82 +++--- server/routes/external.ts | 29 +-- server/routes/reels.ts | 5 +- server/storage.ts | 242 +++++++++++++----- 11 files changed, 431 insertions(+), 135 deletions(-) diff --git a/client/src/components/calendar-view.tsx b/client/src/components/calendar-view.tsx index 07a7837..2e5c72f 100644 --- a/client/src/components/calendar-view.tsx +++ b/client/src/components/calendar-view.tsx @@ -13,6 +13,24 @@ import { useMediaQuery } from "@/hooks/use-media-query"; import { PreviewModal } from "@/components/preview-modal"; 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() { const [currentDate, setCurrentDate] = useState(new Date()); const [editDialogOpen, setEditDialogOpen] = useState(false); @@ -22,8 +40,11 @@ export default function CalendarView() { const { toast } = useToast(); 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({ - queryKey: ["/api/scheduled-posts"], + queryKey: ["/api/scheduled-posts", range], refetchInterval: 30000, // Auto-refresh every 30 seconds }); diff --git a/client/src/components/media-upload.tsx b/client/src/components/media-upload.tsx index dc284bc..8df5022 100644 --- a/client/src/components/media-upload.tsx +++ b/client/src/components/media-upload.tsx @@ -2,25 +2,57 @@ import { useState, useCallback, useRef, useEffect } from "react"; import { useDropzone } from "react-dropzone"; import { Button } from "@/components/ui/button"; 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 { MediaThumbnail } from "@/components/media-thumbnail"; import { CloudUpload, Image as ImageIcon, Video, X, Upload, Loader2, ZoomIn, Camera } from "lucide-react"; import { useToast } from "@/hooks/use-toast"; import { SiFacebook, SiInstagram } from "react-icons/si"; +/** Nombre de vignettes chargées par page. */ +const MEDIA_PAGE_SIZE = 15; + export default function MediaUpload() { const { toast } = useToast(); const [selectedFile, setSelectedFile] = useState(null); - const [visibleCount, setVisibleCount] = useState(5); const [zoomImage, setZoomImage] = useState(null); const loadMoreRef = useRef(null); const cameraInputRef = useRef(null); - const { data: mediaList } = useQuery({ - queryKey: ["/api/media"], + // Pagination côté serveur : la médiathèque entière transitait pour n'afficher + // 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 uploadMutation = useMutation({ @@ -163,8 +195,8 @@ export default function MediaUpload() { useEffect(() => { const observer = new IntersectionObserver( (entries) => { - if (entries[0].isIntersecting && mediaList && visibleCount < (mediaList as any[]).length) { - setVisibleCount(prev => Math.min(prev + 5, (mediaList as any[]).length)); + if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); } }, { threshold: 0.1 } @@ -179,7 +211,7 @@ export default function MediaUpload() { observer.unobserve(loadMoreRef.current); } }; - }, [mediaList, visibleCount]); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); return (
@@ -268,13 +300,13 @@ export default function MediaUpload() {

Fichiers téléchargés - {(mediaList as any[])?.length || 0} média(s) + {totalMedia} média(s)

- {(mediaList as any[])?.slice(0, visibleCount).map((media: any) => ( + {mediaList.map((media: any) => (
setSelectedFile(media)} @@ -316,7 +348,7 @@ export default function MediaUpload() {
{/* Élément sentinelle pour le scroll infini */} - {(mediaList && visibleCount < (mediaList as any[]).length) ? ( + {hasNextPage ? (
diff --git a/client/src/components/recent-publications.tsx b/client/src/components/recent-publications.tsx index 6b915ad..75814e1 100644 --- a/client/src/components/recent-publications.tsx +++ b/client/src/components/recent-publications.tsx @@ -29,6 +29,16 @@ import { Trash2 } from "lucide-react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; 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() { const [previewModalOpen, setPreviewModalOpen] = useState(false); const [previewData, setPreviewData] = useState<{ postText: string; mediaIds: string[]; allMedia: Media[] }>({ postText: '', mediaIds: [], allMedia: [] }); @@ -37,8 +47,13 @@ export default function RecentPublications() { const { toast } = useToast(); 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({ - queryKey: ['/api/scheduled-posts'], + queryKey: ['/api/scheduled-posts', { startDate: startOfDay(since).toISOString() }], }); // Filter and sort attempted posts (scheduled in the past) diff --git a/client/src/lib/queryClient.ts b/client/src/lib/queryClient.ts index bdece4e..b94a919 100644 --- a/client/src/lib/queryClient.ts +++ b/client/src/lib/queryClient.ts @@ -69,13 +69,40 @@ export async function apiRequest( 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)) { + 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"; export const getQueryFn: (options: { on401: UnauthorizedBehavior; }) => QueryFunction = ({ on401: unauthorizedBehavior }) => async ({ queryKey }) => { - const url = queryKey.join("/") as string; + const url = urlFromQueryKey(queryKey); const res = await fetch(url, { credentials: "include", }); diff --git a/client/src/pages/mobile/new-post.tsx b/client/src/pages/mobile/new-post.tsx index 8ed87f1..992d006 100644 --- a/client/src/pages/mobile/new-post.tsx +++ b/client/src/pages/mobile/new-post.tsx @@ -104,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() { const [sidebarOpen, setSidebarOpen] = useState(false); const [, navigate] = useLocation(); @@ -127,8 +137,10 @@ export default function NewPostMobile() { }); 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({ - queryKey: ['/api/media'], + queryKey: ['/api/media', { limit: RECENT_MEDIA_COUNT }], }); const mediaList = useMemo(() => { @@ -142,11 +154,13 @@ export default function NewPostMobile() { const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0; return dateB - dateA; }) - .slice(0, 12); + .slice(0, RECENT_MEDIA_COUNT); }, [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({ - queryKey: ['/api/scheduled-posts'], + queryKey: ['/api/scheduled-posts', { startDate: startOfToday().toISOString() }], }); const uploadMutation = useMutation({ diff --git a/client/src/pages/new-post.tsx b/client/src/pages/new-post.tsx index c293d09..0dd2c0d 100644 --- a/client/src/pages/new-post.tsx +++ b/client/src/pages/new-post.tsx @@ -102,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() { const [sidebarOpen, setSidebarOpen] = useState(false); const [, navigate] = useLocation(); @@ -124,8 +134,10 @@ export default function NewPost() { }); 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({ - queryKey: ['/api/media'], + queryKey: ['/api/media', { limit: RECENT_MEDIA_COUNT }], }); // Afficher seulement les 12 derniers médias triés par date décroissante @@ -138,11 +150,13 @@ export default function NewPost() { const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0; return dateB - dateA; // Tri décroissant (plus récent en premier) }) - .slice(0, 12); + .slice(0, RECENT_MEDIA_COUNT); }, [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({ - queryKey: ['/api/scheduled-posts'], + queryKey: ['/api/scheduled-posts', { startDate: startOfToday().toISOString() }], }); const uploadMutation = useMutation({ diff --git a/server/migrate.ts b/server/migrate.ts index e005eed..3a9aa85 100644 --- a/server/migrate.ts +++ b/server/migrate.ts @@ -213,6 +213,53 @@ export async function migrate() { 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."); } catch (error) { console.error("[Migration] Error during migration:", error); diff --git a/server/routes.ts b/server/routes.ts index 9c7f566..44b02b4 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -111,6 +111,20 @@ function requireAdmin(req: Request, res: Response, next: NextFunction) { 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 { @@ -544,39 +558,28 @@ export async function registerRoutes(app: Express): Promise { const user = req.user as User; 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) const now = new Date(); const yesterday = new Date(now); yesterday.setDate(yesterday.getDate() - 1); yesterday.setHours(0, 0, 0, 0); + const today = new Date(yesterday); + today.setDate(today.getDate() + 1); + const lastMonth = new Date(now); lastMonth.setMonth(lastMonth.getMonth() - 1); - // Textes IA générés hier - const aiTextsYesterday = aiGenerations.filter(gen => { - if (!gen.createdAt) return false; - const genDate = new Date(gen.createdAt); - genDate.setHours(0, 0, 0, 0); - return genDate.getTime() === yesterday.getTime(); - }).length; + const stats = await storage.getDashboardStats(userId, { + yesterdayStart: yesterday, + yesterdayEnd: today, + lastMonth, + }); - // Posts planifiés le mois dernier - const scheduledPostsLastMonth = posts.filter(p => { - if (!p.createdAt) return false; - const createdDate = new Date(p.createdAt); - return p.status === "scheduled" && createdDate < lastMonth; - }).length; + const scheduledPosts = stats.scheduledPosts; + const scheduledPostsLastMonth = stats.scheduledPostsLastMonth; + const currentAiTexts = stats.aiTextsGenerated; + const aiTextsYesterday = stats.aiTextsYesterday; // Calculer les variations en pourcentage const aiTextChange = aiTextsYesterday > 0 @@ -591,11 +594,11 @@ export async function registerRoutes(app: Express): Promise { scheduledPosts, scheduledPostsChange: scheduledPostsChange > 0 ? `+${scheduledPostsChange}%` : `${scheduledPostsChange}%`, scheduledPostsTrending: scheduledPostsChange >= 0 ? "up" : "down", - connectedPages: pages.length, + connectedPages: stats.connectedPages, aiTextsGenerated: currentAiTexts, aiTextsChange: aiTextChange > 0 ? `+${aiTextChange}%` : `${aiTextChange}%`, aiTextsTrending: aiTextChange >= 0 ? "up" : "down", - mediaStored: media.length, + mediaStored: stats.mediaStored, }); } catch (error) { console.error("Error fetching stats:", error); @@ -723,7 +726,19 @@ export async function registerRoutes(app: Express): Promise { try { const user = req.user as User; 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); } catch (error) { console.error("Error fetching media:", error); @@ -1415,16 +1430,15 @@ export async function registerRoutes(app: Express): Promise { const userId = user.id; const { startDate, endDate } = req.query; - const start = startDate ? new Date(startDate as string) : undefined; - const end = endDate ? new Date(endDate as string) : undefined; + // Une date illisible est ignorée plutôt que transmise telle quelle à SQL. + const start = parseDateParam(startDate); + const end = parseDateParam(endDate); let scheduledPosts; if (user.role === 'admin') { // Admin voit tous les posts programmés - on récupère toutes les pages - const allPages = await storage.getAllUsers().then(users => - Promise.all(users.map(u => storage.getSocialPages(u.id))) - ).then(pagesArrays => pagesArrays.flat()); + const allPages = await storage.getAllSocialPages(); const allPageIds = allPages.map(p => p.id); if (allPageIds.length > 0) { @@ -1577,11 +1591,7 @@ export async function registerRoutes(app: Express): Promise { if (user.role === 'admin') { // Les admins voient toutes les pages de tous les utilisateurs - const allUsers = await storage.getAllUsers(); - const allPages = await Promise.all( - allUsers.map(u => storage.getSocialPages(u.id)) - ); - pages = allPages.flat(); + pages = await storage.getAllSocialPages(); } else { // Les utilisateurs normaux voient uniquement les pages auxquelles ils ont accès pages = await storage.getUserAccessiblePages(userId); diff --git a/server/routes/external.ts b/server/routes/external.ts index 0dbf3a0..44bdc69 100644 --- a/server/routes/external.ts +++ b/server/routes/external.ts @@ -270,9 +270,7 @@ router.get("/posts", async (req, res) => { } pageIds = [pageId]; } else { - const users = await storage.getAllUsers(); - const allPages = await Promise.all(users.map(u => storage.getSocialPages(u.id))); - pageIds = allPages.flat().map(p => p.id); + pageIds = (await storage.getAllSocialPages()).map(p => p.id); } if (pageIds.length === 0) { @@ -312,19 +310,20 @@ router.get("/posts", async (req, res) => { if (post.status !== postStatus && postStatus !== "all") continue; 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 mediaItems: Array<{ id: string; url: string; type: string }> = []; - for (const link of postMediaLinks) { - const mediaItem = await storage.getMediaById(link.mediaId); - if (mediaItem) { - mediaItems.push({ - id: mediaItem.id, - url: mediaItem.originalUrl, - type: mediaItem.type, - }); - } - } + const byId = new Map( + (await storage.getMediaByIds(postMediaLinks.map(link => link.mediaId))) + .map(item => [item.id, item]) + ); + const mediaItems = postMediaLinks + .map(link => byId.get(link.mediaId)) + .filter((item): item is NonNullable => item !== undefined) + .map(item => ({ + id: item.id, + url: item.originalUrl, + type: item.type, + })); postMap.set(post.id, { id: post.id, diff --git a/server/routes/reels.ts b/server/routes/reels.ts index 8cd4bf1..c3b35a6 100644 --- a/server/routes/reels.ts +++ b/server/routes/reels.ts @@ -898,10 +898,7 @@ reelsRouter.get('/reels/config', async (req: Request, res: Response) => { reelsRouter.get('/reels/ongoing', async (req: Request, res: Response) => { try { const user = req.user as User; - const allPosts = await storage.getPosts(user.id); - const ongoing = allPosts.filter( - (p) => p.generationStatus === 'processing' || p.generationStatus === 'pending' - ); + const ongoing = await storage.getOngoingReelPosts(user.id); res.json(ongoing); } catch (error) { console.error('❌ Error fetching ongoing reels:', error); diff --git a/server/storage.ts b/server/storage.ts index 6c5f40e..bf874a2 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -44,7 +44,7 @@ import { type InsertFacebookConfig, } from "@shared/schema"; import { db } from "./db"; -import { eq, and, gte, lte, desc, asc, isNull, isNotNull, inArray, notInArray } 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"; /** @@ -71,24 +71,73 @@ function decryptPageTokens(page: SocialPage): SocialPage { }; } +/** 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 { // Users getUser(id: string): Promise; getUserByUsername(username: string): Promise; getAllUsers(): Promise; + getDashboardStats(userId: string, options: DashboardStatsRange): Promise; createUser(user: InsertUser): Promise; updateUser(id: string, user: Partial): Promise; deleteUser(id: string): Promise; // Social Pages getSocialPages(userId: string): Promise; + getAllSocialPages(): Promise; getSocialPage(id: string): Promise; createSocialPage(page: InsertSocialPage): Promise; updateSocialPage(id: string, page: Partial): Promise; deleteSocialPage(id: string): Promise; // Media - getMedia(userId: string): Promise; + getMedia(userId: string, options?: { limit?: number; offset?: number }): Promise; + getMediaByIds(ids: string[]): Promise; + getMediaCount(userId: string): Promise; getMediaById(id: string): Promise; createMedia(media: InsertMedia): Promise; deleteMedia(id: string): Promise; @@ -98,6 +147,7 @@ export interface IStorage { getPosts(userId: string): Promise; getPost(id: string): Promise; getPostWithMedia(id: string): Promise<{ post: Post; media: Media[] } | undefined>; + getOngoingReelPosts(userId: string): Promise; createPost(post: InsertPost): Promise; updatePost(id: string, post: Partial): Promise; updatePostGenerationStatus(id: string, status: string, progress: number, error?: string): Promise; @@ -194,12 +244,58 @@ export class DatabaseStorage implements IStorage { 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 { + const [postCounts, generationCounts, pageCount, mediaCount] = await Promise.all([ + db + .select({ + scheduled: sql`count(*) filter (where ${posts.status} = 'scheduled')`, + scheduledLastMonth: sql`count(*) filter (where ${posts.status} = 'scheduled' and ${posts.createdAt} < ${range.lastMonth})`, + }) + .from(posts) + .where(eq(posts.userId, userId)), + db + .select({ + total: sql`count(*)`, + yesterday: sql`count(*) filter (where ${aiGenerations.createdAt} >= ${range.yesterdayStart} and ${aiGenerations.createdAt} < ${range.yesterdayEnd})`, + }) + .from(aiGenerations) + .where(eq(aiGenerations.userId, userId)), + db.select({ total: sql`count(*)` }).from(socialPages).where(eq(socialPages.userId, userId)), + db.select({ total: sql`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 async getSocialPages(userId: string): Promise { const pages = await db.select().from(socialPages).where(eq(socialPages.userId, userId)); return pages.map(page => decryptPageTokens(page)); } + /** + * Toutes les pages de l'instance, pour les vues d'administration. + * Remplace un `getAllUsers()` suivi d'une requête par utilisateur. + */ + async getAllSocialPages(): Promise { + const pages = await db.select().from(socialPages); + return pages.map(page => decryptPageTokens(page)); + } + async getSocialPage(id: string): Promise { const [page] = await db.select().from(socialPages).where(eq(socialPages.id, id)); return page ? decryptPageTokens(page) : undefined; @@ -234,8 +330,37 @@ export class DatabaseStorage implements IStorage { } // Media - async getMedia(userId: string): Promise { - 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 { + 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 { + 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 { + const [row] = await db + .select({ total: sql`count(*)` }) + .from(media) + .where(eq(media.userId, userId)); + return Number(row?.total ?? 0); } async getMediaById(id: string): Promise { @@ -271,14 +396,16 @@ export class DatabaseStorage implements IStorage { if (!post) return undefined; const postMediaLinks = await this.getPostMedia(id); - const mediaItems: Media[] = []; - for (const link of postMediaLinks) { - const mediaItem = await this.getMediaById(link.mediaId); - if (mediaItem) { - mediaItems.push(mediaItem); - } - } + // Une seule requête pour tous les médias, puis remise dans l'ordre + // d'affichage : une requête par média rendait le coût proportionnel au + // nombre de visuels du post. + 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 }; } @@ -352,67 +479,42 @@ export class DatabaseStorage implements IStorage { // Scheduled Posts async getScheduledPosts(userId: string, startDate?: Date, endDate?: Date): Promise { - let query = db - .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, - })); + return this.queryScheduledPosts(eq(posts.userId, userId), startDate, endDate); } async getScheduledPostsByPages(pageIds: string[], startDate?: Date, endDate?: Date): Promise { if (pageIds.length === 0) { 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 { + 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) .innerJoin(posts, eq(scheduledPosts.postId, posts.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) { - 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, - })); + // La jointure externe renvoie un objet aux champs nuls quand la page a été + // supprimée ; les appelants attendent `page: null` dans ce cas. + return rows.map(row => ({ ...row, page: row.page?.id ? row.page : null })); } async getScheduledPost(id: string): Promise { @@ -468,6 +570,24 @@ export class DatabaseStorage implements IStorage { ); } + /** + * 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 { + return await db + .select() + .from(posts) + .where( + and( + eq(posts.userId, userId), + inArray(posts.generationStatus, ['processing', 'pending']) + ) + ) + .orderBy(desc(posts.createdAt)); + } + // AI Generations async getAiGenerations(userId: string): Promise { return await db.select().from(aiGenerations).where(eq(aiGenerations.userId, userId)).orderBy(desc(aiGenerations.createdAt));