Введите минимум 3 символа для поиска

·14 мин чтения

Свой календарь записи на Laravel: как сделать и когда хватит Calendly

A restaurant reservation book open on a marble stand, two names circled in red and a fountain pen beside it, a maître d' behind, with the headline Booking That Follows Your Rules, not a widget's

Для The Structure, нашего бренда технического due diligence, нужны были звонки, которые сначала проходят проверку, платные встречи, за которые деньги берутся только после одобрения, и анкеты, которые видит один человек. Вместо того чтобы собирать сервис записи, его платные тарифы и плагины, мы сделали свой календарь на Laravel с Google Calendar и Google Meet. Рассказываем, что у него внутри, где обычно спотыкаются и когда готовый сервис всё же лучше.

Почему своё

Почему не Calendly

Вопрос задал Евгений, наш основатель, когда мы уже собирались подключать очередной сервис записи: зачем платить за сервис, потом за его старший тариф, потом за дополнения, если всё это может жить внутри нашего собственного приложения на Laravel? Календарь записи состоит из нескольких таблиц в базе, страницы со свободным временем, связи с Google и набора правил. А правила мы пишем каждый день для клиентов.

The Structure нужен был не просто список свободных часов. Звонков два вида. Бесплатный 30-минутный знакомственный звонок с Евгением о сделке. И платный strategic options review с Валерией Чумаченко, нашим Head of Advisory, у которого свой маршрут:

  • клиент выбирает время и заполняет анкету о компании;
  • Валерия читает её и проверяет конфликт интересов, прежде чем что-то подтверждать;
  • только после этого клиент получает ссылку на оплату со сроком;
  • после оплаты звонок появляется в обоих календарях со ссылкой на Google Meet;
  • после первого звонка можно предложить второй, затем идёт отчёт.

Каждый шаг по отдельности где-то в готовых сервисах есть. Все вместе, в нашем порядке, с нашими правилами и с ответами на нашем сервере, без обходных путей ни в один из них не помещались. Поэтому мы сделали своё. Первая рабочая версия, с подключённым Google и живой проверкой, была готова в ту же неделю, с той же дисциплиной проверки кода, что и на клиентских проектах.

Когда сервис лучше

Не стройте, если это про вас

Честное начало: большинству компаний строить свой календарь не нужно. Calendly, Cal.com и похожие сервисы хорошие продукты. Если звонки принимает один человек, подходит любой свободный слот, оплаты нет или она берётся сразу, а чужой бренд в окне записи вас не смущает, сервис заработает за час и обойдётся дешевле любой разработки.

Свой календарь начинает окупаться, когда запись становится частью процесса, а не всем процессом. Вот признаки, на которые мы смотрим:

  • заявку нужно проверить до подтверждения (подходит ли клиент, нет ли конфликта, соответствует ли условиям);
  • оплата идёт после одобрения, а не при записи, или зависит от вида встречи;
  • записываются к нескольким людям, у каждого свои часы, правила и свои читатели анкет;
  • ответы в анкете чувствительные и должны оставаться на вашем сервере, видимые одному человеку;
  • запись должна жить внутри вашей CRM, клиентского портала или сайта, в вашем дизайне и вашей модели данных.

Записать на звонок легко. Записать так, как работает ваш бизнес, сложнее.

Что мы сделали

Календарь The Structure

Снаружи это спокойная страница. Посетитель видит месяц, выбирает день и получает свободное время в своём часовом поясе: браузер определяет его сам, а поменять можно вручную. После выбора времени человек заполняет короткую форму и получает письмо с подтверждением и личной ссылкой, по которой сам переносит или отменяет звонок. Ни аккаунта, ни пароля не нужно.

Страница записи The Structure: ведущий и звонок слева, месяц в центре, свободное время на выбранный день в часовом поясе посетителя справа
Публичная часть: месяц, свободное время в часовом поясе посетителя, затем короткая форма.

Внутри, в админке того же приложения на Laravel, каждая заявка открывается карточкой: кто, когда в обоих часовых поясах, анкета, следующий шаг одной кнопкой, история всего, что произошло, и заметка для команды. У каждого ведущего своя страница расписания: окна по дням недели, выходные, длительность звонка, паузы между встречами, минимальный срок записи и кнопка, чтобы подключить свой аккаунт Google.

Заявка на звонок в админке: статус, время для ведущего и клиента, данные клиента, кнопка следующего шага и история
Админка: одна карточка на заявку, следующий шаг всегда одна кнопка.

Клиент не видит механики: статусов, таймеров, проверки на конфликт. Он видит время, подтверждение и ссылку на Meet, которая приходит, когда звонок подтверждён.

Часть Google

Google Calendar и Meet по шагам

На этом месте большинство теряет полдня, поэтому по порядку. Нужны проект в Google Cloud, экран согласия OAuth и OAuth-клиент. Для собственного использования платный тариф Google не требуется.

  1. Создайте проект в консоли Google Cloud. Дайте ему понятное имя, например «Company booking». Держите его отдельно от проектов с другими ключами, например доступом к Search Console: разные задачи, разные риски.
  2. Включите Google Calendar API в этом проекте.
  3. Заполните экран согласия (Google Auth Platform, раздел Branding): название приложения, почта поддержки, главная страница, ссылки на политику конфиденциальности и условия использования. Без них Google не даст опубликовать приложение, так что эти страницы должны уже быть на сайте.
  4. Выберите аудиторию. Для личного аккаунта Gmail это External.
  5. Запрашивайте самые узкие права. Мы используем два права на календарь: одно чтобы создавать и удалять события, второе чтобы видеть занятое время (free/busy). Содержимое чужих событий мы не читаем.
  6. Опубликуйте приложение. Пока оно в режиме Testing, Google выдаёт refresh-токены, которые истекают через 7 дней, и связь с календарём умирает через неделю после настройки. Умирает молча: страница записи продолжает принимать заявки, просто перестают появляться события со ссылками на Meet, и первым это замечает клиент, которому не пришло приглашение. В режиме Production токен живёт. Поэтому наша система не ждёт этого клиента: когда календарь ведущего перестаёт отвечать, команда получает письмо с ответом Google и инструкцией, как переподключиться, не чаще раза в 12 часов, пока проблема не решена, а ошибка остаётся на странице расписания в админке.
  7. Создайте OAuth-клиент типа Web application и добавьте адрес возврата ровно в том виде, в каком его отправляет приложение, вплоть до слэша в конце.
  8. Положите client ID и secret в окружение сервера (.env в Laravel), а не в код, репозиторий или окно чата. Если конфигурация кешируется, после изменения пересоберите кеш.

Дальше каждый ведущий нажимает в админке «Подключить Google» и входит своим аккаунтом. Приложение хранит refresh-токен для каждого ведущего и по нему получает короткоживущие токены доступа. Google покажет предупреждение, что приложение не проверено: права на календарь считаются чувствительными. Для своих сотрудников это допустимо. Непроверенное приложение ограничено 100 новыми пользователями, поэтому продукт, который подключает календари клиентов, должен пройти проверку Google.

Когда звонок подтверждён, приложение создаёт событие в календаре ведущего, добавляет клиента гостем и просит Google прикрепить конференцию Meet. Приглашение Google отправляет сам, так что клиент получает обычное приглашение в календарь со ссылкой на Meet. Если звонок переносят, событие переезжает; если отменяют, событие удаляется и гость получает уведомление.

В коде это один запрос. Параметр, который легко пропустить, это conferenceDataVersion=1: без него Google без всякой ошибки игнорирует createRequest, и событие возвращается без ссылки на Meet. А sendUpdates=all заставляет Google отправить гостю приглашение.

