NODEDC_TASKMANAGER/docs_prod/1_STEP_cross-project-task-r.../13_STEP_external-contours-b...

16 KiB
Raw Blame History

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

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 / 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

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

Базовый источник правды

Базовая сущность не меняется:

  • 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

Связанные документы