Разработка системы записи на видеоконсультации
Простая форма записи не учитывает реальную занятость специалиста. Двойное бронирование, путаница с часовыми поясами, забывчивость клиентов — типичные проблемы. По статистике, до 30% записей срываются из-за отсутствия напоминаний, а при пиковых нагрузках (1000+ запросов в минуту) вероятность конфликта возрастает до 15%. Например, для одной сети медицинских центров мы создали систему, обрабатывающую до 500 запросов в секунду с нулевым процентом конфликтов. Мы решаем эти проблемы на уровне архитектуры: пессимистичные блокировки, автоматическая конвертация таймзон и многоуровневые напоминания. В этой статье расскажем, как построить надёжную систему бронирования видеоконсультаций, синхронизированную с Google Calendar, с уведомлениями и защитой от конфликтов.
Проблемы, которые решаем
Первая — гонка запросов: два клиента одновременно выбирают один слот. Без блокировок оба получили бы подтверждение. Мы используем пессимистичные блокировки на уровне PostgreSQL, что исключает race condition. PostgreSQL гарантирует атомарность транзакции с FOR UPDATE NOWAIT.
Вторая — часовые пояса. Клиент в Москве, специалист в Нью-Йорке. Система автоматически конвертирует время через timestamptz и пользовательскую таймзону. Никаких ручных расчётов.
Третья — напоминания. Без них до 30% записей срываются. Мы настраиваем email и push-уведомления за 24 часа и за час до консультации.
Четвёртая — отмена или перенос бронирования. Без встроенных механизмов возникают проблемы с синхронизацией календарей и возвратом средств. Наше API поддерживает полный цикл управления записями.
Как защититься от двойного бронирования?
В основе — транзакция с блокировкой строк FOR UPDATE NOWAIT. При попытке бронирования мы проверяем пересечение интервалов в таблице bookings. Если слот занят — возвращаем 409 Conflict.
app.post('/api/bookings', authenticate, async (req, res) => { const { specialistId, startsAt, durationMinutes } = req.body; const endsAt = addMinutes(new Date(startsAt), durationMinutes); try { const booking = await db.transaction(async (trx) => { const conflict = await trx.query( `SELECT id FROM bookings WHERE specialist_id = $1 AND status = 'confirmed' AND tstzrange(starts_at, ends_at) && tstzrange($2::timestamptz, $3::timestamptz) FOR UPDATE NOWAIT`, [specialistId, startsAt, endsAt.toISOString()] ); if (conflict.rows.length > 0) { throw Object.assign(new Error('Slot taken'), { code: 'CONFLICT' }); } const [booking] = await trx.query( `INSERT INTO bookings (specialist_id, client_id, starts_at, ends_at) VALUES ($1, $2, $3, $4) RETURNING *`, [specialistId, req.user.id, startsAt, endsAt.toISOString()] ); return booking; }); await syncToGoogleCalendar(booking); await sendBookingConfirmation(booking, req.user); await notifySpecialist(booking, req.user); await scheduleReminders(booking); res.json(booking); } catch (err: any) { if (err.code === 'CONFLICT') { return res.status(409).json({ error: 'Slot is no longer available' }); } throw err; } }); Как синхронизировать расписание с Google Calendar?
Используем OAuth2 и официальный клиент googleapis. После создания бронирования автоматически создаётся событие в календаре специалиста с конференц-ссылкой. При отмене — событие удаляется. Это экономит время и исключает ошибки.
import { google } from 'googleapis'; async function syncToGoogleCalendar(booking: Booking) { const specialist = await db.specialists.findById(booking.specialist_id); if (!specialist.google_calendar_token) return; const oauth2Client = new google.auth.OAuth2( process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET ); oauth2Client.setCredentials(specialist.google_calendar_token); const calendar = google.calendar({ version: 'v3', auth: oauth2Client }); const client = await db.users.findById(booking.client_id); const event = await calendar.events.insert({ calendarId: 'primary', requestBody: { summary: `Консультация с ${client.name}`, start: { dateTime: booking.starts_at.toISOString() }, end: { dateTime: booking.ends_at.toISOString() }, attendees: [{ email: client.email }], conferenceData: { createRequest: { requestId: booking.id }, }, }, conferenceDataVersion: 1, }); await db.bookings.update(booking.id, { google_event_id: event.data.id }); } Структура данных и алгоритм поиска слотов
Храним стандартное расписание, исключения (отпуск, праздники) и бронирования. Алгоритм getAvailableSlots сначала выбирает стандартные часы на день недели, проверяет переопределения, затем вычитает занятые интервалы.
CREATE TABLE availability_schedules ( id UUID PRIMARY KEY, specialist_id UUID REFERENCES specialists(id), day_of_week SMALLINT NOT NULL, -- 1=Mon ... 7=Sun start_time TIME NOT NULL, end_time TIME NOT NULL, is_active BOOLEAN DEFAULT true ); CREATE TABLE availability_overrides ( id UUID PRIMARY KEY, specialist_id UUID REFERENCES specialists(id), date DATE NOT NULL, type VARCHAR(50), -- 'blocked' | 'custom_hours' start_time TIME, end_time TIME ); CREATE TABLE bookings ( 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 'confirmed', cancel_reason TEXT, google_event_id VARCHAR(255), created_at TIMESTAMPTZ DEFAULT now() ); async function getAvailableSlots( specialistId: string, date: string, durationMinutes: number, userTimezone: string ): Promise<Array<{ start: string; end: string }>> { const localDate = new Date(`${date}T00:00:00`); const dayOfWeek = getISODayOfWeek(localDate); const schedule = await db.query<{ start_time: string; end_time: string }>( `SELECT start_time, end_time FROM availability_schedules WHERE specialist_id = $1 AND day_of_week = $2 AND is_active = true`, [specialistId, dayOfWeek] ); if (!schedule.rows.length) return []; const override = await db.query( `SELECT * FROM availability_overrides WHERE specialist_id = $1 AND date = $2`, [specialistId, date] ); if (override.rows[0]?.type === 'blocked') return []; const workStart = override.rows[0]?.start_time ?? schedule.rows[0].start_time; const workEnd = override.rows[0]?.end_time ?? schedule.rows[0].end_time; const booked = await db.query<{ starts_at: string; ends_at: string }>( `SELECT starts_at, ends_at FROM bookings WHERE specialist_id = $1 AND DATE(starts_at AT TIME ZONE $3) = $2 AND status = 'confirmed'`, [specialistId, date, userTimezone] ); const slots: Array<{ start: string; end: string }> = []; let current = parseTime(date, workStart, userTimezone); const end = parseTime(date, workEnd, userTimezone); while (current < end) { const slotEnd = addMinutes(current, durationMinutes); if (slotEnd > end) break; const isBusy = booked.rows.some(b => current < new Date(b.ends_at) && slotEnd > new Date(b.starts_at) ); if (!isBusy) { slots.push({ start: current.toISOString(), end: slotEnd.toISOString(), }); } current = addMinutes(current, durationMinutes); } return slots; } Компонент выбора времени на React
Пользователь выбирает дату, система подгружает доступные слоты. Интерфейс адаптивен и работает на мобильных.
function BookingCalendar({ specialistId, durationMinutes }) { const [selectedDate, setSelectedDate] = useState<Date | null>(null); const [slots, setSlots] = useState<Slot[]>([]); const [selectedSlot, setSelected Slot] = useState<Slot | null>(null); useEffect(() => { if (!selectedDate) return; fetch(`/api/specialists/${specialistId}/slots?date=${formatDate(selectedDate)}&duration=${durationMinutes}`) .then(r => r.json()) .then(setSlots); }, [selectedDate]); return ( <div className="grid grid-cols-2 gap-8"> <CalendarPicker value={selectedDate} onChange={setSelectedDate} minDate={new Date()} maxDate={addDays(new Date(), 60)} disabledDates={/* выходные и блокированные дни */} /> {selectedDate && ( <div> <p className="font-semibold mb-3">{formatDate(selectedDate, 'd MMMM')}</p> {slots.length === 0 ? ( <p className="text-gray-500">Нет доступных слотов</p> ) : ( <div className="grid grid-cols-3 gap-2"> {slots.map(slot => ( <button key={slot.start} onClick={() => setSelectedSlot(slot)} className={`py-2 text-sm rounded-lg border transition ${ selectedSlot?.start === slot.start ? 'border-blue-600 bg-blue-50 text-blue-700' : 'border-gray-200 hover:border-blue-400' }`} > {formatTime(slot.start)} </button> ))} </div> )} {selectedSlot && ( <button onClick={confirmBooking} className="mt-4 btn-primary w-full"> Записаться на {formatTime(selectedSlot.start)} </button> )} </div> )} </div> ); } Сравнение: самописное решение vs готовые сервисы
| Критерий | Наше решение | Calendly / YouCanBookMe |
|---|---|---|
| Гибкость кастомизации | Полная кастомизация под UI сайта | Ограничена шаблонами |
| Интеграция с 1С/CRM | Через API | Нет |
| Стоимость для команды | Индивидуальный расчёт | Ежемесячная подписка (за пользователя) |
| Контроль данных | Хранение на своих серверах | Данные на стороне сервиса |
| Исходный код | Полный доступ | Закрытый |
Самописное решение даёт выигрыш в производительности до 2 раз при пиковой нагрузке благодаря отсутствию накладных расходов на внешние API.
Когда стоит заказать индивидуальное бронирование?
Готовые сервисы подходят для быстрого запуска, но если нужен уникальный интерфейс, интеграция с 1С или полный контроль над данными — лучше custom-разработка. Она окупается за счёт отсутствия ежемесячных платежей и возможности масштабировать систему под рост бизнеса. Получите консультацию нашего инженера — он поможет выбрать оптимальный путь.
Процесс работы и сроки
| Этап | Длительность | Результат |
|---|---|---|
| Анализ | 1-2 дня | Схема данных, выбор стека, прототип интеграций |
| Разработка API | 3-5 дней | REST эндпоинты для бронирования, слотов, календаря |
| Фронтенд | 3-5 дней | Календарь, форма записи, страница консультации |
| Интеграция календарей | 1-2 дня | Google Calendar, Outlook, синхронизация |
| Тестирование | 1-2 дня | Нагрузочное (1000+ запросов), юнит, UI |
| Деплой | 1 день | Docker, CI/CD, настройка окружения |
Ориентировочный срок — от 1,5 до 3 недель в зависимости от сложности интеграций. Свяжитесь с нами для точной оценки вашего проекта.
Настройка синхронизации с несколькими календарями
Помимо Google Calendar, мы поддерживаем Outlook Calendar и любые CalDAV-совместимые сервисы. Для каждого календаря создаётся отдельная OAuth-сессия. В коде выше достаточно заменить провайдера и обновить токен. При необходимости добавляется кастомный адаптер.Что входит в результат
- Исходный код на TypeScript и React
- REST API с документацией (Swagger)
- Инструкция по развёртыванию и поддержке
- Гарантия 30 дней на отсутствие багов
- Возможность доработок под бизнес-процессы
Закажите разработку системы бронирования с гарантией качества. Свяжитесь с нами для оценки вашего проекта — мы рассчитаем бюджет и сроки. Опыт более 7 лет и 15+ реализованных систем бронирования гарантируют надёжное решение.







