feat(plugins): foundation système Plugin Karbé

- Modèle Prisma Plugin (key, name, description, category, version, enabled,
  config JSONB, migrationsApplied, timestamps) + migration SQL
- PluginRegistry (src/lib/plugins/registry.ts) avec 12 plugins déclarés :
  visuels (theme-guyane, landing-hero, landing-sections, image-gallery-seed,
  demo-carbets-seed), métier (access-type, seasonality, pirogue-providers,
  min-stay), contenus (content-pages, legal-pages), i18n (i18n-fr-en)
- Server helpers (server.ts) : sync, isEnabled, getEnabledKeys, toggle avec
  hooks onEnable/onDisable, updateConfig, cache 5s
- Client bridge (client.tsx) : PluginProvider + useIsPluginEnabled
- Composant <IfPluginEnabled plugin=... fallback=...>
- Guard requirePluginOr404 pour pages et routes
- Page admin /admin/plugins avec table toggle par catégorie + édition config
- Route PATCH /api/admin/plugins/[key] + GET
- Layout async qui sync registry + passe enabledKeys au PluginProvider

Tous plugins en enabled=false par défaut, activation pilotée depuis l'admin.
This commit is contained in:
Claude Integration 2026-05-30 22:17:10 +00:00
parent d7de43a70e
commit 62cc464738
12 changed files with 577 additions and 2 deletions

View file

@ -0,0 +1,19 @@
-- Foundation : système Plugin Karbé
CREATE TABLE "Plugin" (
"key" TEXT PRIMARY KEY,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL,
"category" TEXT NOT NULL,
"version" TEXT NOT NULL DEFAULT '0.1.0',
"enabled" BOOLEAN NOT NULL DEFAULT false,
"config" JSONB NOT NULL DEFAULT '{}',
"migrationsApplied" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
"installedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"lastEnabledAt" TIMESTAMP(3),
"lastDisabledAt" TIMESTAMP(3)
);
CREATE INDEX "Plugin_category_idx" ON "Plugin" ("category");
CREATE INDEX "Plugin_enabled_idx" ON "Plugin" ("enabled");

View file

@ -244,3 +244,21 @@ model Review {
@@index([carbetId])
@@index([authorId])
}
model Plugin {
key String @id
name String
description String
category String
version String @default("0.1.0")
enabled Boolean @default(false)
config Json @default("{}")
migrationsApplied String[] @default([])
installedAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastEnabledAt DateTime?
lastDisabledAt DateTime?
@@index([category])
@@index([enabled])
}

View file

