isSMTP(); $mail->Host = $MAILCFG['smtp']['host']; $mail->Port = (int)$MAILCFG['smtp']['port']; $mail->SMTPSecure = $MAILCFG['smtp']['secure'] ?? PHPMailer::ENCRYPTION_STARTTLS; $mail->SMTPAuth = (bool)$MAILCFG['smtp']['auth']; $mail->Username = $MAILCFG['smtp']['username'] ?? ''; $mail->Password = $MAILCFG['smtp']['password'] ?? ''; // 🔒 robustesse $mail->Timeout = 8; // seconds (SMTP) $mail->SMTPKeepAlive = false; $mail->SMTPAutoTLS = true; $mail->SMTPDebug = 0; $mail->CharSet = 'UTF-8'; $mail->Encoding = 'base64'; $mail->XMailer = ''; if (!empty($MAILCFG['bounce'])) $mail->Sender = $MAILCFG['bounce']; $from = $MAILCFG['from'] ?? ['contact@uslugi.fr', 'USLUGI.FR']; $rpto = $MAILCFG['reply_to'] ?? ['contact@uslugi.fr', 'USLUGI.FR']; $mail->setFrom($from[0], $from[1] ?? ''); if (!empty($rpto[0])) $mail->addReplyTo($rpto[0], $rpto[1] ?? ''); $mail->addAddress($toEmail, $toName); if (!empty($MAILCFG['suppress_auto'])) { $mail->addCustomHeader('Auto-Submitted', 'auto-generated'); $mail->addCustomHeader('X-Auto-Response-Suppress', 'All'); } $mail->isHTML(true); $mail->Subject = $subject; $mail->Body = $htmlBody; $mail->AltBody = $textBody; // 📎 Invitation iCalendar $mail->addStringAttachment( $ical, $filename, 'base64', 'text/calendar; method=REQUEST; charset=UTF-8' ); $mail->send(); return true; } catch (Throwable $e) { error_log('PHPMailer RDV send exception: '.$e->getMessage()); if (!empty($mail->ErrorInfo)) { error_log('PHPMailer RDV ErrorInfo: '.$mail->ErrorInfo); } return false; } } /* ================== Valeurs par défaut Online/Offline ================== */ if (!isset($c_online)) { $c_online = 'online'; } if (!isset($c_offline)) { $c_offline = 'offline'; } /* ================== CSRF pour l'appel AJAX ================== */ if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(16)); } $CSRF = $_SESSION['csrf_token']; /* ================== Helpers UI (inchangés) ================== */ function ui_head(string $title): void { ?> <?= htmlspecialchars($title) ?>
'; $brand_bas ='УСЛУГИ.ФР'; return ' USLUGI.FR
'. (!empty($data['cta_url']) ? '' : ''). '
'.$brand.'
'.nl2br(htmlspecialchars($data['hello_line'])).'
'.nl2br(htmlspecialchars($data['intro'])).'
'. (!empty($data['format_line']) ? '' : ''). (!empty($data['address_line']) ? '' : ''). '
'.htmlspecialchars($t['email_avec'] ?? 'Avec').''.htmlspecialchars($data['who']).'
'.htmlspecialchars($t['date_label'] ?? 'Date').''.htmlspecialchars($data['when']).'
'.htmlspecialchars($t['format_label'] ?? 'Формат').''.htmlspecialchars($data['format_line']).'
'.htmlspecialchars($t['adresse_label_ru'] ?? 'Адрес').''.htmlspecialchars($data['address_line']).'
'.htmlspecialchars($data['cta_label']).'
'.nl2br(htmlspecialchars($data['footer'])).'
© '.date('Y').' '.$brand_bas.' — '.htmlspecialchars($t['merci_simple'] ?? 'Merci').'
'; } function build_email_text(array $data, array $t): string { $lines = []; $lines[] = $data['hello_line']; $lines[] = ''; $lines[] = $data['intro']; $lines[] = ''; $lines[] = ($t['email_avec'] ?? 'Avec').' '.$data['who']; $lines[] = ($t['date_label'] ?? 'Date').' '.$data['when']; if (!empty($data['format_line'])) $lines[] = ($t['format_label'] ?? 'Формат').': '.$data['format_line']; if (!empty($data['address_line'])) $lines[] = ($t['adresse_label_ru'] ?? 'Адрес').': '.$data['address_line']; if (!empty($data['cta_url'])) { $lines[] = ''; $lines[] = $data['cta_label'].': '.$data['cta_url']; } $lines[] = ''; $lines[] = $data['footer']; return implode("\n", $lines); } /* =================== Helpers ICS (inchangés) =================== */ function ics_escape(string $s): string { $s = str_replace("\\", "\\\\", $s); $s = str_replace(";", "\;", $s); $s = str_replace(",", "\,", $s); $s = preg_replace("/\r\n|\r|\n/", "\\n", $s); return $s; } function build_ics_common_lines( string $uid, DateTime $start, DateTime $end, string $timezone, string $summary, string $description, string $location, array $attendees ): string { $lines = []; $lines[] = "BEGIN:VCALENDAR"; $lines[] = "VERSION:2.0"; $lines[] = "PRODID:-//УСЛУГИ.ФР//RDV//FR"; $lines[] = "CALSCALE:GREGORIAN"; $lines[] = "METHOD:REQUEST"; $lines[] = "BEGIN:VEVENT"; $lines[] = "UID:{$uid}"; $lines[] = "DTSTAMP:" . gmdate('Ymd\THis\Z'); $lines[] = "DTSTART;TZID={$timezone}:" . $start->format('Ymd\THis'); $lines[] = "DTEND;TZID={$timezone}:" . $end->format('Ymd\THis'); $lines[] = "SUMMARY:" . ics_escape($summary); $lines[] = "DESCRIPTION:" . ics_escape($description); $lines[] = "LOCATION:" . ics_escape($location); $lines[] = "ORGANIZER;CN=УСЛУГИ.ФР:mailto:rdv@uslugi.fr"; foreach ($attendees as $a) { if (empty($a['email'])) continue; $cn = ics_escape($a['cn'] ?? ''); $em = $a['email']; $lines[] = "ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;CN={$cn};RSVP=TRUE:mailto:{$em}"; } $lines[] = "STATUS:CONFIRMED"; $lines[] = "END:VEVENT"; $lines[] = "END:VCALENDAR"; return implode("\r\n", $lines) . "\r\n"; } function build_ics_client(array $rdv, DateTime $start, DateTime $end, string $timezone, string $location, string $ctaUrl, array $t, string $uid): string { $summary = ($t['rdv_avec'] ?? 'Rendez-vous avec').' '.($t['titre_dr'] ?? 'Dr').' '.$rdv['prenom_praticien'].' '.$rdv['nom_praticien']; $desc = ($t['votre_rdv_attente'] ?? 'Votre rendez-vous est prévu')."\n\n". ($t['email_avec'] ?? 'Avec').' '.($t['titre_dr'] ?? 'Dr').' '.$rdv['prenom_praticien'].' '.$rdv['nom_praticien']."\n". 'Email: '.$rdv['email_praticien']."\n". (($rdv['tel_praticien'] ?? '') ? ('Téléphone: '.$rdv['tel_praticien']."\n") : ''). ($ctaUrl ? ("\n".($t['consultez_espace_pour_details'] ?? 'Consultez votre espace pour les détails')."\n".$ctaUrl) : ''); $attendees = [ ['cn' => $rdv['prenom_praticien'].' '.$rdv['nom_praticien'], 'email' => $rdv['email_praticien']], ['cn' => $rdv['prenom_client'].' '.$rdv['nom_client'], 'email' => $rdv['email_client']], ]; return build_ics_common_lines($uid, $start, $end, $timezone, $summary, $desc, $location, $attendees); } function build_ics_praticien(array $rdv, DateTime $start, DateTime $end, string $timezone, string $location, string $ctaUrl, array $t, string $uid): string { $summary = ($t['rdv_avec'] ?? 'Rendez-vous avec').' '.$rdv['prenom_client'].' '.$rdv['nom_client']; $desc = ($t['nouveau_rdv_pris_avec'] ?? 'Nouveau rendez-vous pris avec')."\n\n". ($t['client_label'] ?? 'Client').': '.$rdv['prenom_client'].' '.$rdv['nom_client']."\n". 'Email: '.$rdv['email_client']."\n". (($rdv['tel_client'] ?? '') ? ('Téléphone: '.$rdv['tel_client']."\n") : ''). ($ctaUrl ? ("\n".($t['consultez_espace_pour_details'] ?? 'Consultez votre espace pour les détails')."\n".$ctaUrl) : ''); $attendees = [ ['cn' => $rdv['prenom_client'].' '.$rdv['nom_client'], 'email' => $rdv['email_client']], ['cn' => $rdv['prenom_praticien'].' '.$rdv['nom_praticien'], 'email' => $rdv['email_praticien']], ]; return build_ics_common_lines($uid, $start, $end, $timezone, $summary, $desc, $location, $attendees); } /* ================== Mode POST (AJAX) ================== */ if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST' && ($_POST['action'] ?? '') === 'validate') { header('Content-Type: application/json; charset=utf-8'); // CSRF if (!hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf'] ?? '')) { echo json_encode(['ok'=>false, 'html'=>'

Сессия истекла

Обновите страницу и попробуйте ещё раз.

']); exit; } $client_id = $_SESSION['reference_id'] ?? null; $praticien_id = isset($_POST['praticien_id']) ? (int)$_POST['praticien_id'] : null; $startIso = $_POST['start'] ?? null; $mode_rdv_in = $_POST['mode_rdv'] ?? ($_GET['mode_rdv'] ?? null); $mode_rdv = ($mode_rdv_in === $GLOBALS['c_offline']) ? $GLOBALS['c_offline'] : $GLOBALS['c_online']; $planningUrl = '/public/client/reserver_rdv.php?praticien_id='.(int)($praticien_id ?? 0); if (!$praticien_id || !$startIso || !$client_id) { $html = '

'.htmlspecialchars($translations['donnees_manquantes_reservation'] ?? 'Données manquantes pour la réservation').'

'.htmlspecialchars($translations['retour_fiche_praticien_hint'] ?? 'Merci de revenir sur la fiche du praticien et de choisir un créneau.').'

'.htmlspecialchars($translations['retour_a_la_fiche'] ?? 'Retour à la fiche').'
'; echo json_encode(['ok'=>false, 'html'=>$html]); exit; } try { $date_rdv_sql = (new DateTime($startIso))->format('Y-m-d H:i:s'); // Créneau libre ? (libre si seulement "annulé") $stmt = $pdo->prepare(" SELECT COUNT(*) FROM rendez_vous WHERE praticien_id = ? AND date_rdv = ? AND statut <> 'annulé' "); $stmt->execute([$praticien_id, $date_rdv_sql]); if ((int)$stmt->fetchColumn() > 0) { $html = '

'.htmlspecialchars($translations['creneau_deja_reserve'] ?? 'Créneau déjà réservé').'

'.htmlspecialchars($translations['choisir_autre_creneau'] ?? 'Veuillez choisir un autre créneau.').'

'.htmlspecialchars($translations['retour_au_planning'] ?? 'Retour au planning').'
'; echo json_encode(['ok'=>false, 'html'=>$html]); exit; } // Récupérer durée + adresse praticien $stmt = $pdo->prepare("SELECT duree_rdv, adresse, ville, code_postal FROM praticien WHERE id = ?"); $stmt->execute([$praticien_id]); $p = $stmt->fetch(PDO::FETCH_ASSOC); $duree = (int)($p['duree_rdv'] ?? 60); $adresse = trim((string)($p['adresse'] ?? '')); $ville = trim((string)($p['ville'] ?? '')); $code_postal = trim((string)($p['code_postal'] ?? '')); $has_address = ($adresse !== '' && $ville !== '' && $code_postal !== ''); if ($mode_rdv === $c_offline && !$has_address) $mode_rdv = $c_online; $addrSnapshot = $has_address ? "{$adresse}, {$code_postal} {$ville}" : ''; // Insert RDV $uuid = bin2hex(random_bytes(16)); $lieu_insert = ($mode_rdv === $c_offline) ? 'offline' : 'online'; $stmt = $pdo->prepare(" INSERT INTO rendez_vous (praticien_id, client_id, date_rdv, duree_minutes, lieu, statut, commentaire, uuid_ical, created_at) VALUES (?, ?, ?, ?, ?, 'prévu', '', ?, NOW()) "); $stmt->execute([$praticien_id, $client_id, $date_rdv_sql, $duree, $lieu_insert, $uuid]); // Infos pour e-mails & ICS $stmt = $pdo->prepare(" SELECT rv.id AS rdv_id, rv.date_rdv, rv.duree_minutes, rv.lieu, p.nom AS nom_praticien, p.prenom AS prenom_praticien, p.email AS email_praticien, p.telephone AS tel_praticien, p.adresse AS p_adresse, p.ville AS p_ville, p.code_postal AS p_cp, c.email AS email_client, c.nom AS nom_client, c.prenom AS prenom_client, c.telephone AS tel_client FROM rendez_vous rv JOIN praticien p ON rv.praticien_id = p.id JOIN client c ON rv.client_id = c.id WHERE rv.uuid_ical = ? "); $stmt->execute([$uuid]); $rdv = $stmt->fetch(PDO::FETCH_ASSOC); if (!$rdv) { throw new RuntimeException('Rendez-vous introuvable après insertion.'); } $rdvId = (int)$rdv['rdv_id']; // ========================= // LOG RDV (rdv_actions_log) : create // ========================= $after = json_encode([ 'statut' => 'prévu', 'date_rdv' => $rdv['date_rdv'], // datetime SQL 'duree_minutes'=> (int)$rdv['duree_minutes'], 'lieu' => $rdv['lieu'], // online/offline (chez toi) 'praticien_id' => (int)$praticien_id, 'client_id' => (int)$client_id, ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $logStmt = $pdo->prepare(" INSERT INTO rdv_actions_log (rdv_id, action, by_user_id, by_role, before_json, after_json, created_at) VALUES (?, 'create', ?, 'client', NULL, ?, NOW()) "); $logStmt->execute([$rdvId, (int)$client_id, $after]); // ========================= // ========================= // PUSH RDV — création (client) // ========================= try { // ✅ Récupérer compte_id du praticien (pour compteur badge) $st = $pdo->prepare("SELECT id FROM compte WHERE role='praticien' AND reference_id=? LIMIT 1"); $st->execute([(int)$praticien_id]); $praticienCompteId = (int)($st->fetchColumn() ?: 0); // ✅ Récupérer compte_id du client (utile plus tard pour d'autres events, pas obligatoire ici) $st = $pdo->prepare("SELECT id FROM compte WHERE role='client' AND reference_id=? LIMIT 1"); $st->execute([(int)$client_id]); $clientCompteId = (int)($st->fetchColumn() ?: 0); // ✅ Incrémenter le compteur RDV (badge) pour le praticien if ($praticienCompteId > 0) { $pdo->prepare(" INSERT INTO push_badge_counters (compte_id, msg_unread, rdv_unread) VALUES (:cid, 0, 1) ON DUPLICATE KEY UPDATE rdv_unread = LEAST(99, rdv_unread + 1), updated_at = NOW() ")->execute([':cid' => $praticienCompteId]); } // 🔔 Praticien : nouvelle demande push_rdv_to_praticien( $pdo, (int)$praticien_id, $translations['push_rdv_new_title'] ?? 'Новая запись', $translations['push_rdv_new_body'] ?? 'У вас новая запись на приём.', '/public/praticien/rendez_vous.php?id='.(int)$rdvId, [ 'rdv_id' => (int)$rdvId, 'action' => 'create', 'from_role' => 'client', 'client_id' => (int)$client_id, 'praticien_id' => (int)$praticien_id, 'date_rdv' => $rdv['date_rdv'], ] ); // 🔔 Client : confirmation push_rdv_to_client( $pdo, (int)$client_id, $translations['push_rdv_created_title'] ?? 'Запись отправлена', $translations['push_rdv_created_body'] ?? 'Ожидает подтверждения специалиста.', '/public/client/mes_rendez_vous.php', [ 'rdv_id' => (int)$rdvId, 'action' => 'create' ] ); } catch (Throwable $e) { error_log('push rdv create failed: '.$e->getMessage()); } $tz = 'Europe/Paris'; $startDT = new DateTime($rdv['date_rdv'], new DateTimeZone($tz)); $endDT = (clone $startDT)->modify("+{$duree} minutes"); $when = $startDT->format('d/m/Y H:i'); $when_a = $startDT->format('d/m/Y') . ' ' . ($translations['a'] ?? 'в') . ' ' . $startDT->format('H:i'); $ru_online = $translations['option_online'] ?? 'Онлайн'; $ru_offline = $translations['option_cabinet'] ?? 'В кабинете'; $is_offline = ($mode_rdv === $c_offline); $mode_label_ru = $is_offline ? $ru_offline : $ru_online; $location = $is_offline && $has_address ? $addrSnapshot : $ru_online; $cta_client_url = 'https://uslugi.fr/public/client/mes_rendez_vous.php'; $cta_prat_url = "https://uslugi.fr/public/praticien/rendez_vous.php?id={$rdvId}"; // ======= (1) RÉPONSE IMMÉDIATE AU CLIENT (évite le 503) ======= $html = '

