From 57372c04e9ad5f79b51769afa6fb4955248eb0ea Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 18:54:34 +0000 Subject: [PATCH] =?UTF-8?q?Historique=20:=20badge=20=C2=AB=20Nouvel=20essa?= =?UTF-8?q?i=20=C2=BB=20et=20abandon=20des=20publications=20trop=20en=20re?= =?UTF-8?q?tard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Une publication en échec avec une nouvelle tentative programmée affiche « Nouvel essai » et compte dans « En cours », plus dans « Échecs ». - Le planificateur n'envoie plus une publication qui a plus de 24 h de retard sur son heure prévue : elle est marquée abandonnée (dernière erreur conservée), à reprogrammer si besoin. - L'historique ne se rafraîchit plus toutes les 3 s à cause d'un échec en attente de sa prochaine tentative. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018jcWbATGHxatn4V7qS3mr3 --- client/src/components/recent-publications.tsx | 8 +++- client/src/pages/history.tsx | 22 +++++++-- server/src/services/scheduler.rs | 47 +++++++++++++++++++ 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/client/src/components/recent-publications.tsx b/client/src/components/recent-publications.tsx index c430a67..93e0374 100644 --- a/client/src/components/recent-publications.tsx +++ b/client/src/components/recent-publications.tsx @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { ArrowRight, Clock, CheckCircle2, XCircle, Eye, Image as ImageIcon, Smartphone, Clapperboard } from "lucide-react"; +import { ArrowRight, Clock, CheckCircle2, XCircle, RotateCw, Eye, Image as ImageIcon, Smartphone, Clapperboard } from "lucide-react"; import { Link } from "wouter"; import { Badge } from "@/components/ui/badge"; import { EmptyState } from "@/components/empty-state"; @@ -197,7 +197,11 @@ export default function RecentPublications() {

{scheduledPost.page?.pageName || "Page inconnue"}

