# Module Posts Types de contenus configurables via variables d'environnement. Chaque projet déclare ses propres types (blogue, CVE, emploi, événement...) avec les champs dont il a besoin, sans toucher au code. --- ## Configuration ### Variables d'environnement Copier les variables de [`.env.example`](.env.example) dans votre `.env`. ```bash # Liste des types (séparés par |, en minuscules) # Format optionnel avec label : cle:Label ZEN_MODULE_POSTS_TYPES=blogue:Blogue|cve:CVE|emploi:Emplois # Champs par type : nom:type|nom:type|... ZEN_MODULE_POSTS_TYPE_BLOGUE=title:title|slug:slug|category:category|date:date|resume:text|content:markdown|image:image ZEN_MODULE_POSTS_TYPE_CVE=title:title|slug:slug|cve_id:text|severity:text|date:datetime|description:markdown|tags:relation:tag ZEN_MODULE_POSTS_TYPE_TAG=title:title|slug:slug ``` Si aucun label n'est fourni (`ZEN_MODULE_POSTS_TYPES=blogue`), le nom affiché sera la clé avec la première lettre en majuscule. ### Types de champs | Type | Syntaxe `.env` | Description | |---|---|---| | `title` | `nom:title` | Champ texte principal, génère le slug automatiquement | | `slug` | `nom:slug` | Slug unique par type, pré-rempli depuis le titre | | `text` | `nom:text` | Zone de texte libre | | `markdown` | `nom:markdown` | Éditeur Markdown avec prévisualisation | | `date` | `nom:date` | Sélecteur de date (YYYY-MM-DD) | | `datetime` | `nom:datetime` | Date et heure (ISO 8601, UTC) | | `color` | `nom:color` | Sélecteur de couleur, stocke un code hex `#rrggbb` | | `category` | `nom:category` | Menu déroulant lié à la table des catégories | | `image` | `nom:image` | Upload d'image vers le stockage Zen | | `relation` | `nom:relation:type_cible` | Sélection multiple vers des posts d'un autre type | Chaque type doit avoir au moins un champ `title` et un champ `slug`. **`date` ou `datetime` ?** - `date` stocke `"2026-03-14"`. Suffisant pour un billet de blogue ou un événement. - `datetime` stocke `"2026-03-14T10:30:00.000Z"`. Nécessaire pour les CVE ou tout contenu avec une heure précise. ### Champ `relation` Le champ `relation` associe plusieurs posts d'un autre type (many-to-many). ```bash ZEN_MODULE_POSTS_TYPE_ACTUALITE=title:title|slug:slug|date:datetime|resume:text|content:markdown|source:relation:source|tags:relation:tag ZEN_MODULE_POSTS_TYPE_TAG=title:title|slug:slug ZEN_MODULE_POSTS_TYPE_SOURCE=title:title|slug:slug ``` - Le nom avant `relation` (`source`, `tags`) est le nom du champ dans le formulaire et dans la réponse API. - La valeur après `relation` (`source`, `tag`) est le type cible. - La sélection est multiple avec recherche en temps réel. - Les relations sont stockées dans `zen_posts_relations`. - Dans l'API, les relations sont retournées comme un tableau d'objets avec tous les champs du post lié. ### Images Si un type utilise le champ `image`, configurer le stockage Zen dans le `.env` principal : `ZEN_STORAGE_REGION`, `ZEN_STORAGE_ACCESS_KEY`, `ZEN_STORAGE_SECRET_KEY`, `ZEN_STORAGE_BUCKET`. --- ## Base de données Les tables sont créées automatiquement avec `npx zen-db init`. | Table | Description | |---|---| | `zen_posts` | Posts de tous les types (champs personnalisés dans `data JSONB`) | | `zen_posts_category` | Catégories par type (créée si un champ `category` est défini) | | `zen_posts_relations` | Relations entre posts (créée si un champ `relation` est défini) | Tous les champs personnalisés sont dans la colonne `data JSONB`. Ajouter ou retirer un champ dans le `.env` ne nécessite aucune migration SQL. --- ## Interface d'administration | Page | URL | |---|---| | Liste des posts | `/admin/posts/{type}/list` | | Créer un post | `/admin/posts/{type}/new` | | Modifier un post | `/admin/posts/{type}/edit/{id}` | | Liste des catégories | `/admin/posts/{type}/categories` | | Créer une catégorie | `/admin/posts/{type}/categories/new` | | Modifier une catégorie | `/admin/posts/{type}/categories/edit/{id}` | --- ## API publique Pas d'authentification requise. ### Config ``` GET /zen/api/posts/config ``` Retourne la liste de tous les types configurés avec leurs champs. ### Liste de posts ``` GET /zen/api/posts/{type} ``` | Paramètre | Défaut | Description | |---|---|---| | `page` | `1` | Page courante | | `limit` | `20` | Résultats par page | | `category_id` | — | Filtrer par catégorie | | `sortBy` | `created_at` | Trier par (nom de champ du type) | | `sortOrder` | `DESC` | `ASC` ou `DESC` | | `withRelations` | `false` | `true` pour inclure les champs relation | `withRelations=true` exécute une requête SQL supplémentaire par post. Garder un `limit` raisonnable (20 maximum). Sur une page de détail, préférer `/posts/{type}/{slug}` qui charge toujours les relations. **Réponse sans relations (défaut) :** ```json { "success": true, "posts": [ { "id": 1, "post_type": "actualite", "slug": "faille-critique-openssh", "title": "Faille critique dans OpenSSH", "date": "2026-03-14T10:30:00.000Z", "resume": "Une faille critique...", "image": "blog/1234567890-image.webp", "created_at": "2026-03-14T12:00:00Z", "updated_at": "2026-03-14T12:00:00Z" } ], "total": 42, "totalPages": 3, "page": 1, "limit": 20 } ``` **Réponse avec `withRelations=true` :** ```json { "success": true, "posts": [ { "id": 1, "slug": "faille-critique-openssh", "title": "Faille critique dans OpenSSH", "date": "2026-03-14T10:30:00.000Z", "source": [ { "id": 3, "slug": "cert-fr", "post_type": "source", "title": "CERT-FR" } ], "tags": [ { "id": 7, "slug": "openssh", "post_type": "tag", "title": "OpenSSH" }, { "id": 8, "slug": "vulnerabilite", "post_type": "tag", "title": "Vulnérabilité" } ] } ] } ``` ### Post par slug ``` GET /zen/api/posts/{type}/{slug} ``` Les relations sont toujours incluses sur un post individuel. ### Catégories ``` GET /zen/api/posts/{type}/categories ``` Retourne les catégories actives du type (pour alimenter un filtre). ### Images Les clés d'image s'utilisent avec la route de stockage : ```jsx {post.title} ``` --- ## Intégration Next.js ### Liste de posts ```js // app/actualites/page.js export default async function ActualitesPage() { const res = await fetch( `${process.env.NEXT_PUBLIC_URL}/zen/api/posts/actualite?limit=10&sortBy=date&sortOrder=DESC` ); const { posts, total, totalPages } = await res.json(); return ( ); } ``` ### Liste avec relations ```js // app/actualites/page.js export default async function ActualitesPage() { const res = await fetch( `${process.env.NEXT_PUBLIC_URL}/zen/api/posts/actualite?limit=10&withRelations=true` ); const { posts } = await res.json(); return ( ); } ``` ### Page de détail ```js // app/actualites/[slug]/page.js export default async function ActualiteDetailPage({ params }) { const res = await fetch( `${process.env.NEXT_PUBLIC_URL}/zen/api/posts/actualite/${params.slug}` ); const { post } = await res.json(); if (!post) notFound(); return (

