One-on-One Video Consultation System Development
Video consultations are not just a video call. This is a complete system: specialist schedule, time slot booking, reminders, waiting room, the call itself, optional recording, consultation history. You need a cohesive user journey from "choose time" to "end session."
System Architecture
Client booking → Appointment DB → Reminder queue → Video session → Recording → Notes
↓ ↓ ↓
Calendar UI Email/SMS (BullMQ) LiveKit/Daily
↓
Availability slots
(specialist schedule)
Data Model
CREATE TABLE specialists (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id),
name VARCHAR(255),
specialty VARCHAR(100),
timezone VARCHAR(100) DEFAULT 'Europe/Moscow',
session_duration_minutes INTEGER DEFAULT 50
);
CREATE TABLE specialist_schedules (
id UUID PRIMARY KEY,
specialist_id UUID REFERENCES specialists(id),
day_of_week SMALLINT NOT NULL, -- 0=Mon, 6=Sun
start_time TIME NOT NULL,
end_time TIME NOT NULL,
is_active BOOLEAN DEFAULT true
);
CREATE TABLE appointments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
specialist_id UUID REFERENCES specialists(id),
client_id UUID REFERENCES users(id),
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL,
status VARCHAR(50) DEFAULT 'scheduled',
-- 'scheduled' | 'confirmed' | 'in_progress' | 'completed' | 'cancelled' | 'no_show'
video_room_id VARCHAR(255),
recording_url TEXT,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
Availability Slots
async function getAvailableSlots(
specialistId: string,
date: Date
): Promise<{ start: Date; end: Date }[]> {
const specialist = await db.specialists.findById(specialistId);
const dayOfWeek = date.getDay(); // 0=Sun in JS, need to adjust to 0=Mon
// Get specialist's work hours for this day
const schedule = await db.specialistSchedules.findByDayAndSpecialist(
specialistId,
dayOfWeek
);
if (!schedule) return [];
// Booked slots
const existing = await db.appointments.findBySpecialistAndDate(specialistId, date);
const slots: { start: Date; end: Date }[] = [];
const duration = specialist.session_duration_minutes;
let current = setTimeOnDate(date, schedule.start_time, specialist.timezone);
const end = setTimeOnDate(date, schedule.end_time, specialist.timezone);
while (current < end) {
const slotEnd = addMinutes(current, duration);
// Check if slot is available
const isBusy = existing.some(
apt => current < apt.ends_at && slotEnd > apt.starts_at
);
if (!isBusy && slotEnd <= end) {
slots.push({ start: new Date(current), end: new Date(slotEnd) });
}
current = addMinutes(current, duration);
}
return slots;
}
Booking and Confirmation
app.post('/api/appointments', authenticate, async (req, res) => {
const { specialistId, startsAt } = req.body;
const specialist = await db.specialists.findById(specialistId);
const startsAtDate = new Date(startsAt);
const endsAt = addMinutes(startsAtDate, specialist.session_duration_minutes);
// Check availability with pessimistic locking
const appointment = await db.transaction(async (trx) => {
const conflict = await trx.query(
`SELECT id FROM appointments
WHERE specialist_id = $1
AND status NOT IN ('cancelled')
AND starts_at < $2 AND ends_at > $3
FOR UPDATE`,
[specialistId, endsAt, startsAtDate]
);
if (conflict.rows.length > 0) {
throw new Error('Slot already taken');
}
return trx.query(
`INSERT INTO appointments (specialist_id, client_id, starts_at, ends_at)
VALUES ($1, $2, $3, $4) RETURNING *`,
[specialistId, req.user.id, startsAtDate, endsAt]
);
});
// Schedule reminders
await scheduleReminders(appointment.rows[0]);
// Notify specialist
await sendNewAppointmentNotification(specialist, appointment.rows[0], req.user);
res.json(appointment.rows[0]);
});
Reminders
async function scheduleReminders(appointment: Appointment) {
const queue = new Queue('reminders', { connection });
// 24 hours before
await queue.add('reminder-24h', { appointmentId: appointment.id }, {
delay: new Date(appointment.starts_at).getTime() - Date.now() - 24 * 60 * 60 * 1000,
jobId: `reminder-24h-${appointment.id}`,
});
// 1 hour before
await queue.add('reminder-1h', { appointmentId: appointment.id }, {
delay: new Date(appointment.starts_at).getTime() - Date.now() - 60 * 60 * 1000,
jobId: `reminder-1h-${appointment.id}`,
});
// 15 minutes before—with room link
await queue.add('reminder-15m', { appointmentId: appointment.id }, {
delay: new Date(appointment.starts_at).getTime() - Date.now() - 15 * 60 * 1000,
jobId: `reminder-15m-${appointment.id}`,
});
}
Opening Video Room
app.post('/api/appointments/:id/join', authenticate, async (req, res) => {
const appointment = await db.appointments.findById(req.params.id);
if (!appointment) return res.status(404).json({ error: 'Not found' });
// Check this user is a participant
const isParticipant =
appointment.client_id === req.user.id ||
appointment.specialist_user_id === req.user.id;
if (!isParticipant) return res.status(403).json({ error: 'Forbidden' });
// Create video room if doesn't exist
if (!appointment.video_room_id) {
const room = await livekit.createRoom({
name: `consultation-${appointment.id}`,
maxParticipants: 2,
emptyTimeout: 300, // close after 5 min if empty
});
await db.appointments.update(appointment.id, { video_room_id: room.name });
await db.appointments.update(appointment.id, { status: 'in_progress' });
}
const isHost = appointment.specialist_user_id === req.user.id;
const token = await generateLiveKitToken(
appointment.video_room_id,
req.user.id,
{ canPublish: true, canSubscribe: true, roomAdmin: isHost }
);
res.json({ token, roomName: appointment.video_room_id });
});
Frontend: Waiting Room and Call
function AppointmentRoom({ appointmentId }: { appointmentId: string }) {
const [status, setStatus] = useState<'waiting' | 'in_call' | 'ended'>('waiting');
const [token, setToken] = useState<string | null>(null);
const { appointment, timeUntilStart } = useAppointment(appointmentId);
const joinCall = async () => {
const { token: t, roomName } = await fetch(
`/api/appointments/${appointmentId}/join`,
{ method: 'POST' }
).then(r => r.json());
setToken(t);
setStatus('in_call');
};
if (status === 'waiting') {
return (
<div className="text-center py-16">
<CountdownTimer seconds={timeUntilStart} />
<p className="text-gray-600 mt-4">Specialist: {appointment.specialistName}</p>
{timeUntilStart <= 300 && ( // show button 5 min before
<button onClick={joinCall} className="btn-primary mt-6">
Enter Room
</button>
)}
</div>
);
}
if (status === 'in_call' && token) {
return (
<LiveKitRoom
token={token}
serverUrl={process.env.NEXT_PUBLIC_LIVEKIT_URL}
onDisconnected={() => setStatus('ended')}
>
<VideoConferenceUI />
</LiveKitRoom>
);
}
return <ConsultationSummary appointmentId={appointmentId} />;
}
Timeline
Complete video consultation system: schedule, booking, reminders, video call, history—2–3 weeks.







