Intégration webhook

This commit is contained in:
Jean-Philippe Staelen 2025-12-16 16:01:10 +01:00
parent f350b5ff10
commit 67d6f66f59
6 changed files with 508 additions and 0 deletions

View File

@ -84,6 +84,9 @@ spl_autoload_register(function ($class) {
case 'controllers\\PEPPOL_peppol_controller': case 'controllers\\PEPPOL_peppol_controller':
$file = $base_dir . 'controllers/Peppol_controller.php'; $file = $base_dir . 'controllers/Peppol_controller.php';
break; break;
case 'controllers\\PEPPOL_Webhook_controller':
$file = $base_dir . 'controllers/Webhook_controller.php';
break;
case 'models\\PEPPOL_Main_model': case 'models\\PEPPOL_Main_model':
$file = $base_dir . 'models/Main_model.php'; $file = $base_dir . 'models/Main_model.php';
break; break;
@ -163,4 +166,10 @@ add_action(
$plugin->init(); $plugin->init();
}, },
20 20
);
// Enregistrement des routes REST API pour les webhooks
add_action(
'rest_api_init',
[\ESI_PEPPOL\controllers\PEPPOL_Webhook_controller::class, 'register_routes']
); );

View File

@ -0,0 +1,306 @@
<?php
declare(strict_types=1);
namespace ESI_PEPPOL\controllers;
use ESI_PEPPOL\models\PEPPOL_Main_model;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
/**
* Contrôleur pour gérer la réception des webhooks depuis l'API ESI Peppol.
*
* Les webhooks permettent de recevoir des notifications automatiques
* lorsque le statut d'un document change.
*/
class PEPPOL_Webhook_controller {
/**
* Enregistre les routes REST API pour les webhooks.
*
* @return void
*/
public static function register_routes(): void {
register_rest_route(
'esi-peppol/v1',
'/webhook',
[
'methods' => 'POST',
'callback' => [self::class, 'handle_webhook'],
'permission_callback' => [self::class, 'permission_check'],
'args' => [
'event' => [
'required' => true,
'type' => 'string',
'validate_callback' => [self::class, 'validate_event'],
],
],
]
);
}
/**
* Vérifie les permissions pour accéder à l'endpoint webhook.
*
* Les webhooks sont authentifiés via les headers personnalisés
* X-ESIPeppol-Event, X-ESIPeppol-Document-ID, X-ESIPeppol-Company-ID.
*
* @param WP_REST_Request $request Requête REST.
* @return bool|WP_Error
*/
public static function permission_check(WP_REST_Request $request) {
// Vérifier la présence des headers requis
$event_header = $request->get_header('X-ESIPeppol-Event');
$document_id_header = $request->get_header('X-ESIPeppol-Document-ID');
$company_id_header = $request->get_header('X-ESIPeppol-Company-ID');
if (empty($event_header) || empty($document_id_header) || empty($company_id_header)) {
return new WP_Error(
'missing_headers',
__('Headers webhook manquants.', 'esi_peppol'),
['status' => 400]
);
}
// Optionnel : vérifier que le Company-ID correspond à celui configuré
// Pour l'instant, on accepte tous les webhooks avec les headers requis
return true;
}
/**
* Valide le type d'événement webhook.
*
* @param string $value Valeur à valider.
* @param WP_REST_Request $request Requête REST.
* @param string $param Nom du paramètre.
* @return bool|WP_Error
*/
public static function validate_event($value, WP_REST_Request $request, string $param) {
$allowed_events = ['document.sent', 'document.error', 'document.completed'];
if (!in_array($value, $allowed_events, true)) {
return new WP_Error(
'invalid_event',
sprintf(
/* translators: %s: liste des événements autorisés */
__('Type d\'événement invalide. Événements autorisés : %s', 'esi_peppol'),
implode(', ', $allowed_events)
),
['status' => 400]
);
}
return true;
}
/**
* Traite la réception d'un webhook.
*
* @param WP_REST_Request $request Requête REST contenant les données du webhook.
* @return WP_REST_Response|WP_Error
*/
public static function handle_webhook(WP_REST_Request $request) {
// Récupérer les headers
$event = $request->get_header('X-ESIPeppol-Event');
$document_id = $request->get_header('X-ESIPeppol-Document-ID');
$company_id = $request->get_header('X-ESIPeppol-Company-ID');
// Récupérer le body JSON
$body = $request->get_json_params();
if (empty($body)) {
return new WP_Error(
'invalid_body',
__('Corps de la requête invalide ou vide.', 'esi_peppol'),
['status' => 400]
);
}
// Valider la structure du payload
if (!isset($body['event']) || !isset($body['document'])) {
return new WP_Error(
'invalid_payload',
__('Structure du payload invalide. Champs requis : event, document.', 'esi_peppol'),
['status' => 400]
);
}
// Vérifier la cohérence entre le header et le body
if ($body['event'] !== $event) {
return new WP_Error(
'event_mismatch',
__('Le type d\'événement dans le header ne correspond pas à celui du body.', 'esi_peppol'),
['status' => 400]
);
}
// Extraire les données du document
$document_data = $body['document'];
// Le document_id peut être dans document.document_id (UUID) ou document.id (entier)
// On privilégie document_id (UUID) car c'est celui stocké dans notre base
$document_id_from_body = '';
if (isset($document_data['document_id']) && !empty($document_data['document_id'])) {
$document_id_from_body = (string) $document_data['document_id'];
} elseif (isset($document_data['id'])) {
// Fallback sur l'ID numérique si document_id n'est pas disponible
$document_id_from_body = (string) $document_data['id'];
}
// Utiliser le document_id du body si disponible, sinon celui du header
$final_document_id = !empty($document_id_from_body) ? $document_id_from_body : $document_id;
// Log de réception du webhook
if (class_exists(PEPPOL_Plugin::class)) {
PEPPOL_Plugin::write_debug_file(
[
'event' => 'webhook_received',
'event_type' => $event,
'document_id' => $final_document_id,
'company_id' => $company_id,
'payload' => $body,
],
'INFO'
);
}
// Traiter selon le type d'événement
$result = self::process_webhook_event($event, $final_document_id, $body);
if (is_wp_error($result)) {
// Log de l'erreur
if (class_exists(PEPPOL_Plugin::class)) {
PEPPOL_Plugin::write_debug_file(
[
'event' => 'webhook_error',
'event_type' => $event,
'document_id' => $final_document_id,
'error' => $result->get_error_message(),
'error_code' => $result->get_error_code(),
],
'ERROR'
);
}
return $result;
}
// Réponse de succès (code 200-299 requis par la documentation)
return new WP_REST_Response(
[
'success' => true,
'message' => __('Webhook traité avec succès.', 'esi_peppol'),
],
200
);
}
/**
* Traite un événement webhook spécifique.
*
* @param string $event Type d'événement (document.sent, document.error, document.completed).
* @param string $document_id ID du document.
* @param array $payload Données complètes du webhook.
* @return bool|WP_Error
*/
private static function process_webhook_event(string $event, string $document_id, array $payload): bool|WP_Error {
$document_data = $payload['document'] ?? [];
// Déterminer le nouveau statut selon l'événement
$new_status = match ($event) {
'document.sent' => 'sent',
'document.error' => 'error',
'document.completed' => 'completed',
default => null,
};
if ($new_status === null) {
return new WP_Error(
'unknown_event',
sprintf(
/* translators: %s: type d'événement */
__('Type d\'événement inconnu : %s', 'esi_peppol'),
$event
),
['status' => 400]
);
}
// Extraire le peppol_document_id si disponible
$peppol_document_id = isset($document_data['peppol_document_id'])
? (string) $document_data['peppol_document_id']
: '';
// Extraire le message d'erreur si présent
$message = '';
if ($event === 'document.error' && isset($payload['error']['message'])) {
$message = (string) $payload['error']['message'];
}
// Mettre à jour le document dans la base de données
$updated = PEPPOL_Main_model::update_by_document_id(
$document_id,
$new_status,
$peppol_document_id,
$message,
$payload
);
if (!$updated) {
// Document non trouvé - ce n'est pas forcément une erreur critique
// (le document pourrait avoir été créé manuellement dans l'interface ESI Peppol)
if (class_exists(PEPPOL_Plugin::class)) {
PEPPOL_Plugin::write_debug_file(
[
'event' => 'webhook_document_not_found',
'document_id' => $document_id,
'status' => $new_status,
'message' => __('Document non trouvé dans la base de données WordPress. Il a peut-être été créé manuellement dans l\'interface ESI Peppol.', 'esi_peppol'),
],
'WARNING'
);
}
// On retourne quand même true car le webhook a été reçu et traité
// (même si le document n'existe pas dans notre base)
return true;
}
// Log de succès
if (class_exists(PEPPOL_Plugin::class)) {
PEPPOL_Plugin::write_debug_file(
[
'event' => 'webhook_processed',
'event_type' => $event,
'document_id' => $document_id,
'new_status' => $new_status,
'peppol_document_id' => $peppol_document_id,
],
'INFO'
);
}
return true;
}
/**
* Retourne l'URL complète de l'endpoint webhook.
*
* Cette URL doit être configurée dans l'interface d'administration Filament
* de l'API ESI Peppol.
*
* @return string URL du webhook.
*/
public static function get_webhook_url(): string {
$rest_url = rest_url('esi-peppol/v1/webhook');
/**
* Filtre pour modifier l'URL du webhook.
*
* @param string $rest_url URL REST par défaut.
*/
return (string) apply_filters('esi_peppol_webhook_url', $rest_url);
}
}

