docs(posts): simplify README by removing verbose examples and details
This commit is contained in:
+5
-419
@@ -6,21 +6,8 @@ Types de contenus configurables via variables d'environnement. Chaque projet dé
|
||||
|
||||
## 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
|
||||
@@ -40,29 +27,6 @@ Si aucun label n'est fourni (`ZEN_MODULE_POSTS_TYPES=blogue`), le nom affiché s
|
||||
|
||||
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`.
|
||||
|
||||
---
|
||||
@@ -71,14 +35,6 @@ Si un type utilise le champ `image`, configurer le stockage Zen dans le `.env` p
|
||||
|
||||
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
|
||||
@@ -89,382 +45,12 @@ Tous les champs personnalisés sont dans la colonne `data JSONB`. Ajouter ou ret
|
||||
| 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
|
||||
## Documentation
|
||||
|
||||
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
|
||||
<img src={`/zen/api/storage/${post.image}`} alt={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 (
|
||||
<ul>
|
||||
{posts.map(post => (
|
||||
<li key={post.id}>
|
||||
<a href={`/actualites/${post.slug}`}>{post.title}</a>
|
||||
<p>{post.resume}</p>
|
||||
{post.image && <img src={`/zen/api/storage/${post.image}`} alt="" />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 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 (
|
||||
<ul>
|
||||
{posts.map(post => (
|
||||
<li key={post.id}>
|
||||
<a href={`/actualites/${post.slug}`}>{post.title}</a>
|
||||
|
||||
{post.source?.[0] && (
|
||||
<span>Source : {post.source[0].title}</span>
|
||||
)}
|
||||
|
||||
<div>
|
||||
{post.tags?.map(tag => (
|
||||
<a key={tag.id} href={`/tags/${tag.slug}`}>{tag.title}</a>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 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 (
|
||||
<article>
|
||||
<h1>{post.title}</h1>
|
||||
|
||||
<time dateTime={post.date}>
|
||||
{new Date(post.date).toLocaleString('fr-FR')}
|
||||
</time>
|
||||
|
||||
{post.source?.[0] && (
|
||||
<p>Source : <a href={`/sources/${post.source[0].slug}`}>{post.source[0].title}</a></p>
|
||||
)}
|
||||
|
||||
{post.tags?.length > 0 && (
|
||||
<div>
|
||||
{post.tags.map(tag => (
|
||||
<a key={tag.id} href={`/tags/${tag.slug}`}>{tag.title}</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{post.image && <img src={`/zen/api/storage/${post.image}`} alt="" />}
|
||||
<div>{post.content}</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 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 '@zen/core/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 '@zen/core/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 '@zen/core/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 |
|
||||
- [API publique](docs/api.md) — endpoints, paramètres, réponses JSON
|
||||
- [API d'administration](docs/admin-api.md) — routes authentifiées
|
||||
- [Intégration Next.js](docs/integration.md) — liste, détail, SEO
|
||||
- [Usage programmatique](docs/programmatic.md) — `upsertPost`, cron jobs, imports
|
||||
|
||||
Reference in New Issue
Block a user