# Route Spec

## Route ID
`notifications-list`

## Endpoint
`GET /api/v1/notifications`

## Human Description
Returns the durable notifications inbox and its authoritative unread count. Includes vote-on-post, comment-on-post, repost, feed share, follow, hub post, share/repost-to-hub, hub invitation, hub join-request, member-joined, member-removed, and hub role-change events. Hub discussion/chat messages never create notification events. Saved discussions, reports, and blocks also do not create user-facing notifications. Muted hub content activity is suppressed from this feed.

## Authentication
- Required: `yes`

## Request
### Query Parameters
- `cursor` (`string`, optional)
- `limit` (`number`, optional, default `30`, max `100`)
- `unreadOnly` (`boolean`, optional, default `false`)

## Responses
### Success: `200 OK`
```json
{
  "success": true,
  "message": "Notifications loaded",
  "data": {
    "items": [
      {
        "id": "ntf_1",
        "type": "hub_invite_received",
        "actor": {"id": "usr_9", "username": "ahmed", "isAnonymous": false},
        "postId": null,
        "sourcePostId": null,
        "commentId": null,
        "hub": {"id": "hub_2", "name": "Family Weiss"},
        "invitationId": "inv_1",
        "joinRequestId": null,
        "createdAt": "2026-02-18T12:00:00Z",
        "read": false
      },
      {
        "id": "ntf_2",
        "type": "post_commented",
        "actor": {"id": "usr_7", "username": "mona", "isAnonymous": false},
        "postId": "pst_1",
        "sourcePostId": null,
        "commentId": "cmt_1",
        "hub": null,
        "invitationId": null,
        "joinRequestId": null,
        "createdAt": "2026-02-18T12:03:00Z",
        "read": false
      },
      {
        "id": "ntf_3",
        "type": "hub_member_joined",
        "actor": {"id": "usr_12", "username": "samir", "isAnonymous": false},
        "postId": null,
        "hub": {"id": "hub_2", "name": "Policy Hub"},
        "invitationId": null,
        "joinRequestId": "jr_1",
        "createdAt": "2026-02-18T12:05:00Z",
        "read": false
      }
    ],
    "nextCursor": null,
    "unreadCount": 3
  }
}
```

### Notification Types
| Type | Created When |
|---|---|
| `post_voted` | A user votes on another user's post. |
| `post_commented` | A user comments on another user's post. |
| `post_reposted` | A user reposts another user's original post. Global when Public/`public_hub`; Hub-scoped per membership for `private_locked_hub`. |
| `post_shared_to_feed` | A user shares another user's exact post or repost frame to their feed. |
| `user_followed` | A user follows another user. |
| `hub_post_created` | A new original post/article is created inside a hub. Active members of that Hub, excluding the actor. |
| `post_shared_to_hub` | An existing post is shared into a hub, or a repost is published into a hub for hub members. Hub-scoped for ranking and member alerts. Original authors outside the target Hub are not notified for a plain share. For repost source authors, use `post_reposted`. |
| `hub_invite_received` | A hub invitation is created for an existing user. |
| `hub_join_request_created` | A join request is waiting for hub admin/manager approval. |
| `hub_join_request_approved` | A join request is approved. |
| `hub_join_request_rejected` | A join request is rejected. |
| `hub_member_joined` | A user becomes an active hub member. |
| `hub_member_removed` | The recipient is removed from a hub. |
| `hub_role_changed` | A member's hub role is changed. |

Hub discussion/chat messages do not create notification events.

Saved hub discussions, reports, and blocks do not create user-facing notification events.

Hub mute suppresses `hub_post_created`, `post_shared_to_hub`, and Hub-scoped `post_reposted` for the muted user/hub. Suppressed content activity does not appear in this in-app notifications feed. Mute does not change feed ranking.

A notification with a non-null `hub_id` is returned only when the viewer is Hub-ready and an active member of that exact Hub. Historical unauthorized rows are retained and hidden dynamically. Three recipient-only event types have narrow Hub-ready exceptions because active membership does not exist at delivery time: the recipient's own `hub_invite_received`, `hub_join_request_rejected`, and `hub_member_removed` rows. Every other row for that Hub remains hidden without active membership. Legacy null-hub `post_reposted` rows for a `private_locked_hub` repost require Hub readiness plus active membership in at least one current `post_hub_targets` Hub for that post. Null-hub `post_reposted` rows that reference a known `public`/`public_hub` post stay global. Null `post_id`, missing posts, and unknown or malformed visibility fail closed.

## Delivery Contract
- A successfully committed eligible notification atomically produces one durable inbox row and one delivery-outbox row.
- The primary API worker leases delivery outside the mutation request, rechecks current authorization/read state, publishes the Redis/SSE signal, and attempts Firebase Cloud Messaging delivery to every eligible active device token owned by the recipient. Redis/SSE and FCM have independent completion, retry, and dead-letter checkpoints, and each snapshotted FCM device has its own checkpoint. A Redis retry therefore does not resend a completed OS alert, and a failed device does not make already-completed devices repeat. Leases and bounded jittered retries recover process crashes and transient Redis/FCM failures.
- The mobile client treats this endpoint as the source of truth: it fetches it at startup/resume, after an SSE or push signal, and after reconnecting from a dropped stream.
- App-icon and in-app unread badges use `unreadCount`; they are not inferred from the number of push messages received.
- Push/SSE hints may be duplicated or delayed. Android tags and APNs collapse IDs use notification `id`; clients also deduplicate by that ID and reconcile with this endpoint.
- Visible OS copy is deliberately privacy-safe and generic (for example, “Someone voted on your post.”). The navigation data envelope includes `notificationId`, `type`, `recipientUserId`, `actorIsAnonymous`, and only the applicable actor/post/source/comment/Hub/invitation/join-request IDs. Before presenting a foreground alert or honoring an opened/initial intent, mobile requires `recipientUserId` to match the active backend JWT subject; malformed, missing, or cross-account payloads fail closed.
- Votes are always anonymous. Historical activity created by an anonymous user also preserves actor anonymity. These notifications return `actor: {"id":"","username":"","isAnonymous":true}`; clients render a neutral label such as “Someone” and must not attempt to derive or expose the actor identity.
- Hub mute is evaluated before persistence. A muted Hub content event therefore reaches none of the inbox, stream, push, app-icon badge, or Hub-footer badge surfaces.

### Error: `401 Unauthorized`
When returned:
- Missing or invalid access token.

Body:
```json
{"success": false, "error": {"code": "UNAUTHORIZED", "message": "Authentication required.", "details": {}}}
```

## Data & Caching Dependencies
- **Spanner Tables:** `notifications (Read)`, `notification_push_outbox (worker read/write)`, `notification_push_device_deliveries (worker read/write)`
- **Redis Cache:** `None`
- **GCS Storage:** `None`
- **Edge Cache (CDN):** `No`

## Side Effects
- None (read-only endpoint).