View File

@ -283,4 +283,106 @@ class PEPPOL_Main_model {
return $row; return $row;
} }
/**
* Récupère un enregistrement PEPPOL par document_id (UUID retourné par l'API ESI Peppol).
*
* @param string $document_id Document ID (UUID).
* @return object|null
*/
public static function get_by_document_id(string $document_id): ?object {
global $wpdb;
$table_name = self::get_table_name();
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$table_name} WHERE document_id = %s LIMIT 1",
$document_id
)
);
if (!$row) {
return null;
}
// Désérialiser la réponse API et le payload si nécessaire
if (isset($row->response_data)) {
$row->response_data = \maybe_unserialize($row->response_data);
}
if (isset($row->data_sent)) {
$row->data_sent = \maybe_unserialize($row->data_sent);
}
return $row;
}
/**
* Met à jour le statut d'un document par son document_id (UUID).
*
* Utilisé lors de la réception des webhooks pour mettre à jour
* le statut d'un document sans connaître l'ID de commande.
*
* @param string $document_id Document ID (UUID).
* @param string $status Nouveau statut (sent, error, completed).
* @param string $peppol_document_id Peppol document ID (optionnel).
* @param string $message Message d'information / d'erreur (optionnel).
* @param array|null $webhook_payload Payload complet du webhook pour stockage (optionnel).
*
* @return bool True si la mise à jour a réussi, false sinon.
*/
public static function update_by_document_id(
string $document_id,
string $status,
string $peppol_document_id = '',
string $message = '',
?array $webhook_payload = null
): bool {
global $wpdb;
$table_name = self::get_table_name();
$now = \current_time('mysql');
// Déterminer le success selon le statut
$success = in_array($status, ['sent', 'completed'], true) ? 1 : 0;
// Préparer les données de mise à jour
$update_data = [
'status' => $status,
'success' => $success,
'date_update' => $now,
];
$format = ['%s', '%d', '%s'];
// Mettre à jour peppol_document_id si fourni
if ($peppol_document_id !== '') {
$update_data['peppol_document_id'] = $peppol_document_id;
$format[] = '%s';
}
// Mettre à jour le message si fourni
if ($message !== '') {
$update_data['message'] = $message;
$format[] = '%s';
}
// Mettre à jour response_data avec le payload du webhook si fourni
if ($webhook_payload !== null) {
$serialized_payload = \maybe_serialize($webhook_payload);
$update_data['response_data'] = $serialized_payload;
$format[] = '%s';
}
// Effectuer la mise à jour
$updated = $wpdb->update(
$table_name,
$update_data,
['document_id' => $document_id],
$format,
['%s']
);
return $updated !== false && $updated > 0;
}
} }

