Перейти к основному содержимому

GET /api/v1/developers/projects — Поиск/список проектов застройщика

Назначение

Первый экран раздела «Проекты» кабинета застройщика: список проектов своей компании с пагинацией, поиском по названию, фильтрами (статус / тип / класс / статус строительства / регион / город) и сортировками. Менеджер видит все свои проекты, включая Draft/Hidden/Archived — в отличие от публичной ленты, которая отдаёт только Published. Дом полей — сущность Проект.

Почему поиск по read-model, а не по Elasticsearch

Поиск по названию идёт по read-model PostgreSQL (case-insensitive substring), а не по Elasticsearch-проекции ProjectSearchDocument: кабинет застройщика ищет и неопубликованные проекты (Draft/Hidden/Archived), а проекция держит fail-closed только Published-срез (../../search/projects.md). Набор проектов одного tenant-а невелик — substring достаточно (ADR-0056).

Metadata

ПолеЗначение
Сервис/контекстchessboard
API areadevelopers
Feature groupprojects
Статусdraft
Документdocs/06-services/chessboard/api/developers/search-projects.md

Актор и доступ

ПроверкаЗначение
Actor typeUser (менеджер застройщика)
Auth policy / Permissionschessboard.projects.manage (../../identity/domain/permission.md)
Scope (RBAC)организация; сужение «группа проектов/проект» фильтрует выдачу по разрешённым ProjectId
Tenant isolationTenantId = CompanyId (= DeveloperId) из JWT; выборка ограничивается tenant-ом актора (tenant_id = TenantId)

HTTP-контракт

ПолеЗначение
MethodGET
Route/api/v1/developers/projects
Success status200
Correlationсквозной trace через заголовок traceparent

Входные данные / параметры запроса

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
TenantIdidentity claims (tenant_id)GuidДаRequiredTenant isolation
PagequeryintНет (default 1)>= 1
PageSizequeryintНет (default 20)1..100
Searchquerystring?Нет<= 256 симв.; case-insensitive substring по Name + Location.AddressLine (свои проекты, включая неопубликованные)untrusted; параметризованный LIKE
Statusesquery (csv)ProjectStatus[]?Нетзначения enum Draft/Published/Hidden/Archived; пусто = все
Typesquery (csv)ProjectType[]?НетResidentialComplex/ApartHotel/Hotel/Villa (reference-data.md)
Classesquery (csv)ProjectClass[]?НетStandard/Comfort/Business/Premium/Elite
ConstructionStatusesquery (csv)ConstructionStatus[]?НетPlanned/UnderConstruction/Commissioned/Delivered
RegionIdquerylong?Нет> 0; ссылка на справочник регионов handbook — равенство по Project.RegionId
CityIdquerylong?Нет> 0; ссылка на справочник городов handbook — равенство по Project.CityId
SortByqueryProjectSortByНет (default CreatedAtDesc)enum: CreatedAtDesc / CreatedAtAsc / NameAsc / PriceAsc / PriceDesc
IncludeArchivedqueryboolНет (default false)false → фильтр DeletedAt == null

Модель ответа SearchDeveloperProjectsResult

ПолеТипОбязательностьИсточникMaskingОписание
ItemsDeveloperProjectListItem[]Дапроекция ProjectEntityСтраница проектов.
PageintДаecho запросаТекущая страница.
PageSizeintДаecho запросаРазмер страницы.
TotalCountlongДаподсчёт под фильтромВсего записей под фильтром.

Модель DeveloperProjectListItem

ПолеТипОбязательностьИсточникMaskingОписание
IdGuidДаProjectEntity.IdId проекта.
NamestringДаNameНазвание.
StatusProjectStatusДаStatusСтатус публикации.
TypeProjectTypeДаTypeТип проекта.
ClassProjectClassДаClassКласс.
ConstructionStatusConstructionStatusДаConstructionStatusСтатус строительства.
RegionIdlong?НетRegionIdРегион — ссылка на справочник handbook.
RegionNamestring?НетRegionNameDisplay-имя региона (денормализация; заполнено ⇔ RegionId != null).
CityIdlong?НетCityIdГород — ссылка на справочник.
CityNamestring?НетCityNameDisplay-имя города (заполнено ⇔ CityId != null).
CoverImageFileIdGuid?НетCoverImageFileIdОбложка (FileId storage/CDN).
PriceNullableRange<decimal>?НетProject.Price (денормализация, Price.Min/Price.Max)Диапазон цен свободных объектов недвижимости.
CurrencyIdshortДаCurrencyIdВалюта — ссылка на справочник handbook (RUB-only, ADR-0048).
PropertyObjectsTotalCountintДаPropertyObjectsTotalCountВсего объектов недвижимости.
PropertyObjectsFreeCountintДаPropertyObjectsFreeCountСвободных объектов недвижимости.
SyncSourceChessboardSyncSourceДаSyncSourcePin / TalentChessboard (UI блокирует реплика-поля).
CreatedAtDateTimeOffsetДаCreatedAtДата создания.
UpdatedAtDateTimeOffsetДаUpdatedAtПоследнее изменение.
VersionlongДаVersionДоменная версия (для последующих PUT).

