openapi: 3.0.3

info:
  title: Pinlyx Public API v1
  version: 1.2.0
  description: |
    The Pinlyx Public API provides programmatic access to your contacts,
    Telegram messaging, sales pipeline (deals), tasks, finance (read-only) and the
    email inbox. All requests must include an `Authorization: Bearer <token>`
    header where the token is a Pinlyx API key in the format
    `csk_<env>_<keyId><secret>`. Access is scope-based - each key is issued with a
    specific set of scopes (e.g. `contacts:read`, `telegram:send`), and operations
    that require a scope not granted to the key return `403 Forbidden`. All responses
    are JSON. Rate limits are per-key and communicated via `X-RateLimit-Limit`,
    `X-RateLimit-Remaining`, and `X-RateLimit-Reset` response headers. An MCP server
    for AI agent integration is available at the same host (`POST /mcp`).

servers:
  - url: https://api.crmsolid.com
    description: Production

tags:
  - name: Authentication
    description: Verify your API key and inspect the acting workspace identity.
  - name: Account
    description: Workspace and API key metadata.
  - name: Contacts
    description: Create and query CRM contacts across platforms.
  - name: Messages
    description: Enqueue and inspect outbound Telegram messages.
  - name: Deals
    description: Sales pipeline deals. Moving to "won" is panel-only (books revenue).
  - name: Tasks
    description: CRM tasks and reminders.
  - name: Finance
    description: Read-only finance summary, transactions, invoices and revenue sources.
  - name: Email
    description: Email inbox threads — read, set status, assign. No sending.