View File

@ -35,6 +35,22 @@
flex: 1 1 auto; flex: 1 1 auto;
} }
.esi-peppol-webhook-url-wrapper {
display: flex;
align-items: center;
gap: 8px;
}
.esi-peppol-webhook-url-wrapper .regular-text {
flex: 1 1 auto;
background-color: #f6f7f7;
cursor: text;
}
.esi-peppol-webhook-url-wrapper .regular-text:focus {
background-color: #ffffff;
}
#esi-peppol-test-result { #esi-peppol-test-result {
margin-top: 15px; margin-top: 15px;
} }

View File

@ -457,6 +457,44 @@
}); });
}); });
} }
// Bouton pour copier l'URL du webhook
var $copyWebhookBtn = $('.esi-peppol-copy-webhook-url');
if ($copyWebhookBtn.length) {
$copyWebhookBtn.on('click', function (e) {
e.preventDefault();
var $target = $($(this).data('target'));
if ($target.length) {
$target.select();
try {
document.execCommand('copy');
var $btn = $(this);
var originalText = $btn.text();
$btn.text('✓ Copié').prop('disabled', true);
setTimeout(function () {
$btn.text(originalText).prop('disabled', false);
}, 2000);
} catch (err) {
// Fallback pour les navigateurs qui ne supportent pas execCommand
console.error('Erreur lors de la copie:', err);
}
}
});
}
// Toggle afficher/masquer mot de passe
var $passwordToggle = $('.esi-peppol-password-toggle');
if ($passwordToggle.length) {
$passwordToggle.on('click', function (e) {
e.preventDefault();
var $target = $($(this).data('target'));
if ($target.length) {
var type = $target.attr('type') === 'password' ? 'text' : 'password';
$target.attr('type', type);
$(this).text(type === 'password' ? 'Afficher' : 'Masquer');
}
});
}
}); });
})(jQuery); })(jQuery);

