Розробка системи видеоконсультацій один на один
Видеоконсультації — це не просто відеозвонок. Це повнофункціональна система: розписання спеціаліста, бронювання слоту, напоминання, комната очікування, сам звонок, запис за бажанням, історія консультацій. Потрібен зв'язний користувацький шлях від «вибрати час» до «завершити сеанс».
Архітектура системи
Клієнтське бронювання → База даних призначень → Чергу напоминань → Відеосеанс → Запис → Примітки
↓ ↓ ↓
Календар UI Email/SMS (BullMQ) LiveKit/Daily
↓
Слоти доступності
(розписання спеціаліста)
Модель даних
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=Пн, 6=Нд
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()
);
Слоти доступності
async function getAvailableSlots(
specialistId: string,
date: Date
): Promise<{ start: Date; end: Date }[]> {
const specialist = await db.specialists.findById(specialistId);
const dayOfWeek = date.getDay(); // 0=Нд у JS, потрібно привести до 0=Пн
// Отримати робочі години спеціаліста на цей день
const schedule = await db.specialistSchedules.findByDayAndSpecialist(
specialistId,
dayOfWeek
);
if (!schedule) return [];
// Заняті слоти
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);
// Перевірити, чи вільний слот
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;
}
Бронювання та підтвердження
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);
// Перевірити доступність з пессимістичною блокуванням
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('Слот вже займають');
}
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]
);
});
// Запланувати напоминання
await scheduleReminders(appointment.rows[0]);
// Сповістити спеціаліста
await sendNewAppointmentNotification(specialist, appointment.rows[0], req.user);
res.json(appointment.rows[0]);
});
Напоминання
async function scheduleReminders(appointment: Appointment) {
const queue = new Queue('reminders', { connection });
// За 24 години
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 годину
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 хвилин — з ссилкою на комнату
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}`,
});
}
Відкриття відеокомнати
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' });
// Перевірити, що цей користувач — учасник консультації
const isParticipant =
appointment.client_id === req.user.id ||
appointment.specialist_user_id === req.user.id;
if (!isParticipant) return res.status(403).json({ error: 'Forbidden' });
// Створити відеокомнату, якщо не існує
if (!appointment.video_room_id) {
const room = await livekit.createRoom({
name: `consultation-${appointment.id}`,
maxParticipants: 2,
emptyTimeout: 300, // закрити через 5 хв якщо пусто
});
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: комната очікування та звонок
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">Спеціаліст: {appointment.specialistName}</p>
{timeUntilStart <= 300 && ( // показати кнопку за 5 хв
<button onClick={joinCall} className="btn-primary mt-6">
Входять в комнату
</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} />;
}
Терміни
Повна система видеоконсультацій: розписання, бронювання, напоминання, відеозвонок, історія — 2–3 тижня.







