beginTransaction(); try{ $email=strtolower(trim($_POST['email']??'')); $password=$_POST['password']??''; $business=trim($_POST['business_name']??''); if(!$business||!filter_var($email,FILTER_VALIDATE_EMAIL)||strlen($password)<8) throw new RuntimeException('Please provide valid registration details.'); $slug=strtolower(trim(preg_replace('/[^a-z0-9]+/i','-',$business),'-')).'-'.substr(bin2hex(random_bytes(3)),0,6); $stmt=$pdo->prepare('INSERT INTO tenants(uuid,name,slug,industry_key,timezone,locale,currency,trial_ends_at) VALUES(?,?,?,?,?,?,?,DATE_ADD(NOW(),INTERVAL 14 DAY))'); $stmt->execute([uuidv4(),$business,$slug,$_POST['industry_key']?:null,$_POST['timezone']?:'America/New_York',$_POST['locale']?:'en',$_POST['currency']?:'USD']); $tenantId=(int)$pdo->lastInsertId(); $stmt=$pdo->prepare('INSERT INTO users(uuid,first_name,last_name,email,password_hash) VALUES(?,?,?,?,?)'); $stmt->execute([uuidv4(),trim($_POST['first_name']),trim($_POST['last_name']),$email,password_hash($password,PASSWORD_DEFAULT)]); $userId=(int)$pdo->lastInsertId(); $pdo->prepare("INSERT INTO tenant_users(tenant_id,user_id,role,is_default) VALUES(?,?,'owner',1)")->execute([$tenantId,$userId]); $pdo->prepare('INSERT INTO business_profiles(tenant_id,public_name,email,phone) VALUES(?,?,?,?)')->execute([$tenantId,$business,$email,trim($_POST['phone']??'')]); $pdo->prepare('INSERT INTO locations(tenant_id,name,timezone,is_primary) VALUES(?,?,?,1)')->execute([$tenantId,'Main Location',$_POST['timezone']?:'America/New_York']); $pdo->commit(); Auth::login(['id'=>$userId,'first_name'=>$_POST['first_name'],'last_name'=>$_POST['last_name']],$tenantId); redirect('/dashboard'); }catch(Throwable $e){$pdo->rollBack();$error=$e->getMessage();require dirname(__DIR__).'/app/Views/auth/register.php';exit;} } if($path==='/login' && $method==='GET'){require dirname(__DIR__).'/app/Views/auth/login.php';exit;} if($path==='/login' && $method==='POST'){ csrf_check(); $stmt=$pdo->prepare("SELECT u.*,tu.tenant_id FROM users u JOIN tenant_users tu ON tu.user_id=u.id WHERE u.email=? AND u.status='active' ORDER BY tu.is_default DESC LIMIT 1");$stmt->execute([strtolower(trim($_POST['email']??''))]);$user=$stmt->fetch(); if($user && password_verify($_POST['password']??'',$user['password_hash'])){Auth::login($user,(int)$user['tenant_id']);redirect('/dashboard');}$error='Invalid email or password.';require dirname(__DIR__).'/app/Views/auth/login.php';exit; } if ($path === '/logout') { Auth::logout(); redirect('/login'); } /* Public customer appointment routes — no login required */ if (preg_match('#^/appointment/([a-f0-9]{64})/cancel/?$#i', $path, $matches)) { $token = strtolower($matches[1]); if (!$publicTenantId) { http_response_code(404); exit('Business not found.'); } if (!$publicTenantId) { http_response_code(404); exit('Business not found.'); } $stmt = $pdo->prepare( 'SELECT * FROM business_profiles WHERE tenant_id = ? LIMIT 1' ); $stmt->execute([$publicTenantId]); $businessProfile = $stmt->fetch(); if (!$businessProfile) { http_response_code(404); exit('Business not found.'); } $stmt->execute([$publicTenantId]); $cancelAppointment = $stmt->fetch(); if (!$cancelAppointment) { http_response_code(404); exit('Cita no encontrada.'); } if ($method === 'POST') { csrf_check(); if (($cancelAppointment['status'] ?? '') !== 'cancelled') { $stmt = $pdo->prepare( "UPDATE appointments SET status = 'cancelled', cancelled_at = NOW(), cancelled_by = 'customer', cancellation_reason = ? WHERE id = ? AND customer_token = ?" ); $stmt->execute([ trim($_POST['cancellation_reason'] ?? ''), (int)$cancelAppointment['id'], $token ]); } redirect('/appointment/' . $token); } require dirname(__DIR__) . '/app/Views/public/cancel-appointment.php'; exit; } if (preg_match('#^/appointment/([a-f0-9]{64})/calendar/?$#i', $path, $matches)) { $token = strtolower($matches[1]); $stmt = $pdo->prepare( 'SELECT a.*, c.first_name, c.last_name, s.name AS service_name, tm.first_name AS team_first_name, tm.last_name AS team_last_name, bp.public_name AS business_name, bp.address_line1, bp.address_line2, bp.city, bp.state_region, bp.country_code, bp.timezone FROM appointments a LEFT JOIN customers c ON c.id = a.customer_id AND c.tenant_id = a.tenant_id LEFT JOIN services s ON s.id = a.service_id AND s.tenant_id = a.tenant_id LEFT JOIN team_members tm ON tm.id = a.team_member_id AND tm.tenant_id = a.tenant_id LEFT JOIN business_profiles bp ON bp.tenant_id = a.tenant_id WHERE a.customer_token = ? LIMIT 1' ); $stmt->execute([$token]); $appointment = $stmt->fetch(); if (!$appointment) { http_response_code(404); exit('Cita no encontrada.'); } $title = $appointment['service_name'] ?: $appointment['title'] ?: 'Cita'; $businessName = $appointment['business_name'] ?: 'Mi Negocio'; $location = trim(implode(', ', array_filter([ $appointment['address_line1'] ?? '', $appointment['address_line2'] ?? '', $appointment['city'] ?? '', $appointment['state_region'] ?? '', $appointment['country_code'] ?? '' ]))); $description = 'Cita con ' . $businessName; if (!empty($appointment['team_first_name'])) { $description .= ' · Atendido por ' . trim( $appointment['team_first_name'] . ' ' . ($appointment['team_last_name'] ?? '') ); } $escapeIcs = static function (string $value): string { return str_replace( ["\\", "\r\n", "\n", ",", ";"], ["\\\\", "\\n", "\\n", "\\,", "\\;"], $value ); }; $timezone = new DateTimeZone( $appointment['timezone'] ?: 'America/Costa_Rica' ); $startsAt = new DateTimeImmutable($appointment['starts_at'], $timezone); $endsAt = new DateTimeImmutable($appointment['ends_at'], $timezone); $ics = implode("\r\n", [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Business OS//Appointment//ES', 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', 'BEGIN:VEVENT', 'UID:' . $appointment['uuid'] . '@businessos', 'DTSTAMP:' . gmdate('Ymd\\THis\\Z'), 'DTSTART;TZID=' . $timezone->getName() . ':' . $startsAt->format('Ymd\\THis'), 'DTEND;TZID=' . $timezone->getName() . ':' . $endsAt->format('Ymd\\THis'), 'SUMMARY:' . $escapeIcs($title), 'DESCRIPTION:' . $escapeIcs($description), 'LOCATION:' . $escapeIcs($location), 'END:VEVENT', 'END:VCALENDAR', '' ]); header('Content-Type: text/calendar; charset=utf-8'); header( 'Content-Disposition: attachment; filename="cita-' . date('Y-m-d', strtotime($appointment['starts_at'])) . '.ics"' ); echo $ics; exit; } if (preg_match('#^/appointment/([a-f0-9]{64})/?$#i', $path, $matches)) { $token = strtolower($matches[1]); $stmt = $pdo->prepare( 'SELECT a.*, c.first_name, c.last_name, s.name AS service_name, tm.first_name AS team_first_name, tm.last_name AS team_last_name, bp.* FROM appointments a LEFT JOIN customers c ON c.id = a.customer_id AND c.tenant_id = a.tenant_id LEFT JOIN services s ON s.id = a.service_id AND s.tenant_id = a.tenant_id LEFT JOIN team_members tm ON tm.id = a.team_member_id AND tm.tenant_id = a.tenant_id LEFT JOIN business_profiles bp ON bp.tenant_id = a.tenant_id WHERE a.customer_token = ? LIMIT 1' ); $stmt->execute([$token]); $appointment = $stmt->fetch(); if (!$appointment) { http_response_code(404); exit('Cita no encontrada.'); } require dirname(__DIR__) . '/app/Views/public/appointment.php'; exit; } /* ========================================================= PUBLIC TENANT RESOLUTION + BOOKING PAGE ========================================================= */ $host = strtolower($_SERVER['HTTP_HOST'] ?? ''); $host = preg_replace('/:\d+$/', '', $host); $publicTenantId = null; if ($host !== '') { $parts = array_values(array_filter(explode('.', $host))); if (($parts[0] ?? '') === 'www' && count($parts) >= 4) { $subdomain = $parts[1] ?? ''; } else { $subdomain = $parts[0] ?? ''; } if ($subdomain !== '') { $stmt = $pdo->prepare( 'SELECT id FROM tenants WHERE slug = ? LIMIT 1' ); $stmt->execute([$subdomain]); $publicTenantId = $stmt->fetchColumn(); } } /* * BusinessOS Booking Engine — Phase 1 * Drop this AFTER public tenant resolution ($publicTenantId) and BEFORE the /book view route. * Endpoint: GET /book/availability?service_id=123&date=YYYY-MM-DD */ if ($path === '/book/availability' && $method === 'GET') { header('Content-Type: application/json; charset=utf-8'); $json = static function (array $payload, int $status = 200): never { http_response_code($status); echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); exit; }; if (!$publicTenantId) { $json(['ok' => false, 'message' => 'Business not found.'], 404); } $serviceId = (int)($_GET['service_id'] ?? 0); $date = trim((string)($_GET['date'] ?? '')); if ($serviceId <= 0 || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { $json(['ok' => false, 'message' => 'Solicitud inválida.'], 422); } $stmt = $pdo->prepare( 'SELECT id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes FROM services WHERE id = ? AND tenant_id = ? AND is_active = 1 LIMIT 1' ); $stmt->execute([$serviceId, (int)$publicTenantId]); $service = $stmt->fetch(); if (!$service) { $json(['ok' => false, 'message' => 'Servicio no disponible.'], 404); } $stmt = $pdo->prepare( 'SELECT timezone FROM business_profiles WHERE tenant_id = ? LIMIT 1' ); $stmt->execute([(int)$publicTenantId]); $timezoneName = trim((string)($stmt->fetchColumn() ?: 'America/New_York')); try { $timezone = new DateTimeZone($timezoneName); $day = new DateTimeImmutable($date . ' 00:00:00', $timezone); } catch (Throwable $e) { $json(['ok' => false, 'message' => 'Fecha inválida.'], 422); } $today = new DateTimeImmutable('today', $timezone); if ($day < $today) { $json(['ok' => true, 'date' => $date, 'professionals' => []]); } $dayOfWeek = (int)$day->format('w'); // 0 Sunday ... 6 Saturday $stmt = $pdo->prepare( 'SELECT tm.id, tm.first_name, tm.last_name, tm.display_name, tm.job_title, h.start_time, h.end_time FROM service_team_members stm INNER JOIN team_members tm ON tm.id = stm.team_member_id AND tm.tenant_id = stm.tenant_id INNER JOIN team_member_hours h ON h.team_member_id = tm.id AND h.tenant_id = tm.tenant_id AND h.day_of_week = ? AND h.is_working = 1 WHERE stm.tenant_id = ? AND stm.service_id = ? AND tm.is_active = 1 AND tm.accepts_appointments = 1 AND tm.online_booking_enabled = 1 AND tm.public_profile_enabled = 1 ORDER BY tm.sort_order, tm.first_name, tm.last_name' ); $stmt->execute([$dayOfWeek, (int)$publicTenantId, $serviceId]); $professionals = $stmt->fetchAll(); $duration = max(5, (int)($service['duration_minutes'] ?? 60)); $bufferBefore = max(0, (int)($service['buffer_before_minutes'] ?? 0)); $bufferAfter = max(0, (int)($service['buffer_after_minutes'] ?? 0)); $slotStepMinutes = 15; $now = new DateTimeImmutable('now', $timezone); $result = []; foreach ($professionals as $professional) { $memberId = (int)$professional['id']; $workStart = new DateTimeImmutable($date . ' ' . $professional['start_time'], $timezone); $workEnd = new DateTimeImmutable($date . ' ' . $professional['end_time'], $timezone); $stmt = $pdo->prepare( "SELECT a.starts_at, a.ends_at, COALESCE(s.buffer_before_minutes, 0) AS existing_buffer_before, COALESCE(s.buffer_after_minutes, 0) AS existing_buffer_after FROM appointments a LEFT JOIN services s ON s.id = a.service_id AND s.tenant_id = a.tenant_id WHERE a.tenant_id = ? AND a.team_member_id = ? AND a.status NOT IN ('cancelled','no_show') AND a.starts_at < ? AND a.ends_at > ? ORDER BY a.starts_at" ); $stmt->execute([ (int)$publicTenantId, $memberId, $workEnd->format('Y-m-d H:i:s'), $workStart->format('Y-m-d H:i:s'), ]); $existingAppointments = $stmt->fetchAll(); $blocked = []; foreach ($existingAppointments as $appointment) { $existingStart = new DateTimeImmutable($appointment['starts_at'], $timezone); $existingEnd = new DateTimeImmutable($appointment['ends_at'], $timezone); $existingBefore = max(0, (int)$appointment['existing_buffer_before']); $existingAfter = max(0, (int)$appointment['existing_buffer_after']); $blocked[] = [ $existingStart->modify('-' . $existingBefore . ' minutes'), $existingEnd->modify('+' . $existingAfter . ' minutes'), ]; } $slots = []; for ($cursor = $workStart; $cursor < $workEnd; $cursor = $cursor->modify('+' . $slotStepMinutes . ' minutes')) { $serviceEnd = $cursor->modify('+' . $duration . ' minutes'); if ($serviceEnd > $workEnd) { break; } if ($cursor <= $now) { continue; } $candidateStart = $cursor->modify('-' . $bufferBefore . ' minutes'); $candidateEnd = $serviceEnd->modify('+' . $bufferAfter . ' minutes'); $hasConflict = false; foreach ($blocked as [$blockedStart, $blockedEnd]) { if ($candidateStart < $blockedEnd && $candidateEnd > $blockedStart) { $hasConflict = true; break; } } if (!$hasConflict) { $slots[] = [ 'starts_at' => $cursor->format('Y-m-d H:i:s'), 'label' => $cursor->format('g:i A'), ]; } } if ($slots) { $name = trim((string)($professional['display_name'] ?? '')); if ($name === '') { $name = trim(($professional['first_name'] ?? '') . ' ' . ($professional['last_name'] ?? '')); } $result[] = [ 'id' => $memberId, 'name' => $name, 'job_title' => (string)($professional['job_title'] ?? ''), 'slots' => $slots, ]; } } $json([ 'ok' => true, 'service' => [ 'id' => (int)$service['id'], 'name' => (string)$service['name'], 'duration_minutes' => $duration, ], 'date' => $date, 'timezone' => $timezoneName, 'professionals' => $result, ]); } /* Public booking page — no login required */ if ($path === '/book') { if (!$publicTenantId) { http_response_code(404); exit('Business not found.'); } $stmt = $pdo->prepare( 'SELECT bp.*, t.currency FROM business_profiles bp INNER JOIN tenants t ON t.id = bp.tenant_id WHERE bp.tenant_id = ? LIMIT 1' ); $stmt->execute([(int)$publicTenantId]); $businessProfile = $stmt->fetch(); if (!$businessProfile) { http_response_code(404); exit('Business not found.'); } if (!(int)($businessProfile['booking_enabled'] ?? 1)) { http_response_code(404); exit('Online booking is currently unavailable.'); } $stmt = $pdo->prepare( 'SELECT * FROM services WHERE tenant_id = ? AND is_active = 1 ORDER BY name' ); $stmt->execute([(int)$publicTenantId]); $services = $stmt->fetchAll(); $stmt = $pdo->prepare( 'SELECT * FROM team_members WHERE tenant_id = ? AND is_active = 1 AND accepts_appointments = 1 AND online_booking_enabled = 1 AND public_profile_enabled = 1 ORDER BY sort_order, first_name, last_name' ); $stmt->execute([(int)$publicTenantId]); $teamMembers = $stmt->fetchAll(); require dirname(__DIR__) . '/app/Views/public/book.php'; exit; } Auth::requireLogin(); $tenantId=TenantContext::id(); if($path==='/'||$path==='/dashboard'){ $stats=[]; foreach(['customers'=>'customers','services'=>'services'] as $k=>$table){$s=$pdo->prepare("SELECT COUNT(*) FROM $table WHERE tenant_id=?");$s->execute([$tenantId]);$stats[$k]=(int)$s->fetchColumn();} $s=$pdo->prepare("SELECT COUNT(*) FROM appointments WHERE tenant_id=? AND DATE(starts_at)=CURDATE() AND status NOT IN('cancelled','no_show')");$s->execute([$tenantId]);$stats['today']=(int)$s->fetchColumn(); $s=$pdo->prepare('SELECT a.*,c.first_name,c.last_name,s.name service_name FROM appointments a LEFT JOIN customers c ON c.id=a.customer_id LEFT JOIN services s ON s.id=a.service_id WHERE a.tenant_id=? AND a.starts_at>=NOW() ORDER BY a.starts_at LIMIT 6');$s->execute([$tenantId]);$upcoming=$s->fetchAll(); require dirname(__DIR__).'/app/Views/dashboard/index.php';exit; } if($path==='/customers'){ if($method==='POST'){csrf_check();$pdo->prepare('INSERT INTO customers(tenant_id,uuid,first_name,last_name,email,phone,notes) VALUES(?,?,?,?,?,?,?)')->execute([$tenantId,uuidv4(),trim($_POST['first_name']),trim($_POST['last_name']),trim($_POST['email']),trim($_POST['phone']),trim($_POST['notes'])]);redirect('/customers');} $s=$pdo->prepare('SELECT * FROM customers WHERE tenant_id=? ORDER BY created_at DESC LIMIT 100');$s->execute([$tenantId]);$customers=$s->fetchAll();require dirname(__DIR__).'/app/Views/customers/index.php';exit; } /* ========================================================= SERVICES ========================================================= */ if ( preg_match('#^/services/(\d+)/edit$#', $path, $matches) ) { $serviceId = (int)$matches[1]; $stmt = $pdo->prepare( 'SELECT * FROM services WHERE id = ? AND tenant_id = ? LIMIT 1' ); $stmt->execute([$serviceId, $tenantId]); $service = $stmt->fetch(); if (!$service) { http_response_code(404); exit('Servicio no encontrado.'); } $stmt = $pdo->prepare( 'SELECT currency FROM tenants WHERE id = ? LIMIT 1' ); $stmt->execute([$tenantId]); $currency = strtoupper((string)($stmt->fetchColumn() ?: 'USD')); $stmt = $pdo->prepare( 'SELECT id, first_name, last_name, display_name, job_title FROM team_members WHERE tenant_id = ? AND is_active = 1 AND accepts_appointments = 1 ORDER BY sort_order, first_name, last_name' ); $stmt->execute([$tenantId]); $serviceProfessionals = $stmt->fetchAll(); $stmt = $pdo->prepare( 'SELECT team_member_id FROM service_team_members WHERE tenant_id = ? AND service_id = ?' ); $stmt->execute([$tenantId, $serviceId]); $assignedProfessionalIds = array_map( 'intval', $stmt->fetchAll(PDO::FETCH_COLUMN) ); if ($method === 'POST') { csrf_check(); $imagePath = $service['image_path'] ?? null; if (!empty($_POST['remove_image'])) { $imagePath = null; } if ( isset($_FILES['image']) && $_FILES['image']['error'] !== UPLOAD_ERR_NO_FILE ) { if ($_FILES['image']['error'] !== UPLOAD_ERR_OK) { exit('No fue posible recibir la imagen.'); } if ($_FILES['image']['size'] > 5 * 1024 * 1024) { exit('La imagen no puede superar 5 MB.'); } $allowedTypes = [ 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp' ]; $mime = mime_content_type($_FILES['image']['tmp_name']); if (!isset($allowedTypes[$mime])) { exit('Formato de imagen no permitido.'); } $dir = dirname(__DIR__) . '/public/uploads/services/' . $tenantId; if (!is_dir($dir)) { mkdir($dir, 0755, true); } $fileName = 'service-' . $serviceId . '-' . bin2hex(random_bytes(8)) . '.' . $allowedTypes[$mime]; if (!move_uploaded_file( $_FILES['image']['tmp_name'], $dir . '/' . $fileName )) { exit('No fue posible guardar la imagen.'); } $imagePath = '/uploads/services/' . $tenantId . '/' . $fileName; } $stmt = $pdo->prepare( 'UPDATE services SET name = ?, description = ?, duration_minutes = ?, price = ?, booking_mode = ?, image_path = ?, buffer_before_minutes = ?, buffer_after_minutes = ?, max_capacity = ?, tax_rate = ?, is_active = ? WHERE id = ? AND tenant_id = ?' ); $stmt->execute([ mb_substr(trim($_POST['name'] ?? ''), 0, 50), mb_substr(trim($_POST['description'] ?? ''), 0, 200), max(5, (int)($_POST['duration_minutes'] ?? 60)), (float)($_POST['price'] ?? 0), $_POST['booking_mode'] ?? 'appointment', $imagePath, max(0, (int)($_POST['buffer_before_minutes'] ?? 0)), max(0, (int)($_POST['buffer_after_minutes'] ?? 0)), max(1, (int)($_POST['max_capacity'] ?? 1)), max(0, (float)($_POST['tax_rate'] ?? 0)), isset($_POST['is_active']) ? 1 : 0, $serviceId, $tenantId ]); $selectedProfessionalIds = array_values(array_unique(array_filter( array_map('intval', $_POST['professional_ids'] ?? []), static fn (int $id): bool => $id > 0 ))); $pdo->prepare( 'DELETE FROM service_team_members WHERE tenant_id = ? AND service_id = ?' )->execute([$tenantId, $serviceId]); if ($selectedProfessionalIds) { $validProfessional = $pdo->prepare( 'SELECT id FROM team_members WHERE id = ? AND tenant_id = ? AND is_active = 1 AND accepts_appointments = 1 LIMIT 1' ); $insertAssignment = $pdo->prepare( 'INSERT INTO service_team_members ( tenant_id, service_id, team_member_id ) VALUES (?, ?, ?)' ); foreach ($selectedProfessionalIds as $professionalId) { $validProfessional->execute([$professionalId, $tenantId]); if ($validProfessional->fetchColumn()) { $insertAssignment->execute([ $tenantId, $serviceId, $professionalId ]); } } } redirect('/services/' . $serviceId . '/edit'); } require dirname(__DIR__) . '/app/Views/services/edit.php'; exit; } if ($path === '/services') { $stmt = $pdo->prepare( 'SELECT currency FROM tenants WHERE id = ? LIMIT 1' ); $stmt->execute([$tenantId]); $currency = strtoupper((string)($stmt->fetchColumn() ?: 'USD')); if ($method === 'POST') { csrf_check(); $imagePath = null; if ( isset($_FILES['image']) && $_FILES['image']['error'] !== UPLOAD_ERR_NO_FILE ) { if ($_FILES['image']['error'] !== UPLOAD_ERR_OK) { exit('No fue posible recibir la imagen.'); } if ($_FILES['image']['size'] > 5 * 1024 * 1024) { exit('La imagen no puede superar 5 MB.'); } $allowedTypes = [ 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp' ]; $mime = mime_content_type($_FILES['image']['tmp_name']); if (!isset($allowedTypes[$mime])) { exit('Formato de imagen no permitido.'); } $dir = dirname(__DIR__) . '/public/uploads/services/' . $tenantId; if (!is_dir($dir)) { mkdir($dir, 0755, true); } $fileName = 'service-' . bin2hex(random_bytes(8)) . '.' . $allowedTypes[$mime]; if (!move_uploaded_file( $_FILES['image']['tmp_name'], $dir . '/' . $fileName )) { exit('No fue posible guardar la imagen.'); } $imagePath = '/uploads/services/' . $tenantId . '/' . $fileName; } $stmt = $pdo->prepare( 'INSERT INTO services ( tenant_id, name, description, duration_minutes, price, booking_mode, image_path, buffer_before_minutes, buffer_after_minutes, max_capacity, tax_rate ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' ); $stmt->execute([ $tenantId, mb_substr(trim($_POST['name'] ?? ''), 0, 50), mb_substr(trim($_POST['description'] ?? ''), 0, 200), max(5, (int)($_POST['duration_minutes'] ?? 60)), (float)($_POST['price'] ?? 0), $_POST['booking_mode'] ?? 'appointment', $imagePath, max(0, (int)($_POST['buffer_before_minutes'] ?? 0)), max(0, (int)($_POST['buffer_after_minutes'] ?? 0)), max(1, (int)($_POST['max_capacity'] ?? 1)), max(0, (float)($_POST['tax_rate'] ?? 0)) ]); redirect('/services'); } $stmt = $pdo->prepare( 'SELECT * FROM services WHERE tenant_id = ? ORDER BY is_active DESC, name' ); $stmt->execute([$tenantId]); $services = $stmt->fetchAll(); require dirname(__DIR__) . '/app/Views/services/index.php'; exit; } if ( $method === 'POST' && preg_match('#^/team/(\d+)/edit$#', $path, $matches) ) { csrf_check(); $memberId = (int)$matches[1]; $stmt = $pdo->prepare('SELECT * FROM team_members WHERE id = ? AND tenant_id = ? LIMIT 1'); $stmt->execute([$memberId, $tenantId]); $member = $stmt->fetch(); if (!$member) { http_response_code(404); exit('Team member not found.'); } $photoPath = $member['photo_path'] ?? null; if (isset($_FILES['photo']) && $_FILES['photo']['error'] === UPLOAD_ERR_OK) { $allowedTypes = ['image/jpeg'=>'jpg','image/png'=>'png','image/webp'=>'webp']; $mime = mime_content_type($_FILES['photo']['tmp_name']); if (!isset($allowedTypes[$mime])) exit('Formato de imagen no permitido.'); if ($_FILES['photo']['size'] > 5 * 1024 * 1024) exit('La foto no puede superar 5 MB.'); $uploadDirectory = dirname(__DIR__) . '/public/uploads/team/' . $tenantId; if (!is_dir($uploadDirectory)) mkdir($uploadDirectory, 0755, true); $fileName = 'member-' . $memberId . '-' . bin2hex(random_bytes(8)) . '.' . $allowedTypes[$mime]; $destination = $uploadDirectory . '/' . $fileName; if (!move_uploaded_file($_FILES['photo']['tmp_name'], $destination)) exit('No fue posible guardar la foto.'); $photoPath = '/uploads/team/' . $tenantId . '/' . $fileName; } $pdo->beginTransaction(); try { $calendarColor = trim($_POST['calendar_color'] ?? ($member['calendar_color'] ?? '#6D4AFF')); if (!preg_match('/^#[0-9A-Fa-f]{6}$/', $calendarColor)) { $calendarColor = '#6D4AFF'; } $stmt = $pdo->prepare('UPDATE team_members SET display_name = ?, job_title = ?, bio = ?, photo_path = ?, public_profile_enabled = ?, online_booking_enabled = ?, calendar_color = ? WHERE id = ? AND tenant_id = ?'); $stmt->execute([ mb_substr(trim($_POST['display_name'] ?? ''), 0, 50), mb_substr(trim($_POST['job_title'] ?? ''), 0, 45), mb_substr(trim($_POST['bio'] ?? ''), 0, 200), $photoPath, isset($_POST['public_profile_enabled']) ? 1 : 0, isset($_POST['online_booking_enabled']) ? 1 : 0, $calendarColor, $memberId, $tenantId ]); $hoursInput = $_POST['hours'] ?? []; $upsertHours = $pdo->prepare('INSERT INTO team_member_hours (tenant_id, team_member_id, day_of_week, is_working, start_time, end_time) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id), is_working = VALUES(is_working), start_time = VALUES(start_time), end_time = VALUES(end_time)'); foreach ([0,1,2,3,4,5,6] as $dayOfWeek) { $dayData = $hoursInput[$dayOfWeek] ?? []; $isWorking = isset($dayData['is_working']) ? 1 : 0; $startTime = $isWorking ? trim($dayData['start_time'] ?? '') : null; $endTime = $isWorking ? trim($dayData['end_time'] ?? '') : null; if ($isWorking) { if ($startTime === '' || $endTime === '' || strtotime($startTime) === false || strtotime($endTime) === false) throw new RuntimeException('Revisa las horas de trabajo seleccionadas.'); if ($startTime >= $endTime) throw new RuntimeException('La hora de cierre debe ser posterior a la hora de inicio.'); } $upsertHours->execute([$tenantId, $memberId, $dayOfWeek, $isWorking, $startTime, $endTime]); } $pdo->commit(); } catch (Throwable $e) { $pdo->rollBack(); exit($e->getMessage()); } redirect('/team/' . $memberId . '/edit'); } if ( $method === 'GET' && preg_match('#^/team/(\d+)/edit$#', $path, $matches) ) { $memberId = (int)$matches[1]; $stmt = $pdo->prepare('SELECT * FROM team_members WHERE id = ? AND tenant_id = ? LIMIT 1'); $stmt->execute([$memberId, $tenantId]); $member = $stmt->fetch(); if (!$member) { http_response_code(404); exit('Team member not found.'); } $stmt = $pdo->prepare('SELECT day_of_week, is_working, start_time, end_time FROM team_member_hours WHERE tenant_id = ? AND team_member_id = ? ORDER BY day_of_week'); $stmt->execute([$tenantId, $memberId]); $workingHours = $stmt->fetchAll(); require dirname(__DIR__) . '/app/Views/team/edit.php'; exit; } if ($path === '/team') { if ($method === 'POST') { csrf_check(); $stmt = $pdo->prepare( 'INSERT INTO team_members ( tenant_id, uuid, first_name, last_name, email, phone, job_title, calendar_color, accepts_appointments ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' ); $stmt->execute([ $tenantId, uuidv4(), trim($_POST['first_name'] ?? ''), trim($_POST['last_name'] ?? ''), trim($_POST['email'] ?? ''), trim($_POST['phone'] ?? ''), trim($_POST['job_title'] ?? ''), $_POST['calendar_color'] ?? '#6D4AFF', isset($_POST['accepts_appointments']) ? 1 : 0 ]); redirect('/team'); } $stmt = $pdo->prepare( 'SELECT * FROM team_members WHERE tenant_id = ? ORDER BY is_active DESC, sort_order, first_name, last_name' ); $stmt->execute([$tenantId]); $members = $stmt->fetchAll(); require dirname(__DIR__) . '/app/Views/team/index.php'; exit; } if ($path === '/calendar') { if ($method === 'POST') { csrf_check(); $start = trim($_POST['starts_at'] ?? ''); if ($start === '' || strtotime($start) === false) { exit('Fecha y hora inválidas.'); } $duration = max(5, (int)($_POST['duration'] ?? 60)); $end = date('Y-m-d H:i:s', strtotime($start) + ($duration * 60)); $teamMemberId = !empty($_POST['team_member_id']) ? (int)$_POST['team_member_id'] : null; if ($teamMemberId !== null) { $check = $pdo->prepare( 'SELECT id FROM team_members WHERE id = ? AND tenant_id = ? AND is_active = 1 AND accepts_appointments = 1 LIMIT 1' ); $check->execute([$teamMemberId, $tenantId]); if (!$check->fetchColumn()) { exit('Miembro del equipo inválido.'); } } $customerToken = bin2hex(random_bytes(32)); $stmt = $pdo->prepare( 'INSERT INTO appointments ( tenant_id, uuid, customer_token, customer_id, service_id, assigned_user_id, team_member_id, title, starts_at, ends_at, notes, created_by ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' ); $stmt->execute([ $tenantId, uuidv4(), $customerToken, !empty($_POST['customer_id']) ? (int)$_POST['customer_id'] : null, !empty($_POST['service_id']) ? (int)$_POST['service_id'] : null, TenantContext::userId(), $teamMemberId, trim($_POST['title'] ?? ''), date('Y-m-d H:i:s', strtotime($start)), $end, trim($_POST['notes'] ?? ''), TenantContext::userId() ]); redirect('/calendar'); } $stmt = $pdo->prepare( 'SELECT a.*, c.first_name, c.last_name, s.name AS service_name, tm.first_name AS team_first_name, tm.last_name AS team_last_name, tm.calendar_color FROM appointments a LEFT JOIN customers c ON c.id = a.customer_id AND c.tenant_id = a.tenant_id LEFT JOIN services s ON s.id = a.service_id AND s.tenant_id = a.tenant_id LEFT JOIN team_members tm ON tm.id = a.team_member_id AND tm.tenant_id = a.tenant_id WHERE a.tenant_id = ? AND a.starts_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) ORDER BY a.starts_at LIMIT 100' ); $stmt->execute([$tenantId]); $appointments = $stmt->fetchAll(); $stmt = $pdo->prepare( 'SELECT id, first_name, last_name FROM customers WHERE tenant_id = ? ORDER BY first_name, last_name' ); $stmt->execute([$tenantId]); $customers = $stmt->fetchAll(); $stmt = $pdo->prepare( 'SELECT id, name, duration_minutes FROM services WHERE tenant_id = ? AND is_active = 1 ORDER BY name' ); $stmt->execute([$tenantId]); $services = $stmt->fetchAll(); $stmt = $pdo->prepare( 'SELECT id, first_name, last_name, calendar_color FROM team_members WHERE tenant_id = ? AND is_active = 1 AND accepts_appointments = 1 ORDER BY sort_order, first_name, last_name' ); $stmt->execute([$tenantId]); $teamMembers = $stmt->fetchAll(); require dirname(__DIR__) . '/app/Views/calendar/index.php'; exit; } if($path==='/mi-espacio'){ if($method==='POST'){csrf_check();$pdo->prepare('INSERT INTO workspace_items(tenant_id,owner_user_id,item_type,title,content,status,due_at,visibility) VALUES(?,?,?,?,?,?,?,?)')->execute([$tenantId,TenantContext::userId(),$_POST['item_type'],trim($_POST['title']),trim($_POST['content']),'open',$_POST['due_at']?:null,$_POST['visibility']]);redirect('/mi-espacio');} $s=$pdo->prepare('SELECT * FROM workspace_items WHERE tenant_id=? AND (owner_user_id=? OR visibility="team") AND status<>"archived" ORDER BY is_pinned DESC,created_at DESC');$s->execute([$tenantId,TenantContext::userId()]);$items=$s->fetchAll();require dirname(__DIR__).'/app/Views/mi-espacio/index.php';exit; } if ($path === '/business') { $stmt = $pdo->prepare( 'SELECT * FROM business_profiles WHERE tenant_id = ? LIMIT 1' ); $stmt->execute([$tenantId]); $businessProfile = $stmt->fetch(); if ($method === 'POST') { csrf_check(); $logoPath = $businessProfile['logo_path'] ?? null; if ( isset($_FILES['logo']) && $_FILES['logo']['error'] === UPLOAD_ERR_OK ) { $allowedTypes = [ 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp' ]; $mime = mime_content_type($_FILES['logo']['tmp_name']); if (!isset($allowedTypes[$mime])) { exit('Formato de logo no permitido.'); } if ($_FILES['logo']['size'] > 3 * 1024 * 1024) { exit('El logo no puede superar 3 MB.'); } $uploadDirectory = dirname(__DIR__) . '/public/uploads/business/' . $tenantId; if (!is_dir($uploadDirectory)) { mkdir($uploadDirectory, 0755, true); } $fileName = 'logo-' . bin2hex(random_bytes(8)) . '.' . $allowedTypes[$mime]; $destination = $uploadDirectory . '/' . $fileName; if (!move_uploaded_file( $_FILES['logo']['tmp_name'], $destination )) { exit('No fue posible guardar el logo.'); } $logoPath = '/uploads/business/' . $tenantId . '/' . $fileName; } $stmt = $pdo->prepare( 'UPDATE business_profiles SET public_name = ?, description = ?, email = ?, phone = ?, whatsapp = ?, website = ?, instagram = ?, facebook = ?, address_line1 = ?, address_line2 = ?, city = ?, state_region = ?, postal_code = ?, country_code = ?, logo_path = ?, primary_color = ?, secondary_color = ?, timezone = ?, locale = ? WHERE tenant_id = ?' ); $stmt->execute([ trim($_POST['public_name'] ?? ''), mb_substr(trim($_POST['description'] ?? ''), 0, 180), trim($_POST['email'] ?? ''), trim($_POST['phone'] ?? ''), trim($_POST['whatsapp'] ?? ''), trim($_POST['website'] ?? ''), trim($_POST['instagram'] ?? ''), trim($_POST['facebook'] ?? ''), trim($_POST['address_line1'] ?? ''), trim($_POST['address_line2'] ?? ''), trim($_POST['city'] ?? ''), trim($_POST['state_region'] ?? ''), trim($_POST['postal_code'] ?? ''), strtoupper(trim($_POST['country_code'] ?? 'CR')), $logoPath, $_POST['primary_color'] ?? '#6D4AFF', $_POST['secondary_color'] ?? '#111827', $_POST['timezone'] ?? 'America/Costa_Rica', $_POST['locale'] ?? 'es', $tenantId ]); redirect('/business'); } $stmt = $pdo->prepare( 'SELECT * FROM business_profiles WHERE tenant_id = ? LIMIT 1' ); $stmt->execute([$tenantId]); $businessProfile = $stmt->fetch(); require dirname(__DIR__) . '/app/Views/business/index.php'; exit; } http_response_code(404);echo 'Page not found';