paths:
  /v1/me:
    get:
      operationId: getMe
      summary: Get current workspace identity
      description: |
        Returns the workspace identity associated with the bearer key. Useful as a
        connectivity smoke-test. No specific scope is required - any valid key may
        call this endpoint.
      tags:
        - Authentication
        - Account
      responses:
        '200':
          description: Authenticated workspace identity.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
              example:
                id: 42
                email: jane@example.com
                name: Acme Inc.
                createdAt: "2024-01-15T09:00:00Z"
                apiKey:
                  keyId: abc123def456
                  scopes: ["contacts:read", "telegram:send"]
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/contacts:
    get:
      operationId: listContacts
      summary: List contacts
      description: |
        Returns a cursor-paginated list of contacts scoped to the authenticated
        user. Pass `after=<id>` to fetch the next page. Requires scope
        `contacts:read`.
      tags:
        - Contacts
      parameters:
        - name: after
          in: query
          description: Return contacts with an id strictly less than this value (cursor for next page).
          required: false
          schema:
            type: integer
        - name: limit
          in: query
          description: Number of contacts to return. Clamped to 1-100.
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
        - name: platform
          in: query
          description: Filter by platform.
          required: false
          schema:
            type: string
            enum: [telegram, twitter]
        - name: q
          in: query
          description: Case-insensitive substring search across name, username, and phone.
          required: false
          schema:
            type: string
      responses:
        '200':
          description: Paginated contact list.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContactList'
              example:
                items:
                  - id: 101
                    platform: telegram
                    name: Acme Inc.
                    username: john_doe
                    phone: null
                    notes: null
                    stage: Lead
                    createdAt: "2024-03-01T10:00:00Z"
                    lastMessageAt: null
                    hasUnreadMessages: false
                nextCursor: 101
                hasMore: false
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'

    post:
      operationId: createContact
      summary: Create a contact
      description: |
        Creates a new contact for the authenticated user. At least one of `name`,
        `username`, or `phone` must be provided. Defaults `platform` to `telegram`
        if omitted. Requires scope `contacts:write`.
      tags:
        - Contacts
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateContactRequest'
            example:
              platform: telegram
              name: Acme Inc.
              username: john_doe
              phone: null
              notes: Met at conference
      responses:
        '201':
          description: Contact created successfully.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Contact'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'

  /v1/contacts/{id}:
    get:
      operationId: getContact
      summary: Get a contact by id
      description: |
        Returns a single contact owned by the authenticated user. Returns `404` if
        the contact does not exist or belongs to a different user. Requires scope
        `contacts:read`.
      tags:
        - Contacts
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Contact found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Contact'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  /v1/telegram/messages:
    post:
      operationId: sendTelegramMessage
      summary: Enqueue a Telegram outbound message
      description: |
        Enqueues a message for delivery by the background job worker and returns
        immediately with the queued job. The recipient is resolved from `contactId`,
        `username`, or `telegramUserId` - at least one must be supplied. If
        `contactId` is provided and the contact has no resolved Telegram identity,
        the request fails with `400`. Set `runAt` (UTC) to schedule delivery for
        later; omit for immediate dispatch. The `accountId` must belong to the
        authenticated user. Requires scope `telegram:send`.
      tags:
        - Messages
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendMessageRequest'
            example:
              accountId: 7
              username: john_doe
              text: Hi from Acme Inc.!
              runAt: null
      responses:
        '202':
          description: Message job accepted and queued.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageJob'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  /v1/telegram/messages/{id}:
    get:
      operationId: getTelegramMessage
      summary: Get a message job by id
      description: |
        Returns the current state of a previously enqueued message job. Ownership
        is verified via the associated Telegram account - jobs belonging to other
        users return `404`. Requires scope `telegram:read`.
      tags:
        - Messages
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Message job found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageJobDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  /v1/deals:
    get:
      operationId: listDeals
      summary: List deals
      description: Cursor-paginated pipeline deals. Pass `after=<id>` for the next page. Requires scope `deals:read`.
      tags: [Deals]
      parameters:
        - { name: after, in: query, required: false, schema: { type: integer } }
        - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } }
        - { name: stage, in: query, required: false, schema: { type: string, enum: [lead, qualified, proposal, negotiation, won, lost] } }
        - { name: contactId, in: query, required: false, schema: { type: integer } }
        - { name: q, in: query, required: false, description: Substring search on title., schema: { type: string } }
      responses:
        '200':
          description: Paginated deal list.
          content: { application/json: { schema: { $ref: '#/components/schemas/DealList' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      operationId: createDeal
      summary: Create a deal
      description: Creates a deal in an active stage. Creating a deal already `won`/`lost` is rejected (won books revenue — close from the panel). Requires scope `deals:write`.
      tags: [Deals]
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: '#/components/schemas/CreateDealRequest' } } }
      responses:
        '201':
          description: Deal created.
          content: { application/json: { schema: { $ref: '#/components/schemas/Deal' } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/deals/{id}:
    get:
      operationId: getDeal
      summary: Get a deal by id
      description: Returns a single deal with its linked tasks. Requires scope `deals:read`.
      tags: [Deals]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        '200':
          description: Deal found.
          content: { application/json: { schema: { $ref: '#/components/schemas/Deal' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/deals/{id}/stage:
    post:
      operationId: changeDealStage
      summary: Change a deal's stage
      description: Moves a deal between stages. `won` is rejected (books an income ledger entry — panel-only); `lost` and active stages are allowed. Requires scope `deals:write`.
      tags: [Deals]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [stage]
              properties:
                stage: { type: string, enum: [lead, qualified, proposal, negotiation, lost] }
      responses:
        '200':
          description: Updated deal.
          content: { application/json: { schema: { $ref: '#/components/schemas/Deal' } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/tasks:
    get:
      operationId: listTasks
      summary: List tasks
      description: Cursor-paginated CRM tasks. Requires scope `tasks:read`.
      tags: [Tasks]
      parameters:
        - { name: after, in: query, required: false, schema: { type: integer } }
        - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } }
        - { name: status, in: query, required: false, schema: { type: string, enum: [open, inprogress, done] } }
        - { name: priority, in: query, required: false, schema: { type: string, enum: [low, medium, high] } }
        - { name: overdue, in: query, required: false, schema: { type: boolean } }
        - { name: contactId, in: query, required: false, schema: { type: integer } }
        - { name: dealId, in: query, required: false, schema: { type: integer } }
      responses:
        '200':
          description: Paginated task list.
          content: { application/json: { schema: { $ref: '#/components/schemas/TaskList' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
    post:
      operationId: createTask
      summary: Create a task
      description: Creates a task / reminder. Title is required. Requires scope `tasks:write`.
      tags: [Tasks]
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: '#/components/schemas/CreateTaskRequest' } } }
      responses:
        '201':
          description: Task created.
          content: { application/json: { schema: { $ref: '#/components/schemas/Task' } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/tasks/{id}:
    get:
      operationId: getTask
      summary: Get a task by id
      tags: [Tasks]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        '200':
          description: Task found.
          content: { application/json: { schema: { $ref: '#/components/schemas/Task' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/tasks/{id}/status:
    post:
      operationId: changeTaskStatus
      summary: Change a task's status
      description: Sets the task status; `done` stamps the completion time. Requires scope `tasks:write`.
      tags: [Tasks]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status: { type: string, enum: [open, inprogress, done] }
      responses:
        '200':
          description: Updated task.
          content: { application/json: { schema: { $ref: '#/components/schemas/Task' } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/finance/summary:
    get:
      operationId: getFinanceSummary
      summary: Finance summary
      description: Per-currency realized income/expense/net, outstanding (pending) totals and top expense categories over a window. Read-only. Requires scope `finance:read`.
      tags: [Finance]
      parameters:
        - { name: range, in: query, required: false, schema: { type: string, enum: [7d, 30d, month], default: 30d } }
        - { name: currency, in: query, required: false, description: ISO-4217 filter., schema: { type: string } }
      responses:
        '200':
          description: Finance summary.
          content: { application/json: { schema: { $ref: '#/components/schemas/FinanceSummary' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/finance/transactions:
    get:
      operationId: listTransactions
      summary: List finance transactions
      description: Cursor-paginated ledger entries (read-only). Requires scope `finance:read`.
      tags: [Finance]
      parameters:
        - { name: after, in: query, required: false, schema: { type: integer } }
        - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } }
        - { name: type, in: query, required: false, schema: { type: string, enum: [income, expense] } }
        - { name: status, in: query, required: false, schema: { type: string, enum: [completed, pending, refunded, failed] } }
        - { name: contactId, in: query, required: false, schema: { type: integer } }
        - { name: currency, in: query, required: false, schema: { type: string } }
      responses:
        '200':
          description: Paginated transaction list.
          content: { application/json: { schema: { $ref: '#/components/schemas/TransactionList' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/finance/invoices:
    get:
      operationId: listInvoices
      summary: List invoices
      description: Cursor-paginated invoices with an outstanding (sent/overdue) summary per currency (read-only). Requires scope `finance:read`.
      tags: [Finance]
      parameters:
        - { name: after, in: query, required: false, schema: { type: integer } }
        - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } }
        - { name: status, in: query, required: false, schema: { type: string, enum: [draft, sent, paid, overdue, cancelled] } }
      responses:
        '200':
          description: Paginated invoice list with outstanding totals.
          content: { application/json: { schema: { $ref: '#/components/schemas/InvoiceList' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/finance/revenue-sources:
    get:
      operationId: listRevenueSources
      summary: List revenue sources
      description: Configured external revenue sources with last-sync status. Secrets are never returned. Read-only. Requires scope `finance:read`.
      tags: [Finance]
      responses:
        '200':
          description: Revenue source list.
          content: { application/json: { schema: { type: object, properties: { items: { type: array, items: { $ref: '#/components/schemas/RevenueSource' } }, count: { type: integer }, totalIngested: { type: integer, format: int64 } } } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/email/threads:
    get:
      operationId: listEmailThreads
      summary: List email inbox threads
      description: Cursor-paginated email threads. Read-only. Requires scope `email:read`.
      tags: [Email]
      parameters:
        - { name: after, in: query, required: false, schema: { type: integer } }
        - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 50, default: 25 } }
        - { name: status, in: query, required: false, schema: { type: string, enum: [open, pending, closed] } }
        - { name: contactId, in: query, required: false, schema: { type: integer } }
        - { name: unreadOnly, in: query, required: false, schema: { type: boolean } }
        - { name: q, in: query, required: false, schema: { type: string } }
      responses:
        '200':
          description: Paginated thread list.
          content: { application/json: { schema: { $ref: '#/components/schemas/EmailThreadList' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/email/threads/{id}:
    get:
      operationId: getEmailThread
      summary: Get an email thread with messages
      description: Returns a thread with its messages (plain text only). Requires scope `email:read`.
      tags: [Email]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 50, default: 20 } }
      responses:
        '200':
          description: Thread with messages.
          content: { application/json: { schema: { $ref: '#/components/schemas/EmailThreadDetail' } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/email/threads/{id}/status:
    post:
      operationId: setEmailThreadStatus
      summary: Set an email thread's status
      description: Sets the thread workflow status. Does not send mail. Requires scope `email:write`.
      tags: [Email]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status: { type: string, enum: [open, pending, closed] }
      responses:
        '200':
          description: Updated thread.
          content: { application/json: { schema: { $ref: '#/components/schemas/EmailThread' } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/email/threads/{id}/assignee:
    post:
      operationId: assignEmailThread
      summary: Assign an email thread
      description: Assigns the thread to a team member, or unassigns when `userId` is null. Does not send mail. Requires scope `email:write`.
      tags: [Email]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                userId: { type: integer, nullable: true }
      responses:
        '200':
          description: Updated thread.
          content: { application/json: { schema: { $ref: '#/components/schemas/EmailThread' } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/tags:
    get:
      operationId: listTags
      summary: List the tag dictionary
      description: Returns the user's contact-tag dictionary with per-tag contact counts. Requires scope `contacts:read`.
      tags: [Contacts]
      responses:
        '200':
          description: Tag dictionary.
          content: { application/json: { schema: { type: object, properties: { items: { type: array, items: { $ref: '#/components/schemas/TagWithCount' } } } } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/contacts/{id}/tags:
    get:
      operationId: getContactTags
      summary: List a contact's tags
      tags: [Contacts]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        '200':
          description: Tags attached to the contact.
          content: { application/json: { schema: { type: object, properties: { items: { type: array, items: { $ref: '#/components/schemas/Tag' } } } } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
    post:
      operationId: addContactTag
      summary: Attach a tag to a contact
      description: Provide `tagId`, or `tagName` (created if missing). Idempotent. Requires scope `contacts:write`.
      tags: [Contacts]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                tagId: { type: integer, nullable: true }
                tagName: { type: string, nullable: true }
      responses:
        '200':
          description: Updated tag list for the contact.
          content: { application/json: { schema: { type: object, properties: { items: { type: array, items: { $ref: '#/components/schemas/Tag' } } } } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/contacts/{id}/tags/{tagId}:
    delete:
      operationId: removeContactTag
      summary: Detach a tag from a contact
      description: Requires scope `contacts:write`.
      tags: [Contacts]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: tagId, in: path, required: true, schema: { type: integer } }
      responses:
        '200':
          description: Updated tag list for the contact.
          content: { application/json: { schema: { type: object, properties: { items: { type: array, items: { $ref: '#/components/schemas/Tag' } } } } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/contacts/{id}/score:
    put:
      operationId: setContactLeadScore
      summary: Set a contact's lead score
      description: Manually sets the lead score (0-100); marks it a manual override and logs an activity. Requires scope `contacts:write`.
      tags: [Contacts]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [score]
              properties:
                score: { type: integer, minimum: 0, maximum: 100 }
      responses:
        '200':
          description: Updated contact.
          content: { application/json: { schema: { $ref: '#/components/schemas/Contact' } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/contacts/{id}/assignee:
    put:
      operationId: assignContact
      summary: Assign a contact to a team member
      description: Assigns the contact, or unassigns when `userId` is null. Logs an activity. Requires scope `contacts:write`.
      tags: [Contacts]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                userId: { type: integer, nullable: true }
      responses:
        '200':
          description: Updated contact.
          content: { application/json: { schema: { $ref: '#/components/schemas/Contact' } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/contacts/{id}/activities:
    get:
      operationId: getContactActivities
      summary: Get a contact's activity timeline
      tags: [Contacts]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
        - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 100, default: 50 } }
      responses:
        '200':
          description: Activity timeline (newest first).
          content: { application/json: { schema: { type: object, properties: { items: { type: array, items: { $ref: '#/components/schemas/Activity' } } } } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
    post:
      operationId: addContactActivity
      summary: Add a note to a contact's timeline
      description: Requires scope `contacts:write`.
      tags: [Contacts]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body: { type: string, maxLength: 2000 }
      responses:
        '200':
          description: Created activity.
          content: { application/json: { schema: { $ref: '#/components/schemas/Activity' } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: csk_<env>_<key>
      description: >
        Pinlyx API key. Format: `csk_<env>_<12-char-keyId><32-char-secret>`.
        Obtain keys from the Pinlyx dashboard. Keep secret - the full token
        is never stored server-side (only a bcrypt hash of the secret portion).

  schemas:
    User:
      type: object
      description: Workspace identity returned by GET /v1/me.
      properties:
        id:
          type: integer
          description: Internal user id.
        email:
          type: string
          format: email
          description: Workspace email address.
        name:
          type: string
          nullable: true
          description: Display name (maps to FullName on the user record).
        createdAt:
          type: string
          format: date-time
          description: Account creation timestamp (UTC).
        apiKey:
          type: object
          description: Metadata about the API key used in this request.
          properties:
            keyId:
              type: string
              description: 12-character key identifier.
            scopes:
              type: array
              items:
                type: string
              description: Scopes granted to this key.

    Contact:
      type: object
      description: A CRM contact record.
      properties:
        id:
          type: integer
        platform:
          type: string
          enum: [telegram, twitter]
          description: Social platform the contact belongs to.
        name:
          type: string
          nullable: true
        username:
          type: string
          nullable: true
          description: Handle without the leading @.
        phone:
          type: string
          nullable: true
        email:
          type: string
          nullable: true
        company:
          type: string
          nullable: true
        notes:
          type: string
          nullable: true
        stage:
          type: string
          description: CRM pipeline stage.
        leadScore:
          type: integer
          nullable: true
          description: Lead score 0-100 (higher = hotter). Null when not scored.
        leadScoreIsAi:
          type: boolean
          description: True when the score was last set by AI, false for a manual override.
        assignedToUserId:
          type: integer
          nullable: true
          description: Team member the contact is assigned to.
        createdAt:
          type: string
          format: date-time
        lastMessageAt:
          type: string
          format: date-time
          nullable: true
        hasUnreadMessages:
          type: boolean
        tags:
          type: array
          nullable: true
          description: Attached tags (populated on GET by id, omitted in list responses).
          items:
            $ref: '#/components/schemas/Tag'

    ContactList:
      type: object
      description: Cursor-paginated list of contacts.
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Contact'
        nextCursor:
          type: integer
          nullable: true
          description: Pass as `after` on the next request to continue pagination. Null when `hasMore` is false.
        hasMore:
          type: boolean
          description: Whether additional pages exist beyond this response.

    CreateContactRequest:
      type: object
      description: At least one of name, username, or phone is required.
      properties:
        platform:
          type: string
          enum: [telegram, twitter]
          default: telegram
          description: Platform for the new contact. Defaults to `telegram`.
        name:
          type: string
          nullable: true
        username:
          type: string
          nullable: true
          description: Handle with or without a leading @ - the @ is stripped server-side.
        phone:
          type: string
          nullable: true
        notes:
          type: string
          nullable: true

    SendMessageRequest:
      type: object
      required: [accountId, text]
      description: >
        Provide exactly one of `contactId`, `username`, or `telegramUserId` to
        identify the recipient (or let `contactId` resolve to one automatically).
      properties:
        accountId:
          type: integer
          description: Id of the Telegram account (owned by the caller) to send from.
        contactId:
          type: integer
          nullable: true
          description: CRM contact to message. The contact's stored username/telegramUserId is used as the target.
        username:
          type: string
          nullable: true
          description: Telegram @username of the recipient (leading @ is stripped server-side).
        telegramUserId:
          type: integer
          format: int64
          nullable: true
          description: Telegram numeric user id of the recipient.
        text:
          type: string
          maxLength: 4000
          description: Message body. Maximum 4000 characters.
        runAt:
          type: string
          format: date-time
          nullable: true
          description: Scheduled delivery time (UTC). Omit or set to null for immediate dispatch.

    MessageJob:
      type: object
      description: Queued message job returned by POST /v1/telegram/messages (202 Accepted).
      properties:
        id:
          type: integer
        accountId:
          type: integer
        status:
          type: string
          enum: [queued, sent, failed]
          description: Current job status.
        runAt:
          type: string
          format: date-time
          description: Scheduled or effective send time (UTC).
        createdAt:
          type: string
          format: date-time

    MessageJobDetail:
      type: object
      description: Full message job detail returned by GET /v1/telegram/messages/{id}.
      properties:
        id:
          type: integer
        accountId:
          type: integer
        status:
          type: string
          enum: [queued, sent, failed]
        runAt:
          type: string
          format: date-time
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
          nullable: true
        lastError:
          type: string
          nullable: true
          description: Last error message if the job has failed.

    Tag:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        color: { type: string, description: Hex color, e.g. "#2563EB". }

    TagWithCount:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        color: { type: string }
        contactCount: { type: integer }

    Activity:
      type: object
      description: One entry in a contact's activity timeline.
      properties:
        id: { type: integer }
        type: { type: string, description: "Note | StageChanged | Assigned | Unassigned | ScoreChanged | TagAdded | TagRemoved | FieldUpdated | Created" }
        body: { type: string, nullable: true }
        createdAt: { type: string, format: date-time }

    DealTask:
      type: object
      properties:
        id: { type: integer }
        title: { type: string }
        status: { type: string, enum: [Open, InProgress, Done] }
        priority: { type: string, enum: [Low, Medium, High] }
        dueAt: { type: string, format: date-time, nullable: true }
        isOverdue: { type: boolean }

    Deal:
      type: object
      description: A sales pipeline deal. `tasks` is populated on GET by id, omitted in list responses.
      properties:
        id: { type: integer }
        title: { type: string }
        contactId: { type: integer, nullable: true }
        contactName: { type: string, nullable: true }
        value: { type: number }
        currency: { type: string }
        stage: { type: string, enum: [Lead, Qualified, Proposal, Negotiation, Won, Lost] }
        probability: { type: integer, description: 0-100. }
        expectedCloseAt: { type: string, format: date-time, nullable: true }
        closedAt: { type: string, format: date-time, nullable: true }
        notes: { type: string, nullable: true }
        openTaskCount: { type: integer }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time, nullable: true }
        tasks:
          type: array
          nullable: true
          items: { $ref: '#/components/schemas/DealTask' }

    DealList:
      type: object
      properties:
        items: { type: array, items: { $ref: '#/components/schemas/Deal' } }
        nextCursor: { type: integer, nullable: true }
        hasMore: { type: boolean }

    CreateDealRequest:
      type: object
      required: [title]
      properties:
        title: { type: string, maxLength: 200 }
        contactId: { type: integer, nullable: true }
        value: { type: number, default: 0 }
        currency: { type: string, default: USD }
        stage: { type: string, enum: [lead, qualified, proposal, negotiation], default: lead }
        probability: { type: integer, minimum: 0, maximum: 100 }
        expectedCloseAt: { type: string, format: date-time, nullable: true }
        notes: { type: string, nullable: true }

    Task:
      type: object
      properties:
        id: { type: integer }
        title: { type: string }
        description: { type: string, nullable: true }
        contactId: { type: integer, nullable: true }
        contactName: { type: string, nullable: true }
        dealId: { type: integer, nullable: true }
        dealTitle: { type: string, nullable: true }
        priority: { type: string, enum: [Low, Medium, High] }
        status: { type: string, enum: [Open, InProgress, Done] }
        dueAt: { type: string, format: date-time, nullable: true }
        completedAt: { type: string, format: date-time, nullable: true }
        isOverdue: { type: boolean }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time, nullable: true }

    TaskList:
      type: object
      properties:
        items: { type: array, items: { $ref: '#/components/schemas/Task' } }
        nextCursor: { type: integer, nullable: true }
        hasMore: { type: boolean }

    CreateTaskRequest:
      type: object
      required: [title]
      properties:
        title: { type: string, maxLength: 200 }
        description: { type: string, nullable: true }
        contactId: { type: integer, nullable: true }
        dealId: { type: integer, nullable: true }
        priority: { type: string, enum: [low, medium, high], default: medium }
        dueAt: { type: string, format: date-time, nullable: true }

    FinanceSummary:
      type: object
      description: Realized totals (completed rows only) grouped per currency; no FX conversion.
      properties:
        range: { type: string, enum: [7d, 30d, month] }
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }
        currency: { type: string, nullable: true }
        totals:
          type: array
          items:
            type: object
            properties:
              currency: { type: string }
              income: { type: number }
              expense: { type: number }
              net: { type: number }
        topExpenseCategories:
          type: array
          items:
            type: object
            properties:
              categoryId: { type: integer, nullable: true }
              categoryName: { type: string }
              total: { type: number }
        outstanding:
          type: array
          items:
            type: object
            properties:
              currency: { type: string }
              incomePending: { type: number }
              expensePending: { type: number }

    Transaction:
      type: object
      properties:
        id: { type: integer }
        type: { type: string, enum: [income, expense] }
        amount: { type: number }
        currency: { type: string }
        status: { type: string, enum: [completed, pending, refunded, failed] }
        occurredAt: { type: string, format: date-time }
        description: { type: string, nullable: true }
        categoryId: { type: integer, nullable: true }
        contactId: { type: integer, nullable: true }
        productName: { type: string, nullable: true }
        fee: { type: number, nullable: true }
        net: { type: number, nullable: true, description: Amount minus fee when a fee is present. }
        sourceLabel: { type: string, nullable: true }
        invoiceId: { type: integer, nullable: true }

    TransactionList:
      type: object
      properties:
        items: { type: array, items: { $ref: '#/components/schemas/Transaction' } }
        nextCursor: { type: integer, nullable: true }
        hasMore: { type: boolean }

    Invoice:
      type: object
      properties:
        id: { type: integer }
        invoiceNumber: { type: string }
        status: { type: string, enum: [draft, sent, paid, overdue, cancelled] }
        currency: { type: string }
        total: { type: number }
        contactId: { type: integer, nullable: true }
        issueDate: { type: string, format: date-time }
        dueDate: { type: string, format: date-time, nullable: true }
        paidAt: { type: string, format: date-time, nullable: true }

    InvoiceList:
      type: object
      properties:
        items: { type: array, items: { $ref: '#/components/schemas/Invoice' } }
        nextCursor: { type: integer, nullable: true }
        hasMore: { type: boolean }
        outstanding:
          type: array
          items:
            type: object
            properties:
              currency: { type: string }
              count: { type: integer }
              total: { type: number }

    RevenueSource:
      type: object
      description: External revenue feed. Secrets (keys/credentials) are never returned.
      properties:
        id: { type: integer }
        name: { type: string }
        mode: { type: string, enum: [push, pull] }
        defaultCurrency: { type: string }
        isActive: { type: boolean }
        feedUrl: { type: string, nullable: true }
        pullIntervalMinutes: { type: integer, nullable: true }
        lastSyncStatus: { type: string, enum: [never, ok, error] }
        lastSyncAt: { type: string, format: date-time, nullable: true }
        lastSyncError: { type: string, nullable: true }
        totalIngested: { type: integer, format: int64 }
        lastEventAt: { type: string, format: date-time, nullable: true }
        nextRunAt: { type: string, format: date-time, nullable: true }

    EmailThread:
      type: object
      properties:
        id: { type: integer }
        subject: { type: string }
        preview: { type: string, nullable: true }
        status: { type: string, enum: [open, pending, closed] }
        unreadCount: { type: integer }
        isStarred: { type: boolean }
        contactId: { type: integer, nullable: true }
        assignedToUserId: { type: integer, nullable: true }
        aiLeadScore: { type: integer, nullable: true }
        lastMessageAt: { type: string, format: date-time }

    EmailMessage:
      type: object
      properties:
        id: { type: integer }
        direction: { type: string, enum: [inbound, outbound] }
        fromAddress: { type: string }
        fromName: { type: string, nullable: true }
        subject: { type: string }
        body: { type: string, nullable: true, description: Plain text only; raw HTML is never returned. }
        isRead: { type: boolean }
        receivedAt: { type: string, format: date-time }

    EmailThreadDetail:
      allOf:
        - $ref: '#/components/schemas/EmailThread'
        - type: object
          properties:
            aiSummary: { type: string, nullable: true }
            messages:
              type: array
              items: { $ref: '#/components/schemas/EmailMessage' }

    EmailThreadList:
      type: object
      properties:
        items: { type: array, items: { $ref: '#/components/schemas/EmailThread' } }
        nextCursor: { type: integer, nullable: true }
        hasMore: { type: boolean }

    Error:
      type: object
      description: Standard error envelope for all v1 error responses.
      properties:
        error:
          type: string
          enum:
            - bad_request
            - not_found
            - conflict
            - unauthorized
            - forbidden
            - rate_limited
            - internal_error
          description: Machine-readable error code.
        message:
          type: string
          description: Human-readable explanation of the error.
        details:
          description: Optional structured context (field-level validation errors, etc.).
          nullable: true

  responses:
    BadRequest:
      description: The request body or parameters failed validation.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: bad_request
            message: at least one of name, username, phone is required

    Unauthorized:
      description: Missing or invalid bearer token.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: unauthorized
            message: Invalid bearer principal

    Forbidden:
      description: The API key does not have the required scope for this operation.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: forbidden
            message: scope contacts:write is required

    NotFound:
      description: The requested resource does not exist or is not owned by the caller.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: not_found
            message: contact not found

    RateLimited:
      description: Per-key rate limit exceeded. Retry after the time indicated by X-RateLimit-Reset.
      headers:
        X-RateLimit-Limit:
          schema:
            type: integer
          description: Maximum requests allowed per minute for this key.
        X-RateLimit-Remaining:
          schema:
            type: integer
          description: Requests remaining in the current window.
        X-RateLimit-Reset:
          schema:
            type: integer
          description: Unix timestamp (seconds) when the rate limit window resets.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: rate_limited
            message: rate limit exceeded

security:
  - bearerAuth: []
