# Route Spec

## Route ID
`posts-vote-create`

## Endpoint
`POST /api/v1/posts/{postId}/vote`

## Human Description
Casts the current user's one-time 5-position slider vote into anonymous post and hub vote pools. Only posts with `slider` are votable.

## Authentication
- Required: `yes`
- Auth type: `bearer token`
- Registered public-profile basics are required.
- Phone confirmation and contact access are not required for public voting, including public Hub-originated content viewed in General.

## Request
### Headers
- `Content-Type: application/json`

### Body
```json
{
  "position": "left"
}
```

### Validation Rules
- `position`: required enum `strong_left|left|neutral|right|strong_right`.
- Viewer must be allowed to see the post. For `private_locked_hub`, the viewer must be the author or an active member of at least one selected hub.
- User must not already have a `post_vote_receipts` row for this post.
- Eligible hub scopes are calculated for every active membership where the post is already targeted or shared. There is no minimum Hub size.
- A public or `public_hub` post receives its one-time global vote and every eligible Hub scope vote. A `private_locked_hub` post returns `VOTE_NOT_ALLOWED` only when the voter has no eligible active-member Hub scope.

## Responses
### Success: `201 Created`
```json
{
  "success": true,
  "message": "Vote cast",
  "data": {
    "postId": "pst_1",
    "voteReceipt": {
      "hasVoted": true,
      "votedAt": "2026-05-19T10:00:00Z",
      "hubVoteScope": null
    },
    "summary": {
      "totalVotes": 123,
      "resultsVisible": true,
      "leftCount": 78,
      "neutralCount": 12,
      "rightCount": 33
    }
  }
}
```

### Error: `403 Forbidden`
```json
{"success": false, "error": {"code": "VOTE_NOT_ALLOWED", "message": "You cannot vote on this post.", "details": {}}}
```

When returned:
- The post is private Hub-only content and the voter is not Hub-ready or is not an active member of any Hub where the post is visible.

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

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

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

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

### Error: `409 Conflict`
When returned:
- User already voted on this post frame.
- The post has no slider.

Body:
```json
{"success": false, "error": {"code": "VOTE_ALREADY_CAST", "message": "You already voted on this post.", "details": {}}}
```

Alternative body when the post has no slider:
```json
{"success": false, "error": {"code": "POST_NOT_VOTABLE", "message": "Post does not have a slider.", "details": {}}}
```

### Error: `422 Unprocessable Entity`
When returned:
- Invalid `position` value.

Body:
```json
{"success": false, "error": {"code": "VALIDATION_FAILED", "message": "Please fix highlighted fields.", "details": {}}}
```

## Data & Caching Dependencies
- **Spanner Tables:** `post_vote_receipts, post_vote_scope_receipts, post_feed_activity_events, notifications (Write), post_vote_counters, post_vote_bucket_counters, post_hub_vote_counters, post_hub_vote_bucket_counters (Update), post_hub_targets, post_share_targets, hub_memberships (Read)`
- **Redis Cache:** `None`
- **GCS Storage:** `None`
- **Edge Cache (CDN):** `No`

## Side Effects
- Inserts `post_vote_receipts(post_id, user_id, voted_at)` without the selected position.
- Computes eligible hub scopes at vote time from current post hub targets/shares and active hub memberships.
- Inserts `post_vote_scope_receipts` rows for the global scope and each eligible hub scope without the selected position.
- Increments the global post anonymous vote counters and detailed bucket counters.
- Increments anonymous hub vote counters and detailed bucket counters only for eligible hubs at vote time.
- Creates `post_voted` notification for the post owner when voter is not owner.
- Records a `post_voted` feed activity event for Home feed friend-interaction discovery.

## Visibility Rules
- The response never returns the user's selected position.
- Aggregate bucket counts in `summary` are visible from the first vote.
- Hub-specific counts are not backfilled when the user later joins a hub, when the post is later shared into another hub, or when an older vote's Hub scope was omitted by the superseded minimum-member policy.

## Repost Notes
- Votes apply to the current post frame's slider.
- If the target post is a repost, voting uses the repost's independent slider and anonymous vote pools, not the original source post's slider.
- Reposts without top-level `sliderText`/`slider` are not votable even if their `sourcePost` has `sliderText` or `slider`.