{post.title}

{post.source?.[0] && (

Source : {post.source[0].title}

)} {post.tags?.length > 0 && (
{post.tags.map(tag => ( {tag.title} ))}
)} {post.image && }
{post.content}
); } ``` ### Métadonnées SEO dynamiques ```js // app/actualites/[slug]/page.js export async function generateMetadata({ params }) { const res = await fetch( `${process.env.NEXT_PUBLIC_URL}/zen/api/posts/actualite/${params.slug}` ); const { post } = await res.json(); if (!post) return {}; return { title: post.title, description: post.resume, openGraph: { title: post.title, description: post.resume, images: post.image ? [`/zen/api/storage/${post.image}`] : [], }, }; } ``` --- ## Ajouter ou modifier un type **Ajouter un type :** modifier uniquement le `.env`, pas besoin de redémarrer la base. ```bash # Avant ZEN_MODULE_POSTS_TYPES=cve:CVE|actualite:Actualités # Après ZEN_MODULE_POSTS_TYPES=cve:CVE|actualite:Actualités|evenement:Événements ZEN_MODULE_POSTS_TYPE_EVENEMENT=title:title|slug:slug|date:datetime|location:text|description:markdown|image:image ``` Redémarrer le serveur. Les tables ne changent pas (les nouveaux champs utilisent le JSONB existant). **Modifier les champs d'un type existant :** mettre à jour la variable `ZEN_MODULE_POSTS_TYPE_*` et redémarrer. Les posts existants conservent leurs données en JSONB, même si un champ est retiré de la config. --- ## Utilisation programmatique Les fonctions CRUD sont importables côté serveur. Idéal pour les cron jobs, scripts d'import ou fetchers automatisés. ### Fonctions disponibles ```js import { createPost, // Créer un post updatePost, // Modifier un post getPostBySlug, // Chercher par slug getPostByField, // Chercher par n'importe quel champ JSONB upsertPost, // Créer ou mettre à jour (idempotent) getPosts, // Liste avec pagination deletePost, // Supprimer } from '@hykocx/zen/modules/posts/crud'; ``` ### `upsertPost(postType, rawData, uniqueField)` Crée le post s'il n'existe pas, le met à jour sinon. - `postType` : le type de post (`'cve'`, `'actualite'`...) - `rawData` : les données du post (mêmes champs que pour `createPost`) - `uniqueField` : le champ de déduplication (`'slug'` par défaut) Retourne `{ post, created: boolean }`. ### Champs `relation` dans `rawData` Les champs `relation` reçoivent un **tableau d'IDs** de posts existants. ```js // Correct { tags: [7, 8, 12], source: [3] } // Incorrect { tags: ['openssh', 'vuln'], source: { id: 3 } } ``` Si les posts liés n'existent pas encore, les créer d'abord avec `upsertPost` puis utiliser leurs IDs. ### Exemple : fetcher de CVE ```js // src/cron/fetch-cves.js import { upsertPost } from '@hykocx/zen/modules/posts/crud'; export async function fetchAndImportCVEs() { const response = await fetch('https://api.example.com/cves/recent'); const { cves } = await response.json(); const results = { created: 0, updated: 0, errors: 0 }; for (const cve of cves) { try { // Résoudre les relations : s'assurer que les tags existent const tagIds = []; for (const tagName of (cve.tags || [])) { const { post: tag } = await upsertPost('tag', { title: tagName }, 'slug'); tagIds.push(tag.id); } // Upsert du CVE, dédupliqué sur cve_id const { created } = await upsertPost('cve', { title: cve.title, cve_id: cve.id, severity: cve.severity, score: String(cve.cvssScore), product: cve.affectedProduct, date: cve.publishedAt, description: cve.description, tags: tagIds, }, 'cve_id'); created ? results.created++ : results.updated++; } catch (err) { console.error(`[CVE import] Error for ${cve.id}:`, err.message); results.errors++; } } console.log(`[CVE import] Done — created: ${results.created}, updated: ${results.updated}, errors: ${results.errors}`); return results; } ``` ### Exemple : fetcher d'actualités avec source ```js import { upsertPost } from '@hykocx/zen/modules/posts/crud'; export async function fetchAndImportActualites(sourceName, articles) { // S'assurer que la source existe const { post: source } = await upsertPost('source', { title: sourceName }, 'slug'); for (const article of articles) { await upsertPost('actualite', { title: article.title, date: article.publishedAt, resume: article.summary, content: article.content, source: [source.id], tags: [], }, 'slug'); } } ``` --- ## API d'administration Authentification requise. | Méthode | Route | Description | |---|---|---| | `GET` | `/zen/api/admin/posts/config` | Config complète de tous les types | | `GET` | `/zen/api/admin/posts/search?type={type}&q={query}` | Recherche pour le sélecteur de relation | | `GET` | `/zen/api/admin/posts/posts?type={type}` | Liste des posts d'un type | | `GET` | `/zen/api/admin/posts/posts?type={type}&withRelations=true` | Liste avec relations | | `GET` | `/zen/api/admin/posts/posts?type={type}&id={id}` | Post par ID (relations toujours incluses) | | `POST` | `/zen/api/admin/posts/posts?type={type}` | Créer un post | | `PUT` | `/zen/api/admin/posts/posts?type={type}&id={id}` | Modifier un post | | `DELETE` | `/zen/api/admin/posts/posts?type={type}&id={id}` | Supprimer un post | | `POST` | `/zen/api/admin/posts/upload-image` | Upload d'image | | `GET` | `/zen/api/admin/posts/categories?type={type}` | Liste des catégories | | `POST` | `/zen/api/admin/posts/categories?type={type}` | Créer une catégorie | | `PUT` | `/zen/api/admin/posts/categories?type={type}&id={id}` | Modifier une catégorie | | `DELETE` | `/zen/api/admin/posts/categories?type={type}&id={id}` | Supprimer une catégorie |