// the event, the client as a guest and a Meet link in one request
$response = Http::withToken($accessToken)->post(
    'https://www.googleapis.com/calendar/v3/calendars/primary/events'
    .'?conferenceDataVersion=1&sendUpdates=all',
    [
        'summary' => 'Intro call · '.$booking->name,
        'start' => ['dateTime' => $booking->starts_at->toIso8601ZuluString()],
        'end' => ['dateTime' => $booking->ends_at->toIso8601ZuluString()],
        'attendees' => [['email' => $booking->email, 'displayName' => $booking->name]],
        'conferenceData' => ['createRequest' => [
            'requestId' => $booking->token.'-'.time(),
            'conferenceSolutionKey' => ['type' => 'hangoutsMeet'],
        ]],
    ]
);

$meetUrl = $response->json('hangoutLink');

Свободное время

Слоты, часовые пояса и ловушки

Показать свободное время кажется простой задачей до первого перевода часов. Вот правила, к которым мы пришли.

Всё хранится в UTC, окна задаются в часовом поясе ведущего, а клиент видит свой. Часы Валерии записаны как вторник и четверг, с 9:00 до 12:00 по тихоокеанскому времени. Клиент в Дубае и клиент в Лондоне видят те же слоты, переведённые на свои часы. Перевод идёт через базу часовых поясов IANA средствами самого PHP, а не через фиксированное смещение: смещения меняются дважды в год, и не в одни и те же даты в разных странах. В Дубае часы не переводят вовсе, а в США перевели в ноябре, и наутро после этого вручную прописанное смещение сдвинуло бы все слоты на час.

Остальные правила оказались не менее важны:

  • Несколько окон в день. В настоящем рабочем дне есть перерывы: с 6:00 до 9:00, с 11:00 до 14:00 и с 16:00 до 18:00 это три окна, а не одно, и ведущий задаёт их сам на странице расписания. Звонок должен целиком помещаться в одно окно, поэтому при часовых звонках последний утренний слот 8:00, а не 8:30 с заходом на перерыв. Наспех сгенерированные календари часто позволяют один диапазон на день или проверяют только время начала и записывают звонок прямо через перерыв.
  • Паузы до и после каждого звонка, чтобы встречи не шли вплотную.
  • Минимальный срок: платный звонок можно записать не раньше чем за 48 часов, потому что анкету нужно прочитать, а оплату провести. Для бесплатного знакомства хватает 24 часов.
  • Горизонт: не дальше трёх недель вперёд, чтобы календарь не обещал время, о котором никто ещё не думал.
  • Недельный лимит на первые платные звонки, чтобы неделя советника не заполнялась новыми проверками.
  • Выходные на праздники, которые можно править в админке.
  • Занятое время из Google. Перед тем как показать слоты и ещё раз перед сохранением заявки приложение спрашивает у Google занятые периоды ведущего. Визит к стоматологу, внесённый в календарь на телефоне, убирает эти слоты с сайта.

Классическая ошибка самодельных календарей: двойная запись. Два человека открыли страницу, оба видят свободные 10:00, оба нажали. Мы сохраняем заявку внутри транзакции базы данных, которая в этот момент ещё раз проверяет слот, и второй человек получает вежливую просьбу выбрать другое время.

Вот ядро этой защиты в том виде, в каком оно работает в The Structure на SQLite. Первая версия использовала режим транзакций Laravel по умолчанию, DEFERRED, и тест с тремя заявками на одно время в одну и ту же секунду показал подвох: двойная запись ни разу не прошла, но проигравшие заявки получали страницу ошибки «database is locked» вместо вежливого сообщения. В режиме IMMEDIATE транзакция берёт блокировку на запись сразу при старте, заявки выстраиваются в очередь, и вторая видит, что слот занят. Пять раундов того же теста после исправления: каждый раз одна запись и два вежливых отказа. На MySQL или PostgreSQL та же идея делается блокировкой строки, lockForUpdate() на строке ведущего, перед проверкой.

// config/database.php, the sqlite connection
'busy_timeout' => 5000,
'transaction_mode' => 'IMMEDIATE',

// saving a request
$booking = DB::transaction(function () use ($data, $kind, $start) {
    // checked again inside the lock: our own bookings and Google busy time
    if (! Slots::isFree($kind, $start)) {
        throw new SlotTaken;
    }

    return Booking::create([
        ...$data,
        'kind' => $kind,
        'status' => 'requested',
        'starts_at' => $start,
    ]);
});

