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

Как устроена запись к мастеру

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

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

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

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

Отделите услугу, интервал и запись человека

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

Какие данные нужны для записи к одному мастеру
СущностьПример полейЗачем нужна
services — услугиid, name, duration_minutes, is_activeНазвания и длительность доступных услуг
slots — интервалыid, service_id, starts_at, ends_atЗаранее подготовленное время для услуги
bookings — записиid, slot_id, contact, status, created_atОбращение человека к конкретному интервалу

Связать эти сущности можно через их идентификаторы. В интервале хранится service_id, по которому приложение находит услугу, а в записи посетителя есть slot_id, ведущий к выбранному времени. Так название услуги не приходится переписывать во всех записях. Правда, поле с подходящим названием само по себе ничего не гарантирует: сервер должен проверить, что указанная услуга или интервал существуют и относятся к нужной студии. В реляционной базе существование связанной записи обычно дополнительно защищают внешним ключом.

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

У нашей схемы есть понятная опора: один мастер работает с одним человеком в каждом заранее заданном часовом интервале. Если услуга станет длиться полтора часа, ограничение по одному slot_id уже не спасёт от пересечения соседних записей. То же произойдёт, если кто-то создаст второй интервал на те же 15:00. При расширении сервиса придётся учитывать конкретного мастера, продолжительность визита и вместимость, а не только проверять, свободна ли одна строка в расписании.

Выбирайте тип поля по смыслу, который оно должно сохранить

Когда с сущностями стало понятнее, можно выбирать типы полей. Здесь полезно думать о том, что вы собираетесь делать со значением. Телефон, например, похож на число, но складывать два телефонных номера мы не будем. Зато нам нужно сохранить плюс в начале и возможные ведущие нули, поэтому номер хранится как текст. Длительность услуги удобнее задать числом минут и проверить, что оно положительное. Для названия можно сразу предусмотреть обязательное заполнение и ограничение длины, чтобы пустая строка или случайно вставленная страница текста не становились обычной услугой.

Со статусами лучше договориться заранее. Пока их вводят свободным текстом, один экран сохраняет «Подтверждено», другой — «подтверждена», а администратор пишет «ОК». Человек всё это поймёт, но фильтр по одному статусу уже пропустит часть записей. Поэтому внутри приложения используются устойчивые значения, например pending, confirmed и cancelled, а на экране им соответствуют понятные подписи. Тогда название кнопки можно поменять, не переписывая историю и условия фильтрации.

С датами стоит задержаться чуть дольше. Встреча назначена на конкретный момент, но человек видит время в каком-то часовом поясе. Если студия работает по Москве, зафиксируйте Europe/Moscow и покажите эту зону при записи: настройки телефона посетителя из другого города не должны незаметно сдвинуть обещанный час. В PostgreSQL для хранения момента подходит timestamptz, который хранит время через UTC. При этом имя исходного часового пояса в нём не сохраняется, поэтому правило показа расписания или отдельное поле для зоны нужно предусмотреть самим.

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

Войти в приложение и получить доступ к записи — разные проверки

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

Кому доступны услуги, расписание и записи
УчастникЧитаетМеняет
ПосетительАктивные услуги и доступное время без чужих контактовОтправляет свою заявку через серверное действие
Администратор студииЗаписи и контакты своей студииПодтверждает или отменяет запись в пределах своих прав
Посторонний пользовательТолько общедоступную информациюНе изменяет чужие записи

Применять эти правила нужно на сервере при каждом обращении к данным. Значение is_admin или owner_id, присланное из браузера, может изменить сам пользователь, поэтому сервер опирается на проверенный механизм входа и свои сведения о разрешениях. Он определяет, кто обращается, к какой записи и какое действие собирается выполнить. Кнопку удаления можно спрятать для удобства интерфейса, но запретить само удаление должен сервер: запрос к нему можно отправить и без кнопки.

Ещё один момент легко пропустить при проверке экрана. Допустим, публичный календарь показывает только свободное время, но сервер отправляет ему весь объект записи вместе с телефоном клиента. На странице телефона нет, однако в ответе запроса он уже доступен. Поэтому для календаря лучше подготовить отдельный набор публичных полей и убедиться, что лишние сведения не уходят в браузер. Если потом появится личный кабинет посетителя, доступ к его собственным записям нужно будет настроить отдельно.

Один человек нажал дважды: обрабатываем повтор

С правами разобрались, теперь вернёмся к самой отправке формы. Посетитель нажал «Записаться», некоторое время посмотрел на загрузку и нажал ещё раз. Возможно, первый запрос уже сохранил запись, а до браузера просто не дошёл ответ. С точки зрения человека он всё ещё пытается записаться один раз, хотя сервер получил два запроса. Нам нужно сохранить именно этот смысл действия, иначе в кабинете администратора появятся два одинаковых обращения.

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

Посмотреть, как такой договор между клиентом и сервером устроен на практике, можно в документации Stripe. Там описаны идемпотентные запросы и условия работы ключей. При реализации своего сервиса нужно выяснить, поддерживает ли выбранный API такой механизм, сколько времени хранятся результаты и что происходит с одновременными запросами. Два обработчика могут оба успеть прочитать «этого ключа ещё нет», а затем создать две записи. Поэтому в серверной реализации важен сам способ обработки повтора, а не только наличие поля с ключом.

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

Два человека выбрали один интервал: разбираемся с конкуренцией

Теперь немного изменим ситуацию. На 15:00 одновременно записываются два разных человека, каждый со своим телефоном и своим ключом действия. Оба действительно хотят прийти, поэтому система идемпотентности правильно считает их запросы разными. Чтобы мастер не получил двух посетителей, здесь нужна другая проверка: право занять выбранный интервал должно достаться только одному из них.

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

