Введіть мінімум 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

ОБГОВОРИТИ ПРОЄКТ · ОБГОВОРИТИ ПРОЄКТ ·