# Интеграция с 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 — конкретный файл.
$appOrigin = (CMain::IsHTTPS() ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'];
$appUrl = $appOrigin . '/local/diagram-app/index.html';
// Вариант 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` (или пустой строкой) — валидный случай: новый элемент без диаграммы, приложение стартует с чистого холста.
## 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, поэтому в `$appUrl` достаточно явно указывать файл — `/local/diagram-app/index.html`.
- Так как приложение и портал теперь на одном домене, `VITE_BITRIX_ORIGIN`/`appOrigin` — это просто origin самого портала, а не адрес отдельного сервиса.
- **Важный нюанс, найден живым тестом на дев-стенде:** тот же regex-location в nginx, что раздаёт статику, ставит `.html`-файлам `Cache-Control: public` с `expires 30d`. Значит после **повторного** деплоя (обновили React-приложение, положили новый `dist/` поверх старого) браузеры, уже открывавшие `/local/diagram-app/index.html` раньше, могут до 30 дней получать из своего кэша старый `index.html` — а в нём старые хэши JS/CSS-файлов, которых уже нет на диске (404) или которые не соответствуют новой версии приложения. При первом же открытии URL это не проявляется (кэшировать ещё нечего), но при каждом обновлении приложения — да. Решение: в `$appUrl`/`src` iframe добавлять cache-busting query-параметр, привязанный к версии сборки (например, `?v=` или `?v=<дата билда>`), а не просто `/local/diagram-app/index.html` без параметров — тогда каждая новая версия получает свой URL и обходит браузерный кэш. То же самое верно и для самой страницы со свойством (если её тоже будете кэшировать).
Порядок действий:
1. В репозитории `portal-chart-builder` собрать статику с двумя build-time переменными:
```
VITE_BITRIX_ORIGIN=https://ВАШ-ДОМЕН-ПОРТАЛА VITE_BASE_PATH=/local/diagram-app/ npm run build
```
`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/index.html';`, `$appOrigin` — origin портала (без пути).
4. Обновление после правок в React-приложении — тот же `npm run build` + копирование поверх старых файлов. Пересборка/перезапуск контейнеров Bitrix не нужны.
### Вариант 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`, не читается заново при каждом запуске/обновлении. Без неё приложение намеренно упадёт при загрузке (см. раздел 5) — чтобы нельзя было собрать билд с `postMessage('*')`.
Схема с `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.