Promotions : publication programmée et affichage limité à la période

Deux comportements demandés :

1. Une promotion n'apparaît sur le site que pendant sa période de validité.
   Les lectures publiques passent par `VISIBLE_PROMO` (`src/lib/queries.ts`) :
   statut `live` **et** fenêtre `starts_on` / `ends_on`, journée calculée en
   Europe/Paris pour qu'un conteneur en UTC ne retire pas une offre deux
   heures trop tôt. Le statut n'est pas touché : hors période, le backoffice
   explique pourquoi l'offre n'est pas visible (`visibilityNote`) et elle
   revient d'elle-même si les dates changent.

2. La publication peut être programmée. `promotions.publish_at` : l'adhérent
   propose une date, le modérateur la garde, la déplace ou la vide dans le
   formulaire « Valider ». Avec une échéance future la promotion passe au
   statut `scheduled` — rien n'est visible, rien n'est diffusé — et
   `releaseDuePromotions()` fait la bascule à l'heure dite, site et réseaux
   ensemble. Un bouton « Publier maintenant » court-circuite l'attente.

Mécanique :
- `src/lib/promo-schedule.ts` (pur, testé) : conversion `datetime-local` ⇄
  heure de l'association, changements d'heure compris, et refus d'une saisie
  illisible plutôt qu'une publication immédiate involontaire.
- `src/lib/promo-publish.ts` reprend `publishPromoShares()` : deux appelants
  désormais, une action serveur et la boucle de libération, qui n'a pas de
  requête et ne peut donc pas appeler `revalidatePath()`. Idem pour
  `logActivity()` dans `src/lib/activity-log.ts`.
- La bascule `scheduled → live` est un `UPDATE … RETURNING` filtré sur le
  statut : atomique, donc pas de double publication même à plusieurs
  instances, en plus de la garde existante sur `social_posts`.
- Déclencheur : `src/instrumentation.ts`, boucle d'une minute avec rattrapage
  au démarrage (`PROMO_SCHEDULER=off` la désactive). Le travail vit dans
  `instrumentation-node.ts`, écarté du bundle edge par `next.config.mjs` :
  Next compile aussi l'instrumentation pour le middleware, où `pg` ne peut
  pas être embarqué.

Migration 0016 : `promo_status += 'scheduled'`, `promotions.publish_at`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
This commit is contained in:
Claude committed 2026-09-06 11:17:49 +00:00
1 parent 02c5378a47
commit e980ab6e04
20 files changed
+2134 -121

No files matched your search