Предусловия

  • Пользователь авторизован; tenant_id = его компания-застройщик.
  • У актора есть permission chessboard.projects.manage хотя бы в одном scope компании (иначе FORBIDDEN).
  • Выдача заведомо ограничена своим tenant-ом; чужие проекты недостижимы в принципе (не «скрыты», а вне выборки).

Алгоритм

Операция read-only, доменных событий не публикует.

  1. Контекст и доступ. Операцию выполняет менеджер застройщика; tenant — его компания; требуется право chessboard.projects.manage в scope Organization. Нет permission ни в одном scope → FORBIDDEN (403); просроченный/отозванный доступ → UNAUTHORIZED (401) / FORBIDDEN (403). При сужении scope «группа проектов/проект» из назначения извлекается набор разрешённых ProjectId, которым ограничивается выдача (проекты вне scope не показываются и не считаются в TotalCount — не ошибка).
  2. Проверка входа. Диапазоны Page/PageSize, длина Search, положительность RegionId/CityId, парсинг csv-enum-ов — неизвестное значение → ValidationError (400).
  3. Выборка. Страница проекций собирается по фильтрам: tenant-фильтр; при IncludeArchived = false — только не удалённые; Statuses/Types/Classes/ConstructionStatuses — совпадение по множеству; RegionId/CityId — равенство по ссылке справочника (индексы ix_projects_region_id/ix_projects_city_id; несуществующий id справочника даёт пустой результат, не ошибку); Search — case-insensitive substring по Name и Location.AddressLine (набор tenant-а мал, ADR-0056; опц. trigram-индекс ix_projects_name_trgm); scope-сужение — по разрешённым ProjectId. Сортировка — по SortBy: CreatedAtDesc/Asc (ix_projects_created_at), NameAsc (ix_projects_name), PriceAsc/Desc — по Price.Min nulls last (ix_projects_price_min). Считается TotalCount под тем же фильтром. Пустой результат и запрос страницы за пределами последней — валидны: 200, Items = [], фактический TotalCount.
  4. Результат. Возвращаются Items, Page, PageSize, TotalCount; HTTP 200. Маскирование не требуется (PII нет). Freshness: read-committed снимок; денормализации Price, PropertyObjects*Count могут отставать до фонового пересчёта агрегатов project-aggregates-recalc (окно ~40 с).

Acceptance Criteria

  • Happy path. Given tenant с проектами в разных статусах; When GET .../projects; Then 200 со страницей своих проектов (включая Draft/Hidden/Archived), отсортированной по CreatedAtDesc.
  • Фильтр + поиск. Given Statuses=Published&Search=<часть названия>; When запрос; Then только Published-проекты, чьё Name/адрес содержат подстроку.
  • Сужение scope. Given scope сужен до одной группы проектов; When запрос без фильтров; Then только проекты из разрешённого набора; проекты вне scope отсутствуют и не учтены в TotalCount.
  • Пустой/за-предельный. Given фильтр без совпадений или Page за последней страницей; When запрос; Then 200, Items = [].
  • Невалидный вход. Given PageSize вне 1..100 или неизвестный ProjectType; When запрос; Then 400 ValidationError.
  • Нет права. Given нет chessboard.projects.manage; When запрос; Then 403 FORBIDDEN.

Диаграмма

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
ValidationError400Неверные paging/enum/длиныСписок ошибок полейno retry
FORBIDDEN403Нет chessboard.projects.manage«Недостаточно прав»no retry

Совместимость и наблюдаемость

  • Новые optional фильтры/поля ответа — additive; изменение семантики сортировок — breaking (rollout).
  • Logs: TenantId, фильтры (без значений Search); metrics: chessboard_developers_projects_search_total, latency p99 < 300 мс (SLO README); traces: OTel (traceparent).

Обратные ссылки

Автоссылки: где используется этот документ.

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