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));