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

+44 -5
View File
@@ -50,11 +50,12 @@ docker compose up --build # full stack
## Promotions ## Promotions
Statuts : `pending` → `live` → `suspended` ⇄ `live` (+ `rejected` / `expired`). Statuts : `pending` → (`scheduled` →) `live` → `suspended` ⇄ `live`
(+ `rejected` / `expired`).
`promotions.suspended_by` retient qui a suspendu (`member` ou `staff`) : une `promotions.suspended_by` retient qui a suspendu (`member` ou `staff`) : une
suspension par le staff ne peut être levée que par le staff. Les lectures suspension par le staff ne peut être levée que par le staff. Les lectures
publiques filtrent sur `status = 'live'`, donc une promo suspendue disparaît du publiques filtrent sur le statut `live`, donc une promo suspendue — ou encore
site sans traitement supplémentaire. programmée — disparaît du site sans traitement supplémentaire.
**Période de validité** : `promotions.starts_on` / `ends_on` (colonnes `date`, **Période de validité** : `promotions.starts_on` / `ends_on` (colonnes `date`,
les deux facultatives et indépendantes). `src/lib/promo-validity.ts` est **pur** les deux facultatives et indépendantes). `src/lib/promo-validity.ts` est **pur**
@@ -67,6 +68,43 @@ ne sert plus que de repli d'affichage. La même phrase part dans le message
Facebook / LinkedIn (`buildPromoMessage`) : un seul formateur pour le site, Facebook / LinkedIn (`buildPromoMessage`) : un seul formateur pour le site,
le backoffice et les réseaux. le backoffice et les réseaux.
Une promotion n'est **affichée que pendant sa période** : les lectures de
`src/lib/queries.ts` passent par `VISIBLE_PROMO` (statut `live` **et** fenêtre
de dates, journée calculée en `Europe/Paris` — un conteneur en UTC retirerait
sinon une offre deux heures trop tôt). Le statut n'est pas modifié : hors
période, le backoffice affiche `visibilityNote()` (« pas encore affichée » /
« période terminée »), et l'offre revient d'elle-même si les dates changent.
**Publication programmée** : `promotions.publish_at` (`timestamptz`, vide =
mise en ligne immédiate). L'adhérent propose une date dans son formulaire, le
modérateur la garde, la déplace ou la vide dans « Valider » — comme les cases
réseaux, c'est le dernier moment où elle est ajustable. Une promotion validée
avec une échéance future passe au statut `scheduled` : invisible du site et
**rien n'est diffusé**. `releaseDuePromotions()` (`src/lib/promo-publish.ts`)
fait la bascule `scheduled → live` par un `UPDATE … RETURNING` filtré sur le
statut — atomique, donc sans double publication même à plusieurs instances —
puis appelle `publishPromoShares()`. Le bouton « Publier maintenant » de la
modération court-circuite l'attente.
`src/lib/promo-schedule.ts` est **pur** (`tests/promo-schedule.test.ts`) :
il convertit la saisie `datetime-local` depuis/vers l'heure de l'association
(`Europe/Paris`, changements d'heure compris) et refuse une saisie illisible
plutôt que de la transformer en publication immédiate.
Le déclencheur est `src/instrumentation.ts` : une boucle d'une minute démarrée
avec le serveur, avec un premier passage au démarrage pour rattraper les
échéances tombées pendant un redéploiement. `PROMO_SCHEDULER=off` la désactive.
Le travail vit dans `src/instrumentation-node.ts` parce que Next compile aussi
`instrumentation.ts` pour le runtime edge du middleware : `next.config.mjs`
l'écarte de ce bundle (`IgnorePlugin`), sans quoi webpack tente d'y embarquer
`pg` et ses dépendances Node.
`src/lib/promo-publish.ts` porte `publishPromoShares()` — et non plus
`backend/actions.ts` — parce qu'il a deux appelants : une action serveur et la
boucle de libération, qui n'a pas de requête et ne peut donc pas appeler
`revalidatePath()`. `src/lib/activity-log.ts` porte `logActivity()` pour la
même raison.
## Réseaux sociaux ## Réseaux sociaux
- `src/lib/social.ts` publie une promo sur la page Facebook (Graph API) ou - `src/lib/social.ts` publie une promo sur la page Facebook (Graph API) ou
@@ -93,11 +131,12 @@ le backoffice et les réseaux.
- Les images de promo sont stockées en data-URI : l'upload se fait donc en - Les images de promo sont stockées en data-URI : l'upload se fait donc en
binaire (multipart pour Facebook, Images API en 3 étapes pour LinkedIn), pas binaire (multipart pour Facebook, Images API en 3 étapes pour LinkedIn), pas
par URL. par URL.
- La diffusion est déclenchée **par la validation**, pas par un bouton : - La diffusion est déclenchée **par la validation** (ou par l'échéance d'une
publication programmée), pas par un bouton :
`promotions.share_facebook` / `share_linkedin` sont choisis par l'adhérent, `promotions.share_facebook` / `share_linkedin` sont choisis par l'adhérent,
ajustables par le modérateur dans le formulaire « Valider », puis figés ajustables par le modérateur dans le formulaire « Valider », puis figés
(`status !== 'pending'`). (`status !== 'pending'`).
- `publishPromoShares()` dans `backend/actions.ts` est le seul point de - `publishPromoShares()` dans `src/lib/promo-publish.ts` est le seul point de
publication. Elle ne lève jamais et ignore tout réseau ayant déjà une ligne publication. Elle ne lève jamais et ignore tout réseau ayant déjà une ligne
`social_posts` en `posted` : c'est la garde anti-republication, qui couvre `social_posts` en `posted` : c'est la garde anti-republication, qui couvre
aussi le cycle suspension → remise en ligne. aussi le cycle suspension → remise en ligne.
+2
View File
@@ -0,0 +1,2 @@
ALTER TYPE "public"."promo_status" ADD VALUE 'scheduled';--> statement-breakpoint
ALTER TABLE "promotions" ADD COLUMN "publish_at" timestamp with time zone;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -113,6 +113,13 @@
"when": 1788691741621, "when": 1788691741621,
"tag": "0015_curious_winter_soldier", "tag": "0015_curious_winter_soldier",
"breakpoints": true "breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1788692576315,
"tag": "0016_tiresome_korg",
"breakpoints": true
} }
] ]
} }
+14
View File
@@ -8,6 +8,20 @@ const nextConfig = {
// Les images d'entête/logo sont envoyées en data-URL via server action. // Les images d'entête/logo sont envoyées en data-URL via server action.
serverActions: { bodySizeLimit: "4mb" }, serverActions: { bodySizeLimit: "4mb" },
}, },
// `instrumentation.ts` est compilé pour les deux runtimes dès qu'un
// middleware existe. Son chargement du libérateur de publications est déjà
// conditionné à `NEXT_RUNTIME === "nodejs"`, mais webpack suit quand même
// l'import et tente d'embarquer `pg` (donc `fs`, `path`, `stream`) dans le
// bundle edge, où ces modules n'existent pas. On coupe la branche à la
// source : côté edge, le module Node n'est tout simplement pas résolu.
webpack: (config, { nextRuntime, webpack }) => {
if (nextRuntime === "edge") {
config.plugins.push(
new webpack.IgnorePlugin({ resourceRegExp: /^\.\/instrumentation-node$/ })
);
}
return config;
},
async headers() { async headers() {
return [ return [
{ {
+37 -102
View File
@@ -13,7 +13,6 @@ import { CATEGORY_PALETTE } from "@/db/categories";
import { slugify } from "@/lib/slug"; import { slugify } from "@/lib/slug";
import { autoTags } from "@/lib/tags"; import { autoTags } from "@/lib/tags";
import { import {
activityLog,
categories, categories,
contactMessages, contactMessages,
imageConsents, imageConsents,
@@ -25,14 +24,10 @@ import {
pastMeetings, pastMeetings,
promotions, promotions,
siteSettings, siteSettings,
socialPosts,
users, users,
} from "@/db/schema"; } from "@/db/schema";
import { sanitizeActivityMessage } from "@/lib/activity";
import { can, LABEL_TO_ROLE } from "@/lib/rbac"; import { can, LABEL_TO_ROLE } from "@/lib/rbac";
import { import {
isNetworkConfigured,
publishPromoToNetwork,
SOCIAL_LABELS, SOCIAL_LABELS,
SOCIAL_NETWORKS, SOCIAL_NETWORKS,
type SocialNetwork, type SocialNetwork,
@@ -44,16 +39,14 @@ import {
} from "@/lib/social-accounts"; } from "@/lib/social-accounts";
import { normalizeWebsite } from "@/lib/member-profile"; import { normalizeWebsite } from "@/lib/member-profile";
import { isRangeInvalid } from "@/lib/promo-validity"; import { isRangeInvalid } from "@/lib/promo-validity";
import { formatSchedule, isDue, parseScheduleInput } from "@/lib/promo-schedule";
import { publishPromoShares, requestedNetworks } from "@/lib/promo-publish";
import { logActivity } from "@/lib/activity-log";
import { SITE_SETTING_DEFAULTS } from "@/lib/site-settings"; import { SITE_SETTING_DEFAULTS } from "@/lib/site-settings";
import type { AppRole } from "@/types/next-auth"; import type { AppRole } from "@/types/next-auth";
async function logActivity(message: string, dot = "#2C6FB3") {
// Le message agrège des saisies de tiers : on ne laisse passer que <strong>.
await db.insert(activityLog).values({ message: sanitizeActivityMessage(message), dot });
}
// Mot de passe temporaire lisible (sans caractères ambigus). // Mot de passe temporaire lisible (sans caractères ambigus).
// `randomInt` et non `Math.random()` : ce dernier n'est pas cryptographique et // `randomInt` et non `Math.random()` : ce dernier n'est pas cryptographique et
// permet de prédire les mots de passe suivants à partir de quelques tirages. // permet de prédire les mots de passe suivants à partir de quelques tirages.
@@ -133,13 +126,6 @@ function revalidatePromoPaths(memberId?: number | null) {
if (memberId) revalidatePath("/adherents/[id]", "page"); if (memberId) revalidatePath("/adherents/[id]", "page");
} }
/** Réseaux demandés pour une promo, dans l'ordre d'affichage. */
function requestedNetworks(promo: { shareFacebook: boolean; shareLinkedin: boolean }): SocialNetwork[] {
return SOCIAL_NETWORKS.filter((n) =>
n === "facebook" ? promo.shareFacebook : promo.shareLinkedin
);
}
function readShareTargets(formData: FormData) { function readShareTargets(formData: FormData) {
return { return {
shareFacebook: formData.get("shareFacebook") === "on", shareFacebook: formData.get("shareFacebook") === "on",
@@ -147,86 +133,6 @@ function readShareTargets(formData: FormData) {
}; };
} }
/**
* 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.
*/
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,
});
}
}
}
// ---- Promotion moderation ---- // ---- Promotion moderation ----
export async function moderatePromo(formData: FormData) { export async function moderatePromo(formData: FormData) {
const { role, userId, name } = await requireRole(); const { role, userId, name } = await requireRole();
@@ -243,20 +149,36 @@ export async function moderatePromo(formData: FormData) {
// Le modérateur peut ajuster les réseaux demandés par l'adhérent, mais // Le modérateur peut ajuster les réseaux demandés par l'adhérent, mais
// uniquement ici : après validation le choix est figé. // uniquement ici : après validation le choix est figé.
const targets = readShareTargets(formData); const targets = readShareTargets(formData);
// Le champ est pré-rempli avec la date demandée par l'adhérent : vidé, la
// promotion part tout de suite ; une échéance déjà passée aussi.
const publishAt = parseScheduleInput(String(formData.get("publishAt") ?? ""));
const immediate = isDue(publishAt);
await db await db
.update(promotions) .update(promotions)
.set({ .set({
status: "live", status: immediate ? "live" : "scheduled",
publishAt: immediate ? null : publishAt,
...targets, ...targets,
suspendedBy: null, suspendedBy: null,
suspendedById: null, suspendedById: null,
suspendedAt: null, suspendedAt: null,
}) })
.where(eq(promotions.id, id)); .where(eq(promotions.id, id));
await logActivity(`Promotion « ${promo.title} » validée et mise en ligne`, "#1f8a5b");
// Mise en ligne committée avant l'appel réseau : si un réseau répond mal, if (immediate) {
// la promo est quand même publiée sur le site. await logActivity(`Promotion « ${promo.title} » validée et mise en ligne`, "#1f8a5b");
await publishPromoShares(id, requestedNetworks(targets), userId); // Mise en ligne committée avant l'appel réseau : si un réseau répond mal,
// la promo est quand même publiée sur le site.
await publishPromoShares(id, requestedNetworks(targets), userId);
} else {
// Rien n'est diffusé maintenant : `releaseDuePromotions()` s'en chargera
// à l'échéance, site et réseaux d'un seul coup.
await logActivity(
`Promotion « ${promo.title} » validée, publication programmée ${formatSchedule(publishAt)}`,
"#9a6638"
);
}
} else if (action === "suspend") { } else if (action === "suspend") {
// Suspension par l'association : la promotion sort du site public et // Suspension par l'association : la promotion sort du site public et
// l'adhérent ne peut pas la remettre en ligne lui-même. // l'adhérent ne peut pas la remettre en ligne lui-même.
@@ -268,6 +190,15 @@ export async function moderatePromo(formData: FormData) {
`Promotion « ${promo.title} » suspendue par <strong>${name}</strong>`, `Promotion « ${promo.title} » suspendue par <strong>${name}</strong>`,
"#d8472b" "#d8472b"
); );
} else if (action === "publish-now") {
// Raccourci sur une promo programmée : on n'attend pas l'échéance.
if (promo.status !== "scheduled") return;
await db
.update(promotions)
.set({ status: "live", publishAt: null })
.where(eq(promotions.id, id));
await logActivity(`Promotion « ${promo.title} » publiée avant l'heure programmée`, "#1f8a5b");
await publishPromoShares(id, requestedNetworks(promo), userId);
} else if (action === "restore") { } else if (action === "restore") {
await db await db
.update(promotions) .update(promotions)
@@ -388,6 +319,9 @@ export async function publishPromo(formData: FormData) {
throw new Error("La date de fin doit être postérieure à la date de début."); throw new Error("La date de fin doit être postérieure à la date de début.");
} }
const imageUrl = asImageDataUri(formData, "imageUrl"); const imageUrl = asImageDataUri(formData, "imageUrl");
// Date souhaitée de mise en ligne. Elle n'engage rien : le modérateur la voit
// et peut la garder, la déplacer ou la vider dans le formulaire « Valider ».
const publishAt = parseScheduleInput(String(formData.get("publishAt") ?? ""));
// Réseaux souhaités : rien n'est publié ici, la diffusion attend la validation. // Réseaux souhaités : rien n'est publié ici, la diffusion attend la validation.
await db.insert(promotions).values({ await db.insert(promotions).values({
@@ -400,6 +334,7 @@ export async function publishPromo(formData: FormData) {
status: "pending", status: "pending",
startsOn, startsOn,
endsOn, endsOn,
publishAt,
...readShareTargets(formData), ...readShareTargets(formData),
}); });
+30 -2
View File
@@ -8,6 +8,7 @@ import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
import type { SocialNetwork } from "@/lib/social"; import type { SocialNetwork } from "@/lib/social";
import type { PromoCategoryGroup } from "@/lib/promo-categories"; import type { PromoCategoryGroup } from "@/lib/promo-categories";
import { formatValidity, isRangeInvalid } from "@/lib/promo-validity"; import { formatValidity, isRangeInvalid } from "@/lib/promo-validity";
import { formatSchedule, parseScheduleInput } from "@/lib/promo-schedule";
const STRIPE_WARM = const STRIPE_WARM =
"repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)"; "repeating-linear-gradient(45deg,#efe9da,#efe9da 12px,#e6ddc9 12px,#e6ddc9 24px)";
@@ -39,6 +40,7 @@ export function MemberSpaceForm({
const [badge, setBadge] = useState(""); const [badge, setBadge] = useState("");
const [startsOn, setStartsOn] = useState(""); const [startsOn, setStartsOn] = useState("");
const [endsOn, setEndsOn] = useState(""); const [endsOn, setEndsOn] = useState("");
const [publishAt, setPublishAt] = useState("");
const [imgData, setImgData] = useState(""); const [imgData, setImgData] = useState("");
const [shares, setShares] = useState<SocialNetwork[]>([]); const [shares, setShares] = useState<SocialNetwork[]>([]);
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
@@ -67,6 +69,7 @@ export function MemberSpaceForm({
setBadge(""); setBadge("");
setStartsOn(""); setStartsOn("");
setEndsOn(""); setEndsOn("");
setPublishAt("");
setImgData(""); setImgData("");
setShares([]); setShares([]);
setSubmitted(false); setSubmitted(false);
@@ -83,6 +86,7 @@ export function MemberSpaceForm({
fd.set("badge", badge); fd.set("badge", badge);
fd.set("startsOn", startsOn); fd.set("startsOn", startsOn);
fd.set("endsOn", endsOn); fd.set("endsOn", endsOn);
fd.set("publishAt", publishAt);
fd.set("imageUrl", imgData); fd.set("imageUrl", imgData);
if (shares.includes("facebook")) fd.set("shareFacebook", "on"); if (shares.includes("facebook")) fd.set("shareFacebook", "on");
if (shares.includes("linkedin")) fd.set("shareLinkedin", "on"); if (shares.includes("linkedin")) fd.set("shareLinkedin", "on");
@@ -99,6 +103,14 @@ export function MemberSpaceForm({
const validity = formatValidity({ startsOn, endsOn }); const validity = formatValidity({ startsOn, endsOn });
// Bornes incohérentes : on le dit avant l'envoi plutôt qu'après un refus. // Bornes incohérentes : on le dit avant l'envoi plutôt qu'après un refus.
const badRange = isRangeInvalid({ startsOn, endsOn }); 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 ( return (
<div className="grid grid-2" style={{ alignItems: "start" }}> <div className="grid grid-2" style={{ alignItems: "start" }}>
@@ -208,8 +220,24 @@ export function MemberSpaceForm({
{badRange {badRange
? "La date de fin est antérieure à la date de début." ? "La date de fin est antérieure à la date de début."
: validity : validity
? `Affiché sur le site et dans la publication : « ${validity} »` ? `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 : rien ne sera affiché."} : "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>
</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 { configuredNetworks, SOCIAL_LABELS, type SocialNetwork } from "@/lib/social";
import { PROMO_CATEGORY_GROUPS, defaultPromoCategory } from "@/lib/promo-categories"; import { PROMO_CATEGORY_GROUPS, defaultPromoCategory } from "@/lib/promo-categories";
import { memberPath } from "@/lib/seo"; 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 { setOwnPromoShareTargets, setOwnPromoSuspension } from "../../actions";
import { EspaceHeader } from "../EspaceHeader"; import { EspaceHeader } from "../EspaceHeader";
import { MemberSpaceForm } from "../MemberSpaceForm"; 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" }, rejected: { label: "Refusée", bg: "#fbe9e6", color: "#d8472b" },
suspended: { label: "Suspendue", bg: "#fbe9e6", color: "#d8472b" }, suspended: { label: "Suspendue", bg: "#fbe9e6", color: "#d8472b" },
expired: { label: "Expirée", bg: "#f1efe7", color: "#a99c82" }, expired: { label: "Expirée", bg: "#f1efe7", color: "#a99c82" },
scheduled: { label: "Programmée", bg: "#eaf0f6", color: "#2C6FB3" },
}; };
type MyPromo = { type MyPromo = {
@@ -29,6 +31,7 @@ type MyPromo = {
category: string | null; category: string | null;
badge: string | null; badge: string | null;
validUntil: string | null; validUntil: string | null;
publishAt: Date | null;
startsOn: string | null; startsOn: string | null;
endsOn: string | null; endsOn: string | null;
createdAt: Date; createdAt: Date;
@@ -82,6 +85,7 @@ export default async function EspacePromotionsPage() {
category: promotions.category, category: promotions.category,
badge: promotions.badge, badge: promotions.badge,
validUntil: promotions.validUntil, validUntil: promotions.validUntil,
publishAt: promotions.publishAt,
startsOn: promotions.startsOn, startsOn: promotions.startsOn,
endsOn: promotions.endsOn, endsOn: promotions.endsOn,
createdAt: promotions.createdAt, createdAt: promotions.createdAt,
@@ -116,7 +120,12 @@ export default async function EspacePromotionsPage() {
const networks = await configuredNetworks(); const networks = await configuredNetworks();
const live = myPromos.filter((p) => p.status === "live"); const live = myPromos.filter((p) => p.status === "live");
const pending = myPromos.filter((p) => p.status === "pending"); 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 ( return (
<div> <div>
@@ -156,6 +165,16 @@ export default async function EspacePromotionsPage() {
networks={networks} 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 && ( {pending.length > 0 && (
<PromoSection <PromoSection
title="En attente de validation" title="En attente de validation"
@@ -286,6 +305,16 @@ function PromoRow({ promo: mp, networks, lastShare }: { promo: MyPromo; networks
</form> </form>
)} )}
</div> </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 && ( {suspendedByStaff && (
<div style={{ marginTop: 10, background: "#fbe9e6", border: "1px solid #f2d5cf", color: "#a8503c", borderRadius: 9, padding: "9px 11px", fontSize: 12.5, lineHeight: 1.5 }}> <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. Cette promotion a été suspendue par l&apos;association. Contactez-la pour la remettre en ligne.
+3 -2
View File
@@ -6,6 +6,7 @@ import { db } from "@/db";
import { activityLog, contactMessages, members, membershipRequests, promotions, socialPosts } from "@/db/schema"; import { activityLog, contactMessages, members, membershipRequests, promotions, socialPosts } from "@/db/schema";
import { activityNodes } from "@/lib/activity"; import { activityNodes } from "@/lib/activity";
import { can, isStaff } from "@/lib/rbac"; import { can, isStaff } from "@/lib/rbac";
import { countVisiblePromotions } from "@/lib/queries";
import { import {
expiryStatus, expiryStatus,
getSocialAccounts, getSocialAccounts,
@@ -48,7 +49,7 @@ export default async function DashboardPage() {
const [activeMembers, livePromos, pendingPromos, requests, newMessages, activity] = await Promise.all([ const [activeMembers, livePromos, pendingPromos, requests, newMessages, activity] = await Promise.all([
db.select({ id: members.id }).from(members).where(eq(members.status, "active")), db.select({ id: members.id }).from(members).where(eq(members.status, "active")),
db.select({ id: promotions.id }).from(promotions).where(eq(promotions.status, "live")), countVisiblePromotions(),
db.select({ id: promotions.id }).from(promotions).where(eq(promotions.status, "pending")), db.select({ id: promotions.id }).from(promotions).where(eq(promotions.status, "pending")),
db.select({ id: membershipRequests.id }).from(membershipRequests).where(eq(membershipRequests.status, "new")), db.select({ id: membershipRequests.id }).from(membershipRequests).where(eq(membershipRequests.status, "new")),
db.select({ id: contactMessages.id }).from(contactMessages).where(eq(contactMessages.status, "new")), db.select({ id: contactMessages.id }).from(contactMessages).where(eq(contactMessages.status, "new")),
@@ -142,7 +143,7 @@ export default async function DashboardPage() {
<div className="grid grid-4" style={{ marginBottom: 26 }}> <div className="grid grid-4" style={{ marginBottom: 26 }}>
<StatCard label="Adhérents actifs" value={activeMembers.length} hint="sur le réseau" valueColor="#13324F" hintColor="#1f8a5b" /> <StatCard label="Adhérents actifs" value={activeMembers.length} hint="sur le réseau" valueColor="#13324F" hintColor="#1f8a5b" />
<StatCard label="Promotions en ligne" value={livePromos.length} hint="visibles sur le site" /> <StatCard label="Promotions en ligne" value={livePromos} hint="visibles sur le site" />
<StatCard label="À modérer" value={pendingCount} hint="en attente de validation" valueColor="#9a6638" hintColor="#9a6638" /> <StatCard label="À modérer" value={pendingCount} hint="en attente de validation" valueColor="#9a6638" hintColor="#9a6638" />
<Link href="/backend/demandes" style={{ textDecoration: "none" }}> <Link href="/backend/demandes" style={{ textDecoration: "none" }}>
<StatCard <StatCard
+53 -4
View File
@@ -8,7 +8,8 @@ import { configuredNetworks, SOCIAL_LABELS } from "@/lib/social";
import { moderatePromo, retryPromoShare } from "../actions"; import { moderatePromo, retryPromoShare } from "../actions";
import { PromoImage } from "@/components/PromoImage"; import { PromoImage } from "@/components/PromoImage";
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons"; import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
import { formatValidity } from "@/lib/promo-validity"; import { formatValidity, visibilityNote } from "@/lib/promo-validity";
import { formatSchedule, scheduleLabel, toScheduleInput } from "@/lib/promo-schedule";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -21,6 +22,7 @@ const STATUS_CHIP: Record<string, { label: string; bg: string; color: string }>
pending: { label: "En attente", bg: "#fbeede", color: "#9a6638" }, pending: { label: "En attente", bg: "#fbeede", color: "#9a6638" },
live: { label: "En ligne", bg: "#e6f4ec", color: "#1f8a5b" }, live: { label: "En ligne", bg: "#e6f4ec", color: "#1f8a5b" },
suspended: { label: "Suspendue", bg: "#fbe9e6", color: "#d8472b" }, suspended: { label: "Suspendue", bg: "#fbe9e6", color: "#d8472b" },
scheduled: { label: "Programmée", bg: "#eaf0f6", color: "#2C6FB3" },
}; };
function fmtDate(d: Date) { function fmtDate(d: Date) {
@@ -42,6 +44,7 @@ export default async function PromotionsPage() {
text: promotions.text, text: promotions.text,
category: promotions.category, category: promotions.category,
status: promotions.status, status: promotions.status,
publishAt: promotions.publishAt,
validUntil: promotions.validUntil, validUntil: promotions.validUntil,
startsOn: promotions.startsOn, startsOn: promotions.startsOn,
endsOn: promotions.endsOn, endsOn: promotions.endsOn,
@@ -54,14 +57,16 @@ export default async function PromotionsPage() {
}) })
.from(promotions) .from(promotions)
.leftJoin(members, eq(promotions.memberId, members.id)) .leftJoin(members, eq(promotions.memberId, members.id))
.where(inArray(promotions.status, ["pending", "live", "suspended"])) .where(inArray(promotions.status, ["pending", "live", "suspended", "scheduled"]))
.orderBy(desc(promotions.createdAt)); .orderBy(desc(promotions.createdAt));
const pending = rows.filter((r) => r.status === "pending"); const pending = rows.filter((r) => r.status === "pending");
const live = rows.filter((r) => r.status === "live"); const live = rows.filter((r) => r.status === "live");
const suspended = rows.filter((r) => r.status === "suspended"); const suspended = rows.filter((r) => r.status === "suspended");
// En attente d'abord, puis en ligne, puis suspendues. const scheduled = rows.filter((r) => r.status === "scheduled");
const ordered = [...pending, ...live, ...suspended]; // En attente d'abord, puis les programmées (elles attendent une décision de
// calendrier), puis les offres en ligne et enfin les suspendues.
const ordered = [...pending, ...scheduled, ...live, ...suspended];
const shares = ordered.length const shares = ordered.length
? await db ? await db
@@ -94,6 +99,9 @@ export default async function PromotionsPage() {
<span style={{ background: "#13324F", color: "#fff", fontSize: 13, fontWeight: 600, padding: "8px 16px", borderRadius: 999 }}> <span style={{ background: "#13324F", color: "#fff", fontSize: 13, fontWeight: 600, padding: "8px 16px", borderRadius: 999 }}>
En attente · {pending.length} En attente · {pending.length}
</span> </span>
<span style={{ background: "#fff", border: "1px solid #e6dcc6", color: "#6c6150", fontSize: 13, fontWeight: 600, padding: "8px 16px", borderRadius: 999 }}>
Programmées · {scheduled.length}
</span>
<span style={{ background: "#fff", border: "1px solid #e6dcc6", color: "#6c6150", fontSize: 13, fontWeight: 600, padding: "8px 16px", borderRadius: 999 }}> <span style={{ background: "#fff", border: "1px solid #e6dcc6", color: "#6c6150", fontSize: 13, fontWeight: 600, padding: "8px 16px", borderRadius: 999 }}>
En ligne · {live.length} En ligne · {live.length}
</span> </span>
@@ -119,6 +127,7 @@ export default async function PromotionsPage() {
{ordered.map((p) => { {ordered.map((p) => {
const isPending = p.status === "pending"; const isPending = p.status === "pending";
const isSuspended = p.status === "suspended"; const isSuspended = p.status === "suspended";
const isScheduled = p.status === "scheduled";
const chip = STATUS_CHIP[p.status] ?? STATUS_CHIP.pending; const chip = STATUS_CHIP[p.status] ?? STATUS_CHIP.pending;
const chosenNetworks = networks.filter((n) => const chosenNetworks = networks.filter((n) =>
n === "facebook" ? p.shareFacebook : p.shareLinkedin n === "facebook" ? p.shareFacebook : p.shareLinkedin
@@ -188,6 +197,18 @@ export default async function PromotionsPage() {
</div> </div>
)} )}
{p.status === "live" && visibilityNote(p) && (
<div style={{ background: "#faf7ef", border: "1px solid #f0e8d6", color: "#9a8d72", borderRadius: 10, padding: "9px 11px", fontSize: 12, lineHeight: 1.45, marginBottom: 11 }}>
{visibilityNote(p)}
</div>
)}
{isScheduled && (
<div style={{ background: "#eaf0f6", border: "1px solid #d3e0ee", color: "#2C6FB3", borderRadius: 10, padding: "9px 11px", fontSize: 12, lineHeight: 1.45, marginBottom: 11, fontWeight: 600 }}>
{scheduleLabel(p.publishAt)}. Rien n&apos;est encore visible ni diffusé.
</div>
)}
{isSuspended && ( {isSuspended && (
<div style={{ background: "#fbe9e6", border: "1px solid #f2d5cf", color: "#a8503c", borderRadius: 10, padding: "9px 11px", fontSize: 12, lineHeight: 1.45, marginBottom: 11 }}> <div style={{ background: "#fbe9e6", border: "1px solid #f2d5cf", color: "#a8503c", borderRadius: 10, padding: "9px 11px", fontSize: 12, lineHeight: 1.45, marginBottom: 11 }}>
Suspendue par {p.suspendedBy === "member" ? "l'adhérent" : "l'association"} Suspendue par {p.suspendedBy === "member" ? "l'adhérent" : "l'association"}
@@ -226,6 +247,24 @@ export default async function PromotionsPage() {
</div> </div>
</div> </div>
)} )}
{/* Date demandée par l'adhérent, modifiable ici. Vidée,
la promotion part immédiatement. */}
<div style={{ background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 10, padding: "10px 11px", marginBottom: 8 }}>
<label style={{ display: "block", fontSize: 10.5, letterSpacing: "0.08em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 7 }}>
Publication
</label>
<input
type="datetime-local"
name="publishAt"
defaultValue={toScheduleInput(p.publishAt)}
style={{ width: "100%", boxSizing: "border-box", border: "1px solid #e6dcc6", borderRadius: 8, padding: "7px 9px", fontSize: 12.5, fontFamily: "inherit", color: "#3c3322", background: "#fff" }}
/>
<div style={{ fontSize: 11, color: "#a99c82", marginTop: 7, lineHeight: 1.45 }}>
{p.publishAt
? `Demandé par l'adhérent ${formatSchedule(p.publishAt)}. Videz le champ pour publier tout de suite.`
: "Vide : mise en ligne immédiate. Renseignez une date pour programmer."}
</div>
</div>
<button type="submit" style={{ width: "100%", border: "none", background: "#1f8a5b", color: "#fff", fontWeight: 700, fontSize: 13, padding: 10, borderRadius: 9, cursor: "pointer" }}> <button type="submit" style={{ width: "100%", border: "none", background: "#1f8a5b", color: "#fff", fontWeight: 700, fontSize: 13, padding: 10, borderRadius: 9, cursor: "pointer" }}>
Valider Valider
</button> </button>
@@ -242,6 +281,16 @@ export default async function PromotionsPage() {
</div> </div>
)} )}
{isScheduled && (
<form action={moderatePromo} style={{ marginBottom: 8 }}>
<input type="hidden" name="id" value={p.id} />
<input type="hidden" name="action" value="publish-now" />
<button type="submit" style={{ width: "100%", border: "none", background: "#2C6FB3", color: "#fff", fontWeight: 700, fontSize: 13, padding: 10, borderRadius: 9, cursor: "pointer" }}>
Publier maintenant
</button>
</form>
)}
{!isPending && ( {!isPending && (
<div style={{ display: "flex", gap: 8 }}> <div style={{ display: "flex", gap: 8 }}>
<form action={moderatePromo} style={{ flex: 1 }}> <form action={moderatePromo} style={{ flex: 1 }}>
+6
View File
@@ -22,6 +22,10 @@ export const promoStatusEnum = pgEnum("promo_status", [
"expired", "expired",
"rejected", "rejected",
"suspended", "suspended",
// Validée par l'association mais en attente de sa date de publication
// (`publish_at`). Invisible du site public tant que l'échéance n'est pas
// atteinte, et non encore diffusée sur les réseaux.
"scheduled",
]); ]);
// Qui a suspendu la promotion : son créateur (l'adhérent) ou l'association. // Qui a suspendu la promotion : son créateur (l'adhérent) ou l'association.
// Une promotion suspendue par l'association ne peut être réactivée que par elle. // Une promotion suspendue par l'association ne peut être réactivée que par elle.
@@ -118,6 +122,8 @@ export const promotions = pgTable("promotions", {
// promo est « pending » ; la validation le fige ET déclenche la publication. // promo est « pending » ; la validation le fige ET déclenche la publication.
shareFacebook: boolean("share_facebook").notNull().default(false), shareFacebook: boolean("share_facebook").notNull().default(false),
shareLinkedin: boolean("share_linkedin").notNull().default(false), shareLinkedin: boolean("share_linkedin").notNull().default(false),
// Programmation de la publication. Vide = mise en ligne dès la validation.
publishAt: timestamp("publish_at", { withTimezone: true }),
suspendedBy: promoSuspendedByEnum("suspended_by"), suspendedBy: promoSuspendedByEnum("suspended_by"),
suspendedById: integer("suspended_by_id").references(() => users.id, { onDelete: "set null" }), suspendedById: integer("suspended_by_id").references(() => users.id, { onDelete: "set null" }),
suspendedAt: timestamp("suspended_at", { withTimezone: true }), suspendedAt: timestamp("suspended_at", { withTimezone: true }),
+24
View File
@@ -0,0 +1,24 @@
/**
* Boucle de libération des publications programmées. Fichier séparé de
* `instrumentation.ts` : il touche la base et ne doit donc jamais entrer dans
* le bundle edge du middleware.
*/
import { releaseDuePromotions } from "@/lib/promo-publish";
const INTERVAL_MS = 60_000;
async function tick() {
try {
const n = await releaseDuePromotions();
if (n > 0) console.log(`[promotions] ${n} publication(s) programmée(s) mise(s) en ligne`);
} catch (error) {
// `releaseDuePromotions` avale déjà ses erreurs ; ce filet couvre le cas
// d'une base injoignable au démarrage, qui ne doit pas tuer le serveur.
console.error("[promotions] libérateur :", error);
}
}
// Un premier passage au démarrage rattrape les échéances tombées pendant un
// redéploiement ou une coupure.
void tick();
setInterval(tick, INTERVAL_MS).unref();
+21
View File
@@ -0,0 +1,21 @@
/**
* Démarrage du serveur : libérateur des publications programmées.
*
* Le déploiement est un conteneur unique et permanent (`docker-compose`), il
* n'y a donc ni cron ni file d'attente. Une boucle minute suffit : elle relit
* les promotions `scheduled` dont l'échéance est passée, les met en ligne et
* lance la diffusion réseaux. La minute est aussi la précision annoncée dans
* le formulaire.
*
* Sûr à plusieurs instances : la bascule passe par un `UPDATE … RETURNING`
* filtré sur le statut (voir `releaseDuePromotions`), une seule instance
* récupère chaque promotion.
*/
export async function register() {
// `register()` est aussi appelé pour le runtime edge, où il n'y a ni base
// ni `setInterval` durable : le module Node est chargé à la demande pour ne
// pas entrer dans ce bundle.
if (process.env.NEXT_RUNTIME !== "nodejs") return;
if (process.env.PROMO_SCHEDULER === "off") return;
await import("./instrumentation-node");
}
+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; if (!start || !end) return false;
return Date.UTC(end.year, end.month, end.day) < Date.UTC(start.year, start.month, start.day); 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 { db } from "@/db";
import { categories, members, promotions } from "@/db/schema"; 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() { export async function getActiveMembersWithCategory() {
return db return db
.select({ .select({
@@ -32,7 +47,7 @@ export async function getLiveBadgesByMember() {
return db return db
.select({ memberId: promotions.memberId, badge: promotions.badge }) .select({ memberId: promotions.memberId, badge: promotions.badge })
.from(promotions) .from(promotions)
.where(eq(promotions.status, "live")); .where(VISIBLE_PROMO);
} }
export async function getCategoriesWithCounts(limit = 6) { export async function getCategoriesWithCounts(limit = 6) {
@@ -75,7 +90,7 @@ export async function getLivePromotions(limit = 6) {
}) })
.from(promotions) .from(promotions)
.leftJoin(members, eq(promotions.memberId, members.id)) .leftJoin(members, eq(promotions.memberId, members.id))
.where(eq(promotions.status, "live")) .where(VISIBLE_PROMO)
.orderBy(desc(promotions.createdAt)) .orderBy(desc(promotions.createdAt))
.limit(limit); .limit(limit);
return rows; return rows;
@@ -125,7 +140,7 @@ export async function getMemberLivePromotions(memberId: number) {
endsOn: promotions.endsOn, endsOn: promotions.endsOn,
}) })
.from(promotions) .from(promotions)
.where(and(eq(promotions.memberId, memberId), eq(promotions.status, "live"))) .where(and(eq(promotions.memberId, memberId), VISIBLE_PROMO))
.orderBy(desc(promotions.createdAt)); .orderBy(desc(promotions.createdAt));
} }
@@ -222,3 +237,9 @@ export async function getCategoriesInUse() {
const rows = await getCategoriesWithCounts(1000); const rows = await getCategoriesWithCounts(1000);
return rows.filter((c) => Number(c.memberCount) > 0); 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;
}
+67
View File
@@ -0,0 +1,67 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
formatSchedule,
isDue,
isPending,
parseScheduleInput,
scheduleLabel,
toScheduleInput,
} from "../src/lib/promo-schedule";
test("programmation — champ vide = publication immédiate", () => {
assert.equal(parseScheduleInput(""), null);
assert.equal(parseScheduleInput(null), null);
assert.equal(parseScheduleInput(" "), null);
assert.equal(isDue(null), true);
});
test("programmation — heure d'hiver interprétée en heure de Paris (UTC+1)", () => {
const d = parseScheduleInput("2027-01-15T09:00");
assert.ok(d);
assert.equal(d.toISOString(), "2027-01-15T08:00:00.000Z");
});
test("programmation — heure d'été interprétée en heure de Paris (UTC+2)", () => {
const d = parseScheduleInput("2027-07-15T09:00");
assert.ok(d);
assert.equal(d.toISOString(), "2027-07-15T07:00:00.000Z");
});
test("programmation — minuit ne bascule pas d'un jour", () => {
const d = parseScheduleInput("2027-03-10T00:00");
assert.ok(d);
assert.equal(d.toISOString(), "2027-03-09T23:00:00.000Z");
});
test("programmation — aller-retour avec le champ du formulaire", () => {
for (const value of ["2027-01-15T09:00", "2027-07-15T18:30", "2027-12-31T23:45"]) {
assert.equal(toScheduleInput(parseScheduleInput(value)), value);
}
assert.equal(toScheduleInput(null), "");
});
test("programmation — une saisie illisible est refusée, jamais ignorée", () => {
assert.throws(() => parseScheduleInput("demain matin"));
assert.throws(() => parseScheduleInput("2027-13-45T99:99"));
assert.throws(() => parseScheduleInput("2027-01-15"));
});
test("programmation — échéance atteinte", () => {
const at = new Date("2027-01-15T08:00:00.000Z");
assert.equal(isDue(at, new Date("2027-01-15T07:59:59.000Z")), false);
assert.equal(isDue(at, at), true);
assert.equal(isDue(at, new Date("2027-01-15T08:00:01.000Z")), true);
assert.equal(isPending(at, new Date("2027-01-15T07:00:00.000Z")), true);
});
test("programmation — libellé en heure de Paris", () => {
assert.equal(formatSchedule(new Date("2027-01-15T08:00:00.000Z")), "le 15 janvier 2027 à 09:00");
assert.equal(formatSchedule(new Date("2027-07-15T07:00:00.000Z")), "le 15 juillet 2027 à 09:00");
assert.equal(formatSchedule(null), null);
assert.equal(
scheduleLabel(new Date("2027-01-15T08:00:00.000Z")),
"Publication programmée le 15 janvier 2027 à 09:00"
);
assert.equal(scheduleLabel(null), null);
});
+24
View File
@@ -5,7 +5,9 @@ import {
formatValidity, formatValidity,
formatValidityShort, formatValidityShort,
isExpired, isExpired,
isNotStarted,
isRangeInvalid, isRangeInvalid,
visibilityNote,
} from "../src/lib/promo-validity"; } from "../src/lib/promo-validity";
describe("Période de validité d'une promotion", () => { describe("Période de validité d'une promotion", () => {
@@ -74,3 +76,25 @@ describe("Période de validité d'une promotion", () => {
assert.equal(isRangeInvalid({ startsOn: "2027-06-01" }), false); assert.equal(isRangeInvalid({ startsOn: "2027-06-01" }), false);
}); });
}); });
describe("Visibilité publique d'une promotion", () => {
it("distingue avant, pendant et après la période", () => {
const promo = { startsOn: "2027-03-01", endsOn: "2027-03-15" };
assert.equal(
visibilityNote(promo, new Date("2027-02-20T12:00:00Z")),
"Pas encore affichée : elle apparaîtra sur le site le 1er mars 2027."
);
assert.equal(visibilityNote(promo, new Date("2027-03-01T00:00:00Z")), null);
assert.equal(visibilityNote(promo, new Date("2027-03-15T23:00:00Z")), null);
assert.equal(
visibilityNote(promo, new Date("2027-03-16T08:00:00Z")),
"Période terminée le 15 mars 2027 : elle n'apparaît plus sur le site."
);
});
it("laisse visible une offre sans date", () => {
assert.equal(visibilityNote({ startsOn: null, endsOn: null }), null);
assert.equal(visibilityNote({ validUntil: "Ce week-end" }), null);
assert.equal(isNotStarted({ startsOn: null }), false);
});
});