Храните UTC, думайте в поясе ведущего, показывайте пояс клиента.

Жизнь заявки

Заявка, одобрение, подтверждение

Запись не одна строка с датой. Она проходит через статусы, и у каждого статуса есть ответственный и срок:

СтатусЧто значитЧто дальше
ЗаявкаКлиент выбрал время и отправил анкетуВедущий читает и одобряет или отклоняет
ОдобренаПлатный звонок прошёл проверкуКлиент получает ссылку на оплату, действует 24 часа
ПодтвержденаОплачено или бесплатный звонок одобренСобытие в Google с Meet, напоминания перед звонком
Проведена или пропущенаЗвонок состоялся или клиент не пришёлИтоги, предложение второго звонка, отчёт
Истекла, отмененаНе рассмотрели, не оплатили или отменилиВремя возвращается в календарь

Сроки соблюдает команда по расписанию, которая запускается каждые 15 минут. Она освобождает заявки, которые никто не рассмотрел за 24 часа до звонка, освобождает неоплаченное время, рассылает напоминания и удаляет старые ответы анкет. Без этого пульса система записи постепенно забивается временем, которое ни свободно, ни занято.

На виртуальном хостинге планировщик преподнёс нам сюрприз. Панель хостинга запускает задания cron без оболочки, поэтому задание, которое начинается с cd, падает с кодом 127. Решение: полные пути к PHP и к файлу artisan и пробный запуск через собственную обёртку cron хостинга, прежде чем ей доверять.

Клиенты управляют записью по личной ссылке из письма. Перенести или отменить звонок можно не позднее чем за 24 часа до начала; платный звонок, отменённый в этот срок, возвращается полностью. Оплата идёт через Stripe Checkout, поэтому данные карты не касаются нашего сервера, а о платеже приложение узнаёт от Stripe через подписанный webhook, а не через браузер клиента.

Данные и доверие

Кто видит ответы

Анкета для платной проверки спрашивает о компании, сделке и людях в ней. Это чувствительные данные, и страница услуги The Structure обещает, что их читает только советник. В коде это обещание превратилось в три правила:

  • ответы видит только ведущий этого звонка; остальные пользователи админки видят статус и время, но не содержание;
  • ответы удаляются через 90 дней после отправки отчёта и через 30 дней после заявки, которая ни к чему не привела;
  • письма команде сообщают, что пришла заявка, и дают ссылку на неё, но самих ответов в письме нет.

Публичные страницы записи не ставят cookies и не держат сессию. Ботов отсеивают скрытое поле, проверка времени заполнения и ограничение частоты запросов, а не головоломка, которую должен решать клиент. Доверие к форме записи решают такие мелочи задолго до того, как кто-то зайдёт в звонок.

Проверка

До первого настоящего клиента

Мы не открывали всё сразу. Пока шли проверки, запись была спрятана за ключом предпросмотра, и у каждого вида звонка свой переключатель: бесплатные знакомственные звонки с Евгением открылись первыми, а платные проверки Валерии остаются в предпросмотре, пока не подключён её календарь и не заработали платежи.

Безопасными проверки сделали два переключателя. Один останавливает всю исходящую почту, но записывает в журнал, что было бы отправлено. Второй, который действует только пока нет платёжных ключей, заменяет Stripe тестовой кнопкой. Так мы прошли весь маршрут: заявка, одобрение, оплата, подтверждение, отмена, не написав настоящему клиенту и не списав денег с карты.

Живая проверка с Google была простой и убедительной: одобрить заявку, увидеть, как в календаре появилось событие со ссылкой на Meet, отменить и увидеть, как оно исчезло. Проверки нашли и настоящую ошибку: после удаления тестовой записи старая вкладка админки всё ещё указывала на неё, и кнопка отмены отдавала 404. Теперь несуществующая запись возвращает администратора к списку.

Тот же движок

Клиники, школы, рестораны

