openapi: 3.0.3

info:
  title: ポストメッシュ API
  description: |
    ポストメッシュは、複数のSNSプラットフォームに同時投稿できるサービスです。
    この API を使って、SNS アカウントの連携管理、メディアのアップロード、投稿の作成・管理を行えます。

    ## 対応プラットフォーム

    YouTube / TikTok / Instagram / Threads / X (Twitter) / Facebook

    ## 認証

    すべての API エンドポイントには、`Authorization` ヘッダーに Bearer トークン（API キー）が必要です。

    ```
    Authorization: Bearer YOUR_API_KEY
    ```

    API キーはポストメッシュダッシュボードから発行できます。

    ## 共通仕様

    - リクエスト・レスポンスのフィールド名はすべて **snake_case** です。
    - タイムスタンプはすべて **ISO 8601** 形式（例: `2026-02-17T10:00:00.000Z`）です。
    - 投稿一覧エンドポイントはページネーション付きで結果を返します。
  version: 1.0.0

servers:
  - url: https://post-mesh.com/api/v1
    description: 本番環境

security:
  - apiKey: []

tags:
  - name: Connections
    description: SNS アカウント連携の管理
  - name: Media
    description: メディアファイル（動画・画像）のアップロード
  - name: Posts
    description: 投稿の作成・管理

