openapi: 3.1.0
info:
  title: OpenSession Public API
  version: "1.0"
  description: |
    SessionBoard-shaped REST API for the OpenSession Program module.

    **Auth**: pass an API token (minted in Settings → API Tokens) via the
    `x-access-token` header. Tokens are instance-scoped and carry scopes:
    `read:events`, `read:sessions`, `write:sessions`, `read:contacts`,
    `write:contacts`, `read:metadata`, `write:metadata`.

    **Conventions**: GET on a collection lists (query filters); POST on a
    collection searches (JSON body). Creates live at `POST …/create`. Deletes
    are soft (`POST …/{id}/restore` reverses them). Session `PUT` requires
    `updated_at` for optimistic concurrency (409 on stale). Bulk writes go to
    `POST …/bulk` (max 100 operations, per-item results). All list responses
    use the pagination envelope; all errors use `{error: {code, message}}`.
servers:
  - url: /api/v1
security:
  - AccessToken: []
tags:
  - name: Events
  - name: Sessions
  - name: Session files
  - name: Contacts
  - name: Fields
  - name: Metadata

paths:
  /events:
    get:
      tags: [Events]
      summary: List all events on the instance
      description: Requires `read:events`.
      parameters: [{ $ref: "#/components/parameters/Page" }, { $ref: "#/components/parameters/PageSize" }]
      responses:
        "200":
          description: Paginated events
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginatedList"
                  - properties:
                      results:
                        type: array
                        items: { $ref: "#/components/schemas/Event" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /event/{eventId}/sessions:
    parameters: [{ $ref: "#/components/parameters/EventId" }]
    get:
      tags: [Sessions]
      summary: List sessions
      description: Requires `read:sessions`. Excludes soft-deleted sessions.
      parameters:
        - { $ref: "#/components/parameters/Page" }
        - { $ref: "#/components/parameters/PageSize" }
        - name: status
          in: query
          schema: { $ref: "#/components/schemas/SessionStatus" }
        - name: is_abstract
          in: query
          schema: { type: boolean }
        - name: track_id
          in: query
          schema: { type: string, format: uuid }
        - name: tag_id
          in: query
          schema: { type: string, format: uuid }
        - name: search
          in: query
          description: Case-insensitive match on title/description.
          schema: { type: string }
      responses:
        "200": { $ref: "#/components/responses/SessionList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Sessions]
      summary: Search sessions
      description: Requires `read:sessions`. Passing `filters.deletedAt` switches the result set to soft-deleted sessions.
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SessionSearch" }
      responses:
        "200": { $ref: "#/components/responses/SessionList" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /event/{eventId}/sessions/create:
    parameters: [{ $ref: "#/components/parameters/EventId" }]
    post:
      tags: [Sessions]
      summary: Create a session
      description: Requires `write:sessions`. A friendly id (SESS-n) is allocated automatically.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SessionWrite" }
      responses:
        "201":
          description: The created session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Session" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /event/{eventId}/sessions/bulk:
    parameters: [{ $ref: "#/components/parameters/EventId" }]
    post:
      tags: [Sessions]
      summary: Bulk session operations
      description: Requires `write:sessions`. Up to 100 operations; each is applied and reported independently.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BulkRequest" }
      responses:
        "200":
          description: Per-operation results
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BulkResult" }
        "400": { $ref: "#/components/responses/ValidationError" }

  /event/{eventId}/sessions/{sessionId}:
    parameters:
      - { $ref: "#/components/parameters/EventId" }
      - { $ref: "#/components/parameters/SessionId" }
    get:
      tags: [Sessions]
      summary: Get a session
      description: Requires `read:sessions`. Soft-deleted sessions are returned with `deleted_at` set.
      responses:
        "200":
          description: The session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Session" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Sessions]
      summary: Update a session (optimistic concurrency)
      description: |
        Requires `write:sessions`. `updated_at` must equal the session's
        current `updated_at` — a 409 means the row changed since you read it.
        `participants`/`tag_ids`, when present, replace the whole set.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/SessionWrite"
                - required: [updated_at]
                  properties:
                    updated_at: { type: string, format: date-time }
      responses:
        "200":
          description: The updated session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Session" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Stale updated_at
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
    delete:
      tags: [Sessions]
      summary: Soft-delete a session
      description: Requires `write:sessions`. Reversible via `POST …/restore`.
      responses:
        "200":
          description: Deletion receipt
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DeleteReceipt" }
        "404": { $ref: "#/components/responses/NotFound" }

  /event/{eventId}/sessions/{sessionId}/restore:
    parameters:
      - { $ref: "#/components/parameters/EventId" }
      - { $ref: "#/components/parameters/SessionId" }
    post:
      tags: [Sessions]
      summary: Restore a soft-deleted session
      description: Requires `write:sessions`.
      responses:
        "200":
          description: The restored session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Session" }
        "404": { $ref: "#/components/responses/NotFound" }

  /event/{eventId}/sessions/{sessionId}/files:
    parameters:
      - { $ref: "#/components/parameters/EventId" }
      - { $ref: "#/components/parameters/SessionId" }
    get:
      tags: [Session files]
      summary: List completed files on a session
      description: Requires `read:sessions`.
      responses:
        "200":
          description: Completed uploads
          content:
            application/json:
              schema:
                type: object
                properties:
                  results:
                    type: array
                    items: { $ref: "#/components/schemas/File" }
    post:
      tags: [Session files]
      summary: Start an upload (presigned R2 PUT)
      description: |
        Requires `write:sessions`. Returns a presigned URL — PUT the bytes
        there with the same Content-Type, then call `…/files/{fileId}/complete`.
        Responds 503 when the instance has no object storage configured.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [filename, size_bytes, content_type]
              properties:
                filename: { type: string }
                size_bytes: { type: integer, description: "Max 50 MiB" }
                content_type: { type: string }
      responses:
        "201":
          description: Pending file + upload instructions
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  upload:
                    type: object
                    properties:
                      url: { type: string }
                      method: { type: string, enum: [PUT] }
                      headers: { type: object, additionalProperties: { type: string } }
        "503":
          description: Object storage not configured
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /event/{eventId}/sessions/{sessionId}/files/{fileId}:
    parameters:
      - { $ref: "#/components/parameters/EventId" }
      - { $ref: "#/components/parameters/SessionId" }
      - { name: fileId, in: path, required: true, schema: { type: string, format: uuid } }
    put:
      tags: [Session files]
      summary: Update file metadata
      description: Requires `write:sessions`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: [string, "null"] }
                filename: { type: string }
      responses:
        "200":
          description: The updated file
          content:
            application/json:
              schema: { $ref: "#/components/schemas/File" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Session files]
      summary: Soft-delete a file
      description: Requires `write:sessions`.
      responses:
        "200":
          description: Deletion receipt
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DeleteReceipt" }

  /event/{eventId}/sessions/{sessionId}/files/{fileId}/complete:
    parameters:
      - { $ref: "#/components/parameters/EventId" }
      - { $ref: "#/components/parameters/SessionId" }
      - { name: fileId, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [Session files]
      summary: Mark an upload complete
      description: Requires `write:sessions`. Call after the presigned PUT succeeded.
      responses:
        "200":
          description: The completed file
          content:
            application/json:
              schema: { $ref: "#/components/schemas/File" }
        "404": { $ref: "#/components/responses/NotFound" }

  /event/{eventId}/contacts:
    parameters: [{ $ref: "#/components/parameters/EventId" }]
    get:
      tags: [Contacts]
      summary: List contacts
      description: Requires `read:contacts`.
      parameters:
        - { $ref: "#/components/parameters/Page" }
        - { $ref: "#/components/parameters/PageSize" }
        - name: search
          in: query
          description: Case-insensitive match on name/email/company.
          schema: { type: string }
      responses:
        "200": { $ref: "#/components/responses/ContactList" }
    post:
      tags: [Contacts]
      summary: Search contacts
      description: Requires `read:contacts`. Passing `filters.deletedAt` switches the result set to soft-deleted contacts.
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ContactSearch" }
      responses:
        "200": { $ref: "#/components/responses/ContactList" }

  /event/{eventId}/speakers:
    parameters: [{ $ref: "#/components/parameters/EventId" }]
    get:
      tags: [Contacts]
      summary: List speakers
      description: Requires `read:contacts`. Contacts that participate in at least one session.
      parameters: [{ $ref: "#/components/parameters/Page" }, { $ref: "#/components/parameters/PageSize" }]
      responses:
        "200": { $ref: "#/components/responses/ContactList" }

  /event/{eventId}/contacts/create:
    parameters: [{ $ref: "#/components/parameters/EventId" }]
    post:
      tags: [Contacts]
      summary: Create a contact
      description: Requires `write:contacts`. Email is unique per event (409 on duplicates); a friendly id (SPK-n) is allocated automatically.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ContactWrite" }
      responses:
        "201":
          description: The created contact
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Contact" }
        "409":
          description: Duplicate email
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /event/{eventId}/contacts/bulk:
    parameters: [{ $ref: "#/components/parameters/EventId" }]
    post:
      tags: [Contacts]
      summary: Bulk contact operations
      description: Requires `write:contacts`. Up to 100 operations; per-item results.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BulkRequest" }
      responses:
        "200":
          description: Per-operation results
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BulkResult" }

  /event/{eventId}/contacts/{contactId}:
    parameters:
      - { $ref: "#/components/parameters/EventId" }
      - { $ref: "#/components/parameters/ContactId" }
    get:
      tags: [Contacts]
      summary: Get a contact
      description: Requires `read:contacts`.
      responses:
        "200":
          description: The contact
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Contact" }
        "404": { $ref: "#/components/responses/NotFound" }
    put:
      tags: [Contacts]
      summary: Update a contact
      description: Requires `write:contacts`. Partial update.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ContactWrite" }
      responses:
        "200":
          description: The updated contact
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Contact" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Contacts]
      summary: Soft-delete a contact
      description: Requires `write:contacts`. Reversible via `POST …/restore`.
      responses:
        "200":
          description: Deletion receipt
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DeleteReceipt" }

  /event/{eventId}/contacts/{contactId}/restore:
    parameters:
      - { $ref: "#/components/parameters/EventId" }
      - { $ref: "#/components/parameters/ContactId" }
    post:
      tags: [Contacts]
      summary: Restore a soft-deleted contact
      description: Requires `write:contacts`.
      responses:
        "200":
          description: The restored contact
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Contact" }
        "404": { $ref: "#/components/responses/NotFound" }

  /event/{eventId}/contacts/{contactId}/sessions:
    parameters:
      - { $ref: "#/components/parameters/EventId" }
      - { $ref: "#/components/parameters/ContactId" }
    get:
      tags: [Contacts]
      summary: Sessions a contact participates in
      description: Requires `read:contacts`.
      responses:
        "200":
          description: Session stubs
          content:
            application/json:
              schema:
                type: object
                properties:
                  sessions:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        title: { type: string }
                        status: { $ref: "#/components/schemas/SessionStatus" }
                        is_abstract: { type: boolean }

  /event/{eventId}/fields:
    parameters: [{ $ref: "#/components/parameters/EventId" }]
    get:
      tags: [Fields]
      summary: List custom field definitions
      description: |
        Requires `read:metadata`. `custom_fields` objects on sessions and
        contacts are keyed by `internal_name` — fetch these definitions to
        read/write them blind.
      responses:
        "200":
          description: Field definitions
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PaginatedList"
                  - properties:
                      results:
                        type: array
                        items: { $ref: "#/components/schemas/Field" }

  /event/{eventId}/{library}:
    parameters:
      - { $ref: "#/components/parameters/EventId" }
      - { $ref: "#/components/parameters/Library" }
    get:
      tags: [Metadata]
      summary: List library items
      description: Requires `read:metadata`.
      parameters: [{ $ref: "#/components/parameters/Page" }, { $ref: "#/components/parameters/PageSize" }]
      responses:
        "200": { $ref: "#/components/responses/LibraryList" }
    post:
      tags: [Metadata]
      summary: Search library items
      description: Requires `read:metadata`. `filters.search` matches on name.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                filters:
                  type: object
                  properties:
                    search: { type: string }
                sort:
                  type: object
                  properties:
                    order: { type: string, enum: [name, order, createdAt] }
                    sort: { type: string, enum: [asc, desc] }
                page: { type: integer }
                pageSize: { type: integer }
      responses:
        "200": { $ref: "#/components/responses/LibraryList" }

  /event/{eventId}/{library}/create:
    parameters:
      - { $ref: "#/components/parameters/EventId" }
      - { $ref: "#/components/parameters/Library" }
    post:
      tags: [Metadata]
      summary: Create a library item
      description: Requires `write:metadata`. `color` only on tracks (preset palette), `capacity` only on rooms.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LibraryItemWrite" }
      responses:
        "201":
          description: The created item
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LibraryItem" }

  /event/{eventId}/{library}/{itemId}:
    parameters:
      - { $ref: "#/components/parameters/EventId" }
      - { $ref: "#/components/parameters/Library" }
      - { name: itemId, in: path, required: true, schema: { type: string, format: uuid } }
    put:
      tags: [Metadata]
      summary: Update a library item
      description: Requires `write:metadata`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LibraryItemWrite" }
      responses:
        "200":
          description: The updated item
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LibraryItem" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Metadata]
      summary: Delete a library item
      description: Requires `write:metadata`. Hard delete — session references cascade or null out.
      responses:
        "200":
          description: Deletion receipt
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  deleted: { type: boolean }
        "404": { $ref: "#/components/responses/NotFound" }

components:
  securitySchemes:
    AccessToken:
      type: apiKey
      in: header
      name: x-access-token

  parameters:
    EventId:
      name: eventId
      in: path
      required: true
      description: Event UUID (from GET /events).
      schema: { type: string, format: uuid }
    SessionId:
      name: sessionId
      in: path
      required: true
      schema: { type: string, format: uuid }
    ContactId:
      name: contactId
      in: path
      required: true
      schema: { type: string, format: uuid }
    Library:
      name: library
      in: path
      required: true
      schema: { type: string, enum: [tracks, tags, formats, levels, rooms] }
    Page:
      name: page
      in: query
      schema: { type: integer, minimum: 1, maximum: 999, default: 1 }
    PageSize:
      name: pageSize
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }

  responses:
    Unauthorized:
      description: Missing or invalid x-access-token
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: Token lacks the required scope
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Unknown id
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ValidationError:
      description: Invalid body or query (message carries the first issue)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    SessionList:
      description: Paginated sessions
      content:
        application/json:
          schema:
            allOf:
              - $ref: "#/components/schemas/PaginatedList"
              - properties:
                  results:
                    type: array
                    items: { $ref: "#/components/schemas/Session" }
    ContactList:
      description: Paginated contacts
      content:
        application/json:
          schema:
            allOf:
              - $ref: "#/components/schemas/PaginatedList"
              - properties:
                  results:
                    type: array
                    items: { $ref: "#/components/schemas/Contact" }
    LibraryList:
      description: Paginated library items
      content:
        application/json:
          schema:
            allOf:
              - $ref: "#/components/schemas/PaginatedList"
              - properties:
                  results:
                    type: array
                    items: { $ref: "#/components/schemas/LibraryItem" }

  schemas:
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code: { type: string, example: not_found }
            message: { type: string }

    PaginatedList:
      type: object
      properties:
        results:
          type: array
          items: {}
        pagination:
          type: object
          properties:
            currentPage: { type: integer }
            pageSize: { type: integer }
            totalPages: { type: integer }
            totalResults: { type: integer }

    DeleteReceipt:
      type: object
      properties:
        id: { type: string, format: uuid }
        deleted_at: { type: string, format: date-time }

    SessionStatus:
      type: string
      enum: [draft, pending, accept_queue, accepted, decline_queue, declined, withdrawn]

    DateRange:
      type: object
      properties:
        before: { type: string, format: date-time }
        after: { type: string, format: date-time }

    SessionSearch:
      type: object
      properties:
        filters:
          type: object
          properties:
            status: { $ref: "#/components/schemas/SessionStatus" }
            isAbstract: { type: boolean }
            deletedAt: { $ref: "#/components/schemas/DateRange" }
            createdAt: { $ref: "#/components/schemas/DateRange" }
            updatedAt: { $ref: "#/components/schemas/DateRange" }
        sort:
          type: object
          properties:
            order: { type: string, enum: [createdAt, updatedAt] }
            sort: { type: string, enum: [asc, desc] }
        page: { type: integer }
        pageSize: { type: integer }

    ContactSearch:
      type: object
      properties:
        filters:
          type: object
          properties:
            deletedAt: { $ref: "#/components/schemas/DateRange" }
            createdAt: { $ref: "#/components/schemas/DateRange" }
            updatedAt: { $ref: "#/components/schemas/DateRange" }
        sort:
          type: object
          properties:
            order: { type: string, enum: [createdAt, updatedAt] }
            sort: { type: string, enum: [asc, desc] }
        page: { type: integer }
        pageSize: { type: integer }

    Event:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        slug: { type: string }
        timezone: { type: string, example: America/Los_Angeles }

    Participant:
      type: object
      properties:
        id: { type: string, format: uuid }
        contact_id: { type: string, format: uuid }
        role: { type: string, example: speaker }
        is_primary: { type: boolean }
        first_name: { type: string }
        last_name: { type: string }
        email: { type: string, format: email }

    Session:
      type: object
      properties:
        id: { type: string, format: uuid }
        friendly_id: { type: string, example: SESS-4 }
        title: { type: string }
        description: { type: [string, "null"] }
        status: { $ref: "#/components/schemas/SessionStatus" }
        is_abstract: { type: boolean }
        is_public: { type: boolean }
        starts_at: { type: [string, "null"], format: date-time }
        ends_at: { type: [string, "null"], format: date-time }
        capacity: { type: [integer, "null"] }
        ceu_credits: { type: [number, "null"] }
        client_session_id: { type: [string, "null"] }
        custom_fields:
          type: object
          description: Keyed by field internal_name (see GET …/fields).
        participants:
          type: array
          items: { $ref: "#/components/schemas/Participant" }
        tags:
          type: array
          items:
            type: object
            properties:
              id: { type: string, format: uuid }
              name: { type: string }
        track:
          type: [object, "null"]
          properties:
            id: { type: string, format: uuid }
            name: { type: string }
            color: { type: [string, "null"] }
        level:
          type: [object, "null"]
          properties:
            id: { type: string, format: uuid }
            name: { type: string }
        format:
          type: [object, "null"]
          properties:
            id: { type: string, format: uuid }
            name: { type: string }
        room:
          type: [object, "null"]
          properties:
            id: { type: string, format: uuid }
            name: { type: string }
            capacity: { type: [integer, "null"] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        deleted_at: { type: [string, "null"], format: date-time }

    SessionWrite:
      type: object
      description: Snake_case write surface. On create, `title` is required.
      properties:
        title: { type: string }
        description: { type: [string, "null"] }
        status: { $ref: "#/components/schemas/SessionStatus" }
        is_abstract: { type: boolean }
        is_public: { type: boolean }
        starts_at: { type: [string, "null"], format: date-time }
        ends_at: { type: [string, "null"], format: date-time }
        capacity: { type: [integer, "null"] }
        ceu_credits: { type: [number, "null"] }
        client_session_id: { type: [string, "null"] }
        track_id: { type: [string, "null"], format: uuid }
        level_id: { type: [string, "null"], format: uuid }
        format_id: { type: [string, "null"], format: uuid }
        room_id: { type: [string, "null"], format: uuid }
        custom_fields: { type: object }
        tag_ids:
          type: array
          description: Replaces the tag set when present.
          items: { type: string, format: uuid }
        participants:
          type: array
          description: Replaces the participant set when present.
          items:
            type: object
            required: [contact_id]
            properties:
              contact_id: { type: string, format: uuid }
              role: { type: string, default: speaker }
              is_primary: { type: boolean, default: false }

    Contact:
      type: object
      properties:
        id: { type: string, format: uuid }
        friendly_id: { type: string, example: SPK-12 }
        first_name: { type: string }
        last_name: { type: string }
        full_name: { type: string }
        email: { type: string, format: email }
        company_name: { type: [string, "null"] }
        title: { type: [string, "null"] }
        phone_mobile: { type: [string, "null"] }
        about: { type: [string, "null"] }
        photo_url: { type: [string, "null"] }
        linkedin_url: { type: [string, "null"] }
        twitter_url: { type: [string, "null"] }
        facebook_url: { type: [string, "null"] }
        website_url: { type: [string, "null"] }
        pronouns: { type: [string, "null"] }
        custom_fields:
          type: object
          description: Keyed by field internal_name (see GET …/fields).
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        deleted_at: { type: [string, "null"], format: date-time }

    ContactWrite:
      type: object
      description: Snake_case write surface. On create, first_name/last_name/email are required.
      properties:
        first_name: { type: string }
        last_name: { type: string }
        email: { type: string, format: email }
        company_name: { type: [string, "null"] }
        title: { type: [string, "null"] }
        phone_mobile: { type: [string, "null"] }
        about: { type: [string, "null"] }
        photo_url: { type: [string, "null"] }
        linkedin_url: { type: [string, "null"] }
        twitter_url: { type: [string, "null"] }
        facebook_url: { type: [string, "null"] }
        website_url: { type: [string, "null"] }
        pronouns: { type: [string, "null"] }
        custom_fields: { type: object }

    File:
      type: object
      properties:
        id: { type: string, format: uuid }
        filename: { type: string }
        title: { type: [string, "null"] }
        size_bytes: { type: integer }
        content_type: { type: string }
        url: { type: [string, "null"] }
        upload_status: { type: string, enum: [pending, complete] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        deleted_at: { type: [string, "null"], format: date-time }

    Field:
      type: object
      properties:
        id: { type: string, format: uuid }
        internal_name: { type: string }
        public_name: { type: string }
        field_type: { type: string, example: select }
        module: { type: string, enum: [session, contact] }
        options:
          type: [array, "null"]
          items: { type: string }
        max_length: { type: [integer, "null"] }
        contains_pii: { type: boolean }

    LibraryItem:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        order: { type: integer }
        color: { type: [string, "null"], description: Tracks only. }
        capacity: { type: [integer, "null"], description: Rooms only. }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    LibraryItemWrite:
      type: object
      properties:
        name: { type: string }
        color: { type: [string, "null"], description: Tracks only; preset palette hex. }
        capacity: { type: [integer, "null"], description: Rooms only. }

    BulkRequest:
      type: object
      required: [operations]
      properties:
        operations:
          type: array
          maxItems: 100
          items:
            type: object
            required: [action]
            properties:
              action: { type: string, enum: [create, update, delete, restore] }
              id:
                type: string
                format: uuid
                description: Required for update/delete/restore.
              data:
                type: object
                description: SessionWrite/ContactWrite payload for create/update.

    BulkResult:
      type: object
      properties:
        batch_id: { type: string, format: uuid }
        results:
          type: array
          items:
            type: object
            properties:
              index: { type: integer }
              action: { type: string }
              status: { type: string, enum: [ok, error] }
              id: { type: [string, "null"], format: uuid }
              error: { type: [string, "null"] }
        stats:
          type: object
          properties:
            total: { type: integer }
            succeeded: { type: integer }
            failed: { type: integer }
