prefix . 'crvi_sms_log'; $charset_collate = $wpdb->get_charset_collate(); $sql = "CREATE TABLE {$table_name} ( id int(11) NOT NULL AUTO_INCREMENT, numsms varchar(255) NOT NULL, msgsms text NOT NULL, status varchar(50) NOT NULL, date_sms datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, sujet varchar(50) NOT NULL, id_event int(11) NOT NULL, full_log longtext NOT NULL, PRIMARY KEY (id) ) {$charset_collate};"; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta($sql); } /** * AJAX: Envoi d'un SMS de debug via admin-ajax.php * action: crvi_send_sms_debug * champs attendus: * - nonce: string * - phone: string (optionnel, défaut 0485500723) * - message: string (si présent => envoi direct via send_sms) * - OU champs de datas pour send_sms_rdv_cloture (langue, type, date, time, name, gender, intervenant) * - id_event: int (optionnel pour log) * - sujet: string (optionnel pour log) */ public static function ajax_send_sms_debug() { // Vérifier que c'est une requête AJAX valide if (!defined('DOING_AJAX') || !DOING_AJAX) { wp_send_json_error(['message' => 'Requête invalide'], 400); return; } // Vérif nonce $nonce = isset($_REQUEST['nonce']) ? sanitize_text_field(wp_unslash($_REQUEST['nonce'])) : ''; if (empty($nonce) || !wp_verify_nonce($nonce, 'crvi_sms')) { wp_send_json_error(['message' => 'Nonce invalide'], 403); return; } // Vérif permissions (réservé aux utilisateurs pouvant éditer) if (!is_user_logged_in() || !current_user_can('edit_posts')) { wp_send_json_error(['message' => 'Non autorisé'], 403); return; } $phone = isset($_REQUEST['phone']) ? sanitize_text_field(wp_unslash($_REQUEST['phone'])) : '0485500723'; $message = isset($_REQUEST['message']) ? sanitize_text_field(wp_unslash($_REQUEST['message'])) : null; $id_event = isset($_REQUEST['id_event']) ? (int) $_REQUEST['id_event'] : 0; $sujet = isset($_REQUEST['sujet']) ? sanitize_text_field(wp_unslash($_REQUEST['sujet'])) : 'debug'; try { if ($message) { $result = self::sms_send($phone, $message, ['sujet' => $sujet, 'id_event' => $id_event]); if ($result === true) { wp_send_json_success(['sent' => true, 'via' => 'send_sms']); } else { // $result contient maintenant des détails sur l'erreur $error_details = is_array($result) ? $result : ['message' => 'Échec d\'envoi (send_sms)', 'reason' => 'unknown']; wp_send_json_error($error_details); } } // Construction des datas pour le template de clôture par défaut $datas = [ 'phone' => $phone, 'langue' => isset($_REQUEST['langue']) ? sanitize_text_field(wp_unslash($_REQUEST['langue'])) : 'fr', 'type' => isset($_REQUEST['type']) ? sanitize_text_field(wp_unslash($_REQUEST['type'])) : '', 'date' => isset($_REQUEST['date']) ? sanitize_text_field(wp_unslash($_REQUEST['date'])) : '', 'time' => isset($_REQUEST['time']) ? sanitize_text_field(wp_unslash($_REQUEST['time'])) : '', 'name' => isset($_REQUEST['name']) ? sanitize_text_field(wp_unslash($_REQUEST['name'])) : '', 'gender' => isset($_REQUEST['gender']) ? sanitize_text_field(wp_unslash($_REQUEST['gender'])) : '', 'intervenant' => isset($_REQUEST['intervenant']) ? sanitize_text_field(wp_unslash($_REQUEST['intervenant'])) : '', ]; $result = self::send_sms_rdv_cloture($datas); if ($result === true) { wp_send_json_success(['sent' => true, 'via' => 'send_sms_rdv_cloture', 'datas' => $datas]); } else { // $result contient maintenant des détails sur l'erreur $error_details = is_array($result) ? $result : ['message' => 'Échec d\'envoi (send_sms_rdv_cloture)', 'reason' => 'unknown', 'datas' => $datas]; wp_send_json_error($error_details); } } catch (\Throwable $e) { wp_send_json_error(['message' => 'Exception: ' . $e->getMessage()], 500); } } public static function send_rdv_cloture_automatique($datas) { $template_path = plugin_dir_path(__FILE__) . '../../templates/email/rdv-cloture-email-template.php'; // Vérifier que le template existe if (!file_exists($template_path)) { error_log('[CRVI_AGENDA] Template email introuvable : ' . $template_path); return false; } // Exécuter le template PHP avec output buffering pour capturer le HTML généré ob_start(); // Passer les données au template (la variable $datas sera disponible dans le template) include $template_path; $body = ob_get_clean(); $subject = 'Rendez-vous clôturés'; $to = 'test@test.com'; $headers = ['Content-Type: text/html; charset=UTF-8']; $result = wp_mail($to, $subject, $body, $headers); if (!$result) { error_log('[CRVI_AGENDA] Échec d\'envoi de l\'email de clôture automatique'); } return $result; } /** * SMS de clôture de rendez-vous * Datas attendues : phone, name, gender, date, time, type, langue, intervenant */ public static function send_sms_rdv_cloture($datas) { $langue = $datas['langue'] ?? 'fr'; $type = $datas['type'] ?? ''; $date = $datas['date'] ?? ''; $time = $datas['time'] ?? ''; $name = $datas['name'] ?? ''; $gender = $datas['gender'] ?? ''; $phone = $datas['phone'] ?? ''; $intervenant = $datas['intervenant'] ?? ''; // 1. Récupérer le texte du SMS depuis les options ACF, // en fonction de la langue du rendez-vous. $sms_text = ''; // Priorité au répéteur "texte_sms" (texte + langue) $texte_sms_rows = get_field('texte_sms', 'option'); if (!empty($texte_sms_rows) && is_array($texte_sms_rows)) { foreach ($texte_sms_rows as $row) { $row_langue_id = $row['langue'] ?? null; if ($row_langue_id) { $term = get_term($row_langue_id); if ($term && !is_wp_error($term) && $term->slug === $langue) { $sms_text = $row['texte'] ?? ''; break; } } } } // Fallback : texte_fr simple si aucun texte n'a été trouvé if (!$sms_text) { $sms_text = get_field('texte_fr', 'option'); } // Fallback final: modèle par défaut si rien en ACF if (!$sms_text) { $sms_text = "Bonjour %titre% %nom%,\n\nVous avez rendez-vous le %date% à %heure%,\n\nLe crvi"; } if (!$sms_text) { // Rien de configuré : on ne tente pas d'envoyer $error_msg = '[CRVI_AGENDA] Aucun texte SMS configuré pour la langue "' . $langue . '".'; error_log($error_msg); return ['message' => 'Échec d\'envoi (send_sms_rdv_cloture)', 'reason' => 'no_sms_text', 'details' => 'Aucun texte SMS configuré pour la langue "' . $langue . '"']; } // 2. Déterminer le titre selon le genre (h/f/i) et la langue $titres = [ 'fr' => ['h' => 'Mr', 'f' => 'Mme', 'i' => ''], 'ar' => ['h' => 'السيد', 'f' => 'السيدة', 'i' => ''], ]; $titre = $titres[$langue][$gender] ?? ($titres['fr'][$gender] ?? ''); // 3. Remplacer les placeholders dans le texte $replacements = [ '%titre%' => $titre, '%nom%' => $name, '%date%' => $date, '%heure%' => $time, '%intervenant%' => $intervenant, ]; $sms_text_final = strtr($sms_text, $replacements); if (empty($phone)) { $error_msg = '[CRVI_AGENDA] Numéro de téléphone vide, SMS non envoyé.'; error_log($error_msg); // Log en base le skip pour traçabilité self::log_sms([ 'numsms' => '', 'msgsms' => $sms_text_final, 'status' => 'skipped_no_phone', 'sujet' => isset($datas['sujet']) ? (string) $datas['sujet'] : 'rdv_cloture', 'id_event' => isset($datas['id_event']) ? (int) $datas['id_event'] : 0, 'full_log' => 'No phone' ]); return ['message' => 'Échec d\'envoi (send_sms_rdv_cloture)', 'reason' => 'empty_phone', 'details' => 'Numéro de téléphone vide']; } // 4. Envoi via OVH en utilisant login/mot de passe définis dans les options ACF $options = []; if (isset($datas['sujet'])) { $options['sujet'] = (string) $datas['sujet']; } if (isset($datas['id_event'])) { $options['id_event'] = (int) $datas['id_event']; } $result = self::sms_send($phone, $sms_text_final, $options); // Si sms_send retourne true, retourner true, sinon retourner les détails de l'erreur return ($result === true) ? true : $result; } /** * Envoi générique d'un SMS via OVH HTTP2SMS * Utilise les options ACF "option_sms" (login, mot_de_passe, quota_sms). */ public static function send_sms($tel, $message, $options = []) { // Compatibilité ancien appel: si $options n'est pas un array, ignorer if (!is_array($options)) { $options = []; } $sujet = isset($options['sujet']) ? (string) $options['sujet'] : ''; $id_event = isset($options['id_event']) ? (int) $options['id_event'] : 0; // Options SMS stockées dans ACF > Options > Option > Option sms $option_sms = get_field('option_sms', 'option'); $login = $option_sms['login'] ?? ''; $password = $option_sms['mot_de_passe'] ?? ''; // $quota_sms = isset($option_sms['quota_sms']) ? (int) $option_sms['quota_sms'] : 0; // non utilisé ici, mais disponible // smsAccount peut maintenant être configuré dans ACF (champ texte "compte_ovh" en options). // Si le champ est vide ou non défini, on conserve le fallback sur la constante OVH_SMS_ACCOUNT. $smsAccount_acf = ''; if (function_exists('get_field')) { // Champ texte simple en options : compte_ovh $smsAccount_acf = (string) get_field('compte_ovh', 'option'); // Si rien n'est défini à la racine, on tente éventuellement dans le groupe option_sms if (!$smsAccount_acf && is_array($option_sms) && !empty($option_sms['compte_ovh'])) { $smsAccount_acf = (string) $option_sms['compte_ovh']; } } if (empty($login) || empty($password)) { $error_msg = '[CRVI_AGENDA] SMS non envoyé : login ou mot de passe OVH non configuré dans ACF.'; error_log($error_msg); return ['message' => 'Échec d\'envoi (send_sms)', 'reason' => 'missing_credentials', 'details' => 'Login ou mot de passe OVH non configuré dans ACF']; } // Normalisation du numéro (reprise de l'algorithme existant) $tel = str_replace(['+', ' ', '-', '/'], '', $tel); if (substr($tel, 0, 2) === '00') { $to_sms = $tel; // pas de changement } elseif (substr($tel, 0, 2) === '32' || substr($tel, 0, 2) === '33') { $to_sms = '00' . $tel; } else { $to_sms = '0032' . substr($tel, 1); } $text_sms = urlencode(utf8_decode($message)); // Récupérer smsAccount et from depuis ACF / config.php, avec valeurs par défaut // Priorité au champ texte ACF "compte_ovh" (options), puis fallback sur la constante. $smsAccount = $smsAccount_acf ?: (\defined('OVH_SMS_ACCOUNT') ? \constant('OVH_SMS_ACCOUNT') : 'sms-xxxxxxx'); $from = \defined('OVH_SMS_FROM') ? \constant('OVH_SMS_FROM') : '0032496390437'; $url = "https://www.ovh.com/cgi-bin/sms/http2sms.cgi?smsAccount=" . rawurlencode($smsAccount) . "&login=" . rawurlencode($login) . "&password=" . rawurlencode($password) . "&from=" . rawurlencode($from) . "&to=" . rawurlencode($to_sms) . "&contentType=text/xml" . "&message={$text_sms}"; $ret = @file($url); $raw_response = ($ret !== false && isset($ret[0])) ? $ret[0] : ''; $xml = $raw_response ? @simplexml_load_string($raw_response) : false; $success = false; $status_str = 'failed'; $error_details = []; if ($xml && isset($xml->status)) { $status_code = (int) $xml->status; $success = ($status_code === 100); $status_str = $success ? 'success' : ('failed(' . $status_code . ')'); if (!$success) { $error_details = [ 'message' => 'Échec d\'envoi (send_sms)', 'reason' => 'ovh_api_error', 'status_code' => $status_code, 'status_message' => (string) ($xml->message ?? 'Unknown error'), 'raw_response' => $raw_response ]; error_log('[CRVI_AGENDA] Réponse OVH HTTP2SMS: statut ' . $status_code . ' - ' . (string) ($xml->message ?? 'Unknown error')); } } else { $error_details = [ 'message' => 'Échec d\'envoi (send_sms)', 'reason' => 'invalid_response', 'details' => 'Réponse OVH HTTP2SMS invalide ou vide', 'raw_response' => $raw_response ]; error_log('[CRVI_AGENDA] Réponse OVH HTTP2SMS invalide ou vide. Raw response: ' . $raw_response); } // Mettre à jour le quota ACF si l'envoi a réussi, en se basant sur la réponse API (creditLeft) if ($success && $xml && isset($xml->creditLeft) && function_exists('get_field') && function_exists('update_field')) { $creditLeftVal = (int) $xml->creditLeft; $option_sms = get_field('option_sms', 'option'); if (is_array($option_sms)) { $option_sms['quota_sms'] = $creditLeftVal; // Mettre à jour le groupe complet pour rester compatible ACF update_field('option_sms', $option_sms, 'option'); } } // Log en base self::log_sms([ 'numsms' => $to_sms, 'msgsms' => $message, 'status' => $status_str, 'sujet' => $sujet, 'id_event' => $id_event, 'full_log' => $raw_response ]); // Retourner true si succès, sinon retourner les détails de l'erreur if ($success) { return true; } else { return $error_details; } } /** * Alias demandé: utiliser sms_send comme point d'entrée standard. * Garde la même signature et délègue à send_sms. */ public static function sms_send($tel, $message, $options = []) { // Prise en charge d'un appel "cron" avec un array de données d'événement // Exemple: sms_send(['phone'=>'...', 'name'=>'...', 'date'=>'...', 'time'=>'...', 'langue'=>'fr', 'sujet'=>'...', 'id_event'=>123], null); if (is_array($tel)) { $datas = $tel; return self::send_sms_rdv_cloture($datas); } return self::send_sms($tel, $message, $options); } /** * Insère une ligne de log dans la table wp_crvi_sms_log */ private static function log_sms(array $fields) { global $wpdb; $table = $wpdb->prefix . 'crvi_sms_log'; $data = [ 'numsms' => isset($fields['numsms']) ? (string) $fields['numsms'] : '', 'msgsms' => isset($fields['msgsms']) ? (string) $fields['msgsms'] : '', 'status' => isset($fields['status']) ? (string) $fields['status'] : '', 'sujet' => isset($fields['sujet']) ? (string) $fields['sujet'] : '', 'id_event' => isset($fields['id_event']) ? (int) $fields['id_event'] : 0, 'full_log' => isset($fields['full_log']) ? (string) $fields['full_log'] : '', ]; // date_sms a une valeur par défaut CURRENT_TIMESTAMP côté DB // Sécuriser l'insertion via $wpdb->insert (prépare sous le capot) $wpdb->insert($table, $data, ['%s','%s','%s','%s','%d','%s']); } /** * Envoie des SMS de rappel pour les événements individuels dans X jours * * @param int $days Nombre de jours avant l'événement (1, 2 ou 3) * @return array Rapport détaillé avec statistiques d'envoi */ public static function send_sms_reminder($days = 3) { global $wpdb; // Valider le paramètre days if (!in_array($days, [1, 2, 3])) { return [ 'error' => 'Le paramètre days doit être 1, 2 ou 3', 'received' => $days ]; } $table_name = $wpdb->prefix . 'crvi_agenda'; // Calculer la date cible (aujourd'hui + X jours) $target_date = date('Y-m-d', strtotime("+{$days} days")); // Récupérer les événements individuels à la date cible avec statut 'prevu' $sql = $wpdb->prepare( "SELECT * FROM $table_name WHERE date_rdv = %s AND type = 'individuel' AND statut = 'prevu' AND is_deleted = 0 AND id_beneficiaire IS NOT NULL ORDER BY heure_rdv ASC", $target_date ); $events = $wpdb->get_results($sql, ARRAY_A); if (empty($events)) { return [ 'success' => true, 'count' => 0, 'date' => $target_date, 'days' => $days, 'message' => "Aucun événement individuel trouvé pour dans {$days} jour(s) ({$target_date})" ]; } $results = [ 'success' => true, 'date' => $target_date, 'days' => $days, 'total_events' => count($events), 'sms_sent' => 0, 'sms_failed' => 0, 'sms_skipped' => 0, 'details' => [] ]; // Traiter chaque événement foreach ($events as $event) { $event_result = [ 'event_id' => (int) $event['id'], 'date' => $event['date_rdv'], 'heure' => $event['heure_rdv'], 'status' => 'skipped', 'reason' => '' ]; // Charger le bénéficiaire if (empty($event['id_beneficiaire'])) { $event_result['reason'] = 'Pas de bénéficiaire associé'; $results['sms_skipped']++; $results['details'][] = $event_result; continue; } $beneficiaire = \ESI_CRVI_AGENDA\models\CRVI_Beneficiaire_Model::load($event['id_beneficiaire']); if (!$beneficiaire) { $event_result['reason'] = 'Bénéficiaire introuvable'; $results['sms_skipped']++; $results['details'][] = $event_result; continue; } // Vérifier que le bénéficiaire a un téléphone $telephone = $beneficiaire->telephone ?? ''; if (empty($telephone)) { $event_result['reason'] = 'Pas de numéro de téléphone pour le bénéficiaire'; $results['sms_skipped']++; $results['details'][] = $event_result; continue; } // Récupérer le genre du bénéficiaire depuis ACF $genre = ''; if (function_exists('get_field')) { $genre = get_field('genre', $beneficiaire->id) ?? ''; } // Récupérer la langue de l'événement $langue = $event['langue'] ?? 'fr'; // Si la langue est un ID de taxonomy, récupérer le slug if (is_numeric($langue) && function_exists('get_term')) { $langue_term = get_term((int) $langue, 'langue'); if ($langue_term && !is_wp_error($langue_term)) { $langue = $langue_term->slug ?? 'fr'; } } // Récupérer le nom de l'intervenant $intervenant_nom = ''; if (!empty($event['id_intervenant'])) { $intervenant = \ESI_CRVI_AGENDA\models\CRVI_Intervenant_Model::load($event['id_intervenant'], ['id', 'nom', 'prenom']); if ($intervenant) { $intervenant_nom = trim(($intervenant->nom ?? '') . ' ' . ($intervenant->prenom ?? '')); } } // Formater la date et l'heure $date_formatted = date('d/m/Y', strtotime($event['date_rdv'])); $heure_formatted = date('H:i', strtotime($event['heure_rdv'])); // Préparer les données pour l'envoi de SMS $sms_data = [ 'phone' => $telephone, 'name' => trim(($beneficiaire->nom ?? '') . ' ' . ($beneficiaire->prenom ?? '')), 'gender' => $genre, // h, f, ou i 'date' => $date_formatted, 'time' => $heure_formatted, 'type' => $event['type'], 'langue' => $langue, 'intervenant' => $intervenant_nom, 'sujet' => 'rappel_rdv', 'id_event' => (int) $event['id'] ]; // Envoyer le SMS $sms_result = self::send_sms_rdv_cloture($sms_data); if ($sms_result === true) { $event_result['status'] = 'sent'; $event_result['beneficiaire'] = $sms_data['name']; $event_result['telephone'] = $telephone; $results['sms_sent']++; } else { $event_result['status'] = 'failed'; $event_result['reason'] = is_array($sms_result) ? ($sms_result['message'] ?? 'Erreur inconnue') : 'Erreur lors de l\'envoi'; $event_result['beneficiaire'] = $sms_data['name']; $event_result['telephone'] = $telephone; $results['sms_failed']++; } $results['details'][] = $event_result; } return $results; } }