Compare commits

20 Commits

Author SHA1 Message Date
cedric d5d2507f7c fix: change dependency install command
Déploiement API BETA / Tests extensions (push) Successful in 11m3s
Déploiement API BETA / Déploiement beta (push) Failing after 7s
2026-05-14 20:34:39 +04:00
cedric 5e2da640c6 deploy: add npm install step for extenstions
Déploiement API BETA / Tests extensions (push) Failing after 4m55s
Déploiement API BETA / Déploiement beta (push) Has been skipped
2026-05-14 19:43:19 +04:00
cedric 9773f88dc6 deploy: add workflow for beta
Déploiement API BETA / Tests extensions (push) Failing after 6m20s
Déploiement API BETA / Déploiement beta (push) Has been skipped
2026-05-14 19:34:46 +04:00
cedric 8c66473371 chore: change port to 8066 2026-05-14 17:23:36 +04:00
cedric 6e12f46add fix: create docker networks 2026-05-14 17:20:21 +04:00
cedric 0751790cbf build: upgrade to directus 11.17.2 2026-04-14 17:38:47 +04:00
cedric 0e56909626 test: tests Vitest pour les extensions Directus (18 tests, 0 échec)
- disallow-votes (13 tests) : mock knex chaînable + VersionsService
  - versionId manquant, version introuvable, version > 3j, version obsolète
  - échec non bloquant de compare(), collection ignorée si ≠ votes
  - items.delete : voteId manquant, vote introuvable, version associée ancienne
- new-user (5 tests) : mock MailService + database
  - MailService absent, EMAIL_NEW_USER absent, email déjà utilisé
  - envoi e-mail admin, fallback URL par défaut, erreur SMTP non bloquante
