16 KiB
Шаг 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-contoursextra.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
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.
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 / closedstate_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
direction=outgoing,incoming
status=open,closed
state_ids=<uuid>,<uuid>
priority=urgent,high
assignee_ids=<uuid>,<uuid>
created_by_ids=<uuid>,<uuid>
requested_by_ids=<uuid>,<uuid>
source_project_ids=<uuid>,<uuid>
target_project_ids=<uuid>,<uuid>
label_ids=<uuid>,<uuid>
has_unread_updates=true
created_at=<range>,<range>
updated_at=<range>,<range>
target_date=<range>,<range>
search=<string>
order_by=updated_at
sort_by=desc
outgoing_cursor=<cursor>
incoming_cursor=<cursor>
per_page=20
Response shape
{
"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
Базовый источник правды
Базовая сущность не меняется:
IntakeIssueIssueextra.bridge = external-contours
Агрегация по направлениям
outgoing:
extra.source_project_id = current project
incoming:
project_id = current projectextra.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:
outgoingincoming
- 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