mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-23 23:28:04 +02:00
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
118 lines
4.2 KiB
TypeScript
118 lines
4.2 KiB
TypeScript
/**
|
|
* Période de validité d'une promotion.
|
|
*
|
|
* Module **pur** : aucune dépendance à la base ni à React, pour être verrouillé
|
|
* par les tests. Les dates arrivent en `YYYY-MM-DD` (colonnes `date` de
|
|
* Postgres, lues telles quelles par Drizzle) et sont donc traitées comme des
|
|
* jours calendaires, sans fuseau horaire.
|
|
*/
|
|
|
|
export type PromoValidity = {
|
|
startsOn?: string | null;
|
|
endsOn?: string | null;
|
|
/** Texte libre des anciennes promotions, utilisé à défaut de dates. */
|
|
validUntil?: string | null;
|
|
};
|
|
|
|
const MONTHS = [
|
|
"janvier", "février", "mars", "avril", "mai", "juin",
|
|
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
|
|
];
|
|
|
|
type Parts = { day: number; month: number; year: number };
|
|
|
|
function parse(value: string | null | undefined): Parts | null {
|
|
if (!value) return null;
|
|
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value.trim());
|
|
if (!match) return null;
|
|
const [, year, month, day] = match;
|
|
const parts = { day: Number(day), month: Number(month) - 1, year: Number(year) };
|
|
if (parts.month < 0 || parts.month > 11 || parts.day < 1 || parts.day > 31) return null;
|
|
return parts;
|
|
}
|
|
|
|
/** « 1er » et non « 1 », comme on l'écrit en français. */
|
|
function dayLabel(day: number): string {
|
|
return day === 1 ? "1er" : String(day);
|
|
}
|
|
|
|
function full(p: Parts): string {
|
|
return `${dayLabel(p.day)} ${MONTHS[p.month]} ${p.year}`;
|
|
}
|
|
|
|
/**
|
|
* Phrase de validité, ou `null` si la promotion n'en a pas.
|
|
* L'année et le mois ne sont répétés que lorsqu'ils changent : « du 1er au
|
|
* 15 mars 2027 » plutôt que « du 1er mars 2027 au 15 mars 2027 ».
|
|
*/
|
|
export function formatValidity(promo: PromoValidity): string | null {
|
|
const start = parse(promo.startsOn);
|
|
const end = parse(promo.endsOn);
|
|
|
|
if (start && end) {
|
|
const sameYear = start.year === end.year;
|
|
const sameMonth = sameYear && start.month === end.month;
|
|
const from = sameMonth
|
|
? dayLabel(start.day)
|
|
: sameYear
|
|
? `${dayLabel(start.day)} ${MONTHS[start.month]}`
|
|
: full(start);
|
|
return `Valable du ${from} au ${full(end)}`;
|
|
}
|
|
if (end) return `Valable jusqu'au ${full(end)}`;
|
|
if (start) return `Valable à partir du ${full(start)}`;
|
|
|
|
const legacy = promo.validUntil?.trim();
|
|
return legacy || null;
|
|
}
|
|
|
|
/** Variante sans le mot « Valable », pour les libellés déjà introduits. */
|
|
export function formatValidityShort(promo: PromoValidity): string | null {
|
|
const formatted = formatValidity(promo);
|
|
if (!formatted) return null;
|
|
return formatted.replace(/^Valable /, "");
|
|
}
|
|
|
|
/** `true` si la date de fin est dépassée. Un jour de fin reste valable en entier. */
|
|
export function isExpired(promo: PromoValidity, today = new Date()): boolean {
|
|
const end = parse(promo.endsOn);
|
|
if (!end) return false;
|
|
const endOfDay = Date.UTC(end.year, end.month, end.day, 23, 59, 59);
|
|
return today.getTime() > endOfDay;
|
|
}
|
|
|
|
/** Bornes invalides : fin antérieure au début. */
|
|
export function isRangeInvalid(promo: PromoValidity): boolean {
|
|
const start = parse(promo.startsOn);
|
|
const end = parse(promo.endsOn);
|
|
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;
|
|
}
|