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
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
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
site sans traitement supplémentaire.
publiques filtrent sur le statut `live`, donc une promo suspendue — ou encore
programmée — disparaît du site sans traitement supplémentaire.
**Période de validité** : `promotions.starts_on` / `ends_on` (colonnes `date`,
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,
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
- `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
binaire (multipart pour Facebook, Images API en 3 étapes pour LinkedIn), pas
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,
ajustables par le modérateur dans le formulaire « Valider », puis figés
(`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
`social_posts` en `posted` : c'est la garde anti-republication, qui couvre
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,
"tag": "0015_curious_winter_soldier",
"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.
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() {
return [
{
+37 -102
View File
@@ -13,7 +13,6 @@ import { CATEGORY_PALETTE } from "@/db/categories";
import { slugify } from "@/lib/slug";
import { autoTags } from "@/lib/tags";
import {
activityLog,
categories,
contactMessages,
imageConsents,
@@ -25,14 +24,10 @@ import {
pastMeetings,
promotions,
siteSettings,
socialPosts,
users,
} from "@/db/schema";
import { sanitizeActivityMessage } from "@/lib/activity";
import { can, LABEL_TO_ROLE } from "@/lib/rbac";
import {
isNetworkConfigured,
publishPromoToNetwork,
SOCIAL_LABELS,
SOCIAL_NETWORKS,
type SocialNetwork,
@@ -44,16 +39,14 @@ import {
} from "@/lib/social-accounts";
import { normalizeWebsite } from "@/lib/member-profile";
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 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).
// `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.
@@ -133,13 +126,6 @@ function revalidatePromoPaths(memberId?: number | null) {
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) {
return {
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 ----
export async function moderatePromo(formData: FormData) {
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
// uniquement ici : après validation le choix est figé.
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
.update(promotions)
.set({
status: "live",
status: immediate ? "live" : "scheduled",
publishAt: immediate ? null : publishAt,
...targets,
suspendedBy: null,
suspendedById: null,
suspendedAt: null,
})
.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,
// la promo est quand même publiée sur le site.
await publishPromoShares(id, requestedNetworks(targets), userId);
if (immediate) {
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,
// 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") {
// Suspension par l'association : la promotion sort du site public et
// 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>`,
"#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") {
await db
.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.");
}
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.
await db.insert(promotions).values({
@@ -400,6 +334,7 @@ export async function publishPromo(formData: FormData) {
status: "pending",
startsOn,
endsOn,
publishAt,
...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 { 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.
+3 -2
View File
@@ -6,6 +6,7 @@ import { db } from "@/db";
import { activityLog, contactMessages, members, membershipRequests, promotions, socialPosts } from "@/db/schema";
import { activityNodes } from "@/lib/activity";
import { can, isStaff } from "@/lib/rbac";
import { countVisiblePromotions } from "@/lib/queries";
import {
expiryStatus,
getSocialAccounts,
@@ -48,7 +49,7 @@ export default async function DashboardPage() {
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: 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: membershipRequests.id }).from(membershipRequests).where(eq(membershipRequests.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 }}>
<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" />
<Link href="/backend/demandes" style={{ textDecoration: "none" }}>
<StatCard
+53 -4
View File
@@ -8,7 +8,8 @@ import { configuredNetworks, SOCIAL_LABELS } from "@/lib/social";
import { moderatePromo, retryPromoShare } from "../actions";
import { PromoImage } from "@/components/PromoImage";
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";
@@ -21,6 +22,7 @@ const STATUS_CHIP: Record<string, { label: string; bg: string; color: string }>
pending: { label: "En attente", bg: "#fbeede", color: "#9a6638" },
live: { label: "En ligne", bg: "#e6f4ec", color: "#1f8a5b" },
suspended: { label: "Suspendue", bg: "#fbe9e6", color: "#d8472b" },
scheduled: { label: "Programmée", bg: "#eaf0f6", color: "#2C6FB3" },
};
function fmtDate(d: Date) {
@@ -42,6 +44,7 @@ export default async function PromotionsPage() {
text: promotions.text,
category: promotions.category,
status: promotions.status,
publishAt: promotions.publishAt,
validUntil: promotions.validUntil,
startsOn: promotions.startsOn,
endsOn: promotions.endsOn,
@@ -54,14 +57,16 @@ export default async function PromotionsPage() {
})
.from(promotions)
.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));
const pending = rows.filter((r) => r.status === "pending");
const live = rows.filter((r) => r.status === "live");
const suspended = rows.filter((r) => r.status === "suspended");
// En attente d'abord, puis en ligne, puis suspendues.
const ordered = [...pending, ...live, ...suspended];
const scheduled = rows.filter((r) => r.status === "scheduled");
// 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
? 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 }}>
En attente · {pending.length}
</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 }}>
En ligne · {live.length}
</span>
@@ -119,6 +127,7 @@ export default async function PromotionsPage() {
{ordered.map((p) => {
const isPending = p.status === "pending";
const isSuspended = p.status === "suspended";
const isScheduled = p.status === "scheduled";
const chip = STATUS_CHIP[p.status] ?? STATUS_CHIP.pending;
const chosenNetworks = networks.filter((n) =>
n === "facebook" ? p.shareFacebook : p.shareLinkedin
@@ -188,6 +197,18 @@ export default async function PromotionsPage() {
</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 && (
<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"}
@@ -226,6 +247,24 @@ export default async function PromotionsPage() {
</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" }}>
Valider
</button>
@@ -242,6 +281,16 @@ export default async function PromotionsPage() {
</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 && (
<div style={{ display: "flex", gap: 8 }}>
<form action={moderatePromo} style={{ flex: 1 }}>
+6
View File
@@ -22,6 +22,10 @@ export const promoStatusEnum = pgEnum("promo_status", [
"expired",
"rejected",
"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.
// 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.
shareFacebook: boolean("share_facebook").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"),
suspendedById: integer("suspended_by_id").references(() => users.id, { onDelete: "set null" }),
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;
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;
}
+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,
formatValidityShort,
isExpired,
isNotStarted,
isRangeInvalid,
visibilityNote,
} from "../src/lib/promo-validity";
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);
});
});
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);
});
});