+30 -2
View File
@@ -8,6 +8,7 @@ import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
import type { SocialNetwork } from "@/lib/social";
import type { PromoCategoryGroup } from "@/lib/promo-categories";
import { formatValidity, isRangeInvalid } from "@/lib/promo-validity";
import { formatSchedule, parseScheduleInput } from "@/lib/promo-schedule";
const STRIPE_WARM =
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
@@ -39,6 +40,7 @@ export function MemberSpaceForm({
const [badge, setBadge] = useState("");
const [startsOn, setStartsOn] = useState("");
const [endsOn, setEndsOn] = useState("");
const [publishAt, setPublishAt] = useState("");
const [imgData, setImgData] = useState("");
const [shares, setShares] = useState<SocialNetwork[]>([]);
const [submitted, setSubmitted] = useState(false);
@@ -67,6 +69,7 @@ export function MemberSpaceForm({
setBadge("");
setStartsOn("");
setEndsOn("");
setPublishAt("");
setImgData("");
setShares([]);
setSubmitted(false);
@@ -83,6 +86,7 @@ export function MemberSpaceForm({
fd.set("badge", badge);
fd.set("startsOn", startsOn);
fd.set("endsOn", endsOn);
fd.set("publishAt", publishAt);
fd.set("imageUrl", imgData);
if (shares.includes("facebook")) fd.set("shareFacebook", "on");
if (shares.includes("linkedin")) fd.set("shareLinkedin", "on");
@@ -99,6 +103,14 @@ export function MemberSpaceForm({
const validity = formatValidity({ startsOn, endsOn });
// Bornes incohérentes : on le dit avant l'envoi plutôt qu'après un refus.
const badRange = isRangeInvalid({ startsOn, endsOn });
// Aperçu de la programmation. Le champ donne une heure locale : on la relit
// avec le même formateur que le backoffice pour éviter toute surprise.
let scheduleHint: string | null = null;
try {
scheduleHint = formatSchedule(parseScheduleInput(publishAt));
} catch {
scheduleHint = null;
}
return (
<div className="grid grid-2" style={{ alignItems: "start" }}>
@@ -208,8 +220,24 @@ export function MemberSpaceForm({
{badRange
? "La date de fin est antérieure à la date de début."
: validity
? `Affiché sur le site et dans la publication : « ${validity} »`
: "Laissez vide si votre offre n'a pas de date limite : rien ne sera affiché."}
? `Affiché sur le site et dans la publication : « ${validity} ». L'offre n'apparaît que pendant cette période.`
: "Laissez vide si votre offre n'a pas de date limite : elle restera affichée sans échéance."}
</div>
</div>
<div style={{ marginBottom: 16 }}>
<label className="field-label">Programmer la publication · facultatif</label>
<input
type="datetime-local"
value={publishAt}
onChange={(e) => { setPublishAt(e.target.value); setSubmitted(false); }}
className="field"
aria-label="Date et heure de publication souhaitées"
/>
<div style={{ fontSize: 12, color: "#8c8068", marginTop: 8, lineHeight: 1.5 }}>
{scheduleHint
? `Mise en ligne demandée ${scheduleHint} — site et réseaux en même temps. L'association reste libre de la déplacer.`
: "Laissez vide pour une mise en ligne dès la validation par l'association."}
</div>
</div>
+31 -2
View File
@@ -7,7 +7,8 @@ import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
import { configuredNetworks, SOCIAL_LABELS, type SocialNetwork } from "@/lib/social";
import { PROMO_CATEGORY_GROUPS, defaultPromoCategory } from "@/lib/promo-categories";
import { memberPath } from "@/lib/seo";
import { formatValidityShort } from "@/lib/promo-validity";
import { formatValidityShort, visibilityNote } from "@/lib/promo-validity";
import { scheduleLabel } from "@/lib/promo-schedule";
import { setOwnPromoShareTargets, setOwnPromoSuspension } from "../../actions";
import { EspaceHeader } from "../EspaceHeader";
import { MemberSpaceForm } from "../MemberSpaceForm";
@@ -20,6 +21,7 @@ const PROMO_STATUS: Record<string, { label: string; bg: string; color: string }>
rejected: { label: "Refusée", bg: "#fbe9e6", color: "#d8472b" },
suspended: { label: "Suspendue", bg: "#fbe9e6", color: "#d8472b" },
expired: { label: "Expirée", bg: "#f1efe7", color: "#a99c82" },
scheduled: { label: "Programmée", bg: "#eaf0f6", color: "#2C6FB3" },
};
type MyPromo = {
@@ -29,6 +31,7 @@ type MyPromo = {
category: string | null;
badge: string | null;
validUntil: string | null;
publishAt: Date | null;
startsOn: string | null;
endsOn: string | null;
createdAt: Date;
@@ -82,6 +85,7 @@ export default async function EspacePromotionsPage() {
category: promotions.category,
badge: promotions.badge,
validUntil: promotions.validUntil,
publishAt: promotions.publishAt,
startsOn: promotions.startsOn,
endsOn: promotions.endsOn,
createdAt: promotions.createdAt,
@@ -116,7 +120,12 @@ export default async function EspacePromotionsPage() {
const networks = await configuredNetworks();
const live = myPromos.filter((p) => p.status === "live");
const pending = myPromos.filter((p) => p.status === "pending");
const others = myPromos.filter((p) => p.status !== "live" && p.status !== "pending");
// Validées mais pas encore à leur date : elles n'ont rien à faire dans
// l'historique des offres terminées.
const scheduled = myPromos.filter((p) => p.status === "scheduled");
const others = myPromos.filter(
(p) => p.status !== "live" && p.status !== "pending" && p.status !== "scheduled"
);
return (
<div>
@@ -156,6 +165,16 @@ export default async function EspacePromotionsPage() {
networks={networks}
/>
{scheduled.length > 0 && (
<PromoSection
title="Publications programmées"
intro="Validées par l'association : elles apparaîtront sur le site et sur les réseaux à la date prévue."
promos={scheduled}
networks={networks}
lastShare={lastShare}
/>
)}
{pending.length > 0 && (
<PromoSection
title="En attente de validation"
@@ -286,6 +305,16 @@ function PromoRow({ promo: mp, networks, lastShare }: { promo: MyPromo; networks
</form>
)}
</div>
{mp.status === "live" && visibilityNote(mp) && (
<div style={{ marginTop: 10, background: "#faf7ef", border: "1px solid #f0e8d6", color: "#9a8d72", borderRadius: 9, padding: "9px 11px", fontSize: 12.5, lineHeight: 1.5 }}>
{visibilityNote(mp)}
</div>
)}
{mp.status === "scheduled" && (
<div style={{ marginTop: 10, background: "#eaf0f6", border: "1px solid #d3e0ee", color: "#2C6FB3", borderRadius: 9, padding: "9px 11px", fontSize: 12.5, lineHeight: 1.5, fontWeight: 600 }}>
Validée par l&apos;association. {scheduleLabel(mp.publishAt)} — site et réseaux en même temps.
</div>
)}
{suspendedByStaff && (
<div style={{ marginTop: 10, background: "#fbe9e6", border: "1px solid #f2d5cf", color: "#a8503c", borderRadius: 9, padding: "9px 11px", fontSize: 12.5, lineHeight: 1.5 }}>
Cette promotion a été suspendue par l&apos;association. Contactez-la pour la remettre en ligne.