'.($translations['block_1'] ?? 'Спасибо за запись').'

'.htmlspecialchars($when_a).'
'; // ⚠️ libérer la session, envoyer la réponse, puis continuer en arrière-plan session_write_close(); echo json_encode(['ok'=>true, 'html'=>$html]); if (function_exists('fastcgi_finish_request')) { fastcgi_finish_request(); // le client & Varnish ont reçu la réponse => pas de 503 } ignore_user_abort(true); // ======= (2) TÂCHES ARRIÈRE-PLAN : e-mails + WhatsApp ======= try { // ICS $ical_client = build_ics_client($rdv, $startDT, $endDT, $tz, $location, $cta_client_url, $translations, $uuid); $ical_prat = build_ics_praticien($rdv, $startDT, $endDT, $tz, $location, $cta_prat_url, $translations, $uuid); $whoClientSees = ($translations['titre_dr'] ?? 'Dr').' '.$rdv['prenom_praticien'].' '.$rdv['nom_praticien']; $whoPratSees = ($translations['client_label'] ?? 'Client').' '.$rdv['prenom_client'].' '.$rdv['nom_client']; $cta_label = $translations['consultez_espace_pour_details'] ?? 'Voir les détails'; // Client $subject_c = $translations['email_confirmation_rdv_sujet'] ?? 'Confirmation de rendez-vous'; $data_c = [ 'hello_line' => sprintf($translations['mail_valid_bonjour_nom'] ?? 'Bonjour %s', $rdv['prenom_client'].' '.$rdv['nom_client']), 'intro' => ($translations['votre_rdv_attente'] ?? 'Votre rendez-vous est en attente de confirmation').' '. ($translations['email_avec'] ?? 'avec').' '.$whoClientSees.".\n". ($translations['veuillez_trouver_invitation'] ?? "Veuillez trouver l'invitation en pièce jointe."), 'who' => $whoClientSees, 'when' => $when_a, 'format_line' => $mode_label_ru, 'address_line' => ($is_offline && $has_address) ? $addrSnapshot : '', 'cta_url' => $cta_client_url, 'cta_label' => $cta_label, 'footer' => $translations['merci_simple'] ?? 'Merci.', ]; $ok_c = send_rdv_email_with_ics_phpmailer( $rdv['email_client'], $rdv['prenom_client'].' '.$rdv['nom_client'], $subject_c, build_email_text($data_c, $translations), build_email_html($data_c, $translations), $ical_client, "rdv_client_{$uuid}.ics" ); // Praticien $subject_p = ($translations['nouveau_rdv_avec'] ?? 'Nouveau rendez-vous avec').' '.$rdv['prenom_client'].' '.$rdv['nom_client']; $data_p = [ 'hello_line' => ($translations['email_bonjour_dr'] ?? 'Bonjour Dr').' '.$rdv['prenom_praticien'].' '.$rdv['nom_praticien'], 'intro' => ($translations['nouveau_rdv_pris_avec'] ?? 'Un nouveau rendez-vous a été pris avec vous.'), 'who' => $whoPratSees, 'when' => $when_a, 'format_line' => $mode_label_ru, 'address_line' => ($is_offline && $has_address) ? $addrSnapshot : '', 'cta_url' => $cta_prat_url, 'cta_label' => $cta_label, 'footer' => $translations['merci_simple'] ?? 'Merci.', ]; $ok_p = send_rdv_email_with_ics_phpmailer( $rdv['email_praticien'], $rdv['prenom_praticien'].' '.$rdv['nom_praticien'], $subject_p, build_email_text($data_p, $translations), build_email_html($data_p, $translations), $ical_prat, "rdv_praticien_{$uuid}.ics" ); if (!$ok_c || !$ok_p) { error_log('RDV: un ou plusieurs e-mails ICS n’ont pas été envoyés.'); } } catch (Throwable $e) { error_log('RDV: background email error: '.$e->getMessage()); } // WhatsApp (ne doit JAMAIS bloquer) try { $waTo = $rdv['tel_praticien'] ?? ''; $clientFullName = trim(($rdv['prenom_client'] ?? '') . ' ' . ($rdv['nom_client'] ?? '')); $dateHeure = $when; if ($waTo && $clientFullName && $dateHeure && $rdvId) { // Si votre lib permet un timeout, utilisez-le. Sinon, laissez tel quel. $waRes = Sms\notify_rdv_en_attente($waTo, $clientFullName, $dateHeure, $rdvId /*, ['timeout'=>5] */); if ((int)($waRes['http_code'] ?? 0) !== 200) { error_log('WA en_attente non-200: ' . json_encode($waRes, JSON_UNESCAPED_UNICODE)); } } else { error_log('WA en_attente: données incomplètes (tel praticien / nom client / dateHeure / rdvId).'); } } catch (Throwable $e) { error_log('WA en_attente_confirmation exception: ' . $e->getMessage()); } // Fin du background exit; } catch (Throwable $e) { $html = '

'.htmlspecialchars($translations['erreur_prefixe'] ?? 'Erreur').'

'.htmlspecialchars($e->getMessage()).'

'.htmlspecialchars($translations['retour_a_la_fiche'] ?? 'Retour à la fiche').'
'; echo json_encode(['ok'=>false, 'html'=>$html]); exit; } } /* ================== Mode GET : UI + loader + JS ================== */ $praticien_id = isset($_GET['praticien_id']) ? (int)$_GET['praticien_id'] : null; $startIso = $_GET['start'] ?? null; $modeGet = $_GET['mode_rdv'] ?? null; ui_head($translations['validation_rdv_title'] ?? 'Validation du rendez-vous'); ?>