Skip to content

ТЗ — Календари (cms/calendars)

Слой: 🟡 функц-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке

Назначение и возможности

Синхронизация событий/записей с внешними календарями (Google, Яндекс) по CalDAV: экспорт слотов и бронирований, публичные iCal-фиды по подписке, двусторонняя синхронизация занятости.

  • Экспорт слотов/бронирований из cms/commerce-services во внешний календарь по CalDAV
  • Импорт занятости из внешнего календаря обратно — блокировка слотов, занятых вне сайта
  • iCal-фиды по подписке (публичная ссылка на календарь занятости/бронирований)
  • Двусторонняя синхронизация: изменение в одном календаре отражается в другом с задержкой опроса
  • Обработка конфликтов занятости (слот забронирован с обеих сторон одновременно)
  • Ротация публичных iCal-токенов подписки (отзыв утёкшей ссылки)

Зависимости и выключение

requires: cms/integrations-bus · suggests: cms/commerce-services

Поведение при выключении: бронирования продолжают создаваться на сайте без синхронизации с внешним календарём — риск двойного бронирования слота растёт, но сайт не падает. Kill-switch автосинхронизации (см. «Настройки») — более мягкая деградация: подключения и история сохраняются, останавливается только фоновый опрос по расписанию, ручной cms:calendars:sync продолжает работать.

Модель данных

ТаблицаКлючевые поляПримечание
cms_calendars_connectionsid, user_id, resource_id (nullable), provider, caldav_url, timezone, access_token_encrypted, refresh_token_encrypted, token_expires_at, is_active, inactive_reason, last_error_code, conflict_priority (nullable, override)подключение внешнего календаря пользователя/ресурса
cms_calendars_sync_logid, connection_id, direction, status, events_count, error_code, synced_atжурнал синхронизации занятости
cms_calendars_ical_subscriptionsid, token, resource_id (nullable), connection_id (nullable), revoked_at, created_atпубличные iCal-токены подписки, ротируемые независимо от подключения

Индексы: FK user_id/connection_id/resource_idconstrained() + index(); provider/direction/status/inactive_reason — PHP Enum; token в cms_calendars_ical_subscriptions — уникальный, не автоинкремент (UUID/подписанное значение, см. «Безопасность»); cms_calendars_sync_log — append-only, BRIN по synced_at.

ПДн-паспорт: модуль хранит ПДн — кто с кем встречается (участники слота через связь с cms/commerce-services) и контактные данные в описании слота, если провайдер их передаёт во VEVENT (SUMMARY/DESCRIPTION/ATTENDEE). Срок хранения: записи журнала синхронизации — 12 месяцев (ретеншн-джоба, см. «Фоновая работа»), сами подключения и их OAuth-токены — до отзыва пользователем или удаления аккаунта. Модуль реализует хуки ядра «выгрузить всё по субъекту» (подключения + записи журнала, где субъект — владелец) и «забыть по запросу» (анонимизация ATTENDEE-полей в журнале, удаление подключения и отзыв токена у провайдера).

Входные и выходные данные

Всё, что не перечислено ниже, модуль отвергает (whitelist-принцип §11 стандарта).

Входы:

ИсточникДанные/поляЧем валидируется
POST /api/v1/admin/calendars/connectionsprovider, caldav_url, timezone, resource_id (nullable)FormRequest: provider — whitelist enabled_providers, caldav_url — формат https, timezone — валидный IANA-идентификатор
OAuth-редирект провайдера (после авторизации пользователя)access_token, refresh_token, expires_inответ провайдера валидируется по схеме OAuth2, токены сохраняются шифрованными полями (не логируются)
CalDAV-ответ внешнего календаря (опрос)VEVENT-записи занятости (DTSTART, DTEND, SUMMARY, ATTENDEE)схема iCalendar (RFC 5545) валидируется парсером; событие с некорректным форматом — пропуск с записью в cms_calendars_sync_log (status=parse_error), не падение всей синхронизации
GET /api/v1/calendars/ical/{token}.icstoken в путитокен ищется в cms_calendars_ical_subscriptions, проверяется revoked_at IS NULL; неизвестный/отозванный токен — 404, не 403 (не подтверждать существование ссылки)
Filament: настройки модуляenabled_providers[], sync_poll_minutes, ical_feed_enabled, conflict_priority, autosync_enabled, max_connections_per_resourceFormRequest, sync_poll_minutes — валидация в границах (см. «Настройки»)