paths:
  /connections:
    get:
      tags:
        - Connections
      summary: 連携一覧を取得
      description: チームに紐づくすべての有効な SNS アカウント連携を返します。
      operationId: listConnections
      parameters:
        - name: platform
          in: query
          description: プラットフォームでフィルタ
          required: false
          schema:
            $ref: "#/components/schemas/Platform"
      responses:
        "200":
          description: 連携一覧
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Connection"
              example:
                data:
                  - id: "conn_abc123"
                    platform: "youtube"
                    account_name: "My Channel"
                    icon_url: "https://storage.googleapis.com/..."
                    connected_at: "2026-01-15T08:30:00.000Z"
                  - id: "conn_def456"
                    platform: "tiktok"
                    account_name: "@myaccount"
                    icon_url: null
                    connected_at: "2026-02-01T12:00:00.000Z"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

  /media/upload-url:
    post:
      tags:
        - Media
      summary: メディアアップロード URL を生成
      description: |
        メディアファイル（動画・画像）アップロード用の署名付き URL を生成します。

        **対応ファイル形式:**
        - 動画: `.mp4`, `.mov`
        - 画像: `.jpg`, `.jpeg`, `.png`, `.webp`

        **サイズ上限:**
        - 動画: 最大 1 GB
        - 画像: 最大 20 MB

        **アップロード手順:**

        1. このエンドポイントで `upload_url` と `media_id` を取得
        2. `upload_url` に `PUT` リクエストでファイルをアップロード
        3. 投稿作成時に `media_id` を指定

        `PUT` リクエストには `Content-Type` ヘッダーが**必須**です。
        署名付き URL はリクエストで指定した `mime_type` で生成されるため、
        一致しない `Content-Type` を指定すると `403` エラーになります。

        ```bash
        curl -X PUT \
          -H "Content-Type: video/mp4" \
          --upload-file ./my-video.mp4 \
          "UPLOAD_URL"
        ```

        ```javascript
        await fetch(uploadUrl, {
          method: 'PUT',
          headers: { 'Content-Type': 'video/mp4' },
          body: file,
        });
        ```

        アップロード URL の有効期限は **15 分**です。期限切れの場合は再度このエンドポイントを呼び出してください。
      operationId: createMediaUploadUrl
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateMediaUploadUrlRequest"
            example:
              mime_type: "video/mp4"
              size_bytes: 52428800
              name: "my-video.mp4"
      responses:
        "201":
          description: アップロード URL を生成しました
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/MediaUpload"
              example:
                data:
                  media_id: "media_abc123"
                  upload_url: "https://storage.googleapis.com/..."
                  name: "my-video.mp4"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/SubscriptionRequired"
        "500":
          $ref: "#/components/responses/InternalError"

  /posts:
    get:
      tags:
        - Posts
      summary: 投稿一覧を取得
      description: ページネーション付きの投稿一覧を返します。ステータスやプラットフォームでフィルタできます。
      operationId: listPosts
      parameters:
        - name: page
          in: query
          description: ページ番号（1 始まり）。1 未満の場合は 1 にクランプされます。
          required: false
          schema:
            type: integer
            default: 1
        - name: limit
          in: query
          description: 1 ページあたりの件数。1〜100 の範囲にクランプされます。
          required: false
          schema:
            type: integer
            default: 20
        - name: status
          in: query
          description: ステータスでフィルタ
          required: false
          schema:
            $ref: "#/components/schemas/PostListStatus"
        - name: platform
          in: query
          description: プラットフォームでフィルタ
          required: false
          schema:
            $ref: "#/components/schemas/Platform"
      responses:
        "200":
          description: 投稿一覧
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/PostSummary"
                  pagination:
                    $ref: "#/components/schemas/Pagination"
              example:
                data:
                  - id: "pub_abc123"
                    title: "初めての動画"
                    status: "posted"
                    category: "video"
                    platforms:
                      - platform: "youtube"
                        status: "posted"
                      - platform: "tiktok"
                        status: "posted"
                    display_at: "2026-02-17T10:00:00.000Z"
                pagination:
                  total: 42
                  page: 1
                  limit: 20
                  has_next: true
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "500":
          $ref: "#/components/responses/InternalError"

    post:
      tags:
        - Posts
      summary: 投稿を作成
      description: |
        新しい投稿を作成し、指定したプラットフォームに公開します。

        **投稿カテゴリ:**
        - `video` — 事前にアップロードした動画の `media_id` が必要です。
        - `image` — 事前にアップロードした画像の `media_ids` が必要です。
        - `text` — メディア不要です。
        - `tree` — ツリー（連続投稿）です。`tree_cards` が必要です。

        **ツリー投稿（`category: tree`）:**
        - 投稿先はXとThreadsだけです。他のプラットフォームを含めると400になります（下書きでも同様）。
        - 同じSNSアカウントを`targets`に重複して指定すると400になります。
        - 本文とメディアはカード（`tree_cards`）に持たせます。`targets[]`では`connection_id`だけを指定します。`caption` / `youtube_title` / `is_ai_generated` / `tiktok_draft` / `tiktok_auto_add_music`を指定すると400になり、それ以外の未知フィールドは無視されます。
        - 全投稿先に同じカード内容を配信します。投稿先ごとにカードを変えることはできません。
        - 公開・予約投稿ではカードが2件以上必要です。`draft: true`の下書きは1件でも保存できます。
        - `scheduled_at` / `draft` は他カテゴリと同じように使えます。`media_id` / `media_ids` / `thumbnail_time` は使いません。

        **予約投稿:**
        - `scheduled_at` を省略すると即時投稿になります。
        - `scheduled_at` に未来の ISO 8601 日時を指定すると予約投稿になります。

        **下書き:**
        - `draft: true` を指定すると下書きとして保存し、SNS へは配信されません。
        - `scheduled_at` とは同時に指定できません（400 になります）。
        - 下書きでは `targets[].caption` と `youtube_title` を省略できます。
        - 下書きから公開への昇格は、Web アプリの編集画面から行います（API に更新エンドポイントはありません）。

        **SNS連携の状態による 409:**
        - 投稿先の連携が切れている・トークンが失効している場合は `POSTING_READINESS_RECONNECT_REQUIRED` で 409 を返します。連携画面で再連携してから再送してください。
        - SNS 側の一時的なエラーで投稿準備を確認できなかった場合は `POSTING_READINESS_UNAVAILABLE` で 409 を返します。時間をおいて再送してください。
        - どちらの場合も投稿は作成されません。`draft: true` の下書きでは配信しないため、この確認は行いません。

        **ターゲット:**
        各ターゲットは連携済みの SNS アカウントとプラットフォーム別のキャプションを指定します。
        YouTube の場合は `youtube_title` でタイトルを指定できます。
        TikTok の場合は `is_ai_generated` で AI 生成コンテンツラベルを付与できます。
        TikTok への画像投稿の場合は `tiktok_auto_add_music` でおすすめ音楽の自動追加を指定できます。
        TikTok の場合は `tiktok_draft` で、公開せず TikTok アプリの受信箱へ下書きとして送れます。
        TikTok の場合は `tiktok_privacy_level` で公開範囲を、`tiktok_allow_comment` / `tiktok_allow_duet` / `tiktok_allow_stitch` で
        コメント・デュエット・ステッチの可否を指定できます。公開範囲は選べる値がアカウントによって変わるため、
        選べない値を指定すると投稿は作成されず 400 になります。
      operationId: createPost
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreatePostRequest"
            examples:
              video_post:
                summary: YouTube と TikTok に動画投稿
                value:
                  category: "video"
                  media_id: "media_abc123"
                  thumbnail_time: 5.5
                  targets:
                    - connection_id: "conn_yt123"
                      caption: "新しい動画をアップしました！ #youtube"
                      youtube_title: "素敵な動画"
                    - connection_id: "conn_tt456"
                      caption: "新しい動画をアップしました！ #tiktok"
                      is_ai_generated: true
              text_post:
                summary: X と Threads にテキスト投稿
                value:
                  category: "text"
                  targets:
                    - connection_id: "conn_x123"
                      caption: "こんにちは！ #X"
                    - connection_id: "conn_th456"
                      caption: "こんにちは！ #threads"
              image_post:
                summary: X と Instagram に画像投稿
                value:
                  category: "image"
                  media_ids:
                    - "media_img001"
                    - "media_img002"
                  targets:
                    - connection_id: "conn_x123"
                      caption: "美しい夕焼け #photography"
                    - connection_id: "conn_ig789"
                      caption: "美しい夕焼け #photography #sunset"
              scheduled_post:
                summary: 動画の予約投稿
                value:
                  category: "video"
                  media_id: "media_abc123"
                  scheduled_at: "2030-01-01T10:00:00Z"
                  targets:
                    - connection_id: "conn_yt123"
                      caption: "もうすぐ公開！ #youtube"
                      youtube_title: "近日公開"
              tree_post:
                summary: X と Threads にツリー投稿
                value:
                  category: "tree"
                  tree_cards:
                    - caption: "個人開発の話をスレッドで書きます"
                    - caption: "まず作ったきっかけから。"
                      media_ids:
                        - "media_img001"
                    - caption: "続きはプロフィールのリンクから。"
                  targets:
                    - connection_id: "conn_x123"
                    - connection_id: "conn_th456"
              draft_post:
                summary: キャプション未指定の下書き
                value:
                  category: "text"
                  draft: true
                  targets:
                    - connection_id: "conn_x123"
      responses:
        "201":
          description: 投稿を作成しました
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/PostDetail"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/SubscriptionRequired"
        "409":
          description: 投稿先のSNS連携の状態により投稿を受け付けられません。投稿は作成されていません。
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                reconnect_required:
                  summary: 再連携が必要
                  value:
                    error:
                      code: "POSTING_READINESS_RECONNECT_REQUIRED"
                      message: "Xアカウントは再連携が必要です。連携画面で再連携してから投稿してください。"
                posting_readiness_unavailable:
                  summary: SNS側の一時的なエラーで投稿準備を確認できなかった
                  value:
                    error:
                      code: "POSTING_READINESS_UNAVAILABLE"
                      message: "X側の一時的なエラーで投稿準備を確認できませんでした。時間をおいて再度お試しください。"
        "500":
          $ref: "#/components/responses/InternalError"

  /posts/{id}:
    get:
      tags:
        - Posts
      summary: 投稿の詳細を取得
      description: 指定した投稿の詳細情報を返します。各プラットフォームごとのステータスを含みます。
      operationId: getPost
      parameters:
        - name: id
          in: path
          required: true
          description: 投稿 ID
          schema:
            type: string
      responses:
        "200":
          description: 投稿の詳細
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/PostDetail"
              example:
                data:
                  id: "pub_abc123"
                  status: "posted"
                  category: "video"
                  media_ids:
                    - "media_abc123"
                  tree_cards: []
                  scheduled_at: null
                  can_cancel: false
                  platforms:
                    - platform: "youtube"
                      connection_id: "conn_yt123"
                      account_name: "My Channel"
                      status: "posted"
                      caption: "新しい動画をアップしました！ #youtube"
                      youtube_title: "素敵な動画"
                      external_url: "https://www.youtube.com/shorts/dQw4w9WgXcQ"
                      error_message: null
                    - platform: "tiktok"
                      connection_id: "conn_tt456"
                      account_name: "@myaccount"
                      status: "posted"
                      caption: "新しい動画をアップしました！ #tiktok"
                      youtube_title: null
                      external_url: "https://www.tiktok.com/@myaccount"
                      error_message: null
                  display_at: "2026-02-17T10:00:00.000Z"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

    delete:
      tags:
        - Posts
      summary: 予約投稿・下書きをキャンセル
      description: |
        予約投稿または下書きをキャンセルします。
        ステータスが `scheduled`（かつ処理開始前）または `draft` の投稿をキャンセルできます。
      operationId: cancelPost
      parameters:
        - name: id
          in: path
          required: true
          description: 投稿 ID
          schema:
            type: string
      responses:
        "200":
          description: 投稿をキャンセルしました
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                        description: 投稿 ID
                      status:
                        type: string
                        enum:
                          - cancelled
                        description: 常に `cancelled`
                    required:
                      - id
                      - status
              example:
                data:
                  id: "pub_abc123"
                  status: "cancelled"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          description: キャンセルできません（処理中、キャンセル済み、即時投稿のいずれか）
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error:
                  code: "CONFLICT"
                  message: "Cannot cancel: publication is already being processed"
        "500":
          $ref: "#/components/responses/InternalError"

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: |
        API キーを Bearer トークンとして送ります。API キーはポストメッシュダッシュボードの「API キー」画面から発行できます。

        ```
        Authorization: Bearer YOUR_API_KEY
        ```

  schemas:
    # ---------- Connection ----------
    Connection:
      type: object
      description: SNS アカウント連携
      properties:
        id:
          type: string
          description: 連携 ID
        platform:
          $ref: "#/components/schemas/Platform"
        account_name:
          type: string
          nullable: true
          description: 連携アカウントの表示名
        icon_url:
          type: string
          nullable: true
          description: 連携アカウントのプロフィールアイコン URL
        connected_at:
          type: string
          format: date-time
          description: 連携日時
      required:
        - id
        - platform
        - account_name
        - icon_url
        - connected_at

    # ---------- Media ----------
    CreateMediaUploadUrlRequest:
      type: object
      description: メディアアップロード URL 生成リクエスト
      properties:
        mime_type:
          type: string
          description: |
            アップロードするファイルの MIME タイプ。
            対応: `video/mp4`, `video/quicktime`, `image/jpeg`, `image/png`, `image/webp`
          enum:
            - video/mp4
            - video/quicktime
            - image/jpeg
            - image/png
            - image/webp
        size_bytes:
          type: integer
          minimum: 1
          description: |
            ファイルサイズ（バイト）。動画は最大 1 GB、画像は最大 20 MB。
        name:
          type: string
          minLength: 1
          description: ファイル名
      required:
        - mime_type
        - size_bytes
        - name

    MediaUpload:
      type: object
      description: メディアアップロード URL
      properties:
        media_id:
          type: string
          description: 投稿作成時に参照するメディア ID
        upload_url:
          type: string
          format: uri
          description: ファイルアップロード用の署名付き URL（PUT リクエストで使用）
        name:
          type: string
          description: ファイル名
      required:
        - media_id
        - upload_url
        - name

    # ---------- Posts ----------
    CreatePostRequest:
      description: |
        投稿作成リクエスト。`category`の値ごとに必須フィールドが異なるため、カテゴリ別の4つのスキーマをoneOfで定義しています。
        公開・予約と下書き（`draft: true`）で変わる条件（`targets[].caption`の必須など）はOpenAPI 3.0のスキーマでは表現できないため、各フィールドのdescriptionを参照してください。
      oneOf:
        - $ref: "#/components/schemas/CreateVideoPostRequest"
        - $ref: "#/components/schemas/CreateImagePostRequest"
        - $ref: "#/components/schemas/CreateTextPostRequest"
        - $ref: "#/components/schemas/CreateTreePostRequest"
      discriminator:
        propertyName: category
        mapping:
          video: "#/components/schemas/CreateVideoPostRequest"
          image: "#/components/schemas/CreateImagePostRequest"
          text: "#/components/schemas/CreateTextPostRequest"
          tree: "#/components/schemas/CreateTreePostRequest"

    CreatePostRequestBase:
      type: object
      properties:
        scheduled_at:
          type: string
          format: date-time
          nullable: true
          description: 予約投稿日時（ISO 8601）。省略または null で即時投稿。未来の日時を指定してください。
        draft:
          type: boolean
          default: false
          description: |
            true で下書きとして保存し、SNS へは配信されません。
            - `scheduled_at` とは同時に指定できません（400 になります）。
            - 下書きでは `targets[].caption` と `youtube_title` を省略できます。
            - 下書きから公開への昇格は、Web アプリの編集画面から行います（API に更新エンドポイントはありません）。

    CreateVideoPostRequest:
      description: 動画投稿の作成リクエスト
      allOf:
        - $ref: "#/components/schemas/CreatePostRequestBase"
        - type: object
          properties:
            category:
              type: string
              enum:
                - video
              description: 投稿カテゴリ
            targets:
              type: array
              minItems: 1
              description: 投稿先のプラットフォームリスト
              items:
                $ref: "#/components/schemas/PostTarget"
            media_id:
              type: string
              description: "アップロード済み動画のメディア ID。下書き（`draft: true`）でも必須です。"
            thumbnail_time:
              type: number
              description: サムネイルの時間位置（秒）。任意。
          required:
            - category
            - targets
            - media_id

    CreateImagePostRequest:
      description: 画像投稿の作成リクエスト
      allOf:
        - $ref: "#/components/schemas/CreatePostRequestBase"
        - type: object
          properties:
            category:
              type: string
              enum:
                - image
              description: 投稿カテゴリ
            targets:
              type: array
              minItems: 1
              description: 投稿先のプラットフォームリスト
              items:
                $ref: "#/components/schemas/PostTarget"
            media_ids:
              type: array
              items:
                type: string
              description: |
                アップロード済み画像のメディア ID 配列。下書き（`draft: true`）でも必須です。
                1 件以上。上限は投稿先のうち最も厳しいプラットフォームの枚数上限（X 4 枚 / Instagram・Facebook 10 枚 / Threads 20 枚 / TikTok 35 枚）。
              minItems: 1
              maxItems: 35
          required:
            - category
            - targets
            - media_ids

    CreateTextPostRequest:
      description: テキスト投稿の作成リクエスト
      allOf:
        - $ref: "#/components/schemas/CreatePostRequestBase"
        - type: object
          properties:
            category:
              type: string
              enum:
                - text
              description: 投稿カテゴリ
            targets:
              type: array
              minItems: 1
              description: 投稿先のプラットフォームリスト
              items:
                $ref: "#/components/schemas/PostTarget"
          required:
            - category
            - targets

    CreateTreePostRequest:
      description: ツリー投稿（連続投稿）の作成リクエスト。投稿先はXとThreadsのみです。
      allOf:
        - $ref: "#/components/schemas/CreatePostRequestBase"
        - type: object
          properties:
            category:
              type: string
              enum:
                - tree
              description: 投稿カテゴリ
            targets:
              type: array
              minItems: 1
              description: 投稿先のプラットフォームリスト。投稿先はXとThreadsのみで、他のプラットフォームを含めると400になります。
              items:
                $ref: "#/components/schemas/TreePostTarget"
            tree_cards:
              type: array
              minItems: 1
              description: |
                ツリー（連続投稿）を構成するカードの配列で、先頭が起点の投稿、以降がぶら下がる返信になります。

                - 全投稿先に同じカード内容を配信します。投稿先ごとにカードを変えることはできません。
                - 公開・予約投稿では2件以上必要です。1件だけだと400になります。
                - `draft: true`の下書きでは1件でも保存できます。
                - 下書きでも、カード・`caption`・`media_ids`の型、メディアIDの実在とチームへの所属、メディア種別（GIFは不可）は作成時に検証され、違反は400になります。下書きで後回しにできるのは、カード数（2件以上）、本文とメディアが両方空のカードの禁止、文字数制限、メディアの枚数・サイズ制限だけです。
                - 空配列や配列以外を指定すると400になります。
              items:
                $ref: "#/components/schemas/CreateTreeCard"
          required:
            - category
            - targets
            - tree_cards

    TreePostTarget:
      type: object
      description: |
        ツリー投稿の投稿先。`connection_id`だけを指定します。
        `caption` / `youtube_title` / `is_ai_generated` / `tiktok_draft` / `tiktok_auto_add_music`を指定すると400になります。
        それ以外のフィールド（`tiktok_privacy_level`など）は指定しても無視され、設定は反映されません。
      properties:
        connection_id:
          type: string
          minLength: 1
          description: 投稿先の連携 ID
      required:
        - connection_id

    PostTarget:
      type: object
      description: 投稿先ターゲット（video / image / textカテゴリ用。treeカテゴリはTreePostTargetを使います）
      properties:
        connection_id:
          type: string
          minLength: 1
          description: 投稿先の連携 ID
        caption:
          type: string
          minLength: 1
          description: |
            プラットフォーム別のキャプション。公開・予約投稿では必須です。
            `draft: true`の下書きでは省略できます（省略時は空のキャプションとして保存され、昇格時に検証されます）。

            各プラットフォームの文字数制限:
            - X: 280文字（加重カウント） https://developer.x.com/en/docs/counting-characters
            - Threads: 500文字 https://developers.facebook.com/docs/threads/posts
            - TikTok: 2,200文字 https://developers.tiktok.com/doc/content-posting-api-reference-direct-post
            - Instagram: 2,200文字 https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/content-publishing
            - YouTube（説明文）: 5,000バイト https://developers.google.com/youtube/v3/docs/videos#snippet.description
            - Facebook: 制限なし
        youtube_title:
          type: string
          minLength: 1
          description: "YouTube 投稿のタイトル。投稿先が YouTube の場合は**必須**です。"
        is_ai_generated:
          type: boolean
          default: false
          description: "AI で生成されたコンテンツかどうか。TikTok 投稿の場合のみ有効です。true にすると TikTok 上で「AI generated」ラベルが表示されます。"
        tiktok_auto_add_music:
          type: boolean
          default: false
          description: "TikTok がおすすめの音楽を自動で付けるかどうか。TikTok への画像投稿（`category: image`）の場合のみ有効です。`image` 以外のカテゴリで指定すると 400 になります。`tiktok_draft: true` とは同時に指定できません。"
        tiktok_draft:
          type: boolean
          default: false
          description: |
            公開せず、TikTok アプリの受信箱に下書きとして送るかどうか。TikTok 投稿（動画・画像）の場合のみ有効です。
            - 動画の下書きにはキャプションが送られません（TikTok アプリ側で入力します）。
            - 保留中の下書きは 24 時間あたり 5 件までです。
            - `tiktok_auto_add_music: true` とは同時に指定できません（400 になります）。
            - `tiktok_privacy_level` とは同時に指定できません（400 になります）。
        tiktok_privacy_level:
          type: string
          enum:
            - PUBLIC_TO_EVERYONE
            - SELF_ONLY
          description: |
            この投稿を見せる相手。TikTok 投稿の場合のみ有効です。省略すると `PUBLIC_TO_EVERYONE`（全員に公開）になります。
            非公開アカウントでは `PUBLIC_TO_EVERYONE` を選べず、`SELF_ONLY` のみになります。
            そのアカウントで選べない値を指定した場合、投稿は作成されず `TIKTOK_PRIVACY_LEVEL_UNAVAILABLE` で 400 になります。
            エラーメッセージにそのアカウントで選べる値が含まれるので、選び直して再送してください。
            `tiktok_draft: true` とは同時に指定できません（400 になります）。
        tiktok_allow_comment:
          type: boolean
          default: true
          description: "この投稿へのコメントを許可するかどうか。TikTok 投稿の場合のみ有効です。TikTok アカウント側でコメントが無効になっている場合は、`true` を指定しても無効のまま投稿されます。"
        tiktok_allow_duet:
          type: boolean
          default: true
          description: "デュエットを許可するかどうか。TikTok への動画投稿（`category: video`）の場合のみ有効です。`video` 以外のカテゴリで指定すると 400 になります。TikTok アカウント側でデュエットが無効になっている場合は、`true` を指定しても無効のまま投稿されます。"
        tiktok_allow_stitch:
          type: boolean
          default: true
          description: "ステッチを許可するかどうか。TikTok への動画投稿（`category: video`）の場合のみ有効です。`video` 以外のカテゴリで指定すると 400 になります。TikTok アカウント側でステッチが無効になっている場合は、`true` を指定しても無効のまま投稿されます。"
      required:
        - connection_id

    CreateTreeCard:
      type: object
      description: |
        ツリー投稿のカード（リクエスト）。本文・メディアのどちらか一方だけでも構いませんが、
        公開・予約投稿では本文とメディアの両方が空のカードは400になります。
      properties:
        caption:
          type: string
          nullable: true
          description: |
            カードの本文。省略・null・空文字はいずれも本文なしとして扱われます。
            文字数制限は投稿先ごとに異なり、Xは280文字（加重カウント）、Threadsは500文字です。
            指定した投稿先のいずれかの制限を超えると400になります。
        media_ids:
          type: array
          items:
            type: string
          description: |
            カードに添えるメディア ID の配列。省略時は空配列として扱われます。
            `POST /media/upload-url`でアップロード済みのメディアだけを指定できます。

            メディアIDの実在とチームへの所属、メディア種別（GIFは不可）は下書きでも作成時に検証されます。
            以下の枚数・サイズ制限は公開・予約投稿のみ検証されます（いずれかの投稿先の制限を超えると400）:
            - X: 1カードあたり合計4件まで。画像は4枚（`image/jpeg` / `image/png` / `image/webp`、1枚5MBまで）、動画は1本（`video/mp4` / `video/quicktime`、512MBまで）。画像と動画は混在できません。
            - Threads: 1カードあたり合計20件まで。画像は20枚（1枚8MBまで）、動画は20本（`video/mp4` / `video/quicktime`、1GBまで）。画像と動画を混在できます。

            GIF（`image/gif`）は対応していません。

    PostSummary:
      type: object
      description: 投稿の概要（一覧用）
      properties:
        id:
          type: string
          description: 投稿 ID
        title:
          type: string
          description: 投稿タイトル（YouTube タイトルまたはキャプション先頭 30 文字）
        status:
          $ref: "#/components/schemas/PostListStatus"
        category:
          $ref: "#/components/schemas/PostCategory"
        platforms:
          type: array
          description: プラットフォームごとのステータス
          items:
            type: object
            properties:
              platform:
                $ref: "#/components/schemas/Platform"
              status:
                $ref: "#/components/schemas/PostListStatus"
            required:
              - platform
              - status
        display_at:
          type: string
          format: date-time
          description: 表示日時
      required:
        - id
        - title
        - status
        - category
        - platforms
        - display_at

    PostDetail:
      type: object
      description: 投稿の詳細
      properties:
        id:
          type: string
          description: 投稿 ID
        status:
          $ref: "#/components/schemas/PostDetailStatus"
        category:
          $ref: "#/components/schemas/PostCategory"
        media_ids:
          type: array
          items:
            type: string
          description: 関連するメディア ID の配列（API 経由で作成した投稿の場合。動画は最大 1 件、画像は複数件。Web UI から作成した投稿は空配列）
        tree_cards:
          type: array
          description: |
            ツリー投稿のカードの配列（`category: tree`の場合のみ。それ以外のカテゴリでは常に空配列）。
            リクエストの`tree_cards`と同じ順序で、先頭が起点の投稿、以降がぶら下がる返信です。
          items:
            $ref: "#/components/schemas/TreeCard"
        scheduled_at:
          type: string
          format: date-time
          nullable: true
          description: 予約投稿日時。即時投稿の場合は null。
        can_cancel:
          type: boolean
          description: キャンセル可能かどうか。配信処理が始まっておらず、下書き、または予約日時が未来の予約投稿の場合に true。
        platforms:
          type: array
          description: プラットフォームごとの詳細ステータス
          items:
            $ref: "#/components/schemas/PlatformPostDetail"
        display_at:
          type: string
          format: date-time
          description: 表示日時
      required:
        - id
        - status
        - category
        - media_ids
        - tree_cards
        - scheduled_at
        - can_cancel
        - platforms
        - display_at

    TreeCard:
      type: object
      description: ツリー投稿のカード（レスポンス）
      properties:
        caption:
          type: string
          nullable: true
          description: カードの本文。本文なしのカードでは null。
        media_ids:
          type: array
          items:
            type: string
          description: カードに添えたメディア ID の配列（メディアなしの場合は空配列）
      required:
        - caption
        - media_ids

    PlatformPostDetail:
      type: object
      description: プラットフォームごとの投稿ステータス
      properties:
        platform:
          $ref: "#/components/schemas/Platform"
        connection_id:
          type: string
          description: 連携 ID（連携が存在しない場合は空文字列）
        account_name:
          type: string
          description: アカウント名（取得できない場合は空文字列）
        status:
          $ref: "#/components/schemas/PlatformPostDetailStatus"
        caption:
          type: string
          description: プラットフォームごとのキャプション
        youtube_title:
          type: string
          nullable: true
          description: YouTube 投稿のタイトル。YouTube 以外のプラットフォームでは null。
        external_url:
          type: string
          nullable: true
          description: プラットフォーム上の投稿 URL
        error_message:
          type: string
          nullable: true
          description: 投稿失敗時のエラーメッセージ
      required:
        - platform
        - connection_id
        - account_name
        - status
        - caption
        - youtube_title
        - external_url
        - error_message

    # ---------- Enums ----------
    Platform:
      type: string
      enum:
        - youtube
        - tiktok
        - instagram
        - threads
        - x
        - facebook
      description: SNS プラットフォーム

    PostCategory:
      type: string
      enum:
        - video
        - image
        - text
        - tree
      description: "投稿のコンテンツ種別: video（動画）/ image（画像）/ text（テキスト）/ tree（ツリー投稿）"

    PostListStatus:
      type: string
      enum:
        - posted
        - scheduled
        - processing
        - failed
        - draft
      description: "投稿ステータス: posted（投稿済み）/ scheduled（予約済み）/ processing（処理中）/ failed（失敗）/ draft（下書き）"

    PostDetailStatus:
      type: string
      enum:
        - posted
        - scheduled
        - processing
        - failed
        - draft
        - cancelled
      description: "投稿ステータス（詳細）: draft（下書き）/ cancelled（キャンセル済み）を含む"

    PlatformPostDetailStatus:
      type: string
      enum:
        - posted
        - scheduled
        - processing
        - failed
        - draft
        - cancelled
      description: "プラットフォームごとの投稿ステータス: draft（下書き）/ cancelled（キャンセル済み）を含む"

    # ---------- Common ----------
    Pagination:
      type: object
      description: ページネーション情報
      properties:
        total:
          type: integer
          description: 全件数
        page:
          type: integer
          description: 現在のページ番号
        limit:
          type: integer
          description: 1 ページあたりの件数
        has_next:
          type: boolean
          description: 次のページがあるか
      required:
        - total
        - page
        - limit
        - has_next

    Error:
      type: object
      description: エラーレスポンス
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: |
                エラーコード。

                - `VALIDATION_ERROR`（400）— リクエストのバリデーションに失敗
                - `TIKTOK_PRIVACY_LEVEL_UNAVAILABLE`（400）— `POST /posts`で、そのTikTokアカウントでは選べない`tiktok_privacy_level`を指定した
                - `UNAUTHORIZED`（401）— API キーが未指定・無効・失効済み
                - `SUBSCRIPTION_REQUIRED`（403）— 有効なサブスクリプションが必要
                - `NOT_FOUND`（404）— リソースが見つからない
                - `POSTING_READINESS_RECONNECT_REQUIRED`（409）— `POST /posts`で、投稿先のSNS連携が切れており再連携が必要
                - `POSTING_READINESS_UNAVAILABLE`（409）— `POST /posts`で、SNS側の一時的なエラーにより投稿準備を確認できなかった
                - `CONFLICT`（409）— `DELETE /posts/{id}`で、処理中・キャンセル済みなどでキャンセルできない
                - `INTERNAL_ERROR`（500）— 内部サーバーエラー
              enum:
                - VALIDATION_ERROR
                - TIKTOK_PRIVACY_LEVEL_UNAVAILABLE
                - UNAUTHORIZED
                - SUBSCRIPTION_REQUIRED
                - NOT_FOUND
                - POSTING_READINESS_RECONNECT_REQUIRED
                - POSTING_READINESS_UNAVAILABLE
                - CONFLICT
                - INTERNAL_ERROR
            message:
              type: string
              description: エラーの詳細メッセージ
          required:
            - code
            - message
      required:
        - error

  responses:
    Unauthorized:
      description: API キーが未指定または無効です
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error:
              code: "UNAUTHORIZED"
              message: "Missing or invalid Authorization header"

    ValidationError:
      description: リクエストのバリデーションに失敗しました
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error:
              code: "VALIDATION_ERROR"
              message: "targets must be a non-empty array"

    NotFound:
      description: リソースが見つかりません
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error:
              code: "NOT_FOUND"
              message: "Post not found"

    SubscriptionRequired:
      description: 有効なサブスクリプションが必要です
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error:
              code: "SUBSCRIPTION_REQUIRED"
              message: "Active subscription is required"

    InternalError:
      description: 内部サーバーエラー
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error:
              code: "INTERNAL_ERROR"
              message: "An unexpected error occurred"
