UNPKG

appointment-mcp-server

Version:

Customer-focused MCP Server for appointment management with comprehensive service discovery, availability checking, and booking capabilities

1,195 lines 70.2 kB
import { Pool } from 'pg'; import { randomUUID } from 'crypto'; const databaseUrl = process.env.DATABASE_URL; const BUSINESS_ID = process.env.BUSINESS_ID; if (!databaseUrl) { throw new Error('Missing DATABASE_URL environment variable. Please check your MCP server configuration.'); } if (!BUSINESS_ID) { throw new Error('Missing BUSINESS_ID environment variable. Please check your MCP server configuration.'); } // Create PostgreSQL connection pool export const pool = new Pool({ connectionString: databaseUrl, ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false, }); // Helper function to execute queries async function query(text, params) { const client = await pool.connect(); try { const result = await client.query(text, params); return result; } finally { client.release(); } } // Function to verify database connection and ensure business exists export async function ensureBusinessExists() { try { // Check if business exists const result = await query('SELECT * FROM businesses WHERE id = $1', [BUSINESS_ID]); // If business doesn't exist, create it if (result.rows.length === 0) { await query('INSERT INTO businesses (id, name, created_at, updated_at) VALUES ($1, $2, $3, $4)', [BUSINESS_ID, `Business ${BUSINESS_ID}`, new Date().toISOString(), new Date().toISOString()]); console.log(`Created business with ID: ${BUSINESS_ID}`); } else { console.log(`Business exists with ID: ${BUSINESS_ID}`); } } catch (error) { console.error('Error ensuring business exists:', error); throw error; } } // Helper function to generate UUID export function generateUUID() { return randomUUID(); } // Helper function to validate UUID format export function isValidUUID(uuid) { const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; return uuidRegex.test(uuid); } export async function getBusinessDetails() { try { const result = await query('SELECT * FROM businesses WHERE id = $1', [BUSINESS_ID]); if (result.rows.length === 0) { throw new Error(`Business not found: ${BUSINESS_ID}`); } return result.rows[0]; } catch (error) { throw new Error(`Failed to get business details: ${error.message}`); } } // Customer management functions export async function createCustomer(customerData) { try { const result = await query(`INSERT INTO customers (business_id, first_name, last_name, email, phone_number, notes, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, [ BUSINESS_ID, customerData.first_name || null, customerData.last_name || null, customerData.email || null, customerData.phone, customerData.notes || null, new Date().toISOString(), new Date().toISOString() ]); return result.rows[0]; } catch (error) { throw new Error(`Failed to create customer: ${error.message}`); } } export async function getCustomer(customer_id) { try { const result = await query('SELECT * FROM customers WHERE id = $1 AND business_id = $2', [customer_id, BUSINESS_ID]); if (result.rows.length === 0) { throw new Error(`Customer not found: ${customer_id}`); } return result.rows[0]; } catch (error) { throw new Error(`Failed to get customer: ${error.message}`); } } export async function searchCustomers(searchTerm) { try { const result = await query(`SELECT * FROM customers WHERE business_id = $1 AND ( first_name ILIKE $2 OR last_name ILIKE $2 OR email ILIKE $2 OR phone_number ILIKE $2 ) ORDER BY created_at DESC`, [BUSINESS_ID, `%${searchTerm}%`]); return result.rows; } catch (error) { throw new Error(`Failed to search customers: ${error.message}`); } } export async function updateCustomer(customer_id, updates) { try { const setClause = []; const values = []; let paramIndex = 1; for (const [key, value] of Object.entries(updates)) { if (value !== undefined) { // Map phone to phone_number for database compatibility const dbKey = key === 'phone' ? 'phone_number' : key; setClause.push(`${dbKey} = $${paramIndex}`); values.push(value); paramIndex++; } } if (setClause.length === 0) { throw new Error('No valid updates provided'); } setClause.push(`updated_at = $${paramIndex}`); values.push(new Date().toISOString()); paramIndex++; // Add customer_id and business_id for WHERE clause const customerIdParam = paramIndex; const businessIdParam = paramIndex + 1; values.push(customer_id, BUSINESS_ID); const result = await query(`UPDATE customers SET ${setClause.join(', ')} WHERE id = $${customerIdParam} AND business_id = $${businessIdParam} RETURNING *`, values); if (result.rows.length === 0) { throw new Error(`Customer not found: ${customer_id}`); } return result.rows[0]; } catch (error) { throw new Error(`Failed to update customer: ${error.message}`); } } // Service inquiry functions export async function getServices() { try { const result = await query(`SELECT s.*, sc.name as category_name, sc.description as category_description FROM services s LEFT JOIN service_categories sc ON s.category_id = sc.id WHERE s.business_id = $1 AND s.is_active = true ORDER BY s.name`, [BUSINESS_ID]); return result.rows; } catch (error) { throw new Error(`Failed to get services: ${error.message}`); } } export async function getService(service_id) { try { const result = await query(`SELECT s.*, sc.name as category_name, sc.description as category_description FROM services s LEFT JOIN service_categories sc ON s.category_id = sc.id WHERE s.id = $1 AND s.business_id = $2`, [service_id, BUSINESS_ID]); if (result.rows.length === 0) { throw new Error(`Service not found: ${service_id}`); } const service = result.rows[0]; // Get staff for this service const staffResult = await query(`SELECT st.first_name, st.last_name, st.bio, st.avatar_url FROM staff st JOIN staff_services ss ON st.id = ss.staff_id WHERE ss.service_id = $1 AND st.business_id = $2`, [service_id, BUSINESS_ID]); service.staff = staffResult.rows; return service; } catch (error) { throw new Error(`Failed to get service: ${error.message}`); } } export async function getServiceByName(service_name) { try { const result = await query(`SELECT s.*, sc.name as category_name, sc.description as category_description FROM services s LEFT JOIN service_categories sc ON s.category_id = sc.id WHERE LOWER(s.name) LIKE LOWER($1) AND s.business_id = $2 AND s.is_active = true ORDER BY s.name`, [`%${service_name}%`, BUSINESS_ID]); if (result.rows.length === 0) { throw new Error(`No services found matching: ${service_name}`); } // For each service, get staff information const servicesWithStaff = await Promise.all(result.rows.map(async (service) => { const staffResult = await query(`SELECT st.first_name, st.last_name, st.bio, st.avatar_url, st.email, st.phone_number FROM staff st JOIN staff_services ss ON st.id = ss.staff_id WHERE ss.service_id = $1 AND st.business_id = $2 AND st.is_active = true ORDER BY st.first_name, st.last_name`, [service.id, BUSINESS_ID]); return { ...service, staff: staffResult.rows, staff_count: staffResult.rows.length }; })); return servicesWithStaff; } catch (error) { throw new Error(`Failed to get service by name: ${error.message}`); } } export async function searchServicesFuzzy(service_name, similarity_threshold = 0.3) { try { const result = await query(`SELECT * FROM search_services_fuzzy($1, $2)`, [service_name, similarity_threshold]); if (!result.rows[0] || !result.rows[0].search_services_fuzzy) { return []; } return result.rows[0].search_services_fuzzy; } catch (error) { throw new Error(`Failed to search services with fuzzy matching: ${error.message}`); } } export async function searchServicesComprehensive(search_term, similarity_threshold = 0.3) { try { const result = await query(`SELECT * FROM search_services_comprehensive($1, $2)`, [search_term, similarity_threshold]); if (!result.rows[0] || !result.rows[0].search_services_comprehensive) { return []; } return result.rows[0].search_services_comprehensive; } catch (error) { throw new Error(`Failed to search services comprehensively: ${error.message}`); } } // Customer appointment history export async function getCustomerAppointments(customer_id, limit) { try { let queryText = ` SELECT a.*, s.name as service_name, s.duration_minutes, s.price_cents, st.first_name as staff_first_name, st.last_name as staff_last_name, r.rating, r.review_text FROM appointments a LEFT JOIN services s ON a.service_id = s.id LEFT JOIN staff st ON a.staff_id = st.id LEFT JOIN reviews r ON a.id = r.appointment_id WHERE a.business_id = $1 AND a.customer_id = $2 ORDER BY a.start_time DESC `; const params = [BUSINESS_ID, customer_id]; if (limit) { queryText += ` LIMIT $3`; params.push(limit.toString()); } const result = await query(queryText, params); return result.rows; } catch (error) { throw new Error(`Failed to get customer appointments: ${error.message}`); } } // Business hours and availability export async function getBusinessHours() { try { const result = await query('SELECT * FROM get_business_hours($1)', [BUSINESS_ID]); return result.rows[0].get_business_hours; } catch (error) { throw new Error(`Failed to get business hours: ${error.message}`); } } // Staff information export async function getStaff() { try { const result = await query(`SELECT st.*, json_agg( json_build_object( 'name', s.name, 'description', s.description ) ) FILTER (WHERE s.id IS NOT NULL) as services FROM staff st LEFT JOIN staff_services ss ON st.id = ss.staff_id LEFT JOIN services s ON ss.service_id = s.id WHERE st.business_id = $1 AND st.is_active = true GROUP BY st.id ORDER BY st.first_name`, [BUSINESS_ID]); return result.rows; } catch (error) { throw new Error(`Failed to get staff: ${error.message}`); } } // Customer reviews export async function getCustomerReviews(customer_id) { try { const result = await query(`SELECT r.*, a.start_time, s.name as service_name, st.first_name as staff_first_name, st.last_name as staff_last_name FROM reviews r LEFT JOIN appointments a ON r.appointment_id = a.id LEFT JOIN services s ON r.service_id = s.id LEFT JOIN staff st ON r.staff_id = st.id WHERE r.business_id = $1 AND r.customer_id = $2 ORDER BY r.created_at DESC`, [BUSINESS_ID, customer_id]); return result.rows; } catch (error) { throw new Error(`Failed to get customer reviews: ${error.message}`); } } export async function createReview(reviewData) { try { const result = await query(`INSERT INTO reviews (business_id, appointment_id, customer_id, service_id, staff_id, rating, review_text, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`, [ BUSINESS_ID, reviewData.appointment_id, reviewData.customer_id, reviewData.service_id, reviewData.staff_id || null, reviewData.rating, reviewData.review_text || null, new Date().toISOString(), new Date().toISOString() ]); return result.rows[0]; } catch (error) { throw new Error(`Failed to create review: ${error.message}`); } } // Helper function to get the actual status constraint values from the database async function getStatusConstraintValues() { try { const result = await query(`SELECT pg_get_constraintdef(oid) as definition FROM pg_constraint WHERE conrelid = 'appointments'::regclass AND contype = 'c' AND conname = 'appointments_mvp_status_check'`); if (result.rows.length > 0) { const definition = result.rows[0].definition; console.log('Status constraint definition:', definition); // Parse the constraint definition to extract valid values const match = definition.match(/CHECK \(status IN \(([^)]+)\)\)/i); if (match) { const values = match[1].split(',').map((v) => v.trim().replace(/'/g, '')); console.log('Valid status values:', values); return values; } } // Fallback to default values if we can't parse the constraint return ['scheduled', 'confirmed', 'canceled', 'completed', 'no_show']; } catch (error) { console.log('Could not get status constraint values:', error); // Fallback to default values return ['scheduled', 'confirmed', 'canceled', 'completed', 'no_show']; } } // Helper function to create customer if they don't exist export async function createCustomerIfNotExists(customerName, email, phone) { try { // First try to find existing customer const existingCustomers = await searchCustomers(customerName); if (existingCustomers.length > 0) { return existingCustomers[0]; } // If no customer found, create a new one const nameParts = customerName.trim().split(' '); const firstName = nameParts[0] || ''; const lastName = nameParts.slice(1).join(' ') || ''; const customerData = { first_name: firstName, last_name: lastName, email: email || null, phone: phone || '000-000-0000', // Default phone if none provided notes: `Auto-created from appointment booking` }; const newCustomer = await createCustomer(customerData); console.log(`Created new customer: ${firstName} ${lastName} (ID: ${newCustomer.id})`); return newCustomer; } catch (error) { throw new Error(`Failed to create customer: ${error.message}`); } } export async function createAppointment(appointmentData) { try { // Validate and potentially resolve customer_id let customerId = appointmentData.customer_id; // If customer_id is not a valid UUID, try to find customer by name if (!isValidUUID(customerId)) { console.log(`Customer ID "${customerId}" is not a valid UUID, searching by name...`); // Search for customer by name const customers = await searchCustomers(customerId); if (customers.length === 0) { // Try to create customer if they don't exist console.log(`No customer found, attempting to create customer: ${customerId}`); const newCustomer = await createCustomerIfNotExists(customerId); customerId = newCustomer.id; } else if (customers.length > 1) { throw new Error(`Multiple customers found with name "${customerId}". Please use a specific customer ID.`); } else { customerId = customers[0].id; console.log(`Found customer: ${customers[0].first_name} ${customers[0].last_name} (ID: ${customerId})`); } } // Validate service_id if (!isValidUUID(appointmentData.service_id)) { throw new Error(`Invalid service ID format: ${appointmentData.service_id}`); } // Validate staff_id if provided if (appointmentData.staff_id && !isValidUUID(appointmentData.staff_id)) { throw new Error(`Invalid staff ID format: ${appointmentData.staff_id}`); } // Get service details to calculate duration and price const serviceResult = await query('SELECT duration_minutes, price_cents FROM services WHERE id = $1 AND business_id = $2', [appointmentData.service_id, BUSINESS_ID]); if (serviceResult.rows.length === 0) { throw new Error(`Service not found: ${appointmentData.service_id}`); } const service = serviceResult.rows[0]; // Calculate duration from start and end times const startTime = new Date(appointmentData.start_time); const endTime = new Date(appointmentData.end_time); const durationMinutes = Math.round((endTime.getTime() - startTime.getTime()) / (1000 * 60)); // Get the actual status constraint values from the database const validStatusValues = await getStatusConstraintValues(); const defaultStatus = validStatusValues[0] || 'scheduled'; console.log(`Using status: ${defaultStatus} (from valid values: ${validStatusValues.join(', ')})`); // Check the actual status constraint in the database try { const constraintResult = await query(`SELECT conname, pg_get_constraintdef(oid) as definition FROM pg_constraint WHERE conrelid = 'appointments'::regclass AND contype = 'c' AND conname LIKE '%status%'`); if (constraintResult.rows.length > 0) { console.log('Status constraints found:', constraintResult.rows); } } catch (constraintError) { console.log('Could not check status constraints:', constraintError); } const result = await query(`INSERT INTO appointments (business_id, customer_id, service_id, staff_id, start_time, end_time, duration_minutes, price_cents, status, notes, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`, [ BUSINESS_ID, customerId, appointmentData.service_id, appointmentData.staff_id || null, appointmentData.start_time, appointmentData.end_time, durationMinutes, service.price_cents, defaultStatus, appointmentData.notes || null, new Date().toISOString(), new Date().toISOString() ]); return result.rows[0]; } catch (error) { // Enhanced error logging console.error('Appointment creation error details:', { error: error.message, code: error.code, detail: error.detail, hint: error.hint, where: error.where }); // If it's a status constraint error, provide more specific guidance if (error.message.includes('mvp_status_check') || error.message.includes('status')) { const validStatusValues = await getStatusConstraintValues(); throw new Error(`Status constraint violation. Valid status values are: ${validStatusValues.map(v => `'${v}'`).join(', ')}. Error: ${error.message}`); } throw new Error(`Failed to create appointment: ${error.message}`); } } export async function getAppointments(filters) { try { let whereClause = 'WHERE a.business_id = $1'; const params = [BUSINESS_ID]; let paramIndex = 2; if (filters?.customer_id) { whereClause += ` AND a.customer_id = $${paramIndex}`; params.push(filters.customer_id); paramIndex++; } if (filters?.service_id) { whereClause += ` AND a.service_id = $${paramIndex}`; params.push(filters.service_id); paramIndex++; } if (filters?.staff_id) { whereClause += ` AND a.staff_id = $${paramIndex}`; params.push(filters.staff_id); paramIndex++; } if (filters?.status) { whereClause += ` AND a.status = $${paramIndex}`; params.push(filters.status); paramIndex++; } if (filters?.start_date) { whereClause += ` AND a.start_time >= $${paramIndex}`; params.push(filters.start_date); paramIndex++; } if (filters?.end_date) { whereClause += ` AND a.start_time <= $${paramIndex}`; params.push(filters.end_date); paramIndex++; } const result = await query(`SELECT a.*, c.first_name as customer_first_name, c.last_name as customer_last_name, c.email as customer_email, s.name as service_name, s.duration_minutes, s.price_cents, st.first_name as staff_first_name, st.last_name as staff_last_name FROM appointments a LEFT JOIN customers c ON a.customer_id = c.id LEFT JOIN services s ON a.service_id = s.id LEFT JOIN staff st ON a.staff_id = st.id ${whereClause} ORDER BY a.start_time ASC`, params); return result.rows; } catch (error) { throw new Error(`Failed to get appointments: ${error.message}`); } } export async function getAppointment(appointment_id) { try { const result = await query(`SELECT a.*, c.first_name as customer_first_name, c.last_name as customer_last_name, c.email as customer_email, c.phone_number as customer_phone, s.name as service_name, s.description as service_description, s.duration_minutes, s.price_cents, st.first_name as staff_first_name, st.last_name as staff_last_name, st.email as staff_email FROM appointments a LEFT JOIN customers c ON a.customer_id = c.id LEFT JOIN services s ON a.service_id = s.id LEFT JOIN staff st ON a.staff_id = st.id WHERE a.id = $1 AND a.business_id = $2`, [appointment_id, BUSINESS_ID]); if (result.rows.length === 0) { throw new Error(`Appointment not found: ${appointment_id}`); } return result.rows[0]; } catch (error) { throw new Error(`Failed to get appointment: ${error.message}`); } } export async function deleteAppointment(appointment_id) { try { const result = await query('DELETE FROM appointments WHERE id = $1 AND business_id = $2 RETURNING *', [appointment_id, BUSINESS_ID]); if (result.rows.length === 0) { throw new Error(`Appointment not found: ${appointment_id}`); } return result.rows[0]; } catch (error) { throw new Error(`Failed to delete appointment: ${error.message}`); } } // Database connection verification export async function verifyDatabaseConnection() { try { const result = await query('SELECT COUNT(*) FROM businesses LIMIT 1'); console.log('Database connection verified'); } catch (error) { console.error('Error verifying database connection:', error); throw error; } } // Graceful shutdown process.on('SIGINT', async () => { console.log('Closing database pool...'); await pool.end(); process.exit(0); }); process.on('SIGTERM', async () => { console.log('Closing database pool...'); await pool.end(); process.exit(0); }); // Availability and Staff Management Functions /** * Get staff availability for a specific date */ export async function getStaffAvailability(date) { try { const dayOfWeek = new Date(date).getDay(); const result = await query(`SELECT s.id as staff_id, s.first_name, s.last_name, s.email, s.phone_number, s.avatar_url, s.bio, s.is_active, swh.day_of_week, swh.open_time, swh.close_time, swh.is_available, -- Check if staff has time off on this date CASE WHEN sto.date = $2::date THEN true ELSE false END as has_time_off, sto.title as time_off_title, sto.description as time_off_description, sto.is_all_day as time_off_all_day, sto.start_time as time_off_start, sto.end_time as time_off_end FROM staff s LEFT JOIN staff_working_hours swh ON s.id = swh.staff_id AND swh.day_of_week = $3 LEFT JOIN staff_time_off sto ON s.id = sto.staff_id AND sto.date = $2::date WHERE s.business_id = $1 AND s.is_active = true ORDER BY s.first_name, s.last_name`, [BUSINESS_ID, date, dayOfWeek]); return result.rows; } catch (error) { throw new Error(`Failed to get staff availability: ${error.message}`); } } /** * Get available time slots for a specific service and date */ export async function getAvailableTimeSlots(service_id, date) { try { const dayOfWeek = new Date(date).getDay(); // First get the service details const serviceResult = await query('SELECT id, name, duration_minutes, buffer_time_minutes, max_bookings_per_slot FROM services WHERE id = $1 AND business_id = $2', [service_id, BUSINESS_ID]); if (serviceResult.rows.length === 0) { throw new Error(`Service not found: ${service_id}`); } const service = serviceResult.rows[0]; const serviceDuration = service.duration_minutes; const bufferTime = service.buffer_time_minutes || 0; const maxBookingsPerSlot = service.max_bookings_per_slot || 1; // Get business hours for this day const businessHoursResult = await query('SELECT open_time, close_time FROM working_hours WHERE business_id = $1 AND day_of_week = $2 AND (is_closed = false OR is_closed IS NULL)', [BUSINESS_ID, dayOfWeek]); if (businessHoursResult.rows.length === 0) { return []; // Business is closed on this day } const businessHours = businessHoursResult.rows[0]; const openTime = businessHours.open_time; const closeTime = businessHours.close_time; // Get staff who can provide this service const staffResult = await query(`SELECT DISTINCT s.id, s.first_name, s.last_name FROM staff s JOIN staff_services ss ON s.id = ss.staff_id WHERE ss.service_id = $1 AND s.business_id = $2 AND s.is_active = true`, [service_id, BUSINESS_ID]); if (staffResult.rows.length === 0) { return []; // No staff available for this service } // Get existing appointments for this date const appointmentsResult = await query(`SELECT staff_id, start_time, end_time FROM appointments WHERE business_id = $1 AND service_id = $2 AND DATE(start_time) = $3 AND status != 'cancelled' ORDER BY start_time`, [BUSINESS_ID, service_id, date]); const existingAppointments = appointmentsResult.rows; // Generate time slots const timeSlots = []; const slotInterval = 30; // 30-minute intervals // Convert time strings to minutes for easier calculation const openMinutes = timeToMinutes(openTime); const closeMinutes = timeToMinutes(closeTime); for (let currentMinutes = openMinutes; currentMinutes + serviceDuration <= closeMinutes; currentMinutes += slotInterval) { const slotStartTime = minutesToTime(currentMinutes); const slotEndTime = minutesToTime(currentMinutes + serviceDuration); // Check availability for each staff member const availableStaff = []; for (const staff of staffResult.rows) { const isStaffAvailable = !existingAppointments.some((appointment) => { if (appointment.staff_id !== staff.id) return false; const appointmentStart = timeToMinutes(appointment.start_time.split('T')[1].substring(0, 5)); const appointmentEnd = timeToMinutes(appointment.end_time.split('T')[1].substring(0, 5)); // Check for overlap (including buffer time) return ((currentMinutes < appointmentEnd + bufferTime) && (currentMinutes + serviceDuration + bufferTime > appointmentStart)); }); if (isStaffAvailable) { availableStaff.push({ id: staff.id, name: `${staff.first_name} ${staff.last_name}` }); } } if (availableStaff.length > 0) { timeSlots.push({ start_time: slotStartTime, end_time: slotEndTime, available_staff: availableStaff, available_slots: Math.min(availableStaff.length, maxBookingsPerSlot) }); } } return timeSlots; } catch (error) { throw new Error(`Failed to get available time slots: ${error.message}`); } } // Helper functions for time conversion function timeToMinutes(timeString) { const [hours, minutes] = timeString.split(':').map(Number); return hours * 60 + minutes; } function minutesToTime(minutes) { const hours = Math.floor(minutes / 60); const mins = minutes % 60; return `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}`; } /** * Get all staff information with their services and working hours */ export async function getAllStaffInfo() { try { const result = await query(`SELECT s.id as staff_id, s.first_name, s.last_name, s.email, s.phone_number, s.avatar_url, s.bio, s.is_active, -- Get services this staff member provides STRING_AGG(DISTINCT sv.name, ', ' ORDER BY sv.name) as services_provided, COUNT(DISTINCT ss.service_id) as total_services, -- Get working hours summary STRING_AGG( DISTINCT CASE swh.day_of_week WHEN 0 THEN 'Monday' WHEN 1 THEN 'Tuesday' WHEN 2 THEN 'Wednesday' WHEN 3 THEN 'Thursday' WHEN 4 THEN 'Friday' WHEN 5 THEN 'Saturday' WHEN 6 THEN 'Sunday' END || ': ' || swh.open_time || '-' || swh.close_time, '; ' ) as working_hours_summary, -- Get upcoming appointments count COUNT(DISTINCT CASE WHEN a.status IN ('confirmed', 'pending') AND a.start_time > NOW() THEN a.id END) as upcoming_appointments, -- Get completed appointments count COUNT(DISTINCT CASE WHEN a.status = 'completed' THEN a.id END) as completed_appointments FROM staff s LEFT JOIN staff_services ss ON s.id = ss.staff_id LEFT JOIN services sv ON ss.service_id = sv.id LEFT JOIN staff_working_hours swh ON s.id = swh.staff_id AND swh.is_available = true LEFT JOIN appointments a ON s.id = a.staff_id WHERE s.business_id = $1 GROUP BY s.id, s.first_name, s.last_name, s.email, s.phone_number, s.avatar_url, s.bio, s.is_active ORDER BY s.first_name, s.last_name`, [BUSINESS_ID]); return result.rows; } catch (error) { throw new Error(`Failed to get staff information: ${error.message}`); } } /** * Get staff member by ID with detailed information */ export async function getStaffMember(staff_id) { try { const result = await query(`SELECT s.id as staff_id, s.first_name, s.last_name, s.email, s.phone_number, s.avatar_url, s.bio, s.is_active, -- Get services this staff member provides json_agg( DISTINCT jsonb_build_object( 'id', sv.id, 'name', sv.name, 'description', sv.description, 'duration_minutes', sv.duration_minutes, 'price_cents', sv.price_cents ) ) FILTER (WHERE sv.id IS NOT NULL) as services, -- Get working hours json_agg( DISTINCT jsonb_build_object( 'day_of_week', swh.day_of_week, 'day_name', CASE swh.day_of_week WHEN 0 THEN 'Monday' WHEN 1 THEN 'Tuesday' WHEN 2 THEN 'Wednesday' WHEN 3 THEN 'Thursday' WHEN 4 THEN 'Friday' WHEN 5 THEN 'Saturday' WHEN 6 THEN 'Sunday' END, 'open_time', swh.open_time, 'close_time', swh.close_time, 'is_available', swh.is_available ) ) FILTER (WHERE swh.id IS NOT NULL) as working_hours, -- Get upcoming appointments COUNT(DISTINCT CASE WHEN a.status IN ('confirmed', 'pending') AND a.start_time > NOW() THEN a.id END) as upcoming_appointments, -- Get completed appointments count COUNT(DISTINCT CASE WHEN a.status = 'completed' THEN a.id END) as completed_appointments FROM staff s LEFT JOIN staff_services ss ON s.id = ss.staff_id LEFT JOIN services sv ON ss.service_id = sv.id LEFT JOIN staff_working_hours swh ON s.id = swh.staff_id LEFT JOIN appointments a ON s.id = a.staff_id WHERE s.business_id = $1 AND s.id = $2 GROUP BY s.id, s.first_name, s.last_name, s.email, s.phone_number, s.avatar_url, s.bio, s.is_active`, [BUSINESS_ID, staff_id]); if (result.rows.length === 0) { throw new Error(`Staff member not found: ${staff_id}`); } return result.rows[0]; } catch (error) { throw new Error(`Failed to get staff member: ${error.message}`); } } /** * Get staff time off for a specific date range */ export async function getStaffTimeOff(start_date, end_date) { try { let whereClause = 'WHERE sto.business_id = $1'; const params = [BUSINESS_ID]; let paramIndex = 2; if (start_date) { whereClause += ` AND sto.date >= $${paramIndex}`; params.push(start_date); paramIndex++; } if (end_date) { whereClause += ` AND sto.date <= $${paramIndex}`; params.push(end_date); paramIndex++; } const result = await query(`SELECT sto.id, sto.staff_id, s.first_name, s.last_name, sto.title, sto.description, sto.date, sto.start_time, sto.end_time, sto.is_all_day, sto.created_at FROM staff_time_off sto JOIN staff s ON sto.staff_id = s.id ${whereClause} ORDER BY sto.date ASC, sto.start_time ASC`, params); return result.rows; } catch (error) { throw new Error(`Failed to get staff time off: ${error.message}`); } } /** * Check if a service is available on a specific date and time */ export async function checkServiceAvailability(service_name, date, time) { try { const dayOfWeek = new Date(date).getDay(); // First, find the service by name const serviceResult = await query('SELECT id, name, duration_minutes, buffer_time_minutes, max_bookings_per_slot FROM services WHERE LOWER(name) LIKE LOWER($1) AND business_id = $2 AND is_active = true', [`%${service_name}%`, BUSINESS_ID]); if (serviceResult.rows.length === 0) { return { available: false, reason: `Service "${service_name}" not found or not active`, service: null, staff: [] }; } const service = serviceResult.rows[0]; // Get staff who provide this service and are available on this day const staffResult = await query(`SELECT s.id as staff_id, s.first_name, s.last_name, s.email, swh.open_time, swh.close_time, swh.is_available, -- Check if staff has time off on this date CASE WHEN sto.date = $3::date THEN true ELSE false END as has_time_off, sto.title as time_off_title, sto.is_all_day as time_off_all_day FROM staff s JOIN staff_services ss ON s.id = ss.staff_id AND ss.service_id = $2 LEFT JOIN staff_working_hours swh ON s.id = swh.staff_id AND swh.day_of_week = $4 LEFT JOIN staff_time_off sto ON s.id = sto.staff_id AND sto.date = $3::date WHERE s.business_id = $1 AND s.is_active = true AND swh.is_available = true AND (sto.id IS NULL OR sto.is_all_day = false) ORDER BY s.first_name, s.last_name`, [BUSINESS_ID, service.id, date, dayOfWeek]); if (staffResult.rows.length === 0) { return { available: false, reason: `No staff available for ${service.name} on ${date}`, service: service, staff: [] }; } // If specific time is requested, check if it's within working hours let availableStaff = staffResult.rows; if (time) { availableStaff = staffResult.rows.filter((staff) => { if (staff.has_time_off) return false; if (!staff.open_time || !staff.close_time) return false; const requestedTime = new Date(`2000-01-01T${time}`); const openTime = new Date(`2000-01-01T${staff.open_time}`); const closeTime = new Date(`2000-01-01T${staff.close_time}`); return requestedTime >= openTime && requestedTime < closeTime; }); if (availableStaff.length === 0) { return { available: false, reason: `No staff available for ${service.name} at ${time} on ${date}`, service: service, staff: staffResult.rows }; } } // Check existing appointments for this service and date at the specific time slot let appointmentsQuery = ''; let appointmentParams = [BUSINESS_ID, service.id, date]; if (time) { // If specific time is requested, check appointments that overlap with the requested time slot const requestedTime = new Date(`2000-01-01T${time}`); const slotEndTime = new Date(requestedTime.getTime() + service.duration_minutes * 60000); appointmentsQuery = ` SELECT COUNT(*) as appointment_count FROM appointments WHERE business_id = $1 AND service_id = $2 AND DATE(start_time) = $3::date AND status IN ('confirmed', 'pending', 'scheduled') AND ( (start_time::time >= $4::time AND start_time::time < $5::time) OR (end_time::time > $4::time AND end_time::time <= $5::time) OR (start_time::time <= $4::time AND end_time::time >= $5::time) )`; appointmentParams.push(time, slotEndTime.toTimeString().slice(0, 8)); } else { // If no specific time, check total appointments for the day appointmentsQuery = ` SELECT COUNT(*) as appointment_count FROM appointments WHERE business_id = $1 AND service_id = $2 AND DATE(start_time) = $3::date AND status IN ('confirmed', 'pending', 'scheduled')`; } const appointmentsResult = await query(appointmentsQuery, appointmentParams); const existingAppointments = parseInt(appointmentsResult.rows[0].appointment_count); // Check if the service has reached its booking capacity if (existingAppointments >= service.max_bookings_per_slot) { return { available: false, reason: `${service.name} is fully booked on ${date}${time ? ` at ${time}` : ''} (${existingAppointments}/${service.max_bookings_per_slot} slots taken)`, service: service, staff: availableStaff, existingAppointments: existingAppointments, maxBookings: service.max_bookings_per_slot }; } return { available: true, reason: `${service.name} is available on ${date}${time ? ` at ${time}` : ''} (${service.max_bookings_per_slot - existingAppointments} slots remaining)`, service: service, staff: availableStaff, existingAppointments: existingAppointments, maxBookings: service.max_bookings_per_slot, remainingSlots: service.max_bookings_per_slot - existingAppointments }; } catch (error) { throw new Error(`Failed to check service availability: ${error.message}`); } } /** * Get available time slots for a service on a specific date */ export async function getServiceTimeSlots(service_name, date) { try { const availability = await checkServiceAvailability(service_name, date); if (!availability.available) { return { available: false, reason: availability.reason, timeSlots: [] }; } const service = availability.service; const availableStaff = availability.staff; const timeSlots = []; // Generate time slots for each available staff member for (const staff of availableStaff) { if (!staff.open_time || !staff.close_time) continue; const startTime = new Date(`2000-01-01T${staff.open_time}`); const endTime = new Date(`2000-01-01T${staff.close_time}`); const slotDuration = service.duration_minutes + service.buffer_time_minutes; let currentTime = new Date(startTime); while (currentTime < endTime) { const slotEnd = new Date(currentTime.getTime() + service.duration_minutes * 60000); if (slotEnd <= endTime) { const slotStartTime = currentTime.toTimeString().slice(0, 8); const slotEndTime = slotEnd.toTimeString().slice(0, 8); // Check if this specific time slot has availability const slotAvailability = await checkServiceAvailability(service_name, date, slotStartTime); if (slotAvailability.available) { timeSlots.push({ staff_id: staff.staff_id, staff_name: `${staff.first_name} ${staff.last_name}`, service_id: service.id, service_name: service.name, duration_minutes: service.duration_minutes, start_time: slotStartTime, end_time: slotEndTime, date: date, remaining_slots: slotAvailability.remainingSlots, total_slots: slotAvailability.maxBookings, existing_appointments: slotAvailability.existingAppointments }); } } currentTime = new Date(currentTime.getTime() + 30 * 60000); // Add 30 minutes } } return { available: timeSlots.length > 0, reason: timeSlots.length > 0 ? `${service.name} has ${timeSlots.length} available time slots on ${date}` : `${service.name} is fully booked on ${date}`, timeSlots: timeSlots, service: service }; } catch (error) { throw new Error(`Failed to get service time slots: ${error.message}`); } } /** * Check business hours for a specific date */ export async function checkBusinessHours(date) { try { const dayOfWeek = new Date(date).getDay(); const result = await query(`SELECT day_of_week, open_time, close_time, is_closed FROM working_hours WHERE business_id = $1 AND day_of_week = $2`, [BUSINESS_ID, dayOfWeek]); if (result.rows.length === 0) { return { isOpen: false, reason: `No business hours set for this day`, hours: null }; } const hours = result.rows[0]; if (hours.is_closed) { return { isOpen: false, reason: `Business is closed on ${date}`, hours: hours }; } return { isOpen: true, reason: `Business is open from ${hours.open_time} to ${hours.close_time} on ${date}`, hours: hours }; } catch (error) { throw new Error(`Failed to check business hours: ${error.message}`); } } /** * Check for appointment conflicts comprehensively */ // Appointment Lifecycle Management Functions export async function updateAppointment(appointment_id, customer_id, service_id, staff_id, start_time, end_time, status, notes) { try { const result = await query('SELECT * FROM update_appointment($1, $2, $3, $4, $5, $6, $7, $8)', [appointment_id, customer_id, service_id, staff_id, start_time, end_time, status, notes || '']); if (!result.rows[0] || !result.rows[0].update_appointment.success) { throw new Error(result.rows[0]?.update_appointment?.error || 'Failed to update appointment'); } return result.rows[0].update_appointment; } catch (error) { throw new Error(`Failed to update appointment: ${error.message}`); } } export async function cancelAppointment(appointment_id, cancellation_reason, cancelled_by) { try { const result = await query('SELECT * FROM cancel_appointment($1, $2, $3)', [appointment_id, cancellation_reason, cancelled_by]); if (!result.rows[0] || !result.rows[0].cancel_appointment.success) { throw new Error(result.rows[0]?.cancel_appointment?.error || 'Failed to cancel appointment'); } return result.rows[0].cancel_appointment; } catch (error) { throw new Error(`Failed to cancel appointment: ${error.message}`); } } export async function rescheduleAppointment(appointment_id, new_start_time, new_end_time, rescheduled_by) { try { const result = await query('SELECT * FROM reschedule_appointment($1, $2, $3, $4)', [appointment_id, new_start_time, new_end_time, rescheduled_by]); if (!result.rows[0] || !result.rows[0].reschedule_appointment.success) { throw new Error(result.rows[0]?.reschedule_appointment?.error || 'Failed to reschedule appointment'); } return result.rows[0].reschedule_appointment; } catch (error) { throw new Error(`Failed to reschedule appointment: ${error.message}`); } } export async function confirmAppointment(appointment_id, confirmed_by) { try { const result = await query('SELECT * FROM confirm_appointment($1, $2)', [appointment_id, confirmed_by]); if (!result.rows[0] || !result.rows[0].confirm_appointment.success) { throw new Error(result.rows[0]?.confirm_appointment?.error || 'Failed to confirm appointment'); } return result.rows[0].confirm_appointment; } catch (error) { throw new Error(`Failed to confirm appointment: ${error.message}`); } } export async function completeAppointment(appointment_id, completed_by, completion_notes) { try { const result = await query('SELECT * FROM complete_appointment($1, $2, $3)', [appointment_id, completed_by, completion_notes || '']); if (!result.rows[0] || !result.rows[0].complete_appointment.success) { throw new Error(result.rows[0]?.complete_appointment?.error || 'Failed to complete appointment'); } return result.rows[0].complete_appointment; } catch (error) { throw new Error(`Failed to complete appointment: ${error.message}`); } } export async function getStaffAvailabilityCalendar(staff_id, start_date, end_date) { try { const result = await query('SELECT * FROM get_staff_availability_calendar($1, $2, $3)', [staff_id, start_date, end_date]); if (!result.rows[0] || !result.rows[0].get_staff_availability_calendar.success) { throw new Error(result.rows[0]?.get_staff_availability_calendar?.error || 'Failed to get staff availability calendar'); } return result.rows[0].get_staff_availability_calendar; } catch (error) { throw new Error(`Failed to get staff availability calendar: ${error.message}`); } } export async function checkRealTimeAvailability(service_id, date, time) { try { const result = await query('SELECT * FROM check_real_time_availability($1, $2, $3)', [service_id, date, time]); if (!result.rows[0] || !result.rows[0].check_real_time_availability.success) { throw new Error(result.rows[0]?.check_real_time_availability?.error || 'Failed to check real-time availability'); } return result.r