# MarkNotificationAsRead

## Permission Scope

inbox

## Overview

markNotificationAsRead adds `READ` to a Notification's `engagementStatuses` set (and `SEEN` if absent), stamping `readAt` (and `seenAt` when SEEN was newly added). The command operates on the recipient's own Notification only — a notification owned by another user is reported exactly like a missing one (`NOTIFICATION_NOT_FOUND`) so callers cannot enumerate foreign notification ids. Engagement-axis additions are monotonic: a row already containing `READ` is returned unchanged (idempotent), and `readAt` is never overwritten on subsequent calls. A row already `ARCHIVED` is terminal for engagement writes and is likewise returned unchanged (idempotent), mirroring the ARCHIVED skip in `markAllNotificationsAsRead`. Operator-owned `deliveryStatus` values (`QUEUED`, `FAILED`, `BOUNCED`) are not user-actionable — those rows do not appear in the inbox surface and cannot be marked read.

## Business Rules

- `notificationId` is required
- The Notification must exist and be owned by the caller; a missing row and a row owned by another user are both reported as `NOTIFICATION_NOT_FOUND` (no existence oracle)
- `deliveryStatus` must be `SENT` or `DELIVERED`; rows in `QUEUED`, `FAILED`, or `BOUNCED` are not user-actionable
- Idempotent: if `READ` is already in `engagementStatuses`, return the row unchanged with `readAt` preserved
- Idempotent: if `ARCHIVED` is already in `engagementStatuses`, return the row unchanged even when `READ` is absent (archived rows are terminal for engagement writes)
- When adding `READ`, also add `SEEN` if absent and stamp `seenAt`
- Stamps `readAt` only on the first transition to READ
- Writes a `NotificationDeliveryAudit` row with `eventType=READ`, `occurredBy=recipientUserId`

## Process Flow

```mermaid
flowchart TD
    A[Receive mark-read request] --> B{Notification exists AND recipientUserId == caller.userId?}
    B -->|No, missing or foreign-owned| C[Return NOTIFICATION_NOT_FOUND]
    B -->|Yes| F{deliveryStatus IN SENT, DELIVERED?}
    F -->|No| G[Return NOT_DELIVERED]
    F -->|Yes| H{READ or ARCHIVED already in engagementStatuses?}
    H -->|Yes| I[Return existing row, idempotent no-op]
    H -->|No| J[Add READ to engagementStatuses, stamp readAt]
    J --> K{SEEN already present?}
    K -->|No| L[Add SEEN, stamp seenAt]
    K -->|Yes| M[Skip SEEN add]
    L --> N[Write audit row eventType=READ, occurredBy=recipientUserId]
    M --> N
    N --> O[Return updated Notification]
```

## External Dependencies

- [notification-delivery-audit](../feature/notification-delivery-audit.md) - Writes a `READ` audit row inline as part of the engagement-axis transition

## Error Scenarios

- **NOTIFICATION_NOT_FOUND**: No Notification matches the supplied `notificationId`
- **NOT_DELIVERED**: Notification's `deliveryStatus` is one of `QUEUED`, `FAILED`, `BOUNCED` and is not user-actionable

## Test Cases

- adds READ (and SEEN if absent) to engagementStatuses on a SENT Notification owned by the caller and stamps readAt
- adds SEEN alongside READ when SEEN was not previously present
- returns the existing row unchanged when READ is already present (idempotent), without re-stamping readAt
- returns the existing row unchanged when the row is ARCHIVED (idempotent), even if READ is absent
- returns NOTIFICATION_NOT_FOUND when the Notification's recipientUserId is a different user (no existence oracle)
- returns NOTIFICATION_NOT_FOUND when notificationId does not match any row
- returns NOT_DELIVERED when deliveryStatus is QUEUED
- returns NOT_DELIVERED when deliveryStatus is FAILED
- returns NOT_DELIVERED when deliveryStatus is BOUNCED
- writes a NotificationDeliveryAudit row with eventType=READ and occurredBy=recipientUserId on the first transition
- decrements the unreadCount returned by the inbox feed query on the next call
