openapi: 3.1.0
info:
  title: Wayki Project API
  version: 1.0.0
  summary: Read-only access to one Wayki project for external systems.
  description: |
    Server-to-server REST API. A project owner or admin creates a credential
    (client_id + client_secret) in Project settings > API and chooses its
    scopes. Your server exchanges the credential for a bearer token at
    `POST /v1/oauth/token` (OAuth 2.0 client-credentials) and calls the
    `/v1/projects/{projectId}` routes with it.

    - Tokens last up to 1 hour. There is no refresh token: request a new one.
    - A token is bound to the credential's project. Any other project answers 403.
    - Revoking the credential stops its tokens immediately.
    - No CORS: never ship the client secret to a browser or a mobile app.

    Human documentation: https://wayki.app/developers
  contact:
    name: Wayki
    url: https://wayki.app/developers
servers:
  - url: https://api.wayki.app
    description: Production
security:
  - projectApiToken: []
tags:
  - name: Authentication
  - name: Project
  - name: Issues
  - name: RFIs
  - name: Change orders
  - name: Non-conformances
  - name: Meetings
  - name: Forms

paths:
  /v1/oauth/token:
    post:
      tags: [Authentication]
      summary: Exchange client credentials for an access token
      description: |
        OAuth 2.0 client-credentials grant (RFC 6749 §4.4). Send the credential
        with HTTP Basic (preferred) or as `client_id` / `client_secret` in the
        body, never both. Limited to 10 requests per minute per IP: cache the
        token until shortly before `expires_in`.
      security:
        - clientBasic: []
        - {}
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: '#/components/schemas/TokenRequest'
          application/json:
            schema:
              $ref: '#/components/schemas/TokenRequest'
      responses:
        '200':
          description: Access token issued.
          headers:
            Cache-Control:
              schema: { type: string, example: no-store }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TokenResponse'
        '400':
          description: invalid_request, unsupported_grant_type, invalid_scope or unauthorized_client.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OAuthError'
        '401':
          description: invalid_client. Unknown client, wrong secret, revoked or expired credential.
          headers:
            WWW-Authenticate:
              schema: { type: string, example: 'Basic realm="wayki-project-api"' }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OAuthError'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '503':
          description: The project API is not enabled on this server.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OAuthError'

  /v1/projects/{projectId}:
    get:
      tags: [Project]
      summary: Get the project
      security: [{ projectApiToken: ['project:read'] }]
      parameters: [{ $ref: '#/components/parameters/projectId' }]
      responses:
        '200':
          description: The project.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Project' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /v1/projects/{projectId}/members:
    get:
      tags: [Project]
      summary: List project members
      description: Includes email addresses. Not paginated.
      security: [{ projectApiToken: ['members:read'] }]
      parameters: [{ $ref: '#/components/parameters/projectId' }]
      responses:
        '200':
          description: Members.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Member' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /v1/projects/{projectId}/issues/settings:
    get:
      tags: [Issues]
      summary: Issue settings (statuses, priorities, categories, attributes)
      security: [{ projectApiToken: ['issues:read'] }]
      parameters: [{ $ref: '#/components/parameters/projectId' }]
      responses:
        '200': { $ref: '#/components/responses/Item' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
  /v1/projects/{projectId}/issues:
    get:
      tags: [Issues]
      summary: List issues
      description: '`from` / `to` filter on creation date. Sort: createdAt, updatedAt, number, title, dueDate, priority.'
      security: [{ projectApiToken: ['issues:read'] }]
      parameters:
        - $ref: '#/components/parameters/projectId'
        - $ref: '#/components/parameters/page'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/sort'
        - $ref: '#/components/parameters/order'
        - $ref: '#/components/parameters/q'
        - $ref: '#/components/parameters/from'
        - $ref: '#/components/parameters/to'
      responses:
        '200': { $ref: '#/components/responses/Page' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
  /v1/projects/{projectId}/issues/{issueId}:
    get:
      tags: [Issues]
      summary: Get an issue
      security: [{ projectApiToken: ['issues:read'] }]
      parameters:
        - $ref: '#/components/parameters/projectId'
        - { name: issueId, in: path, required: true, schema: { $ref: '#/components/schemas/ObjectId' } }
      responses:
        '200': { $ref: '#/components/responses/Item' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
  /v1/projects/{projectId}/issues/{issueId}/replies:
    get:
      tags: [Issues]
      summary: List the replies of an issue
      description: Oldest first unless `order=desc`.
      security: [{ projectApiToken: ['issues:read'] }]
      parameters:
        - $ref: '#/components/parameters/projectId'
        - { name: issueId, in: path, required: true, schema: { $ref: '#/components/schemas/ObjectId' } }
        - $ref: '#/components/parameters/page'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/order'
      responses:
        '200': { $ref: '#/components/responses/Page' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/projects/{projectId}/rfis/settings:
    get:
      tags: [RFIs]
      summary: RFI settings
      security: [{ projectApiToken: ['rfis:read'] }]
      parameters: [{ $ref: '#/components/parameters/projectId' }]
      responses:
        '200': { $ref: '#/components/responses/Item' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
  /v1/projects/{projectId}/rfis:
    get:
      tags: [RFIs]
      summary: List RFIs
      description: '`from` / `to` filter on creation date. Sort: createdAt, updatedAt, number, title, dueDate, status, priority.'
      security: [{ projectApiToken: ['rfis:read'] }]
      parameters:
        - $ref: '#/components/parameters/projectId'
        - $ref: '#/components/parameters/page'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/sort'
        - $ref: '#/components/parameters/order'
        - $ref: '#/components/parameters/q'
        - $ref: '#/components/parameters/from'
        - $ref: '#/components/parameters/to'
      responses:
        '200': { $ref: '#/components/responses/Page' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
  /v1/projects/{projectId}/rfis/{rfiId}:
    get:
      tags: [RFIs]
      summary: Get an RFI
      security: [{ projectApiToken: ['rfis:read'] }]
      parameters:
        - $ref: '#/components/parameters/projectId'
        - { name: rfiId, in: path, required: true, schema: { $ref: '#/components/schemas/ObjectId' } }
      responses:
        '200': { $ref: '#/components/responses/Item' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
  /v1/projects/{projectId}/rfis/{rfiId}/replies:
    get:
      tags: [RFIs]
      summary: List the replies of an RFI
      security: [{ projectApiToken: ['rfis:read'] }]
      parameters:
        - $ref: '#/components/parameters/projectId'
        - { name: rfiId, in: path, required: true, schema: { $ref: '#/components/schemas/ObjectId' } }
        - $ref: '#/components/parameters/page'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/order'
      responses:
        '200': { $ref: '#/components/responses/Page' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/projects/{projectId}/change-orders/settings:
    get:
      tags: [Change orders]
      summary: Change order settings
      security: [{ projectApiToken: ['change-orders:read'] }]
      parameters: [{ $ref: '#/components/parameters/projectId' }]
      responses:
        '200': { $ref: '#/components/responses/Item' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
  /v1/projects/{projectId}/change-orders:
    get:
      tags: [Change orders]
      summary: List change orders
      description: '`from` / `to` filter on creation date. Sort: createdAt, updatedAt, number, title, dueDate, status, priority.'
      security: [{ projectApiToken: ['change-orders:read'] }]
      parameters:
        - $ref: '#/components/parameters/projectId'
        - $ref: '#/components/parameters/page'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/sort'
        - $ref: '#/components/parameters/order'
        - $ref: '#/components/parameters/q'
        - $ref: '#/components/parameters/from'
        - $ref: '#/components/parameters/to'
      responses:
        '200': { $ref: '#/components/responses/Page' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
  /v1/projects/{projectId}/change-orders/{changeOrderId}:
    get:
      tags: [Change orders]
      summary: Get a change order
      security: [{ projectApiToken: ['change-orders:read'] }]
      parameters:
        - $ref: '#/components/parameters/projectId'
        - { name: changeOrderId, in: path, required: true, schema: { $ref: '#/components/schemas/ObjectId' } }
      responses:
        '200': { $ref: '#/components/responses/Item' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
  /v1/projects/{projectId}/change-orders/{changeOrderId}/replies:
    get:
      tags: [Change orders]
      summary: List the replies of a change order
      security: [{ projectApiToken: ['change-orders:read'] }]
      parameters:
        - $ref: '#/components/parameters/projectId'
        - { name: changeOrderId, in: path, required: true, schema: { $ref: '#/components/schemas/ObjectId' } }
        - $ref: '#/components/parameters/page'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/order'
      responses:
        '200': { $ref: '#/components/responses/Page' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/projects/{projectId}/non-conformances/settings:
    get:
      tags: [Non-conformances]
      summary: Non-conformance settings
      security: [{ projectApiToken: ['non-conformances:read'] }]
      parameters: [{ $ref: '#/components/parameters/projectId' }]
      responses:
        '200': { $ref: '#/components/responses/Item' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
  /v1/projects/{projectId}/non-conformances:
    get:
      tags: [Non-conformances]
      summary: List non-conformances
      description: '`from` / `to` filter on creation date. Sort: createdAt, updatedAt, number, title, dueDate, status, priority.'
      security: [{ projectApiToken: ['non-conformances:read'] }]
      parameters:
        - $ref: '#/components/parameters/projectId'
        - $ref: '#/components/parameters/page'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/sort'
        - $ref: '#/components/parameters/order'
        - $ref: '#/components/parameters/q'
        - $ref: '#/components/parameters/from'
        - $ref: '#/components/parameters/to'
      responses:
        '200': { $ref: '#/components/responses/Page' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
  /v1/projects/{projectId}/non-conformances/{nonConformanceId}:
    get:
      tags: [Non-conformances]
      summary: Get a non-conformance
      security: [{ projectApiToken: ['non-conformances:read'] }]
      parameters:
        - $ref: '#/components/parameters/projectId'
        - { name: nonConformanceId, in: path, required: true, schema: { $ref: '#/components/schemas/ObjectId' } }
      responses:
        '200': { $ref: '#/components/responses/Item' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
  /v1/projects/{projectId}/non-conformances/{nonConformanceId}/replies:
    get:
      tags: [Non-conformances]
      summary: List the replies of a non-conformance
      security: [{ projectApiToken: ['non-conformances:read'] }]
      parameters:
        - $ref: '#/components/parameters/projectId'
        - { name: nonConformanceId, in: path, required: true, schema: { $ref: '#/components/schemas/ObjectId' } }
        - $ref: '#/components/parameters/page'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/order'
      responses:
        '200': { $ref: '#/components/responses/Page' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/projects/{projectId}/meetings/settings:
    get:
      tags: [Meetings]
      summary: Meeting settings (agreement statuses and priorities)
      security: [{ projectApiToken: ['meetings:read'] }]
      parameters: [{ $ref: '#/components/parameters/projectId' }]
      responses:
        '200': { $ref: '#/components/responses/Item' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
  /v1/projects/{projectId}/meetings:
    get:
      tags: [Meetings]
      summary: List meetings
      description: '`from` / `to` filter on the planned start date. Sort: startPlannedDate (default), createdAt, title, number.'
      security: [{ projectApiToken: ['meetings:read'] }]
      parameters:
        - $ref: '#/components/parameters/projectId'
        - $ref: '#/components/parameters/page'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/sort'
        - $ref: '#/components/parameters/order'
        - $ref: '#/components/parameters/q'
        - $ref: '#/components/parameters/from'
        - $ref: '#/components/parameters/to'
      responses:
        '200': { $ref: '#/components/responses/Page' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
  /v1/projects/{projectId}/meetings/{meetingId}:
    get:
      tags: [Meetings]
      summary: Get a meeting with its agenda, agreements and participants
      security: [{ projectApiToken: ['meetings:read'] }]
      parameters:
        - $ref: '#/components/parameters/projectId'
        - { name: meetingId, in: path, required: true, schema: { $ref: '#/components/schemas/ObjectId' } }
      responses:
        '200': { $ref: '#/components/responses/Item' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
  /v1/projects/{projectId}/meetings/{meetingId}/minutes:
    get:
      tags: [Meetings]
      summary: List the frozen minutes of a meeting (one per closed session)
      security: [{ projectApiToken: ['meetings:read'] }]
      parameters:
        - $ref: '#/components/parameters/projectId'
        - { name: meetingId, in: path, required: true, schema: { $ref: '#/components/schemas/ObjectId' } }
        - $ref: '#/components/parameters/page'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/order'
      responses:
        '200': { $ref: '#/components/responses/Page' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
  /v1/projects/{projectId}/meetings/{meetingId}/minutes/{versionId}:
    get:
      tags: [Meetings]
      summary: Get one frozen minutes version
      security: [{ projectApiToken: ['meetings:read'] }]
      parameters:
        - $ref: '#/components/parameters/projectId'
        - { name: meetingId, in: path, required: true, schema: { $ref: '#/components/schemas/ObjectId' } }
        - { name: versionId, in: path, required: true, schema: { $ref: '#/components/schemas/ObjectId' } }
      responses:
        '200': { $ref: '#/components/responses/Item' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/projects/{projectId}/forms/settings:
    get:
      tags: [Forms]
      summary: Form settings
      security: [{ projectApiToken: ['forms:read'] }]
      parameters: [{ $ref: '#/components/parameters/projectId' }]
      responses:
        '200': { $ref: '#/components/responses/Item' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
  /v1/projects/{projectId}/forms:
    get:
      tags: [Forms]
      summary: List forms
      description: '`from` / `to` filter on creation date. Sort: createdAt, updatedAt, number, name.'
      security: [{ projectApiToken: ['forms:read'] }]
      parameters:
        - $ref: '#/components/parameters/projectId'
        - $ref: '#/components/parameters/page'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/sort'
        - $ref: '#/components/parameters/order'
        - $ref: '#/components/parameters/q'
        - $ref: '#/components/parameters/from'
        - $ref: '#/components/parameters/to'
      responses:
        '200': { $ref: '#/components/responses/Page' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
  /v1/projects/{projectId}/forms/{formId}:
    get:
      tags: [Forms]
      summary: Get a form
      security: [{ projectApiToken: ['forms:read'] }]
      parameters:
        - $ref: '#/components/parameters/projectId'
        - { name: formId, in: path, required: true, schema: { $ref: '#/components/schemas/ObjectId' } }
      responses:
        '200': { $ref: '#/components/responses/Item' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

components:
  securitySchemes:
    projectApiToken:
      type: oauth2
      description: Bearer token from `POST /v1/oauth/token`.
      flows:
        clientCredentials:
          tokenUrl: /v1/oauth/token
          scopes:
            project:read: Project name, code, status and company.
            members:read: Members and roles, including email addresses.
            issues:read: Issues, their settings and replies.
            rfis:read: RFIs, their settings and replies.
            change-orders:read: Change orders, their settings and replies.
            non-conformances:read: Non-conformances, their settings and replies.
            meetings:read: Meetings, agreements and frozen minutes.
            forms:read: Filled-in forms and their settings.
    clientBasic:
      type: http
      scheme: basic
      description: '`client_id:client_secret`, only on the token endpoint.'

  parameters:
    projectId:
      name: projectId
      in: path
      required: true
      description: Must be the project the credential belongs to.
      schema: { $ref: '#/components/schemas/ObjectId' }
    page:
      name: page
      in: query
      schema: { type: integer, minimum: 1, default: 1 }
    limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
    sort:
      name: sort
      in: query
      description: Resource-specific whitelist (see each operation). Anything else is a 400.
      schema: { type: string }
    order:
      name: order
      in: query
      schema: { type: string, enum: [asc, desc] }
    q:
      name: q
      in: query
      description: Free-text search.
      schema: { type: string, maxLength: 200 }
    from:
      name: from
      in: query
      description: Inclusive lower bound, ISO 8601.
      schema: { type: string, format: date-time }
    to:
      name: to
      in: query
      description: Inclusive upper bound, ISO 8601.
      schema: { type: string, format: date-time }

  responses:
    Item:
      description: A single resource.
      content:
        application/json:
          schema:
            type: object
            required: [data]
            properties:
              data: { type: object, additionalProperties: true }
    Page:
      description: A page of resources.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Page'
    BadRequest:
      description: Invalid query parameter (unknown parameter, bad sort, limit above 100...).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: Missing, expired or revoked token.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Forbidden:
      description: The token lacks the scope, or belongs to another project.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: The resource does not exist in this project.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    TooManyRequests:
      description: Rate limit exceeded. Retry after the time in `Retry-After`.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    ObjectId:
      type: string
      pattern: '^[a-f0-9]{24}$'
      example: 64b7f0c2e4b0a1b2c3d4e5f6
    TokenRequest:
      type: object
      required: [grant_type]
      properties:
        grant_type: { type: string, enum: [client_credentials] }
        client_id: { type: string, example: wk_ci_3fQpZ0... }
        client_secret: { type: string, example: wk_cs_Yb81... }
        scope:
          type: string
          description: Optional space-delimited subset of the credential's scopes.
          example: issues:read rfis:read
    TokenResponse:
      type: object
      required: [access_token, token_type, expires_in, scope]
      properties:
        access_token: { type: string }
        token_type: { type: string, enum: [Bearer] }
        expires_in: { type: integer, example: 3600 }
        scope: { type: string, example: issues:read rfis:read }
    OAuthError:
      type: object
      required: [error]
      properties:
        error:
          type: string
          enum:
            - invalid_request
            - invalid_client
            - invalid_scope
            - unauthorized_client
            - unsupported_grant_type
            - temporarily_unavailable
            - server_error
        error_description: { type: string }
    Error:
      type: object
      properties:
        statusCode: { type: integer }
        message:
          oneOf:
            - { type: string }
            - { type: array, items: { type: string } }
        error: { type: string }
    Page:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: { type: object, additionalProperties: true }
        meta:
          type: object
          required: [page, limit, total, totalPages]
          properties:
            page: { type: integer }
            limit: { type: integer }
            total: { type: integer }
            totalPages: { type: integer }
    Project:
      type: object
      properties:
        _id: { $ref: '#/components/schemas/ObjectId' }
        name: { type: string }
        code: { type: [string, 'null'] }
        description: { type: [string, 'null'] }
        status: { type: string }
        type: { type: [string, 'null'] }
        constructionPhase: { type: [string, 'null'] }
        deliveryMethod: { type: [string, 'null'] }
        startDate: { type: [string, 'null'], format: date-time }
        endDate: { type: [string, 'null'], format: date-time }
        timezone: { type: [string, 'null'] }
        location:
          type: object
          properties:
            addressLine: { type: [string, 'null'] }
            city: { type: [string, 'null'] }
            postCode: { type: [string, 'null'] }
            country: { type: [string, 'null'] }
            latitude: { type: [number, 'null'] }
            longitude: { type: [number, 'null'] }
        company:
          type: [object, 'null']
          properties:
            _id: { $ref: '#/components/schemas/ObjectId' }
            name: { type: string }
        memberCount: { type: integer }
        createdAt: { type: [string, 'null'], format: date-time }
        updatedAt: { type: [string, 'null'], format: date-time }
    Member:
      type: object
      properties:
        user:
          type: object
          properties:
            _id: { $ref: '#/components/schemas/ObjectId' }
            name: { type: string }
            email: { type: string }
            image: { type: [object, 'null'], additionalProperties: true }
            jobTitle: { type: [string, 'null'] }
        role: { type: string, enum: [owner, admin, member, viewer] }
        roleId: { type: [string, 'null'] }
        joinedAt: { type: [string, 'null'], format: date-time }
