npm package: @fickou/adonis-workflow
Description: Generic workflow engine for AdonisJS 5 + Lucid ORM
Author: fickou
Latest version: 1.0.5

# @fickou/adonis-workflow

Moteur de workflow générique pour **AdonisJS 5** + Lucid ORM.

S'attache à n'importe quelle entité métier (`invoice`, `purchase_order`, `leave_request`…) sans importer de modèles métier — uniquement via `entity_type` (string) + `entity_id` (string).

---

## Table des matières

1. [Installation](#1-installation)
2. [Migrations](#2-migrations)
3. [Enregistrement du provider](#3-enregistrement-du-provider)
4. [Configuration](#4-configuration)
5. [Implémenter l'EntityResolver](#5-implémenter-lentityresolver)
6. [Utiliser le WorkflowEngine](#6-utiliser-le-workflowengine)
7. [ActionHooks — logique métier transactionnelle](#7-actionhooks--logique-métier-transactionnelle)
8. [Écouter les événements](#8-écouter-les-événements)
9. [RBAC — rôles et permissions](#9-rbac--rôles-et-permissions)
10. [Délégations](#10-délégations)
11. [RuleEngineService — conditions dynamiques](#11-ruleengineservice--conditions-dynamiques)
12. [WebSocket temps réel](#12-websocket-temps-réel)
13. [Commandes Ace](#13-commandes-ace)
14. [Référence des routes API](#14-référence-des-routes-api)
15. [Codes d'erreur](#15-codes-derreur)

---

## 1. Installation

```bash
npm install @fickou/adonis-workflow
```

Vérifier que les peer dependencies sont présentes dans votre projet hôte :

```json
"@adonisjs/core": "^5.8.0",
"@adonisjs/lucid": "^18.0.0",
"luxon": "^3.0.0"
```

---

## 2. Migrations

Le package embarque ses propres migrations. Ajoutez leur chemin dans `config/database.ts` — **pas besoin de les copier** :

```typescript
// config/database.ts
import { join } from 'path'
import Application from '@ioc:Adonis/Core/Application'

const databaseConfig: DatabaseConfig = {
  connection: 'sqlite',
  connections: {
    sqlite: {
      client: 'sqlite3',
      connection: {
        filename: Application.tmpPath('db.sqlite3'),
      },
      useNullAsDefault: true,
      migrations: {
        paths: [
          Application.makePath('database/migrations'),
          join(__dirname, '../node_modules/@fickou/adonis-workflow/build/migrations'),
        ],
      },
    },
  },
}
```

Lancer les migrations :

```bash
node ace migration:run
```

> Les migrations sont numérotées `001` → `011`. Tables créées : `workflow_definitions`, `workflow_steps`, `workflow_transitions`, `workflow_step_validators`, `workflow_step_actions`, `workflow_step_conditions`, `workflow_instances`, `workflow_instance_steps`, `workflow_action_logs`, `workflow_delegations`, `workflow_definition_snapshots`.

---

## 3. Enregistrement du provider

Dans `.adonisrc.json` :

```json
{
  "providers": [
    "@fickou/adonis-workflow/build/providers/WorkflowProvider"
  ],
  "commands": [
    "@fickou/adonis-workflow/build/commands"
  ],
  "preloads": [
    "./start/workflow"
  ]
}
```

---

## 4. Configuration

Créer `config/workflow.ts` :

```typescript
// config/workflow.ts
import { WorkflowProviderConfig } from '@fickou/adonis-workflow/build/providers/WorkflowProvider'

const workflowConfig: WorkflowProviderConfig = {
  // Préfixe des routes (défaut : '/workflow')
  routePrefix: '/workflow',

  // Résolveur de rôle RBAC — retourner le rôle workflow de l'utilisateur
  // Rôles valides : 'workflow_admin' | 'workflow_manager' | 'workflow_user'
  roleResolver: async (userId: string) => {
    const { default: User } = await import('App/Models/User')
    const user = await User.find(userId)
    if (user?.isAdmin) return 'workflow_admin'
    if (user?.isManager) return 'workflow_manager'
    return 'workflow_user'
  },
}

export default workflowConfig
```

---

## 5. Implémenter l'EntityResolver

L'`EntityResolver` permet au moteur d'accéder aux données de vos entités métier sans les importer directement.

Créer `start/workflow.ts` et le déclarer dans `preloads` (voir section 3) :

```typescript
// start/workflow.ts
import Application from '@ioc:Adonis/Core/Application'

const registry = Application.container.use('Workflow/EntityResolverRegistry')

// Resolver pour les factures
registry.register('invoice', {
  async resolve(id: string) {
    // Import dynamique obligatoire : évite les dépendances circulaires
    const { default: Invoice } = await import('App/Models/Invoice')
    const invoice = await Invoice.find(id)
    return invoice?.serialize() ?? null
  },
  async exists(id: string) {
    const { default: Invoice } = await import('App/Models/Invoice')
    return !!(await Invoice.find(id))
  },
})

// Ajouter autant de resolvers que d'entity_type dans votre projet
// registry.register('purchase_order', { ... })
// registry.register('leave_request', { ... })
```

> Si un `entity_type` n'est pas enregistré, `WorkflowEngine` lève `WorkflowException('ENTITY_NOT_FOUND')`.

---

## 6. Utiliser le WorkflowEngine

Récupérer le moteur depuis le container IoC dans vos controllers :

```typescript
// app/Controllers/Http/InvoiceController.ts
import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
import Application from '@ioc:Adonis/Core/Application'

export default class InvoiceController {
  public async submit({ request, response }: HttpContextContract) {
    const engine = Application.container.use('Workflow/WorkflowEngine')
    const userId = request.header('X-User-Id')!

    // Démarrer un workflow — signature : (entityType, entityId, initiatorId)
    const instance = await engine.startWorkflow('invoice', params.id, userId)

    return response.created({ instance })
  }

  public async action({ params, request, response }: HttpContextContract) {
    const engine = Application.container.use('Workflow/WorkflowEngine')
    const userId = request.header('X-User-Id')!

    // Exécuter une action — signature : (instanceId, action, actorId, payload)
    await engine.executeAction(
      params.instanceId,
      request.input('action'),   // 'approve' | 'reject_cancel' | ...
      userId,
      {
        version: request.input('version'),  // verrouillage optimiste — OBLIGATOIRE
        reason: request.input('reason'),
      }
    )

    return response.ok({ message: 'Action exécutée.' })
  }
}
```

### Actions disponibles

| Action | Description | `reason` obligatoire |
|---|---|---|
| `approve` | Valide l'étape courante | Non |
| `reject_previous` | Renvoie à l'étape précédente | Non |
| `reject_start` | Renvoie au début du workflow | Non |
| `reject_cancel` | Annule définitivement le workflow | Oui |
| `request_correction` | Renvoie au créateur pour correction | Oui |
| `transfer` | Transfère l'étape à un autre validateur (`delegate_id` requis) | Oui |
| `suspend` | Met l'instance en pause | Oui |

---

## 7. ActionHooks — logique métier transactionnelle

Les ActionHooks s'exécutent **à l'intérieur de la transaction DB** d'une action. Un retour `{ allowed: false }` déclenche un rollback complet.

```typescript
// start/workflow.ts (suite)
import Application from '@ioc:Adonis/Core/Application'

const hookRegistry = Application.container.use('Workflow/ActionHookRegistry')

// Hook exécuté avant chaque 'approve' sur les purchase_order
hookRegistry.register('purchase_order', 'approve', {
  async execute(context) {
    const { entityData, trx } = context

    // TOUTES les opérations DB dans un hook DOIVENT utiliser `trx`
    const { default: Budget } = await import('App/Models/Budget')
    const budget = await Budget.query({ client: trx })
      .where('department_id', entityData.department_id as string)
      .firstOrFail()

    if (budget.available < (entityData.amount as number)) {
      return {
        allowed: false,
        reason: `Budget insuffisant. Disponible : ${budget.available} €`,
      }
    }

    budget.available -= entityData.amount as number
    await budget.useTransaction(trx).save()

    return { allowed: true }
  },
})
```

> Les hooks sont **optionnels**. Si aucun hook n'est enregistré pour `(entityType, action)`, l'action passe directement.

---

## 8. Écouter les événements

Le moteur n'envoie jamais de notifications directement. Le projet hôte écoute les événements préfixés `workflow:` dans `start/events.ts` :

```typescript
// start/events.ts
import Event from '@ioc:Adonis/Core/Event'
import { WORKFLOW_EVENTS } from '@fickou/adonis-workflow/build/events/WorkflowEvents'

// Workflow démarré
Event.on(WORKFLOW_EVENTS.STARTED, ({ instance, initiatorId }) => {
  // Notifier le créateur...
})

// Action exécutée (approve, reject, etc.)
Event.on(WORKFLOW_EVENTS.ACTION_EXECUTED, ({ instance, action, actorId }) => {
  // Logger, notifier les parties concernées...
})

// Workflow terminé
Event.on(WORKFLOW_EVENTS.COMPLETED, ({ instance }) => {
  // Notifier le créateur de l'approbation finale...
})

// Délai dépassé (émis par workflow:check-deadlines)
Event.on(WORKFLOW_EVENTS.DEADLINE_EXCEEDED, ({ instanceId, stepId, exceededSince }) => {
  // Envoyer une relance au validateur...
})

// Hook bloquant (action refusée par la logique métier)
Event.on(WORKFLOW_EVENTS.ACTION_HOOK_REJECTED, ({ instanceId, action, reason }) => {
  // Notifier l'acteur via WebSocket...
})
```

### Tableau complet des événements

| Constante | Chaîne émise | Déclencheur |
|---|---|---|
| `STARTED` | `workflow:started` | `startWorkflow()` |
| `COMPLETED` | `workflow:completed` | Dernière étape approuvée |
| `REJECTED` | `workflow:rejected` | `reject_cancel` |
| `ACTION_EXECUTED` | `workflow:action:executed` | Toute action réussie |
| `ACTION_HOOK_REJECTED` | `workflow:action:hook_rejected` | Hook retourne `allowed: false` |
| `STEP_COMPLETED` | `workflow:step:completed` | Étape validée, transition |
| `STEP_SUSPENDED` | `workflow:step:suspended` | `suspend` |
| `STEP_TRANSFERRED` | `workflow:step:transferred` | `transfer` |
| `CORRECTION_REQUESTED` | `workflow:correction:requested` | `request_correction` |
| `CORRECTION_SUBMITTED` | `workflow:correction:submitted` | `resubmitAfterCorrection()` |
| `DELEGATION_CREATED` | `workflow:delegation:created` | Création délégation |
| `DELEGATION_REVOKED` | `workflow:delegation:revoked` | Révocation délégation |
| `DEADLINE_EXCEEDED` | `workflow:deadline:exceeded` | `handleDeadline()` via cron |
| `CONCURRENT_CONFLICT` | `workflow:concurrent:conflict` | Conflit de version optimiste |

---

## 9. RBAC — rôles et permissions

### Hiérarchie des rôles

| Rôle | Accès |
|---|---|
| `workflow_admin` | Tout : CRUD définitions, monitoring, statistiques, forcer des actions |
| `workflow_manager` | Lecture toutes instances + statistiques |
| `workflow_user` | Exécuter des actions sur ses étapes assignées |

### Vérification manuelle dans un controller

```typescript
import Application from '@ioc:Adonis/Core/Application'

const permService = Application.container.use('Workflow/WorkflowPermissionService')

// Vérifier un rôle minimum (lève WorkflowException 403 si insuffisant)
await permService.requireRole(userId, 'workflow_manager')
```

---

## 10. Délégations

Un utilisateur peut déléguer ses droits de validation pour une période donnée.

```typescript
import Application from '@ioc:Adonis/Core/Application'
import { DateTime } from 'luxon'

const delegationService = Application.container.use('Workflow/WorkflowDelegationService')

// Créer une délégation
// Signature : (delegatorId, delegateId, scope, validFrom, validUntil, type, workflowDefinitionId?, stepId?)
await delegationService.createDelegation(
  'user-123',           // délégant
  'user-456',           // délégué
  'workflow',           // 'global' | 'workflow' | 'step'
  DateTime.now(),
  DateTime.now().plus({ days: 7 }),
  'delegation',         // 'delegation' | 'substitution'
  42,                   // workflowDefinitionId (requis si scope = 'workflow' ou 'step')
)

// Révoquer
await delegationService.revokeDelegation(delegationId, revokedByUserId)
```

**Priorité de résolution :** `step` > `workflow` > `global`

---

## 11. RuleEngineService — conditions dynamiques

Les conditions sur les étapes et transitions sont définies en JSON et évaluées par le `RuleEngineService`.

### Format d'un RuleSet

```json
{
  "operator": "AND",
  "conditions": [
    { "field": "amount", "op": ">", "value": 10000 },
    { "field": "status", "op": "==", "value": "draft" }
  ]
}
```

### Opérateurs disponibles

`==`, `!=`, `<`, `>`, `<=`, `>=`, `in`, `not_in`, `between`, `contains`, `starts_with`, `is_empty`, `is_null`, `is_not_null`, `custom_query`

### Handler personnalisé (`custom_query`)

```typescript
import Application from '@ioc:Adonis/Core/Application'

const ruleEngine = Application.container.use('Workflow/RuleEngineService')

ruleEngine.registerCustomHandler('check_budget', async (context, params) => {
  const { default: Budget } = await import('App/Models/Budget')
  const budget = await Budget.findBy('department_id', context.department_id as string)
  return (budget?.available ?? 0) >= (params.minAmount as number)
})
```

Utilisation dans une condition :

```json
{ "field": "custom", "op": "custom_query", "value": { "handler": "check_budget", "minAmount": 5000 } }
```

> Un handler non trouvé lève `WorkflowException('UNKNOWN_CUSTOM_HANDLER')`.

---

## 12. WebSocket temps réel

Si `@adonisjs/websocket` est installé, le moteur pousse des notifications sur les channels `workflow:instance:{id}`.

Le `WorkflowProvider` enregistre automatiquement le channel si le binding `Adonis/Addons/Ws` est présent. WebSocket est **optionnel** — si absent, les actions fonctionnent normalement.

### Messages envoyés automatiquement

| Événement WS | Déclencheur |
|---|---|
| `action_executed` | Toute action réussie |
| `step_changed` | Transition vers une nouvelle étape |
| `status_changed` | Changement de statut de l'instance |

---

## 13. Commandes Ace

### `workflow:seed`

Crée un workflow d'exemple `invoice` (2 étapes avec condition sur le montant) pour le développement.

```bash
node ace workflow:seed
```

> À lancer **avant** `db:seed` si vos seeders applicatifs démarrent des workflows.

Séquence complète pour initialiser un environnement de dev :

```bash
node ace migration:fresh && node ace workflow:seed && node ace db:seed
```

### `workflow:check-deadlines`

Vérifie toutes les étapes actives et émet `workflow:deadline:exceeded` pour celles dépassées.

```bash
node ace workflow:check-deadlines
```

À planifier en cron toutes les 5 à 15 minutes :

```
*/10 * * * * cd /chemin/vers/projet && node ace workflow:check-deadlines >> storage/logs/deadlines.log 2>&1
```

### `workflow:migrate`

Affiche le chemin des migrations du package (à référencer dans `config/database.ts`).

```bash
node ace workflow:migrate
```

---

## 14. Référence des routes API

Toutes les routes sont montées sous le préfixe configuré (défaut : `/workflow`).

### Instances

| Méthode | Route | Description |
|---|---|---|
| `POST` | `/instances` | Démarrer un workflow — body: `{ entity_type, entity_id }` |
| `GET` | `/instances/:id` | Récupérer une instance par ID |
| `GET` | `/instances/by-entity` | Instance par entité — query: `entity_type`, `entity_id` |
| `POST` | `/instances/:id/actions` | Exécuter une action — body: `{ action, version, reason? }` |
| `POST` | `/instances/:id/resubmit` | Resoumettre après correction |
| `GET` | `/instances/:id/history` | Journal des actions |

### Définitions (workflow_admin)

| Méthode | Route | Description |
|---|---|---|
| `GET` | `/definitions` | Lister toutes les définitions |
| `POST` | `/definitions` | Créer une définition |
| `GET` | `/definitions/by-type/:entityType` | Définition active par entity_type |
| `GET` | `/definitions/:id` | Détail d'une définition |
| `PUT` | `/definitions/:id` | Mettre à jour |
| `DELETE` | `/definitions/:id` | Supprimer |
| `PATCH` | `/definitions/:id/activate` | Activer |
| `PATCH` | `/definitions/:id/deactivate` | Désactiver |
| `GET/POST/PUT/DELETE` | `/definitions/:id/steps/...` | CRUD des étapes |
| `GET/POST/PUT/DELETE` | `/definitions/:id/transitions/...` | CRUD des transitions |
| `GET/POST/DELETE` | `/definitions/:id/steps/:stepId/validators/...` | CRUD des validateurs |
| `GET/PUT` | `/definitions/:id/steps/:stepId/actions/...` | Actions d'une étape |

### Délégations

| Méthode | Route | Description |
|---|---|---|
| `GET` | `/delegations` | Mes délégations |
| `POST` | `/delegations` | Créer une délégation |
| `DELETE` | `/delegations/:id` | Révoquer |

### Statistiques (workflow_manager)

| Méthode | Route | Description |
|---|---|---|
| `GET` | `/stats/delays` | Délais moyens par étape |
| `GET` | `/stats/rejection-rates` | Taux de rejet par étape |
| `GET` | `/stats/volumes` | Volumes par période |
| `GET` | `/stats/pending` | Documents en attente |

---

## 15. Codes d'erreur

Toutes les erreurs métier sont des instances de `WorkflowException` avec un `code` string et un status HTTP.

| Code | Status | Description |
|---|---|---|
| `ENTITY_NOT_FOUND` | 404 | EntityResolver non enregistré ou entité inexistante |
| `NO_ACTIVE_DEFINITION` | 404 | Pas de définition active pour cet entity_type |
| `INSTANCE_NOT_FOUND` | 404 | Instance introuvable |
| `INSTANCE_ALREADY_ACTIVE` | 409 | Instance déjà en cours pour cette entité |
| `NO_ACTIVE_STEP` | 404 | Étape active introuvable |
| `ACTION_FORBIDDEN` | 403 | Acteur non autorisé à agir sur cette étape |
| `ACTION_DISABLED` | 422 | Action désactivée pour cette étape |
| `REASON_REQUIRED` | 422 | Raison obligatoire pour cette action |
| `CONCURRENT_MODIFICATION` | 409 | Conflit de version optimiste |
| `HOOK_REJECTED` | 422 | ActionHook a bloqué l'action |
| `HOOK_EXCEPTION` | 500 | Exception non gérée dans un hook |
| `INSUFFICIENT_ROLE` | 403 | Rôle insuffisant |
| `UNKNOWN_CUSTOM_HANDLER` | 500 | Handler `custom_query` non enregistré |
| `TRANSFER_MISSING_TARGET` | 422 | `delegate_id` manquant pour l'action `transfer` |
| `EMPTY_DEFINITION` | 422 | La définition ne contient aucune étape |

---

## Développement et Exemple

Le package contient un exemple complet d'intégration dans `examples/adonis-example`.

### 1. Installation de l'exemple

Depuis la racine du package `packages/adonis-workflow` :

```bash
npm run example:install
```

### 2. Initialisation de la base de données (SQLite)

```bash
cd examples/adonis-example

# Création des tables (métier + workflow)
node ace migration:run

# Peuplement des données (utilisateurs, définitions, factures, instances)
node ace db:seed
```

### 3. Lancer le serveur de développement

Depuis la racine du package `packages/adonis-workflow` :

```bash
npm run example:dev
```

Le serveur sera accessible sur `http://localhost:3333`.