Entity: AuthorSubscription (лента subscription)
Дом полей подписки пользователя на автора/компанию (контекст news). Проекции — модель ответа
подписок, use-case ленты — ссылаются сюда и перечисляют только дельту.
Назначение
Личная подписка пользователя-подписчика на источник новостей — автора-пользователя (агент,
инвестор-блогер) или компанию (застройщик, агентство). Совокупность активных подписок одного
пользователя задаёт его ленту subscription — один из двух режимов ленты новостей (второй,
global, подписок не требует; бриф лент global/subscription).
Подписка — read-side состояние: она не порождает контент, а служит фильтром при сборке ленты. Лента строится запросом (pull), не пушится подписчику:
Лента subscription (list-subscription-feed, keyset по (PublishedAt, Id) DESC):
targets := AuthorSubscription where SubscriberUserId = @me and DeletedAt is null
authorUserIds := targets[TargetKind = Author ].TargetUserId
authorCompanyIds := targets[TargetKind = Company].TargetCompanyId
SELECT post
WHERE post.Status = 'Published'
AND (post.AuthorUserId = ANY(authorUserIds) OR post.AuthorCompanyId = ANY(authorCompanyIds))
ORDER BY post.PublishedAt DESC, post.Id DESC -- keyset < cursor
Дом полей поста и статус Published — post.md.
Классификация
| Поле | Значение |
|---|---|
| Контекст | news |
| Kind | Entity (самостоятельный корень своей мини-транзакции; не AggregateRoot — событий не поднимает) |
| Source of truth | этот контекст (news) |
| Таблица | news.author_subscriptions |
| Tenant isolation | вне tenant — личная подписка пользователя-подписчика (инвестора/агента), не данные компании; изоляция — по SubscriberUserId из JWT, не по TenantId |
| Статус | производный от DeletedAt (Active / Deleted) — отдельной status-колонки нет |
| Concurrency | не требуется: toggle идемпотентен через partial-unique ux_subscriptions_target + upsert |
| Аудит | Created/Updated/DeletedByUserId; актор всегда = SubscriberUserId (INV-SUB-004) |
Поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app | Идентификатор подписки. |
SubscriberUserId | Guid | Да | index ix_subscriptions_subscriber where deleted_at is null; логическая ссылка на identity.users (без FK) | JWT | Кто подписан. Берётся из контекста актора, не из тела запроса (INV-SUB-004). |
TargetKind | SubscriptionTargetKind | Да | enum (хранится как text), varchar(16); часть unique | app | Author / Company — тип источника. |
TargetUserId | Guid? | Условно (при TargetKind=Author) | index ix_subscriptions_target_user where deleted_at is null; логическая ссылка на identity.users / AuthorReplica; XOR (INV-SUB-002) | app | Автор-пользователь (агент, инвестор-блогер). |
TargetCompanyId | Guid? | Условно (при TargetKind=Company) | index ix_subscriptions_target_company where deleted_at is null; логическая ссылка на identity.companies / CompanyReplica; XOR (INV-SUB-002) | app | Компания (застройщик/агентство). |
CreatedAt | DateTimeOffset | Да | — | app | Дата первой подписки; при reactivate сохраняется. |
UpdatedAt | DateTimeOffset | Да | — | app | Последняя мутация (подписка/отписка/reactivate). |
CreatedByUserId / UpdatedByUserId / DeletedByUserId | Guid? | audit | без FK; = SubscriberUserId | app | Actor-stamping; для этой сущности всегда сам подписчик. |
DeletedAt | DateTimeOffset? | Нет | partial-фильтр всех индексов | app | Soft-отписка: null = активна, заполнено = отписан. |
Enum SubscriptionTargetKind
| Значение | Описание |
|---|---|
Author | Подписка на автора-пользователя: заполнен TargetUserId, TargetCompanyId — null. |
Company | Подписка на компанию: заполнен TargetCompanyId, TargetUserId — null. |
Value objects
Value objects не используются.
Модели JSONB
JSONB-модели не используются.
Связи
| Связь | Тип | Ключ | Правило загрузки | Delete behavior |
|---|---|---|---|---|
AuthorReplica | reference (логическая) | target_user_id при TargetKind=Author | подпись автора (имя/аватар) при рендере списка подписок — Filter реплик | реплика живёт своей жизнью; блокировка/удаление автора → пост-фильтр в ленте (запись подписки не трогается) |
CompanyReplica | reference (логическая) | target_company_id при TargetKind=Company | подпись компании (название/логотип) — Filter реплик | то же |
identity.users (подписчик) | reference (логическая) | subscriber_user_id | «я» из JWT, реплика не нужна | — |
Post (лента subscription) | derived read | AuthorUserId / AuthorCompanyId ∈ активные подписки | Filter постов по формуле из «Назначение» | — |
Дом подписей автора/компании — identity-replicas.md.
Инварианты и переходы состояний
| Инвариант | Условие | Где проверяется | Ошибка |
|---|---|---|---|
| INV-SUB-001 UniqueTarget | Не более одной живой подписки на цель: unique (subscriber_user_id, target_kind, coalesce(target_user_id, target_company_id)) where deleted_at is null | partial-unique index ux_subscriptions_target + FindBy перед вставкой | NEWS_SUBSCRIPTION_ALREADY_EXISTS |
| INV-SUB-002 TargetXor | Ровно одно из TargetUserId / TargetCompanyId заполнено, согласно TargetKind (Author→user, Company→company) | check-constraint + validator операции | NEWS_SUBSCRIPTION_TARGET_INVALID |
| INV-SUB-003 NoSelf | Нельзя подписаться на себя: при TargetKind=Author — SubscriberUserId != TargetUserId | validator операции | NEWS_SUBSCRIPTION_SELF |
| INV-SUB-004 RecordLevel | Управляет строкой только сам подписчик: SubscriberUserId берётся из JWT, чтение/мутация чужих подписок невозможны | actor-контекст операции (не из тела запроса) | NEWS_SUBSCRIPTION_FORBIDDEN |
Статус — производный от DeletedAt: Active (deleted_at is null) / Deleted. Повторная подписка
не создаёт новую строку — reactivate существующей soft-удалённой (upsert по естественному ключу),
DeletedAt → null, UpdatedAt обновляется, CreatedAt (дата первой подписки) сохраняется.
Доменные и интеграционные события
Событий нет. Подписка — личное read-side состояние подписчика; лента строится запросом (pull),
поэтому ни доменных, ни интеграционных событий (pin.news.*.v1) не публикуется — downstream-подписчиков
на «факт подписки» нет.
Аналитический сигнал клика подписки/отписки отправляет фронтенд напрямую в user-activity как
ActionEvent (ADR-0054), а не доменным событием news: так весь клиентский поведенческий трафик
остаётся в одном доме аналитики и не нагружает write-путь news.
Конкурентность и согласованность
- Toggle идемпотентен. Естественный ключ
(SubscriberUserId, TargetKind, coalesce(TargetUserId, TargetCompanyId))под partial-uniqueux_subscriptions_target(wheredeleted_at is null) гарантирует ≤ 1 живой подписки на цель. Повторныйsubscribeпри живой подписке →NEWS_SUBSCRIPTION_ALREADY_EXISTS; повторныйunsubscribeпри уже отписанной → no-op (тот же результат). - Гонка двух подписок. Два одновременных
subscribeна одну цель: partial-unique пропускает один insert, конкурент упирается в индекс и разрешается как reactivate/no-op той же строки — в итоге ровно одна активная подписка, без дублей. - Resubscribe сохраняет историю. Повторная подписка на ранее отписанную цель не создаёт новую строку —
reactivate существующей (
DeletedAt → null,UpdatedAt = now),CreatedAt(дата первой подписки) сохраняется для аналитики оттока. - Свежесть ленты. Лента
subscriptionстроится pull-запросом по активным подписке в момент запроса: подписка/отписка отражается в следующей выборке ленты сразу (без событий и кэш-инвалидации). Подпись автора/компании в списке подписок — из реплик (AuthorReplica/CompanyReplica), их свежесть eventual (лаг консюмераidentity).
Acceptance Criteria
- Подписка. Дано у пользователя нет подписки на автора
A; когда он подписывается (TargetKind=Author,TargetUserId=A); тогда создаётся активная строка (DeletedAt = null), и постыAпоявляются в лентеsubscriptionподписчика. - Повторная подписка (дубль). Дано активная подписка на
A; когдаsubscribeповторяется; тогдаNEWS_SUBSCRIPTION_ALREADY_EXISTS(INV-SUB-001), новая строка не создаётся. - Отписка и повторная подписка. Дано активная подписка; когда
unsubscribe(soft,DeletedAt = now), затем сноваsubscribe; тогда reactivate прежней строки,CreatedAtсохранён,DeletedAt = null. - XOR цели. Дано
TargetKind=AuthorбезTargetUserId(или заполнены обе цели); когда операция; тогдаNEWS_SUBSCRIPTION_TARGET_INVALID(INV-SUB-002). - Подписка на себя. Дано
TargetKind=Author,TargetUserId == SubscriberUserId; когдаsubscribe; тогдаNEWS_SUBSCRIPTION_SELF(INV-SUB-003). - Record-level. Дано попытка управлять/читать чужие подписки (иной
SubscriberUserId); когда операция; тогдаNEWS_SUBSCRIPTION_FORBIDDEN—SubscriberUserIdвсегда из JWT, не из тела (INV-SUB-004). - Идемпотентная отписка. Дано подписки нет или уже отписан; когда
unsubscribe; тогда no-op (результат — «не подписан»), без ошибки.
Индексы, хранение, безопасность
| Аспект | Значение |
|---|---|
| Индексы | PK pk_author_subscriptions (id); ux_subscriptions_target unique btree по выражению (subscriber_user_id, target_kind, coalesce(target_user_id, target_company_id)) where deleted_at is null (реализует INV-SUB-001; coalesce-форма вместо 4 колонок — иначе NULLS DISTINCT не ловит дубли по nullable-цели); ix_subscriptions_subscriber (subscriber_user_id) where deleted_at is null — «мои подписки» и сбор целей ленты; ix_subscriptions_target_user (target_user_id) where deleted_at is null; ix_subscriptions_target_company (target_company_id) where deleted_at is null — счётчики подписчиков автора/компании |
| Партиционирование / retention | none; история отписок сохраняется soft-delete-ом (аналитика оттока), физического удаления нет |
| Concurrency | не требуется: toggle идемпотентен через ux_subscriptions_target + upsert (повторный subscribe = reactivate, повторный unsubscribe — no-op) |
| Permissions / PII | Public API (../api/public/subscriptions/*): только владелец-подписчик по JWT; SubscriberUserId — из контекста актора, не из тела (INV-SUB-004); tenant-интерсепторы не применяются (вне tenant). PII нет — только id-ссылки |
| Audit | Actor-stamping (*ByUserId = подписчик) + soft-delete; переходы Active↔Deleted видны по DeletedAt/UpdatedAt |
Обратные ссылки
Автоссылки: где используется этот документ.
- API (4): DELETE /api/v1/subscriptions/{id} — Отписаться от источника, GET /api/v1/feed/subscriptions — Лента по подпискам, GET /api/v1/subscriptions — Мои подписки, POST /api/v1/subscriptions — Подписаться на автора/компанию
- Use Cases (1): UC-NWS-004. Подписки и лента subscription
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
Связанные документы
- post.md — дом полей поста, статус
Published, поляAuthorUserId/AuthorCompanyIdленты. - identity-replicas.md —
AuthorReplica/CompanyReplica(подписи в списке подписок). - API:
../api/public/subscriptions/(subscribe / unsubscribe / list),../api/public/feed/list-subscription-feed.md. - Use-case:
../use-cases/UC-NWS-004-subscription-feed.md. - ADR-0054 — клиентские
ActionEvent→ сервисuser-activity(клик подписки не идёт доменным событием).