openapi: "3.0.3"

info:
  title: Aether API
  version: "1.0.0"
  description: |
    Unified Social Media API Platform.
    Publish, schedule, manage inboxes, and pull analytics across all major social platforms
    with a single API key.

    This spec is the **single source of truth**. SDKs, MCP server tools, and documentation
    are all generated from this file. Adding a new endpoint here triggers automatic updates
    across all downstream consumers.

    ## Authentication
    All endpoints require an API key passed in the `Authorization` header:
    ```
    Authorization: Bearer aeth_live_your_api_key_here
    ```

    ## Error format
    All errors follow a consistent shape:
    ```json
    { "success": false, "error": { "code": "ERROR_CODE", "message": "Human-readable message" } }
    ```
    Error codes are designed for LLM self-correction — they are uppercase snake_case strings
    that describe the exact condition (e.g. `PROFILE_NOT_FOUND`, `INVALID_PLATFORM`).

  contact:
    name: Aether Support
    url: https://aetherhq.dev/contact
    email: support@aetherhq.dev

  license:
    name: Proprietary

servers:
  - url: https://api.aetherhq.dev/v1
    description: Production
  - url: http://localhost:3001/v1
    description: Local development

tags:
  - name: Health
    description: Check Aether API service health and availability. Returns database and Redis connection status in real-time.
  - name: Profiles
    description: Manage connected social media accounts across Instagram, Facebook, TikTok, LinkedIn, YouTube, Threads, and Reddit via OAuth abstraction.
  - name: Posts
    description: Publish and schedule social media posts across 7 platforms. Supports per-platform overrides, media attachments, and timezone-aware scheduling.
  - name: Inbox
    description: Unified inbox for DMs, comments, and mentions across all connected social platforms. Read and reply to messages from a single endpoint.
  - name: Analytics
    description: Query post and account metrics across connected social platforms. Powered by Tinybird for sub-second analytics and normalized schema.
  - name: Webhooks
    description: Manage webhook endpoints and delivery logs. HMAC-signed events for post.published, post.failed, comment.created, and message.created.
  - name: Connect Links
    description: Generate magic OAuth connect links for end-user social account connection without exposing your credentials or Aether branding.
  - name: OAuth
    description: OAuth connect flows (Facebook Page selection after multi-Page authorization)
  - name: API Keys
    description: Create, list, and revoke API keys for authenticating with the Aether REST API. Supports scoped keys and per-profile access restrictions.
  - name: Organizations
    description: Manage organizations, team members, roles, and invitations. Organizations are the top-level billing and access control unit in Aether.
  - name: Media
    description: Upload media files to cloud storage via presigned URLs. Returns a media key for use in post creation across all connected platforms.
  - name: Queues
    description: Configure recurring publish slot schedules for an organization. Posts added to a queue are automatically published at the scheduled times.
  - name: Automations
    description: Configure comment-to-DM automation rules that trigger private replies when incoming comments match defined keyword patterns.

components:
  securitySchemes:
    ApiKeyAuth:
      type: http
      scheme: bearer
      bearerFormat: "aeth_live_..."
      description: API key from your Aether dashboard

  schemas:

    # ─── Primitives ────────────────────────────────────────────────────────────

    Platform:
      type: string
      enum: [instagram, tiktok, facebook, linkedin, youtube, threads, reddit]
      description: Social media platform identifier

    WebhookEvent:
      type: string
      enum:
        - post.published
        - post.failed
        - post.scheduled
        - post.cancelled
        - post.platform.publishing
        - post.platform.published
        - post.platform.failed
        - inbox.message_received
        - inbox.comment_received
        - inbox.story_reply_received
        - automation.triggered
        - profile.connected
        - profile.disconnected
        - profile.token_expired
        - profile.error
        - connect_link.used
        - connect_link.expired
      description: Webhook event type

    PostStatus:
      type: string
      enum: [draft, scheduled, publishing, published, failed, cancelled]
      description: Current lifecycle state of a post

    # ─── Error / Pagination ────────────────────────────────────────────────────

    Error:
      type: object
      required: [code, message]
      properties:
        code:
          type: string
          description: Machine-readable error code (designed for LLM self-correction)
          example: PROFILE_NOT_FOUND
        message:
          type: string
          description: Human-readable error message
        details:
          type: object
          additionalProperties: true

    ErrorResponse:
      type: object
      required: [success, error]
      properties:
        success:
          type: boolean
          example: false
        error:
          $ref: "#/components/schemas/Error"

    Pagination:
      type: object
      required: [total, page, perPage, hasMore]
      properties:
        total:
          type: integer
          description: Total number of items matching the query
        page:
          type: integer
          description: Current page number (1-indexed)
        perPage:
          type: integer
          description: Items per page
        hasMore:
          type: boolean
          description: Whether there are more pages after this one

    PaginatedListMeta:
      type: object
      required: [total, hasMore]
      properties:
        total:
          type: integer
          description: Total number of items matching the query
        hasMore:
          type: boolean
          description: Whether there are more pages after this one

    # ─── Profiles ─────────────────────────────────────────────────────────────

    SocialProfile:
      type: object
      required:
        - id
        - organizationId
        - platform
        - platformAccountId
        - username
        - displayName
        - connectionStatus
        - createdAt
      properties:
        id:
          type: string
          example: prof_abc123
        organizationId:
          type: string
        platform:
          $ref: "#/components/schemas/Platform"
        platformAccountId:
          type: string
          description: The account ID assigned by the social platform
        username:
          type: string
          example: aetherhq
        displayName:
          type: string
          example: Aether HQ
        avatarUrl:
          type: string
          format: uri
          nullable: true
        connectionStatus:
          type: string
          enum: [connected, disconnected, error]
        errorMessage:
          type: string
          nullable: true
          description: >
            Human-readable explanation when connectionStatus is error (e.g. no Facebook Page found).
        tokenExpiresAt:
          type: string
          format: date-time
          nullable: true
          description: When the OAuth access token expires (null = non-expiring)
        lastRefreshedAt:
          type: string
          format: date-time
          nullable: true
        createdAt:
          type: string
          format: date-time

    SocialProfileListResponse:
      type: object
      required: [success, data]
      properties:
        success:
          type: boolean
          example: true
        data:
          type: array
          items:
            $ref: "#/components/schemas/SocialProfile"

    SocialProfileResponse:
      type: object
      required: [success, data]
      properties:
        success:
          type: boolean
          example: true
        data:
          $ref: "#/components/schemas/SocialProfile"

    FacebookPendingPage:
      type: object
      required: [id, name]
      properties:
        id:
          type: string
        name:
          type: string
        avatarUrl:
          type: string
          format: uri

    FacebookPendingPagesResponse:
      type: object
      required: [success, data]
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          required: [pages, expiresAt]
          properties:
            pages:
              type: array
              items:
                $ref: "#/components/schemas/FacebookPendingPage"
            expiresAt:
              type: string
              format: date-time

    FacebookPageCompleteRequest:
      type: object
      required: [pendingId, pageId]
      properties:
        pendingId:
          type: string
          format: uuid
        pageId:
          type: string

    # ─── Posts ────────────────────────────────────────────────────────────────

    ContentType:
      type: string
      enum: [feed, story, reel]
      description: Post format — feed (default), story, or reel. Supported on Instagram and Facebook.

    InstagramUserTag:
      type: object
      required: [username, x, y]
      properties:
        username:
          type: string
        x:
          type: number
          minimum: 0
          maximum: 1
          description: Horizontal position from left edge (0.0–1.0)
        y:
          type: number
          minimum: 0
          maximum: 1
          description: Vertical position from top edge (0.0–1.0)
        mediaIndex:
          type: integer
          minimum: 0
          description: Carousel slide index (defaults to 0)

    ThreadsThreadItem:
      type: object
      required: [text]
      properties:
        text:
          type: string
          maxLength: 10000
        mediaUrls:
          type: array
          items:
            type: string
            format: uri

    FacebookCarouselCard:
      type: object
      required: [link]
      properties:
        link:
          type: string
          format: uri
        name:
          type: string
          maxLength: 255
        description:
          type: string
          maxLength: 255

    PlatformOverride:
      type: object
      description: |
        Per-platform content override. Merged with the base post at publish time.
        Platform-specific fields are ignored when publishing to other platforms.
      properties:
        text:
          type: string
          maxLength: 10000
          description: Platform-specific text (overrides the base text field)
        mediaUrls:
          type: array
          items:
            type: string
            format: uri
          description: Platform-specific media attachments
        firstComment:
          type: string
          maxLength: 2200
          description: First comment for this platform (overrides post-level firstComment)
        contentType:
          $ref: "#/components/schemas/ContentType"
        # Instagram
        userTags:
          type: array
          items:
            $ref: "#/components/schemas/InstagramUserTag"
        collaborators:
          type: array
          maxItems: 3
          items:
            type: string
        thumbOffset:
          type: integer
          minimum: 0
          description: Reel cover frame offset in milliseconds
        instagramThumbnail:
          type: string
          format: uri
          description: Custom cover image URL for Reels
        shareToFeed:
          type: boolean
          default: true
          description: When false, Reel appears on Reels tab only
        # Threads
        threadItems:
          type: array
          minItems: 1
          maxItems: 10
          items:
            $ref: "#/components/schemas/ThreadsThreadItem"
        topicTag:
          type: string
          minLength: 1
          maxLength: 50
          description: Threads topic tag (no periods or ampersands)
        # Facebook
        carouselCards:
          type: array
          minItems: 2
          maxItems: 5
          items:
            $ref: "#/components/schemas/FacebookCarouselCard"
        carouselLink:
          type: string
          format: uri
          description: Top-level see-more link for carousel posts
        pageId:
          type: string
          description: Target Facebook Page ID (defaults to connected profile)
        # LinkedIn
        organizationUrn:
          type: string
          description: Post as organization (e.g. urn:li:organization:12345)
        documentTitle:
          type: string
          maxLength: 255
          description: Title for PDF document posts (required when media is PDF)
        disableLinkPreview:
          type: boolean
          description: Disable automatic link previews for URLs in post text

    CreatePostInput:
      type: object
      required: [text, profileIds]
      properties:
        text:
          type: string
          maxLength: 10000
          description: Base post text shown on all platforms unless overridden
          example: "Shipping something new today 🚀"
        mediaUrls:
          type: array
          items:
            type: string
            format: uri
          description: Base media attachments (use overrides for platform-specific media)
        profileIds:
          type: array
          items:
            type: string
          minItems: 1
          description: IDs of connected SocialProfiles to publish to
          example: ["prof_abc123", "prof_xyz789"]
        overrides:
          type: object
          additionalProperties:
            $ref: "#/components/schemas/PlatformOverride"
          description: Per-platform content overrides keyed by platform name
          example:
            instagram:
              text: "Instagram-specific caption with #hashtags"
        scheduledFor:
          type: string
          format: date-time
          description: >
            When to publish. Two forms are supported: (1) absolute UTC or offset —
            e.g. `2026-06-01T14:00:00Z` or `2026-06-01T09:00:00-05:00` (timezone is optional metadata);
            (2) wall-clock local time without offset — e.g. `2026-06-01T09:00:00` with required `timezone`
            `America/Chicago` (server converts to UTC). Must be at least 30 seconds in the future.
            During DST spring-forward gaps, non-existent local times snap forward by one hour.
            Mutually exclusive with queueId and useDefaultQueue.
          example: "2026-06-01T09:00:00-05:00"
        queueId:
          type: string
          description: Assign the next available slot from this org publish queue. Mutually exclusive with scheduledFor.
        useDefaultQueue:
          type: boolean
          description: Assign the next slot from the org's default publish queue. Mutually exclusive with scheduledFor.
        timezone:
          type: string
          description: >
            IANA timezone (e.g. America/Chicago). Required when `scheduledFor` has no UTC offset;
            optional metadata when `scheduledFor` includes `Z` or ±HH:MM. Stored on the post for display.
            On fall-back nights, duplicate local times (e.g. 1:30 AM twice) resolve to the earlier UTC
            instant (first occurrence). See internal scheduling docs for DST details.
          example: America/Chicago
        firstComment:
          type: string
          maxLength: 2200
          description: Text to post as the first comment after publishing (Instagram hashtag strategy)
        saveDraft:
          type: boolean
          default: false
          description: >
            If true, create the post in draft status without publishing or scheduling.
            profileIds is optional when saveDraft is true — useful for saving content ideas
            before deciding where to publish. Three outcomes: scheduledFor set → scheduled;
            saveDraft true → draft; neither → published immediately.

    UpdatePostInput:
      type: object
      description: Partial update — only provided fields are changed. Only allowed while status is draft or scheduled.
      properties:
        text:
          type: string
          maxLength: 10000
        mediaUrls:
          type: array
          items:
            type: string
            format: uri
        profileIds:
          type: array
          items:
            type: string
          minItems: 1
        overrides:
          type: object
          additionalProperties:
            $ref: "#/components/schemas/PlatformOverride"
        scheduledFor:
          type: string
          format: date-time
          description: >
            Reschedule time. Same rules as CreatePost.scheduledFor: absolute UTC/offset or naive
            local + required `timezone`; at least 30 seconds in the future; DST spring-forward snap.
        timezone:
          type: string
          description: >
            IANA timezone. Required when `scheduledFor` has no offset. Fall-back ambiguous
            times resolve to the earlier UTC instant.
        firstComment:
          type: string
          maxLength: 2200

    Post:
      type: object
      required:
        - id
        - organizationId
        - status
        - text
        - mediaUrls
        - mediaKeys
        - profileIds
        - createdAt
        - updatedAt
      properties:
        id:
          type: string
          example: post_abc123
        organizationId:
          type: string
        status:
          $ref: "#/components/schemas/PostStatus"
        text:
          type: string
        mediaUrls:
          type: array
          items:
            type: string
            format: uri
          description: Public URLs of attached media files
        mediaKeys:
          type: array
          items:
            type: string
          description: >
            Canonical provider-agnostic object keys (e.g. "orgId/uuid.jpg").
            Stable across storage provider migrations. Empty array for posts
            created before the storage abstraction was introduced.
        profileIds:
          type: array
          items:
            type: string
        overrides:
          type: object
          additionalProperties:
            $ref: "#/components/schemas/PlatformOverride"
          nullable: true
        scheduledFor:
          type: string
          format: date-time
          nullable: true
        queueId:
          type: string
          nullable: true
        scheduledViaQueue:
          type: boolean
          default: false
        timezone:
          type: string
          nullable: true
        firstComment:
          type: string
          nullable: true
        publishedAt:
          type: string
          format: date-time
          nullable: true
        failedAt:
          type: string
          format: date-time
          nullable: true
        failureCode:
          type: string
          nullable: true
          description: >
            Machine-readable failure reason.
            ALL_TARGETS_FAILED: all platforms rejected the post.
            PARTIAL_FAILURE: some platforms succeeded, some failed.
            STORAGE_UNAVAILABLE: media storage health check failed.
            WORKER_EXCEPTION: unexpected internal error.
          enum:
            - ALL_TARGETS_FAILED
            - PARTIAL_FAILURE
            - STORAGE_UNAVAILABLE
            - WORKER_EXCEPTION
        failureMessage:
          type: string
          nullable: true
          description: Human-readable failure detail, suitable for display
        publishResults:
          type: array
          description: Per-profile publish outcomes from the latest publish attempt
          items:
            $ref: "#/components/schemas/PublishResult"
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    PublishResultStatus:
      type: string
      enum: [pending, published, failed]

    PublishResult:
      type: object
      required: [profileId, platform, status]
      properties:
        profileId:
          type: string
        platform:
          $ref: "#/components/schemas/Platform"
        status:
          $ref: "#/components/schemas/PublishResultStatus"
        platformPostId:
          type: string
          nullable: true
        error:
          type: string
          nullable: true
        publishedAt:
          type: string
          format: date-time
          nullable: true
        mayHavePublished:
          type: boolean
          nullable: true
          description: >
            Meta code 1 and similar — the post may exist on the platform despite a failed status.

    AcknowledgePublishInput:
      type: object
      required: [profileId, platformPostId]
      properties:
        profileId:
          type: string
        platformPostId:
          type: string
          description: Facebook post ID or full post URL

    PublishPostInput:
      type: object
      properties:
        profileIds:
          type: array
          items:
            type: string
          description: Subset of profiles to publish to. Defaults to all post targets.

    RetryPostInput:
      allOf:
        - $ref: "#/components/schemas/UpdatePostInput"
      description: Optional content updates applied atomically before retrying failed targets.

    PostListResponse:
      type: object
      required: [success, data]
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          required: [items, total, hasMore]
          properties:
            items:
              type: array
              items:
                $ref: "#/components/schemas/Post"
            total:
              type: integer
            hasMore:
              type: boolean

    PostResponse:
      type: object
      required: [success, data]
      properties:
        success:
          type: boolean
          example: true
        data:
          $ref: "#/components/schemas/Post"

    BulkPostResult:
      type: object
      required: [success, created, failed]
      properties:
        success:
          type: boolean
          example: true
        created:
          type: integer
          description: Number of posts successfully created
        failed:
          type: integer
          description: Number of rows that could not be parsed or validated
        errors:
          type: array
          items:
            type: object
            properties:
              row:
                type: integer
              message:
                type: string

    # ─── Inbox ────────────────────────────────────────────────────────────────

    Sender:
      type: object
      required: [platformUserId]
      properties:
        platformUserId:
          type: string
        username:
          type: string
          nullable: true
        displayName:
          type: string
          nullable: true
        avatarUrl:
          type: string
          format: uri
          nullable: true

    Message:
      type: object
      required:
        - id
        - organizationId
        - socialProfileId
        - platform
        - platformMessageId
        - type
        - content
        - sender
        - isRead
        - receivedAt
        - createdAt
      properties:
        id:
          type: string
          example: msg_abc123
        organizationId:
          type: string
        socialProfileId:
          type: string
          description: ID of the SocialProfile that received this message
        platform:
          $ref: "#/components/schemas/Platform"
        platformMessageId:
          type: string
          description: Native message ID from the social platform
        type:
          type: string
          enum: [dm, comment, story_reply, mention, review]
        content:
          type: string
        sender:
          $ref: "#/components/schemas/Sender"
        threadId:
          type: string
          nullable: true
          description: Groups related messages into a conversation thread
        parentMessageId:
          type: string
          nullable: true
          description: ID of the message this is a reply to
        isRead:
          type: boolean
        receivedAt:
          type: string
          format: date-time
          description: When the platform received the message (may differ from createdAt)
        createdAt:
          type: string
          format: date-time

    MessageListResponse:
      type: object
      required: [success, data]
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          required: [items, total, hasMore]
          properties:
            items:
              type: array
              items:
                $ref: "#/components/schemas/Message"
            total:
              type: integer
            hasMore:
              type: boolean

    MessageResponse:
      type: object
      required: [success, data]
      properties:
        success:
          type: boolean
          example: true
        data:
          $ref: "#/components/schemas/Message"

    ReplyInput:
      type: object
      required: [content]
      properties:
        content:
          type: string
          minLength: 1
          maxLength: 10000

    # ─── Analytics ────────────────────────────────────────────────────────────

    PostMetrics:
      type: object
      required: [postId, platform, metrics]
      description: Aggregated metrics for one published platform target on an Aether post.
      properties:
        postId:
          type: string
          description: Aether post ID (same value as the path parameter on GET /analytics/posts/{postId}).
        platform:
          $ref: "#/components/schemas/Platform"
        metrics:
          type: object
          properties:
            impressions:
              type: integer
              nullable: true
            reach:
              type: integer
              nullable: true
            likes:
              type: integer
              nullable: true
            comments:
              type: integer
              nullable: true
            shares:
              type: integer
              nullable: true
            clicks:
              type: integer
              nullable: true
            saves:
              type: integer
              nullable: true
            views:
              type: integer
              nullable: true

    AccountAnalyticsDataPoint:
      type: object
      required: [timestamp, platform]
      properties:
        timestamp:
          type: string
          format: date-time
        platform:
          $ref: "#/components/schemas/Platform"
        profileId:
          type: string
          nullable: true
        impressions:
          type: integer
        reach:
          type: integer
        likes:
          type: integer
        comments:
          type: integer
        shares:
          type: integer
        followerGrowth:
          type: integer

    BestTimeSlot:
      type: object
      required: [platform, dayOfWeek, hourUtc, engagementScore]
      properties:
        platform:
          $ref: "#/components/schemas/Platform"
        dayOfWeek:
          type: integer
          minimum: 0
          maximum: 6
          description: "Day of week: 0=Sunday, 6=Saturday"
        hourUtc:
          type: integer
          minimum: 0
          maximum: 23
          description: Hour in UTC (0–23)
        engagementScore:
          type: number
          format: float
          description: Relative engagement score (higher is better)

    # ─── Webhooks ─────────────────────────────────────────────────────────────

    CreateWebhookInput:
      type: object
      required: [url, events]
      properties:
        url:
          type: string
          format: uri
          description: HTTPS endpoint that will receive webhook payloads
          example: https://yourapp.com/webhooks/aether
        events:
          type: array
          items:
            $ref: "#/components/schemas/WebhookEvent"
          minItems: 1
          description: Event types to subscribe to
        description:
          type: string
          maxLength: 255
          description: Optional human-readable label for this webhook

    UpdateWebhookInput:
      type: object
      properties:
        url:
          type: string
          format: uri
        events:
          type: array
          items:
            $ref: "#/components/schemas/WebhookEvent"
          minItems: 1
        isActive:
          type: boolean
          description: Pause (false) or resume (true) delivery to this endpoint
        description:
          type: string
          maxLength: 255

    Webhook:
      type: object
      required:
        - id
        - organizationId
        - url
        - events
        - isActive
        - secretPrefix
        - createdAt
        - updatedAt
      properties:
        id:
          type: string
          example: wh_abc123
        organizationId:
          type: string
        url:
          type: string
          format: uri
        events:
          type: array
          items:
            $ref: "#/components/schemas/WebhookEvent"
        description:
          type: string
          nullable: true
        isActive:
          type: boolean
        secretPrefix:
          type: string
          description: First 8 characters of the signing secret — use to identify secrets
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    CreatedWebhook:
      allOf:
        - $ref: "#/components/schemas/Webhook"
        - type: object
          required: [plainSecret]
          properties:
            plainSecret:
              type: string
              description: >
                Full signing secret — shown only once at creation.
                Use this to verify the X-Aether-Signature header on incoming deliveries:
                HMAC-SHA256(body, plainSecret).

    WebhookDelivery:
      type: object
      required:
        - id
        - webhookId
        - eventType
        - attemptNumber
        - status
        - createdAt
      properties:
        id:
          type: string
        webhookId:
          type: string
        eventType:
          $ref: "#/components/schemas/WebhookEvent"
        httpStatus:
          type: integer
          nullable: true
          description: HTTP status code returned by your endpoint (null on network errors)
        attemptNumber:
          type: integer
          description: Which attempt this record represents (1-indexed)
        nextRetryAt:
          type: string
          format: date-time
          nullable: true
          description: When the next retry is scheduled (null if delivered or dead)
        status:
          type: string
          enum: [pending, delivered, failed, dead]
          description: >
            pending: queued, not yet attempted.
            delivered: endpoint returned 2xx.
            failed: endpoint returned non-2xx or network error, will retry.
            dead: max attempts exhausted, no further retries.
        createdAt:
          type: string
          format: date-time

    WebhookListResponse:
      type: object
      required: [success, data]
      properties:
        success:
          type: boolean
          example: true
        data:
          type: array
          items:
            $ref: "#/components/schemas/Webhook"

    WebhookResponse:
      type: object
      required: [success, data]
      properties:
        success:
          type: boolean
          example: true
        data:
          $ref: "#/components/schemas/Webhook"

    WebhookDeliveryListResponse:
      type: object
      required: [success, data]
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          required: [items, total, hasMore]
          properties:
            items:
              type: array
              items:
                $ref: "#/components/schemas/WebhookDelivery"
            total:
              type: integer
            hasMore:
              type: boolean

    # ─── Connect Links ────────────────────────────────────────────────────────

    CreateConnectLinkInput:
      type: object
      required: [platform]
      properties:
        platform:
          $ref: "#/components/schemas/Platform"
        label:
          type: string
          maxLength: 100
          description: Optional label to identify this link in your dashboard
        redirectUrl:
          type: string
          format: uri
          description: URL to redirect the user to after successful account connection

    ConnectLink:
      type: object
      required: [id, organizationId, platform, url, status, expiresAt, createdAt]
      properties:
        id:
          type: string
          example: cl_abc123
        organizationId:
          type: string
        platform:
          $ref: "#/components/schemas/Platform"
        label:
          type: string
          nullable: true
        url:
          type: string
          format: uri
          description: The magic OAuth URL to send to your end user
        status:
          type: string
          enum: [active, used, expired]
        profileId:
          type: string
          nullable: true
          description: ID of the SocialProfile created when this link was used
        expiresAt:
          type: string
          format: date-time
          description: Link expires after 24 hours if unused
        createdAt:
          type: string
          format: date-time

    ConnectLinkListResponse:
      type: object
      required: [success, data]
      properties:
        success:
          type: boolean
          example: true
        data:
          type: array
          items:
            $ref: "#/components/schemas/ConnectLink"

    ConnectLinkResponse:
      type: object
      required: [success, data]
      properties:
        success:
          type: boolean
          example: true
        data:
          $ref: "#/components/schemas/ConnectLink"

    # ─── API Keys ─────────────────────────────────────────────────────────────

    CreateApiKeyInput:
      type: object
      required: [name, scope]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 100
          example: Production integration
        scope:
          type: string
          enum: [full, read_only, profiles_only]
          description: >
            full: all API operations.
            read_only: GET requests only (no publishing, no mutations).
            profiles_only: only /profiles endpoints (list, sync, disconnect).
        allowedProfileIds:
          type: array
          items:
            type: string
          description: >
            Optional allowlist of social profile IDs. Empty means all profiles in the
            organization. When set, reads and writes on profile-scoped resources (posts,
            inbox, analytics, etc.) are limited to these profiles.

    ApiKey:
      type: object
      required:
        - id
        - organizationId
        - name
        - scope
        - allowedProfileIds
        - keyPrefix
        - createdAt
      properties:
        id:
          type: string
        organizationId:
          type: string
        name:
          type: string
        scope:
          type: string
          enum: [full, read_only, profiles_only]
        allowedProfileIds:
          type: array
          items:
            type: string
          description: Empty array means unrestricted access to all profiles
        keyPrefix:
          type: string
          description: First 8 characters of the API key (for identification)
        lastUsedAt:
          type: string
          format: date-time
          nullable: true
        createdAt:
          type: string
          format: date-time

    CreatedApiKey:
      allOf:
        - $ref: "#/components/schemas/ApiKey"
        - type: object
          required: [plainTextKey]
          properties:
            plainTextKey:
              type: string
              description: Full API key — shown only once at creation, never stored in plain text

    ApiKeyListResponse:
      type: object
      required: [success, data]
      properties:
        success:
          type: boolean
          example: true
        data:
          type: array
          items:
            $ref: "#/components/schemas/ApiKey"

    # ─── Organizations ────────────────────────────────────────────────────────

    Organization:
      type: object
      required: [id, name, slug, plan, createdAt, updatedAt]
      properties:
        id:
          type: string
        name:
          type: string
          minLength: 2
          maxLength: 80
        slug:
          type: string
          minLength: 2
          maxLength: 60
          pattern: "^[a-z0-9-]+$"
          description: URL-safe identifier for the organization
        plan:
          type: string
          enum: [free, starter, growth, scale, enterprise]
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    UpdateOrganizationInput:
      type: object
      properties:
        name:
          type: string
          minLength: 2
          maxLength: 80
        slug:
          type: string
          minLength: 2
          maxLength: 60
          pattern: "^[a-z0-9-]+$"

    Member:
      type: object
      required: [userId, email, role, joinedAt]
      properties:
        userId:
          type: string
        email:
          type: string
          format: email
        name:
          type: string
          nullable: true
        role:
          type: string
          enum: [owner, admin, member]
        joinedAt:
          type: string
          format: date-time

    InviteMemberInput:
      type: object
      required: [email, role]
      properties:
        email:
          type: string
          format: email
        role:
          type: string
          enum: [admin, member]

    # ─── Media ────────────────────────────────────────────────────────────────

    PresignInput:
      type: object
      required: [filename, contentType]
      properties:
        filename:
          type: string
          description: Original filename (used to derive the storage key extension)
          example: hero-image.jpg
        contentType:
          type: string
          description: MIME type of the file to upload (image/jpeg, image/png, image/gif, image/webp, video/mp4, video/quicktime, video/webm, application/pdf)
          example: image/jpeg
          enum:
            - image/jpeg
            - image/png
            - image/gif
            - image/webp
            - video/mp4
            - video/quicktime
            - video/webm
            - application/pdf
        size:
          type: integer
          description: File size in bytes (used to enforce plan limits)

    PresignResponse:
      type: object
      required: [success, data]
      properties:
        success:
          type: boolean
          example: true
        data:
          type: object
          required: [uploadUrl, publicUrl, key, expiresAt]
          properties:
            uploadUrl:
              type: string
              format: uri
              description: Presigned PUT URL — upload the file directly to this URL
            publicUrl:
              type: string
              format: uri
              description: Public URL of the file after upload completes
            key:
              type: string
              description: Canonical storage key — use this in mediaKeys when creating posts
            expiresAt:
              type: string
              format: date-time
              description: When the presigned upload URL expires (15 minutes)

    QueueSlot:
      type: object
      required: [dayOfWeek, time]
      properties:
        dayOfWeek:
          type: integer
          minimum: 0
          maximum: 6
        time:
          type: string
          pattern: "^\\d{2}:\\d{2}$"

    CreatePublishQueueInput:
      type: object
      required: [name, timezone, slots]
      properties:
        name:
          type: string
        timezone:
          type: string
        slots:
          type: array
          items:
            $ref: "#/components/schemas/QueueSlot"
        profileIds:
          type: array
          items:
            type: string
        isDefault:
          type: boolean
        isActive:
          type: boolean

    UpdatePublishQueueInput:
      type: object
      properties:
        name:
          type: string
        timezone:
          type: string
        slots:
          type: array
          items:
            $ref: "#/components/schemas/QueueSlot"
        profileIds:
          type: array
          items:
            type: string
        isDefault:
          type: boolean
        isActive:
          type: boolean

    DmButton:
      type: object
      description: |
        Inline button for private-reply DMs via Meta button_template.
        Up to 3 buttons. When buttons are set, message/dmMessage must be 640 characters or fewer.
      required: [type, title]
      properties:
        type:
          type: string
          enum: [url, postback]
        title:
          type: string
          maxLength: 20
        url:
          type: string
          format: uri
          description: Required when type is url
        payload:
          type: string
          maxLength: 1000
          description: Required when type is postback

    PrivateReplyInput:
      type: object
      required: [profileId, message]
      properties:
        profileId:
          type: string
        message:
          type: string
          maxLength: 2200
          description: Max 640 characters when buttons are attached
        buttons:
          type: array
          minItems: 1
          maxItems: 3
          items:
            $ref: "#/components/schemas/DmButton"

    CreateCommentAutomationInput:
      type: object
      required: [profileId, name, keywords, dmMessage]
      properties:
        profileId:
          type: string
        name:
          type: string
        trigger:
          type: string
          enum: [comment, story_reply]
        platformPostId:
          type: string
        keywords:
          type: array
          items:
            type: string
        matchMode:
          type: string
          enum: [exact, contains]
        dmMessage:
          type: string
          description: Max 640 characters when buttons are attached
        commentReply:
          type: string
        buttons:
          type: array
          maxItems: 3
          items:
            $ref: "#/components/schemas/DmButton"
        isActive:
          type: boolean

    UpdateCommentAutomationInput:
      type: object
      properties:
        profileId:
          type: string
        name:
          type: string
        trigger:
          type: string
          enum: [comment, story_reply]
        platformPostId:
          type: string
        keywords:
          type: array
          items:
            type: string
        matchMode:
          type: string
          enum: [exact, contains]
        dmMessage:
          type: string
          description: Max 640 characters when buttons are attached
        commentReply:
          type: string
        buttons:
          type: array
          maxItems: 3
          items:
            $ref: "#/components/schemas/DmButton"
        isActive:
          type: boolean

    TestCommentAutomationInput:
      type: object
      required: [platformPostId, commentText]
      properties:
        platformPostId:
          type: string
          description: Platform post or story media ID for scoping
        commentId:
          type: string
          description: Required when testing a comment trigger automation
        commentText:
          type: string
          description: Comment or story reply text to match against keywords
        recipientId:
          type: string
          description: Required when testing a story_reply trigger — Instagram-scoped sender ID or Facebook PSID
        commenterId:
          type: string

  responses:
    Unauthorized:
      description: Missing or invalid API key
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    ValidationError:
      description: Request body failed validation
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    BadRequest:
      description: Bad request
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    Forbidden:
      description: Insufficient permissions to perform this action
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"

