# Интеграция с Bitrix — спецификация для backend-разработчика Это документ для человека, который не участвовал в разработке самого React-приложения (`portal-chart-builder`) и не обязан знать TypeScript — здесь только то, что нужно на стороне PHP/Bitrix. ## 1. Что это Конструктор диаграмм — отдельное React-приложение (не часть Bitrix24, продукт — «1С-Битрикс: Управление сайтом»). Встраивается как **пользовательский тип свойства инфоблока**: в админке, на странице редактирования элемента, вместо обычного поля отображается iframe с редактором диаграммы. Значение свойства — JSON-строка со схемой диаграммы, хранится как обычное строковое свойство (`PROPERTY_TYPE => 'S'`). Никакого REST-модуля, вебхуков или JS SDK Bitrix24 не требуется — это нативный механизм модуля `iblock`. ## 2. Регистрация типа свойства В `local/php_interface/init.php`: ```php AddEventHandler("iblock", "OnIBlockPropertyBuildList", ["DiagramProperty", "GetUserTypeDescription"]); ``` После регистрации новый тип появляется в **Контент → Инфоблоки → [нужный] → Свойства → Тип свойства**. Свойство должно быть **одиночным** (`MULTIPLE = N`) — одна диаграмма на элемент. ## 3. Скелет PHP-класса Ниже — иллюстративный каркас, не готовый к продакшену код. Реализация валидации, экранирования и т.д. — на усмотрение разработчика, но с обязательным соблюдением требований из раздела 5. ```php class DiagramProperty { public static function GetUserTypeDescription(): array { return [ 'PROPERTY_TYPE' => 'S', 'USER_TYPE' => 'DiagramEditor', 'DESCRIPTION' => 'Конструктор диаграмм процессов', 'GetPropertyFieldHtml' => [__CLASS__, 'GetPropertyFieldHtml'], 'ConvertToDB' => [__CLASS__, 'ConvertToDB'], 'ConvertFromDB' => [__CLASS__, 'ConvertFromDB'], ]; } public static function GetPropertyFieldHtml($arProperty, $value, $strHTMLControlName): string { $inputName = htmlspecialcharsbx($strHTMLControlName['VALUE']); $rawValue = $value['VALUE'] ?? ''; // Вариант A — рекомендуемый, приложение лежит внутри дерева портала (см. раздел 7): // appOrigin — это просто origin текущего запроса, appUrl — конкретный файл. // ВАЖНО: имя файла — не константа. dist/index.html выходит из сборки как // index-.html (хеш меняется при каждой значимой пересборке, см. // раздел 7) — 'index.html' строкой здесь писать нельзя, нужно получать // актуальное имя каким-то способом на своей стороне (см. раздел 7). $appOrigin = (CMain::IsHTTPS() ? 'https://' : 'http://') . $_SERVER['HTTP_HOST']; $appUrl = $appOrigin . '/local/diagram-app/' . GetActualDiagramAppIndexFile(); // пример, не готовая функция // Вариант B — если приложение развёрнуто отдельным контейнером (см. раздел 7), // appUrl/appOrigin вместо этого указывают на его отдельный адрес, например: // $appOrigin = 'http://localhost:5178'; // $appUrl = $appOrigin; ob_start(); ?> } 5. Пользователь редактирует → с debounce ~400мс: Приложение → мост: { type: 'diagram:change', payload: '' } 6. Мост при каждом diagram:change пишет payload в value hidden input 7. Пользователь жмёт штатное «Сохранить» → браузер сабмитит форму → ConvertToDB → сохранение ``` Важно: `payload` в `diagram:change` — **уже сериализованная JSON-строка** (`JSON.stringify` со стороны приложения), не объект. Мост должен положить её в `input.value` как есть, без повторной обработки. `diagram:init` с `payload: null` (или пустой строкой) — валидный случай: новый элемент без диаграммы, приложение стартует с чистого холста. ### Режим только для просмотра Хост может открыть приложение на чтение, добавив `?mode=view` к URL iframe: ``` /local/diagram-app/index.html?mode=view ``` Тогда редактор стартует в режиме «Просмотр» и переключатель «Просмотр/Редактирование» скрыт — фигуры нельзя двигать, создавать и удалять. Без параметра поведение прежнее: редактирование с переключателем. Параметр читается один раз при загрузке (`src/App.tsx`), в контракте `postMessage` ничего не меняется — `diagram:init`/`diagram:change` работают одинаково в обоих режимах. Зачем: встраивающая сторона, которая не слушает `diagram:change` (например, страница портала, показывающая утверждённую схему), иначе предлагала бы правки, которые молча никуда не сохраняются. ## 5. Требования безопасности (обязательны, не опциональны) - Значение всегда попадает на страницу только через `value`-атрибут hidden input (`htmlspecialcharsbx($rawValue)`, см. раздел 3), не через встраивание в инлайн-скрипт — так JSON-содержимое никогда не оказывается внутри JS-исходника, и `JSON_HEX_*`-экранирование не требуется вообще. Мост читает `input.value` напрямую в JS. Если по каким-то причинам всё же понадобится встроить значение в инлайн-скрипт (например, отдельный `initialValue` вместо чтения из DOM) — тогда обязателен `JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT` при `json_encode`. - `event.origin` проверяется **в обе стороны**: и в мосте (когда он слушает сообщения от iframe), и в приложении (уже реализовано во frontend). - `targetOrigin` у любого `postMessage` — всегда конкретный домен (`appOrigin`), **никогда `'*'`**. - `name` у hidden input должен **точно** совпадать с `$strHTMLControlName['VALUE']` — это единственная связь между кастомным рендером и штатным сохранением формы. ## 6. Формат JSON диаграммы Пример реального значения свойства (то, что лежит в hidden input и уходит в `ConvertToDB`): ```json { "nodes": [ { "id": "start", "type": "startEvent", "position": { "x": 0, "y": 80 }, "width": 48, "height": 48, "data": { "label": "Заявка получена" } }, { "id": "review", "type": "task", "position": { "x": 220, "y": 60 }, "width": 140, "height": 50, "data": { "label": "Приём заявок", "description": "Проводится ответственным лицом, 1-2 дня.", "responsible": "Иван Иванов", "duration": "1-2 дня", "color": "#eef2ff" }, "parentId": "some-other-node-id", "zIndex": 1 } ], "edges": [ { "id": "start-review", "source": "start", "target": "review", "sourceHandle": "right", "targetHandle": "left", "label": "Да" } ] } ``` Пояснения по полям (все, кроме перечисленных обязательными, — опциональны и могут отсутствовать): - `nodes[].id` / `edges[].id` — уникальные строки, обязательны. - `nodes[].type` — один из: `task`, `startEvent`, `intermediateEvent`, `endEvent`, `exclusiveGateway`, `parallelGateway`, `inclusiveGateway`. - `nodes[].position` — координаты на холсте, обязательны. - `nodes[].width`/`height` — размер фигуры (пользователь может менять руками). - `nodes[].data.label` — название, обязательно; остальные поля `data` — произвольные метаданные, отображаемые в форме редактирования. - `nodes[].parentId` — если узел вложен в другой (drill-down «под-процесс» внутри той же диаграммы, не переход на другой элемент). - `nodes[].zIndex` / `edges[].zIndex` — порядок наложения друг на друга. - `edges[].source`/`target` — id узлов, обязательны. - `edges[].sourceHandle`/`targetHandle` — с какой стороны фигуры выходит/входит линия (`top`/`right`/`bottom`/`left`). Backend не обязан ничего валидировать глубже «это либо пустая строка, либо синтаксически корректный JSON» — вся содержательная валидация (обязательные поля и т.п.) — на стороне приложения. ## 7. Деплой приложения Два варианта. Вариант A проще и ближе к тому, как уже устроен сам портал — рекомендуется. Вариант B (отдельный контейнер) остаётся как альтернатива на случай, если приложение когда-нибудь понадобится хостить отдельно от Bitrix-стенда (другой домен, отдельный CI/CD и т.п.). ### Вариант A (рекомендуется) — статика прямо в дереве Bitrix Проверено на текущем дев-стенде (`portal-bitrix-docker`): каталог сайта — не часть Docker-образа, а volume `bitrix_www`, смонтированный в контейнеры `nginx` и `php` как bind-mount на каталог **хоста** (`docker volume inspect bitrix_www` → `device: ..., o: bind`). Именно так уже хостится сам портал: файлы лежат на диске хоста, штатный nginx их и раздаёт — значит собранную статику приложения можно положить туда же, без отдельного контейнера. Что это даёт технически: - Nginx-конфиг стенда отдаёт файлы по расширению (`js|css|html|svg|png|...`) напрямую с диска — это срабатывает раньше, чем запрос попадёт в PHP-роутер Bitrix. Любой статический файл под корнем сайта отдаётся сам, никаких правок nginx не требуется. - `local/` уже существует как каталог для кастомного кода (`ai`, `components`, `modules`, `php_interface`, `templates` — по факту на дев-стенде). Новый подкаталог, например `local/diagram-app/`, вписывается в эту конвенцию и не пересекается с `local/modules`/`local/php_interface` — они у nginx явно помечены `internal` (заблокированы для прямого внешнего доступа), новый подкаталог под этот блок не попадает. - Нюанс: `index` на уровне сервера — `index.php`, не `index.html`. Открыть голый `/local/diagram-app/` (без имени файла) не выйдет — nginx попытается найти там `index.php` и не найдёт. PHP-класс (раздел 3) сам подставляет `src` у iframe — но, в отличие от более ранней версии этого документа, **больше нельзя просто написать `/local/diagram-app/index.html` строкой** — см. следующий пункт. - Так как приложение и портал теперь на одном домене, `VITE_BITRIX_ORIGIN`/`appOrigin` — это просто origin самого портала, а не адрес отдельного сервиса. - **Имена файлов в `dist/` — с хешем содержимого, включая сам `index.html`.** `index.js`/`index.css`/шрифты хеширует сам Vite при сборке (стандартное поведение, как в любом Vite-проекте). Кроме того, в `vite.config.ts` есть небольшой плагин (`hashIndexHtml`), который **дополнительно хеширует и сам `index.html`** — на выходе получается что-то вроде `index-2fddeca6.html`, а не просто `index.html`. Имя меняется всякий раз, когда меняется что-то значимое (сама HTML-обёртка либо то, что она подключает), и не меняется при пересборке без реальных изменений. Из этого следует главное отличие от более ранней версии данного документа: **`$appUrl` в PHP-классе (раздел 3) больше нельзя хардкодить как строку** — конкретное имя `index-.html` меняется при каждой пересборке приложения. Бэкенду нужен свой способ узнавать актуальное имя файла на момент рендера страницы (например, взять единственный `index-*.html` из каталога деплоя, или через отдельный метод/эндпоинт, который вернёт актуальное имя, — механизм на усмотрение бэкенда, здесь не фиксирован, уточняйте на момент внедрения). Взамен это полностью снимает необходимость в отдельном cache-busting query-параметре (`?v=...`) — раньше он был обязателен и для самого `index.html`, и (до появления хеша у самого файла) для `index.js`/`index.css` внутри него. Теперь URL меняется вместе с содержимым: старый закэшированный `index-OLDHASH.html` просто никогда больше не запрашивается, как только хост узнаёт новое имя — что убирает разом оба уровня кэш-проблемы, описанной в более ранней версии этого раздела. Порядок действий: 1. В репозитории `portal-chart-builder` собрать статику с двумя build-time переменными: ``` VITE_BASE_PATH=/local/diagram-app/ npm run build ``` `VITE_BITRIX_ORIGIN` в этом варианте не задаётся — см. примечание в конце раздела. `VITE_BASE_PATH` — путь, под которым будет отдаваться приложение (нужен, чтобы ссылки на JS/CSS внутри `dist/index.html` были корректными под этим подпутём; см. `vite.config.ts`). По умолчанию, если переменная не задана, `base` — `/` (не влияет на локальную разработку, `npm run dev`). 2. Скопировать содержимое `dist/` в `local/diagram-app/` внутри host-каталога, который смонтирован как `bitrix_www` (на текущем дев-стенде это `/home/nikit/bitrix/www/local/diagram-app/` — точный путь для конкретного стенда уточняется у того, кто его поднимал/администрирует). 3. В PHP-классе (раздел 3): `$appUrl = $appOrigin . '/local/diagram-app/' . $actualIndexFileName;`, `$appOrigin` — origin портала (без пути), `$actualIndexFileName` — актуальное имя `index-.html` в `local/diagram-app/`, полученное способом на усмотрение бэкенда (см. врезку про кэш выше — хардкодить `index.html` строкой уже нельзя). 4. Обновление после правок в React-приложении — тот же `npm run build` + копирование поверх старых файлов. Пересборка/перезапуск контейнеров Bitrix не нужны. Старый `index-.html` от предыдущей сборки при этом не удаляется автоматически — если способ поиска актуального файла (шаг 3) предполагает «взять единственный файл по маске», после нескольких деплоев в каталоге стоит убирать файлы прошлых версий, иначе маска перестанет быть однозначной. ### Вариант B — отдельный Docker-контейнер В репозитории `portal-chart-builder` есть `Dockerfile` (multi-stage: сборка через node, раздача статики через nginx) и `.dockerignore` — для случая, если приложение должно жить отдельно от Bitrix-стенда (свой домен, свой деплой). Ключевое рассуждение: iframe грузит **браузер администратора**, а не сервер Bitrix — запроса «сервер → приложение» не происходит вообще. Значит, если контейнер с приложением опубликован портом на тот же хост, откуда админ открывает Bitrix (текущий дев-стенд: это одна и та же машина), достаточно сослаться на `http://localhost:<порт>` — отдельный домен или туннель не нужны. Пример сервиса для добавления в `docker-compose.yml` Bitrix-стенда (иллюстративный — правки самого файла делает тот, кто ведёт стенд): ```yaml services: diagram-app: build: context: ../portal-chart-builder args: VITE_BITRIX_ORIGIN: http://portal.local # origin самого Bitrix-стенда container_name: portal_diagram_app restart: unless-stopped ports: - "5178:80" # публикуем на хост ``` Тогда: - `VITE_BITRIX_ORIGIN` (build-arg, см. раздел 5) = origin Bitrix-портала — `http://portal.local`. - `$appUrl`/`$appOrigin` в PHP-классе (раздел 3) = адрес самого приложения — `http://localhost:5178`. Как передать сам аргумент: - **Через docker-compose** (как в примере выше) — значение прописывается в `build.args` сервиса; при `docker compose up --build`/`docker compose build` compose сам подставит его в сборку, никаких дополнительных флагов не нужно. - **Через `docker build` напрямую** (ручная сборка, например для локальной проверки) — флагом `--build-arg`: `docker build --build-arg VITE_BITRIX_ORIGIN=http://portal.local -t portal-diagram-app .` — именно так проверялось выше. - Чтобы не хардкодить реальный адрес прямо в `docker-compose.yml` (он попадёт в git) — вынести значение в `.env`-файл рядом с `docker-compose.yml` (`VITE_BITRIX_ORIGIN=http://portal.local`) и сослаться на него в compose как `VITE_BITRIX_ORIGIN: ${VITE_BITRIX_ORIGIN}` — Compose сам подхватывает `.env` из той же папки. Собрал и проверил локально (`docker build` + `docker run -p 5178:80`) — контейнер поднимается и корректно отдаёт приложение по `http://localhost:5178`. **Важно (общее для обоих вариантов):** `VITE_BITRIX_ORIGIN` — build-time переменная, вшивается в JS-бандл на этапе `npm run build`/`docker build`, не читается заново при каждом запуске/обновлении. В варианте A она **не нужна**: приложение и портал на одном домене, и origin хоста приложение берёт из `window.location.origin`. Более того, заданная переменная там вредна — она перебьёт рантайм-значение, и на любом домене, кроме зашитого, обмен молча не состоится. Нужна она только в варианте B (или при локальной разработке), где хост и приложение на разных origin. Требование «никогда `postMessage('*')`» соблюдается в обоих случаях: targetOrigin всегда конкретный origin. Схема с `localhost` (вариант B) работает, пока браузер администратора и Docker-хост — одна машина (текущий дев-стенд). Если позже появится реальный удалённый прод с админами не с этой машины — понадобится либо настоящий домен, либо reverse-proxy; это отдельная, более поздняя задача. Вариант A этой проблемы не имеет вообще, так как всегда живёт на домене портала. ## 8. Открытые решения (нужно определить до/во время внедрения) - **Конкретный инфоблок**, к которому подключается свойство, — не выбран, определяется пользователем/владельцем задачи отдельно. Код из раздела 3 не зависит от конкретного инфоблока — тип свойства регистрируется глобально и включается точечно через админку (см. раздел 2). - **Публичная часть сайта** — решено, что в первой версии не нужна (только админка). React-приложение уже поддерживает read-only «режим просмотра» — если публичная часть понадобится позже, доработка небольшая (реализовать `GetPublicViewHTML`, встроить тот же iframe в режиме просмотра). ## 9. Definition of done 1. Открыть страницу редактирования элемента (для которого включено свойство) — поле показывает iframe с пустой диаграммой (для нового элемента) или с ранее сохранённой. 2. Отредактировать диаграмму (подвигать фигуры, поменять текст). 3. Нажать штатную кнопку «Сохранить» на странице элемента (не внутри iframe). 4. Перезагрузить страницу — изменения на месте. 5. Открыть значение свойства напрямую в БД/через API инфоблоков — там лежит валидный JSON вида из раздела 6.