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
235 lines
8.8 KiB
TypeScript
235 lines
8.8 KiB
TypeScript
import Link from "next/link";
|
|
import { redirect } from "next/navigation";
|
|
import { desc, eq } from "drizzle-orm";
|
|
import { getSession } from "@/lib/session";
|
|
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,
|
|
SOCIAL_LABELS,
|
|
tokenHealthCached,
|
|
type SocialNetwork,
|
|
} from "@/lib/social-accounts";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
function StatCard({
|
|
label,
|
|
value,
|
|
hint,
|
|
valueColor = "#13324F",
|
|
hintColor = "#9a8d72",
|
|
}: {
|
|
label: string;
|
|
value: number | string;
|
|
hint: string;
|
|
valueColor?: string;
|
|
hintColor?: string;
|
|
}) {
|
|
return (
|
|
<div className="stat-card" style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16 }}>
|
|
<div className="stat-card__label">{label}</div>
|
|
<div className="font-display stat-card__value" style={{ color: valueColor }}>
|
|
{value}
|
|
</div>
|
|
<div className="stat-card__hint" style={{ color: hintColor }}>{hint}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default async function DashboardPage() {
|
|
const session = await getSession();
|
|
if (!isStaff(session?.user.role)) {
|
|
redirect("/backend/espace");
|
|
}
|
|
|
|
const [activeMembers, livePromos, pendingPromos, requests, newMessages, activity] = await Promise.all([
|
|
db.select({ id: members.id }).from(members).where(eq(members.status, "active")),
|
|
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")),
|
|
db.select().from(activityLog).orderBy(desc(activityLog.createdAt)).limit(6),
|
|
]);
|
|
|
|
const pendingCount = pendingPromos.length;
|
|
|
|
// Jetons réseaux à renouveler. Deux signaux, car une date d'expiration ne
|
|
// suffit pas : un jeton de page Facebook n'expire jamais mais peut être
|
|
// révoqué, et cela ne se voit qu'à la première publication en échec.
|
|
const expiringNetworks: { network: SocialNetwork; status: string }[] = [];
|
|
if (can(session?.user.role, "manageSettings")) {
|
|
const connected = (await getSocialAccounts()).filter((a) => a.accessToken && a.targetId);
|
|
|
|
// Dernière tentative de publication par réseau : une erreur d'autorisation
|
|
// signale un jeton mort, sans avoir à interroger la plateforme ici.
|
|
const attempts = connected.length
|
|
? await db
|
|
.select({
|
|
network: socialPosts.network,
|
|
status: socialPosts.status,
|
|
error: socialPosts.error,
|
|
})
|
|
.from(socialPosts)
|
|
.orderBy(desc(socialPosts.createdAt))
|
|
.limit(40)
|
|
: [];
|
|
const lastAttempt = new Map<string, (typeof attempts)[number]>();
|
|
for (const a of attempts) if (!lastAttempt.has(a.network)) lastAttempt.set(a.network, a);
|
|
|
|
for (const account of connected) {
|
|
const last = lastAttempt.get(account.network);
|
|
const authFailure =
|
|
last?.status === "failed" &&
|
|
/oauth|token|401|expired|invalid|permission/i.test(last.error ?? "");
|
|
// Contrôle direct, mis en cache 6 h : c'est ce qui permet de prévenir
|
|
// avant la première publication en échec, sans appeler la plateforme à
|
|
// chaque affichage du tableau de bord.
|
|
const probe = authFailure ? null : await tokenHealthCached(account.network);
|
|
const expiry = expiryStatus(account.expiresAt);
|
|
|
|
if (authFailure || probe?.ok === false) {
|
|
expiringNetworks.push({ network: account.network, status: "broken" });
|
|
} else if (expiry === "soon" || expiry === "expired") {
|
|
expiringNetworks.push({ network: account.network, status: expiry });
|
|
}
|
|
}
|
|
}
|
|
|
|
function timeAgo(date: Date) {
|
|
const diff = Date.now() - new Date(date).getTime();
|
|
const h = Math.floor(diff / 3_600_000);
|
|
if (h < 1) return "à l'instant";
|
|
if (h < 24) return `il y a ${h} h`;
|
|
const d = Math.floor(h / 24);
|
|
return d === 1 ? "hier" : `il y a ${d} j`;
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
{expiringNetworks.length > 0 && (
|
|
<Link
|
|
href="/backend/reseaux"
|
|
style={{
|
|
display: "block",
|
|
textDecoration: "none",
|
|
background: "#fbeede",
|
|
border: "1px solid #ecd8b8",
|
|
color: "#9a6638",
|
|
borderRadius: 12,
|
|
padding: "13px 16px",
|
|
marginBottom: 20,
|
|
fontSize: 13.5,
|
|
lineHeight: 1.6,
|
|
}}
|
|
>
|
|
{expiringNetworks.map((n) => (
|
|
<div key={n.network}>
|
|
<strong>{SOCIAL_LABELS[n.network]}</strong>{" "}
|
|
{n.status === "broken"
|
|
? "— la dernière publication a été refusée : le jeton ne fonctionne plus."
|
|
: n.status === "expired"
|
|
? "— le jeton a expiré, les publications échoueront."
|
|
: "— le jeton expire dans moins de 7 jours."}{" "}
|
|
Reconnecter le compte →
|
|
</div>
|
|
))}
|
|
</Link>
|
|
)}
|
|
|
|
<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} 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
|
|
label="Demandes & messages"
|
|
value={requests.length + newMessages.length}
|
|
hint={`${requests.length} adhésion · ${newMessages.length} contact`}
|
|
valueColor="#13324F"
|
|
hintColor="#9a6638"
|
|
/>
|
|
</Link>
|
|
</div>
|
|
|
|
<div className="grid dash-split">
|
|
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 22 }}>
|
|
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 17, margin: "0 0 16px", color: "#26201a" }}>
|
|
Activité récente
|
|
</h3>
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
|
{activity.length === 0 && (
|
|
<div style={{ fontSize: 13.5, color: "#a99c82" }}>Aucune activité récente.</div>
|
|
)}
|
|
{activity.map((a) => (
|
|
<div key={a.id} style={{ display: "flex", alignItems: "center", gap: 13 }}>
|
|
<span style={{ width: 9, height: 9, borderRadius: "50%", background: a.dot, flexShrink: 0 }} />
|
|
<div style={{ flex: 1, fontSize: 13.5, color: "#3c3322" }}>
|
|
{activityNodes(a.message)}
|
|
</div>
|
|
<span style={{ fontSize: 12, color: "#a99c82", whiteSpace: "nowrap" }}>
|
|
{timeAgo(a.createdAt)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ background: "#13324F", borderRadius: 16, padding: 22, color: "#cfe0ee" }}>
|
|
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 17, margin: "0 0 6px", color: "#fff" }}>
|
|
Promotions à modérer
|
|
</h3>
|
|
<p style={{ margin: "0 0 16px", fontSize: 13, color: "#9bb6cd" }}>
|
|
{pendingCount} en attente de validation.
|
|
</p>
|
|
<Link
|
|
href="/backend/promotions"
|
|
className="font-display"
|
|
style={{
|
|
display: "block",
|
|
textAlign: "center",
|
|
width: "100%",
|
|
border: "none",
|
|
background: "#E0A63C",
|
|
color: "#33291D",
|
|
fontWeight: 700,
|
|
fontSize: 14.5,
|
|
padding: 13,
|
|
borderRadius: 11,
|
|
cursor: "pointer",
|
|
textDecoration: "none",
|
|
}}
|
|
>
|
|
Ouvrir la modération
|
|
</Link>
|
|
<Link
|
|
href="/backend/espace"
|
|
className="font-display"
|
|
style={{
|
|
display: "block",
|
|
textAlign: "center",
|
|
width: "100%",
|
|
marginTop: 10,
|
|
border: "1px solid rgba(255,255,255,0.25)",
|
|
background: "transparent",
|
|
color: "#fff",
|
|
fontWeight: 700,
|
|
fontSize: 14.5,
|
|
padding: 13,
|
|
borderRadius: 11,
|
|
cursor: "pointer",
|
|
textDecoration: "none",
|
|
}}
|
|
>
|
|
Espace adhérent
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|