Выходы:

ПотребительДанныеФормат
GET /api/v1/calendars/ical/{token}.icsзанятость/бронирования подпискиiCal (RFC 5545, text/calendar)
Внешний CalDAV-календарь (экспорт)слот/бронирование из cms/commerce-servicesVEVENT по CalDAV-протоколу через cms/integrations-bus
Событие CalendarSynced / CalendarConflictDetectedитог цикла синхронизации / обнаруженный конфликтканал 1, для наблюдаемости и алертов
GET /api/v1/admin/calendars/sync-logжурнал синхронизацииJSON, keyset-пагинация
Booking-виджет (если интегрирован с cms/commerce-services)актуальная занятость слотовданные сервиса модуля, не прямой запрос к CalDAV на каждый рендер

Настройки (группа calendars)

КлючТипДефолтaffectsPageCacheОписание
calendars.enabled_providersarray["google","yandex"]нетРазрешённые провайдеры CalDAV
calendars.sync_poll_minutesint15нетПериодичность двусторонней синхронизации; допустимые границы 560 (ниже — риск упереться в rate-limit провайдера, выше — устаревшая занятость и риск двойного бронирования)
calendars.ical_feed_enabledbooltrueнетРазрешить публичные iCal-фиды по подписке
calendars.conflict_priorityenum(external_wins/site_wins)external_winsнетПравило приоритета при конфликте занятости (см. «Крайние случаи»)
calendars.autosync_enabledbooltrueнетKill-switch: отключить фоновую автосинхронизацию по расписанию без потери подключений; ручной cms:calendars:sync продолжает работать
calendars.max_connections_per_resourceint3нетЛимит числа подключённых календарей на пользователя/ресурс

Секреты приложения (client_id/client_secret провайдера OAuth) — только .envconfig/calendars.php. Токен доступа конкретного подключения (access/refresh token пользователя) — не секрет приложения, а данные конкретной записи: хранится шифрованным полем (encrypted cast) в cms_calendars_connections, не в .env.env физически нельзя разместить токен на каждого пользователя/подключение). ⚠️ Противоречие с исходной формулировкой раздела «Секреты доступа к CalDAV (токены OAuth провайдеров) — только .env»: применимо только к статичным креденшлам приложения (client_id/client_secret), не к пер-подключенческим токенам — формулировка уточнена в этом разделе и в «Безопасности».

API

МетодПутьДоступНазначение
GET/api/v1/calendars/ical/{token}.icsпубличный по токену подпискиiCal-фид занятости/бронирований
POST/api/v1/admin/calendars/connectionsadmin (calendars.manage)Подключение внешнего календаря
DELETE/api/v1/admin/calendars/connections/{id}admin (calendars.manage)Отключение календаря (отзыв, не удаление истории)
POST/api/v1/admin/calendars/connections/{id}/rotate-tokenadmin (calendars.manage)Перевыпуск iCal-токена подписки, инвалидация старой ссылки
GET/api/v1/admin/calendars/sync-logadmin (calendars.view)Журнал синхронизации

Журнал синхронизации — keyset-пагинация, не OFFSET. Idempotency-Key обязателен на POST /connections (повторная отправка формы не создаёт дублирующее подключение).

