POST /api/public/notifications/inbox/{notificationId}/read — Отметить прочитанным
Назначение
Авторизованный пользователь отмечает своё in-app уведомление
(NotificationEntity) прочитанным (Sent → Read, проставляется ReadAt) и
получает обновлённый счётчик непрочитанных. Поддерживает одиночную отметку, батч (несколько id) и «отметить
все». Owning-контекст — notifications, feature group inbox.
| Поле | Значение |
|---|
| Сервис/контекст | notifications |
| API area | public (JWT пользователя) |
| Feature group | inbox |
| Статус | draft |
| Документ | docs/06-services/notifications/api/public/mark-notification-read.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User |
| Auth policy / Permissions | notifications.inbox.read (own) |
| Record-level | владелец = RecipientUserId (актор); чужое уведомление недоступно (INV-NTF-6) |
| Tenant isolation | доступ по RecipientUserId |
| Audit | update (ReadAt, Version) |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/public/notifications/inbox/{notificationId}/read (или /inbox/read с телом-списком / all=true) |
| Success status | 200 |
| Idempotency header | не применяется (повтор — no-op, уже Read) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
RecipientUserId | identity claims (JWT sub) | Guid | Да | не Guid.Empty | владелец |
NotificationId | route | Guid? | Условно | обязателен, если MarkAll = false | own record |
NotificationIds | body | IReadOnlyList<Guid>? | Нет | ≤ 200 элементов (батч) | own records |
MarkAll | query | bool | Нет | default false; true — отметить все непрочитанные | — |
Модель ответа MarkReadResultModel
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
MarkedCount | int | Да | число переведённых в Read | — | Сколько уведомлений отмечено. |
UnreadCount | int | Да | число Status != Read при Channel = InApp | — | Остаток непрочитанных (бейдж). |
Алгоритм
- Контекст и доступ. Операцию выполняет авторизованный пользователь (
RecipientUserId из JWT; нет
валидного JWT → UNAUTHENTICATED); доступ — только к собственным уведомлениям.
- Проверка входа. Задан
NotificationId/NotificationIds или MarkAll = true; размер батча ≤ 200.
Иначе → VALIDATION_FAILED.
- Выбор целевых уведомлений. При
MarkAll = true — все in-app уведомления получателя в статусе Sent.
Иначе — уведомления по переданным id; каждое должно принадлежать актору и быть каналом InApp (INV-NTF-6):
чужое или не-InApp → NTF_READ_FORBIDDEN; несуществующий NotificationId → NTF_NOTIFICATION_NOT_FOUND.
- Переход. Каждое выбранное уведомление переводится
Sent → Read (ReadAt = now, INV-NTF-5); уже
Read — no-op (в MarkedCount не учитывается).
- Согласованность. Изменения применяются батчем в одном согласованном изменении; правка не должна
затирать конкурентную. После фиксации бейдж непрочитанных обновляется realtime (сигнал
NotificationStatusChanged → SignalR).
- Результат. Возвращается
MarkReadResultModel (MarkedCount, UnreadCount), HTTP 200.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Уведомления | realtime-обновление бейджа непрочитанных (SignalR) |
Семантика батча и конкурентность
- Атомарная валидация батча. Перед переходом проверяется весь набор целей: любой id, не принадлежащий
актору или не
InApp (INV-NTF-6) → NTF_READ_FORBIDDEN и ничего не отмечается; любой несуществующий id
→ NTF_NOTIFICATION_NOT_FOUND, батч отклонён целиком. Так частичное «полу-применение» исключено.
- Уже
Read — no-op. В валидном батче записи, уже находящиеся в Read, пропускаются и не входят в
MarkedCount (идемпотентность повтора). MarkAll = true берёт только in-app в статусе Sent.
- Только
Sent → Read. Отмечается уведомление в Sent (INV-NTF-5). Ещё не доставленный in-app (Pending)
как непрочитанный в бейдже учитывается, но целью явной отметки не является (перейдёт в Sent доставкой).
- Конкурентность. Гонка
mark-read ↔ dispatcher/retry (смена статуса той же записи) разрешается
оптимистично по версии записи: правка не должна затирать более свежее состояние; проигравший перечитывает и
повторяет. Две параллельные отметки одного id → одна отмечает, вторая видит Read (no-op).
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
UNAUTHENTICATED | 401 | нет/невалидный JWT | «Требуется авторизация» | no retry |
VALIDATION_FAILED | 400 | ни id, ни MarkAll; батч > 200 | «Проверьте параметры» | no retry |
NTF_READ_FORBIDDEN | 403 | уведомление не принадлежит актору или не InApp (INV-NTF-6) | «Недоступно» | no retry |
NTF_NOTIFICATION_NOT_FOUND | 404 | NotificationId не найден | «Уведомление не найдено» | no retry |
Пред- и постусловия
- Предусловие. Пользователь авторизован; цели — его собственные in-app уведомления; батч ≤ 200.
- Постусловие (успех). Валидные
Sent-цели переведены в Read (ReadAt = now, Version ++); бейдж
непрочитанных пересчитан и разослан realtime; уже Read и Pending не тронуты.
Совместимость и наблюдаемость
- Батч/
MarkAll — additive поверх одиночной отметки. Повтор — идемпотентный no-op.
- Logs:
marked-read (RecipientUserId, MarkedCount); metrics: notifications_marked_read_total.
Acceptance criteria
- Given непрочитанное in-app уведомление актора, when отметка по id, then
200, Sent → Read,
MarkedCount = 1, UnreadCount уменьшен, бейдж обновлён realtime.
- Given уже
Read уведомление, when повторная отметка, then 200, MarkedCount = 0 (no-op).
- Given
MarkAll = true, when отметка, then все Sent-in-app актора → Read, UnreadCount = 0.
- Given батч с чужим или не-
InApp id, when отметка, then 403 NTF_READ_FORBIDDEN, ничего
не отмечено.
- Given несуществующий
NotificationId, when отметка, then 404 NTF_NOTIFICATION_NOT_FOUND.
- Given ни id, ни
MarkAll, либо батч > 200, when запрос, then 400 VALIDATION_FAILED.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы