openapi: 3.0.3
info:
  title: Khoemart Socket Server API
  description: |
    REST API for pharmacy consult chat (Express) + Centrifugo WebSocket.

    **Authentication**
    - Chat routes (`/api/chat/*`): `Authorization: Bearer <login JWT from khoemart-api>`
      verified with `KHOEMART_API_JWT_SECRET`.
    - Centrifugo connection token: `GET|POST /api/chat/token` (server-signed JWT, separate from login JWT).
    - Legacy demo routes (`/api/token`, `/api/publish`): same login JWT middleware.

    **threadId in URLs** must be URL-encoded (`chat:consult_abc` → `chat%3Aconsult_abc`).

    See also: [chat-fe-integration-v2.md](./chat-fe-integration-v2.md)
  version: 2.0.0
  contact:
    name: Khoemart Socket Server

# servers:
#   - url: http://127.0.0.1:3040
#     description: Local dev (API_PORT default 3040)
#   - url: https://chat.khoemart.com
#     description: Production (example)

tags:
  - name: Health
    description: Liveness
  - name: Demo
    description: Legacy demo page helpers (prefer /api/chat/* in production)
  - name: Chat
    description: Pharmacy consult chat (JWT from khoemart-api)

paths:
  /api/health:
    get:
      tags: [Health]
      summary: Health check
      operationId: getHealth
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthOk'

  /api/bootstrap:
    get:
      tags: [Demo]
      summary: Demo bootstrap (public)
      description: Returns Centrifugo WS URL and demo channel for static demo page. No auth.
      operationId: getBootstrap
      responses:
        '200':
          description: Bootstrap config
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BootstrapResponse'

  /api/token:
    post:
      tags: [Demo]
      summary: Legacy demo Centrifugo token
      description: Prefer `GET /api/chat/token` for production chat clients.
      operationId: postDemoToken
      security:
        - bearerAuth: []
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DemoTokenRequest'
      responses:
        '200':
          description: Centrifugo connection token (no channel ACL)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CentrifugoTokenResponse'
        '400':
          $ref: '#/components/responses/BadRequestError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/publish:
    post:
      tags: [Demo]
      summary: Legacy demo publish to Centrifugo
      description: Production clients should use `POST /api/chat/send`.
      operationId: postDemoPublish
      security:
        - bearerAuth: []
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DemoPublishRequest'
      responses:
        '200':
          description: Published
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublishOk'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/chat/token:
    get:
      tags: [Chat]
      summary: Centrifugo connection JWT (GET)
      operationId: getChatToken
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Connection token with channel ACL for current user
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CentrifugoTokenResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'
    post:
      tags: [Chat]
      summary: Centrifugo connection JWT (POST)
      operationId: postChatToken
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Same as GET
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CentrifugoTokenResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/chat/sessions:
    post:
      tags: [Chat]
      summary: Create or reuse consult session
      description: CUSTOMER only. Returns existing open thread if one exists.
      operationId: postChatSessions
      security:
        - bearerAuth: []
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateSessionRequest'
      responses:
        '201':
          description: New session created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionResponse'
        '200':
          description: Existing open session reused
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/chat/threads:
    get:
      tags: [Chat]
      summary: List threads (inbox)
      description: |
        CUSTOMER: threads where `customerId` = JWT user.
        PHARMACIST: threads where `assignedPharmacistId` = JWT user.
      operationId: getChatThreads
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/limit'
        - name: status
          in: query
          schema:
            $ref: '#/components/schemas/ThreadStatus'
        - name: type
          in: query
          schema:
            type: string
            example: PHARMACIST_CONSULT
      responses:
        '200':
          description: Thread list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ThreadListResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/chat/threads/queue:
    get:
      tags: [Chat]
      summary: Pharmacist waiting queue
      operationId: getChatThreadsQueue
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/limit'
      responses:
        '200':
          description: WAITING threads for pharmacist pool
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/QueueResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/chat/threads/{threadId}/claim:
    post:
      tags: [Chat]
      summary: Claim a waiting consult thread
      description: PHARMACIST only. Empty body; pharmacist identity from JWT.
      operationId: postChatThreadClaim
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/threadId'
      responses:
        '200':
          description: Claimed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ThreadActionResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFoundError'
        '409':
          description: Already claimed or not WAITING
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SimpleError'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/chat/threads/{threadId}/resolve:
    post:
      tags: [Chat]
      summary: Resolve (close) a thread
      operationId: postChatThreadResolve
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/threadId'
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ResolveThreadRequest'
      responses:
        '200':
          description: Resolved
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ThreadActionResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFoundError'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/chat/threads/{threadId}/transfer:
    post:
      tags: [Chat]
      summary: Transfer thread to another pharmacist
      operationId: postChatThreadTransfer
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/threadId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TransferThreadRequest'
      responses:
        '200':
          description: Transferred
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ThreadActionResponse'
        '400':
          $ref: '#/components/responses/BadRequestError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFoundError'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/chat/threads/{threadId}/read:
    post:
      tags: [Chat]
      summary: Mark thread as read by current user
      description: |
        Stamps the thread's `reads.<userId>` to `now` and publishes a `thread.read` event
        on the thread's Centrifugo channel so the counterpart can render a ✓✓ receipt.
        CUSTOMER may only mark their own thread; PHARMACIST may mark any thread they can access.
      operationId: postChatThreadRead
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/threadId'
      responses:
        '200':
          description: Read receipt stored + event published
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReadReceiptResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFoundError'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/chat/staff/orders/{orderId}/thread:
    get:
      tags: [Chat]
      summary: Get prescription order chat thread by orderId (deep link)
      description: |
        Returns the `PRESCRIPTION_ORDER` thread for the given order when the caller is a participant.
        Used from order detail screens on khoemart-api dashboard.
      operationId: getStaffOrderThread
      security:
        - bearerAuth: []
      parameters:
        - name: orderId
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Thread found
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                  orderId:
                    type: string
                  thread:
                    $ref: '#/components/schemas/ChatThread'
                  unreadCount:
                    type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: No thread for this order yet
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SimpleError'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/chat/staff/me/status:
    get:
      tags: [Chat]
      summary: Get pharmacist presence
      operationId: getStaffPresence
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Presence record
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StaffPresenceResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: No presence record yet
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SimpleError'
        '500':
          $ref: '#/components/responses/InternalError'
    patch:
      tags: [Chat]
      summary: Update pharmacist presence
      operationId: patchStaffPresence
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/StaffPresenceRequest'
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StaffPresenceOk'
        '400':
          $ref: '#/components/responses/BadRequestError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/chat/send:
    post:
      tags: [Chat]
      summary: Send chat message
      description: |
        Persists message, publishes to Centrifugo thread channel, updates thread preview.
        `senderId`, `senderName`, `senderRole` are taken from JWT — do not send in body.
      operationId: postChatSend
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendMessageRequest'
      responses:
        '201':
          description: Message saved
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SendMessageResponse'
        '400':
          $ref: '#/components/responses/BadRequestError'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFoundError'
        '500':
          $ref: '#/components/responses/InternalError'

  /api/chat/{threadId}/messages:
    get:
      tags: [Chat]
      summary: Thread message history
      description: |
        Max 40 messages per request. CUSTOMER must own thread; PHARMACIST may read
        WAITING queue threads or threads they can access.
      operationId: getChatThreadMessages
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/threadId'
        - $ref: '#/components/parameters/limit'
      responses:
        '200':
          description: Messages (oldest → newest)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessagesResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFoundError'
        '500':
          $ref: '#/components/responses/InternalError'

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: Login JWT from khoemart-api (HS256, `KHOEMART_API_JWT_SECRET`)

  parameters:
    threadId:
      name: threadId
      in: path
      required: true
      description: Thread / Centrifugo channel id (URL-encoded)
      schema:
        type: string
        example: chat%3Aconsult_a1b2-c3d4
    limit:
      name: limit
      in: query
      description: Max items (messages capped at 40 server-side)
      schema:
        type: integer
        minimum: 1
        maximum: 40
        default: 40

  responses:
    Unauthorized:
      description: Missing, expired, or invalid Bearer token
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/AuthError'
    Forbidden:
      description: Wrong role or no access to resource
      content:
        application/json:
          schema:
            oneOf:
              - $ref: '#/components/schemas/AuthError'
              - $ref: '#/components/schemas/SimpleError'
    BadRequestError:
      description: Validation error
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/SimpleError'
    NotFoundError:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/SimpleError'
    InternalError:
      description: Server error
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/SimpleError'

  schemas:
    HealthOk:
      type: object
      properties:
        ok:
          type: boolean
          example: true

    BootstrapResponse:
      type: object
      properties:
        centrifugoWsUrl:
          type: string
          example: ws://127.0.0.1:8382/connection/websocket
        channel:
          type: string
          example: demo-chat
        tokenExpiresInSec:
          type: integer
          example: 900

    DemoTokenRequest:
      type: object
      properties:
        userId:
          type: string
          example: demo-user
        displayName:
          type: string
          example: Khách demo

    DemoPublishRequest:
      type: object
      properties:
        channel:
          type: string
        text:
          type: string
        data:
          type: object
          additionalProperties: true

    PublishOk:
      type: object
      properties:
        ok:
          type: boolean
        channel:
          type: string

    CentrifugoTokenResponse:
      type: object
      required: [token, userId, centrifugoWsUrl, channels, tokenExpiresInSec]
      properties:
        token:
          type: string
          description: Centrifugo connection JWT (HS256)
        userId:
          type: string
        centrifugoWsUrl:
          type: string
        channels:
          type: array
          items:
            type: string
          description: |
            Subscribable channels for this user, e.g. `chat:consult_{sessionId}`,
            `inbox:pharmacists` (pharmacist pool).
        tokenExpiresInSec:
          type: integer
          example: 900

    UserRole:
      type: string
      enum: [CUSTOMER, PHARMACIST]

    ThreadStatus:
      type: string
      enum: [WAITING, ASSIGNED, ACTIVE, RESOLVED]

    ThreadType:
      type: string
      enum: [PHARMACIST_CONSULT]

    MessageType:
      type: string
      enum: [text, image, system]
      default: text

    SenderRole:
      type: string
      enum: [CUSTOMER, PHARMACIST, SYSTEM]

    StaffPresenceStatus:
      type: string
      enum: [online, busy, offline]

    Thread:
      type: object
      properties:
        threadId:
          type: string
          example: chat:consult_a1b2-c3d4
        sessionId:
          type: string
        customerId:
          type: string
        type:
          $ref: '#/components/schemas/ThreadType'
        status:
          $ref: '#/components/schemas/ThreadStatus'
        assignedPharmacistId:
          type: string
          nullable: true
        assignedPharmacistName:
          type: string
          nullable: true
        claimedAt:
          type: string
          format: date-time
          nullable: true
        lastMessageAt:
          type: string
          format: date-time
          nullable: true
        lastMessagePreview:
          type: string
          nullable: true
        priority:
          type: string
          example: NORMAL
        tags:
          type: array
          items:
            type: string
        context:
          type: object
          nullable: true
          additionalProperties: true
        createdAt:
          type: string
          format: date-time
          nullable: true
        closedAt:
          type: string
          format: date-time
          nullable: true
        reads:
          type: object
          description: Map of `userId → ISO-string lastReadAt` for read-receipt tracking.
          additionalProperties:
            type: string
            format: date-time
          example:
            cust-001: '2026-05-25T05:32:11.000Z'
            pharm-001: '2026-05-25T05:33:02.000Z'

    Message:
      type: object
      properties:
        _id:
          type: string
          description: MongoDB ObjectId as string
        threadId:
          type: string
        senderId:
          type: string
        senderName:
          type: string
        senderRole:
          $ref: '#/components/schemas/SenderRole'
        content:
          type: string
        type:
          $ref: '#/components/schemas/MessageType'
        sentAt:
          type: string
          format: date-time

    MessagePublication:
      type: object
      description: Payload published on Centrifugo thread channel
      properties:
        id:
          type: string
        senderId:
          type: string
        senderName:
          type: string
        senderRole:
          $ref: '#/components/schemas/SenderRole'
        content:
          type: string
        type:
          $ref: '#/components/schemas/MessageType'
        sentAt:
          type: string
          format: date-time

    CreateSessionRequest:
      type: object
      properties:
        context:
          type: object
          additionalProperties: true
          example:
            productId: optional-product-id

    SessionResponse:
      allOf:
        - type: object
          required: [ok, reused]
          properties:
            ok:
              type: boolean
            reused:
              type: boolean
        - $ref: '#/components/schemas/Thread'

    ThreadListResponse:
      type: object
      properties:
        total:
          type: integer
        threads:
          type: array
          items:
            $ref: '#/components/schemas/ThreadWithUnread'

    QueueResponse:
      type: object
      properties:
        role:
          type: string
          example: PHARMACIST
        total:
          type: integer
        threads:
          type: array
          items:
            $ref: '#/components/schemas/ThreadWithUnread'

    ThreadWithUnread:
      allOf:
        - $ref: '#/components/schemas/Thread'
        - type: object
          properties:
            unreadCount:
              type: integer
              description: Unread messages for the JWT user (sender ≠ self, sentAt > reads[self])

    ThreadActionResponse:
      type: object
      properties:
        ok:
          type: boolean
        thread:
          $ref: '#/components/schemas/Thread'

    ResolveThreadRequest:
      type: object
      properties:
        note:
          type: string

    TransferThreadRequest:
      type: object
      required: [toPharmacistId]
      properties:
        toPharmacistId:
          type: string
        toPharmacistName:
          type: string
          example: Dược sĩ

    SendMessageRequest:
      type: object
      required: [threadId, content]
      properties:
        threadId:
          type: string
          example: chat:consult_a1b2-c3d4
        content:
          type: string
        type:
          $ref: '#/components/schemas/MessageType'

    SendMessageResponse:
      type: object
      properties:
        ok:
          type: boolean
        message:
          $ref: '#/components/schemas/Message'
        thread:
          $ref: '#/components/schemas/Thread'

    MessagesResponse:
      type: object
      properties:
        threadId:
          type: string
        thread:
          $ref: '#/components/schemas/Thread'
        total:
          type: integer
        messages:
          type: array
          items:
            $ref: '#/components/schemas/Message'
        reads:
          type: object
          description: Same as `thread.reads`, surfaced at the top level for convenience.
          additionalProperties:
            type: string
            format: date-time
        unreadCount:
          type: integer
          description: Messages from other users that arrived after `reads[viewerId]`.
        viewerId:
          type: string
          description: Echoed JWT user id so the client can look up `reads[viewerId]` easily.

    ReadReceiptResponse:
      type: object
      properties:
        ok:
          type: boolean
        threadId:
          type: string
        userId:
          type: string
          description: User who just marked the thread as read.
        lastReadAt:
          type: string
          format: date-time
          description: Timestamp stored in `thread.reads[userId]`.
        thread:
          $ref: '#/components/schemas/Thread'

    StaffPresence:
      type: object
      properties:
        userId:
          type: string
        role:
          type: string
        status:
          $ref: '#/components/schemas/StaffPresenceStatus'
        lastSeenAt:
          type: string
          format: date-time

    StaffPresenceRequest:
      type: object
      required: [status]
      properties:
        status:
          $ref: '#/components/schemas/StaffPresenceStatus'

    StaffPresenceResponse:
      type: object
      properties:
        presence:
          $ref: '#/components/schemas/StaffPresence'

    StaffPresenceOk:
      type: object
      properties:
        ok:
          type: boolean
        presence:
          $ref: '#/components/schemas/StaffPresence'

    AuthError:
      type: object
      properties:
        statusCode:
          type: integer
          example: 401
        message:
          type: string

    SimpleError:
      type: object
      properties:
        error:
          type: string
