Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a57bf3607b | |||
| 66314481a0 | |||
| 881aa75d2a |
@@ -0,0 +1,3 @@
|
||||
# Claude Code Rules
|
||||
|
||||
Always read [docs/DEV.md](docs/DEV.md) at the start of every conversation before doing any work in this project.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Ce projet utilise l'IA. Voici ce que ça veut dire.
|
||||
|
||||
L'IA génère du code. On le relit, on le comprend, on décide si ça rentre ou pas.
|
||||
|
||||
Ce n'est pas du vibe coding. Chaque ligne engagée dans ce dépôt a été lue et validée par un développeur expérimenté. Si on ne comprend pas ce que fait le code, il ne passe pas. Aucun agent n'a d'accès direct au dépôt. Entre l'IA et ce qui atterrit en production, il y a toujours un humain qui comprend ce qu'il engage.
|
||||
|
||||
L'IA s'occupe de l'implémentation. Le raisonnement, les décisions d'architecture et la validation restent humains.
|
||||
+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
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# API d'administration — Module Posts
|
||||
|
||||
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 |
|
||||
@@ -0,0 +1,108 @@
|
||||
# API publique — Module Posts
|
||||
|
||||
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} />
|
||||
```
|
||||
@@ -0,0 +1,125 @@
|
||||
# Intégration Next.js — Module Posts
|
||||
|
||||
## 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}`] : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,114 @@
|
||||
# Usage programmatique — Module Posts
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user