Реалізація відеоконсультацій (один на один) на сайті

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.

Розробка та обслуговування будь-яких видів сайтів:

Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Реалізація відеоконсультацій (один на один) на сайті
Складна
~2-4 тижні
Часті питання

Наші компетенції:

Етапи розробки

Останні роботи

  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

Розробка системи видеоконсультацій один на один

Видеоконсультації — це не просто відеозвонок. Це повнофункціональна система: розписання спеціаліста, бронювання слоту, напоминання, комната очікування, сам звонок, запис за бажанням, історія консультацій. Потрібен зв'язний користувацький шлях від «вибрати час» до «завершити сеанс».

Архітектура системи

Клієнтське бронювання → База даних призначень → Чергу напоминань → Відеосеанс → Запис → Примітки
      ↓                                ↓                 ↓
  Календар 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 тижня.