security:
  - ApiKeyAuth: []

paths:
  # ─────────────────────────────────────────────
  # HEALTH
  # ─────────────────────────────────────────────
  /health:
    get:
      tags: [Health]
      summary: Service health check
      operationId: getHealth
      security: []
      description: >
        Returns 200 when both database and Redis are reachable.
        Returns 503 when one or more dependencies are unavailable.
        No authentication required.
      responses:
        "200":
          description: All dependencies healthy
          content:
            application/json:
              schema:
                type: object
                required: [status, db, redis, timestamp]
                properties:
                  status:
                    type: string
                    enum: [ok, degraded]
                  db:
                    type: string
                    enum: [ok, error]
                  redis:
                    type: string
                    enum: [ok, error]
                  timestamp:
                    type: string
                    format: date-time
        "400":
          description: Not used — included to satisfy linting rules
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "503":
          description: One or more dependencies unavailable
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: degraded
                  db:
                    type: string
                  redis:
                    type: string
                  timestamp:
                    type: string
                    format: date-time

  # ─────────────────────────────────────────────
  # OAUTH (Facebook page selection)
  # ─────────────────────────────────────────────
  /oauth/facebook/pending/{pendingId}:
    get:
      tags: [OAuth]
      summary: List Facebook Pages awaiting selection
      operationId: getFacebookPendingPages
      description: >
        After Facebook OAuth when the user manages multiple Pages, the callback stores a
        short-lived pending session. Use this endpoint to populate the in-dashboard Page picker.
      parameters:
        - name: pendingId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Pending Pages for selection
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FacebookPendingPagesResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /oauth/facebook/complete:
    post:
      tags: [OAuth]
      summary: Complete Facebook Page selection
      operationId: completeFacebookPageConnect
      description: >
        Exchanges the pending session for a connected Facebook Page profile.
        The pending session is single-use and expires after 15 minutes.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/FacebookPageCompleteRequest"
      responses:
        "200":
          description: Connected Facebook Page profile
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SocialProfileResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  # ─────────────────────────────────────────────
  # PROFILES
  # ─────────────────────────────────────────────
  /profiles:
    get:
      tags: [Profiles]
      summary: List all connected social profiles
      operationId: listProfiles
      parameters:
        - name: platform
          in: query
          description: Filter by social platform
          schema:
            $ref: "#/components/schemas/Platform"
        - name: connectionStatus
          in: query
          description: Filter by connection status
          schema:
            type: string
            enum: [connected, disconnected, error]
      responses:
        "200":
          description: List of social profiles
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SocialProfileListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /profiles/{profileId}:
    get:
      tags: [Profiles]
      summary: Get a social profile by ID
      operationId: getProfile
      parameters:
        - name: profileId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Social profile
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SocialProfileResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

    delete:
      tags: [Profiles]
      summary: Disconnect a social profile
      operationId: deleteProfile
      description: >
        Revokes the stored OAuth tokens and marks the profile as disconnected.
        Dispatches a `profile.disconnected` webhook event to all subscribed endpoints.
      parameters:
        - name: profileId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Profile disconnected
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SocialProfileResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  # ─────────────────────────────────────────────
  # POSTS
  # ─────────────────────────────────────────────
  /posts:
    get:
      tags: [Posts]
      summary: List posts
      operationId: listPosts
      parameters:
        - name: status
          in: query
          description: Filter by post status
          schema:
            $ref: "#/components/schemas/PostStatus"
        - name: profileId
          in: query
          description: Filter to posts targeting a specific profile
          schema:
            type: string
        - name: page
          in: query
          schema:
            type: integer
            minimum: 1
            default: 1
        - name: perPage
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
      responses:
        "200":
          description: Paginated list of posts
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PostListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

    post:
      tags: [Posts]
      summary: Create and optionally schedule a post
      operationId: createPost
      description: >
        Creates a post and publishes it immediately (if no scheduledFor) or queues it
        for scheduled delivery. Returns the post object with status `publishing` (immediate)
        or `scheduled`. Subscribe to `post.published`, `post.failed`, and per-platform
        `post.platform.*` webhooks to track publish progress.

        **Idempotency:** Pass a unique `x-request-id` header (UUID recommended). After a
        request completes, retries with the same ID within 5 minutes return HTTP 200 with the
        original response body and `X-Cache: HIT`. If a duplicate arrives while the first
        request is still in flight, the API returns HTTP 409 `IDEMPOTENCY_IN_PROGRESS`
        immediately — clients should retry after a short delay.

        **Content deduplication:** Identical content to the same profile IDs within 24 hours
        returns HTTP 409 with `existingPostId` in the error details.
      parameters:
        - name: x-request-id
          in: header
          required: false
          schema:
            type: string
            format: uuid
          description: >
            Optional client-generated request identifier for safe retries. Completed requests
            are replayed within ~5 minutes (HTTP 200, X-Cache HIT). Concurrent duplicates
            receive HTTP 409 IDEMPOTENCY_IN_PROGRESS.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreatePostInput"
      responses:
        "200":
          description: Idempotent replay of a prior request (same x-request-id)
          headers:
            X-Cache:
              schema:
                type: string
                enum: [HIT]
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PostResponse"
        "201":
          description: Post created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PostResponse"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          description: Duplicate content or idempotent request still in progress
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /posts/bulk:
    post:
      tags: [Posts]
      summary: Bulk create posts from CSV
      operationId: bulkCreatePosts
      description: >
        Upload a CSV file to schedule many posts at once. CSV columns:
        `text` (required), `profileIds` (comma-separated, required),
        `scheduledFor` (ISO 8601, optional), `mediaUrls` (comma-separated URLs, optional).
        Returns a summary of created and failed rows.

        Supports the same `x-request-id` idempotency semantics as `POST /posts` — the entire
        bulk operation is cached, so retrying a failed network call does not re-process rows.
      parameters:
        - name: x-request-id
          in: header
          required: false
          schema:
            type: string
            format: uuid
          description: Optional idempotency key for the whole bulk request.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [csv]
              properties:
                csv:
                  type: string
                  format: binary
                  description: CSV file
      responses:
        "200":
          description: Bulk creation result
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BulkPostResult"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /posts/{postId}:
    get:
      tags: [Posts]
      summary: Get a post by ID
      operationId: getPost
      parameters:
        - name: postId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Post object
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PostResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

    patch:
      tags: [Posts]
      summary: Update a draft or scheduled post
      operationId: updatePost
      description: Only posts with status `draft`, `scheduled`, or `failed` can be updated.
      parameters:
        - name: postId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdatePostInput"
      responses:
        "200":
          description: Updated post
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PostResponse"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

    delete:
      tags: [Posts]
      summary: Cancel or delete a post
      operationId: deletePost
      description: >
        Cancels scheduled posts (sets status to `cancelled`) or permanently deletes
        drafts. Published posts cannot be deleted via this endpoint.
      parameters:
        - name: postId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Post cancelled
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PostResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/ValidationError"

  /posts/{postId}/publish:
    post:
      tags: [Posts]
      summary: Publish a post immediately
      operationId: publishPost
      description: >
        Publishes a draft, scheduled, or failed post immediately. For scheduled posts,
        clears the schedule and publishes now. Optional profileIds limits which targets
        are published in this attempt.
      parameters:
        - name: postId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PublishPostInput"
      responses:
        "200":
          description: Post after publish attempt (may be publishing, published, or failed)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PostResponse"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/ValidationError"

  /posts/{postId}/retry:
    post:
      tags: [Posts]
      summary: Retry publishing failed targets
      operationId: retryPost
      description: >
        Retries publishing for a fully failed post (all targets) or only the failed
        profiles on a post with PARTIAL_FAILURE. Optional body applies content updates
        before retry.
      parameters:
        - name: postId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RetryPostInput"
      responses:
        "200":
          description: Post after retry attempt
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PostResponse"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/ValidationError"

  /posts/{postId}/reconcile:
    post:
      tags: [Posts]
      summary: Reconcile failed Facebook publishes
      operationId: reconcilePostPublish
      description: >
        Checks recent Facebook page posts for failed targets that may have published
        despite a Meta API error. Updates publishResults when a matching post is found.
      parameters:
        - name: postId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Reconciliation result
          content:
            application/json:
              schema:
                type: object
                required: [success, data]
                properties:
                  success:
                    type: boolean
                  data:
                    type: object
                    required: [post, reconciledProfileIds]
                    properties:
                      post:
                        $ref: "#/components/schemas/Post"
                      reconciledProfileIds:
                        type: array
                        items:
                          type: string
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/ValidationError"

  /posts/{postId}/acknowledge-publish:
    post:
      tags: [Posts]
      summary: Mark a failed target as published
      operationId: acknowledgePostPublish
      description: >
        Manually mark a failed profile target as published when the post is already
        live on the platform (e.g. after a Meta false-negative error).
      parameters:
        - name: postId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AcknowledgePublishInput"
      responses:
        "200":
          description: Post after acknowledgement
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PostResponse"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/ValidationError"

  # ─────────────────────────────────────────────
  # INBOX
  # ─────────────────────────────────────────────
  /inbox:
    get:
      tags: [Inbox]
      summary: List inbox messages (DMs, comments, mentions)
      operationId: listMessages
      parameters:
        - name: platform
          in: query
          schema:
            $ref: "#/components/schemas/Platform"
        - name: type
          in: query
          schema:
            type: string
            enum: [dm, comment, story_reply, mention, review]
        - name: isRead
          in: query
          schema:
            type: boolean
        - name: profileId
          in: query
          description: Filter to messages on a specific social profile
          schema:
            type: string
        - name: page
          in: query
          schema:
            type: integer
            minimum: 1
            default: 1
        - name: perPage
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
      responses:
        "200":
          description: Paginated list of messages
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MessageListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /inbox/{messageId}:
    get:
      tags: [Inbox]
      summary: Get a message by ID
      operationId: getMessage
      parameters:
        - name: messageId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Message
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MessageResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

    delete:
      tags: [Inbox]
      summary: Delete a message
      operationId: deleteMessage
      parameters:
        - name: messageId
          in: path
          required: true
          schema:
            type: string
      responses:
        "204":
          description: Message deleted
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /inbox/{messageId}/reply:
    post:
      tags: [Inbox]
      summary: Reply to a message
      description: |
        Routes by message type:

        - **comment** — posts a public reply on the platform (Instagram/Facebook comment APIs).
        - **dm** / **story_reply** — sends a direct message via the Meta Messaging API
          (`POST /{accountId}/messages`) using the sender's platform user ID from ingestion.

        Instagram and Facebook support DM replies. Other platforms return `422 DM_REPLY_UNSUPPORTED`.

        Meta enforces a **24-hour messaging window** for DM replies after the user's last message.
        Expired windows return `422 DM_OUTSIDE_24H_WINDOW`.
      operationId: replyToMessage
      parameters:
        - name: messageId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ReplyInput"
      responses:
        "201":
          description: Reply sent
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MessageResponse"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          description: |
            Reply not possible. Codes: `REPLY_UNSUPPORTED`, `DM_REPLY_UNSUPPORTED`,
            `DM_RECIPIENT_MISSING`, `DM_OUTSIDE_24H_WINDOW`, `DM_USER_BLOCKED`,
            `PROFILE_NOT_CONNECTED`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /inbox/{messageId}/read:
    patch:
      tags: [Inbox]
      summary: Mark a message as read
      operationId: markMessageRead
      parameters:
        - name: messageId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Message marked as read
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MessageResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  # ─────────────────────────────────────────────
  # ANALYTICS
  # ─────────────────────────────────────────────
  /analytics/posts/{postId}:
    get:
      tags: [Analytics]
      summary: Get metrics for a specific post
      description: Returns per-platform aggregated metrics for each published target on the post. The path `postId` is the Aether post ID from `GET /posts` or `POST /posts`.
      operationId: getPostAnalytics
      parameters:
        - name: postId
          in: path
          required: true
          description: Aether post ID (not the platform-native post ID).
          schema:
            type: string
      responses:
        "200":
          description: Per-platform metrics for the post
          content:
            application/json:
              schema:
                type: object
                required: [success, data]
                properties:
                  success:
                    type: boolean
                    example: true
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/PostMetrics"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /analytics/accounts:
    get:
      tags: [Analytics]
      summary: Get account-level aggregated analytics
      operationId: getAccountAnalytics
      parameters:
        - name: profileId
          in: query
          description: >
            Filter to a single social profile. When the API key has `allowedProfileIds`,
            omitting this parameter returns only metrics for the allowed profiles (not all
            org profiles). Passing a `profileId` outside the key's allowlist returns 403.
          schema:
            type: string
        - name: platform
          in: query
          schema:
            $ref: "#/components/schemas/Platform"
        - name: from
          in: query
          required: true
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          required: true
          schema:
            type: string
            format: date-time
        - name: granularity
          in: query
          schema:
            type: string
            enum: [hour, day, week, month]
            default: day
      responses:
        "200":
          description: Time-series account analytics
          content:
            application/json:
              schema:
                type: object
                required: [success, data]
                properties:
                  success:
                    type: boolean
                    example: true
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/AccountAnalyticsDataPoint"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  /analytics/best-times:
    get:
      tags: [Analytics]
      summary: Get recommended posting times
      operationId: getBestTimes
      description: >
        Analyzes historical post performance to recommend the best day-of-week and
        hour-of-day combinations per platform. Requires at least 10 published posts
        per platform to return meaningful results.
        API keys with `allowedProfileIds` receive 403 — this endpoint aggregates
        org-wide data and cannot be scoped to a profile subset.
        JWT callers and unrestricted keys are unaffected.
      parameters:
        - name: platform
          in: query
          description: Filter to a specific platform (omit for all)
          schema:
            $ref: "#/components/schemas/Platform"
      responses:
        "200":
          description: Best posting time slots ranked by engagement score
          content:
            application/json:
              schema:
                type: object
                required: [success, data]
                properties:
                  success:
                    type: boolean
                    example: true
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/BestTimeSlot"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  # ─────────────────────────────────────────────
  # WEBHOOKS
  # ─────────────────────────────────────────────
  /webhooks:
    get:
      tags: [Webhooks]
      summary: List webhooks
      operationId: listWebhooks
      responses:
        "200":
          description: List of webhooks
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

    post:
      tags: [Webhooks]
      summary: Create a webhook
      operationId: createWebhook
      description: >
        Creates a webhook subscription. Returns the webhook with a one-time `plainSecret`
        field for signing verification. Store this secret securely — it is never returned again.
        Verify incoming payloads: `sha256=HMAC-SHA256(body, plainSecret)` must match
        the `X-Aether-Signature` header.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateWebhookInput"
      responses:
        "201":
          description: Webhook created — includes one-time signing secret
          content:
            application/json:
              schema:
                type: object
                required: [success, data]
                properties:
                  success:
                    type: boolean
                    example: true
                  data:
                    $ref: "#/components/schemas/CreatedWebhook"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /webhooks/{webhookId}:
    patch:
      tags: [Webhooks]
      summary: Update a webhook
      operationId: updateWebhook
      description: Update the URL, subscribed events, active status, or description.
      parameters:
        - name: webhookId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateWebhookInput"
      responses:
        "200":
          description: Updated webhook
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookResponse"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

    delete:
      tags: [Webhooks]
      summary: Delete a webhook
      operationId: deleteWebhook
      parameters:
        - name: webhookId
          in: path
          required: true
          schema:
            type: string
      responses:
        "204":
          description: Webhook deleted
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /webhooks/{webhookId}/deliveries:
    get:
      tags: [Webhooks]
      summary: List delivery attempts for a webhook
      operationId: listWebhookDeliveries
      parameters:
        - name: webhookId
          in: path
          required: true
          schema:
            type: string
        - name: page
          in: query
          schema:
            type: integer
            minimum: 1
            default: 1
        - name: perPage
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
      responses:
        "200":
          description: Paginated delivery log
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WebhookDeliveryListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  # ─────────────────────────────────────────────
  # CONNECT LINKS
  # ─────────────────────────────────────────────
  /connect-links:
    get:
      tags: [Connect Links]
      summary: List connect links
      operationId: listConnectLinks
      responses:
        "200":
          description: List of connect links
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConnectLinkListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

    post:
      tags: [Connect Links]
      summary: Generate a magic OAuth connect link
      operationId: createConnectLink
      description: >
        Creates a one-time OAuth URL for your end user. Send this URL to the user —
        when they click it, they'll be redirected to the social platform's OAuth flow.
        On success, a SocialProfile is created in your organization and a
        `profile.connected` webhook event is dispatched. The link expires after 24 hours.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateConnectLinkInput"
      responses:
        "201":
          description: Connect link with URL and expiry
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConnectLinkResponse"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"

  # ─────────────────────────────────────────────
  # API KEYS
  # ─────────────────────────────────────────────
  /api-keys:
    get:
      tags: [API Keys]
      summary: List API keys
      operationId: listApiKeys
      description: >
        Returns all API keys for the organization. Plain-text key values are never returned.
        Requires full API key scope when using API key authentication (JWT always allowed).
      responses:
        "200":
          description: List of API keys
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiKeyListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

    post:
      tags: [API Keys]
      summary: Create an API key
      operationId: createApiKey
      description: >
        Creates a new API key. Returns the key object with a one-time `plainTextKey` field.
        Store this value securely — it is never returned again.
        Requires full API key scope when using API key authentication (JWT always allowed).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateApiKeyInput"
      responses:
        "201":
          description: New API key — plain-text value included once only
          content:
            application/json:
              schema:
                type: object
                required: [success, data]
                properties:
                  success:
                    type: boolean
                    example: true
                  data:
                    $ref: "#/components/schemas/CreatedApiKey"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api-keys/{keyId}:
    delete:
      tags: [API Keys]
      summary: Revoke an API key
      operationId: revokeApiKey
      description: >
        Permanently revokes the key. Any requests using this key will fail immediately.
        Requires full API key scope when using API key authentication (JWT always allowed).
      parameters:
        - name: keyId
          in: path
          required: true
          schema:
            type: string
      responses:
        "204":
          description: API key revoked
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  # ─────────────────────────────────────────────
  # ORGANIZATIONS
  # ─────────────────────────────────────────────
  /organization:
    get:
      tags: [Organizations]
      summary: Get current organization
      operationId: getOrganization
      responses:
        "200":
          description: Organization details
          content:
            application/json:
              schema:
                type: object
                required: [success, data]
                properties:
                  success:
                    type: boolean
                    example: true
                  data:
                    $ref: "#/components/schemas/Organization"
        "401":
          $ref: "#/components/responses/Unauthorized"

    patch:
      tags: [Organizations]
      summary: Update organization settings
      operationId: updateOrganization
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateOrganizationInput"
      responses:
        "200":
          description: Updated organization
          content:
            application/json:
              schema:
                type: object
                required: [success, data]
                properties:
                  success:
                    type: boolean
                    example: true
                  data:
                    $ref: "#/components/schemas/Organization"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /organization/members:
    get:
      tags: [Organizations]
      summary: List organization members
      operationId: listMembers
      responses:
        "200":
          description: List of members with roles
          content:
            application/json:
              schema:
                type: object
                required: [success, data]
                properties:
                  success:
                    type: boolean
                    example: true
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Member"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /organization/members/{userId}:
    delete:
      tags: [Organizations]
      summary: Remove a team member
      operationId: removeMember
      description: Removes the user from the organization. Only owners and admins can remove members.
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: string
      responses:
        "204":
          description: Member removed
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /organization/invites:
    post:
      tags: [Organizations]
      summary: Invite a team member
      operationId: inviteMember
      description: >
        Sends an email invitation to the specified address. The invitee receives a
        magic link that creates their account and adds them to the organization.
        Invites expire after 7 days.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/InviteMemberInput"
      responses:
        "201":
          description: Invite sent
          content:
            application/json:
              schema:
                type: object
                required: [success]
                properties:
                  success:
                    type: boolean
                    example: true
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"

  # ─────────────────────────────────────────────
  # MEDIA
  # ─────────────────────────────────────────────
  /queues:
    get:
      tags: [Queues]
      summary: List org publish queues
      operationId: listQueues
      responses:
        "200":
          description: Queue list
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Queues]
      summary: Create a publish queue
      operationId: createQueue
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreatePublishQueueInput"
      responses:
        "201":
          description: Queue created
        "401":
          $ref: "#/components/responses/Unauthorized"

  /queues/next-slot:
    get:
      tags: [Queues]
      summary: Preview next available queue slot
      operationId: peekQueueNextSlot
      parameters:
        - name: queueId
          in: query
          schema:
            type: string
      responses:
        "200":
          description: Next slot preview
        "401":
          $ref: "#/components/responses/Unauthorized"

  /queues/{queueId}:
    get:
      tags: [Queues]
      summary: Get a publish queue
      operationId: getQueue
      parameters:
        - name: queueId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Queue details
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      tags: [Queues]
      summary: Update a publish queue
      operationId: updateQueue
      parameters:
        - name: queueId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdatePublishQueueInput"
      responses:
        "200":
          description: Queue updated
    delete:
      tags: [Queues]
      summary: Delete a publish queue
      operationId: deleteQueue
      parameters:
        - name: queueId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Queue deleted

  /automations/comment-to-dm:
    get:
      tags: [Automations]
      summary: List comment-to-DM automations
      operationId: listCommentAutomations
      parameters:
        - name: profileId
          in: query
          schema:
            type: string
      responses:
        "200":
          description: Automation list
    post:
      tags: [Automations]
      summary: Create a comment-to-DM automation
      operationId: createCommentAutomation
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateCommentAutomationInput"
      responses:
        "201":
          description: Automation created

  /automations/comment-to-dm/{automationId}:
    get:
      tags: [Automations]
      summary: Get automation
      operationId: getCommentAutomation
      parameters:
        - name: automationId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Automation details
    patch:
      tags: [Automations]
      summary: Update automation
      operationId: updateCommentAutomation
      parameters:
        - name: automationId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateCommentAutomationInput"
      responses:
        "200":
          description: Automation updated
    delete:
      tags: [Automations]
      summary: Delete automation
      operationId: deleteCommentAutomation
      parameters:
        - name: automationId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Automation deleted

  /automations/comment-to-dm/{automationId}/test:
    post:
      tags: [Automations]
      summary: Manually test a comment-to-DM automation
      operationId: testCommentAutomation
      parameters:
        - name: automationId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TestCommentAutomationInput"
      responses:
        "200":
          description: Test executed

  /inbox/comments/{platformPostId}/{commentId}/private-reply:
    post:
      tags: [Inbox]
      summary: Send a private reply to a comment author
      operationId: sendPrivateReplyToComment
      description: |
        Send a private DM to a commenter on Instagram or Facebook.
        Optionally attach 1–3 inline buttons (Meta button_template).
        When buttons are provided, message must be 640 characters or fewer.
      parameters:
        - name: platformPostId
          in: path
          required: true
          schema:
            type: string
        - name: commentId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PrivateReplyInput"
      responses:
        "201":
          description: Private reply sent
        "422":
          description: Validation or platform error (e.g. PRIVATE_REPLY_MESSAGE_TOO_LONG, PRIVATE_REPLY_ALREADY_SENT)

  /media/presign:
    post:
      tags: [Media]
      summary: Get a presigned URL for direct media upload
      operationId: presignMedia
      description: >
        Returns a presigned PUT URL for uploading media directly to Aether's storage
        (Cloudflare R2). Upload the file directly to `uploadUrl` with the correct
        `Content-Type` header. Then use the returned `key` in the `mediaKeys` field
        when creating posts — this ensures media URLs survive storage provider changes.

        Size limits: images 20 MB, videos 512 MB.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PresignInput"
      responses:
        "200":
          description: Presigned upload URL and public media URL
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PresignResponse"
        "400":
          $ref: "#/components/responses/ValidationError"
        "401":
          $ref: "#/components/responses/Unauthorized"