Если вы работаете с собственной PostgreSQL-базой, для нашей схемы можно использовать частичный уникальный индекс. Предположим, таблица bookings уже создана, slot_id обязателен и связан внешним ключом с расписанием, а status тоже обязателен и принимает только согласованные значения. Индекс ниже разрешает одну активную запись на интервал, оставляя в таблице историю отменённых обращений. Это синтаксис PostgreSQL; способ настройки Agnostic Data разберём в следующем разделе.

Пример ограничения в собственной PostgreSQL-базе
CREATE UNIQUE INDEX one_active_booking_per_slot
ON bookings (slot_id)
WHERE status IN ('pending', 'confirmed');

С таким индексом две конкурирующие вставки не смогут сохранить активные записи с одинаковым slot_id. Серверу останется обработать конфликт и объяснить второму посетителю, что время уже занято и можно выбрать другое. Условие по статусам здесь тоже имеет смысл: если задать UNIQUE(slot_id) для всех строк без исключения, старая отменённая запись помешает записаться следующему человеку. Частичный индекс позволяет сохранить историю и одновременно освободить время после отмены.

В этом варианте статус pending уже занимает интервал, хотя администратор ещё не подтвердил визит. Если вы хотите освобождать неподтверждённую запись через определённое время, нужно договориться о сроке и добавить серверный процесс, который изменит её статус. Индекс сам не следит за часами. Аналогично нужно согласовать запись данных и подтверждение посетителю: при сбое между двумя действиями человек не должен получить обещание визита, для которого место так и не закрепили.

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

Как использовать эту схему в Agnostic Data

Теперь перенесём схему в Agnostic. Выберите проект и окружение, откройте Data и посмотрите, какие коллекции уже существуют. Затем подготовьте услуги, интервалы и записи, задав типы и правила для их полей. Для первых проверок удобно использовать контакты, которые контролируете сами. Серверный код обращается к подготовленным ресурсам через текущий Runtime SDK, а токен доступа хранится на сервере. Так форма и кабинет работают с одними данными, не передавая служебные ключи посетителю.

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

При выборе защиты от двойной записи учитывайте возможности хранилища. Текущие управляемые индексы Agnostic Data не поддерживают уникальность и не объединяют несколько записей в атомарную операцию, поэтому приведённый SQL нельзя использовать здесь как готовую настройку. Последовательные вызовы SDK тоже не дают такой гарантии. Если подходящий серверный механизм ещё не реализован и не проверен, можно начать с формы заявки: посетитель выбирает желаемое время, а администратор подтверждает его после проверки расписания. Тогда текст на сайте должен объяснять, что обращение принято и подтверждение ещё предстоит.

Задача на проектирование данных в Agnostic
Спроектируй сервис записи в Agnostic.

Начни с чтения контекста. Назови выбранные проект, окружение
и доступные действия. Во внешнем редакторе проверь Agnostic MCP.
Если его нет, помоги выбрать проект и окружение, получить
инструкцию из меню MCP и покажи план подключения.
Продолжи после авторизации в браузере.
Инструкция: https://agn0.ru/blog/connect-cursor/.

Сначала только проектирование, без изменений ресурсов.
Одна студия, один мастер, часовые непересекающиеся интервалы.
Учти существующие коллекции. Предложи услуги, интервалы
и записи с типами, обязательностью, связями и статусами.
Покажи права посетителя и администратора.

Вход пользователей — через App Auth Agnostic.
Права и принадлежность записей проверяет сервер приложения.
Для будущей реализации используй текущий Runtime SDK на сервере;
runtime-токен не передавай браузеру.
Если потребуется код, работай в workspace выбранного проекта,
полученном через Agnostic CLI; помоги получить его при отсутствии.

Раздельно объясни обработку повтора одного действия
и конкуренцию двух посетителей за одно время.
Проверь реальные гарантии доступного хранилища и сервера.
Не считай обычный индекс уникальным и не обещай атомарность
нескольких записей. Если гарантии не подтверждены, предложи
заявку с ручным подтверждением вместо мгновенного бронирования.
Верни схему, ограничения, план и сценарии проверки
в выбранном окружении Agnostic.

После согласования схемы и реализации пройдите весь путь в одной тестовой среде: отправьте заявку как посетитель и найдите её в кабинете администратора после обновления страницы. Если запись есть в Data, но приложение её не показывает, сначала сравните выбранные проект и окружение, затем фильтры и права пользователя. Так вы проследите, где именно оборвался путь обращения. Переносить данные между средами наугад в этот момент только мешает разбираться.

Проверка модели до реальной записи клиентов0 из 7

Частые вопросы

Обязательно ли разбивать данные на несколько коллекций?

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

Идемпотентность защитит от двух записей на одно время?

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

Можно использовать SQL из статьи в Agnostic Data?

Нет, пример частичного уникального индекса относится к собственной PostgreSQL-базе. Управляемые индексы Agnostic Data имеют другой контракт и не поддерживают уникальные индексы. Реализацию резервирования нужно проектировать по фактически доступным гарантиям.

Почему запись есть в Preview, но отсутствует в Live?

Данные относятся к окружению, в котором с ними работали. Проверьте выбранную среду в Data и настройки серверного подключения приложения. Публикация кода не означает автоматический перенос тестовых клиентских записей в Live.

Подключите данные к форме

Посмотрите, как форма передаёт заявку серверу и как администратор находит её в своём списке.

Перейти к примеру

Источники