# Route Spec

## Route ID
`hubs-feed-list`

## Endpoint
`GET /api/v1/hubs/{hubId}/feed`

## Human Description
Returns posts feed scoped to one hub, including native and shared posts. Feed cards include hub discussion metadata for the private LETS TALK discussion when available.

## Authentication
- Required: `yes`

## Request
### Query Parameters
- `cursor` (`string`, optional)
- `limit` (`number`, optional, default `20`, max `50`)
- `sort` (`string`, optional, enum `recent|trending`)

## Responses
### Success: `200 OK`
```json
{
  "success": true,
  "message": "Hub feed loaded",
  "data": {
    "items": [
      {
        "id": "pst_1",
        "author": {"id": "usr_1", "username": "sara"},
        "article": {"id": "mc_1", "kind": "text", "text": "Original content being discussed"},
        "thoughtText": "Original post thought",
        "sliderText": "Original post slider",
        "slider": {"leftLabel": "Agree", "rightLabel": "Disagree"},
        "voteSummary": {
          "totalVotes": 12,
          "resultsVisible": true,
          "minimumVotesToShow": 3,
          "leftCount": 7,
          "neutralCount": 2,
          "rightCount": 3
        },
        "voteDistribution": {
          "totalVotes": 12,
          "resultsVisible": true,
          "minimumVotesToShow": 3,
          "buckets": [
            {"position": "strong_left", "count": 3},
            {"position": "left", "count": 4},
            {"position": "neutral", "count": 2},
            {"position": "right", "count": 2},
            {"position": "strong_right", "count": 1}
          ]
        },
        "viewerVote": {
          "hasVoted": true,
          "votedAt": "2026-05-19T10:00:00Z"
        },
        "isRepost": false,
        "sourcePostId": null,
        "sourcePost": null,
        "repostCount": 2,
        "visibility": "private_locked_hub",
        "canRepost": false,
        "canShare": false,
        "hubDiscussion": {"discussionId": "hd_1", "hubId": "hub_1", "messageCount": 4, "lastMessageAt": "2026-05-19T10:00:00Z"},
        "createdAt": "2026-05-19T09:00:00Z"
      }
    ],
    "nextCursor": null
  }
}
```

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

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

### Error: `403 Forbidden`
When returned:
- User is not a member of this hub.

Body:
```json
{"success": false, "error": {"code": "HUB_MEMBERSHIP_REQUIRED", "message": "Hub membership required.", "details": {}}}
```

### Error: `404 Not Found`
When returned:
- Hub does not exist.

Body:
```json
{"success": false, "error": {"code": "HUB_NOT_FOUND", "message": "Hub does not exist.", "details": {}}}
```

## Data & Caching Dependencies
- **Spanner Tables:** `post_hub_targets, post_share_events, post_share_targets, posts, post_main_contents, users, post_vote_counters, post_hub_vote_counters, post_repost_counters, post_vote_receipts, post_vote_scope_receipts, hub_discussions (Read)`
- **Redis Cache:** `active_users_day (HLL update)`
- **GCS Storage:** `None`
- **Edge Cache (CDN):** `No`

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

## Notes
- Hub discussion is private to active hub members and separate from public post comments.
- Opening LETS TALK uses the `hubDiscussion.discussionId`.
- `sliderText` is the visible slider title/prompt; there is no separate `sliderTitle` field.
- Repost cards keep top-level `article` null and include expanded `sourcePost` with the original post's display content. Clients render original/source fallback as `sourcePost.article`, then `sourcePost.thoughtText`, then `sourcePost.sliderText`. Repost-owned display content is limited to `thoughtText` and optional current-frame `sliderText`/`slider`.
- `sourcePost.slider` may be present as source slider metadata, but only top-level `sliderText`/`slider` make the current repost frame votable.
- Private locked hub posts appear here for active hub members and return `canRepost=false`, `canShare=false`.
- Hub feed vote summaries use the hub vote scope and hide bucket counts until at least 3 votes exist in that hub scope.
- Hub feed cards include `viewerVote`, which exposes only whether the viewer voted and when. The selected vote position is never returned.
- Slider cards include `voteDistribution` only when the authenticated viewer already voted, so the client can show the breakdown without an extra distribution request.
