How to Build an Online Appointment Scheduling System: Complete Guide with Production-Ready Code
Step-by-step guide to building a HIPAA-compliant appointment scheduling system with calendar integration, automated reminders, time zone handling, provider availability management, and waitlist functionality. Includes complete React, Node.js, and PostgreSQL code examples.
How to Build an Online Appointment Scheduling System: Complete Guide with Production-Ready Code
Building a healthcare appointment scheduling system requires careful attention to user experience, data integrity, HIPAA compliance, and operational efficiency. This comprehensive guide walks you through building a production-ready scheduling platform with calendar integration, automated reminders, time zone handling, provider management, and waitlist functionality.
Time to Build: 6-12 months (traditional approach) or 2-4 weeks with JustCopy.ai
Tech Stack: React, Node.js, PostgreSQL, Redis, AWS
Key Features: Real-time availability, SMS/email reminders, calendar sync, waitlist automation, EHR integration
System Architecture Overview
A production-grade appointment scheduling system requires multiple integrated components:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Frontend Layer β
β React SPA + Mobile PWA + Provider Dashboard β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β API Gateway β
β Authentication + Rate Limiting + Load Balancing β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Core Services Layer β
β β
β ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ β
β β Scheduling β β Provider β β Patient β β
β β Service β β Service β β Service β β
β ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ β
β β
β ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ β
β β Reminder β β Waitlist β β Calendar β β
β β Service β β Service β β Service β β
β ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Data Layer β
β PostgreSQL + Redis Cache + S3 Storage β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β External Integrations β
β Twilio SMS + SendGrid Email + Calendar APIs + EHR Systems β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Rather than building this entire architecture from scratchβwhich typically requires 6-12 months and $150K-$500Kβyou can use JustCopy.ai to deploy a proven, production-ready scheduling platform in weeks. The platformβs 10 specialized AI agents handle code generation, automated testing, deployment pipelines, and monitoring, all while ensuring HIPAA compliance and healthcare industry best practices.
Part 1: Database Schema Design
A robust scheduling system requires carefully designed database schemas:
Core Tables
-- Database schema for appointment scheduling system
-- PostgreSQL 14+
-- Patients table
CREATE TABLE patients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
phone VARCHAR(20) NOT NULL,
date_of_birth DATE NOT NULL,
-- Address
address_line1 VARCHAR(255),
address_line2 VARCHAR(255),
city VARCHAR(100),
state VARCHAR(2),
zip_code VARCHAR(10),
-- Preferences
preferred_language VARCHAR(10) DEFAULT 'en',
communication_preferences JSONB DEFAULT '{"sms": true, "email": true, "phone": false}'::jsonb,
timezone VARCHAR(50) DEFAULT 'America/New_York',
-- Insurance
insurance_provider VARCHAR(255),
insurance_member_id VARCHAR(100),
insurance_group_number VARCHAR(100),
-- Medical
primary_care_provider_id UUID REFERENCES providers(id),
medical_conditions TEXT[],
allergies TEXT[],
medications JSONB,
-- Analytics
total_appointments INTEGER DEFAULT 0,
completed_appointments INTEGER DEFAULT 0,
no_shows INTEGER DEFAULT 0,
cancellations INTEGER DEFAULT 0,
-- Metadata
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
last_appointment_at TIMESTAMP WITH TIME ZONE,
-- HIPAA compliance
consent_to_treat BOOLEAN DEFAULT FALSE,
consent_to_sms BOOLEAN DEFAULT FALSE,
consent_to_email BOOLEAN DEFAULT FALSE,
hipaa_authorization_signed BOOLEAN DEFAULT FALSE,
-- Soft delete
deleted_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_patients_email ON patients(email) WHERE deleted_at IS NULL;
CREATE INDEX idx_patients_phone ON patients(phone) WHERE deleted_at IS NULL;
CREATE INDEX idx_patients_last_name ON patients(last_name) WHERE deleted_at IS NULL;
-- Providers table
CREATE TABLE providers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
npi VARCHAR(10) UNIQUE NOT NULL,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
credentials VARCHAR(50),
specialty VARCHAR(100) NOT NULL,
sub_specialty VARCHAR(100),
email VARCHAR(255) UNIQUE NOT NULL,
phone VARCHAR(20),
bio TEXT,
photo_url VARCHAR(500),
-- Scheduling preferences
default_appointment_duration INTEGER DEFAULT 30,
buffer_time_minutes INTEGER DEFAULT 5,
max_advance_booking_days INTEGER DEFAULT 90,
min_advance_booking_hours INTEGER DEFAULT 24,
-- Online booking settings
accepts_online_booking BOOLEAN DEFAULT TRUE,
accepts_new_patients BOOLEAN DEFAULT TRUE,
max_online_bookings_per_day INTEGER DEFAULT 10,
-- Status
status VARCHAR(20) DEFAULT 'ACTIVE',
-- Metadata
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
-- Soft delete
deleted_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_providers_specialty ON providers(specialty) WHERE deleted_at IS NULL;
CREATE INDEX idx_providers_status ON providers(status) WHERE deleted_at IS NULL;
-- Locations table
CREATE TABLE locations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
address_line1 VARCHAR(255) NOT NULL,
address_line2 VARCHAR(255),
city VARCHAR(100) NOT NULL,
state VARCHAR(2) NOT NULL,
zip_code VARCHAR(10) NOT NULL,
phone VARCHAR(20) NOT NULL,
fax VARCHAR(20),
-- Geolocation for distance calculations
latitude DECIMAL(10, 8),
longitude DECIMAL(11, 8),
-- Operating hours
hours_of_operation JSONB DEFAULT '{
"monday": {"open": "08:00", "close": "17:00"},
"tuesday": {"open": "08:00", "close": "17:00"},
"wednesday": {"open": "08:00", "close": "17:00"},
"thursday": {"open": "08:00", "close": "17:00"},
"friday": {"open": "08:00", "close": "17:00"},
"saturday": null,
"sunday": null
}'::jsonb,
timezone VARCHAR(50) DEFAULT 'America/New_York',
-- Facilities
parking_available BOOLEAN DEFAULT TRUE,
wheelchair_accessible BOOLEAN DEFAULT TRUE,
status VARCHAR(20) DEFAULT 'ACTIVE',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_locations_city_state ON locations(city, state) WHERE deleted_at IS NULL;
CREATE INDEX idx_locations_coords ON locations USING GIST (
ll_to_earth(latitude, longitude)
) WHERE deleted_at IS NULL AND latitude IS NOT NULL AND longitude IS NOT NULL;
-- Provider availability schedules
CREATE TABLE provider_schedules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider_id UUID NOT NULL REFERENCES providers(id) ON DELETE CASCADE,
location_id UUID NOT NULL REFERENCES locations(id),
-- Day of week (0 = Sunday, 6 = Saturday)
day_of_week INTEGER NOT NULL CHECK (day_of_week >= 0 AND day_of_week <= 6),
-- Time blocks
start_time TIME NOT NULL,
end_time TIME NOT NULL,
-- Effective dates
effective_from DATE NOT NULL,
effective_until DATE,
-- Appointment types allowed during this block
allowed_appointment_types UUID[] DEFAULT ARRAY[]::UUID[],
-- Recurrence
is_recurring BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT valid_time_range CHECK (end_time > start_time)
);
CREATE INDEX idx_provider_schedules_provider ON provider_schedules(provider_id);
CREATE INDEX idx_provider_schedules_location ON provider_schedules(location_id);
CREATE INDEX idx_provider_schedules_day ON provider_schedules(day_of_week);
-- Provider schedule exceptions (PTO, holidays, etc.)
CREATE TABLE schedule_exceptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider_id UUID NOT NULL REFERENCES providers(id) ON DELETE CASCADE,
location_id UUID REFERENCES locations(id),
exception_type VARCHAR(50) NOT NULL, -- 'PTO', 'HOLIDAY', 'CONFERENCE', 'EMERGENCY', etc.
start_datetime TIMESTAMP WITH TIME ZONE NOT NULL,
end_datetime TIMESTAMP WITH TIME ZONE NOT NULL,
all_day BOOLEAN DEFAULT FALSE,
reason TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES providers(id),
CONSTRAINT valid_datetime_range CHECK (end_datetime > start_datetime)
);
CREATE INDEX idx_schedule_exceptions_provider ON schedule_exceptions(provider_id);
CREATE INDEX idx_schedule_exceptions_dates ON schedule_exceptions(start_datetime, end_datetime);
-- Appointment types
CREATE TABLE appointment_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
description TEXT,
default_duration_minutes INTEGER NOT NULL,
-- Booking rules
requires_referral BOOLEAN DEFAULT FALSE,
new_patients_allowed BOOLEAN DEFAULT TRUE,
online_booking_allowed BOOLEAN DEFAULT TRUE,
-- Categorization
category VARCHAR(50), -- 'CONSULTATION', 'FOLLOW_UP', 'PROCEDURE', 'SCREENING', etc.
specialty VARCHAR(100),
-- Billing
default_cpt_codes VARCHAR(10)[],
color_code VARCHAR(7) DEFAULT '#3B82F6', -- For calendar display
active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Appointments table (main)
CREATE TABLE appointments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
confirmation_number VARCHAR(20) UNIQUE NOT NULL,
patient_id UUID NOT NULL REFERENCES patients(id),
provider_id UUID NOT NULL REFERENCES providers(id),
location_id UUID NOT NULL REFERENCES locations(id),
appointment_type_id UUID NOT NULL REFERENCES appointment_types(id),
-- Timing
start_time TIMESTAMP WITH TIME ZONE NOT NULL,
end_time TIMESTAMP WITH TIME ZONE NOT NULL,
timezone VARCHAR(50) NOT NULL,
-- Status
status VARCHAR(20) NOT NULL DEFAULT 'BOOKED',
-- Possible statuses: BOOKED, CONFIRMED, CHECKED_IN, IN_PROGRESS, COMPLETED, NO_SHOW, CANCELLED
-- Booking information
booking_source VARCHAR(50) NOT NULL, -- 'PATIENT_ONLINE', 'PATIENT_PHONE', 'STAFF', 'REFERRAL', etc.
booked_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
booked_by UUID, -- Staff member who booked if applicable
-- Patient confirmation
patient_confirmed BOOLEAN DEFAULT FALSE,
confirmed_at TIMESTAMP WITH TIME ZONE,
-- Check-in
checked_in_at TIMESTAMP WITH TIME ZONE,
-- Cancellation
cancelled_at TIMESTAMP WITH TIME ZONE,
cancelled_by UUID, -- Patient or staff ID
cancellation_reason VARCHAR(50),
cancellation_notes TEXT,
-- Clinical information
chief_complaint TEXT,
visit_reason TEXT,
special_instructions TEXT,
-- Reminders sent
reminders_sent JSONB DEFAULT '[]'::jsonb,
-- Waitlist
filled_from_waitlist BOOLEAN DEFAULT FALSE,
waitlist_entry_id UUID,
-- Follow-up
requires_follow_up BOOLEAN DEFAULT FALSE,
follow_up_timeframe VARCHAR(50), -- '2_WEEKS', '1_MONTH', '3_MONTHS', etc.
follow_up_appointment_id UUID REFERENCES appointments(id),
-- HIPAA audit trail
audit_log JSONB DEFAULT '[]'::jsonb,
-- Metadata
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT valid_appointment_time CHECK (end_time > start_time)
);
CREATE INDEX idx_appointments_patient ON appointments(patient_id);
CREATE INDEX idx_appointments_provider ON appointments(provider_id);
CREATE INDEX idx_appointments_location ON appointments(location_id);
CREATE INDEX idx_appointments_start_time ON appointments(start_time);
CREATE INDEX idx_appointments_status ON appointments(status);
CREATE INDEX idx_appointments_confirmation ON appointments(confirmation_number);
-- Waitlist table
CREATE TABLE waitlist (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
patient_id UUID NOT NULL REFERENCES patients(id),
-- Requested appointment details
requested_providers UUID[] NOT NULL, -- Array of acceptable provider IDs
requested_locations UUID[], -- Array of acceptable location IDs
requested_appointment_types UUID[] NOT NULL,
-- Timing preferences
preferred_date_from DATE,
preferred_date_until DATE,
preferred_times_of_day VARCHAR(20)[], -- 'MORNING', 'AFTERNOON', 'EVENING'
preferred_days_of_week INTEGER[], -- 0-6
-- Priority
clinical_priority VARCHAR(20) DEFAULT 'ROUTINE', -- 'URGENT', 'SEMI_URGENT', 'ROUTINE'
urgency_score INTEGER DEFAULT 50, -- 0-100
-- Contact preferences
contact_methods VARCHAR(20)[] DEFAULT ARRAY['SMS', 'EMAIL'], -- How to notify when slot available
-- Status
status VARCHAR(20) DEFAULT 'ACTIVE', -- 'ACTIVE', 'MATCHED', 'EXPIRED', 'CANCELLED'
-- Reason
reason TEXT,
-- Results
matched_appointment_id UUID REFERENCES appointments(id),
matched_at TIMESTAMP WITH TIME ZONE,
-- Expiration
expires_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
cancelled_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_waitlist_patient ON waitlist(patient_id);
CREATE INDEX idx_waitlist_status ON waitlist(status) WHERE status = 'ACTIVE';
CREATE INDEX idx_waitlist_providers ON waitlist USING GIN(requested_providers);
-- Reminder log
CREATE TABLE reminder_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
appointment_id UUID NOT NULL REFERENCES appointments(id) ON DELETE CASCADE,
channel VARCHAR(20) NOT NULL, -- 'SMS', 'EMAIL', 'PHONE', 'PUSH'
timing VARCHAR(20) NOT NULL, -- '7_DAY', '48_HOUR', '24_HOUR', '2_HOUR'
sent_at TIMESTAMP WITH TIME ZONE NOT NULL,
status VARCHAR(20) NOT NULL, -- 'SENT', 'DELIVERED', 'FAILED', 'BOUNCED'
-- External service IDs for tracking
external_message_id VARCHAR(255),
-- Content
message_content TEXT,
-- Delivery confirmation
delivered_at TIMESTAMP WITH TIME ZONE,
opened_at TIMESTAMP WITH TIME ZONE,
clicked_at TIMESTAMP WITH TIME ZONE,
-- Error tracking
error_code VARCHAR(50),
error_message TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_reminder_log_appointment ON reminder_log(appointment_id);
CREATE INDEX idx_reminder_log_status ON reminder_log(status);
This comprehensive database schema provides the foundation for a production-grade scheduling system. However, designing, implementing, and testing these schemas typically requires 2-3 months of development time. With JustCopy.ai, you can deploy production-ready database schemas instantly, with the platformβs AI agents automatically generating migrations, indexes, and seed data for your specific needs.
Part 2: Backend API Implementation
Core Scheduling Service
// Node.js + Express scheduling service
// File: services/scheduling-service.js
const { Pool } = require("pg");
const Redis = require("ioredis");
const { DateTime } = require("luxon");
class SchedulingService {
constructor() {
this.db = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: { rejectUnauthorized: false },
});
this.redis = new Redis(process.env.REDIS_URL);
}
/**
* Find available appointment slots for a provider
*/
async findAvailableSlots(params) {
const {
providerId,
locationId,
appointmentTypeId,
startDate,
endDate,
patientId,
} = params;
// Get appointment type details
const appointmentType = await this.getAppointmentType(appointmentTypeId);
// Get provider schedule
const schedule = await this.getProviderSchedule(
providerId,
locationId,
startDate,
endDate
);
// Get existing appointments
const existingAppointments = await this.getExistingAppointments(
providerId,
startDate,
endDate
);
// Get schedule exceptions (PTO, holidays, etc.)
const exceptions = await this.getScheduleExceptions(
providerId,
startDate,
endDate
);
// Calculate available slots
const slots = this.calculateAvailableSlots(
schedule,
existingAppointments,
exceptions,
appointmentType.default_duration_minutes
);
// Apply booking rules
const filteredSlots = await this.applyBookingRules(
slots,
providerId,
appointmentType,
patientId
);
return filteredSlots;
}
async getProviderSchedule(providerId, locationId, startDate, endDate) {
const query = `
SELECT
ps.*,
l.timezone,
l.hours_of_operation
FROM provider_schedules ps
JOIN locations l ON l.id = ps.location_id
WHERE ps.provider_id = $1
AND ps.location_id = $2
AND ps.effective_from <= $4
AND (ps.effective_until IS NULL OR ps.effective_until >= $3)
AND ps.is_recurring = true
`;
const result = await this.db.query(query, [
providerId,
locationId,
startDate,
endDate,
]);
return result.rows;
}
async getExistingAppointments(providerId, startDate, endDate) {
const query = `
SELECT
id,
start_time,
end_time,
status,
appointment_type_id
FROM appointments
WHERE provider_id = $1
AND start_time >= $2
AND start_time < $3
AND status IN ('BOOKED', 'CONFIRMED', 'CHECKED_IN', 'IN_PROGRESS')
ORDER BY start_time
`;
const result = await this.db.query(query, [providerId, startDate, endDate]);
return result.rows;
}
async getScheduleExceptions(providerId, startDate, endDate) {
const query = `
SELECT *
FROM schedule_exceptions
WHERE provider_id = $1
AND start_datetime < $3
AND end_datetime > $2
`;
const result = await this.db.query(query, [providerId, startDate, endDate]);
return result.rows;
}
calculateAvailableSlots(
schedule,
existingAppointments,
exceptions,
durationMinutes
) {
const slots = [];
for (const scheduleBlock of schedule) {
const dayOfWeek = scheduleBlock.day_of_week;
// Generate slots for each occurrence of this day in the date range
let currentDate = this.getNextDayOfWeek(
new Date(scheduleBlock.effective_from),
dayOfWeek
);
const endDate = scheduleBlock.effective_until
? new Date(scheduleBlock.effective_until)
: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000); // 90 days
while (currentDate <= endDate) {
// Create DateTime objects for this day's schedule
const scheduleStart = DateTime.fromObject({
year: currentDate.getFullYear(),
month: currentDate.getMonth() + 1,
day: currentDate.getDate(),
hour: parseInt(scheduleBlock.start_time.split(":")[0]),
minute: parseInt(scheduleBlock.start_time.split(":")[1]),
zone: scheduleBlock.timezone,
});
const scheduleEnd = DateTime.fromObject({
year: currentDate.getFullYear(),
month: currentDate.getMonth() + 1,
day: currentDate.getDate(),
hour: parseInt(scheduleBlock.end_time.split(":")[0]),
minute: parseInt(scheduleBlock.end_time.split(":")[1]),
zone: scheduleBlock.timezone,
});
// Generate slots for this day
let slotStart = scheduleStart;
while (slotStart.plus({ minutes: durationMinutes }) <= scheduleEnd) {
const slotEnd = slotStart.plus({ minutes: durationMinutes });
// Check if slot conflicts with existing appointment
const hasConflict = existingAppointments.some((apt) => {
const aptStart = DateTime.fromJSDate(new Date(apt.start_time));
const aptEnd = DateTime.fromJSDate(new Date(apt.end_time));
return (
(slotStart >= aptStart && slotStart < aptEnd) ||
(slotEnd > aptStart && slotEnd <= aptEnd) ||
(slotStart <= aptStart && slotEnd >= aptEnd)
);
});
// Check if slot conflicts with exception
const hasException = exceptions.some((exc) => {
const excStart = DateTime.fromJSDate(new Date(exc.start_datetime));
const excEnd = DateTime.fromJSDate(new Date(exc.end_datetime));
return (
(slotStart >= excStart && slotStart < excEnd) ||
(slotEnd > excStart && slotEnd <= excEnd) ||
(slotStart <= excStart && slotEnd >= excEnd)
);
});
if (!hasConflict && !hasException) {
slots.push({
start: slotStart.toJSDate(),
end: slotEnd.toJSDate(),
startISO: slotStart.toISO(),
endISO: slotEnd.toISO(),
timezone: scheduleBlock.timezone,
duration: durationMinutes,
});
}
// Move to next slot (with buffer)
slotStart = slotEnd.plus({ minutes: 5 }); // 5-minute buffer
}
// Move to next week
currentDate.setDate(currentDate.getDate() + 7);
}
}
return slots;
}
async applyBookingRules(slots, providerId, appointmentType, patientId) {
const provider = await this.getProvider(providerId);
const patient = patientId ? await this.getPatient(patientId) : null;
const now = DateTime.now();
return slots.filter((slot) => {
const slotTime = DateTime.fromJSDate(slot.start);
// Minimum advance booking time
const hoursInAdvance = slotTime.diff(now, "hours").hours;
if (hoursInAdvance < provider.min_advance_booking_hours) {
return false;
}
// Maximum advance booking time
const daysInAdvance = slotTime.diff(now, "days").days;
if (daysInAdvance > provider.max_advance_booking_days) {
return false;
}
// New patient rules
if (patient && patient.total_appointments === 0) {
if (!appointmentType.new_patients_allowed) {
return false;
}
if (!provider.accepts_new_patients) {
return false;
}
}
// Online booking allowed
if (!appointmentType.online_booking_allowed) {
return false;
}
return true;
});
}
/**
* Book an appointment
*/
async bookAppointment(appointmentData) {
const {
patientId,
providerId,
locationId,
appointmentTypeId,
startTime,
endTime,
timezone,
chiefComplaint,
visitReason,
bookingSource,
bookedBy,
} = appointmentData;
// Begin transaction
const client = await this.db.connect();
try {
await client.query("BEGIN");
// Double-check slot availability (prevent race conditions)
const slotAvailable = await this.verifySlotAvailability(
providerId,
startTime,
endTime,
client
);
if (!slotAvailable) {
throw new Error("SLOT_NO_LONGER_AVAILABLE");
}
// Generate confirmation number
const confirmationNumber = this.generateConfirmationNumber();
// Create appointment
const insertQuery = `
INSERT INTO appointments (
confirmation_number,
patient_id,
provider_id,
location_id,
appointment_type_id,
start_time,
end_time,
timezone,
status,
booking_source,
booked_by,
chief_complaint,
visit_reason,
audit_log
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, 'BOOKED', $9, $10, $11, $12,
jsonb_build_array(
jsonb_build_object(
'action', 'CREATED',
'timestamp', NOW(),
'performed_by', $2,
'details', jsonb_build_object(
'booking_source', $9
)
)
)
)
RETURNING *
`;
const result = await client.query(insertQuery, [
confirmationNumber,
patientId,
providerId,
locationId,
appointmentTypeId,
startTime,
endTime,
timezone,
bookingSource,
bookedBy || patientId,
chiefComplaint,
visitReason,
]);
const appointment = result.rows[0];
// Update patient statistics
await client.query(
`
UPDATE patients
SET total_appointments = total_appointments + 1,
last_appointment_at = $2,
updated_at = NOW()
WHERE id = $1
`,
[patientId, startTime]
);
await client.query("COMMIT");
// Schedule reminders (async, outside transaction)
setImmediate(() => {
this.scheduleReminders(appointment.id).catch(console.error);
});
// Send confirmation (async)
setImmediate(() => {
this.sendBookingConfirmation(appointment.id).catch(console.error);
});
// Clear relevant caches
await this.clearAvailabilityCache(providerId, startTime);
return appointment;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
async verifySlotAvailability(providerId, startTime, endTime, client = null) {
const db = client || this.db;
const query = `
SELECT COUNT(*) as conflict_count
FROM appointments
WHERE provider_id = $1
AND status IN ('BOOKED', 'CONFIRMED', 'CHECKED_IN', 'IN_PROGRESS')
AND (
(start_time >= $2 AND start_time < $3) OR
(end_time > $2 AND end_time <= $3) OR
(start_time <= $2 AND end_time >= $3)
)
`;
const result = await db.query(query, [providerId, startTime, endTime]);
return parseInt(result.rows[0].conflict_count) === 0;
}
generateConfirmationNumber() {
// Generate format: APT-YYYYMMDD-XXXXX
const date = DateTime.now().toFormat("yyyyLLdd");
const random = Math.floor(10000 + Math.random() * 90000);
return `APT-${date}-${random}`;
}
async scheduleReminders(appointmentId) {
// This will be implemented in the reminder service section
// Schedule 7-day, 48-hour, 24-hour, and 2-hour reminders
}
async sendBookingConfirmation(appointmentId) {
// This will be implemented in the notification section
}
async clearAvailabilityCache(providerId, date) {
const dateKey = DateTime.fromJSDate(new Date(date)).toFormat("yyyy-LL-dd");
const cacheKey = `availability:${providerId}:${dateKey}`;
await this.redis.del(cacheKey);
}
getNextDayOfWeek(date, targetDay) {
const currentDay = date.getDay();
const daysUntilTarget = (targetDay - currentDay + 7) % 7;
const result = new Date(date);
result.setDate(date.getDate() + daysUntilTarget);
return result;
}
async getProvider(providerId) {
const query = "SELECT * FROM providers WHERE id = $1";
const result = await this.db.query(query, [providerId]);
return result.rows[0];
}
async getPatient(patientId) {
const query = "SELECT * FROM patients WHERE id = $1";
const result = await this.db.query(query, [patientId]);
return result.rows[0];
}
async getAppointmentType(appointmentTypeId) {
const query = "SELECT * FROM appointment_types WHERE id = $1";
const result = await this.db.query(query, [appointmentTypeId]);
return result.rows[0];
}
}
module.exports = SchedulingService;
This backend scheduling service handles the core logic for finding available slots and booking appointments. Building, testing, and debugging this service typically requires 1-2 months of development time. With JustCopy.ai, you get production-ready scheduling services immediately, with the platformβs AI agents automatically customizing the logic for your specific business rules and operational workflows.
Part 3: Calendar Integration
Google Calendar, Outlook, and iCal Support
// Calendar integration service
// File: services/calendar-service.js
const { google } = require("googleapis");
const ical = require("ical-generator");
const { DateTime } = require("luxon");
class CalendarService {
/**
* Generate iCal file for email attachments
*/
generateICalFile(appointment) {
const calendar = ical({
name: "Medical Appointment",
timezone: appointment.timezone,
});
calendar.createEvent({
start: DateTime.fromJSDate(new Date(appointment.start_time)),
end: DateTime.fromJSDate(new Date(appointment.end_time)),
summary: `Medical Appointment - ${appointment.provider_name}`,
description: this.generateAppointmentDescription(appointment),
location: appointment.location_address,
organizer: {
name: appointment.provider_name,
email: appointment.provider_email,
},
attendees: [
{
name: appointment.patient_name,
email: appointment.patient_email,
rsvp: true,
},
],
alarms: [
{
type: "display",
trigger: 60 * 24, // 24 hours before
},
{
type: "display",
trigger: 60 * 2, // 2 hours before
},
],
url: `${process.env.APP_URL}/appointments/${appointment.id}`,
status: "CONFIRMED",
});
return calendar.toString();
}
/**
* Generate Google Calendar link
*/
generateGoogleCalendarLink(appointment) {
const start = DateTime.fromJSDate(
new Date(appointment.start_time)
).toFormat("yyyyLLdd'T'HHmmss");
const end = DateTime.fromJSDate(new Date(appointment.end_time)).toFormat(
"yyyyLLdd'T'HHmmss"
);
const params = new URLSearchParams({
action: "TEMPLATE",
text: `Medical Appointment - ${appointment.provider_name}`,
dates: `${start}/${end}`,
details: this.generateAppointmentDescription(appointment),
location: appointment.location_address,
ctz: appointment.timezone,
});
return `https://calendar.google.com/calendar/render?${params.toString()}`;
}
/**
* Generate Outlook calendar link
*/
generateOutlookCalendarLink(appointment) {
const start = DateTime.fromJSDate(new Date(appointment.start_time)).toISO();
const end = DateTime.fromJSDate(new Date(appointment.end_time)).toISO();
const params = new URLSearchParams({
path: "/calendar/action/compose",
rru: "addevent",
subject: `Medical Appointment - ${appointment.provider_name}`,
startdt: start,
enddt: end,
body: this.generateAppointmentDescription(appointment),
location: appointment.location_address,
});
return `https://outlook.live.com/calendar/0/deeplink/compose?${params.toString()}`;
}
generateAppointmentDescription(appointment) {
return `
Appointment Details:
Provider: ${appointment.provider_name}
Date: ${DateTime.fromJSDate(new Date(appointment.start_time)).toFormat("DDDD")}
Time: ${DateTime.fromJSDate(new Date(appointment.start_time)).toFormat(
"t ZZZZ"
)}
Location:
${appointment.location_name}
${appointment.location_address}
Phone: ${appointment.location_phone}
Confirmation Number: ${appointment.confirmation_number}
Please arrive 15 minutes early for check-in.
Bring your insurance card and photo ID.
To reschedule or cancel:
${process.env.APP_URL}/appointments/${appointment.id}
`.trim();
}
/**
* Sync appointment to provider's Google Calendar
*/
async syncToProviderGoogleCalendar(appointmentId) {
const appointment = await this.getAppointmentDetails(appointmentId);
const provider = await this.getProvider(appointment.provider_id);
if (!provider.google_calendar_id || !provider.google_refresh_token) {
console.log("Provider does not have Google Calendar connected");
return null;
}
// Initialize Google Calendar API
const oauth2Client = new google.auth.OAuth2(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
process.env.GOOGLE_REDIRECT_URI
);
oauth2Client.setCredentials({
refresh_token: provider.google_refresh_token,
});
const calendar = google.calendar({ version: "v3", auth: oauth2Client });
// Create event
const event = {
summary: `Patient: ${appointment.patient_name}`,
description: `
Appointment Type: ${appointment.appointment_type_name}
Patient: ${appointment.patient_name}
Phone: ${appointment.patient_phone}
Chief Complaint: ${appointment.chief_complaint || "Not specified"}
Confirmation: ${appointment.confirmation_number}
`.trim(),
location: appointment.location_address,
start: {
dateTime: new Date(appointment.start_time).toISOString(),
timeZone: appointment.timezone,
},
end: {
dateTime: new Date(appointment.end_time).toISOString(),
timeZone: appointment.timezone,
},
reminders: {
useDefault: false,
overrides: [{ method: "popup", minutes: 15 }],
},
colorId: "9", // Blue for appointments
extendedProperties: {
private: {
appointmentId: appointmentId,
system: "scheduling-platform",
},
},
};
try {
const response = await calendar.events.insert({
calendarId: provider.google_calendar_id,
resource: event,
});
// Store the Google Calendar event ID for future updates/cancellations
await this.saveCalendarEventMapping(
appointmentId,
"GOOGLE",
response.data.id
);
return response.data;
} catch (error) {
console.error("Error syncing to Google Calendar:", error);
throw error;
}
}
}
module.exports = CalendarService;
Implementing robust calendar integration across multiple platforms is complex and time-consuming. JustCopy.ai provides production-ready calendar integration code that its AI agents automatically customize for your specific calendar requirements, including bidirectional sync, conflict resolution, and error handling.
Part 4: Automated Reminders System
SMS and Email Reminder Implementation
// Reminder service with Twilio and SendGrid
// File: services/reminder-service.js
const twilio = require("twilio");
const sendgrid = require("@sendgrid/mail");
const { DateTime } = require("luxon");
class ReminderService {
constructor() {
this.twilioClient = twilio(
process.env.TWILIO_ACCOUNT_SID,
process.env.TWILIO_AUTH_TOKEN
);
sendgrid.setApiKey(process.env.SENDGRID_API_KEY);
}
/**
* Schedule all reminders for an appointment
*/
async scheduleAllReminders(appointmentId) {
const appointment = await this.getAppointmentDetails(appointmentId);
const patient = await this.getPatient(appointment.patient_id);
// Define reminder schedule
const reminderSchedule = [
{ timing: "7_DAY", days: 7, channels: ["EMAIL"] },
{ timing: "48_HOUR", hours: 48, channels: ["SMS", "EMAIL"] },
{ timing: "24_HOUR", hours: 24, channels: ["SMS"] },
{ timing: "2_HOUR", hours: 2, channels: ["SMS"] },
];
for (const reminder of reminderSchedule) {
const sendTime = DateTime.fromJSDate(
new Date(appointment.start_time)
).minus({
days: reminder.days || 0,
hours: reminder.hours || 0,
});
// Only schedule if in the future
if (sendTime > DateTime.now()) {
await this.scheduleReminder({
appointmentId: appointmentId,
patientId: patient.id,
timing: reminder.timing,
channels: reminder.channels,
sendAt: sendTime.toJSDate(),
});
}
}
}
async scheduleReminder(reminderData) {
// In production, use a job queue like Bull or AWS SQS
// For this example, we'll use a simple database-based approach
const query = `
INSERT INTO scheduled_reminders (
appointment_id,
patient_id,
timing,
channels,
send_at,
status
) VALUES ($1, $2, $3, $4, $5, 'PENDING')
`;
await this.db.query(query, [
reminderData.appointmentId,
reminderData.patientId,
reminderData.timing,
reminderData.channels,
reminderData.sendAt,
]);
}
/**
* Send SMS reminder
*/
async sendSMSReminder(appointment, timing) {
const patient = await this.getPatient(appointment.patient_id);
// Verify consent
if (!patient.consent_to_sms) {
console.log("Patient has not consented to SMS");
return null;
}
const message = this.generateSMSMessage(appointment, timing);
try {
const result = await this.twilioClient.messages.create({
to: patient.phone,
from: process.env.TWILIO_PHONE_NUMBER,
body: message,
statusCallback: `${process.env.API_URL}/webhooks/sms-status`,
});
// Log reminder
await this.logReminder({
appointmentId: appointment.id,
channel: "SMS",
timing: timing,
status: "SENT",
externalMessageId: result.sid,
messageContent: message,
});
return result;
} catch (error) {
// Log failed reminder
await this.logReminder({
appointmentId: appointment.id,
channel: "SMS",
timing: timing,
status: "FAILED",
errorCode: error.code,
errorMessage: error.message,
});
throw error;
}
}
generateSMSMessage(appointment, timing) {
const appointmentTime = DateTime.fromJSDate(
new Date(appointment.start_time)
).setZone(appointment.timezone);
const confirmLink = `${process.env.APP_URL}/confirm/${appointment.confirmation_number}`;
switch (timing) {
case "7_DAY":
return `Reminder: You have an appointment with ${
appointment.provider_name
} on ${appointmentTime.toFormat(
"EEE, MMM d"
)} at ${appointmentTime.toFormat("h:mm a")}. Confirm: ${confirmLink}`;
case "48_HOUR":
return `Upcoming appointment: ${appointmentTime.toFormat(
"EEE, MMM d"
)} at ${appointmentTime.toFormat("h:mm a")} with ${
appointment.provider_name
}. Reply C to confirm, R to reschedule. ${confirmLink}`;
case "24_HOUR":
return `Tomorrow at ${appointmentTime.toFormat(
"h:mm a"
)}: Appointment with ${appointment.provider_name} at ${
appointment.location_name
}. ${confirmLink}`;
case "2_HOUR":
return `Your appointment is today at ${appointmentTime.toFormat(
"h:mm a"
)}. ${appointment.location_name}, ${
appointment.location_address
}. See you soon!`;
default:
return `Appointment reminder: ${appointmentTime.toFormat(
"EEE, MMM d"
)} at ${appointmentTime.toFormat("h:mm a")}. ${confirmLink}`;
}
}
/**
* Send email reminder
*/
async sendEmailReminder(appointment, timing) {
const patient = await this.getPatient(appointment.patient_id);
if (!patient.consent_to_email) {
console.log("Patient has not consented to email");
return null;
}
const emailContent = this.generateEmailContent(appointment, timing);
// Generate calendar file
const calendarService = new CalendarService();
const icsContent = calendarService.generateICalFile(appointment);
const msg = {
to: patient.email,
from: {
email: process.env.SENDGRID_FROM_EMAIL,
name: process.env.PRACTICE_NAME,
},
subject: emailContent.subject,
html: emailContent.html,
attachments: [
{
content: Buffer.from(icsContent).toString("base64"),
filename: "appointment.ics",
type: "text/calendar",
disposition: "attachment",
},
],
trackingSettings: {
clickTracking: { enable: true },
openTracking: { enable: true },
},
customArgs: {
appointmentId: appointment.id,
timing: timing,
},
};
try {
const result = await sendgrid.send(msg);
// Log reminder
await this.logReminder({
appointmentId: appointment.id,
channel: "EMAIL",
timing: timing,
status: "SENT",
externalMessageId: result[0].headers["x-message-id"],
messageContent: emailContent.html,
});
return result;
} catch (error) {
// Log failed reminder
await this.logReminder({
appointmentId: appointment.id,
channel: "EMAIL",
timing: timing,
status: "FAILED",
errorCode: error.code,
errorMessage: error.message,
});
throw error;
}
}
generateEmailContent(appointment, timing) {
const appointmentTime = DateTime.fromJSDate(
new Date(appointment.start_time)
).setZone(appointment.timezone);
const confirmLink = `${process.env.APP_URL}/confirm/${appointment.confirmation_number}`;
const rescheduleLink = `${process.env.APP_URL}/reschedule/${appointment.confirmation_number}`;
const cancelLink = `${process.env.APP_URL}/cancel/${appointment.confirmation_number}`;
const calendarService = new CalendarService();
const googleLink = calendarService.generateGoogleCalendarLink(appointment);
const outlookLink =
calendarService.generateOutlookCalendarLink(appointment);
let subject, greeting;
switch (timing) {
case "7_DAY":
subject = `Upcoming Appointment - ${appointmentTime.toFormat("MMM d")}`;
greeting = "Your appointment is coming up next week.";
break;
case "48_HOUR":
subject = `Appointment in 2 Days - ${appointmentTime.toFormat(
"EEE, h:mm a"
)}`;
greeting = "This is a reminder that your appointment is in 2 days.";
break;
case "24_HOUR":
subject = `Tomorrow: Appointment at ${appointmentTime.toFormat(
"h:mm a"
)}`;
greeting = "Your appointment is tomorrow!";
break;
case "2_HOUR":
subject = `Appointment Today at ${appointmentTime.toFormat("h:mm a")}`;
greeting = "Your appointment is in 2 hours.";
break;
}
const html = `
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #0066cc;
color: white;
padding: 20px;
text-align: center;
}
.content {
background-color: #f9f9f9;
padding: 30px;
border-radius: 5px;
}
.appointment-details {
background-color: white;
padding: 20px;
margin: 20px 0;
border-left: 4px solid #0066cc;
}
.button {
display: inline-block;
padding: 12px 24px;
margin: 10px 5px;
background-color: #0066cc;
color: white;
text-decoration: none;
border-radius: 5px;
}
.button-secondary {
background-color: #6c757d;
}
.calendar-links {
margin: 20px 0;
}
.calendar-links a {
margin-right: 15px;
color: #0066cc;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>Appointment Reminder</h2>
</div>
<div class="content">
<p><strong>${greeting}</strong></p>
<div class="appointment-details">
<h3>Appointment Details</h3>
<p><strong>Date:</strong> ${appointmentTime.toFormat(
"EEEE, MMMM d, yyyy"
)}</p>
<p><strong>Time:</strong> ${appointmentTime.toFormat(
"h:mm a ZZZZ"
)}</p>
<p><strong>Provider:</strong> ${appointment.provider_name}</p>
<p><strong>Location:</strong><br>
${appointment.location_name}<br>
${appointment.location_address}</p>
<p><strong>Phone:</strong> ${appointment.location_phone}</p>
<p><strong>Confirmation #:</strong> ${
appointment.confirmation_number
}</p>
</div>
<div style="text-align: center;">
<a href="${confirmLink}" class="button">Confirm Appointment</a>
<a href="${rescheduleLink}" class="button button-secondary">Reschedule</a>
</div>
<div class="calendar-links">
<p><strong>Add to Calendar:</strong></p>
<a href="${googleLink}">Google Calendar</a> |
<a href="${outlookLink}">Outlook</a>
<p><em>Or use the attached .ics file for other calendar applications.</em></p>
</div>
<div style="margin-top: 30px; padding: 15px; background-color: #fff3cd; border-radius: 5px;">
<h4>Before Your Visit:</h4>
<ul>
<li>Arrive 15 minutes early for check-in</li>
<li>Bring your insurance card and photo ID</li>
<li>Bring a list of current medications</li>
<li>Complete any pre-visit forms online (if available)</li>
</ul>
</div>
<div style="text-align: center; margin-top: 20px;">
<p><small>Need to cancel? <a href="${cancelLink}">Click here</a></small></p>
</div>
</div>
</div>
</body>
</html>
`;
return { subject, html };
}
async logReminder(reminderData) {
const query = `
INSERT INTO reminder_log (
appointment_id,
channel,
timing,
sent_at,
status,
external_message_id,
message_content,
error_code,
error_message
) VALUES ($1, $2, $3, NOW(), $4, $5, $6, $7, $8)
`;
await this.db.query(query, [
reminderData.appointmentId,
reminderData.channel,
reminderData.timing,
reminderData.status,
reminderData.externalMessageId || null,
reminderData.messageContent || null,
reminderData.errorCode || null,
reminderData.errorMessage || null,
]);
}
}
module.exports = ReminderService;
Building a reliable, HIPAA-compliant reminder system with multi-channel support is complex. JustCopy.ai provides production-ready reminder services with built-in retry logic, delivery tracking, and compliance features, all customizable through its AI agents.
Due to length constraints, Iβll summarize the remaining sections. The complete implementation would continue withβ¦
Part 5: Time Zone Handling
- Luxon library for timezone conversions
- Storing all times in UTC in database
- Converting to patient/provider timezones for display
- Handling daylight saving time transitions
Part 6: Waitlist Management
- Automated matching algorithm
- Multi-factor scoring (urgency, wait time, preferences)
- Real-time notifications when slots open
- Time-limited offers (30-minute claim window)
Part 7: HIPAA Compliance
- Data encryption (at rest and in transit)
- Access controls and audit logging
- Business Associate Agreements
- Secure API authentication (JWT with refresh tokens)
- PHI handling best practices
Part 8: EHR Integration
- FHIR API integration (Epic, Cerner)
- Athenahealth REST API
- Bidirectional sync
- Error handling and reconciliation
Conclusion
Building a production-ready appointment scheduling system requires significant expertise across backend development, frontend engineering, database design, security, compliance, and healthcare workflows. The traditional approach requires:
Time: 6-12 months Cost: $150K-$500K Team: Backend developers, frontend developers, DevOps engineers, QA testers, security specialists
Alternative: Use JustCopy.ai
Instead of building from scratch, JustCopy.ai lets you copy a proven, production-ready appointment scheduling platform and customize it for your specific needs in 2-4 weeks. The platformβs 10 specialized AI agents handle:
- Code Generation Agent: Customizes the codebase for your workflows
- Testing Agent: Generates comprehensive test suites
- Security Agent: Implements HIPAA compliance and encryption
- Integration Agent: Connects to your EHR, calendar, and communication systems
- Database Agent: Optimizes schemas and queries
- API Agent: Creates RESTful APIs with proper authentication
- Frontend Agent: Generates responsive, accessible UI components
- DevOps Agent: Sets up CI/CD pipelines and deployment automation
- Monitoring Agent: Implements logging, alerting, and performance tracking
- Documentation Agent: Creates technical documentation and user guides
All code is production-ready, fully tested, HIPAA-compliant, and deployable to your infrastructure.
Start building your appointment scheduling system today with JustCopy.aiβcopy, customize, and deploy in weeks instead of months.
Build This with JustCopy.ai
Skip months of development with 10 specialized AI agents. JustCopy.ai can copy, customize, and deploy this application instantly. Our AI agents write code, run tests, handle deployment, and monitor your applicationβall following healthcare industry best practices and HIPAA compliance standards.