mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-22 06:38:05 +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
176 lines
5.8 KiB
TypeScript
176 lines
5.8 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|