diff --git a/ESI_peppol.php b/ESI_peppol.php index e03edaf..3961fa5 100644 --- a/ESI_peppol.php +++ b/ESI_peppol.php @@ -84,6 +84,9 @@ spl_autoload_register(function ($class) { case 'controllers\\PEPPOL_peppol_controller': $file = $base_dir . 'controllers/Peppol_controller.php'; break; + case 'controllers\\PEPPOL_Webhook_controller': + $file = $base_dir . 'controllers/Webhook_controller.php'; + break; case 'models\\PEPPOL_Main_model': $file = $base_dir . 'models/Main_model.php'; break; @@ -163,4 +166,10 @@ add_action( $plugin->init(); }, 20 +); + +// Enregistrement des routes REST API pour les webhooks +add_action( + 'rest_api_init', + [\ESI_PEPPOL\controllers\PEPPOL_Webhook_controller::class, 'register_routes'] ); \ No newline at end of file diff --git a/app/controllers/Webhook_controller.php b/app/controllers/Webhook_controller.php new file mode 100644 index 0000000..b310e6c --- /dev/null +++ b/app/controllers/Webhook_controller.php @@ -0,0 +1,306 @@ + '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); + } +} diff --git a/app/models/Main_model.php b/app/models/Main_model.php index 7b3814a..791b5e7 100644 --- a/app/models/Main_model.php +++ b/app/models/Main_model.php @@ -283,4 +283,106 @@ class PEPPOL_Main_model { 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; + } } \ No newline at end of file diff --git a/assets/css/admin.css b/assets/css/admin.css index 9440396..562bff1 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -35,6 +35,22 @@ 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 { margin-top: 15px; } diff --git a/assets/js/admin.js b/assets/js/admin.js index 2292868..b895fc5 100644 --- a/assets/js/admin.js +++ b/assets/js/admin.js @@ -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); diff --git a/templates/admin/settings.php b/templates/admin/settings.php index 27bc010..7f844be 100644 --- a/templates/admin/settings.php +++ b/templates/admin/settings.php @@ -120,6 +120,43 @@ if ($logo_email_id) { + + + + + + +
+ + +
+

+ En savoir plus.', 'esi_peppol') + ), + esc_url('https://demo.esi-peppol.be/api-demo.html#webhooks') + ); + ?> +

+ + +