- {scheduledPost.error ? ( + {scheduledPost.error && !scheduledPost.publishedAt && scheduledPost.nextAttemptAt ? ( + + Nouvel essai + + ) : scheduledPost.error ? ( Échec diff --git a/client/src/pages/history.tsx b/client/src/pages/history.tsx index 2e88174..0a042e8 100644 --- a/client/src/pages/history.tsx +++ b/client/src/pages/history.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { Page } from "@/components/layout/app-shell"; import { PageHeader } from "@/components/layout/page-header"; import { useQuery } from "@tanstack/react-query"; -import { CheckCircle, XCircle, Calendar, History as HistoryIcon, Eye, Image as ImageIcon, Smartphone, Loader2, Clapperboard, Search, Sparkles } from "lucide-react"; +import { CheckCircle, XCircle, RotateCw, Calendar, History as HistoryIcon, Eye, Image as ImageIcon, Smartphone, Loader2, Clapperboard, Search, Sparkles } from "lucide-react"; import { Link } from "wouter"; import { Card } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; @@ -27,10 +27,17 @@ function isAwaitingPublication(scheduledPost: ScheduledPostWithRelations): boole return ( !!scheduledPost.scheduledAt && new Date(scheduledPost.scheduledAt) <= new Date() && - !scheduledPost.publishedAt + !scheduledPost.publishedAt && + // Un échec ne bouge plus avant sa prochaine tentative (minutes ou heures) + !scheduledPost.error ); } +/** Échec dont le scheduler fera une nouvelle tentative automatique. */ +function isRetrying(scheduledPost: ScheduledPostWithRelations): boolean { + return !!scheduledPost.error && !scheduledPost.publishedAt && !!scheduledPost.nextAttemptAt; +} + type StatusFilter = "all" | "published" | "pending" | "failed"; const STATUS_TABS: { value: StatusFilter; label: string }[] = [ @@ -115,8 +122,13 @@ export default function History() { return null; }; + // Un échec avec un nouvel essai programmé est encore « en cours » const statusOf = (sp: ScheduledPostWithRelations): StatusFilter => - sp.error ? "failed" : !sp.publishedAt ? "pending" : "published"; + sp.publishedAt && !sp.error + ? "published" + : sp.error && !isRetrying(sp) + ? "failed" + : "pending"; const counts = publishedPosts.reduce( (acc, sp) => { @@ -200,6 +212,8 @@ export default function History() {

{scheduledPost.page?.pageName || "Page inconnue"}

{status === "failed" ? ( Échec + ) : isRetrying(scheduledPost) ? ( + Nouvel essai ) : status === "pending" ? ( En cours ) : ( @@ -238,7 +252,7 @@ export default function History() { {scheduledPost.nextAttemptAt ? `Nouvelle tentative automatique le ${format(new Date(scheduledPost.nextAttemptAt), "d MMM yyyy 'à' HH:mm", { locale: fr })}` : scheduledPost.attempts > 0 - ? `Abandon après ${scheduledPost.attempts} tentatives : reprogrammez la publication pour réessayer.` + ? "Plus de nouvelle tentative automatique : reprogrammez la publication pour réessayer." : null}

)} diff --git a/server/src/services/scheduler.rs b/server/src/services/scheduler.rs index acd0d7d..9185b91 100644 --- a/server/src/services/scheduler.rs +++ b/server/src/services/scheduler.rs @@ -37,6 +37,15 @@ pub fn start(state: AppState) { /// (il faut alors la reprogrammer). pub const MAX_PUBLISH_ATTEMPTS: i32 = 5; +/// Retard au-delà duquel une publication n'est plus envoyée : une promotion +/// ou une story publiée un jour trop tard fait plus de mal que de bien. +const MAX_PUBLISH_DELAY_HOURS: i64 = 24; + +/// Publication trop en retard sur son heure prévue pour être encore envoyée. +fn is_stale(scheduled_at: Ts, now: Ts) -> bool { + scheduled_at.add(chrono::Duration::hours(MAX_PUBLISH_DELAY_HOURS)) < now +} + /// Délai avant la tentative suivante, selon le nombre d'échecs déjà subis. /// Une limite de débit (quota d'appels, blocage anti-spam) appelle des délais /// plus longs : réessayer tout de suite prolonge le blocage. @@ -106,6 +115,36 @@ pub async fn process_pending_posts(state: &AppState) { let mut cooldowns: HashMap = HashMap::new(); for scheduled_post in pending { + if is_stale(scheduled_post.scheduled_at, Ts::now()) { + tracing::warn!( + "[Scheduler] Post {} abandonné : plus de {MAX_PUBLISH_DELAY_HOURS} h de retard", + scheduled_post.id + ); + // Le dernier message d'erreur est conservé s'il y en a un + let reason = format!( + "Publication abandonnée : plus de {MAX_PUBLISH_DELAY_HOURS} h de retard sur l'heure prévue." + ); + let error = match scheduled_post.error.as_deref().filter(|e| !e.is_empty()) { + Some(last) => format!("{reason} Dernière erreur : {last}"), + None => reason, + }; + let abandoned = state + .db + .update_scheduled_post( + &scheduled_post.id, + Patch::new() + .set("error", error) + .set("attempts", MAX_PUBLISH_ATTEMPTS) + .set("next_attempt_at", None::), + ) + .await; + if let Err(error) = abandoned { + tracing::error!("Error processing pending posts: {error}"); + return; + } + continue; + } + if !cooldowns.is_empty() { let platform = page_platform(state, &scheduled_post.page_id).await; if let Some(until) = platform.and_then(|p| cooldowns.get(&p)).copied() { @@ -399,6 +438,14 @@ mod tests { assert!(!is_rate_limited("Post or page not found")); } + #[test] + fn posts_more_than_a_day_late_are_stale() { + let now = Ts::parse("2026-10-12T19:00:00Z").unwrap(); + assert!(is_stale(Ts::parse("2026-10-11T16:30:00Z").unwrap(), now)); + assert!(!is_stale(Ts::parse("2026-10-12T16:30:00Z").unwrap(), now)); + assert!(!is_stale(Ts::parse("2026-10-11T19:00:00Z").unwrap(), now)); + } + #[test] fn retry_delays_grow_and_stay_bounded() { assert_eq!(retry_delay(1, false), chrono::Duration::minutes(5));