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
JavaScript
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