perf(api): alléger les requêtes du calendrier, des stats et de la médiathèque

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qns6Gcpd95bCHWUxySh4Ew
This commit is contained in:
Claude committed 2026-09-10 06:39:16 +00:00
1 parent 25bf0981bd
commit 94e961d0f1
11 files changed
+431 -135

No files matched your search

+22 -1
View File
@@ -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<ScheduledPost[]>({
queryKey: ["/api/scheduled-posts"],
queryKey: ["/api/scheduled-posts", range],
refetchInterval: 30000, // Auto-refresh every 30 seconds
});
+42 -10
View File
@@ -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<any>(null);
const [visibleCount, setVisibleCount] = useState(5);
const [zoomImage, setZoomImage] = useState<string | null>(null);
const loadMoreRef = useRef<HTMLDivElement>(null);
const cameraInputRef = useRef<HTMLInputElement>(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 (
<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">
<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">
{(mediaList as any[])?.length || 0} média(s)
{totalMedia} média(s)
</span>
</h4>
<div className="max-h-[500px] overflow-y-auto">
<div className="grid grid-cols-3 gap-3">
{(mediaList as any[])?.slice(0, visibleCount).map((media: any) => (
{mediaList.map((media: any) => (
<div
key={media.id}
onClick={() => setSelectedFile(media)}
@@ -316,7 +348,7 @@ export default function MediaUpload() {
</div>
{/* É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">
<Loader2 className="w-5 h-5 text-primary animate-spin" />
</div>
+16 -1
View File
@@ -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<ScheduledPostWithRelations[]>({
queryKey: ['/api/scheduled-posts'],
queryKey: ['/api/scheduled-posts', { startDate: startOfDay(since).toISOString() }],
});
// Filter and sort attempted posts (scheduled in the past)
+28 -1
View File
@@ -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<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";
export const getQueryFn: <T>(options: {
on401: UnauthorizedBehavior;
}) => QueryFunction<T> =
({ on401: unauthorizedBehavior }) =>
async ({ queryKey }) => {
const url = queryKey.join("/") as string;
const url = urlFromQueryKey(queryKey);
const res = await fetch(url, {
credentials: "include",
});
+17 -3
View File
@@ -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<Media[]>({
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<ScheduledPost[]>({
queryKey: ['/api/scheduled-posts'],
queryKey: ['/api/scheduled-posts', { startDate: startOfToday().toISOString() }],
});
const uploadMutation = useMutation({
+17 -3
View File
@@ -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<Media[]>({
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<ScheduledPost[]>({
queryKey: ['/api/scheduled-posts'],
queryKey: ['/api/scheduled-posts', { startDate: startOfToday().toISOString() }],
});
const uploadMutation = useMutation({