# Route Spec

## Route ID
`posts-create`

## Endpoint
`POST /api/v1/posts`

## Human Description
Creates an original post with optional reusable `article`, optional author `thoughtText`, optional `sliderText`/`slider`, and explicit visibility. At least one display field is required.

## Authentication
- Required: `yes`

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

### Body
```json
{
  "article": {
    "text": "The article, link, media, or text being discussed",
    "assetId": "ast_optional_image",
    "linkUrl": "https://example.com/article",
    "linkTitle": "Optional link title",
    "linkDescription": "Optional link description"
  },
  "thoughtText": "My take on this article",
  "sliderText": "Do you support this proposal?",
  "slider": {
    "leftLabel": "Agree",
    "rightLabel": "Disagree"
  },
  "visibility": "public_hub",
  "hubIds": ["hub_1", "hub_2"]
}
```

### Validation Rules
- At least one of `article`, `thoughtText`, or `sliderText` is required.
- `article`: optional. When supplied, at least one of `article.text`, `article.assetId`, or `article.linkUrl` is required.
- `article.text`: optional, max 3000 chars.
- `article.assetId`: optional, must be an owned completed `post_image` media/image asset.
- `article.linkUrl`: optional, valid URL.
- `article.linkTitle`: optional, max 200 chars.
- `article.linkDescription`: optional, max 500 chars.
- `thoughtText`: optional, max 250 chars. This is the author's own thought/take.
- `sliderText`: optional, 1-260 chars. This is the visible slider title/prompt shown with the post frame.
- There is no separate `sliderTitle` field.
- `slider`: required when `sliderText` is supplied and omitted when `sliderText` is omitted.
- `slider.leftLabel` and `slider.rightLabel`: required when `slider` is supplied, 1-20 chars, must differ.
- `visibility`: required enum `public|public_hub|private_locked_hub`.
- `hubIds`: required for `public_hub` and `private_locked_hub`, omitted or empty for `public`, max 10.
- User must be an active member of each supplied hub.
- `private_locked_hub` posts are visible only to active members of the selected hubs and the author.
- `private_locked_hub` posts are not repostable, not shareable to other hubs, and do not expose a public share link.

## Responses
### Success: `201 Created`
```json
{"success": true, "message": "Post created", "data": {"postId": "pst_1"}}
```

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

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

### Error: `422 Unprocessable Entity`
When returned:
- Invalid post payload (text, labels, or media/link constraints).

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

### Error: `403 Forbidden`
When returned:
- User is not an active member of one or more supplied hubs.

Body:
```json
{"success": false, "error": {"code": "HUB_MEMBERSHIP_REQUIRED", "message": "Join target hub before posting there.", "details": {}}}
```

## Data & Caching Dependencies
- **Spanner Tables:** `post_main_contents, posts, post_hub_targets, post_feed_activity_events (Write), hub_memberships (Read when hubIds supplied), user_social_counters (Update), hub_notification_settings (Read when hubIds supplied), notifications (Write when hubIds supplied)`
- **Redis Cache:** `None`
- **GCS Storage:** `None`
- **Edge Cache (CDN):** `No`

## Side Effects
- Creates a new original post frame and, when `article` is supplied, a reusable article entity.
- Stores visibility and selected hub targets when `visibility` is hub-scoped.
- Initializes independent anonymous slider analytics aggregates only when the post has a slider.
- Adds post to creator feed and optional target hub feeds.
- Records a `post_created` feed activity event for Home feed ranking and social context.
- When `hubIds` are supplied, creates `hub_post_created` notifications for active members of each target hub, excluding the creator.
- Skips `hub_post_created` notification creation for active members who muted content activity for that hub.

## Repost Relationship
- Original posts may have article-only, thought-only, slider-only, or combined content.
- Reposts point to an original post through `sourcePostId`, do not include a new `article`, and provide new `thoughtText` and/or `sliderText`/`slider`.
- Repost cards keep top-level `article` null. Original article content is returned as `sourcePost.article`.
- Clients render original/source fallback as `sourcePost.article`, then `sourcePost.thoughtText`, then `sourcePost.sliderText`.
- Repost `slider` is current-frame vote-control metadata. `sourcePost.slider` may be present as source metadata, but it does not make the repost frame votable.
- Reposting an existing repost is not allowed.
