{post.title}
{post.source?.[0] && (Source : {post.source[0].title}
)} {post.tags?.length > 0 && (# 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
```
---
## 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 (
{post.resume}
{post.image &&Source : {post.source[0].title}
)} {post.tags?.length > 0 && (