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:
+1
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user