Компоненты

  • Filament: ресурс подключённых календарей (статус: активно/токен отозван/ошибка синхронизации — цветовая индикация в списке), журнал синхронизации, действие «перевыпустить iCal-ссылку».
  • Виджеты: нет собственных публичных виджетов — booking-виджет принадлежит cms/commerce-services, модуль лишь отдаёт ему данные занятости через сервис.
  • Команды: cms:calendars:sync --json (ручной запуск синхронизации, работает даже при autosync_enabled=false), cms:calendars:rotate-token {connection} --json.
  • Демо-контент: сидер демо-подключения (мок-провайдер) для playground — показывает ресурс подключённых календарей без реальной OAuth-авторизации.

События и обмен

СобытиеКогдаPayload
CalendarSyncedсинхронизация занятости завершенаconnection_id, direction, events_count
CalendarConflictDetectedслот занят одновременно с двух сторонconnection_id, slot_id

Обмен по CalDAV с внешними календарями — через cms/integrations-bus.

Таблица взаимодействий:

Сущность/модульКаналНаправлениеЧто происходит
cms/integrations-busпрямой сервис-вызов (requires, канал 4)модуль → integrations-busвесь CalDAV-трафик (экспорт/импорт) идёт только через шину интеграций
cms/commerce-services (suggests)прямой сервис-вызов, если установленмодуль ↔ commerce-servicesэкспорт слотов/бронирований наружу, импорт занятости блокирует слоты на стороне сервисов
CalendarConflictDetected (событие)канал 1модуль → cms/notifications-bus (если установлен)алерт менеджеру о конфликте занятости с предложенным действием
cms/healthhealth-чек (реестр манифеста)модуль → healthдоступность CalDAV-соединения на подключение, отставание очереди calendars, отношение revoked-подключений
cms/audit (если включён)событиемодуль → auditотзыв подключения, перевыпуск iCal-токена — аудируемые действия

Фоновая работа

Двусторонняя синхронизация — по расписанию (sync_poll_minutes), именованная очередь calendars; не по запросу пользователя. Ретраи с backoff при недоступности CalDAV- сервера, circuit breaker на подключение при систематических ошибках — отличается от единичного сбоя (см. «Крайние случаи»): временная недоступность не переводит подключение в is_active=false, только исчерпание ретраев или 401 от провайдера. Ретеншн-джоба cms:calendars:prune-sync-log (по расписанию) удаляет записи журнала синхронизации старше срока хранения ПДн (12 месяцев) — обязательна, журнальная таблица без политики очистки запрещена стандартом (§9 анти-паттерны).

Производительность и кеш

  • iCal-фид — горячий путь: внешние подписчики (Google/Яндекс/другие клиенты) опрашивают .ics-ссылку с частотой, которую сайт не контролирует (типично раз в 15–60 минут на клиента, но не гарантировано) — эндпоинт обязан отдавать закешированный фид с коротким TTL (тег calendars:ical:{token}, TTL ~5 минут), не бить в БД на каждый внешний опрос; инвалидация — по факту изменения занятости подписки (CalendarSynced/бронирование), не только по TTL.
  • Занятость (для booking-виджета) — без собственного кеша модуля, отдаётся сервисом из актуальных данных (риск устаревшего кеша выше выгоды на этом пути — как и зафиксировано изначально).
  • Ожидаемые объёмы: от единиц до десятков подключений на клиента (max_connections_per_resource ограничивает верх), журнал синхронизации — растущая append-only таблица под ретеншн.
  • Бюджет запросов: sync-джоба — по одному CalDAV-обмену на подключение за цикл (не N+1 по слотам); iCal-фид — 1 запрос к кешу + fallback на 1 запрос к БД на холодный кеш.
  • Индексы — см. «Модель данных»; cms_calendars_sync_log под BRIN по synced_at (журнальная append-only таблица).

