Compare commits

...

3 Commits

Author SHA1 Message Date
hykocx a57bf3607b docs(posts): add API and Next.js integration documentation
Add three documentation files for the posts module:
- `api.md`: public API reference (list, slug, categories, images)
- `admin-api.md`: admin API reference with all CRUD endpoints
- `integration.md`: Next.js integration examples with code snippets
2026-04-12 15:59:37 -04:00
hykocx 66314481a0 docs(posts): simplify README by removing verbose examples and details 2026-04-12 15:59:11 -04:00
hykocx 881aa75d2a docs(posts): simplify README by removing verbose examples 2026-04-12 15:58:58 -04:00
7 changed files with 381 additions and 419 deletions
+3
View File
@@ -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.
+7
View File
@@ -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
View File
@@ -6,21 +6,8 @@ Types de contenus configurables via variables d'environnement. Chaque projet dé
## Configuration ## Configuration
### Variables d'environnement
Copier les variables de [`.env.example`](.env.example) dans votre `.env`. 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. 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 ### 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`. 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`. 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`. 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 ## 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` | | Créer un post | `/admin/posts/{type}/new` |
| Modifier un post | `/admin/posts/{type}/edit/{id}` | | Modifier un post | `/admin/posts/{type}/edit/{id}` |
| Liste des catégories | `/admin/posts/{type}/categories` | | 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. - [API publique](docs/api.md) — endpoints, paramètres, réponses JSON
- [API d'administration](docs/admin-api.md) — routes authentifiées
### Config - [Intégration Next.js](docs/integration.md) — liste, détail, SEO
- [Usage programmatique](docs/programmatic.md) — `upsertPost`, cron jobs, imports
```
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 |
+19
View File
@@ -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 |
+108
View File
@@ -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} />
```
+125
View File
@@ -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}`] : [],
},
};
}
```
+114
View File
@@ -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');
}
}
```