Тема
ТЗ — Календари (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_connections | id, 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_log | id, connection_id, direction, status, events_count, error_code, synced_at | журнал синхронизации занятости |
cms_calendars_ical_subscriptions | id, token, resource_id (nullable), connection_id (nullable), revoked_at, created_at | публичные iCal-токены подписки, ротируемые независимо от подключения |
Индексы: FK user_id/connection_id/resource_id — constrained() + 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/connections | provider, 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}.ics | token в пути | токен ищется в 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_resource | FormRequest, sync_poll_minutes — валидация в границах (см. «Настройки») |
Выходы:
| Потребитель | Данные | Формат |
|---|---|---|
GET /api/v1/calendars/ical/{token}.ics | занятость/бронирования подписки | iCal (RFC 5545, text/calendar) |
| Внешний CalDAV-календарь (экспорт) | слот/бронирование из cms/commerce-services | VEVENT по 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_providers | array | ["google","yandex"] | нет | Разрешённые провайдеры CalDAV |
calendars.sync_poll_minutes | int | 15 | нет | Периодичность двусторонней синхронизации; допустимые границы 5–60 (ниже — риск упереться в rate-limit провайдера, выше — устаревшая занятость и риск двойного бронирования) |
calendars.ical_feed_enabled | bool | true | нет | Разрешить публичные iCal-фиды по подписке |
calendars.conflict_priority | enum(external_wins/site_wins) | external_wins | нет | Правило приоритета при конфликте занятости (см. «Крайние случаи») |
calendars.autosync_enabled | bool | true | нет | Kill-switch: отключить фоновую автосинхронизацию по расписанию без потери подключений; ручной cms:calendars:sync продолжает работать |
calendars.max_connections_per_resource | int | 3 | нет | Лимит числа подключённых календарей на пользователя/ресурс |
Секреты приложения (client_id/client_secret провайдера OAuth) — только .env → config/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/connections | admin (calendars.manage) | Подключение внешнего календаря |
| DELETE | /api/v1/admin/calendars/connections/{id} | admin (calendars.manage) | Отключение календаря (отзыв, не удаление истории) |
| POST | /api/v1/admin/calendars/connections/{id}/rotate-token | admin (calendars.manage) | Перевыпуск iCal-токена подписки, инвалидация старой ссылки |
| GET | /api/v1/admin/calendars/sync-log | admin (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/health | health-чек (реестр манифеста) | модуль → 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-токен конкретного подключения — шифрованное поле в БД (encryptedcast), не попадает в логи, не возвращается в 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вне границ5–60отклоняется валидацией настроек - [ ] Достижение
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запрещён