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

+13
View File
@@ -0,0 +1,13 @@
import { db } from "@/db";
import { activityLog } from "@/db/schema";
import { sanitizeActivityMessage } from "@/lib/activity";
/**
* Écrit une ligne du journal d'activité.
*
* Le message agrège des saisies de tiers (jusqu'au formulaire de contact
* public) : il ne laisse passer que `<strong>`.
*/
export async function logActivity(message: string, dot = "#2C6FB3") {
await db.insert(activityLog).values({ message: sanitizeActivityMessage(message), dot });
}
+175
View File
@@ -0,0 +1,175 @@
/**
* Mise en ligne d'une promotion : diffusion sur les réseaux et libération des
* publications programmées.
*
* Ce module vit hors de `backend/actions.ts` parce qu'il a deux appelants : la
* validation par un modérateur (une requête HTTP) et le libérateur périodique
* démarré par `instrumentation.ts` (aucune requête). Il ne fait donc jamais
* appel à `revalidatePath()`, qui n'a pas de sens hors requête ; les pages
* publiques sont en `force-dynamic` et relisent la base à chaque affichage.
*/
import { and, eq, isNotNull, lte, sql } from "drizzle-orm";
import { db } from "@/db";
import { members, promotions, socialPosts } from "@/db/schema";
import { logActivity } from "@/lib/activity-log";
import {
isNetworkConfigured,
publishPromoToNetwork,
SOCIAL_LABELS,
SOCIAL_NETWORKS,
type SocialNetwork,
} from "@/lib/social";
/** Réseaux demandés pour une promo, dans l'ordre d'affichage. */
export function requestedNetworks(promo: {
shareFacebook: boolean;
shareLinkedin: boolean;
}): SocialNetwork[] {
return SOCIAL_NETWORKS.filter((n) =>
n === "facebook" ? promo.shareFacebook : promo.shareLinkedin
);
}
/**
* Publie une promotion sur les réseaux demandés. Ne lève jamais : un réseau
* indisponible ne doit pas faire échouer la mise en ligne, l'échec est
* enregistré dans `social_posts` et rattrapable depuis le backoffice.
*
* Un réseau déjà publié avec succès est ignoré : c'est ce qui empêche toute
* republication, y compris sur un cycle suspension → remise en ligne, et ce
* qui rend le libérateur périodique sans danger s'il repasse sur une promo.
*/
export async function publishPromoShares(
promoId: number,
networks: SocialNetwork[],
userId: number | null
) {
if (networks.length === 0) return;
const [promo] = await db
.select({
title: promotions.title,
text: promotions.text,
badge: promotions.badge,
validUntil: promotions.validUntil,
startsOn: promotions.startsOn,
endsOn: promotions.endsOn,
imageUrl: promotions.imageUrl,
memberId: promotions.memberId,
memberName: members.name,
memberCity: members.city,
})
.from(promotions)
.leftJoin(members, eq(promotions.memberId, members.id))
.where(eq(promotions.id, promoId));
if (!promo) return;
const done = await db
.select({ network: socialPosts.network })
.from(socialPosts)
.where(and(eq(socialPosts.promotionId, promoId), eq(socialPosts.status, "posted")));
const alreadyPosted = new Set(done.map((d) => d.network));
for (const network of networks) {
if (alreadyPosted.has(network)) continue;
if (!(await isNetworkConfigured(network))) {
await db.insert(socialPosts).values({
promotionId: promoId,
network,
status: "failed",
error: `${SOCIAL_LABELS[network]} n'est pas configuré sur ce serveur.`,
postedById: userId,
});
continue;
}
try {
const result = await publishPromoToNetwork(network, promo);
await db.insert(socialPosts).values({
promotionId: promoId,
network,
status: "posted",
externalId: result.externalId,
url: result.url,
postedById: userId,
});
await logActivity(
`Promotion « ${promo.title} » publiée sur ${SOCIAL_LABELS[network]}`,
"#2C6FB3"
);
} catch (error) {
const message = error instanceof Error ? error.message : "Erreur inconnue";
await db.insert(socialPosts).values({
promotionId: promoId,
network,
status: "failed",
error: message.slice(0, 2000),
postedById: userId,
});
}
}
}
/**
* Met en ligne les promotions programmées dont l'échéance est passée.
*
* La bascule `scheduled → live` est faite par un `UPDATE … RETURNING` filtré
* sur le statut : deux instances de l'application qui tourneraient en même
* temps ne peuvent pas récupérer la même ligne, chacune n'obtient que celles
* qu'elle a effectivement changées. La diffusion réseaux suit, protégée en
* plus par la garde anti-republication de `publishPromoShares`.
*
* Ne lève jamais : le libérateur tourne en tâche de fond, une erreur ne doit
* pas arrêter la boucle.
*/
export async function releaseDuePromotions(now = new Date()): Promise<number> {
let released: { id: number; title: string; memberId: number | null; shareFacebook: boolean; shareLinkedin: boolean }[];
try {
released = await db
.update(promotions)
.set({ status: "live", publishAt: null })
.where(
and(
eq(promotions.status, "scheduled"),
isNotNull(promotions.publishAt),
lte(promotions.publishAt, now)
)
)
.returning({
id: promotions.id,
title: promotions.title,
memberId: promotions.memberId,
shareFacebook: promotions.shareFacebook,
shareLinkedin: promotions.shareLinkedin,
});
} catch (error) {
console.error("[promotions] libération programmée impossible :", error);
return 0;
}
for (const promo of released) {
try {
await logActivity(
`Promotion « ${promo.title} » publiée à l'heure programmée`,
"#1f8a5b"
);
// `null` : la publication n'est déclenchée par personne, c'est l'échéance.
await publishPromoShares(promo.id, requestedNetworks(promo), null);
} catch (error) {
console.error(`[promotions] publication programmée #${promo.id} :`, error);
}
}
return released.length;
}
/** Nombre de promotions encore en attente de leur date de publication. */
export async function countScheduledPromotions(): Promise<number> {
const [row] = await db
.select({ n: sql<number>`count(*)::int` })
.from(promotions)
.where(eq(promotions.status, "scheduled"));
return row?.n ?? 0;
}
+117
View File
@@ -0,0 +1,117 @@
/**
* Programmation de la publication d'une promotion.
*
* Une promotion validée peut soit partir tout de suite, soit attendre une
* date choisie : c'est `promotions.publish_at`. Tant que cette date n'est pas
* atteinte la promotion reste au statut `scheduled` — invisible sur le site
* public (les lectures filtrent `status = 'live'`) et non diffusée sur les
* réseaux. Le libérateur (`releaseDuePromotions`) fait la bascule.
*
* Ce module est **pur** : aucune base, aucun réseau. Il ne s'occupe que de la
* conversion et du rendu des dates, et il est verrouillé par
* `tests/promo-schedule.test.ts`.
*
* Le fuseau est celui de l'association, pas celui du serveur : un conteneur
* qui tourne en UTC ne doit pas décaler d'une heure la publication demandée
* par un adhérent lorrain.
*/
export const ASSOCIATION_TIME_ZONE = "Europe/Paris";
const LOCAL_DATETIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::\d{2})?$/;
/**
* Décalage du fuseau de l'association à un instant donné, en millisecondes.
* Passe par `Intl` plutôt que par une constante : l'heure d'été change deux
* fois par an et une valeur figée publierait à la mauvaise heure la moitié de
* l'année.
*/
function zoneOffsetMs(instant: Date): number {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: ASSOCIATION_TIME_ZONE,
hour12: false,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).formatToParts(instant);
const get = (type: string) => Number(parts.find((p) => p.type === type)?.value ?? "0");
// `hour` vaut 24 à minuit avec hour12:false sur certaines plateformes.
const hour = get("hour") % 24;
const asIfUtc = Date.UTC(get("year"), get("month") - 1, get("day"), hour, get("minute"), get("second"));
return asIfUtc - instant.getTime();
}
/**
* Convertit la saisie d'un `<input type="datetime-local">` (heure locale de
* l'association) en instant absolu.
*
* Renvoie `null` si le champ est vide, et lève si la saisie n'a pas la forme
* attendue : une date illisible ne doit pas se transformer silencieusement en
* publication immédiate.
*/
export function parseScheduleInput(value: string | null | undefined): Date | null {
const raw = (value ?? "").trim();
if (!raw) return null;
const m = LOCAL_DATETIME.exec(raw);
if (!m) throw new Error("Date de programmation invalide.");
const [, y, mo, d, h, mi] = m.map(Number) as unknown as number[];
// `Date.UTC` accepte 13 comme mois ou 99 comme heure en débordant sur la
// date suivante : on refuse plutôt que de publier à un moment inattendu.
if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59) {
throw new Error("Date de programmation invalide.");
}
const naive = Date.UTC(y, mo - 1, d, h, mi);
if (new Date(naive).getUTCDate() !== d) throw new Error("Date de programmation invalide.");
// Deux passes : la première donne un décalage approché, la seconde le corrige
// quand la date tombe juste après un changement d'heure.
const first = new Date(naive - zoneOffsetMs(new Date(naive)));
const exact = new Date(naive - zoneOffsetMs(first));
if (Number.isNaN(exact.getTime())) throw new Error("Date de programmation invalide.");
return exact;
}
/** Valeur à remettre dans un `<input type="datetime-local">`. */
export function toScheduleInput(date: Date | null | undefined): string {
if (!date || Number.isNaN(date.getTime())) return "";
const local = new Date(date.getTime() + zoneOffsetMs(date));
return local.toISOString().slice(0, 16);
}
/** Une programmation passée n'a plus lieu d'être : on publie tout de suite. */
export function isDue(publishAt: Date | null | undefined, now = new Date()): boolean {
return !publishAt || publishAt.getTime() <= now.getTime();
}
/** `true` si la promotion doit attendre. */
export function isPending(publishAt: Date | null | undefined, now = new Date()): boolean {
return !isDue(publishAt, now);
}
/** « le 12 mars 2027 à 09:00 » — sans majuscule, à insérer dans une phrase. */
export function formatSchedule(publishAt: Date | null | undefined): string | null {
if (!publishAt || Number.isNaN(publishAt.getTime())) return null;
const day = new Intl.DateTimeFormat("fr-FR", {
timeZone: ASSOCIATION_TIME_ZONE,
day: "numeric",
month: "long",
year: "numeric",
}).format(publishAt);
const time = new Intl.DateTimeFormat("fr-FR", {
timeZone: ASSOCIATION_TIME_ZONE,
hour: "2-digit",
minute: "2-digit",
}).format(publishAt);
return `le ${day} à ${time}`;
}
/** Phrase complète pour le backoffice. */
export function scheduleLabel(publishAt: Date | null | undefined): string | null {
const when = formatSchedule(publishAt);
return when ? `Publication programmée ${when}` : null;
}
+27
View File
@@ -88,3 +88,30 @@ export function isRangeInvalid(promo: PromoValidity): boolean {
if (!start || !end) return false;
return Date.UTC(end.year, end.month, end.day) < Date.UTC(start.year, start.month, start.day);
}
/** `true` si la période n'a pas encore commencé. */
export function isNotStarted(promo: PromoValidity, today = new Date()): boolean {
const start = parse(promo.startsOn);
if (!start) return false;
return today.getTime() < Date.UTC(start.year, start.month, start.day);
}
/**
* Pourquoi une promotion validée n'apparaît pas sur le site aujourd'hui.
*
* Les lectures publiques (`src/lib/queries.ts`) écartent les offres hors
* période ; ce message l'explique côté backoffice, pour qu'un adhérent ne
* cherche pas une offre « en ligne » qui n'est pas encore, ou plus, affichée.
* Renvoie `null` quand la promotion est bien visible.
*/
export function visibilityNote(promo: PromoValidity, today = new Date()): string | null {
const start = parse(promo.startsOn);
const end = parse(promo.endsOn);
if (start && isNotStarted(promo, today)) {
return `Pas encore affichée : elle apparaîtra sur le site le ${full(start)}.`;
}
if (end && isExpired(promo, today)) {
return `Période terminée le ${full(end)} : elle n'apparaît plus sur le site.`;
}
return null;
}
+25 -4
View File
@@ -1,7 +1,22 @@
import { and, asc, count, desc, eq } from "drizzle-orm";
import { and, asc, count, desc, eq, sql } from "drizzle-orm";
import { db } from "@/db";
import { categories, members, promotions } from "@/db/schema";
/**
* Promotion réellement visible par le public : validée **et** dans sa période
* de validité.
*
* Les deux bornes sont facultatives — une offre sans date reste affichée sans
* limite. La journée de référence est celle de l'association, pas celle du
* serveur : un conteneur en UTC retirerait sinon une offre « valable jusqu'au
* 30 juin » deux heures trop tôt.
*/
const VISIBLE_PROMO = and(
eq(promotions.status, "live"),
sql`(${promotions.startsOn} is null or ${promotions.startsOn} <= (current_timestamp at time zone 'Europe/Paris')::date)`,
sql`(${promotions.endsOn} is null or ${promotions.endsOn} >= (current_timestamp at time zone 'Europe/Paris')::date)`
);
export async function getActiveMembersWithCategory() {
return db
.select({
@@ -32,7 +47,7 @@ export async function getLiveBadgesByMember() {
return db
.select({ memberId: promotions.memberId, badge: promotions.badge })
.from(promotions)
.where(eq(promotions.status, "live"));
.where(VISIBLE_PROMO);
}
export async function getCategoriesWithCounts(limit = 6) {
@@ -75,7 +90,7 @@ export async function getLivePromotions(limit = 6) {
})
.from(promotions)
.leftJoin(members, eq(promotions.memberId, members.id))
.where(eq(promotions.status, "live"))
.where(VISIBLE_PROMO)
.orderBy(desc(promotions.createdAt))
.limit(limit);
return rows;
@@ -125,7 +140,7 @@ export async function getMemberLivePromotions(memberId: number) {
endsOn: promotions.endsOn,
})
.from(promotions)
.where(and(eq(promotions.memberId, memberId), eq(promotions.status, "live")))
.where(and(eq(promotions.memberId, memberId), VISIBLE_PROMO))
.orderBy(desc(promotions.createdAt));
}
@@ -222,3 +237,9 @@ export async function getCategoriesInUse() {
const rows = await getCategoriesWithCounts(1000);
return rows.filter((c) => Number(c.memberCount) > 0);
}
/** Promotions réellement affichées aujourd'hui — le chiffre du tableau de bord. */
export async function countVisiblePromotions(): Promise<number> {
const [row] = await db.select({ n: count() }).from(promotions).where(VISIBLE_PROMO);
return row?.n ?? 0;
}