Безопасность

  • Синхронизация идемпотентна — повторный опрос не создаёт дублирующие события в календаре.

  • Конфликт занятости фиксируется событием CalendarConflictDetected, не приводит к тихой потере бронирования.

  • iCal-фид по токену не раскрывает данные чужих пользователей — токен скоупится на конкретное подключение/ресурс, проверка scope на каждый запрос; токен — не автоинкремент, а UUID/подписанное значение (переборонепригодность), ротация перевыпускает токен и инвалидирует старую ссылку (сценарий «токен утёк» — «Крайние случаи»).

  • Секреты: статичные креденшлы OAuth-приложения (client_id/client_secret) — только .env; access/refresh-токен конкретного подключения — шифрованное поле в БД (encrypted cast), не попадает в логи, не возвращается в API-ответах (hidden-поле модели).

  • Матрица ролей:

    Permissionадминменеджерредакторstudio
    calendars.view (журнал синхронизации, список подключений, статусы)
    calendars.manage (подключение/отключение календаря, перевыпуск iCal-токена)
    Изменение conflict_priority и других настроек модуля
  • ПДн в журнале синхронизации (участники слота, контакты из ATTENDEE) — доступны только ролям с calendars.view; в общие логи приложения и аналитику не попадают.

UX-требования

Админ:

  • список подключений показывает статус каждого понятным индикатором: «активно» / «токен отозван» (провайдер отключил доступ) / «ошибка синхронизации» (временный сбой CalDAV) — три разных состояния, не один общий «не работает»;
  • конфликт занятости (CalendarConflictDetected) — заметное уведомление в админке с прямым действием (перейти к слоту/бронированию, увидеть обе стороны конфликта), не только строка в журнале;
  • отзыв подключения (DELETE /connections/{id}) — подтверждение необратимого действия перед выполнением;
  • пустое состояние списка подключений — подсказка «подключите первый календарь» со ссылкой на форму, не пустая таблица без объяснения.

Посетитель:

  • если модуль интегрирован с booking-виджетом cms/commerce-services — занятость слотов отражает актуальное состояние без ощутимой задержки восприятия (виджет не показывает слот свободным, если он уже занят на момент опроса виджета, с поправкой на sync_poll_minutes);
  • прямого взаимодействия с модулем calendars у посетителя нет — весь пользовательский интерфейс бронирования принадлежит cms/commerce-services, модуль только источник занятости.

Крайние случаи и типовые баги

  • Конфликт занятости (слот забронирован на сайте И одновременно занят во внешнем календаре, обнаружено при опросе) → правило приоритета — настройка calendars.conflict_priority, дефолт external_wins: внешний календарь считается источником истины (обычно отражает реальную договорённость мастера/менеджера вне сайта), бронирование на сайте отменяется, клиенту и менеджеру уходит алерт с извинением; site_wins — обратный вариант для сайтов, где сайт — единственный канал записи. В любом случае CalendarConflictDetected издаётся и алертит менеджера независимо от выбранного приоритета.
  • CalDAV-токен/OAuth-доступ отозван на стороне провайдера (пользователь отключил доступ в Google/Яндекс) → синхронизация падает с 401 → соединение помечается is_active=false, inactive_reason=token_revoked, алерт владельцу подключения, повторные попытки прекращаются (не долбим впустую), бронирования на сайте продолжают создаваться без синхронизации для этого подключения.
  • Таймзоны → слот в календаре мастера может быть в другой таймзоне, чем клиент, оформляющий запись, и чем сервер: все времена хранятся в UTC + явная timezone ресурса/подключения, отображение клиенту — в его локальной таймзоне (из RequestContext/браузера); ошибка в конвертации ведёт к реальному расхождению по времени — критичный баг, покрывается тестами на несовпадающие TZ сервера/ресурса/ клиента.
  • iCal-фид — приватность и утечка ссылки → публичная ссылка по токену не должна давать возможность перебора (токен — UUID/подписанное значение, не автоинкремент); сценарий «токен утёк» — владелец инициирует ротацию (rotate-token), старая ссылка немедленно возвращает 404, новая выдаётся отдельно.
  • Частота опроса вне разумных границsync_poll_minutes слишком маленький (< 5) — риск упереться в rate-limit провайдера CalDAV (провайдер начинает отвечать 429); слишком большой (> 60) — устаревшая занятость, повышенный риск двойного бронирования; настройка валидируется в этих границах на сохранении.
  • Провайдер CalDAV временно недоступен во время синхронизации → ретраи с backoff, соединение НЕ помечается is_active=false при единичном/временном сбое — отличать по коду ошибки от отозванного токена (401 → revoked, 5xx/timeout → временный сбой, ретраи продолжаются); систематический сбой (исчерпание ретраев) — circuit breaker останавливает попытки до ручного вмешательства, с алертом.
  • Достигнут max_connections_per_resource → создание следующего подключения отклоняется понятной ошибкой в Filament («лимит подключений исчерпан»), не тихим игнорированием.
  • Kill-switch autosync_enabled=false → фоновый опрос по расписанию не выполняется, подключения и токены сохраняются, ручной cms:calendars:sync --json продолжает работать по требованию администратора.
  • Malformed VEVENT во входящем CalDAV-ответе → событие с некорректным форматом пропускается с записью status=parse_error в журнал, не валит всю синхронизацию подключения; остальные события цикла обрабатываются штатно.
  • Отсутствие cms/commerce-services (suggests не установлен) → экспорт/импорт занятости слотов недоступен (нечего синхронизировать), но iCal-фид и ручные CalDAV-подключения продолжают работать как самостоятельная функциональность — деградация конкретной возможности, не всего модуля.