- vitest.config.mjs : pointe sur extensions/*/src/__tests__/**/*.test.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 06:35:01 +04:00
cedric d59972af91 security: restreindre CORS_ORIGIN à une whitelist explicite
Remplace CORS_ORIGIN=true (toutes origines autorisées) par la valeur
de production https://konstitisyon.nu dans .env.sample.
Documente également la valeur de dev local (http://localhost:3000).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 21:35:10 +04:00
cedric 154856b2a9 docs: corrige l'URL /websocket et ajoute timeout dans la configuration nginx 2026-01-24 17:58:00 +04:00
cedric df3219c3ba docs: ajout de la documentation pour le déploiement 2026-01-23 23:23:21 +04:00
cedric fe8b3baf1f chore: renomme domaine api.konstitisyon.la vers api.konstitisyon.nu 2026-01-10 23:42:30 +04:00
cedric 6a9717008b Bloquer votes sur versions obsolètes 2026-01-04 13:04:10 +04:00
cedric 5595587c6f build: upgrade to directus 11.5.1 2025-12-26 10:14:55 +04:00
cedric cb8e918279 feat: add docker-compose.yml file 2025-06-10 03:24:39 +02:00
cedric a06e208bfa refactor: move data.sample.db to dbtbbase directory 2025-06-07 14:00:23 +02:00
cedric 5ac4b99e32 Ajout de la variable DIRECTUS_URL pour l'envoie de l'e-mail 2024-12-22 03:57:22 +04:00
cedric bc0aafa8b3 Création de l'extension 'directus-extension-new-user' 2024-12-22 03:43:07 +04:00
cedric 90f07c68db feat: ajout de l'extension disallow-votes 2024-12-17 12:34:42 +04:00
cedric 67f8c565cc docs: création du README avec la documentation complète de l'API 2024-12-16 14:19:30 +04:00
cedric 56aef7751a Add data.sample.db 2024-12-16 14:13:19 +04:00
20 changed files with 12154 additions and 281 deletions
+6 -2
View File
@@ -61,7 +61,7 @@ PUBLIC_URL="http://localhost:8055"
# you need to pass to the database instance. # you need to pass to the database instance.
DB_CLIENT="sqlite3" DB_CLIENT="sqlite3"
DB_FILENAME="data.db" DB_FILENAME="./database/data.db"
@@ -242,7 +242,11 @@ SESSION_COOKIE_NAME="directus_session_token"
CORS_ENABLED=true CORS_ENABLED=true
# Value for the Access-Control-Allow-Origin header. Use true to match the Origin header, or provide a domain or a CSV of domains for specific access [false] # Value for the Access-Control-Allow-Origin header. Use true to match the Origin header, or provide a domain or a CSV of domains for specific access [false]
CORS_ORIGIN=true # NE PAS utiliser true en production — lister explicitement les origines autorisées
# Dev local :
# CORS_ORIGIN=http://localhost:3000
# Production :
CORS_ORIGIN=https://konstitisyon.nu
# Value for the Access-Control-Allow-Methods header [GET,POST,PATCH,DELETE] # Value for the Access-Control-Allow-Methods header [GET,POST,PATCH,DELETE]
CORS_METHODS=GET,POST,PATCH,DELETE CORS_METHODS=GET,POST,PATCH,DELETE
+61
View File
@@ -0,0 +1,61 @@
name: Déploiement API BETA
run-name: ${{ gitea.actor }} est en cours de déploiement API BETA
on:
push:
branches:
- dev
jobs:
test:
name: Tests extensions
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Installer les dépendances
run: npm install
- name: Lancer les tests
run: npm test
deploy:
name: Déploiement beta
needs: test
runs-on: ubuntu-latest
steps:
- name: Déployer via SSH
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SSH_PORT }}
script: |
set -e
cd ${{ secrets.DEPLOY_PATH }}
echo "==> Pull branche dev"
git pull origin dev
echo "==> Build des extensions"
for ext in extensions/directus-extension-disallow-votes extensions/directus-extension-new-user; do
echo " Building $ext..."
cd "$ext"
npm install
npm ci
npm run build
cd -
done
echo "==> Redémarrage Directus"
docker compose restart directus
echo "==> Vérification santé"
sleep 5
curl -sf http://localhost:8066/server/health | grep -q '"status":"ok"'
echo "Déploiement OK"
+173
View File
@@ -0,0 +1,173 @@
# Deploiement Backend Directus
Guide de deploiement du backend Directus sur un serveur Ubuntu.
## Prerequis
- Ubuntu 20.04+ / Debian 11+
- Acces root ou sudo
- Docker (ou Node.js 22+ pour une installation sans Docker)
- Nom de domaine configure (ex: `api.exemple.com`)
## 1. Installation des dependances
```bash
sudo apt update && sudo apt upgrade -y
# Docker
sudo apt install -y docker.io docker-compose-v2
sudo usermod -aG docker $USER
newgrp docker
# Nginx et Certbot
sudo apt install -y nginx certbot python3-certbot-nginx
```
## 2. Configuration du projet
```bash
# Cloner le projet
git clone <URL_DU_REPO> backend
cd backend
# Configurer l'environnement
cp .env.sample .env
nano .env
```
Variables a modifier dans `.env`:
```env
PUBLIC_URL="https://api.exemple.com"
KEY="<openssl rand -hex 32>"
SECRET="<openssl rand -hex 32>"
```
## 3. Preparation des volumes
```bash
mkdir -p database uploads extensions
chmod 755 database uploads extensions
```
## 4. Demarrage de Directus
```bash
docker compose up -d
docker compose ps
curl http://localhost:8055/server/health
```
## 5. Configuration Nginx
```bash
sudo nano /etc/nginx/sites-available/api.exemple.com
```
```nginx
server {
listen 80;
listen [::]:80;
server_name api.exemple.com;
client_max_body_size 100M;
access_log /var/log/nginx/api.exemple.com.access.log;
error_log /var/log/nginx/api.exemple.com.error.log;
location / {
proxy_pass http://127.0.0.1:8055;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_connect_timeout 600;
proxy_send_timeout 600;
proxy_read_timeout 600;
}
location /websocket {
proxy_pass http://127.0.0.1:8055/websocket;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
}
}
```
Activer le site:
```bash
sudo ln -s /etc/nginx/sites-available/api.exemple.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```
## 6. Certificat SSL
Verifier le DNS:
```bash
dig +short api.exemple.com
curl -4 ifconfig.me
```
Obtenir le certificat:
```bash
sudo certbot --nginx -d api.exemple.com
```
## 7. Verification
```bash
curl https://api.exemple.com/server/health
```
Acces admin: `https://api.exemple.com/admin`
## Commandes utiles
```bash
# Logs
docker compose logs -f
# Redemarrer
docker compose restart
# Mise a jour
git pull origin main
docker compose down && docker compose up -d
# Backup base de donnees
cp database/data.db database/data.db.backup-$(date +%Y%m%d)
# Backup uploads
tar -czf uploads-backup-$(date +%Y%m%d).tar.gz uploads/
```
## Troubleshooting
### Erreur 502
```bash
docker compose ps
curl http://localhost:8055/server/health
sudo tail -20 /var/log/nginx/api.exemple.com.error.log
```
### Erreur SSL
```bash
dig +short api.exemple.com
sudo certbot certificates
sudo certbot renew --force-renewal
```
+169 -1
View File
@@ -1 +1,169 @@
# api.konstitisyon.la # api.konstitisyon.nu
Backend Directus pour konstitisyon.nu, une plateforme de rédaction constitutionnelle collaborative. Ce dépôt contient la configuration Directus et le schéma de la base de données.
## Architecture
### Technologies
- **Backend**: Directus 11.x
- **Base de données**: SQLite
- **Authentification**: JWT via Directus
### Structure du projet
```
api.konstitisyon.nu/
├── extensions/ # Extensions Directus personnalisées
├── uploads/ # Fichiers uploadés
├── data.db # Base de données SQLite
└── .env # Configuration de l'environnement
```
## Modèle de données
### Schéma de la base de données
```mermaid
erDiagram
TITRES ||--o{ ARTICLES : contient
TITRES ||--o{ COMMENTAIRES : discute
TITRES {
uuid id PK
string status
uuid user_created FK
datetime date_created
text contenu
integer numero
}
ARTICLES {
uuid id PK
string status
uuid user_created FK
datetime date_created
text contenu
integer numero
uuid titre FK
}
COMMENTAIRES {
uuid id PK
string status
uuid user_created FK
datetime date_created
text contenu
uuid titre FK
}
VOTES {
uuid id PK
uuid user_created FK
datetime date_created
datetime date_updated
uuid content_version_id FK
integer vote
}
DIRECTUS_VERSIONS ||--o{ VOTES : evalue
DIRECTUS_USERS ||--o{ VOTES : cree
DIRECTUS_USERS ||--o{ COMMENTAIRES : ecrit
DIRECTUS_USERS ||--o{ ARTICLES : redige
DIRECTUS_USERS ||--o{ TITRES : redige
```
### Collections Directus
#### Collections principales
- **Titres**
- Sections principales de la constitution
- Numérotés et versionnés
- Champs : numero, contenu, status
- **Articles**
- Contenus détaillés sous chaque titre
- Liés à un titre parent
- Champs : numero, contenu, titre (FK), status
#### Collections participatives
- **Commentaires**
- Discussions sur les titres
- Liés à un titre spécifique
- Champs : contenu, titre (FK), user_created
- **Votes**
- Évaluation des versions de contenu
- Valeurs : +1 ou -1
- Champs : content_version_id (FK), vote, user_created
## Installation
### Prérequis
- Node.js 16+
- npm ou yarn
- SQLite 3
### Configuration
Copier le fichier `.env.sample` vers un fichier `.env` :
```bash
cp .env.sample .env
```
### Démarrage
```bash
# Installation des dépendances
npm install
# Démarrage du serveur
npx directus start
```
## API
### Points d'entrée principaux
#### Titres
- `GET /items/titres` : Liste des titres
- `GET /items/titres/:id` : Détails d'un titre
- `POST /items/titres` : Créer un titre
- `PATCH /items/titres/:id` : Modifier un titre
#### Articles
- `GET /items/articles` : Liste des articles
- `GET /items/articles/:id` : Détails d'un article
- `POST /items/articles` : Créer un article
- `PATCH /items/articles/:id` : Modifier un article
#### Commentaires
- `GET /items/commentaires` : Liste des commentaires
- `GET /items/commentaires/:id` : Détails d'un commentaire
- `POST /items/commentaires` : Créer un commentaire
#### Votes
- `GET /items/votes` : Liste des votes
- `GET /items/votes/:id` : Détails d'un vote
- `POST /items/votes` : Créer un vote
- `PATCH /items/votes/:id` : Modifier un vote
### Authentification
- `POST /auth/login` : Connexion utilisateur
- `POST /auth/refresh` : Rafraîchissement du token
- `POST /auth/logout` : Déconnexion
### Base de données
Le projet utilise une base de données SQLite. Un fichier `data.sample.db` est fourni avec :
- La structure complète de la base de données
- Un compte administrateur par défaut :
- Email : admin@example.com
- Mot de passe : admin
Pour démarrer un nouveau projet :
1. Copier `data.sample.db` vers `data.db`
- `cp data.sample.db data.db`
2. Mettre à jour le fichier `.env` pour pointer vers `data.db`
3. Démarrer le serveur
## License
Ce projet est sous licence AGPL-3. Cette licence garantit que le code reste libre et que toute modification doit être partagée avec la communauté. Voir le fichier `LICENSE` pour plus de détails.
## Contact
Pour toute question ou suggestion concernant le projet, n'hésitez pas à ouvrir une issue sur Codeberg.
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
services:
directus:
image: directus/directus:11.17.2
ports:
- 8066:8066
volumes:
- ./database:/directus/database
- ./uploads:/directus/uploads
- ./extensions:/directus/extensions
env_file: ".env"
networks:
- konstitisyon_network
networks:
konstitisyon_network:
external: true
@@ -0,0 +1,3 @@
.DS_Store
node_modules
dist
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
{
"name": "disallow-votes",
"description": "Interdit les votes sur des versions ayant plus de 3 jours",
"icon": "extension",
"version": "1.0.0",
"keywords": [
"directus",
"directus-extension",
"directus-extension-hook"
],
"type": "module",
"files": [
"dist"
],
"directus:extension": {
"type": "hook",
"path": "dist/index.js",
"source": "src/index.js",
"host": "^10.10.0"
},
"scripts": {
"build": "directus-extension build",
"dev": "directus-extension build -w --no-minify",
"link": "directus-extension link"
},
"devDependencies": {
"@directus/extensions-sdk": "12.1.4"
}
}
@@ -0,0 +1,231 @@
import {describe, it, expect, vi, beforeEach} from 'vitest'
import hookFactory from '../index.js'
// Helpers pour construire les mocks Directus ---------------------------------
/**
* Construit un mock de database (knex) chaînable.
* Chaque méthode retourne `this` sauf `.first()` qui résout la valeur fournie.
*/
const makeDatabase = (resolvedRow) => {
const db = vi.fn(() => db)
db.select = vi.fn(() => db)
db.where = vi.fn(() => db)
db.first = vi.fn(() => Promise.resolve(resolvedRow))
return db
}
/**
* Construit un mock de VersionsService.
* `compareResult` est la valeur renvoyée par `compare()`.
*/
const makeVersionsService = (compareResult) => ({
VersionsService: class {
compare() {
return Promise.resolve(compareResult)
}
},
})
/**
* Enregistre les callbacks du hook et les expose par événement.
* Permet d'appeler directement `callbacks['items.create'](input, meta, ctx)`.
*/
const mountHook = (services) => {
const callbacks = {}
const filter = (event, handler) => {
callbacks[event] = handler
}
hookFactory({filter}, {services})
return callbacks
}
// Dates de référence ---------------------------------------------------------
const RECENT_DATE = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString() // hier
const OLD_DATE = new Date(Date.now() - 4 * 24 * 60 * 60 * 1000).toISOString() // il y a 4 jours
const SCHEMA = {}
// Tests ----------------------------------------------------------------------
describe('disallow-votes — items.create', () => {
let callbacks
beforeEach(() => {
callbacks = mountHook(makeVersionsService({outdated: false}))
})
it('lève une erreur si versionId est absent', async () => {
const db = makeDatabase(null)
await expect(
callbacks['items.create']({}, {collection: 'votes'}, {database: db, schema: SCHEMA})
).rejects.toThrow('identifiant de la version est manquant')
})
it('lève une erreur si la version est introuvable', async () => {
const db = makeDatabase(undefined)
await expect(
callbacks['items.create'](
{content_version_id: 'abc'},
{collection: 'votes'},
{database: db, schema: SCHEMA},
)
).rejects.toThrow('Version non trouvée')
})
it('lève une erreur si la version a plus de 3 jours', async () => {
const db = makeDatabase({date_created: OLD_DATE})
await expect(
callbacks['items.create'](
{content_version_id: 'abc'},
{collection: 'votes'},
{database: db, schema: SCHEMA},
)
).rejects.toThrow('3 jours')
})
it('lève une erreur si la version est obsolète', async () => {
const db = makeDatabase({date_created: RECENT_DATE})
callbacks = mountHook(makeVersionsService({outdated: true}))
await expect(
callbacks['items.create'](
{content_version_id: 'abc'},
{collection: 'votes'},
{database: db, schema: SCHEMA},
)
).rejects.toThrow('version obsolète')
})
it('autorise le vote si la version est récente et valide', async () => {
const db = makeDatabase({date_created: RECENT_DATE})
const input = {content_version_id: 'abc'}
const result = await callbacks['items.create'](
input,
{collection: 'votes'},
{database: db, schema: SCHEMA},
)
expect(result).toBe(input)
})
it('autorise le vote si compare() échoue (non bloquant)', async () => {
const db = makeDatabase({date_created: RECENT_DATE})
const servicesWithBrokenCompare = {
VersionsService: class {
compare() {
return Promise.reject(new Error('service indisponible'))
}
},
}
callbacks = mountHook(servicesWithBrokenCompare)
const input = {content_version_id: 'abc'}
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const result = await callbacks['items.create'](
input,
{collection: 'votes'},
{database: db, schema: SCHEMA},
)
expect(result).toBe(input)
expect(warnSpy).toHaveBeenCalled()
warnSpy.mockRestore()
})
it('ignore les collections qui ne sont pas "votes"', async () => {
const db = makeDatabase(null)
const input = {content_version_id: 'abc'}
const result = await callbacks['items.create'](
input,
{collection: 'articles'},
{database: db, schema: SCHEMA},
)
expect(result).toBe(input)
expect(db).not.toHaveBeenCalled()
})
})
describe('disallow-votes — items.update', () => {
it('applique la même logique que items.create', async () => {
const db = makeDatabase({date_created: OLD_DATE})
const callbacks = mountHook(makeVersionsService({outdated: false}))
await expect(
callbacks['items.update'](
{content_version_id: 'abc'},
{collection: 'votes'},
{database: db, schema: SCHEMA},
)
).rejects.toThrow('3 jours')
})
})
describe('disallow-votes — items.delete', () => {
it('lève une erreur si voteId est absent', async () => {
const db = makeDatabase(null)
const callbacks = mountHook(makeVersionsService({outdated: false}))
await expect(
callbacks['items.delete']([], {collection: 'votes'}, {database: db, schema: SCHEMA})
).rejects.toThrow('identifiant du vote est manquant')
})
it('lève une erreur si le vote est introuvable', async () => {
const db = makeDatabase(undefined)
const callbacks = mountHook(makeVersionsService({outdated: false}))
await expect(
callbacks['items.delete'](['vote-1'], {collection: 'votes'}, {database: db, schema: SCHEMA})
).rejects.toThrow('Vote non trouvé')
})
it('lève une erreur si la version associée au vote a plus de 3 jours', async () => {
let callCount = 0
const db = vi.fn(() => db)
db.select = vi.fn(() => db)
db.where = vi.fn(() => db)
db.first = vi.fn(() => {
callCount++
// Premier appel : lookup du vote → retourne content_version_id
// Deuxième appel : lookup de la version → retourne date ancienne
return Promise.resolve(
callCount === 1
? {content_version_id: 'v-abc'}
: {date_created: OLD_DATE}
)
})
const callbacks = mountHook(makeVersionsService({outdated: false}))
await expect(
callbacks['items.delete'](['vote-1'], {collection: 'votes'}, {database: db, schema: SCHEMA})
).rejects.toThrow('3 jours')
})
it('autorise la suppression si la version est encore valide', async () => {
let callCount = 0
const db = vi.fn(() => db)
db.select = vi.fn(() => db)
db.where = vi.fn(() => db)
db.first = vi.fn(() => {
callCount++
return Promise.resolve(
callCount === 1
? {content_version_id: 'v-abc'}
: {date_created: RECENT_DATE}
)
})
const callbacks = mountHook(makeVersionsService({outdated: false}))
const input = ['vote-1']
const result = await callbacks['items.delete'](
input,
{collection: 'votes'},
{database: db, schema: SCHEMA},
)
expect(result).toBe(input)
})
})
@@ -0,0 +1,82 @@
export default ({filter}, {services}) => {
const checkVersionValidity = async (versionId, database, schema) => {
if (!versionId) {
throw new Error('Lidentifiant de la version est manquant.')
}
const version = await database('directus_versions')
.select('date_created')
.where({id: versionId })
.first()
if (!version) {
throw new Error('Version non trouvée.')
}
// Check if version is older than 3 days
const createdAt = new Date(version.date_created)
const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000)
if (createdAt < threeDaysAgo) {
throw new Error(
'Le vote nest plus possible après 3 jours de la création de la version.'
)
}
// Check if version is outdated (has been superseded by a promoted version)
try {
const {VersionsService} = services
const versionsService = new VersionsService({schema, knex: database})
const comparison = await versionsService.compare(versionId)
if (comparison && comparison.outdated === true) {
throw new Error(
'Le vote nest plus possible sur une version obsolète.'
)
}
} catch (error) {
// If it's our custom error, rethrow it
if (error.message === 'Le vote nest plus possible sur une version obsolète.') {
throw error
}
// Otherwise, log and continue (don't block vote if comparison fails)
console.warn('Could not check version outdated status:', error.message)
}
}
filter('items.create', async (input, {collection}, {database, schema}) => {
if (collection === 'votes') {
await checkVersionValidity(input.content_version_id, database, schema)
}
return input
})
filter('items.update', async (input, {collection}, {database, schema}) => {
if (collection === 'votes') {
await checkVersionValidity(input.content_version_id, database, schema)
}
return input
})
filter('items.delete', async (input, {collection}, {database, schema}) => {
if (collection === 'votes') {
const voteId = input[0]
if (!voteId) {
throw new Error('Lidentifiant du vote est manquant.')
}
const vote = await database('votes')
.select('content_version_id')
.where({id: voteId })
.first()
if (!vote) {
throw new Error('Vote non trouvé.')
}
await checkVersionValidity(vote.content_version_id, database, schema)
}
return input
})
}
@@ -0,0 +1,2 @@
EMAIL_NEW_USER=
DIRECTUS_URL="http://0.0.0.0:8055"
@@ -0,0 +1,3 @@
.DS_Store
node_modules
dist
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
{
"name": "directus-extension-new-user",
"description": "Envoie un e-mail lors de linscription dun utilisateur",
"icon": "extension",
"version": "1.0.0",
"keywords": [
"directus",
"directus-extension",
"directus-extension-hook"
],
"type": "module",
"files": [
"dist"
],
"directus:extension": {
"type": "hook",
"path": "dist/index.js",
"source": "src/index.js",
"host": "^10.10.0"
},
"scripts": {
"build": "directus-extension build",
"dev": "directus-extension build -w --no-minify",
"link": "directus-extension link"
},
"devDependencies": {
"@directus/extensions-sdk": "12.1.4"
}
}
@@ -0,0 +1,139 @@
import {describe, it, expect, vi, beforeEach} from 'vitest'
import hookFactory from '../index.js'
// Helpers --------------------------------------------------------------------
const makeDatabase = (existingUser) => {
const db = vi.fn(() => db)
db.select = vi.fn(() => db)
db.where = vi.fn(() => db)
db.first = vi.fn(() => Promise.resolve(existingUser))
return db
}
/**
* Monte le hook et capture le callback `users.create`.
*/
const mountHook = ({services, env}) => {
let callback
const filter = (event, handler) => {
if (event === 'users.create') {
callback = handler
}
}
hookFactory({filter}, {services, env})
return callback
}
const SCHEMA = {}
const BASE_ENV = {
EMAIL_NEW_USER: 'admin@example.com',
DIRECTUS_URL: 'http://localhost:8055',
}
// Tests ----------------------------------------------------------------------
describe('new-user — users.create', () => {
let sendMock
let services
beforeEach(() => {
sendMock = vi.fn()
services = {
MailService: class {
send(options) {
return sendMock(options)
}
},
}
})
it('renvoie input sans envoyer d\'e-mail si MailService est absent', async () => {
const db = makeDatabase(undefined)
const callback = mountHook({services: {}, env: BASE_ENV})
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const input = {email: 'user@example.com'}
const result = await callback(input, {schema: SCHEMA}, {database: db})
expect(result).toBe(input)
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('MailService'))
consoleSpy.mockRestore()
})
it('renvoie input sans envoyer d\'e-mail si EMAIL_NEW_USER est absent', async () => {
const db = makeDatabase(undefined)
const callback = mountHook({services, env: {}})
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const input = {email: 'user@example.com'}
const result = await callback(input, {schema: SCHEMA}, {database: db})
expect(result).toBe(input)
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('EMAIL_NEW_USER'))
consoleSpy.mockRestore()
})
it('renvoie input sans envoyer d\'e-mail si l\'adresse existe déjà', async () => {
const db = makeDatabase({id: 'existing-user-id'}) // utilisateur trouvé
const callback = mountHook({services, env: BASE_ENV})
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const input = {email: 'existing@example.com'}
const result = await callback(input, {schema: SCHEMA}, {database: db})
expect(result).toBe(input)
expect(sendMock).not.toHaveBeenCalled()
consoleSpy.mockRestore()
})
it('envoie un e-mail à l\'admin pour un nouvel utilisateur valide', async () => {
const db = makeDatabase(undefined) // aucun utilisateur existant
const callback = mountHook({services, env: BASE_ENV})
const input = {email: 'new@example.com', first_name: 'Alice'}
const result = await callback(input, {schema: SCHEMA}, {database: db})
expect(result).toBe(input)
expect(sendMock).toHaveBeenCalledOnce()
const callArgs = sendMock.mock.calls[0][0]
expect(callArgs.to).toBe('admin@example.com')
expect(callArgs.subject).toContain('new@example.com')
})
it('utilise l\'URL Directus par défaut si DIRECTUS_URL est absent', async () => {
const db = makeDatabase(undefined)
const envWithoutUrl = {EMAIL_NEW_USER: 'admin@example.com'}
const callback = mountHook({services, env: envWithoutUrl})
const input = {email: 'new@example.com'}
await callback(input, {schema: SCHEMA}, {database: db})
const callArgs = sendMock.mock.calls[0][0]
expect(callArgs.html).toContain('http://0.0.0.0:8055')
})
it('renvoie input si l\'envoi de mail échoue (non bloquant)', async () => {
const db = makeDatabase(undefined)
const brokenServices = {
MailService: class {
send() {
throw new Error('SMTP error')
}
},
}
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const callback = mountHook({services: brokenServices, env: BASE_ENV})
const input = {email: 'new@example.com'}
const result = await callback(input, {schema: SCHEMA}, {database: db})
expect(result).toBe(input)
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('Erreur'),
expect.any(Error),
)
consoleSpy.mockRestore()
})
})
@@ -0,0 +1,55 @@
export default ({filter}, {services, env}) => {
const checkEmailExists = async (email, database) => {
const user = await database('directus_users')
.select('id')
.where({email})
.first()
return user !== undefined
}
filter('users.create', async (input, {schema}, {database}) => {
if (!services.MailService) {
console.error('Le service MailService est manquant.')
return input
}
const adminEmail = env.EMAIL_NEW_USER
const directusURL = env.DIRECTUS_URL || 'http://0.0.0.0:8055'
if (!adminEmail) {
console.error('La variable EMAIL_NEW_USER est manquante.')
return input
}
const emailExists = await checkEmailExists(input.email, database)
if (emailExists) {
console.error('Ladresse e-mail est déjà utilisée.')
return input
}
try {
const mailService = new services.MailService({schema})
mailService.send({
to: adminEmail,
subject: `Nouvel utilisateur : ${input.email}`,
text: `Un nouvel utilisateur a été créé :\n Nom: ${input.first_name || 'N/A'} Email: ${input.email || 'N/A'}\n Pour valider => ${directusURL}/admin/users`,
html: `
<p>Un nouvel utilisateur a été créé :</p>
<ul>
<li><strong>Nom:</strong> ${input.first_name || 'N/A'} </li>
<li><strong>Email:</strong> ${input.email || 'N/A'}</li>
</ul>
<p>Pour valider, <a href="${directusURL}/admin/users" target="_blank"><strong>cliquez ici</strong></a> ou sur ce lien => <a href="${directusURL}/admin/users" target="_blank">${directusURL}/admin/users</a></p>
`,
})
console.log('Email envoyé avec succès à', adminEmail)
} catch (err) {
console.error("Erreur lors de lenvoi de le-mail via MailService:", err)
}
return input
})
}
+1113 -276
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -1,9 +1,11 @@
{ {
"name": "api.konstitisyon.la", "name": "api.konstitisyon.nu",
"version": "1.0.0", "version": "1.0.0",
"main": "index.js", "main": "index.js",
"type": "module",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1" "test": "vitest run",
"test:watch": "vitest"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
@@ -12,5 +14,8 @@
"dependencies": { "dependencies": {
"directus": "^11.2.1", "directus": "^11.2.1",
"sqlite3": "^5.1.7" "sqlite3": "^5.1.7"
},
"devDependencies": {
"vitest": "^4.1.4"
} }
} }
+9
View File
@@ -0,0 +1,9 @@
import {defineConfig} from 'vitest/config'
export default defineConfig({
test: {
globals: false,
environment: 'node',
include: ['extensions/*/src/__tests__/**/*.test.js'],
},
})