Files
Socialflow/client/src/lib/queryClient.ts
T
Claude 94e961d0f1 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
2026-09-10 06:39:16 +00:00

161 lines
5.8 KiB
TypeScript

import { MutationCache, QueryClient, QueryFunction } from "@tanstack/react-query";
// Les routes d'authentification elles-mêmes ne doivent jamais déclencher
// de redirection automatique (ex: mauvais mot de passe sur /login).
function isAuthRoute(url: string): boolean {
return url.startsWith("/api/auth/");
}
// Si la session serveur a expiré (ex: redémarrage du serveur, expiration du
// cookie) alors que la SPA pense toujours l'utilisateur connecté (le check
// de session initial est mis en cache indéfiniment par react-query), toutes
// les requêtes suivantes échouent silencieusement en 401 sans jamais
// renvoyer l'utilisateur vers l'écran de connexion. On force donc un
// rechargement vers /login dès qu'une requête API (hors routes d'auth)
// renvoie 401, ce qui réinitialise aussi le cache react-query.
export function handleUnauthorized(url: string) {
if (isAuthRoute(url) || window.location.pathname === "/login") {
return;
}
window.location.href = "/login";
}
/**
* Extrait un message lisible d'une erreur d'API.
* apiRequest lève des erreurs de la forme `500: {"error":"…"}` : sans ce
* décodage, l'interface ne peut afficher qu'un message générique alors que le
* serveur a renvoyé la cause exacte.
*/
export function getErrorMessage(error: unknown, fallback: string): string {
if (!(error instanceof Error) || !error.message) return fallback;
const withoutStatus = error.message.replace(/^\d{3}:\s*/, '').trim();
if (!withoutStatus) return fallback;
try {
const parsed = JSON.parse(withoutStatus);
const message = parsed?.error || parsed?.message;
if (typeof message === 'string' && message.trim()) return message.trim();
} catch {
// Corps non-JSON : on affiche le texte tel quel
}
return withoutStatus.startsWith('<') ? fallback : withoutStatus;
}
async function throwIfResNotOk(res: Response, url: string) {
if (!res.ok) {
if (res.status === 401) {
handleUnauthorized(url);
}
const text = (await res.text()) || res.statusText;
throw new Error(`${res.status}: ${text}`);
}
}
export async function apiRequest(
method: string,
url: string,
data?: unknown | undefined,
): Promise<Response> {
const res = await fetch(url, {
method,
headers: data ? { "Content-Type": "application/json" } : {},
body: data ? JSON.stringify(data) : undefined,
credentials: "include",
});
await throwIfResNotOk(res, url);
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 = urlFromQueryKey(queryKey);
const res = await fetch(url, {
credentials: "include",
});
if (unauthorizedBehavior === "returnNull" && res.status === 401) {
return null;
}
await throwIfResNotOk(res, url);
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({
// 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: {
queries: {
queryFn: getQueryFn({ on401: "throw" }),
// Le serveur agit de son côté — le scheduler publie toutes les minutes, le
// renouvellement des jetons tourne en tâche de fond — sans que le
// 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,
},
mutations: {
retry: false,
},
},
});