@ -0,0 +1,102 @@
"use client";
import { useState, useTransition } from "react";
interface PluginRow {
key: string;
name: string;
description: string;
category: string;
version: string;
enabled: boolean;
config: Record<string, unknown>;
}
const CATEGORY_LABEL: Record<string, string> = {
visual: "Visuels",
business: "Métier",
content: "Contenus",
i18n: "Internationalisation",
core: "Core",
};
export default function PluginToggleTable({ plugins: initial }: { plugins: PluginRow[] }) {
const [plugins, setPlugins] = useState(initial);
const [pending, startTransition] = useTransition();
const [busyKey, setBusyKey] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const byCategory = plugins.reduce<Record<string, PluginRow[]>>((acc, p) => {
(acc[p.category] ??= []).push(p);
return acc;
}, {});
async function toggle(key: string, next: boolean) {
setError(null);
setBusyKey(key);
try {
const res = await fetch(`/api/admin/plugins/${encodeURIComponent(key)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: next }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.error || `HTTP ${res.status}`);
}
const updated = await res.json();
startTransition(() => {
setPlugins((curr) =>
curr.map((p) => (p.key === key ? { ...p, enabled: !!updated.enabled } : p)),
);
});
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusyKey(null);
}
}
return (
<div className="space-y-8">
{error && (
<div className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</div>
)}
{Object.entries(byCategory).map(([category, rows]) => (
<section key={category}>
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-gray-500">
{CATEGORY_LABEL[category] ?? category}
</h2>
<ul className="divide-y divide-gray-200 rounded-lg border border-gray-200 bg-white">
{rows.map((p) => (
<li key={p.key} className="flex items-start justify-between gap-4 px-4 py-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">{p.name}</span>
<code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600">{p.key}</code>
<span className="text-xs text-gray-400">v{p.version}</span>
</div>
<p className="mt-1 text-sm text-gray-600">{p.description}</p>
</div>
<button
type="button"
onClick={() => toggle(p.key, !p.enabled)}
disabled={pending || busyKey === p.key}
className={`shrink-0 rounded-full px-3 py-1 text-xs font-semibold transition ${
p.enabled
? "bg-green-600 text-white hover:bg-green-700"
: "bg-gray-200 text-gray-700 hover:bg-gray-300"
} disabled:opacity-50`}
>
{busyKey === p.key ? "…" : p.enabled ? "Activé" : "Désactivé"}
</button>
</li>
))}
</ul>
</section>
))}
</div>
);
}

View file

@ -0,0 +1,26 @@
import { requireRole } from "@/lib/authorization";
import { UserRole } from "@/generated/prisma/enums";
import { listAllPlugins, syncPluginsFromRegistry } from "@/lib/plugins/server";
import PluginToggleTable from "./_components/PluginToggleTable";
export const dynamic = "force-dynamic";
export default async function PluginsAdminPage() {
await requireRole([UserRole.ADMIN]);
// S'assure que tous les plugins du registry sont en DB.
await syncPluginsFromRegistry();
const plugins = await listAllPlugins();
return (
<div className="mx-auto max-w-5xl px-4 py-8 sm:px-6 lg:px-8">
<h1 className="text-2xl font-semibold">Plugins Karbé</h1>
<p className="mt-2 text-sm text-gray-600">
Active ou désactive chaque module. Les changements prennent effet immédiatement (cache 5 s).
L&apos;onEnable/onDisable est exécuté avant la bascule.
</p>
<div className="mt-6">
<PluginToggleTable plugins={plugins} />
</div>
</div>
);
}

View file

@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireRole } from "@/lib/authorization";
import { UserRole } from "@/generated/prisma/enums";
import { findDescriptor } from "@/lib/plugins/registry";
import { togglePlugin, updatePluginConfig, getPluginState } from "@/lib/plugins/server";
const patchSchema = z.object({
enabled: z.boolean().optional(),
config: z.record(z.string(), z.unknown()).optional(),
});
export async function PATCH(req: Request, ctx: { params: Promise<{ key: string }> }) {
await requireRole([UserRole.ADMIN]);
const { key } = await ctx.params;
if (!findDescriptor(key)) {
return NextResponse.json({ error: "Unknown plugin" }, { status: 404 });
}
const parsed = patchSchema.safeParse(await req.json().catch(() => ({})));
if (!parsed.success) {
return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
}
let state = await getPluginState(key);
if (parsed.data.enabled !== undefined) {
state = await togglePlugin(key, parsed.data.enabled);
}
if (parsed.data.config !== undefined) {
state = await updatePluginConfig(key, parsed.data.config);
}
return NextResponse.json(state);
}
export async function GET(_req: Request, ctx: { params: Promise<{ key: string }> }) {
await requireRole([UserRole.ADMIN]);
const { key } = await ctx.params;
const state = await getPluginState(key);
if (!state) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(state);
}

View file

@ -1,6 +1,8 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { PluginProvider } from "@/lib/plugins/client";
import { getEnabledPluginKeys, syncPluginsFromRegistry } from "@/lib/plugins/server";
const geistSans = Geist({
variable: "--font-geist-sans",
@ -32,17 +34,30 @@ export const metadata: Metadata = {
},
};
export default function RootLayout({
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
// Plugins Karbé : sync registry → DB puis charge la liste activée pour le client.
// En cas d'erreur DB (build statique sans DB par ex.), on retombe en mode "aucun
// plugin activé" pour ne pas casser le rendu.
let enabledKeys: string[] = [];
try {
await syncPluginsFromRegistry();
enabledKeys = await getEnabledPluginKeys();
} catch {
enabledKeys = [];
}
return (
<html
lang="fr"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<body className="min-h-full flex flex-col">
<PluginProvider enabledKeys={enabledKeys}>{children}</PluginProvider>
</body>
</html>
);
}

View file

@ -0,0 +1,31 @@
"use client";
import type { ReactNode } from "react";
import { useIsPluginEnabled } from "@/lib/plugins/client";
/**
* Composant client : affiche `children` uniquement si le plugin est activé.
* Le `<PluginProvider>` doit être présent en amont (layout) avec la liste
* des plugins activés calculée côté serveur.
*
* Usage :
* <IfPluginEnabled plugin="landing-hero">
* <HeroSection />
* </IfPluginEnabled>
*
* <IfPluginEnabled plugin="seasonality" fallback={<DefaultBanner />}>
* <SeasonBanner />
* </IfPluginEnabled>
*/
export function IfPluginEnabled({
plugin,
fallback = null,
children,
}: {
plugin: string;
fallback?: ReactNode;
children: ReactNode;
}) {
const enabled = useIsPluginEnabled(plugin);
return <>{enabled ? children : fallback}</>;
}

View file

@ -0,0 +1,28 @@
/**
* Plugin Karbé bridge client.
*
* Le client ne lit JAMAIS la DB. Il reçoit l'état des plugins depuis un
* server component qui appelle `getEnabledPluginKeys()` et passe la liste en
* prop ou via le composant `<PluginProvider>`.
*/
"use client";
import { createContext, useContext, type ReactNode } from "react";
const PluginContext = createContext<Set<string>>(new Set());
export function PluginProvider({
enabledKeys,
children,
}: {
enabledKeys: string[];
children: ReactNode;
}) {
return <PluginContext.Provider value={new Set(enabledKeys)}>{children}</PluginContext.Provider>;
}
export function useIsPluginEnabled(key: string): boolean {
const set = useContext(PluginContext);
return set.has(key);
}

22
src/lib/plugins/guard.ts Normal file
View file

@ -0,0 +1,22 @@
/**
* Plugin Karbé guard pour pages et routes API.
*
* Côté server component (page) :
* import { requirePluginOr404 } from "@/lib/plugins/guard";
* export default async function Page() {
* await requirePluginOr404("landing-hero");
* ...
* }
*
* Côté route handler :
* if (!(await isPluginEnabled("access-type"))) return new Response("Not Found", { status: 404 });
*/
import "server-only";
import { notFound } from "next/navigation";
import { isPluginEnabled } from "./server";
export async function requirePluginOr404(key: string): Promise<void> {
const ok = await isPluginEnabled(key);
if (!ok) notFound();
}

19
src/lib/plugins/hooks.ts Normal file
View file

@ -0,0 +1,19 @@
/**
* Plugin Karbé hooks d'activation/désactivation par plugin.
*
* Chaque plugin peut déclarer un `onEnable` et/ou `onDisable` ici. Exemples
* d'usage : seed initial des carbets démo (au enable), soft-delete des
* carbets démo (au disable). Les hooks ne sont **pas** des migrations DB
* pour ça on passe par les fichiers prisma/migrations.
*/
export type PluginHook = () => Promise<void>;
export interface PluginHookSet {
onEnable?: PluginHook;
onDisable?: PluginHook;
}
// Pour l'instant, vide : les plugins métier ajouteront leurs hooks ici
// au fur et à mesure (sans toucher au runtime du système Plugin).
export const pluginHooks: Record<string, PluginHookSet | undefined> = {};

127
src/lib/plugins/registry.ts Normal file
View file

@ -0,0 +1,127 @@
/**
* Plugin Karbé registry statique des plugins disponibles.
*
* Chaque plugin déclaré ici sera synchronisé en DB au démarrage (insertion si absent,
* sans toucher au flag `enabled` existant). L'état (enabled / config) est piloté
* depuis /admin/plugins. Le code des plugins est TOUJOURS présent dans le bundle ;
* l'activation au runtime conditionne juste l'exposition des pages, routes et composants.
*/
export type PluginCategory = "core" | "visual" | "business" | "content" | "i18n";
export interface PluginDescriptor {
key: string;
name: string;
description: string;
category: PluginCategory;
version: string;
}
export const PLUGINS: PluginDescriptor[] = [
// Visuels
{
key: "theme-guyane",
name: "Thème Guyane",
description:
"Palette tropicale (vert canopée, eau Maroni, ocre latérite, bois karbé) + typographies display + tokens Tailwind.",
category: "visual",
version: "0.1.0",
},
{
key: "landing-hero",
name: "Hero d'accueil",
description:
"Refonte de la page d'accueil avec hero plein écran, claim, CTA double Découvrir/Proposer.",
category: "visual",
version: "0.1.0",
},
{
key: "landing-sections",
name: "Sections d'accueil",
description:
"Sections « 2 expériences », « Comment ça marche », « Pour comités d'entreprise », « Témoignages », footer riche.",
category: "visual",
version: "0.1.0",
},
{
key: "image-gallery-seed",
name: "Galerie d'images seed",
description:
"8 images photo-réalistes générées (carbets, intérieurs, pirogue) stockées dans MinIO. Illustrations vectorielles pour les sections.",
category: "visual",
version: "0.1.0",
},
{
key: "demo-carbets-seed",
name: "Carbets de démo",
description:
"5-8 carbets d'exemple avec photos, GPS réels, types d'accès variés. Désactivation = soft-delete via tag.",
category: "visual",
version: "0.1.0",
},
// Métier
{
key: "access-type",
name: "Type d'accès route/fleuve",
description:
"Distinction ROAD_AND_RIVER vs RIVER_ONLY (badge, filtre recherche, fiche enrichie). Migration soft.",
category: "business",
version: "0.1.0",
},
{
key: "seasonality",
name: "Saisonnalité",
description:
"Saison sèche / pluies / étiage. Bandeau d'alerte, dispo conditionnelle, filtre « ouvert maintenant ».",
category: "business",
version: "0.1.0",
},
{
key: "pirogue-providers",
name: "Prestataires pirogue",
description:
"Partenaires pirogue + mode transport sur Carbet (OWNER_PROVIDES / SELF_ARRANGE / PARTNER_PROVIDER).",
category: "business",
version: "0.1.0",
},
{
key: "min-stay",
name: "Durée min/max séjour",
description:
"Contraintes nuits min/max, capacité min, règles week-end CE vs semaine public mappées dans Availability.",
category: "business",
version: "0.1.0",
},
// Contenus / i18n
{
key: "content-pages",
name: "Pages de contenu",
description:
"Pages markdown éditables depuis l'admin (À propos, FAQ, Comment ça marche, Pour CE, Devenir loueur).",
category: "content",
version: "0.1.0",
},
{
key: "i18n-fr-en",
name: "i18n FR + EN",
description: "Routes [locale]/, détection navigateur, switcher header. Plugin désactivable = FR pur sans préfixe.",
category: "i18n",
version: "0.1.0",
},
{
key: "legal-pages",
name: "Pages légales",
description: "CGV, RGPD, mentions légales (absorbe SYS-9). Markdown + rendu statique.",
category: "content",
version: "0.1.0",
},
];
export const PLUGIN_KEYS = PLUGINS.map((p) => p.key);
export type PluginKey = (typeof PLUGIN_KEYS)[number];
export function findDescriptor(key: string): PluginDescriptor | undefined {
return PLUGINS.find((p) => p.key === key);
}

129
src/lib/plugins/server.ts Normal file
View file

@ -0,0 +1,129 @@
/**
* Plugin Karbé accès serveur à l'état des plugins.
*
* - getPluginState(key): lit la DB (avec cache 5s pour éviter les hammer).
* - isPluginEnabled(key): helper booléen.
* - syncPluginsFromRegistry(): à appeler au démarrage pour insérer les plugins
* nouvellement ajoutés au code (sans toucher aux flags existants).
* - togglePlugin(key, enabled): admin action avec hook onEnable/onDisable.
*/
import "server-only";
import { prisma } from "@/lib/prisma";
import { PLUGINS, type PluginDescriptor } from "./registry";
import { pluginHooks } from "./hooks";
type PluginRow = {
key: string;
enabled: boolean;
config: Record<string, unknown>;
version: string;
category: string;
name: string;
description: string;
};
let cache: Map<string, PluginRow> | null = null;
let cacheStamp = 0;
const CACHE_TTL_MS = 5000;
async function loadAll(): Promise<Map<string, PluginRow>> {
const now = Date.now();
if (cache && now - cacheStamp < CACHE_TTL_MS) return cache;
const rows = await prisma.plugin.findMany();
const map = new Map<string, PluginRow>();
for (const r of rows) {
map.set(r.key, {
key: r.key,
enabled: r.enabled,
config: (r.config ?? {}) as Record<string, unknown>,
version: r.version,
category: r.category,
name: r.name,
description: r.description,
});
}
cache = map;
cacheStamp = now;
return map;
}
export function invalidatePluginCache(): void {
cache = null;
cacheStamp = 0;
}
export async function getPluginState(key: string): Promise<PluginRow | null> {
const m = await loadAll();
return m.get(key) ?? null;
}
export async function isPluginEnabled(key: string): Promise<boolean> {
const s = await getPluginState(key);
return !!s?.enabled;
}
export async function getEnabledPluginKeys(): Promise<string[]> {
const m = await loadAll();
return [...m.values()].filter((r) => r.enabled).map((r) => r.key);
}
export async function listAllPlugins(): Promise<PluginRow[]> {
const m = await loadAll();
return [...m.values()].sort((a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name));
}
export async function syncPluginsFromRegistry(): Promise<void> {
const existing = new Set((await prisma.plugin.findMany({ select: { key: true } })).map((p) => p.key));
const toInsert: PluginDescriptor[] = PLUGINS.filter((p) => !existing.has(p.key));
if (toInsert.length) {
await prisma.plugin.createMany({
data: toInsert.map((p) => ({
key: p.key,
name: p.name,
description: p.description,
category: p.category,
version: p.version,
enabled: false,
config: {},
})),
skipDuplicates: true,
});
invalidatePluginCache();
}
// Update name/description/version if the descriptor changed (idempotent).
for (const p of PLUGINS) {
if (existing.has(p.key)) {
await prisma.plugin.update({
where: { key: p.key },
data: { name: p.name, description: p.description, category: p.category, version: p.version },
});
}
}
invalidatePluginCache();
}
export async function togglePlugin(key: string, enabled: boolean): Promise<PluginRow | null> {
const before = await prisma.plugin.findUnique({ where: { key } });
if (!before) return null;
if (before.enabled === enabled) return await getPluginState(key);
const hook = pluginHooks[key];
if (enabled && hook?.onEnable) await hook.onEnable();
if (!enabled && hook?.onDisable) await hook.onDisable();
await prisma.plugin.update({
where: { key },
data: {
enabled,
lastEnabledAt: enabled ? new Date() : before.lastEnabledAt,
lastDisabledAt: !enabled ? new Date() : before.lastDisabledAt,
},
});
invalidatePluginCache();
return await getPluginState(key);
}
export async function updatePluginConfig(key: string, config: Record<string, unknown>): Promise<PluginRow | null> {
await prisma.plugin.update({ where: { key }, data: { config } });
invalidatePluginCache();
return await getPluginState(key);
}