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 (
{label}
{value}
{hint}
); } 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(); 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 (
{expiringNetworks.length > 0 && ( {expiringNetworks.map((n) => (
{SOCIAL_LABELS[n.network]}{" "} {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 →
))} )}

Activité récente

{activity.length === 0 && (
Aucune activité récente.
)} {activity.map((a) => (
{activityNodes(a.message)}
{timeAgo(a.createdAt)}
))}

Promotions à modérer

{pendingCount} en attente de validation.

Ouvrir la modération Espace adhérent
); }