openapi: 3.0.3
info:
  title: fc.nikgob API
  version: 1.0.0
  description: API contract for the native Android and Windows applications.
servers:
  - url: https://fc.nikgob.com/v1
    description: production
  - url: http://localhost:8080/v1
    description: local
security:
  - bearer: []
components:
  securitySchemes:
    bearer:
      type: http
      scheme: bearer
      bearerFormat: JWT
  schemas:
    Error:
      type: object
      required: [error, request_id]
      properties:
        error: { type: string }
        request_id: { type: string }
    User:
      type: object
      required: [id, email, role, banned, sr_algorithm]
      properties:
        id: { type: string }
        email: { type: string, format: email }
        display_name: { type: string }
        role: { type: string, enum: [user, admin] }
        banned: { type: boolean }
        banned_reason: { type: string }
        sr_algorithm: { type: string, enum: [sm2, fsrs] }
        lang: { type: string, enum: [ru, en] }
        theme: { type: string, enum: [system, light, dark] }
        seed_color:
          type: string
          pattern: "^#[0-9A-F]{6}$"
          description: >-
            Material 3 source color the client expands into a full scheme.
            Absent or empty means the built-in brand seed.
        dynamic_color:
          type: boolean
          description: Prefer a platform-provided palette (Android 12+ wallpaper colors) over seed_color where available.
        contrast: { type: string, enum: [standard, medium, high] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        last_login_at: { type: string, format: date-time }
    AuthTokens:
      type: object
      required: [token, access_token, refresh_token, expires_in, user]
      properties:
        token:
          type: string
          description: Compatibility alias of access_token.
        access_token: { type: string }
        refresh_token: { type: string }
        expires_in:
          type: integer
          format: int64
          minimum: 1
          description: Access token lifetime in seconds.
        user: { $ref: "#/components/schemas/User" }
    RefreshTokenRequest:
      type: object
      required: [refresh_token]
      properties:
        refresh_token: { type: string, minLength: 32 }
    DeckMutationRequest:
      type: object
      additionalProperties: false
      required: [name]
      properties:
        name:
          type: string
          minLength: 1
          description: Must contain at least one non-whitespace character.
        description: { type: string }
        color: { type: string }
        language:
          type: string
          pattern: "^([a-z]{2,3}(-[A-Za-z0-9]{2,8}){0,3})?$"
          maxLength: 35
          description: BCP-47 tag of the language studied in this deck; empty clears it.
        translation_language:
          type: string
          pattern: "^([a-z]{2,3}(-[A-Za-z0-9]{2,8}){0,3})?$"
          maxLength: 35
          description: BCP-47 tag the answers are written in.
    Deck:
      type: object
      required: [id, user_id, name, version, created_at, updated_at]
      properties:
        id: { type: string }
        user_id: { type: string }
        name: { type: string }
        description: { type: string }
        color: { type: string }
        language:
          type: string
          pattern: "^([a-z]{2,3}(-[A-Za-z0-9]{2,8}){0,3})?$"
          maxLength: 35
          description: >-
            BCP-47 tag of the language studied in this deck ("es", "es-ES").
            Absent or empty means unspecified. Server-side speech synthesis
            derives its locale from this when the client sends none.
        translation_language:
          type: string
          pattern: "^([a-z]{2,3}(-[A-Za-z0-9]{2,8}){0,3})?$"
          maxLength: 35
          description: BCP-47 tag the answers are written in, usually the learner's own.
        version: { type: integer, format: int64, minimum: 0 }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    CardMutationRequest:
      type: object
      additionalProperties: false
      required: [card_type, fields]
      properties:
        card_type:
          type: string
          enum: [basic, basic_reversed, sentence, cloze]
        fields:
          type: object
          minProperties: 1
          additionalProperties: { type: string }
          description: >-
            Required non-blank fields depend on card_type: front and back for basic/basic_reversed;
            word and sentence for sentence; text for cloze.
        tags:
          type: array
          items: { type: string }
    Card:
      description: Versioned v1 card resource combining editable content, media references, and scheduler state.
      type: object
      required: [id, deck_id, user_id, card_type, fields, version, sched, due, created_at, updated_at]
      properties:
        id: { type: string }
        deck_id: { type: string }
        user_id: { type: string }
        card_type:
          type: string
          enum: [basic, basic_reversed, sentence, cloze]
        fields:
          type: object
          additionalProperties: { type: string }
        tags:
          type: array
          items: { type: string }
        media:
          type: array
          items: { type: string }
        tts_media_id: { type: string }
        tts_media_slug: { type: string }
        version: { type: integer, format: int64, minimum: 0 }
        sched: { $ref: "#/components/schemas/SchedState" }
        due: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    ReviewSubmitRequest:
      type: object
      additionalProperties: false
      required: [operation_id, device_id, card_id, card_version, rating, reviewed_at]
      properties:
        operation_id:
          type: string
          format: uuid
          description: Stable client-generated idempotency identifier.
        device_id: { type: string, minLength: 1, maxLength: 128 }
        card_id: { type: string, minLength: 1, maxLength: 128 }
        card_version:
          type: integer
          format: int64
          minimum: 0
          description: Version observed when the client performed the review.
        rating: { type: integer, enum: [1, 2, 3, 4] }
        reviewed_at:
          type: string
          format: date-time
          description: Client occurrence time; timestamps more than five minutes in the future are rejected.
    ReviewPreview:
      type: object
      required: [rating, interval_days, due, sched]
      properties:
        rating: { type: integer, enum: [1, 2, 3, 4] }
        interval_days: { type: number, format: double, minimum: 0 }
        due: { type: string, format: date-time }
        sched: { $ref: "#/components/schemas/SchedState" }
    ReviewPreviewResponse:
      type: object
      required: [card_id, card_version, previews]
      properties:
        card_id: { type: string }
        card_version: { type: integer, format: int64, minimum: 0 }
        previews:
          type: array
          minItems: 4
          maxItems: 4
          items: { $ref: "#/components/schemas/ReviewPreview" }
    ReviewResult:
      type: object
      required: [operation_id, device_id, card_id, deck_id, rating, reviewed_at, before, after, base_card_version, card_version, card]
      properties:
        operation_id: { type: string, format: uuid }
        device_id: { type: string }
        card_id: { type: string }
        deck_id: { type: string }
        rating: { type: integer, enum: [1, 2, 3, 4] }
        reviewed_at: { type: string, format: date-time }
        before: { $ref: "#/components/schemas/SchedState" }
        after: { $ref: "#/components/schemas/SchedState" }
        base_card_version: { type: integer, format: int64, minimum: 0 }
        card_version: { type: integer, format: int64, minimum: 1 }
        card: { $ref: "#/components/schemas/Card" }
    SchedState:
      type: object
      required: [algorithm, interval, ease, reps, lapses]
      properties:
        algorithm: { type: string, enum: [sm2, fsrs] }
        interval: { type: number, format: double, minimum: 0 }
        ease: { type: number, format: double }
        reps: { type: integer, minimum: 0 }
        lapses: { type: integer, minimum: 0 }
        stability: { type: number, format: double }
        difficulty: { type: number, format: double }
        last_review: { type: integer, format: int64 }
        fsrs_state: { type: integer }
    SyncCardMutation:
      type: object
      additionalProperties: false
      required: [deck_id, card_type, fields]
      properties:
        deck_id: { type: string, minLength: 1, maxLength: 128 }
        card_type:
          type: string
          enum: [basic, basic_reversed, sentence, cloze]
        fields:
          type: object
          minProperties: 1
          additionalProperties: { type: string }
          description: >-
            Required non-blank fields depend on card_type: front and back for basic/basic_reversed;
            word and sentence for sentence; text for cloze.
        tags:
          type: array
          items: { type: string }
    SyncDeckMutation:
      type: object
      additionalProperties: false
      required: [name]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: Must contain at least one non-whitespace character.
        description: { type: string }
        color: { type: string }
        language:
          type: string
          pattern: "^([a-z]{2,3}(-[A-Za-z0-9]{2,8}){0,3})?$"
          maxLength: 35
          description: BCP-47 tag of the language studied in this deck; empty clears it.
        translation_language:
          type: string
          pattern: "^([a-z]{2,3}(-[A-Za-z0-9]{2,8}){0,3})?$"
          maxLength: 35
          description: BCP-47 tag the answers are written in.
    SyncPushOperation:
      description: >-
        For upsert, exactly one payload matching entity_type is required: card for card or deck for deck.
        For delete, both typed payloads must be omitted.
      type: object
      additionalProperties: false
      required: [operation_id, device_id, client_sequence, entity_type, entity_id, action, base_version]
      properties:
        operation_id:
          type: string
          format: uuid
          description: Stable client-generated idempotency identifier.
        device_id: { type: string, minLength: 1, maxLength: 128 }
        client_sequence: { type: integer, format: int64, minimum: 1 }
        dependencies:
          type: array
          maxItems: 50
          uniqueItems: true
          items: { type: string, format: uuid }
        entity_type: { type: string, enum: [card, deck] }
        entity_id: { type: string, minLength: 1, maxLength: 128 }
        action: { type: string, enum: [upsert, delete] }
        base_version:
          type: integer
          format: int64
          minimum: 0
          description: Version observed before the local mutation; zero creates a new entity.
        card:
          allOf:
            - { $ref: "#/components/schemas/SyncCardMutation" }
          description: Required only for card upsert; forbidden for deck operations and all deletes.
        deck:
          allOf:
            - { $ref: "#/components/schemas/SyncDeckMutation" }
          description: Required only for deck upsert; forbidden for card operations and all deletes.
    SyncTombstone:
      type: object
      required: [entity_type, entity_id, version, operation_id, deleted_at]
      properties:
        entity_type: { type: string, enum: [card, deck] }
        entity_id: { type: string }
        version: { type: integer, format: int64, minimum: 1 }
        operation_id: { type: string, format: uuid }
        deleted_at: { type: string, format: date-time }
    SyncPushOperationResult:
      description: >-
        Successful upserts and version conflicts may include the authoritative typed entity snapshot.
        Successful deletes and delete conflicts may include the authoritative tombstone.
      type: object
      required: [operation_id, status, entity_type, entity_id, action, base_version, entity_version]
      properties:
        operation_id: { type: string, format: uuid }
        status:
          type: string
          enum: [accepted, duplicate, conflict, rejected, retryable_error]
        entity_type: { type: string, enum: [card, deck] }
        entity_id: { type: string }
        action: { type: string, enum: [upsert, delete] }
        base_version: { type: integer, format: int64, minimum: 0 }
        entity_version: { type: integer, format: int64, minimum: 0 }
        card: { $ref: "#/components/schemas/Card" }
        deck: { $ref: "#/components/schemas/Deck" }
        tombstone: { $ref: "#/components/schemas/SyncTombstone" }
        error:
          type: string
          description: Client-safe explanation for a non-success status.
    SyncPushResponse:
      type: object
      required: [results]
      properties:
        results:
          type: array
          items: { $ref: "#/components/schemas/SyncPushOperationResult" }
    SyncChange:
      description: >-
        A server-sequenced entity snapshot or tombstone. Upserts contain the typed snapshot matching
        entity_type; deletes contain a tombstone. Review-driven card changes may also include review.
      type: object
      required: [id, sequence, entity_type, entity_id, action, entity_version, created_at]
      properties:
        id: { type: string }
        sequence: { type: integer, format: int64, minimum: 1 }
        entity_type: { type: string, enum: [card, deck] }
        entity_id: { type: string }
        action: { type: string, enum: [upsert, delete] }
        entity_version: { type: integer, format: int64, minimum: 0 }
        operation_id: { type: string, format: uuid }
        card: { $ref: "#/components/schemas/Card" }
        deck: { $ref: "#/components/schemas/Deck" }
        tombstone: { $ref: "#/components/schemas/SyncTombstone" }
        review: { $ref: "#/components/schemas/ReviewResult" }
        created_at: { type: string, format: date-time }
    SyncPullResponse:
      type: object
      required: [changes, next_cursor, has_more]
      properties:
        changes:
          type: array
          items: { $ref: "#/components/schemas/SyncChange" }
        next_cursor:
          type: string
          description: Opaque cursor to use for the next page. It advances to the last returned change.
        has_more: { type: boolean }
    MediaUploadResponse:
      type: object
      required: [url, slug]
      properties:
        url:
          type: string
          format: uri
        slug:
          type: string
    Media:
      type: object
      required: [id, user_id, kind, status, created_at, updated_at]
      properties:
        id: { type: string }
        user_id: { type: string }
        card_id: { type: string }
        kind: { type: string, enum: [tts, upload] }
        status: { type: string, enum: [ready, stale] }
        url: { type: string, format: uri }
        slug: { type: string }
        mime_type: { type: string }
        size: { type: integer, format: int64, minimum: 0 }
        checksum: { type: string }
        source_hash: { type: string }
        provider: { type: string }
        model: { type: string }
        voice: { type: string }
        locale: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    TTSJob:
      type: object
      required: [id, user_id, card_id, voice, provider, model, source_hash, status, attempts, next_attempt, created_at, updated_at]
      properties:
        id: { type: string }
        user_id: { type: string }
        card_id: { type: string }
        locale: { type: string }
        voice: { type: string }
        provider: { type: string }
        model: { type: string }
        source_hash: { type: string }
        status: { type: string, enum: [pending, running, completed, failed] }
        attempts: { type: integer, minimum: 0 }
        next_attempt: { type: string, format: date-time }
        media_id: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        completed_at: { type: string, format: date-time }
    AdminStats:
      type: object
      required: [users, admins, banned, decks, cards, reviews, due_cards, reviews_last_24h, reviews_last_7d, new_users_last_7d]
      properties:
        users: { type: integer, format: int64, minimum: 0 }
        admins: { type: integer, format: int64, minimum: 0 }
        banned: { type: integer, format: int64, minimum: 0 }
        decks: { type: integer, format: int64, minimum: 0 }
        cards: { type: integer, format: int64, minimum: 0 }
        reviews: { type: integer, format: int64, minimum: 0 }
        due_cards: { type: integer, format: int64, minimum: 0 }
        reviews_last_24h: { type: integer, format: int64, minimum: 0 }
        reviews_last_7d: { type: integer, format: int64, minimum: 0 }
        new_users_last_7d: { type: integer, format: int64, minimum: 0 }
    AdminUserListResponse:
      type: object
      required: [users, total]
      properties:
        users:
          type: array
          items: { $ref: "#/components/schemas/User" }
        total: { type: integer, format: int64, minimum: 0 }
    AdminAuditUserState:
      type: object
      required: [role, banned, deleted]
      properties:
        role: { type: string, enum: [user, admin] }
        banned: { type: boolean }
        banned_reason: { type: string }
        deleted: { type: boolean }
    AdminAudit:
      type: object
      required: [id, actor_user_id, target_user_id, action, created_at]
      properties:
        id: { type: string, format: uuid }
        actor_user_id: { type: string }
        target_user_id: { type: string }
        action: { type: string, enum: [set_role, ban, unban, delete] }
        before: { $ref: "#/components/schemas/AdminAuditUserState" }
        after: { $ref: "#/components/schemas/AdminAuditUserState" }
        created_at: { type: string, format: date-time }
    AdminAuditListResponse:
      type: object
      required: [events, next_cursor, has_more]
      properties:
        events:
          type: array
          items: { $ref: "#/components/schemas/AdminAudit" }
        next_cursor:
          type: string
          description: Opaque cursor for the next page; empty when has_more is false.
        has_more: { type: boolean }
    AdminRoleRequest:
      type: object
      additionalProperties: false
      required: [role]
      properties:
        role: { type: string, enum: [user, admin] }
    AdminBanRequest:
      type: object
      additionalProperties: false
      required: [reason]
      properties:
        reason: { type: string, maxLength: 500 }
    AdminMutationResponse:
      type: object
      required: [ok]
      properties:
        ok: { type: boolean, enum: [true] }
paths:
  /auth/request-code:
    post:
      operationId: requestAuthCode
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        "200": { description: Code accepted for delivery }
        "400": { description: Invalid email address, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "429": { description: Resend cooldown or rate limit active, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: Code could not be generated or delivered, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /auth/verify-code:
    post:
      operationId: verifyAuthCode
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, code]
              properties:
                email: { type: string, format: email }
                code: { type: string, pattern: "^[0-9]{6,10}$" }
                device_id: { type: string, maxLength: 200 }
                device_name: { type: string, maxLength: 200 }
      responses:
        "200":
          description: Authenticated
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuthTokens" }
        "401": { description: Invalid or expired code, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /auth/refresh:
    post:
      operationId: refreshAuthSession
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RefreshTokenRequest" }
      responses:
        "200":
          description: Rotated refresh token and a new access token
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuthTokens" }
        "401": { description: Invalid, expired, or revoked refresh token, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /auth/logout:
    post:
      operationId: logoutAuthSession
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RefreshTokenRequest" }
      responses:
        "204": { description: Session revoked or already absent }
  /auth/logout-all:
    post:
      operationId: logoutAllAuthSessions
      responses:
        "204": { description: All refresh sessions revoked }
        "401": { description: Invalid access token, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /me:
    get:
      operationId: getCurrentUser
      responses:
        "200": { description: Current user, content: { application/json: { schema: { $ref: "#/components/schemas/User" } } } }
    patch:
      operationId: updateCurrentUser
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              minProperties: 1
              properties:
                sr_algorithm: { type: string, enum: [sm2, fsrs] }
                lang: { type: string, enum: [en, ru] }
                theme: { type: string, enum: [system, light, dark] }
                seed_color:
                  type: string
                  pattern: "^(#[0-9A-Fa-f]{6})?$"
                  description: Uppercased by the server; an empty string restores the built-in brand seed.
                dynamic_color: { type: boolean }
                contrast: { type: string, enum: [standard, medium, high] }
      responses:
        "200": { description: Updated user, content: { application/json: { schema: { $ref: "#/components/schemas/User" } } } }
        "400": { description: Invalid or empty profile settings, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: User not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: Internal server error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    delete:
      operationId: deleteCurrentUser
      description: >-
        Atomically deletes the authenticated account and all database-owned data, including every refresh session.
        The last active administrator is protected. External object blobs are reconciled asynchronously.
      responses:
        "204": { description: Account and all database-owned data deleted }
        "401": { description: Invalid access token, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: User not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "409": { description: Last-active-administrator protection, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: Internal server error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /decks:
    get:
      operationId: listDecks
      responses:
        "200":
          description: Decks
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Deck" }
    post:
      operationId: createDeck
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DeckMutationRequest" }
      responses:
        "201": { description: Created deck, content: { application/json: { schema: { $ref: "#/components/schemas/Deck" } } } }
        "400": { description: Invalid deck, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: Internal server error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /decks/{deckId}:
    parameters:
      - in: path
        name: deckId
        required: true
        schema: { type: string }
    get:
      operationId: getDeck
      responses:
        "200": { description: Deck, content: { application/json: { schema: { $ref: "#/components/schemas/Deck" } } } }
        "404": { description: Deck not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: Internal server error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    patch:
      operationId: updateDeck
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DeckMutationRequest" }
      responses:
        "200": { description: Updated deck, content: { application/json: { schema: { $ref: "#/components/schemas/Deck" } } } }
        "400": { description: Invalid deck, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: Deck not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: Internal server error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    delete:
      operationId: deleteDeck
      responses:
        "204": { description: Deck and its owned cards deleted }
        "404": { description: Deck not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: Internal server error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /decks/{deckId}/cards:
    parameters:
      - in: path
        name: deckId
        required: true
        schema: { type: string }
    get:
      operationId: listCards
      responses:
        "200":
          description: Cards
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Card" }
    post:
      operationId: createCard
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CardMutationRequest" }
      responses:
        "201": { description: Created card, content: { application/json: { schema: { $ref: "#/components/schemas/Card" } } } }
        "400": { description: Invalid card type or fields, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: Deck not found or not owned by the current user, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: Internal server error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /cards/{cardId}:
    parameters:
      - in: path
        name: cardId
        required: true
        schema: { type: string }
    get:
      operationId: getCard
      responses:
        "200": { description: Card, content: { application/json: { schema: { $ref: "#/components/schemas/Card" } } } }
        "404": { description: Card not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: Internal server error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    patch:
      operationId: updateCard
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CardMutationRequest" }
      responses:
        "200": { description: Updated card, content: { application/json: { schema: { $ref: "#/components/schemas/Card" } } } }
        "400": { description: Invalid card type or fields, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: Card not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: Internal server error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    delete:
      operationId: deleteCard
      responses:
        "204": { description: Deleted }
        "404": { description: Card not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: Internal server error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /cards/{cardId}/tts:
    parameters:
      - in: path
        name: cardId
        required: true
        schema: { type: string }
    post:
      operationId: enqueueCardTTS
      description: Queues permanent server-side audio generation. Card save does not depend on this operation.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                field:
                  type: string
                  description: Optional type-aware card field. The server reads the text from the owned card.
                locale: { type: string, maxLength: 35 }
                voice: { type: string, maxLength: 100 }
      responses:
        "202":
          description: Existing or newly queued durable job
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TTSJob" }
        "400": { description: Card has no valid text for TTS, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: Card not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "429": { description: Per-user daily synthesis quota reached (administrators are exempt), content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "503": { description: TTS is disabled or unavailable, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /cards/{cardId}/media:
    parameters:
      - in: path
        name: cardId
        required: true
        schema: { type: string }
    get:
      operationId: listCardMedia
      responses:
        "200":
          description: Ready media owned by the current user
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Media" }
        "404": { description: Card not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /tts/jobs/{jobId}:
    parameters:
      - in: path
        name: jobId
        required: true
        schema: { type: string }
    get:
      operationId: getTTSJob
      responses:
        "200":
          description: Sanitized durable job status
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TTSJob" }
        "404": { description: Job not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /reviews/next:
    get:
      operationId: getNextReviews
      parameters:
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - in: query
          name: deck_id
          schema: { type: string }
      responses:
        "200":
          description: Due cards
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Card" }
  /reviews/preview:
    get:
      operationId: previewReviewRatings
      description: Deterministically computes all four scheduler outcomes without mutating the card.
      parameters:
        - in: query
          name: card_id
          required: true
          schema: { type: string }
      responses:
        "200": { description: Scheduler previews, content: { application/json: { schema: { $ref: "#/components/schemas/ReviewPreviewResponse" } } } }
        "400": { description: Missing card identifier, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: Card not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: Internal server error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /reviews/submit:
    post:
      operationId: submitReview
      description: >-
        Atomically records an immutable review and updates the owned card schedule using
        card_version compare-and-swap. Retrying the same identical operation_id returns
        the original stored result without scheduling twice.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ReviewSubmitRequest" }
      responses:
        "200":
          description: Stored review result, including the authoritative resulting card snapshot
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReviewResult" }
        "400": { description: Invalid review operation, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: Card not found or not owned by the current user, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "409": { description: Card version or reused operation identifier conflicts, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: Internal server error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /sync/push:
    post:
      operationId: pushSyncOperations
      description: >-
        Applies card outbox operations in request order. Each result is durable except retryable_error;
        exact operation retries return duplicate and stale base versions return conflict with an authoritative snapshot.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [ops]
              properties:
                ops:
                  type: array
                  maxItems: 100
                  items: { $ref: "#/components/schemas/SyncPushOperation" }
      responses:
        "200":
          description: One result for each supplied operation, in request order
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SyncPushResponse" }
        "400": { description: Invalid body or more than 100 operations, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: Request-level internal server error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /sync/pull:
    get:
      operationId: pullSyncChanges
      description: Returns the authenticated user's authoritative changes in stable server sequence order.
      parameters:
        - in: query
          name: cursor
          schema: { type: string }
          description: Opaque next_cursor returned by the previous page. Omit for the first page.
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 500, default: 200 }
      responses:
        "200":
          description: Stable page of server-sequenced changes
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SyncPullResponse" }
        "400": { description: Invalid cursor or limit, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: Internal server error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /media:
    post:
      operationId: uploadMedia
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
      responses:
        "200":
          description: Uploaded media
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MediaUploadResponse" }
  /admin/stats:
    get:
      operationId: getAdminStats
      description: Requires the current server-side administrator role.
      responses:
        "200": { description: Platform statistics, content: { application/json: { schema: { $ref: "#/components/schemas/AdminStats" } } } }
        "401": { description: Invalid access token, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "403": { description: Current user is not an administrator, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: Internal server error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /admin/audit:
    get:
      operationId: listAdminAudit
      description: Returns append-only security-relevant administrative account events in newest-first order.
      parameters:
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - in: query
          name: cursor
          schema: { type: string }
          description: Opaque next_cursor returned by the previous page.
      responses:
        "200": { description: Stable page of audit events, content: { application/json: { schema: { $ref: "#/components/schemas/AdminAuditListResponse" } } } }
        "400": { description: Invalid limit or cursor, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { description: Invalid access token, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "403": { description: Current user is not an administrator, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: Internal server error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /admin/users:
    get:
      operationId: listAdminUsers
      description: Search treats the supplied text as a literal case-insensitive email substring.
      parameters:
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - in: query
          name: skip
          schema: { type: integer, minimum: 0, maximum: 1000000, default: 0 }
        - in: query
          name: search
          schema: { type: string }
        - in: query
          name: role
          schema: { type: string, enum: [user, admin] }
        - in: query
          name: banned
          schema: { type: boolean }
      responses:
        "200": { description: Users, content: { application/json: { schema: { $ref: "#/components/schemas/AdminUserListResponse" } } } }
        "400": { description: Invalid pagination or filter, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "401": { description: Invalid access token, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "403": { description: Current user is not an administrator, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: Internal server error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /admin/users/{userId}:
    parameters:
      - in: path
        name: userId
        required: true
        schema: { type: string }
    get:
      operationId: getAdminUser
      responses:
        "200": { description: User, content: { application/json: { schema: { $ref: "#/components/schemas/User" } } } }
        "404": { description: User not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: Internal server error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    delete:
      operationId: deleteAdminUser
      description: Atomically deletes database-owned account data. Self-delete and deletion of the last active administrator are rejected.
      responses:
        "204": { description: Deleted }
        "404": { description: User not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "409": { description: Self-action, unavailable actor, or last-active-administrator protection, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: Internal server error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /admin/users/{userId}/role:
    parameters:
      - in: path
        name: userId
        required: true
        schema: { type: string }
    post:
      operationId: setAdminUserRole
      description: Revokes target sessions after a role change. Self-demotion and demotion of the last active administrator are rejected.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AdminRoleRequest" }
      responses:
        "200": { description: Updated role, content: { application/json: { schema: { $ref: "#/components/schemas/AdminMutationResponse" } } } }
        "400": { description: Invalid body or role, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: User not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "409": { description: Self-action, unavailable actor, or last-active-administrator protection, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: Internal server error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /admin/users/{userId}/ban:
    parameters:
      - in: path
        name: userId
        required: true
        schema: { type: string }
    post:
      operationId: banAdminUser
      description: Revokes target sessions. Self-ban and banning the last active administrator are rejected.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AdminBanRequest" }
      responses:
        "200": { description: Banned user, content: { application/json: { schema: { $ref: "#/components/schemas/AdminMutationResponse" } } } }
        "400": { description: Invalid body or reason longer than 500 Unicode code points, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "404": { description: User not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "409": { description: Self-action, unavailable actor, or last-active-administrator protection, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: Internal server error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /admin/users/{userId}/unban:
    parameters:
      - in: path
        name: userId
        required: true
        schema: { type: string }
    post:
      operationId: unbanAdminUser
      responses:
        "200": { description: Unbanned user, content: { application/json: { schema: { $ref: "#/components/schemas/AdminMutationResponse" } } } }
        "404": { description: User not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "409": { description: Acting administrator is unavailable, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: Internal server error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /admin/users/{userId}/decks:
    parameters:
      - in: path
        name: userId
        required: true
        schema: { type: string }
    get:
      operationId: listAdminUserDecks
      responses:
        "200":
          description: User decks
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Deck" }
        "500": { description: Internal server error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
  /admin/users/{userId}/cards:
    parameters:
      - in: path
        name: userId
        required: true
        schema: { type: string }
    get:
      operationId: listAdminUserCards
      parameters:
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 1000, default: 200 }
      responses:
        "200":
          description: User cards
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Card" }
        "400": { description: Invalid limit, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "500": { description: Internal server error, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
