feat: página pública de verificación de paciente por OTP (SMS)
verificar.php: - Pública, sin autenticación ni menú lateral - Diseño moderno: card glassmorphism sobre gradiente brand - Flujo en 3 pasos con indicador animado - Paso 1: ingresa cédula → busca paciente - Paso 2: inputs OTP de 6 dígitos con navegación automática y pegado desde portapapeles; cuenta regresiva + reenvío a los 60s - Paso 3: pantalla de éxito con animación api/public/send_otp.php: - Busca paciente por cédula, genera OTP aleatorio de 6 dígitos - Guarda en lab_otp_tokens con expiración 5 min - Envía SMS via WEBSms usando config de lab_config - Retorna teléfono enmascarado (3**1234**67) api/public/verify_otp.php: - Valida cedula+codigo contra lab_otp_tokens activos - Marca OTP como usado y paciente como verificado (telefono_verificado=1, verificado_at=NOW()) DB: - lab_pacientes: +telefono_verificado, +verificado_at - lab_otp_tokens: tabla nueva para almacenar OTPs temporales Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
2fc33d22b2
commit
ae8d2cb13a
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /api/public/send_otp.php
|
||||
* Público — no requiere autenticación.
|
||||
* Body JSON: { cedula: "12345678" }
|
||||
* Busca el paciente, genera OTP de 6 dígitos y lo envía por SMS.
|
||||
*/
|
||||
ob_start();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
error_reporting(0);
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
|
||||
function jpubOk(array $data = []): void {
|
||||
ob_clean();
|
||||
echo json_encode(['ok' => true] + $data);
|
||||
exit;
|
||||
}
|
||||
function jpubErr(string $msg, int $code = 400): void {
|
||||
ob_clean();
|
||||
http_response_code($code);
|
||||
echo json_encode(['ok' => false, 'error' => $msg]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jpubErr('Método no permitido', 405);
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
$datos = json_decode($raw, true);
|
||||
$cedula = trim($datos['cedula'] ?? '');
|
||||
|
||||
if (!$cedula || !preg_match('/^\d{4,12}$/', $cedula)) jpubErr('Número de documento inválido.');
|
||||
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
$pac = $db->fetch(
|
||||
"SELECT id, nombre_completo, telefono FROM lab_pacientes WHERE numero_documento = ? AND is_active = 1 LIMIT 1",
|
||||
[$cedula]
|
||||
);
|
||||
} catch (\Throwable $e) { jpubErr('Error de base de datos.', 500); }
|
||||
|
||||
if (!$pac) jpubErr('Paciente no encontrado con ese documento.');
|
||||
if (!$pac['telefono']) jpubErr('El paciente no tiene teléfono registrado. Acércate a recepción.');
|
||||
|
||||
// Leer config SMS
|
||||
try {
|
||||
$cfgs = $db->fetchAll("SELECT clave, valor FROM lab_config WHERE clave IN ('sms_url','sms_api_key','sms_activo')");
|
||||
$cfg = array_column($cfgs, 'valor', 'clave');
|
||||
} catch (\Throwable $e) { jpubErr('Error de configuración.', 500); }
|
||||
|
||||
if (($cfg['sms_activo'] ?? '0') !== '1') jpubErr('El servicio de SMS no está activo.');
|
||||
$smsUrl = rtrim($cfg['sms_url'] ?? '', '/');
|
||||
$smsKey = $cfg['sms_api_key'] ?? '';
|
||||
if (!$smsUrl || !$smsKey) jpubErr('SMS no configurado.');
|
||||
|
||||
// Invalidar OTPs anteriores del mismo paciente
|
||||
try {
|
||||
$db->query("UPDATE lab_otp_tokens SET usado = 1 WHERE paciente_id = ? AND usado = 0", [$pac['id']]);
|
||||
} catch (\Throwable $_) {}
|
||||
|
||||
// Generar OTP
|
||||
$codigo = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
||||
$expires = date('Y-m-d H:i:s', time() + 300); // 5 min
|
||||
|
||||
try {
|
||||
$db->query(
|
||||
"INSERT INTO lab_otp_tokens (paciente_id, codigo, expires_at) VALUES (?, ?, ?)",
|
||||
[$pac['id'], $codigo, $expires]
|
||||
);
|
||||
} catch (\Throwable $e) { jpubErr('No se pudo generar el código.', 500); }
|
||||
|
||||
// Enviar SMS
|
||||
$numero = preg_replace('/\D/', '', $pac['telefono']);
|
||||
$mensaje = "Tu código de verificación de Laboratorio es: {$codigo}. Válido por 5 minutos.";
|
||||
$payload = json_encode(['numero' => $numero, 'mensaje' => $mensaje], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$ch = curl_init($smsUrl . '/api/sms/send');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $smsKey],
|
||||
CURLOPT_TIMEOUT => 12,
|
||||
]);
|
||||
$resp = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$curlErr = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($curlErr || $httpCode >= 400) {
|
||||
$smsResp = json_decode($resp, true);
|
||||
jpubErr('No se pudo enviar el SMS: ' . ($smsResp['error'] ?? "HTTP {$httpCode}"), 502);
|
||||
}
|
||||
|
||||
// Enmascarar teléfono para mostrar en UI: 3001****67
|
||||
$tel = $pac['telefono'];
|
||||
$digits = preg_replace('/\D/', '', $tel);
|
||||
$masked = substr($digits, 0, 3) . str_repeat('*', max(0, strlen($digits) - 5)) . substr($digits, -2);
|
||||
|
||||
jpubOk([
|
||||
'nombre' => explode(' ', $pac['nombre_completo'])[0],
|
||||
'telefono' => $masked,
|
||||
'expira' => 300,
|
||||
]);
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
/**
|
||||
* POST /api/public/verify_otp.php
|
||||
* Público — no requiere autenticación.
|
||||
* Body JSON: { cedula: "12345678", codigo: "123456" }
|
||||
*/
|
||||
ob_start();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
error_reporting(0);
|
||||
|
||||
require_once __DIR__ . '/../../config/config.php';
|
||||
|
||||
function jpubOk(array $data = []): void { ob_clean(); echo json_encode(['ok' => true] + $data); exit; }
|
||||
function jpubErr(string $msg, int $code = 400): void {
|
||||
ob_clean(); http_response_code($code);
|
||||
echo json_encode(['ok' => false, 'error' => $msg]); exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jpubErr('Método no permitido', 405);
|
||||
|
||||
$datos = json_decode(file_get_contents('php://input'), true);
|
||||
$cedula = trim($datos['cedula'] ?? '');
|
||||
$codigo = trim($datos['codigo'] ?? '');
|
||||
|
||||
if (!$cedula) jpubErr('Documento requerido.');
|
||||
if (!preg_match('/^\d{6}$/', $codigo)) jpubErr('Código inválido.');
|
||||
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
$pac = $db->fetch("SELECT id, nombre_completo FROM lab_pacientes WHERE numero_documento = ? AND is_active = 1", [$cedula]);
|
||||
} catch (\Throwable $_) { jpubErr('Error de base de datos.', 500); }
|
||||
|
||||
if (!$pac) jpubErr('Paciente no encontrado.');
|
||||
|
||||
// Buscar OTP válido
|
||||
try {
|
||||
$otp = $db->fetch(
|
||||
"SELECT id FROM lab_otp_tokens
|
||||
WHERE paciente_id = ? AND codigo = ? AND usado = 0 AND expires_at > NOW()
|
||||
ORDER BY id DESC LIMIT 1",
|
||||
[$pac['id'], $codigo]
|
||||
);
|
||||
} catch (\Throwable $_) { jpubErr('Error de base de datos.', 500); }
|
||||
|
||||
if (!$otp) jpubErr('Código incorrecto o expirado. Solicita uno nuevo.');
|
||||
|
||||
// Marcar OTP como usado y paciente como verificado
|
||||
try {
|
||||
$db->query("UPDATE lab_otp_tokens SET usado = 1 WHERE id = ?", [$otp['id']]);
|
||||
$db->query(
|
||||
"UPDATE lab_pacientes SET telefono_verificado = 1, verificado_at = NOW() WHERE id = ?",
|
||||
[$pac['id']]
|
||||
);
|
||||
} catch (\Throwable $_) { jpubErr('Error al guardar verificación.', 500); }
|
||||
|
||||
jpubOk(['nombre' => explode(' ', $pac['nombre_completo'])[0]]);
|
||||
Reference in New Issue
Block a user