docs: translate posts README to French and update language guide

- Rewrite content with clearer structure, adding env variable examples and improving field type descriptions
This commit is contained in:
2026-04-12 14:32:21 -04:00
parent 99a56d2c39
commit c33383adf7
3 changed files with 172 additions and 534 deletions
+1 -2
View File
@@ -6,7 +6,6 @@ Tout ce qui est **code** est en **anglais**, sans exception :
- Noms de fichiers (sauf dossiers de routes Next.js, voir ci-dessous)
- Variables, fonctions, classes, composants
- Commentaires dans le code
- README.md
- Props, événements, constantes, types
- Git commit
@@ -15,7 +14,7 @@ Tout ce qui est **code** est en **anglais**, sans exception :
Tout ce qui est **visible par l'utilisateur** est en **français** :
- Textes, titres, descriptions, labels
- Slugs et noms de dossiers qui correspondent à des routes URL
- Documentations
- Documentations, README.md
## Messages de commit Git
-284
View File
@@ -1,284 +0,0 @@
# Module System
Modules are self-contained features that can be enabled/disabled via environment variables.
## File Structure
```
src/modules/your-module/
├── module.config.js # Required — navigation, pages, widgets
├── db.js # Database schema (createTables / dropTables)
├── crud.js # CRUD operations
├── actions.js # Server actions (for public pages)
├── metadata.js # SEO metadata generators
├── api.js # API route handlers
├── cron.config.js # Scheduled tasks
├── index.js # Public API re-exports
├── .env.example # Environment variable documentation
├── admin/ # Admin pages (lazy-loaded)
│ └── index.js # Re-exports admin components
├── pages/ # Public pages (lazy-loaded)
│ └── index.js
├── dashboard/ # Dashboard widgets
│ ├── statsActions.js
│ └── Widget.js
└── sub-feature/ # Optional sub-modules (e.g. items/, categories/)
├── db.js
├── crud.js
└── admin/
```
> Not all files are required. Only create what the module actually needs.
---
## Step 1 — Create `module.config.js`
```javascript
import { lazy } from 'react';
export default {
// Module identity
name: 'your-module',
displayName: 'Your Module',
version: '1.0.0',
description: 'Description of your module',
// Other modules this one depends on (must be enabled too)
dependencies: ['clients'],
// Environment variables this module uses (documentation only)
envVars: [
'YOUR_MODULE_API_KEY',
],
// Admin navigation — single section object or array of section objects
navigation: {
id: 'your-module',
title: 'Your Module',
icon: 'SomeIcon', // String icon name from shared/Icons.js
items: [
{ name: 'Items', href: '/admin/your-module/items', icon: 'PackageIcon' },
{ name: 'New', href: '/admin/your-module/items/new', icon: 'PlusSignIcon' },
],
},
// Admin pages — path → lazy component
adminPages: {
'/admin/your-module/items': lazy(() => import('./admin/ItemsListPage.js')),
'/admin/your-module/items/new': lazy(() => import('./admin/ItemCreatePage.js')),
'/admin/your-module/items/edit': lazy(() => import('./admin/ItemEditPage.js')),
},
// (Optional) Custom resolver for dynamic paths not known at build time.
// Called before the adminPages map. Return the lazy component or null.
pageResolver(path) {
const parts = path.split('/').filter(Boolean);
// example: /admin/your-module/{type}/list
if (parts[2] === 'list') return lazy(() => import('./admin/ItemsListPage.js'));
return null;
},
// Public pages — keyed by 'default' (one component handles all public routes)
publicPages: {
default: lazy(() => import('./pages/YourModulePublicPages.js')),
},
// Public route patterns for SEO/route matching (relative to /zen/your-module/)
publicRoutes: [
{ pattern: ':id', description: 'View item' },
{ pattern: ':id/pdf', description: 'PDF viewer' },
],
// Dashboard widgets (lazy-loaded, rendered on the admin dashboard)
dashboardWidgets: [
lazy(() => import('./dashboard/Widget.js')),
],
};
```
### Navigation as multiple sections
When a module provides several distinct sections (like the `posts` module with one section per post type), set `navigation` to an array:
```javascript
navigation: [
{ id: 'your-module-foo', title: 'Foo', icon: 'Book02Icon', items: [...] },
{ id: 'your-module-bar', title: 'Bar', icon: 'Layers01Icon', items: [...] },
],
```
---
## Step 2 — Create `db.js`
Every module that uses a database must expose a `createTables` function:
```javascript
import { query } from '@hykocx/zen/database';
export async function createTables() {
const created = [];
const skipped = [];
const exists = await query(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = $1
)`, ['zen_your_module']);
if (!exists.rows[0].exists) {
await query(`
CREATE TABLE zen_your_module (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
)
`);
created.push('zen_your_module');
} else {
skipped.push('zen_your_module');
}
return { created, skipped };
}
export async function dropTables() {
await query(`DROP TABLE IF EXISTS zen_your_module CASCADE`);
}
```
> **Never create migrations.** Instead, provide the SQL to the user so they can run it manually.
---
## Step 3 — Create `.env.example`
Document every environment variable the module reads:
```bash
#################################
# MODULE YOUR-MODULE
ZEN_MODULE_YOUR_MODULE=false
ZEN_MODULE_YOUR_MODULE_API_KEY=
ZEN_MODULE_YOUR_MODULE_SOME_OPTION=default_value
#################################
```
---
## Step 4 — Create `cron.config.js` (optional)
Only needed if the module requires scheduled tasks:
```javascript
import { doSomething } from './reminders.js';
export default {
jobs: [
{
name: 'your-module-task',
description: 'Description of what this job does',
schedule: '*/5 * * * *', // cron expression
handler: doSomething,
timezone: process.env.ZEN_TIMEZONE || 'America/Toronto',
},
],
};
```
---
## Step 5 — Register the module in 5 files
### `modules/modules.registry.js` — add the module name
```javascript
export const AVAILABLE_MODULES = [
'clients',
'invoice',
'your-module',
];
```
### `modules/modules.pages.js` — import the config
```javascript
'use client';
import yourModuleConfig from './your-module/module.config.js';
const MODULE_CONFIGS = {
// ...existing modules...
'your-module': yourModuleConfig,
};
```
### `modules/modules.actions.js` — import server actions (if public pages or dashboard widgets)
```javascript
import { yourPublicAction } from './your-module/actions.js';
import { getYourModuleDashboardStats } from './your-module/dashboard/statsActions.js';
export const MODULE_ACTIONS = {
// ...existing modules...
'your-module': { yourPublicAction },
};
export const MODULE_DASHBOARD_ACTIONS = {
// ...existing modules...
'your-module': getYourModuleDashboardStats,
};
```
### `modules/modules.metadata.js` — import metadata generators (if SEO needed)
```javascript
import * as yourModuleMetadata from './your-module/metadata.js';
export const MODULE_METADATA = {
// ...existing modules...
'your-module': yourModuleMetadata,
};
```
### `modules/init.js` — register the database initializer
```javascript
import { createTables as createYourModuleTables } from './your-module/db.js';
const MODULE_DB_INITIALIZERS = {
// ...existing modules...
'your-module': createYourModuleTables,
};
```
---
## Step 6 — Enable the module
```bash
ZEN_MODULE_YOUR_MODULE=true
```
The environment variable is derived from the module name: `ZEN_MODULE_` + module name uppercased (hyphens become underscores).
---
## Sub-modules
For complex modules, group related features into sub-directories. Each sub-module follows the same pattern (`db.js`, `crud.js`, `admin/`). The parent `module.config.js` registers all sub-module admin pages, and the parent `index.js` re-exports everything publicly.
See `src/modules/invoice/` for a complete example with `items/`, `categories/`, `transactions/`, and `recurrences/` sub-modules.
---
## Reference implementations
| Module | Features demonstrated |
|--------|-----------------------|
| `src/modules/invoice/` | Sub-modules, public pages, cron jobs, Stripe, email, PDF, dashboard widgets, metadata |
| `src/modules/posts/` | Dynamic config from env vars, `pageResolver`, multiple navigation sections |
| `src/modules/clients/` | Simple module, dependencies, no public pages |
+171 -248
View File
@@ -1,99 +1,102 @@
# Posts Module
# Module Posts
Configurable Custom Post Types via environment variables. Inspired by the WordPress CPT concept: each project declares its own content types (blog, CVE, job, event, etc.) with the fields it needs, without modifying code.
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.
---
## Features
## Configuration
- **Multiple post types** in a single module (blog, CVE, job...)
- **Dynamic fields** per type: title, slug, text, markdown, date, datetime, color, category, image, relation
- **Generic admin**: forms adapt automatically to the config
- **Public API** per type for integration in a Next.js site
- **Optional categories** per type (enabled if a `category` field is defined)
- **Relations** between types (many-to-many, e.g. CVE → Tags)
- **Unique slugs** per type (scoped: `blogue/mon-article``cve/mon-article`)
### Variables d'environnement
---
## Installation
### 1. Environment variables
Copy variables from [`.env.example`](.env.example) into your `.env`:
> If no label is provided (`ZEN_MODULE_POSTS_TYPES=blogue`), the display name will be the key with the first letter capitalized (`Blogue`).
**Optional (images):**
If one of your types uses the `image` field, configure Zen storage in your main `.env` (`ZEN_STORAGE_REGION`, `ZEN_STORAGE_ACCESS_KEY`, `ZEN_STORAGE_SECRET_KEY`, `ZEN_STORAGE_BUCKET`).
### 2. Available field types
| Type | `.env` syntax | Description |
|---|---|---|
| `title` | `name:title` | Main text field — auto-generates the slug |
| `slug` | `name:slug` | Unique URL slug per type — auto-filled from title |
| `text` | `name:text` | Free text area (textarea) |
| `markdown` | `name:markdown` | Markdown editor with preview |
| `date` | `name:date` | Date picker only (YYYY-MM-DD) |
| `datetime` | `name:datetime` | Date **and time** picker (YYYY-MM-DDTHH:MM) |
| `color` | `name:color` | Color picker — stores a hex code `#rrggbb` |
| `category` | `name:category` | Dropdown linked to the category table |
| `image` | `name:image` | Image upload to Zen storage |
| `relation` | `name:relation:target_type` | Multi-select to posts of another type |
> **Rule:** each type must have at least one `title` field and one `slug` field. The `category` field automatically creates the `zen_posts_category` table. The `relation` field automatically creates the `zen_posts_relations` table.
#### `date` vs `datetime`
- `date` → stores `"2026-03-14"` — sufficient for blog posts, events
- `datetime` → stores `"2026-03-14T10:30:00.000Z"` (ISO 8601, UTC) — needed for CVEs, security bulletins, precise schedules
### `relation` field — Linking posts together
The `relation` field associates multiple posts of another type (many-to-many). Example: news posts referencing a source and tags.
Copier les variables de [`.env.example`](.env.example) dans votre `.env`.
```bash
ZEN_MODULE_POSTS_TYPE_ACTUALITE=title:title|slug:slug|date:datetime|resume:text|content:markdown|image:image|source:relation:source|tags:relation:tag
# 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
```
- The name before `relation` (`source`, `tags`) is the field name in the form and in the API response
- The value after `relation` (`source`, `tag`) is the target post type
- Selection is **multiple** (multi-select with real-time search)
- Relations are stored in `zen_posts_relations` (junction table)
- In the API, relations are returned as an array of objects containing **all fields** of the linked post: `tags: [{ id, slug, title, color, ... }]`
- 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é.
### 3. Database tables
### Images
Tables are created automatically with `npx zen-db init`. For reference, here are the module tables:
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 (all types — custom fields in `data JSONB`) |
| `zen_posts_category` | Categories per type (if a `category` field is defined) |
| `zen_posts_relations` | Relations between posts (if a `relation` field is defined) |
| `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) |
> **Design:** all custom fields are stored in the `data JSONB` column. Adding or removing a field in `.env` requires no SQL migration.
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.
---
## Admin interface
## Interface d'administration
| Page | URL |
|---|---|
| Post list for a type | `/admin/posts/{type}/list` |
| Create a post | `/admin/posts/{type}/new` |
| Edit a post | `/admin/posts/{type}/edit/{id}` |
| Category list for a type | `/admin/posts/{type}/categories` |
| Create a category | `/admin/posts/{type}/categories/new` |
| Edit a category | `/admin/posts/{type}/categories/edit/{id}` |
| 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}` |
---
## Public API (no authentication)
## API publique
Pas d'authentification requise.
### Config
@@ -101,28 +104,27 @@ Tables are created automatically with `npx zen-db init`. For reference, here are
GET /zen/api/posts/config
```
Returns the list of all configured types with their fields.
Retourne la liste de tous les types configurés avec leurs champs.
### Post list
### Liste de posts
```
GET /zen/api/posts/{type}
```
**Query parameters:**
| Parameter | Default | Description |
| Paramètre | Défaut | Description |
|---|---|---|
| `page` | `1` | Current page |
| `limit` | `20` | Results per page |
| `category_id` | — | Filter by category |
| `sortBy` | `created_at` | Sort by (field name of the type) |
| `sortOrder` | `DESC` | `ASC` or `DESC` |
| `withRelations` | `false` | `true` to include relation fields in each post |
| `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 |
> **Performance:** `withRelations=true` runs an additional SQL query per post. Use with a reasonable `limit` (≤ 20). On a detail page, prefer `/posts/{type}/{slug}` which always loads relations.
`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) :**
**Response without `withRelations` (default):**
```json
{
"success": true,
@@ -146,7 +148,8 @@ GET /zen/api/posts/{type}
}
```
**Response with `withRelations=true`:**
**Réponse avec `withRelations=true` :**
```json
{
"success": true,
@@ -160,61 +163,33 @@ GET /zen/api/posts/{type}
{ "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é" }
{ "id": 7, "slug": "openssh", "post_type": "tag", "title": "OpenSSH" },
{ "id": 8, "slug": "vulnerabilite", "post_type": "tag", "title": "Vulnérabilité" }
]
}
]
}
```
### Single post by slug
### Post par slug
```
GET /zen/api/posts/{type}/{slug}
```
Relations are **always included** on a single post.
Les relations sont toujours incluses sur un post individuel.
**Response for a news post:**
```json
{
"success": true,
"post": {
"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 a été découverte...",
"content": "# Détails\n\n...",
"image": "blog/1234567890-image.webp",
"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é" }
],
"created_at": "2026-03-14T12:00:00Z",
"updated_at": "2026-03-14T12:00:00Z"
}
}
```
### Categories
### Catégories
```
GET /zen/api/posts/{type}/categories
```
Returns the list of active categories for the type (to populate a filter).
Retourne les catégories actives du type (pour alimenter un filtre).
### Images
Image keys are built as follows: `/zen/api/storage/{image_field_value}`
Les clés d'image s'utilisent avec la route de stockage :
```jsx
<img src={`/zen/api/storage/${post.image}`} alt={post.title} />
@@ -222,9 +197,9 @@ Image keys are built as follows: `/zen/api/storage/{image_field_value}`
---
## Next.js integration examples
## Intégration Next.js
### News list (without relations)
### Liste de posts
```js
// app/actualites/page.js
@@ -248,7 +223,7 @@ export default async function ActualitesPage() {
}
```
### News list with tags and source (withRelations)
### Liste avec relations
```js
// app/actualites/page.js
@@ -264,12 +239,10 @@ export default async function ActualitesPage() {
<li key={post.id}>
<a href={`/actualites/${post.slug}`}>{post.title}</a>
{/* Source (array, usually 1 element) */}
{post.source?.[0] && (
<span>Source : {post.source[0].title}</span>
)}
{/* Tags (array of 0..N elements) */}
<div>
{post.tags?.map(tag => (
<a key={tag.id} href={`/tags/${tag.slug}`}>{tag.title}</a>
@@ -282,7 +255,7 @@ export default async function ActualitesPage() {
}
```
### News detail page (relations always included)
### Page de détail
```js
// app/actualites/[slug]/page.js
@@ -298,19 +271,14 @@ export default async function ActualiteDetailPage({ params }) {
<article>
<h1>{post.title}</h1>
{/* datetime field: display with time */}
<time dateTime={post.date}>
{new Date(post.date).toLocaleString('fr-FR')}
</time>
{/* Source — array even with a single element */}
{post.source?.[0] && (
<p>
Source : <a href={`/sources/${post.source[0].slug}`}>{post.source[0].title}</a>
</p>
<p>Source : <a href={`/sources/${post.source[0].slug}`}>{post.source[0].title}</a></p>
)}
{/* Tags */}
{post.tags?.length > 0 && (
<div>
{post.tags.map(tag => (
@@ -326,46 +294,7 @@ export default async function ActualiteDetailPage({ params }) {
}
```
### CVE detail page
```js
// app/cve/[slug]/page.js
export default async function CVEDetailPage({ params }) {
const res = await fetch(
`${process.env.NEXT_PUBLIC_URL}/zen/api/posts/cve/${params.slug}`
);
const { post } = await res.json();
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<p>ID : {post.cve_id}</p>
<p>Sévérité : {post.severity} Score : {post.score}</p>
<p>Produit : {post.product}</p>
{/* Disclosure date and time */}
<time dateTime={post.date}>
{new Date(post.date).toLocaleString('fr-FR')}
</time>
{/* Associated tags */}
{post.tags?.length > 0 && (
<div>
{post.tags.map(tag => (
<span key={tag.id}>{tag.title}</span>
))}
</div>
)}
<div>{post.description}</div>
</article>
);
}
```
### Dynamic SEO metadata
### Métadonnées SEO dynamiques
```js
// app/actualites/[slug]/page.js
@@ -390,63 +319,74 @@ export async function generateMetadata({ params }) {
---
## Adding a new post type
## Ajouter ou modifier un type
Edit `.env` only — no database restart needed:
**Ajouter un type :** modifier uniquement le `.env`, pas besoin de redémarrer la base.
```bash
# Before
ZEN_MODULE_POSTS_TYPES=cve:CVEs|actualite:Actualités
# Avant
ZEN_MODULE_POSTS_TYPES=cve:CVE|actualite:Actualités
# After — adding the 'evenement' type
ZEN_MODULE_POSTS_TYPES=cve:CVEs|actualite:Actualités|evenement:Événements
# 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
```
Restart the server. Tables are unchanged (new fields use the existing JSONB).
Redémarrer le serveur. Les tables ne changent pas (les nouveaux champs utilisent le JSONB existant).
## Modifying fields on an existing type
Update the `ZEN_MODULE_POSTS_TYPE_*` variable and restart. Existing posts keep their data in JSONB even if a field is removed from the config — it simply won't appear in the form anymore.
**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.
---
## Programmatic usage (importers / fetchers)
## Utilisation programmatique
CRUD functions are directly importable server-side. No need to go through the HTTP API — ideal for cron jobs, import scripts, or automated fetchers.
Les fonctions CRUD sont importables côté serveur. Idéal pour les cron jobs, scripts d'import ou fetchers automatisés.
### Available functions
### Fonctions disponibles
```js
import {
createPost, // Create a post
updatePost, // Update a post
getPostBySlug, // Find by slug
getPostByField, // Find by any JSONB field
upsertPost, // Create or update (idempotent, for importers)
getPosts, // List with pagination
deletePost, // Delete
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)`
Key function for importers: creates the post if it doesn't exist, updates it otherwise.
Crée le post s'il n'existe pas, le met à jour sinon.
- `postType`: the post type (`'cve'`, `'actualite'`, etc.)
- `rawData`: post data (same fields as for `createPost`)
- `uniqueField`: the deduplication key field (`'slug'` by default, or `'cve_id'`, etc.)
- `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)
Returns `{ post, created: boolean }`.
Retourne `{ post, created: boolean }`.
### Example — CVE fetcher (cron job)
### 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() {
// 1. Fetch data from an external source
const response = await fetch('https://api.example.com/cves/recent');
const { cves } = await response.json();
@@ -454,24 +394,24 @@ export async function fetchAndImportCVEs() {
for (const cve of cves) {
try {
// 2. Resolve relations — ensure tags exist
// 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);
}
// 3. Upsert the CVE (deduplicated on cve_id)
// Upsert du CVE, dédupliqué sur cve_id
const { created } = await upsertPost('cve', {
title: cve.title, // title field
cve_id: cve.id, // text field
severity: cve.severity, // text field
score: String(cve.cvssScore), // text field
product: cve.affectedProduct, // text field
date: cve.publishedAt, // datetime field (ISO 8601)
description: cve.description, // markdown field
tags: tagIds, // relation:tag field — array of IDs
}, 'cve_id'); // deduplicate on cve_id
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) {
@@ -485,17 +425,14 @@ export async function fetchAndImportCVEs() {
}
```
### Example — News fetcher with source
### Exemple : fetcher d'actualités avec source
```js
import { upsertPost, getPostByField } from '@hykocx/zen/modules/posts/crud';
import { upsertPost } from '@hykocx/zen/modules/posts/crud';
export async function fetchAndImportActualites(sourceName, sourceUrl, articles) {
// 1. Ensure the source exists
const { post: source } = await upsertPost('source', {
title: sourceName,
color: '#3b82f6',
}, 'slug');
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', {
@@ -503,45 +440,31 @@ export async function fetchAndImportActualites(sourceName, sourceUrl, articles)
date: article.publishedAt,
resume: article.summary,
content: article.content,
source: [source.id], // relation:source — array of IDs
tags: [], // relation:tag
source: [source.id],
tags: [],
}, 'slug');
}
}
```
### Rules for relation fields in `rawData`
`relation` fields must receive an **array of IDs** of existing posts:
```js
// Correct
{ tags: [7, 8, 12], source: [3] }
// Incorrect — no slugs or objects
{ tags: ['openssh', 'vuln'], source: { id: 3 } }
```
If the linked posts don't exist yet, create them first with `upsertPost` then use their IDs.
---
## Admin API (authentication required)
## API d'administration
These routes require an active admin session.
Authentification requise.
| Method | Route | Description |
| Méthode | Route | Description |
|---|---|---|
| `GET` | `/zen/api/admin/posts/config` | Full config for all types |
| `GET` | `/zen/api/admin/posts/search?type={type}&q={query}` | Search posts for the relation picker |
| `GET` | `/zen/api/admin/posts/posts?type={type}` | Post list for a type |
| `GET` | `/zen/api/admin/posts/posts?type={type}&withRelations=true` | List with relations included |
| `GET` | `/zen/api/admin/posts/posts?type={type}&id={id}` | Post by ID (relations always included) |
| `POST` | `/zen/api/admin/posts/posts?type={type}` | Create a post |
| `PUT` | `/zen/api/admin/posts/posts?type={type}&id={id}` | Update a post |
| `DELETE` | `/zen/api/admin/posts/posts?type={type}&id={id}` | Delete a post |
| `POST` | `/zen/api/admin/posts/upload-image` | Upload an image |
| `GET` | `/zen/api/admin/posts/categories?type={type}` | Category list |
| `POST` | `/zen/api/admin/posts/categories?type={type}` | Create a category |
| `PUT` | `/zen/api/admin/posts/categories?type={type}&id={id}` | Update a category |
| `DELETE` | `/zen/api/admin/posts/categories?type={type}&id={id}` | Delete a category |
| `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 |