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

GET /api/v1/authoring/posts — Мои посты и черновики

Авторский список постов news во всех статусах жизненного цикла (Draft/Published/Unpublished/ Archived) с keyset-пагинацией по updated_at desc — рабочая витрина автора/менеджера («мои посты и черновики», README, область authoring). В отличие от публичной ленты (list-global-feed.md) выдаёт неопубликованные статусы и обязан применять record-level scope (INV-NWS-007). Только чтение: без мутаций, событий и синхронных вызовов.

Назначение

Отдать актору его собственные посты и черновики, а менеджеру компании — дополнительно посты, привязанные к компаниям в его scope (при permission news.posts.manage), во всех статусах, с фильтром по Status и keyset-пагинацией по времени последнего изменения.

Metadata

ПолеЗначение
Сервис/контекстnews
API areaauthoring
Feature groupauthoring
Статусdraft
Документdocs/06-services/news/api/authoring/list-my-posts.md

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

ПроверкаЗначение
Actor typeUser (автор поста / менеджер застройщика или агентства)
Auth policy / PermissionsJWT обязателен; news.posts.manage-own (свои посты) + news.posts.manage (посты компаний в scope менеджера); permission.md
Scope (RBAC)организация — company scope менеджера (набор AuthorCompanyId из claims); свои посты — вне company scope
Record-levelобязателен: AuthorUserId = actor ИЛИ (AuthorCompanyId ∈ managed-scopenews.posts.manage), INV-NWS-007
Tenant isolationпосты инвестора/сотрудника вне tenant (AuthorCompanyId = null, ADR-0052 п.4); record-level строится явно, авто-tenant-фильтр отключён — иначе OR-условие «свои ∪ компанийные» не выразимо
Auditread — отдельная audit-запись не пишется (чтение своих данных)

HTTP-контракт

ПолеЗначение
MethodGET
Route/api/v1/authoring/posts
Success status200
Idempotency headerне применяется (безопасный идемпотентный GET)
Correlationсквозной trace через traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
ActorUserIdidentity claimsGuidДаиз JWT; основа record-level предикатане из тела запроса
StatusqueryPostStatus?Нетenum-строкой; неизвестное значение → VALIDATION_ERROR; null — все статусы
Cursorquerystring?Нетopaque base64 keyset-курсор; при повреждении → VALIDATION_ERRORнепрозрачный, не PII
LimitqueryintНет1..30, default 20защита от тяжёлых страниц

Enum PostStatus (query, enum-строкой)

Дом значений — post.md; фильтр по одному значению Post.Status.

ЗначениеЧто попадает в выдачу
DraftЧерновики (не в ленте/поиске).
PublishedОпубликованные (в ленте/поиске).
UnpublishedСнятые с публикации (черновик правок сохранён).
ArchivedSoft-deleted (DeletedAt задан) — включаются в выборку authoring явным фильтром (в отличие от лент).

Archived попадают в выдачу только при Status = Archived; при Status = null (все статусы) включение Archived определяется явным режимом «включать удалённые» (см. алгоритм).

Модель ответа (страница AuthoringPostItem)

Постраничный список в keyset-режиме: Items + курсорные поля; TotalCount не вычисляется (перф/SLO).

ПолеТипОбязательностьИсточникОписание
ItemsIReadOnlyList<AuthoringPostItem>ДаprojectionСтраница постов, updated_at desc, id desc.
NextCursorstring?Нетcomputed (keyset)Opaque-курсор следующей страницы; null — конец списка.
HasMoreboolДаcomputedЕсть ли ещё страницы.

AuthoringPostItem

ПолеТипОбязательностьИсточникMaskingОписание
PostIdGuidДаPost.IdИдентификатор поста.
NamestringДаPost.NameВнутреннее/отображаемое имя (авторская рабочая витрина).
SuffixstringДаPost.SuffixURL-slug (/news/{suffix}).
TitlestringДаPost.TitleЗаголовок поста.
CategorySlugstringДаPostCategory.SlugСтабильный kebab-код категории (SEED).
StatusPostStatusДаPost.StatusСтатус жизненного цикла (enum-строкой).
CommentsCountintДаPost.CommentsCountДенормализованный счётчик комментариев.
ViewsCountlongДаPost.ViewsCountДенормализованные просмотры (трекинг — user-activity).
CurrentVersionNumberintДаPost.CurrentVersionNumberНомер последнего PostVersion; 0 — неопубликованный черновик.
PublishedAtDateTimeOffset?НетPost.PublishedAtМомент текущей публикации; null — не публиковался/снят.
UpdatedAtDateTimeOffsetДаPost.UpdatedAtПоследнее изменение — ключ сортировки/keyset.

Внутренние AuthorUserId/AuthorCompanyId наружу не отдаются. Поля поста — post.md; CategorySlugpost-category.md; история — post-version.md.

