# Шаг 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=<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

```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)
