Разработка системы записи на видеоконсультации
Система записи — это не просто форма. Нужно отображать реальную занятость специалиста, предотвращать двойное бронирование, учитывать часовые пояса, управлять отменами и переносами, синхронизировать с Google Calendar или Outlook.
Структура доступности
-- Стандартное расписание (повторяющееся)
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',
-- 'confirmed' | 'cancelled' | 'rescheduled' | 'no_show' | 'completed'
cancel_reason TEXT,
google_event_id VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT now()
);
Алгоритм свободных слотов
async function getAvailableSlots(
specialistId: string,
date: string, // YYYY-MM-DD
durationMinutes: number,
userTimezone: string
): Promise<Array<{ start: string; end: string }>> {
const localDate = new Date(`${date}T00:00:00`);
const dayOfWeek = getISODayOfWeek(localDate); // 1-7
// 1. Стандартное расписание на этот день
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 [];
// 2. Проверить переопределения (отпуск, кастомные часы)
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;
// 3. Занятые интервалы
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]
);
// 4. Сгенерировать слоты
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;
}
API бронирования с защитой от двойного бронирования
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) => {
// Пессимистичная блокировка — исключить race condition
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;
});
// Создать событие в Google Calendar
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
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 });
}
Компонент выбора времени
function BookingCalendar({ specialistId, durationMinutes }) {
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const [slots, setSlots] = useState<Slot[]>([]);
const [selectedSlot, setSelectedSlot] = 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>
);
}
Сроки
Система записи с календарём доступности, защитой от двойного бронирования, напоминаниями и синхронизацией с Google Calendar — 1.5–2 недели.