View File

@ -120,6 +120,43 @@ if ($logo_email_id) {
</td> </td>
</tr> </tr>
<tr>
<th scope="row">
<label><?php esc_html_e('URL Webhook', 'esi_peppol'); ?></label>
</th>
<td>
<?php
$webhook_url = \ESI_PEPPOL\controllers\PEPPOL_Webhook_controller::get_webhook_url();
?>
<div class="esi-peppol-webhook-url-wrapper">
<input type="text"
class="regular-text"
id="esi_peppol_webhook_url"
value="<?php echo esc_attr($webhook_url); ?>"
readonly
onclick="this.select();"
/>
<button type="button"
class="button button-small esi-peppol-copy-webhook-url"
data-target="#esi_peppol_webhook_url"
aria-label="<?php esc_attr_e('Copier l\'URL du webhook', 'esi_peppol'); ?>">
<?php esc_html_e('Copier', 'esi_peppol'); ?>
</button>
</div>
<p class="description">
<?php
printf(
wp_kses_post(
/* translators: %s: URL de la documentation */
__('Configurez cette URL dans votre profil d\'entreprise via l\'interface d\'administration Filament de l\'API ESI Peppol. Les webhooks permettent de recevoir des notifications automatiques lorsque le statut d\'un document change. <a href="%s" target="_blank" rel="noopener noreferrer">En savoir plus</a>.', 'esi_peppol')
),
esc_url('https://demo.esi-peppol.be/api-demo.html#webhooks')
);
?>
</p>
</td>
</tr>
<!-- <tr> <!-- <tr>
<th scope="row"> <th scope="row">
<label for="esi_peppol_logo_email"><?php esc_html_e('Logo Email', 'esi_peppol'); ?></label> <label for="esi_peppol_logo_email"><?php esc_html_e('Logo Email', 'esi_peppol'); ?></label>