Алгоритм

  1. Контекст и доступ. Читает авторизованный пользователь (User); без JWT → UNAUTHENTICATED (401). Разрешить company scope менеджера: набор компаний managedCompanyIds, где у актора есть право news.posts.manage.
  2. Проверка входа. Limit в диапазоне 1..30 (default 20); Status (если задан) — валидное значение PostStatus; Cursor (если задан) — декодируется в keyset-позицию (LastUpdatedAt, LastId), сбой декодирования → VALIDATION_ERROR (400).
  3. Выборка (record-level). Вернуть страницу постов, доступных актору по правилу INV-NWS-007: AuthorUserId == ActorUserId ИЛИ (AuthorCompanyId задан И входит в managedCompanyIds). Если managedCompanyIds пуст — предикат сужается до собственных постов (только свои). Опционально — фильтр по Status. Выдача включает Archived только при Status = Archived или в режиме «все статусы» (иначе soft-deleted скрыты). Порядок — updated_at desc, id desc (Id — Guid v7, детерминированный tiebreaker); keyset-предикат отбирает строки строго «после» позиции курсора (updated_at < LastUpdatedAt OR (updated_at = LastUpdatedAt AND id < LastId)). Читается на одну строку больше запрошенного Limit, чтобы определить наличие следующей страницы (индексы ix_posts_author/ix_posts_company, добор сортировки по updated_at).
  4. Маппинг и результат. Для страницы подтянуть Slug категорий (SEED-справочник, кэшируемо) и собрать AuthoringPostItem поле-в-поле; внутренние AuthorUserId/AuthorCompanyId наружу не отдаются. Сформировать страницу: Items = первые Limit; HasMore = была ли лишняя строка; NextCursor — позиция последнего отданного при HasMore, иначе null. Ответ → 200. Пустая выдача — успех 200 с пустым Items.

Диаграмма

Побочные эффекты

ТипДетали
Integration eventsnone (read-only)
External callsnone — категории из локальной таблицы, без синхронного identity/selections
Cache / projections / searchЧтение ix_posts_author/ix_posts_company; SEED PostCategory кэшируемо; собственного кэша ответа нет (ADR-0056)
Notificationsnone

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
UNAUTHENTICATED401нет/невалидный JWT«Требуется авторизация»no retry
VALIDATION_ERROR400Limit вне 1..30; неизвестный Status; повреждённый Cursor«Проверьте параметры запроса»no retry

Отказ БД — инфраструктурная ошибка (5xx, ProblemDetails), а не бизнес-ошибка. Пустая выдача (нет постов актора под фильтр) — успех 200 с пустым Items, не ошибка. Менеджер без company scope видит только свои посты — не ошибка (предикат сужается).

Предусловия и постусловия

Предусловия (истинны до сценария):

  • актор аутентифицирован (User); для доступа к постам компаний — news.posts.manage в scope этих компаний;
  • запрошенный Status (если задан) — валидное значение PostStatus; Cursor (если задан) — корректный keyset-курсор.

Постусловия (гарантированы после чтения):

  • состояние не изменяется (read-only): нет мутаций, событий и синхронных вызовов;
  • выдача содержит только посты, доступные актору по record-level INV-NWS-007 (свои AuthorUserId посты компаний в его managed-scope) — чужие посты вне scope не попадают;
  • Archived включаются только при Status = Archived либо в режиме «все статусы»; порядок — updated_at desc, id desc, keyset-страница ≤ Limit, NextCursor/HasMore согласованы; свежесть — актуальная на момент запроса (кэша нет).

Acceptance Criteria

  • Свои посты во всех статусах. Дано: автор с постами Draft/Published/Unpublished. Когда: GET /api/v1/authoring/posts без Status. Тогда: 200, страница своих постов в порядке updated_at desc, Archived скрыты (если не режим «все статусы»).
  • Фильтр по статусу. Дано: Status = Draft. Когда: GET ...?status=Draft. Тогда: только черновики актора.
  • Явный запрос архива. Дано: Status = Archived. Когда: GET ...?status=Archived. Тогда: soft-deleted посты актора включены.
  • Company scope менеджера. Дано: менеджер с news.posts.manage над компанией. Когда: GET. Тогда: выдача = свои посты ∪ посты компаний в managed-scope (INV-NWS-007).
  • Менеджер без scope. Дано: у актора нет managed-компаний. Когда: GET. Тогда: только собственные посты (предикат сужается — не ошибка).
  • Изоляция. Дано: чужой пост вне scope актора. Когда: GET. Тогда: пост не появляется в выдаче.
  • Keyset-пагинация. Дано: постов больше Limit. Когда: GET с NextCursor предыдущей страницы. Тогда: следующая страница без пропусков/дублей, HasMore корректен.
  • Пустая выдача. Дано: у актора нет постов под фильтр. Когда: GET. Тогда: 200 с пустым Items (не ошибка).
  • Невалидные параметры. Дано: Limit вне 1..30, неизвестный Status или повреждённый Cursor. Когда: GET. Тогда: 400 VALIDATION_ERROR.
  • Гость. Дано: нет/невалидный JWT. Когда: GET. Тогда: 401 UNAUTHENTICATED.

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

  • Новые optional-поля AuthoringPostItem/значения PostStatus — additive (без breaking). Удаление/ переименование параметра или поля, смена типа — breaking (versioning + rollout). Неизвестный Status в запросе → VALIDATION_ERROR (fail-fast, не тихий fallback).
  • Logs: authoring posts queried (actorUserId, status, managedScopeSize, resultCount; без PII). Metrics: news_authoring_list_requests_total{status,result}, latency p99. Traces — сквозной trace через traceparent; dashboard news-overview.
  • SLO: p99 < 300 мс (keyset + ix_posts_author/ix_posts_company). Тесты record-level изоляции обязательны (authoring, ADR-0052): актор не видит чужие посты вне своего company scope.

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

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

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