Донорский код

Донор: — (новая разработка). Легаси-импорт (§16 стандарта): не применимо — у модуля нет донора с уже существующими CalDAV-подключениями или iCal-подписками на переносимых сайтах (интеграция всегда настраивается заново на новой платформе через OAuth), команда import-legacy не создаётся.

Тесты и приёмка

  • [ ] Мок CalDAV-сервера: тест экспорта слота и импорта занятости обратно
  • [ ] Конфликт занятости фиксируется событием, не приводит к тихой потере бронирования
  • [ ] conflict_priority=external_wins/site_wins — обе ветки покрыты тестом на исход конфликта
  • [ ] Синхронизация идемпотентна — повторный опрос не создаёт дублирующие события в календаре
  • [ ] iCal-фид по токену не раскрывает данные чужих пользователей (проверка scope токена)
  • [ ] Ротация iCal-токена инвалидирует старую ссылку (404 после rotate-token)
  • [ ] Таймзоны: тест на сервер/ресурс/клиента в разных TZ — время отображается корректно
  • [ ] Отзыв OAuth-доступа провайдером (401) → is_active=false, inactive_reason=token_revoked, ретраи прекращаются
  • [ ] Временный сбой CalDAV (5xx/timeout) НЕ переводит соединение в is_active=false, ретраи с backoff продолжаются
  • [ ] sync_poll_minutes вне границ 560 отклоняется валидацией настроек
  • [ ] Достижение max_connections_per_resource отклоняет новое подключение понятной ошибкой
  • [ ] Kill-switch autosync_enabled=false останавливает расписание, ручной sync работает
  • [ ] Malformed VEVENT пропускается с parse_error, не валит синхронизацию подключения
  • [ ] Ретеншн-джоба prune-sync-log удаляет записи журнала старше срока хранения ПДн
  • [ ] Хуки «выгрузить/забыть по субъекту» покрывают подключения и журнал синхронизации
  • [ ] При выключении модуля бронирования на сайте создаются штатно, без синхронизации
  • [ ] Матрица ролей: calendars.manage разграничивает просмотр журнала (view) и подключение календарей (manage)
  • [ ] Access/refresh-токен подключения не попадает в API-ответы и логи (шифрованное поле, hidden)
  • [ ] Контрактный набор cms-testing зелёный, пакет тестируется в testbench-изоляции
  • [ ] Feature-тест на каждый роут; тестовая БД только calendars_test, migrate:fresh запрещён

Внутренняя база знаний студии