# Шаг 13. Технический контракт двусторонней доски внешних контуров ## Зачем нужен отдельный технический шаг Продуктовое решение по двусторонней доске уже зафиксировано: - нужны `Исходящие` - нужны `Входящие` - обе зоны должны фильтроваться единообразно - открытие карточки должно вести в общий shell - drag-and-drop между зонами не нужен и не должен появиться Но текущая реализация проекта не дает собрать это одной UI-переоберткой. Нужен отдельный технический контракт уровня board-layer. ## Что есть в коде сейчас ### 1. `external-contours` сейчас source-side only Текущий endpoint: - `GET /api/workspaces/:slug/projects/:project_id/external-contours/` Сейчас возвращает только записи, где: - `extra.bridge = external-contours` - `extra.source_project_id = current project` То есть это только исходящие запросы проекта-источника. Следствие: - текущий список не умеет входящие - текущий detail endpoint тоже source-side only ### 2. Detail endpoint тоже завязан только на перспективу источника Текущий detail: - `GET /api/workspaces/:slug/projects/:project_id/external-contours/:request_id/` Он тоже фильтрует по `extra.source_project_id = current project`. Следствие: - из проекта-цели нельзя открыть ту же сущность через тот же endpoint - двусторонняя доска не может использовать один и тот же detail flow без нового контракта доступа ### 3. Frontend store внешних контуров слишком узкий Текущий `ProjectExternalContoursStore` умеет: - список запросов - вкладки `open / closed` - create - update - source-side actions `accept / decline / reply` Но он не умеет: - фильтры по пользователям, статусам, датам и проектам - сортировки - две зоны в одной модели - отдельные cursors по зонам ### 4. Входящая сторона живет в другом модуле Входящие рабочие объекты сейчас живут в `intake`: - у них свой store - свой фильтровый тип - своя пагинация - своя модель выдачи То есть сегодня `Исходящие` и `Входящие` — это две разные подсистемы. ### 5. Существующий `views` слой нельзя просто взять как есть Слой `ProjectView` уже умеет: - rich filters - display filters - display properties Но он привязан к обычным `Issue` layouts проекта. Он не является готовой моделью для межконтурной доски, потому что: - колонка там означает layout/grouping issues - а в `Внешних контурах` колонка должна означать направление или выборку - и сама сущность там не сводится к plain `Issue` ## Технический вывод Новая доска должна строиться не вокруг существующего `Issue board`, а вокруг отдельного board contract для сущности `External Contour Request`. При этом сам доменный источник правды можно по-прежнему держать на `IntakeIssue + bridge metadata`. ## Что считаем единицей доски Единица двусторонней доски — это не обычный `Issue`. Единица доски — это `External Contour Board Item`, то есть проекция bridge-сущности на UI-слой внешнего контура. Базовый идентификатор: - `request_id = IntakeIssue.id` Это важно, потому что: - исходящий и входящий сценарии должны указывать на одну и ту же межконтурную сущность - detail-shell должен открываться по одному стабильному id ## Целевой frontend type contract ```ts type TExternalContourBoardDirection = "outgoing" | "incoming"; type TExternalContourBoardStatus = "open" | "closed"; type TExternalContourBoardItem = { id: string; direction: TExternalContourBoardDirection; status: TExternalContourBoardStatus; has_unread_updates: boolean; requested_at: string | null; updated_at: string; source_decision?: "accepted" | null; issue: { id: string; sequence_id: number | null; name: string; description_html?: string | null; priority?: "low" | "medium" | "high" | "urgent" | "none"; target_date?: string | null; state_id?: string | null; state_detail?: { id: string; name: string; group: string; color: string; } | null; assignee_details?: { id: string; display_name: string; avatar_url?: string | null; }[]; created_by_detail?: { id: string; display_name: string; avatar_url?: string | null; } | null; label_details?: { id: string; name: string; color: string; }[]; }; source_project: { id: string; identifier?: string | null; name: string; logo_props?: unknown; } | null; target_project: { id: string; identifier?: string | null; name: string; logo_props?: unknown; } | null; requested_by: { id: string | null; display_name: string | null; } | null; capabilities: { can_open_detail: boolean; can_open_target_issue: boolean; can_edit_request: boolean; can_reply: boolean; can_source_decide: boolean; }; }; ``` ## Целевой filter contract Это отдельный filter layer для board-level представления, а не копия `inbox` и не копия `project views`. ```ts type TExternalContourBoardFilter = { direction?: TExternalContourBoardDirection[]; status?: ("open" | "closed")[]; state_ids?: string[]; priority?: ("low" | "medium" | "high" | "urgent" | "none")[]; assignee_ids?: string[]; created_by_ids?: string[]; requested_by_ids?: string[]; source_project_ids?: string[]; target_project_ids?: string[]; label_ids?: string[]; has_unread_updates?: boolean; created_at?: string[]; updated_at?: string[]; target_date?: string[]; search?: string; }; type TExternalContourBoardSorting = { order_by?: "requested_at" | "updated_at" | "issue__sequence_id" | "target_date"; sort_by?: "asc" | "desc"; }; ``` ### Почему фильтры именно такие - `direction` нужен для будущих пользовательских представлений - `status` нужен для рабочего деления `open / closed` - `state_ids`, `assignee_ids`, `created_by_ids`, `label_ids`, `priority` повторяют рабочую логику внутренних контуров - `source_project_ids` и `target_project_ids` нужны именно для межконтурной аналитики и наблюдения - `requested_by_ids` нужен отдельно, потому что внешний инициатор и внутренний `created_by` обычной задачи не всегда совпадают по смыслу - `has_unread_updates` нужен для рабочего мониторинга ## Целевой backend API ### 1. Board list endpoint Нужен отдельный endpoint уровня доски: `GET /api/workspaces/:slug/projects/:project_id/external-contours/board/` Он не должен заменять текущий source-side list сразу. Он должен стать новым контрактом именно для двусторонней доски. ### Query params ```text direction=outgoing,incoming status=open,closed state_ids=, priority=urgent,high assignee_ids=, created_by_ids=, requested_by_ids=, source_project_ids=, target_project_ids=, label_ids=, has_unread_updates=true created_at=, updated_at=, target_date=, search= order_by=updated_at sort_by=desc outgoing_cursor= incoming_cursor= per_page=20 ``` ### Response shape ```json { "filters": { "direction": ["outgoing", "incoming"], "status": ["open"], "assignee_ids": [], "source_project_ids": [] }, "sorting": { "order_by": "updated_at", "sort_by": "desc" }, "columns": [ { "key": "outgoing", "title": "Исходящие", "total_count": 12, "next_cursor": "abc", "results": [] }, { "key": "incoming", "title": "Входящие", "total_count": 7, "next_cursor": "def", "results": [] } ] } ``` ### Почему один endpoint на обе зоны Потому что это дает: - единый filter contract - единые count values - единый sorting contract - отсутствие расхождений между двумя независимыми загрузками При этом пагинация должна быть раздельной: - отдельный cursor для `outgoing` - отдельный cursor для `incoming` ## Целевой detail contract Нужен новый detail endpoint с перспективой доступа, а не только source-side read. Рекомендуемый вариант: `GET /api/workspaces/:slug/projects/:project_id/external-contours/board-items/:request_id/` Контракт должен сам определить perspective относительно текущего `project_id`: - если проект совпадает с `source_project_id` — это `outgoing` - если проект совпадает с `target_project_id` или с проектом bridge issue — это `incoming` Следствие: - detail-shell открывается по одному route-contract - входящая зона не должна притворяться source-side проектом только ради открытия карточки ### Что важно не ломать Текущие source-side mutation endpoints можно оставить отдельными: - update - decision - reply Но read-model detail для board должен быть общим. ## Рекомендуемая модель backend aggregation ### Базовый источник правды Базовая сущность не меняется: - `IntakeIssue` - `Issue` - `extra.bridge = external-contours` ### Агрегация по направлениям `outgoing`: - `extra.source_project_id = current project` `incoming`: - `project_id = current project` - `extra.bridge = external-contours` ### Нормализованный serializer Нужен отдельный serializer уровня board item. Он должен: - выдавать `direction` - выдавать обе project references - отдавать capabilities - отдавать status и has_unread_updates - нормализовать issue-поля в один и тот же формат для обеих сторон ## Frontend store design Рекомендуется не расширять бесконечно текущий `ProjectExternalContoursStore`. Нужен отдельный store: `ProjectExternalContoursBoardStore` ### Что он хранит - board filters - board sorting - map items by id - columns: - `outgoing` - `incoming` - per-column cursor - per-column total_count - selected board item id - board loader states ### Что остается в текущем store Текущий `ProjectExternalContoursStore` можно временно оставить для: - create request - source-side update - source-side decision - source-side reply - target project options То есть board store отвечает за read-model, а существующий store пока отвечает за mutations. Это позволит не ломать уже рабочий runtime одним большим переносом. ## UI contract ### 1. Доска состоит из фиксированных системных колонок - колонка `Исходящие` - колонка `Входящие` - дальше уже поверх них могут появиться пользовательские представления ### 2. Колонка — это выборка, а не workflow stage Следовательно: - нет drag handle - нет `onDrop` - нет optimistic move между колонками ### 3. Клик по карточке ведет в shell шага 11 Детали должны открываться через общий shell, а не через отдельный старый source-only right pane. ## Что сознательно не делаем на этом шаге ### 1. Не склеиваем две выдачи в браузере Это проигрышно из-за: - разных сортировок - разных count values - разных filter models - разных правил пагинации ### 2. Не превращаем board в kanban по статусам Это не тот продуктовый сценарий. ### 3. Не переносим пользовательские колонки в первый технический контракт Для начала нужен стабильный системный board layer. Пользовательские представления должны появиться только поверх уже работающего fixed contract. ## Порядок реализации ### Шаг A. Backend contract - board list endpoint - common board item serializer - detail read endpoint для обеих перспектив - filter parsing - per-column pagination ### Шаг B. Frontend types and services - новые types для board item, filters, sorting, response - новый service layer - новый board store ### Шаг C. UI board shell - fixed `Исходящие / Входящие` - фильтровая пипка - counts - открытие detail-shell ### Шаг D. Cut-over - перевод `Внешних контуров` со старого sidebar/list режима на board mode - сохранение работающих mutation flows - последующее упрощение legacy source-only списка ## Критерий приемки технического шага - у команды есть конкретный API contract для двусторонней доски - у команды есть отдельный frontend type contract, не завязанный на plain `Issue` - у detail-shell есть единый read endpoint для `incoming` и `outgoing` - документ прямо фиксирует отказ от frontend-склейки и drag-and-drop ## Связанные документы - [11_STEP_external-contours-detail-shell.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/docs_prod/1_STEP_cross-project-task-routing/11_STEP_external-contours-detail-shell.md) - [12_STEP_external-contours-bidirectional-board.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/docs_prod/1_STEP_cross-project-task-routing/12_STEP_external-contours-bidirectional-board.md) - [phase-roadmap.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/docs_prod/1_STEP_cross-project-task-routing/phase-roadmap.md)