# Route Spec

## Route ID
`posts-feed-list`

## Endpoint
`GET /api/v1/posts`

## Human Description
Returns the main Home feed timeline. Authenticated users get a personalized timeline from their own posts, followed users, active hub co-members, joined hubs, shared posts, and public posts that followed users or hub co-members interacted with. Guests and users with no personal feed sources get public posts from admin-managed default feed users.

## Authentication
- Required: `no` (optional bearer enriches and personalizes the response)
- Auth type: `none|bearer token`

## Request
### Headers
- `Authorization: Bearer <token>` (optional)

### Query Parameters
- `hubId` (`string`, optional): filter to one hub.
- `cursor` (`string`, optional)
- `limit` (`number`, optional, default `20`, max `50`)
- `sort` (`string`, optional, enum `recent|trending`, default `recent`)

## Feed Source Rules
- With a valid bearer token and at least one personal feed source, return the authenticated user's personalized feed.
- Without a bearer token, return public posts from default feed users.
- With a valid bearer token but no followed users, joined hubs, or other personal feed sources, return public posts from default feed users.
- Default feed users are admin-managed accounts whose public posts seed the first-run feed.
- Personalized Home feed candidates include:
  - Current user's own public and public-hub posts.
  - Public and public-hub posts from followed users.
  - Public and public-hub posts from active co-members in at least one hub the viewer belongs to.
  - Posts targeted to hubs where the viewer is an active member, including private locked hub posts for those selected hubs.
  - Public and public-hub posts that followed users or active hub co-members interacted with by voting, commenting, reposting, or sharing.
- `private_locked_hub` posts appear only for authenticated viewers who are active members of at least one selected hub for that post.
- `private_locked_hub` posts never appear to guests, followers outside the selected hubs, or through friend-interaction discovery unless the viewer is an active member of one selected hub for that post.
- Actions from feed cards such as follow, create post, vote, comment, join hub, or share still require authentication in their own routes. Saving is available only from the hub discussion/chat surface.

## Feed Context
- Feed cards may include `feedContext` so the client can explain why a post appears in Home.
- `feedContext.reason` values:
  - `own_post`
  - `followed_author`
  - `hub_co_member_author`
  - `joined_hub_post`
  - `friend_interaction`
  - `shared_to_hub`
  - `default_feed_user`
- For `friend_interaction`, `feedContext.interactionType` is one of `post_voted|post_commented|post_reposted|post_shared_to_feed|post_shared_to_hub`.
- `feedContext.actors` contains a small preview of followed users or hub co-members who caused the feed candidate.

## Responses
### Success: `200 OK`
```json
{
  "success": true,
  "message": "Feed loaded",
  "data": {
    "feedSource": "personalized",
    "items": [
      {
        "id": "pst_1",
        "author": {"id": "usr_1", "username": "sara", "fullName": "Sara Ahmed", "profilePhotoUrl": null},
        "article": {
          "id": "mc_1",
          "kind": "link",
          "text": null,
          "imageUrl": null,
          "linkUrl": "https://example.com/article",
          "linkTitle": "Article title",
          "linkDescription": "Article summary"
        },
        "thoughtText": "My take on this article",
        "sliderText": "Do you support this proposal?",
        "slider": {"leftLabel": "Agree", "rightLabel": "Disagree"},
        "voteSummary": {
          "totalVotes": 84,
          "resultsVisible": true,
          "minimumVotesToShow": 3,
          "leftCount": 57,
          "neutralCount": 8,
          "rightCount": 19
        },
        "voteDistribution": {
          "totalVotes": 84,
          "resultsVisible": true,
          "minimumVotesToShow": 3,
          "buckets": [
            {"position": "strong_left", "count": 20},
            {"position": "left", "count": 37},
            {"position": "neutral", "count": 8},
            {"position": "right", "count": 12},
            {"position": "strong_right", "count": 7}
          ]
        },
        "viewerVote": {
          "hasVoted": true,
          "votedAt": "2026-02-18T10:15:00Z"
        },
        "isRepost": false,
        "sourcePostId": null,
        "sourcePost": null,
        "repostCount": 7,
        "visibility": "public_hub",
        "canRepost": true,
        "canShare": true,
        "sharedInHub": {"hubId": "hub_2", "hubName": "Policy Hub", "sharedBy": {"id": "usr_8", "username": "ali"}},
        "feedContext": {
          "reason": "friend_interaction",
          "interactionType": "post_commented",
          "hubId": null,
          "actors": [{"id": "usr_7", "username": "mona", "profilePhotoUrl": null}]
        },
        "createdAt": "2026-02-18T10:00:00Z"
      }
    ],
    "nextCursor": "cur_2"
  }
}
```

### Error: `401 Unauthorized`
When returned:
- An `Authorization` header is supplied but the access token is invalid.

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

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

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

## Repost Rendering
- Feed cards render the current post frame's optional `article`, `thoughtText`, `sliderText`, and `slider`.
- `sliderText` is the visible slider title/prompt for the current frame; there is no separate `sliderTitle` field.
- `slider` is current-frame vote-control metadata. It provides labels for voting on this card frame and does not describe fallback source text.
- When `isRepost=true`, top-level `article` is null. The original article is exposed as `sourcePost.article`.
- Clients render the original/source fallback in this order: `sourcePost.article`, then `sourcePost.thoughtText`, then `sourcePost.sliderText`.
- `sourcePost.slider` may be present as source slider metadata, but `sourcePost.slider`/`sourcePost.sliderText` do not make the current repost frame votable. Only top-level `sliderText`/`slider` do.
- Repost-owned display content is limited to `thoughtText` and optional `sliderText`/`slider`.
- Repost cards do not expose a repost action because reposting a repost is not allowed.
- Private locked hub cards do not expose repost or share actions.

## Vote Result Visibility
- Feed card `voteSummary` hides bucket counts until the displayed vote scope has at least 3 votes.
- When hidden, `voteSummary.resultsVisible=false` and `leftCount`, `neutralCount`, and `rightCount` are `null`.
- Feed cards include `viewerVote`, which exposes only whether the viewer voted and when. It never returns the viewer's selected position.
- Slider cards include `voteDistribution` only when the authenticated viewer already voted. This lets the app open the vote breakdown from the feed payload. When aggregate results are still hidden, `voteDistribution.resultsVisible=false` and `buckets` is empty.