The Structure наш собственный случай, но сам движок для нас не новый. Мы делали модули записи и бронирования для клиник, образовательных площадок и ресторанов, и ядро всегда одно: ресурсы, правила, статусы, уведомления. Меняются ресурс и правила.

  • Клиника записывает к врачу, часто одновременно в кабинет, а иногда и на оборудование, с длительностью процедур, инструкциями по подготовке и правилом, кто может записаться на первый приём онлайн.
  • Образовательная площадка продаёт места в группе, а не время с человеком: вместимость, лист ожидания и оплата до того, как место закреплено.
  • Ресторан бронирует стол на число гостей и время, с паузой между посадками, большие компании подтверждает менеджер, а в загруженные вечера берётся депозит. Как такая система связывает столы, маркет и доставку, мы описали в статье одна система для ресторана, маркета и доставки.

В каждом случае смысл своего решения тот же, что и у нас: запись идёт так, как бизнес уже работает, и попадает в его собственные системы, а не бизнес подстраивается под виджет. Именно это мы имеем в виду, когда говорим о веб-приложениях, построенных вокруг вашего бизнеса.

ИИ и запись

А если навайбкодить

Честно: форму со свободным временем ИИ-инструмент сгенерирует за вечер, и выглядеть она будет нормально. Проблемы сидят в списке выше: часовые пояса при переводе часов, двойная запись, сроки, которые освобождают время, refresh-токены, умирающие через неделю, платежи, подтверждённые через webhook, и ответы, которые должны оставаться закрытыми. В демо ничего из этого не видно, в первый месяц с живыми клиентами видно всё.

Мы сами каждый день работаем с ИИ-агентами, и часть этого календаря тоже написали они. Разница в том, что каждое изменение читал человек, пути, через которые идут деньги и доступы, были проверены тестами, а правила сверены до запуска. О рисках кода, который никто не читал, мы писали в статье о prompt injection в ИИ-агентах для кода. Если ваша команда уже работает на Laravel, этого руководства хватит, чтобы начать. Если хотите, чтобы запись встроили в ваш сайт, CRM или портал, наши разработчики на Laravel сделают это обычным проектом, и код останется вашим.

FAQ

Вопросы о своём календаре записи

Дешевле сделать свой календарь записи, чем платить за Calendly?

Для одного человека с простыми слотами нет: сервис обойдётся дешевле любой разработки. Своё окупается, когда запись становится частью процесса с одобрением, оплатой после проверки, несколькими ведущими или чувствительными анкетами, и когда вы хотите держать её внутри своего сайта или CRM, не оплачивая год за годом тарифы и дополнения.

Нужен ли платный Google Workspace, чтобы создавать ссылки на Google Meet?

Нет. Мы подключили личный аккаунт Google: Calendar API создаёт событие и запрашивает конференцию Meet, а приглашение отправляет сам Google. Нужен проект в Google Cloud с включённым Calendar API, экраном согласия и OAuth-клиентом.

Почему связь с Google Calendar перестала работать через неделю?

Скорее всего, OAuth-приложение всё ещё в режиме Testing. Для внешних приложений в этом режиме Google выдаёт refresh-токены, которые истекают через 7 дней. Опубликуйте приложение в Production и подключите аккаунт заново.

Как не дать двум людям записаться на одно и то же время?

Слот проверяется ещё раз внутри транзакции базы данных в момент сохранения заявки, вместе с занятым временем ведущего из Google. Если кто-то занял его секундой раньше, второго посетителя просят выбрать другое время.

Может ли ИИ написать систему записи за меня?

Хорошую первую версию он напишет быстро. Проверьте её там, где самодельные календари ломаются: часовые пояса и перевод часов, двойная запись, истекающие токены, webhook платежей, сроки и закрытость анкеты. Эти места должен прочитать и проверить тестами человек.

Источники

Запись должна идти по вашему процессу, а не по правилам виджета? Давайте посмотрим, что должен уметь ваш календарь.

Записаться на стратегическую сессию

Разработка веб-приложений и SaaS